From 07bf384a8b3fab7179ab3072288beed45cd4225e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Jul 2026 01:02:53 -0700 Subject: [PATCH] feat(crestodian): conversational agent-loop onboarding across CLI, web install, and macOS app (#99935) * feat(crestodian): AI-first conversational onboarding with typed-op guardrails Interactive `openclaw onboard` (and bare `openclaw` on a fresh install) now opens the Crestodian conversation: detection-backed first-run proposal (Claude Code/Codex logins, API keys), persona AI turns for every free-form message (configless local-runtime fallback, 60s deadline, deterministic degradation), approval-gated typed operations, chat-hosted channel setup (`connect `), config get/schema read ops with secret redaction, and a post-write validation hook that feeds schema errors back for a self-fix turn. Adds the additive gateway `crestodian.chat` method so app clients run the same conversation. Classic wizard stays behind --classic/explicit flags; non-interactive automation unchanged; `--modern` becomes a deprecated alias for `openclaw crestodian`. * feat(macos): Crestodian chat onboarding and importance-ordered permissions Replace the gateway step-wizard page with a Crestodian chat over the new crestodian.chat method (works before any model auth exists), sort the permissions page by importance with no scrolling, drop the redundant manual refresh, and bump the onboarding version. * feat(crestodian): run the custodian on the real agent loop with a ring-zero tool Crestodian conversations now execute through the same embedded agent runner as regular agents: a persistent agent session with a single construction-gated `crestodian` tool wrapping the typed operations (read actions free; mutations require approved=true asserted from explicit user consent, audited, with post-write config validation fed back into the loop). The engine prefers the loop (configured models or the Codex app-server fallback) and degrades to the single-turn planner, then to deterministic commands. Setup approval seeds the crestodian exec approval so local model harnesses can run; the configless Codex backend config now enables exec and direct tool loading (it was dead-on-arrival behind tools.exec.mode=deny and the tool-search index). * test(crestodian): type the engine mock signatures for the core test lane * fix(crestodian): map the advertised create_agent tool action * fix(crestodian): host-verified approval arming and a macOS setup completion gate Review findings: the model-supplied approved flag alone could authorize ring-zero mutations (prompt injection / model error), and removing the macOS wizard gate let users Next past the Crestodian page with nothing configured. Mutating tool actions now also require host-verified consent (the engine arms approval only when the user's actual message is an explicit yes), and local macOS onboarding blocks advancing until setup authored the config, using the same signal the old step wizard checked. * fix(crestodian): bind approval to the exact proposed operation and gate dot navigation A generic yes no longer authorizes arbitrary mutations: denied mutating tool calls register a canonical operation fingerprint (host-owned, per session), and an armed turn executes only the identical call, once. The denial message is arming-aware so the approved turn self-heals in one roundtrip, and the agent protocol pre-registers proposals. macOS onboarding page dots now honor the same setup-completion gate as the Next button. * fix(crestodian): redact sensitive wizard answers, skip logged-out CLIs, gate programmatic advance Sensitive channel-wizard answers (tokens, passwords) are redacted from the AI-visible conversation history; setup and the onboarding welcome never pick or advertise a definitively logged-out CLI as the model; and macOS handleNext() honors the page gates for programmatic callers (chat handoff) just like the Next button. * fix(crestodian): align the onboarding welcome's configured predicate with the app gate A valid config carrying only a default model (partial/hand-written) now still gets the first-run proposal instead of the ready guide, so the macOS setup gate can always be satisfied from the conversation. * fix(crestodian): armed turns can never mint their own executable proposal An approval-mismatched call inside an armed turn no longer re-registers and invites a retry (which let the model swap the approved operation for another in the same turn); it voids the approval entirely and requires a fresh yes. Proposals register only in unarmed turns, which the agent protocol already does when proposing. * fix(onboard): route any explicit setup flag to the classic wizard * fix(ci): satisfy new lint rules, tool-display guard, and generated artifacts for crestodian * chore(i18n): refresh native inventory after permissions copy wrap * fix(crestodian): harden conversational onboarding * docs(crestodian): document conversational onboarding * test(crestodian): type embedded runner mock * fix(crestodian): close onboarding security gaps * chore: retrigger ci --- CHANGELOG.md | 1 + apps/.i18n/native-source.json | 324 ++++----- apps/macos/Sources/OpenClaw/Constants.swift | 2 +- apps/macos/Sources/OpenClaw/Onboarding.swift | 29 +- .../OpenClaw/OnboardingCrestodianChat.swift | 225 ++++++ .../OpenClaw/OnboardingView+Actions.swift | 5 +- .../OpenClaw/OnboardingView+Chat.swift | 5 +- .../OnboardingView+CrestodianSetup.swift | 92 +++ .../OpenClaw/OnboardingView+Layout.swift | 21 +- .../OpenClaw/OnboardingView+Monitoring.swift | 4 + .../OpenClaw/OnboardingView+Pages.swift | 47 +- .../OpenClaw/OnboardingView+Testing.swift | 2 +- .../OpenClaw/OnboardingView+Wizard.swift | 109 --- .../Sources/OpenClaw/OnboardingWizard.swift | 418 ----------- .../OpenClaw/PermissionsSettings.swift | 48 +- .../OpenClawMacCLI/WizardCommand.swift | 3 + .../OnboardingViewSmokeTests.swift | 24 +- .../OnboardingWizardStepViewTests.swift | 46 -- .../OpenClawKit/Resources/tool-display.json | 9 + .../OpenClawProtocol/GatewayModels.swift | 52 ++ docs/cli/crestodian.md | 5 + docs/start/onboarding-overview.md | 9 +- docs/start/onboarding.md | 34 +- .../src/app-server/dynamic-tool-build.ts | 1 + packages/gateway-protocol/src/index.ts | 11 + packages/gateway-protocol/src/schema.ts | 1 + .../gateway-protocol/src/schema/crestodian.ts | 39 ++ .../src/schema/protocol-schemas.ts | 3 + packages/gateway-protocol/src/schema/types.ts | 4 + scripts/protocol-gen-swift.ts | 1 + src/agents/agent-tools.ts | 3 + src/agents/embedded-agent-runner/run.ts | 1 + .../run/attempt-tool-construction-plan.ts | 1 + .../embedded-agent-runner/run/attempt.ts | 1 + .../embedded-agent-runner/run/params.ts | 2 + src/agents/openclaw-tools.ts | 7 + src/agents/tool-display-config.ts | 5 + src/agents/tools/crestodian-tool.test.ts | 223 ++++++ src/agents/tools/crestodian-tool.ts | 275 ++++++++ src/cli/program/register.onboard.ts | 10 +- src/cli/program/register.setup.ts | 2 + src/commands/onboard-inference.test.ts | 102 +++ src/commands/onboard-inference.ts | 174 +++++ src/commands/onboard-interactive.ts | 27 + src/commands/onboard-types.ts | 2 + src/commands/onboard.test.ts | 47 ++ src/commands/onboard.ts | 46 +- src/crestodian/agent-turn.test.ts | 79 +++ src/crestodian/agent-turn.ts | 159 +++++ src/crestodian/assistant-backends.ts | 13 +- src/crestodian/assistant-prompts.ts | 109 ++- src/crestodian/assistant.test.ts | 9 +- src/crestodian/assistant.ts | 14 + .../chat-engine.channel-hooks.test.ts | 71 ++ src/crestodian/chat-engine.test.ts | 402 +++++++++++ src/crestodian/chat-engine.ts | 654 ++++++++++++++++++ src/crestodian/crestodian.ts | 4 + src/crestodian/dialogue.ts | 2 +- src/crestodian/onboarding-welcome.test.ts | 117 ++++ src/crestodian/onboarding-welcome.ts | 89 +++ src/crestodian/operations.test.ts | 94 ++- src/crestodian/operations.ts | 358 ++++++++-- src/crestodian/overview.ts | 16 + src/crestodian/setup-apply.ts | 199 ++++++ src/crestodian/tui-backend.ts | 142 ++-- src/gateway/local-request-context.ts | 1 + src/gateway/methods/core-descriptors.ts | 1 + src/gateway/server-methods.ts | 8 + src/gateway/server-methods/crestodian.test.ts | 130 ++++ src/gateway/server-methods/crestodian.ts | 100 +++ src/gateway/server-methods/shared-types.ts | 1 + src/gateway/server-methods/wizard.ts | 6 +- src/gateway/server-request-context.test.ts | 1 + src/gateway/server-request-context.ts | 2 + src/gateway/server.impl.ts | 5 + src/wizard/session.test.ts | 43 ++ src/wizard/session.ts | 78 ++- src/wizard/setup.finalize.ts | 63 +- src/wizard/setup.model-auth.ts | 259 +++++++ src/wizard/setup.shared.ts | 161 +++++ src/wizard/setup.ts | 407 +---------- 81 files changed, 4829 insertions(+), 1470 deletions(-) create mode 100644 apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift create mode 100644 apps/macos/Sources/OpenClaw/OnboardingView+CrestodianSetup.swift delete mode 100644 apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift delete mode 100644 apps/macos/Sources/OpenClaw/OnboardingWizard.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift create mode 100644 packages/gateway-protocol/src/schema/crestodian.ts create mode 100644 src/agents/tools/crestodian-tool.test.ts create mode 100644 src/agents/tools/crestodian-tool.ts create mode 100644 src/commands/onboard-inference.test.ts create mode 100644 src/commands/onboard-inference.ts create mode 100644 src/crestodian/agent-turn.test.ts create mode 100644 src/crestodian/agent-turn.ts create mode 100644 src/crestodian/chat-engine.channel-hooks.test.ts create mode 100644 src/crestodian/chat-engine.test.ts create mode 100644 src/crestodian/chat-engine.ts create mode 100644 src/crestodian/onboarding-welcome.test.ts create mode 100644 src/crestodian/onboarding-welcome.ts create mode 100644 src/crestodian/setup-apply.ts create mode 100644 src/gateway/server-methods/crestodian.test.ts create mode 100644 src/gateway/server-methods/crestodian.ts create mode 100644 src/wizard/setup.model-auth.ts create mode 100644 src/wizard/setup.shared.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e3450b61f6..6f93b61bf364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Changes +- **Conversational onboarding:** add a real agent-loop Crestodian setup flow across the CLI, Gateway, web install, and macOS app, with typed operations, exact approval binding, masked credential prompts, isolated session transcripts, and safe handoff to the normal agent. - **Generated session titles:** name new Control UI sessions from their first message, and add default/per-agent `utilityModel` routing for lower-cost session, topic, and thread title generation. Thanks @Juliangsm and @zhangguiping-xydt. - **ClawRouter routing and quotas:** add the bundled ClawRouter provider plugin with credential-scoped dynamic model discovery, OpenAI-compatible and native Anthropic/Gemini transports, and managed budget reporting across OpenClaw usage surfaces. (#99658) - **Model and provider coverage:** add GPT-5.6 support, use Nemotron Super's 1M context window, and preserve explicit OpenRouter authentication headers. (#98333, #98726, #98187) Thanks @steipete-oai, @eleqtrizit, @sunlit-deng, and @laurencebrown. diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index a6d1ee7a9e8e..1dc6121b6336 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -16547,7 +16547,7 @@ }, { "kind": "conditional-branch", - "line": 164, + "line": 167, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Finish", "surface": "apple", @@ -16555,7 +16555,7 @@ }, { "kind": "conditional-branch", - "line": 164, + "line": 167, "path": "apps/macos/Sources/OpenClaw/Onboarding.swift", "source": "Next", "surface": "apple", @@ -16563,7 +16563,55 @@ }, { "kind": "ui-call", - "line": 107, + "line": 116, + "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", + "source": "Crestodian is working…", + "surface": "apple", + "id": "native.apple.6124d93872cfcfe4" + }, + { + "kind": "ui-call", + "line": 141, + "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", + "source": "Restart", + "surface": "apple", + "id": "native.apple.105683a219ce17d7" + }, + { + "kind": "ui-call", + "line": 152, + "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", + "source": "Enter secret…", + "surface": "apple", + "id": "native.apple.839da8db3e4256b2" + }, + { + "kind": "ui-call", + "line": 154, + "path": "apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift", + "source": "Reply to Crestodian… (yes sets everything up)", + "surface": "apple", + "id": "native.apple.3e9e9347173fffbc" + }, + { + "kind": "ui-call", + "line": 8, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+CrestodianSetup.swift", + "source": "Talk to Crestodian", + "surface": "apple", + "id": "native.apple.a63c9bf74cc6fa3e" + }, + { + "kind": "ui-call-concatenated", + "line": 10, + "path": "apps/macos/Sources/OpenClaw/OnboardingView+CrestodianSetup.swift", + "source": "Crestodian is OpenClaw's setup custodian. It finds AI access you already have — a Claude Code or Codex login, or API keys — and sets everything up when you say yes. Just tell it what you want.", + "surface": "apple", + "id": "native.apple.c29bee62e4f63d1c" + }, + { + "kind": "ui-call", + "line": 102, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift", "source": "Back", "surface": "apple", @@ -16923,31 +16971,23 @@ }, { "kind": "ui-call", - "line": 597, + "line": 600, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Grant permissions", "surface": "apple", "id": "native.apple.4bad86566d023a64" }, { - "kind": "ui-call", - "line": 599, + "kind": "ui-call-concatenated", + "line": 607, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "These macOS permissions let OpenClaw automate apps and capture context on this Mac.", + "source": "These macOS permissions let OpenClaw automate apps and capture context on this Mac. Status updates automatically.", "surface": "apple", - "id": "native.apple.0cbf774c4168d80b" - }, - { - "kind": "ui-modifier", - "line": 625, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Refresh status", - "surface": "apple", - "id": "native.apple.c9c5c08199b23e4a" + "id": "native.apple.ce0d1241d7417406" }, { "kind": "ui-call", - "line": 638, + "line": 634, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Setting up OpenClaw", "surface": "apple", @@ -16955,7 +16995,7 @@ }, { "kind": "ui-call", - "line": 640, + "line": 636, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OpenClaw is installing the local Gateway and its managed runtime.", "surface": "apple", @@ -16963,7 +17003,7 @@ }, { "kind": "ui-call", - "line": 652, + "line": 648, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Checking existing setup…", "surface": "apple", @@ -16971,7 +17011,7 @@ }, { "kind": "ui-call", - "line": 659, + "line": 655, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Installing the Gateway…", "surface": "apple", @@ -16979,7 +17019,7 @@ }, { "kind": "ui-call", - "line": 663, + "line": 659, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Gateway installed", "surface": "apple", @@ -16987,7 +17027,7 @@ }, { "kind": "ui-call", - "line": 672, + "line": 668, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Retry setup", "surface": "apple", @@ -16995,7 +17035,7 @@ }, { "kind": "ui-call", - "line": 674, + "line": 670, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Check again", "surface": "apple", @@ -17003,7 +17043,7 @@ }, { "kind": "ui-call", - "line": 687, + "line": 683, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Uses a private user-space install. No Terminal, administrator access, or Homebrew required.", "surface": "apple", @@ -17011,7 +17051,7 @@ }, { "kind": "ui-call", - "line": 696, + "line": 692, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Agent workspace", "surface": "apple", @@ -17019,7 +17059,7 @@ }, { "kind": "ui-call-concatenated", - "line": 698, + "line": 694, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "OpenClaw runs the agent from a dedicated workspace so it can load `AGENTS.md` and write files there without mixing into your other projects.", "surface": "apple", @@ -17027,7 +17067,7 @@ }, { "kind": "ui-call", - "line": 709, + "line": 705, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway detected", "surface": "apple", @@ -17035,7 +17075,7 @@ }, { "kind": "ui-call-concatenated", - "line": 711, + "line": 707, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Create the workspace on the remote host (SSH in first). The macOS app can’t write files on your gateway over SSH yet.", "surface": "apple", @@ -17043,7 +17083,7 @@ }, { "kind": "conditional-branch", - "line": 717, + "line": 713, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Copied", "surface": "apple", @@ -17051,7 +17091,7 @@ }, { "kind": "conditional-branch", - "line": 717, + "line": 713, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Copy setup command", "surface": "apple", @@ -17059,7 +17099,7 @@ }, { "kind": "ui-call", - "line": 723, + "line": 719, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Workspace folder", "surface": "apple", @@ -17067,7 +17107,7 @@ }, { "kind": "ui-call", - "line": 737, + "line": 733, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Create workspace", "surface": "apple", @@ -17075,7 +17115,7 @@ }, { "kind": "ui-call", - "line": 743, + "line": 739, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open folder", "surface": "apple", @@ -17083,7 +17123,7 @@ }, { "kind": "ui-call", - "line": 750, + "line": 746, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Save in config", "surface": "apple", @@ -17091,7 +17131,7 @@ }, { "kind": "ui-call-concatenated", - "line": 771, + "line": 767, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Tip: edit AGENTS.md in this folder to shape the assistant’s behavior. For backup, make the workspace a private git repo so your agent’s “memory” is versioned.", "surface": "apple", @@ -17099,7 +17139,7 @@ }, { "kind": "ui-call", - "line": 786, + "line": 782, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Meet your agent", "surface": "apple", @@ -17107,15 +17147,15 @@ }, { "kind": "ui-call-concatenated", - "line": 788, + "line": 784, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "This is a dedicated onboarding chat. Your agent will introduce itself, learn who you are, and help you connect WhatsApp or Telegram if you want.", + "source": "This is a dedicated onboarding chat. Your agent will introduce itself, learn who you are, and help you connect Discord, Slack, Telegram, WhatsApp, or another channel if you want.", "surface": "apple", - "id": "native.apple.dad36dffbcbdfa9c" + "id": "native.apple.2c5c8568a34d4f0f" }, { "kind": "ui-call", - "line": 809, + "line": 806, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "All set", "surface": "apple", @@ -17123,7 +17163,7 @@ }, { "kind": "ui-named-argument", - "line": 814, + "line": 811, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Configure later", "surface": "apple", @@ -17131,7 +17171,7 @@ }, { "kind": "ui-named-argument", - "line": 815, + "line": 812, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Pick Local or Remote in Settings → General whenever you’re ready.", "surface": "apple", @@ -17139,7 +17179,7 @@ }, { "kind": "ui-named-argument", - "line": 822, + "line": 819, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Remote gateway checklist", "surface": "apple", @@ -17147,7 +17187,7 @@ }, { "kind": "ui-named-argument-multiline", - "line": 823, + "line": 820, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "On your gateway host: install/update the `openclaw` package and make sure credentials exist\n(typically `~/.openclaw/credentials/oauth.json`). Then connect again if needed.", "surface": "apple", @@ -17155,7 +17195,7 @@ }, { "kind": "ui-named-argument", - "line": 832, + "line": 829, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel", "surface": "apple", @@ -17163,7 +17203,7 @@ }, { "kind": "ui-named-argument", - "line": 833, + "line": 830, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Click the OpenClaw menu bar icon for quick chat and status.", "surface": "apple", @@ -17171,15 +17211,15 @@ }, { "kind": "ui-named-argument", - "line": 836, + "line": 833, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", - "source": "Connect WhatsApp or Telegram", + "source": "Connect Discord, Slack, Telegram, WhatsApp, …", "surface": "apple", - "id": "native.apple.1a91058f54cbd6e2" + "id": "native.apple.1b6cfcf3be8765c2" }, { "kind": "ui-named-argument", - "line": 837, + "line": 834, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels to link channels and monitor status.", "surface": "apple", @@ -17187,7 +17227,7 @@ }, { "kind": "ui-named-argument", - "line": 839, + "line": 836, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Channels", "surface": "apple", @@ -17195,7 +17235,7 @@ }, { "kind": "ui-named-argument", - "line": 844, + "line": 841, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Try Voice Wake", "surface": "apple", @@ -17203,7 +17243,7 @@ }, { "kind": "ui-named-argument", - "line": 845, + "line": 842, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable Voice Wake in Settings for hands-free commands with a live transcript overlay.", "surface": "apple", @@ -17211,7 +17251,7 @@ }, { "kind": "ui-named-argument", - "line": 848, + "line": 845, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Use the panel + Canvas", "surface": "apple", @@ -17219,7 +17259,7 @@ }, { "kind": "ui-named-argument", - "line": 849, + "line": 846, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open the menu bar panel for quick chat; the agent can show previews ", "surface": "apple", @@ -17227,7 +17267,7 @@ }, { "kind": "ui-named-argument", - "line": 853, + "line": 850, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Give your agent more powers", "surface": "apple", @@ -17235,7 +17275,7 @@ }, { "kind": "ui-named-argument", - "line": 854, + "line": 851, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.", "surface": "apple", @@ -17243,7 +17283,7 @@ }, { "kind": "ui-named-argument", - "line": 856, + "line": 853, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Open Settings → Skills", "surface": "apple", @@ -17251,7 +17291,7 @@ }, { "kind": "ui-call", - "line": 861, + "line": 858, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Launch at login", "surface": "apple", @@ -17259,7 +17299,7 @@ }, { "kind": "ui-call", - "line": 882, + "line": 879, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Skills included", "surface": "apple", @@ -17267,7 +17307,7 @@ }, { "kind": "ui-call", - "line": 889, + "line": 886, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Refresh", "surface": "apple", @@ -17275,7 +17315,7 @@ }, { "kind": "ui-call", - "line": 898, + "line": 895, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Couldn’t load skills from the Gateway.", "surface": "apple", @@ -17283,7 +17323,7 @@ }, { "kind": "ui-call-concatenated", - "line": 901, + "line": 898, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Make sure the Gateway is running and connected, then hit Refresh (or open Settings → Skills).", "surface": "apple", @@ -17291,7 +17331,7 @@ }, { "kind": "ui-call", - "line": 907, + "line": 904, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "Details: \\(error)", "surface": "apple", @@ -17299,116 +17339,12 @@ }, { "kind": "ui-call", - "line": 913, + "line": 910, "path": "apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift", "source": "No skills reported yet.", "surface": "apple", "id": "native.apple.b82621bc28d4c6e3" }, - { - "kind": "ui-call", - "line": 9, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Setup Wizard", - "surface": "apple", - "id": "native.apple.a611a5555d06da70" - }, - { - "kind": "ui-call", - "line": 11, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.", - "surface": "apple", - "id": "native.apple.195e6fbfdd7f4647" - }, - { - "kind": "ui-call", - "line": 64, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Wizard error", - "surface": "apple", - "id": "native.apple.593a59a20ed22b12" - }, - { - "kind": "ui-call", - "line": 70, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Retry", - "surface": "apple", - "id": "native.apple.5ad203347ffbe0e8" - }, - { - "kind": "ui-call", - "line": 80, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Setup is paused", - "surface": "apple", - "id": "native.apple.4850230e903ad303" - }, - { - "kind": "ui-call", - "line": 82, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Resume OpenClaw to start the local Gateway and continue setup.", - "surface": "apple", - "id": "native.apple.860797a4ad8455d2" - }, - { - "kind": "ui-call", - "line": 85, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Resume setup", - "surface": "apple", - "id": "native.apple.9781249acc361d32" - }, - { - "kind": "ui-call", - "line": 90, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Starting wizard…", - "surface": "apple", - "id": "native.apple.48cc28b5b02b9140" - }, - { - "kind": "ui-call", - "line": 102, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Wizard complete. Continue to the next step.", - "surface": "apple", - "id": "native.apple.3559abd61b4a02b3" - }, - { - "kind": "ui-call", - "line": 105, - "path": "apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift", - "source": "Waiting for wizard…", - "surface": "apple", - "id": "native.apple.4b16d82449e62fef" - }, - { - "kind": "ui-call", - "line": 286, - "path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift", - "source": "Unsupported step type", - "surface": "apple", - "id": "native.apple.3000242e4a2f9031" - }, - { - "kind": "conditional-branch", - "line": 291, - "path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift", - "source": "Continue", - "surface": "apple", - "id": "native.apple.bceef69ef30abb27" - }, - { - "kind": "conditional-branch", - "line": 291, - "path": "apps/macos/Sources/OpenClaw/OnboardingWizard.swift", - "source": "Run", - "surface": "apple", - "id": "native.apple.96628eeeceb361fb" - }, { "kind": "ui-named-argument", "line": 15, @@ -17563,7 +17499,7 @@ }, { "kind": "ui-named-argument", - "line": 174, + "line": 190, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Refresh status", "surface": "apple", @@ -17571,7 +17507,7 @@ }, { "kind": "ui-named-argument", - "line": 175, + "line": 191, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Recheck macOS after approving access in System Settings.", "surface": "apple", @@ -17579,7 +17515,7 @@ }, { "kind": "ui-call", - "line": 181, + "line": 197, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Refresh", "surface": "apple", @@ -17587,7 +17523,7 @@ }, { "kind": "ui-call", - "line": 267, + "line": 283, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Grant", "surface": "apple", @@ -17595,7 +17531,7 @@ }, { "kind": "ui-call", - "line": 274, + "line": 293, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Granted", "surface": "apple", @@ -17603,7 +17539,7 @@ }, { "kind": "ui-call", - "line": 278, + "line": 297, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Checking…", "surface": "apple", @@ -17611,7 +17547,7 @@ }, { "kind": "ui-call", - "line": 282, + "line": 301, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Request access", "surface": "apple", @@ -17619,7 +17555,7 @@ }, { "kind": "conditional-branch", - "line": 308, + "line": 328, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Automation (AppleScript)", "surface": "apple", @@ -17627,7 +17563,7 @@ }, { "kind": "conditional-branch", - "line": 309, + "line": 329, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Notifications", "surface": "apple", @@ -17635,7 +17571,7 @@ }, { "kind": "conditional-branch", - "line": 310, + "line": 330, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Accessibility", "surface": "apple", @@ -17643,7 +17579,7 @@ }, { "kind": "conditional-branch", - "line": 311, + "line": 331, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Screen Recording", "surface": "apple", @@ -17651,7 +17587,7 @@ }, { "kind": "conditional-branch", - "line": 312, + "line": 332, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Microphone", "surface": "apple", @@ -17659,7 +17595,7 @@ }, { "kind": "conditional-branch", - "line": 313, + "line": 333, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Speech Recognition", "surface": "apple", @@ -17667,7 +17603,7 @@ }, { "kind": "conditional-branch", - "line": 314, + "line": 334, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Camera", "surface": "apple", @@ -17675,7 +17611,7 @@ }, { "kind": "conditional-branch", - "line": 315, + "line": 335, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Location", "surface": "apple", @@ -17683,7 +17619,7 @@ }, { "kind": "conditional-branch", - "line": 321, + "line": 341, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Control other apps (e.g. Terminal) for automation actions", "surface": "apple", @@ -17691,7 +17627,7 @@ }, { "kind": "conditional-branch", - "line": 323, + "line": 343, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Show desktop alerts for agent activity", "surface": "apple", @@ -17699,7 +17635,7 @@ }, { "kind": "conditional-branch", - "line": 324, + "line": 344, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Control UI elements when an action requires it", "surface": "apple", @@ -17707,7 +17643,7 @@ }, { "kind": "conditional-branch", - "line": 325, + "line": 345, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Capture the screen for context or screenshots", "surface": "apple", @@ -17715,7 +17651,7 @@ }, { "kind": "conditional-branch", - "line": 326, + "line": 346, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Allow Voice Wake and audio capture", "surface": "apple", @@ -17723,7 +17659,7 @@ }, { "kind": "conditional-branch", - "line": 327, + "line": 347, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Transcribe Voice Wake trigger phrases on-device", "surface": "apple", @@ -17731,7 +17667,7 @@ }, { "kind": "conditional-branch", - "line": 328, + "line": 348, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Capture photos and video from the camera", "surface": "apple", @@ -17739,7 +17675,7 @@ }, { "kind": "conditional-branch", - "line": 329, + "line": 349, "path": "apps/macos/Sources/OpenClaw/PermissionsSettings.swift", "source": "Share location when requested by the agent", "surface": "apple", @@ -19675,7 +19611,7 @@ }, { "kind": "conditional-branch", - "line": 454, + "line": 457, "path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift", "source": " [\\(initial)]", "surface": "apple", @@ -19683,7 +19619,7 @@ }, { "kind": "conditional-branch", - "line": 501, + "line": 504, "path": "apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift", "source": " — \\(option.hint!)", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/Constants.swift b/apps/macos/Sources/OpenClaw/Constants.swift index 01dcc0cc1300..38d7e4608dc3 100644 --- a/apps/macos/Sources/OpenClaw/Constants.swift +++ b/apps/macos/Sources/OpenClaw/Constants.swift @@ -6,7 +6,7 @@ let launchdLabel = "ai.openclaw.mac" let gatewayLaunchdLabel = "ai.openclaw.gateway" let onboardingVersionKey = "openclaw.onboardingVersion" let onboardingSeenKey = "openclaw.onboardingSeen" -let currentOnboardingVersion = 7 +let currentOnboardingVersion = 8 let pauseDefaultsKey = "openclaw.pauseEnabled" let iconAnimationsEnabledKey = "openclaw.iconAnimationsEnabled" let swabbleEnabledKey = "openclaw.swabbleEnabled" diff --git a/apps/macos/Sources/OpenClaw/Onboarding.swift b/apps/macos/Sources/OpenClaw/Onboarding.swift index 4dd0562236f2..8cc25fd8d301 100644 --- a/apps/macos/Sources/OpenClaw/Onboarding.swift +++ b/apps/macos/Sources/OpenClaw/Onboarding.swift @@ -94,7 +94,8 @@ struct OnboardingView: View { @State var gatewayDiscovery: GatewayDiscoveryModel @State var onboardingChatModel: OpenClawChatViewModel @State var onboardingSkillsModel = SkillsSettingsModel() - @State var onboardingWizard = OnboardingWizardModel() + @State var crestodianChat = CrestodianOnboardingChatModel() + @State var crestodianSetupComplete = false @State var didLoadOnboardingSkills = false @State var localGatewayProbe: LocalGatewayProbe? @State var defaultsToLocalGateway: Bool @@ -105,10 +106,12 @@ struct OnboardingView: View { static let windowHeight: CGFloat = 752 // ~+10% to fit full onboarding content let pageWidth: CGFloat = Self.windowWidth - let contentHeight: CGFloat = 460 + // Sized so the permissions page fits all capabilities without scrolling: + // 145 (icon header) + 535 + ~60 (nav bar) stays inside windowHeight 752. + let contentHeight: CGFloat = 535 let connectionPageIndex = 1 let cliPageIndex = 2 - let wizardPageIndex = 3 + let crestodianPageIndex = 3 let onboardingChatPageIndex = 8 let permissionsPageIndex = 5 @@ -164,20 +167,22 @@ struct OnboardingView: View { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } - var wizardPageOrderIndex: Int? { - self.pageOrder.firstIndex(of: self.wizardPageIndex) - } - - var isWizardBlocking: Bool { - self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete - } - var isCLIBlocking: Bool { self.activePageIndex == self.cliPageIndex && !self.cliInstalled } + /// Local onboarding must not finish with nothing configured: the Crestodian + /// page blocks Next until setup authored the config (the conversation's + /// "yes"), mirroring the old wizard-completion gate. "Configure later" on + /// the connection page remains the explicit skip path. + var isCrestodianBlocking: Bool { + self.activePageIndex == self.crestodianPageIndex && + self.state.connectionMode == .local && + !self.crestodianSetupComplete + } + var canAdvance: Bool { - !self.isCLIBlocking && !self.isWizardBlocking + !self.isCLIBlocking && !self.isCrestodianBlocking } struct LocalGatewayProbe: Equatable { diff --git a/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift b/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift new file mode 100644 index 000000000000..dbdfad971110 --- /dev/null +++ b/apps/macos/Sources/OpenClaw/OnboardingCrestodianChat.swift @@ -0,0 +1,225 @@ +import Foundation +import Observation +import OpenClawIPC +import SwiftUI + +/// Onboarding talks to Crestodian over the gateway `crestodian.chat` RPC. +/// The conversation is the setup: no wizard steps, no forms. Crestodian works +/// before any model is configured, so this page functions on a fresh machine. +@MainActor +@Observable +final class CrestodianOnboardingChatModel { + struct Message: Identifiable, Equatable { + enum Role { + case assistant + case user + } + + let id = UUID() + let role: Role + let text: String + } + + private(set) var messages: [Message] = [] + private(set) var isSending = false + private(set) var errorMessage: String? + private(set) var expectsSensitiveReply = false + var input = "" + /// Set when Crestodian hands off to the normal agent ("talk to agent"). + var onAgentHandoff: (() -> Void)? + /// Called after every assistant reply (setup may have applied config). + var onReplyReceived: (() -> Void)? + + private let sessionId = "mac-onboarding-\(UUID().uuidString)" + private var started = false + + private struct ChatResult: Decodable { + let sessionId: String + let reply: String + let action: String + let sensitive: Bool? + } + + func startIfNeeded() async { + guard !self.started else { return } + self.started = true + await self.requestReply(message: nil) + } + + func send() { + let text = self.input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !self.isSending, self.errorMessage == nil else { return } + self.input = "" + self.messages.append(Message( + role: .user, + text: self.expectsSensitiveReply ? "" : text)) + Task { await self.requestReply(message: text) } + } + + func restartAfterError() { + Task { await self.requestReply(message: nil, reset: true) } + } + + private func requestReply(message: String?, reset: Bool = false) async { + self.isSending = true + self.errorMessage = nil + defer { self.isSending = false } + do { + var params: [String: AnyCodable] = [ + "sessionId": AnyCodable(self.sessionId), + "welcomeVariant": AnyCodable("onboarding"), + ] + if let message { + params["message"] = AnyCodable(message) + } + if reset { + params["reset"] = AnyCodable(true) + } + let data = try await GatewayConnection.shared.request( + method: "crestodian.chat", + params: params, + timeoutMs: 190_000, + retryTransportFailures: false) + let result = try JSONDecoder().decode(ChatResult.self, from: data) + if reset { + self.messages.removeAll() + self.input = "" + } + self.expectsSensitiveReply = result.sensitive == true + self.messages.append(Message(role: .assistant, text: result.reply)) + self.onReplyReceived?() + if result.action == "open-agent" { + self.onAgentHandoff?() + } + } catch { + self.errorMessage = error.localizedDescription + } + } +} + +struct CrestodianOnboardingChatView: View { + @Bindable var model: CrestodianOnboardingChatModel + + var body: some View { + VStack(spacing: 8) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(self.model.messages) { message in + CrestodianChatBubble(message: message) + .id(message.id) + } + if self.model.isSending { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Crestodian is working…") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.leading, 4) + } + } + .padding(10) + } + .onChange(of: self.model.messages) { _, messages in + if let last = messages.last { + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + + if let error = self.model.errorMessage { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + Spacer(minLength: 0) + Button("Restart") { + self.model.restartAfterError() + } + .buttonStyle(.link) + } + .padding(.horizontal, 10) + } + + HStack(spacing: 8) { + Group { + if self.model.expectsSensitiveReply { + SecureField("Enter secret…", text: self.$model.input) + } else { + TextField( + "Reply to Crestodian… (yes sets everything up)", + text: self.$model.input) + } + } + .textFieldStyle(.roundedBorder) + .onSubmit { self.model.send() } + .disabled(self.model.errorMessage != nil) + Button { + self.model.send() + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + } + .buttonStyle(.plain) + .disabled(self.model.isSending || + self.model.errorMessage != nil || + self.model.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .padding([.horizontal, .bottom], 10) + } + } +} + +private struct CrestodianChatBubble: View { + let message: CrestodianOnboardingChatModel.Message + + var body: some View { + HStack { + if self.message.role == .user { + Spacer(minLength: 40) + } + Text(self.attributedText) + .font(.callout) + .textSelection(.enabled) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(self.message.role == .user + ? Color.accentColor.opacity(0.22) + : Color(NSColor.controlBackgroundColor))) + if self.message.role == .assistant { + Spacer(minLength: 40) + } + } + } + + private var attributedText: AttributedString { + // Crestodian replies use light markdown (headings, bold, backticks). + // Parse per line so multi-line replies keep their structure. + var result = AttributedString() + let lines = self.message.text.split(separator: "\n", omittingEmptySubsequences: false) + for (index, line) in lines.enumerated() { + var text = String(line) + var isHeading = false + if text.hasPrefix("## ") { + text = String(text.dropFirst(3)) + isHeading = true + } + var piece = (try? AttributedString(markdown: text)) ?? AttributedString(text) + if isHeading { + piece.font = .headline + } + result.append(piece) + if index < lines.count - 1 { + result.append(AttributedString("\n")) + } + } + return result + } +} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift index 2183c89da8c2..5af0f860bfe7 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Actions.swift @@ -15,7 +15,6 @@ extension OnboardingView { func selectUnconfiguredGateway() { self.defaultsToLocalGateway = false - Task { await self.onboardingWizard.cancelIfRunning() } self.state.connectionMode = .unconfigured self.preferredGatewayID = nil self.showAdvancedConnection = false @@ -24,7 +23,6 @@ extension OnboardingView { func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { self.defaultsToLocalGateway = false - Task { await self.onboardingWizard.cancelIfRunning() } self.preferredGatewayID = gateway.stableID GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID) GatewayDiscoverySelectionSupport.applyRemoteSelection(gateway: gateway, state: self.state) @@ -44,7 +42,8 @@ extension OnboardingView { } func handleNext() { - if self.isWizardBlocking { return } + // All callers (Next button, chat handoff) honor the same page gates. + guard self.canAdvance else { return } self.commitRecommendedConnectionIfNeeded(for: self.activePageIndex) if self.currentPage < self.pageCount - 1 { withAnimation { self.currentPage += 1 } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift index f95da4ffbb5d..32169502b37d 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Chat.swift @@ -16,9 +16,10 @@ extension OnboardingView { let kickoff = "Hi! I just installed OpenClaw and you’re my brand‑new agent. " + "Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " + - "and before we talk about WhatsApp/Telegram, visit soul.md with me to craft SOUL.md: " + + "and before we talk about channels, visit soul.md with me to craft SOUL.md: " + "ask what matters to me and how you should be. Then guide me through choosing " + - "how we should talk (web‑only, WhatsApp, or Telegram)." + "how we should talk (this app, Discord, Slack, Telegram, WhatsApp, …) and help " + + "me connect the channel I pick." self.onboardingChatModel.input = kickoff self.onboardingChatModel.send() } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+CrestodianSetup.swift b/apps/macos/Sources/OpenClaw/OnboardingView+CrestodianSetup.swift new file mode 100644 index 000000000000..d70701818e9d --- /dev/null +++ b/apps/macos/Sources/OpenClaw/OnboardingView+CrestodianSetup.swift @@ -0,0 +1,92 @@ +import SwiftUI + +extension OnboardingView { + /// Conversational setup: the user talks to Crestodian over the gateway and + /// it configures everything (AI detection, config, workspace). No wizard. + func crestodianSetupPage() -> some View { + VStack(spacing: 12) { + Text("Talk to Crestodian") + .font(.largeTitle.weight(.semibold)) + Text( + "Crestodian is OpenClaw's setup custodian. It finds AI access you already have — " + + "a Claude Code or Codex login, or API keys — and sets everything up when you say yes. " + + "Just tell it what you want.") + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 540) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingGlassCard(padding: 4) { + CrestodianOnboardingChatView(model: self.crestodianChat) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + } + .padding(.horizontal, 28) + .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) + } + + func maybeStartCrestodianChat(for pageIndex: Int) { + self.refreshCrestodianSetupComplete() + guard pageIndex == self.crestodianPageIndex else { return } + // Local mode reaches this page only after the CLI/gateway install page, + // so the gateway is up before the first RPC. + guard self.state.connectionMode != .local || self.cliInstalled else { return } + if self.crestodianChat.onAgentHandoff == nil { + self.crestodianChat.onAgentHandoff = { [self] in + // "talk to agent": refresh workspace state so the agent chat + // page appears, then advance. + self.refreshBootstrapStatus() + self.refreshCrestodianSetupComplete() + self.handleNext() + } + } + if self.crestodianChat.onReplyReceived == nil { + self.crestodianChat.onReplyReceived = { [self] in + // Setup applies mid-conversation; re-check so Next unlocks. + self.refreshCrestodianSetupComplete() + } + } + Task { await self.crestodianChat.startIfNeeded() } + } + + /// Setup is complete once the config carries authored wizard/gateway-auth + /// state — the same signal the old step wizard used to skip itself. + func refreshCrestodianSetupComplete() { + let root = OpenClawConfigFile.loadDict() + if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { + self.crestodianSetupComplete = true + return + } + if let gateway = root["gateway"] as? [String: Any], + let auth = gateway["auth"] as? [String: Any], + Self.hasCrestodianSetupAuth(auth) + { + self.crestodianSetupComplete = true + return + } + self.crestodianSetupComplete = false + } + + static func hasCrestodianSetupAuth(_ auth: [String: Any]) -> Bool { + if let mode = auth["mode"] as? String, + !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return true + } + return ["token", "password"].contains { key in + if let value = auth[key] as? String { + return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + guard let ref = auth[key] as? [String: Any], ref.count == 3, + let source = ref["source"] as? String, + ["env", "file", "exec"].contains(source), + let provider = ref["provider"] as? String, + !provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let id = ref["id"] as? String + else { return false } + return !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + } +} diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift index dde1a45d4516..47cbdd49b5d3 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Layout.swift @@ -51,15 +51,10 @@ extension OnboardingView { guard installed else { return } self.updateMonitoring(for: self.activePageIndex) } - .onChange(of: onboardingWizard.isComplete) { _, newValue in - guard newValue, self.activePageIndex == self.wizardPageIndex else { return } - self.handleNext() - } .onDisappear { self.onboardingVisible = false self.stopPermissionMonitoring() self.stopDiscovery() - Task { await self.onboardingWizard.cancelIfRunning() } } .task { await self.refreshPerms() @@ -91,8 +86,8 @@ extension OnboardingView { var navigationBar: some View { let connectionLockIndex = pageOrder.firstIndex(of: connectionPageIndex) - let wizardLockIndex = wizardPageOrderIndex let cliLockIndex = pageOrder.firstIndex(of: cliPageIndex) + let crestodianLockIndex = pageOrder.firstIndex(of: crestodianPageIndex) return HStack(spacing: 20) { ZStack(alignment: .leading) { Button(action: {}, label: { @@ -124,8 +119,14 @@ extension OnboardingView { let isConnectionLocked = self.isConnectionSelectionBlocking && index > (connectionLockIndex ?? 0) let isCLILocked = cliLockIndex != nil && !self.cliInstalled && index > (cliLockIndex ?? 0) - let isWizardLocked = wizardLockIndex != nil && !self.onboardingWizard - .isComplete && index > (wizardLockIndex ?? 0) + // Dots must honor the same setup gate as Next: no jumping + // past the Crestodian page before setup authored the config. + let isCrestodianLocked = crestodianLockIndex != nil && + self.state.connectionMode == .local && + !self.crestodianSetupComplete && + index > (crestodianLockIndex ?? 0) + let isLocked = isInstallLocked || isConnectionLocked || isCLILocked || + isCrestodianLocked Button { withAnimation { self.currentPage = index } } label: { @@ -134,8 +135,8 @@ extension OnboardingView { .frame(width: 8, height: 8) } .buttonStyle(.plain) - .disabled(isInstallLocked || isConnectionLocked || isCLILocked || isWizardLocked) - .opacity(isInstallLocked || isConnectionLocked || isCLILocked || isWizardLocked ? 0.3 : 1) + .disabled(isLocked) + .opacity(isLocked ? 0.3 : 1) } } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift index 6ac36000e2fc..6ba8e8ce2eed 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Monitoring.swift @@ -43,6 +43,10 @@ extension OnboardingView { self.updatePermissionMonitoring(for: pageIndex) self.updateDiscoveryMonitoring(for: pageIndex) self.maybeInstallCLI(for: pageIndex) + self.maybeStartCrestodianChat(for: pageIndex) + // Crestodian setup creates the workspace (BOOTSTRAP.md); re-check so + // the Meet-your-agent page joins the flow once setup ran. + self.refreshBootstrapStatus() maybeKickoffOnboardingChat(for: pageIndex) } diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift index 41bd80b77871..c0b494f45f1c 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift @@ -16,7 +16,7 @@ extension OnboardingView { case 2: self.cliPage() case 3: - self.wizardPage() + self.crestodianSetupPage() case 5: self.permissionsPage() case 8: @@ -593,18 +593,28 @@ extension OnboardingView { } func permissionsPage() -> some View { - self.onboardingPage { - Text("Grant permissions") - .font(.largeTitle.weight(.semibold)) - Text("These macOS permissions let OpenClaw automate apps and capture context on this Mac.") + // Fixed layout (no ScrollView): sorted by importance and sized so all + // permissions stay visible at once — no scrollbars during onboarding. + VStack(spacing: 12) { + HStack(spacing: 8) { + Text("Grant permissions") + .font(.largeTitle.weight(.semibold)) + if self.isRequesting { + ProgressView() + .controlSize(.small) + } + } + Text( + "These macOS permissions let OpenClaw automate apps and capture context on this Mac. " + + "Status updates automatically.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .frame(maxWidth: 520) .fixedSize(horizontal: false, vertical: true) - self.onboardingCard(spacing: 8, padding: 12) { - ForEach(Capability.allCases, id: \.self) { cap in + self.onboardingCard(spacing: 4, padding: 12) { + ForEach(Capability.importanceOrdered, id: \.self) { cap in PermissionRow( capability: cap, status: self.permissionMonitor.status[cap] ?? false, @@ -613,24 +623,10 @@ extension OnboardingView { Task { await self.request(cap) } } } - - HStack(spacing: 12) { - Button { - Task { await self.refreshPerms() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .buttonStyle(.bordered) - .controlSize(.small) - .help("Refresh status") - if self.isRequesting { - ProgressView() - .controlSize(.small) - } - } - .padding(.top, 4) } } + .padding(.horizontal, 28) + .frame(width: self.pageWidth, height: self.contentHeight, alignment: .top) } func cliPage() -> some View { @@ -787,7 +783,8 @@ extension OnboardingView { .font(.largeTitle.weight(.semibold)) Text( "This is a dedicated onboarding chat. Your agent will introduce itself, " + - "learn who you are, and help you connect WhatsApp or Telegram if you want.") + "learn who you are, and help you connect Discord, Slack, Telegram, WhatsApp, " + + "or another channel if you want.") .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -833,7 +830,7 @@ extension OnboardingView { subtitle: "Click the OpenClaw menu bar icon for quick chat and status.", systemImage: "bubble.left.and.bubble.right") self.featureActionRow( - title: "Connect WhatsApp or Telegram", + title: "Connect Discord, Slack, Telegram, WhatsApp, …", subtitle: "Open Settings → Channels to link channels and monitor status.", systemImage: "link", buttonTitle: "Open Settings → Channels") diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift index 2bd9c525ad4a..eb0bf28b72d4 100644 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift +++ b/apps/macos/Sources/OpenClaw/OnboardingView+Testing.swift @@ -40,7 +40,7 @@ extension OnboardingView { view.state.connectionMode = .local _ = view.welcomePage() _ = view.connectionPage() - _ = view.wizardPage() + _ = view.crestodianSetupPage() _ = view.permissionsPage() _ = view.cliPage() _ = view.workspacePage() diff --git a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift b/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift deleted file mode 100644 index 8b2dd8a13839..000000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingView+Wizard.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Observation -import OpenClawProtocol -import SwiftUI - -extension OnboardingView { - func wizardPage() -> some View { - self.onboardingPage { - VStack(spacing: 16) { - Text("Setup Wizard") - .font(.largeTitle.weight(.semibold)) - Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 520) - - self.onboardingCard(spacing: 14, padding: 16) { - OnboardingWizardCardContent( - wizard: self.onboardingWizard, - mode: self.state.connectionMode, - workspacePath: self.workspacePath, - paused: self.state.isPaused, - onResume: { self.state.isPaused = false }) - } - } - .task(id: "\(self.cliInstalled)-\(self.state.isPaused)") { - guard self.state.connectionMode != .local || self.cliInstalled else { return } - await self.onboardingWizard.startIfNeeded( - mode: self.state.connectionMode, - workspace: self.workspacePath.isEmpty ? nil : self.workspacePath) - } - } - } -} - -private struct OnboardingWizardCardContent: View { - @Bindable var wizard: OnboardingWizardModel - let mode: AppState.ConnectionMode - let workspacePath: String - let paused: Bool - let onResume: () -> Void - - private enum CardState { - case error(String) - case paused - case starting - case step(WizardStep) - case complete - case waiting - } - - private var state: CardState { - if self.paused, !self.wizard.isComplete { return .paused } - if let error = wizard.errorMessage { return .error(error) } - if self.wizard.isStarting { return .starting } - if let step = wizard.currentStep { return .step(step) } - if self.wizard.isComplete { return .complete } - return .waiting - } - - var body: some View { - switch self.state { - case let .error(error): - Text("Wizard error") - .font(.headline) - Text(error) - .font(.subheadline) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button("Retry") { - self.wizard.reset() - Task { - await self.wizard.startIfNeeded( - mode: self.mode, - workspace: self.workspacePath.isEmpty ? nil : self.workspacePath) - } - } - .buttonStyle(.borderedProminent) - case .paused: - Text("Setup is paused") - .font(.headline) - Text("Resume OpenClaw to start the local Gateway and continue setup.") - .font(.subheadline) - .foregroundStyle(.secondary) - Button("Resume setup", action: self.onResume) - .buttonStyle(.borderedProminent) - case .starting: - HStack(spacing: 8) { - ProgressView() - Text("Starting wizard…") - .foregroundStyle(.secondary) - } - case let .step(step): - OnboardingWizardStepView( - step: step, - isSubmitting: self.wizard.isSubmitting) - { value in - Task { await self.wizard.submit(step: step, value: value) } - } - .id(step.id) - case .complete: - Text("Wizard complete. Continue to the next step.") - .font(.headline) - case .waiting: - Text("Waiting for wizard…") - .foregroundStyle(.secondary) - } - } -} diff --git a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift b/apps/macos/Sources/OpenClaw/OnboardingWizard.swift deleted file mode 100644 index e0c63b467ede..000000000000 --- a/apps/macos/Sources/OpenClaw/OnboardingWizard.swift +++ /dev/null @@ -1,418 +0,0 @@ -import Foundation -import Observation -import OpenClawKit -import OpenClawProtocol -import OSLog -import SwiftUI - -private let onboardingWizardLogger = Logger(subsystem: "ai.openclaw", category: "onboarding.wizard") - -// MARK: - Swift 6 AnyCodable Bridging Helpers - -// Bridge between OpenClawProtocol.AnyCodable and the local module to avoid -// Swift 6 strict concurrency type conflicts. - -private typealias ProtocolAnyCodable = OpenClawProtocol.AnyCodable - -private func bridgeToLocal(_ value: ProtocolAnyCodable) -> AnyCodable { - if let data = try? JSONEncoder().encode(value), - let decoded = try? JSONDecoder().decode(AnyCodable.self, from: data) - { - return decoded - } - return AnyCodable(value.value) -} - -private func bridgeToLocal(_ value: ProtocolAnyCodable?) -> AnyCodable? { - value.map(bridgeToLocal) -} - -@MainActor -@Observable -final class OnboardingWizardModel { - private(set) var sessionId: String? - private(set) var currentStep: WizardStep? - private(set) var status: String? - private(set) var errorMessage: String? - var isStarting = false - var isSubmitting = false - private var lastStartMode: AppState.ConnectionMode? - private var lastStartWorkspace: String? - private var restartAttempts = 0 - private let maxRestartAttempts = 1 - - var isComplete: Bool { - self.status == "done" - } - - var isRunning: Bool { - self.status == "running" - } - - func reset() { - self.sessionId = nil - self.currentStep = nil - self.status = nil - self.errorMessage = nil - self.isStarting = false - self.isSubmitting = false - self.restartAttempts = 0 - self.lastStartMode = nil - self.lastStartWorkspace = nil - } - - func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async { - guard self.sessionId == nil, !self.isStarting else { return } - guard mode == .local else { return } - if self.shouldSkipWizard() { - self.sessionId = nil - self.currentStep = nil - self.status = "done" - self.errorMessage = nil - return - } - guard Self.shouldStart(mode: mode, paused: AppStateStore.shared.isPaused) else { return } - self.isStarting = true - self.errorMessage = nil - self.lastStartMode = mode - self.lastStartWorkspace = workspace - defer { self.isStarting = false } - - do { - GatewayProcessManager.shared.setActive(true) - if await GatewayProcessManager.shared.waitForGatewayReady(timeout: 12) == false { - throw NSError( - domain: "Gateway", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Gateway did not become ready. Check that it is running."]) - } - var params: [String: AnyCodable] = ["mode": AnyCodable("local")] - if let workspace, !workspace.isEmpty { - params["workspace"] = AnyCodable(workspace) - } - let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardStart, - params: params) - self.applyStartResult(res) - } catch { - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)") - } - } - - nonisolated static func shouldStart(mode: AppState.ConnectionMode, paused: Bool) -> Bool { - mode == .local && !paused - } - - func submit(step: WizardStep, value: AnyCodable?) async { - guard let sessionId, !self.isSubmitting else { return } - self.isSubmitting = true - self.errorMessage = nil - defer { self.isSubmitting = false } - - do { - var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)] - var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)] - if let value { - answer["value"] = value - } - params["answer"] = AnyCodable(answer) - let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardNext, - params: params) - self.applyNextResult(res) - } catch { - if self.restartIfSessionLost(error: error) { - return - } - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)") - } - } - - func cancelIfRunning() async { - guard let sessionId, self.isRunning else { return } - do { - let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded( - method: .wizardCancel, - params: ["sessionId": AnyCodable(sessionId)]) - self.applyStatusResult(res) - } catch { - self.status = "error" - self.errorMessage = error.localizedDescription - onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)") - } - } - - private func applyStartResult(_ res: WizardStartResult) { - self.sessionId = res.sessionid - self.status = wizardStatusString(res.status) ?? (res.done ? "done" : "running") - self.errorMessage = res.error - self.currentStep = res.step - if res.done { self.currentStep = nil } - self.restartAttempts = 0 - } - - private func applyNextResult(_ res: WizardNextResult) { - let status = wizardStatusString(res.status) - self.status = status ?? self.status - self.errorMessage = res.error - self.currentStep = res.step - if res.done { self.currentStep = nil } - if res.done || status == "done" || status == "cancelled" || status == "error" { - self.sessionId = nil - } - } - - private func applyStatusResult(_ res: WizardStatusResult) { - self.status = wizardStatusString(res.status) ?? "unknown" - self.errorMessage = res.error - self.currentStep = nil - self.sessionId = nil - } - - private func restartIfSessionLost(error: Error) -> Bool { - guard let gatewayError = error as? GatewayResponseError else { return false } - guard gatewayError.code == ErrorCode.invalidRequest.rawValue else { return false } - let message = gatewayError.message.lowercased() - guard message.contains("wizard not found") || message.contains("wizard not running") else { return false } - guard let mode = self.lastStartMode, self.restartAttempts < self.maxRestartAttempts else { - return false - } - self.restartAttempts += 1 - self.sessionId = nil - self.currentStep = nil - self.status = nil - self.errorMessage = "Wizard session lost. Restarting…" - Task { await self.startIfNeeded(mode: mode, workspace: self.lastStartWorkspace) } - return true - } - - private func shouldSkipWizard() -> Bool { - let root = OpenClawConfigFile.loadDict() - if let wizard = root["wizard"] as? [String: Any], !wizard.isEmpty { - return true - } - if let gateway = root["gateway"] as? [String: Any], - let auth = gateway["auth"] as? [String: Any] - { - if let mode = auth["mode"] as? String, - !mode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let token = auth["token"] as? String, - !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - if let password = auth["password"] as? String, - !password.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - return true - } - } - return false - } -} - -struct OnboardingWizardStepView: View { - let step: WizardStep - let isSubmitting: Bool - let onStepSubmit: (AnyCodable?) -> Void - - @State private var textValue: String - @State private var confirmValue: Bool - @State private var selectedIndex: Int - @State private var selectedIndices: Set - - private let optionItems: [WizardOptionItem] - - init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) { - self.step = step - self.isSubmitting = isSubmitting - self.onStepSubmit = onSubmit - let options = parseWizardOptions(step.options).enumerated().map { index, option in - WizardOptionItem(index: index, option: option) - } - self.optionItems = options - let initialText = anyCodableString(step.initialvalue) - let initialConfirm = anyCodableBool(step.initialvalue) - let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0 - let initialMulti = Set( - options.filter { option in - anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) } - }.map(\.index)) - - _textValue = State(initialValue: initialText) - _confirmValue = State(initialValue: initialConfirm) - _selectedIndex = State(initialValue: initialIndex) - _selectedIndices = State(initialValue: initialMulti) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - if let title = step.title, !title.isEmpty { - Text(title) - .font(.title2.weight(.semibold)) - } - if let message = step.message, !message.isEmpty { - Text(message) - .font(.body) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - switch wizardStepType(self.step) { - case "note": - EmptyView() - case "text": - self.textField - case "confirm": - Toggle("", isOn: self.$confirmValue) - .toggleStyle(.switch) - case "select": - self.selectOptions - case "multiselect": - self.multiselectOptions - case "progress": - ProgressView() - .controlSize(.small) - case "action": - EmptyView() - default: - Text("Unsupported step type") - .foregroundStyle(.secondary) - } - - Button(action: self.submit) { - Text(wizardStepType(self.step) == "action" ? "Run" : "Continue") - .frame(minWidth: 120) - } - .buttonStyle(.borderedProminent) - .disabled(self.isSubmitting || self.isBlocked) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private var textField: some View { - let isSensitive = self.step.sensitive == true - if isSensitive { - SecureField(self.step.placeholder ?? "", text: self.$textValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 360) - } else { - TextField(self.step.placeholder ?? "", text: self.$textValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 360) - } - } - - private var selectOptions: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.optionItems, id: \.index) { item in - self.selectOptionRow(item) - } - } - } - - private var multiselectOptions: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(self.optionItems, id: \.index) { item in - self.multiselectOptionRow(item) - } - } - } - - private func selectOptionRow(_ item: WizardOptionItem) -> some View { - Button { - self.selectedIndex = item.index - } label: { - HStack(alignment: .top, spacing: 8) { - Image(systemName: self.selectedIndex == item.index ? "largecircle.fill.circle" : "circle") - .foregroundStyle(Color.accentColor) - VStack(alignment: .leading, spacing: 2) { - Text(item.option.label) - .foregroundStyle(.primary) - if let hint = item.option.hint, !hint.isEmpty { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - .buttonStyle(.plain) - } - - private func multiselectOptionRow(_ item: WizardOptionItem) -> some View { - Toggle(isOn: self.bindingForOption(item)) { - VStack(alignment: .leading, spacing: 2) { - Text(item.option.label) - if let hint = item.option.hint, !hint.isEmpty { - Text(hint) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - - private func bindingForOption(_ item: WizardOptionItem) -> Binding { - Binding(get: { - self.selectedIndices.contains(item.index) - }, set: { newValue in - if newValue { - self.selectedIndices.insert(item.index) - } else { - self.selectedIndices.remove(item.index) - } - }) - } - - private var isBlocked: Bool { - let type = wizardStepType(step) - if type == "select" { return self.optionItems.isEmpty } - if type == "multiselect" { return self.optionItems.isEmpty } - return false - } - - private func submit() { - switch wizardStepType(self.step) { - case "note", "progress": - self.onStepSubmit(nil) - case "text": - self.onStepSubmit(AnyCodable(self.textValue)) - case "confirm": - self.onStepSubmit(AnyCodable(self.confirmValue)) - case "select": - guard self.optionItems.indices.contains(self.selectedIndex) else { - self.onStepSubmit(nil) - return - } - let option = self.optionItems[self.selectedIndex].option - self.onStepSubmit(bridgeToLocal(option.value) ?? AnyCodable(option.label)) - case "multiselect": - let values = self.optionItems - .filter { self.selectedIndices.contains($0.index) } - .map { bridgeToLocal($0.option.value) ?? AnyCodable($0.option.label) } - self.onStepSubmit(AnyCodable(values)) - case "action": - self.onStepSubmit(AnyCodable(true)) - default: - self.onStepSubmit(nil) - } - } -} - -private struct WizardOptionItem: Identifiable { - let index: Int - let option: WizardOption - - var id: Int { - self.index - } -} diff --git a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift index 9addc5763b54..5be02588b663 100644 --- a/apps/macos/Sources/OpenClaw/PermissionsSettings.swift +++ b/apps/macos/Sources/OpenClaw/PermissionsSettings.swift @@ -152,6 +152,22 @@ private struct LocationAccessSettings: View { } } +extension Capability { + /// Importance order for permission lists: app control and context capture + /// first (they power most agent actions), voice/media next, location last. + /// Keep onboarding and Settings in the same order so users can cross-reference. + static let importanceOrdered: [Capability] = [ + .appleScript, + .accessibility, + .screenRecording, + .notifications, + .microphone, + .speechRecognition, + .camera, + .location, + ] +} + struct PermissionStatusList: View { let status: [Capability: Bool] let refresh: () async -> Void @@ -159,12 +175,12 @@ struct PermissionStatusList: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(Capability.allCases.enumerated()), id: \.element) { index, cap in + ForEach(Array(Capability.importanceOrdered.enumerated()), id: \.element) { index, cap in PermissionRow( capability: cap, status: self.status[cap] ?? false, isPending: self.pendingCapability == cap, - showsDivider: index != Capability.allCases.count - 1) + showsDivider: index != Capability.importanceOrdered.count - 1) { Task { await self.handle(cap) } } @@ -270,18 +286,22 @@ struct PermissionRow: View { .frame(minWidth: self.compact ? 68 : 78, alignment: .trailing) } - if self.status { - Text("Granted") - .font(.caption.weight(.medium)) - .foregroundStyle(.green) - } else if self.isPending { - Text("Checking…") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Text("Request access") - .font(.caption) - .foregroundStyle(.secondary) + // Compact rows (onboarding) skip the caption so the full + // permission list fits the fixed page without scrolling. + if !self.compact { + if self.status { + Text("Granted") + .font(.caption.weight(.medium)) + .foregroundStyle(.green) + } else if self.isPending { + Text("Checking…") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text("Request access") + .font(.caption) + .foregroundStyle(.secondary) + } } } .frame(minWidth: self.compact ? 86 : 104, alignment: .trailing) diff --git a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift index bb12e570ded7..e3c7d6eacd60 100644 --- a/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift +++ b/apps/macos/Sources/OpenClawMacCLI/WizardCommand.swift @@ -374,6 +374,9 @@ private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) asyn print("Wizard complete.") return } + if let error = nextResult.error, !opts.json { + fputs("wizard: \(error)\n", stderr) + } if let step = nextResult.step { let answer = try promptAnswer(for: step) diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift index 84819bca34d2..7bcffcea658e 100644 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/OnboardingViewSmokeTests.swift @@ -1,5 +1,6 @@ import Foundation import OpenClawDiscovery +import OpenClawIPC import SwiftUI import Testing @testable import OpenClaw @@ -33,7 +34,7 @@ struct OnboardingViewSmokeTests { #expect(!order.contains(8)) } - @Test func `fresh local setup installs CLI before starting gateway wizard`() { + @Test func `fresh local setup installs CLI before the Crestodian chat`() { let order = OnboardingView.pageOrder( for: .local, showOnboardingChat: false, @@ -109,9 +110,13 @@ struct OnboardingViewSmokeTests { installing: false)) } - @Test func `paused onboarding defers the local gateway wizard`() { - #expect(!OnboardingWizardModel.shouldStart(mode: .local, paused: true)) - #expect(OnboardingWizardModel.shouldStart(mode: .local, paused: false)) + @Test func `Crestodian setup requires a nonempty auth value`() { + #expect(!OnboardingView.hasCrestodianSetupAuth([:])) + #expect(!OnboardingView.hasCrestodianSetupAuth(["token": " "])) + #expect(OnboardingView.hasCrestodianSetupAuth(["mode": "token"])) + #expect(OnboardingView.hasCrestodianSetupAuth([ + "token": ["source": "env", "provider": "default", "id": "GATEWAY_TOKEN"], + ])) } @Test func `select remote gateway clears stale ssh target when endpoint unresolved`() async { @@ -145,4 +150,15 @@ struct OnboardingViewSmokeTests { #expect(state.remoteTarget.isEmpty) } } + + @Test + func `permission list covers every capability in importance order`() { + #expect(Set(Capability.importanceOrdered) == Set(Capability.allCases)) + #expect(Capability.importanceOrdered.count == Capability.allCases.count) + // App control and context capture lead; location stays last. + #expect(Capability.importanceOrdered.first == .appleScript) + #expect(Array(Capability.importanceOrdered.prefix(3)) + == [.appleScript, .accessibility, .screenRecording]) + #expect(Capability.importanceOrdered.last == Capability.location) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift b/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift deleted file mode 100644 index 387d3cdfa246..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/OnboardingWizardStepViewTests.swift +++ /dev/null @@ -1,46 +0,0 @@ -import OpenClawProtocol -import SwiftUI -import Testing -@testable import OpenClaw - -private typealias ProtoAnyCodable = OpenClawProtocol.AnyCodable - -@Suite(.serialized) -@MainActor -struct OnboardingWizardStepViewTests { - @Test func `note step builds`() { - let step = WizardStep( - id: "step-1", - type: ProtoAnyCodable("note"), - title: "Welcome", - message: "Hello", - format: nil, - options: nil, - initialvalue: nil, - placeholder: nil, - sensitive: nil, - executor: nil) - let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in }) - _ = view.body - } - - @Test func `select step builds`() { - let options: [[String: ProtoAnyCodable]] = [ - ["value": ProtoAnyCodable("local"), "label": ProtoAnyCodable("Local"), "hint": ProtoAnyCodable("This Mac")], - ["value": ProtoAnyCodable("remote"), "label": ProtoAnyCodable("Remote")], - ] - let step = WizardStep( - id: "step-2", - type: ProtoAnyCodable("select"), - title: "Mode", - message: "Choose a mode", - format: nil, - options: options, - initialvalue: ProtoAnyCodable("local"), - placeholder: nil, - sensitive: nil, - executor: nil) - let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in }) - _ = view.body - } -} diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json index 9d9cf366f373..810237026f04 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/Resources/tool-display.json @@ -418,6 +418,15 @@ "proposal_id" ] }, + "crestodian": { + "emoji": "🦀", + "title": "Crestodian", + "detailKeys": [ + "action", + "path", + "model" + ] + }, "gateway": { "emoji": "🔌", "title": "Gateway", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index b7832625b18a..5245633a08ec 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3125,6 +3125,58 @@ public struct ConfigSchemaLookupResult: Codable, Sendable { } } +public struct CrestodianChatParams: Codable, Sendable { + public let sessionid: String + public let message: String? + public let welcomevariant: AnyCodable? + public let reset: Bool? + + public init( + sessionid: String, + message: String?, + welcomevariant: AnyCodable?, + reset: Bool?) + { + self.sessionid = sessionid + self.message = message + self.welcomevariant = welcomevariant + self.reset = reset + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case message + case welcomevariant = "welcomeVariant" + case reset + } +} + +public struct CrestodianChatResult: Codable, Sendable { + public let sessionid: String + public let reply: String + public let sensitive: Bool? + public let action: AnyCodable + + public init( + sessionid: String, + reply: String, + sensitive: Bool? = nil, + action: AnyCodable) + { + self.sessionid = sessionid + self.reply = reply + self.sensitive = sensitive + self.action = action + } + + private enum CodingKeys: String, CodingKey { + case sessionid = "sessionId" + case reply + case sensitive + case action + } +} + public struct WizardStartParams: Codable, Sendable { public let mode: AnyCodable? public let workspace: String? diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index ba529cf5ce96..da4bb74a8335 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -91,6 +91,11 @@ Persistent, require conversational approval (or `--yes` for a direct command): w Applied writes are recorded in `~/.openclaw/audit/crestodian.jsonl`. Discovery is not audited; only applied operations and writes are. +Channel setup can run as a hosted conversation when the host supports masked +input. The local Crestodian TUI does not accept sensitive wizard answers; +instead it directs you to `openclaw channels add --channel `, whose +interactive prompts mask credentials. + ## Setup bootstrap `setup` is the chat-first onboarding bootstrap. It writes only through typed config operations and asks for approval first. diff --git a/docs/start/onboarding-overview.md b/docs/start/onboarding-overview.md index 394ab7f938e6..62dbe5759c91 100644 --- a/docs/start/onboarding-overview.md +++ b/docs/start/onboarding-overview.md @@ -15,7 +15,7 @@ optional chat channels — they just differ in how you interact with the setup. | | CLI onboarding | macOS app onboarding | | -------------- | -------------------------------------- | ------------------------- | | **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only | -| **Interface** | Terminal wizard | Guided UI in the app | +| **Interface** | Terminal wizard | Guided UI + Crestodian chat | | **Best for** | Servers, headless, full control | Desktop Mac, visual setup | | **Automation** | `--non-interactive` for scripts | Manual only | | **Command** | `openclaw onboard` | Launch the app | @@ -50,8 +50,11 @@ CLI command docs: [`openclaw onboard`](/cli/onboard) ## macOS app onboarding -Open the OpenClaw app. The first-run wizard walks you through the same steps -with a visual interface. +Open the OpenClaw app. For local setup, the first-run flow starts the Gateway, +then opens a Crestodian conversation that detects existing AI access, proposes +the workspace and config, and applies the plan after approval. Sensitive +credentials use masked input. Remote setup connects to an already-configured +Gateway instead. Full reference: [Onboarding (macOS App)](/start/onboarding) diff --git a/docs/start/onboarding.md b/docs/start/onboarding.md index 163399b276f7..656931f04ea9 100644 --- a/docs/start/onboarding.md +++ b/docs/start/onboarding.md @@ -7,8 +7,9 @@ title: "Onboarding (macOS app)" sidebarTitle: "Onboarding: macOS App" --- -The macOS app's first-run wizard: pick where the Gateway runs, connect auth, -grant permissions, and hand off to the agent's own bootstrap ritual. +The macOS app's first-run flow: pick where the Gateway runs, complete local +setup through a Crestodian conversation, grant permissions, and hand off to +the agent's own bootstrap ritual. For CLI onboarding and a comparison of both paths, see [Onboarding Overview](/start/onboarding-overview). @@ -59,6 +60,22 @@ Where does the **Gateway** run? + + Local setup installs the global `openclaw` CLI via npm, pnpm, or bun, + preferring npm first. Node remains the recommended runtime for the Gateway + itself. Existing compatible installations are reused. + + + Local setup opens a dedicated conversation with Crestodian after the Gateway + is ready. Crestodian detects an existing Claude Code or Codex login and + supported API keys, proposes the workspace and configuration, then waits for + approval before writing anything. Next remains locked until the conversation + has authored setup state. Credential prompts use masked input; after an + ambiguous transport failure, restart the setup conversation instead of + replaying the previous turn. + + Remote and Configure Later flows skip this local setup conversation. + @@ -66,17 +83,12 @@ Where does the **Gateway** run? Onboarding requests TCC permissions for: Automation (AppleScript), Notifications, Accessibility, Screen Recording, Microphone, Speech Recognition, Camera, and Location. - - - This step is optional - The app can install the global `openclaw` CLI via npm, pnpm, or bun, - preferring npm first. Node remains the recommended runtime for the Gateway - itself. - After setup, the app opens a dedicated onboarding chat session so the agent can - introduce itself and guide next steps, kept separate from your normal - conversation history. See [Bootstrapping](/start/bootstrapping) for what + After setup, the app opens a separate agent onboarding chat so the agent can + introduce itself and guide next steps without mixing that exchange into the + normal conversation history. This follows the Crestodian setup conversation; + it does not replace it. See [Bootstrapping](/start/bootstrapping) for what happens on the gateway host during the agent's first real turn. diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index 76d2c47f1c82..37396473371c 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -231,6 +231,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { const nativeExecutionPolicy = resolveCodexNativeExecutionPolicyForDynamicTools(input); const allTools = createOpenClawCodingTools({ agentId: input.sessionAgentId, + ...(params.crestodianTool ? { crestodianTool: params.crestodianTool } : {}), ...buildEmbeddedAttemptToolRunContext(params), exec: { ...params.execOverrides, diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index d46043fd2533..4df57686d13e 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -505,6 +505,10 @@ import { WebLoginStartParamsSchema, type WebLoginWaitParams, WebLoginWaitParamsSchema, + type CrestodianChatParams, + CrestodianChatParamsSchema, + type CrestodianChatResult, + CrestodianChatResultSchema, type WizardCancelParams, WizardCancelParamsSchema, type WizardNextParams, @@ -749,6 +753,9 @@ export const validateConfigSchemaLookupParams = lazyCompile( ConfigSchemaLookupResultSchema, ); +export const validateCrestodianChatParams = lazyCompile( + CrestodianChatParamsSchema, +); export const validateWizardStartParams = lazyCompile(WizardStartParamsSchema); export const validateWizardNextParams = lazyCompile(WizardNextParamsSchema); export const validateWizardCancelParams = lazyCompile(WizardCancelParamsSchema); @@ -1133,6 +1140,8 @@ export { ConfigSchemaResponseSchema, ConfigSchemaLookupResultSchema, UpdateStatusParamsSchema, + CrestodianChatParamsSchema, + CrestodianChatResultSchema, WizardStartParamsSchema, WizardNextParamsSchema, WizardCancelParamsSchema, @@ -1306,6 +1315,8 @@ export type { ConfigPatchParams, ConfigSchemaParams, ConfigSchemaResponse, + CrestodianChatParams, + CrestodianChatResult, WizardStartParams, WizardNextParams, WizardCancelParams, diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index 120db2f79fbe..51651bf548de 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -11,6 +11,7 @@ export * from "./schema/artifacts.js"; export * from "./schema/channels.js"; export * from "./schema/commands.js"; export * from "./schema/config.js"; +export * from "./schema/crestodian.js"; export * from "./schema/cron.js"; export * from "./schema/error-codes.js"; export * from "./schema/environments.js"; diff --git a/packages/gateway-protocol/src/schema/crestodian.ts b/packages/gateway-protocol/src/schema/crestodian.ts new file mode 100644 index 000000000000..7b374d595eae --- /dev/null +++ b/packages/gateway-protocol/src/schema/crestodian.ts @@ -0,0 +1,39 @@ +// Gateway Protocol schema module defines Crestodian chat payloads. +import { Type } from "typebox"; +import { NonEmptyString } from "./primitives.js"; + +/** + * Crestodian chat lets clients (macOS app onboarding, future UIs) hold the + * setup/repair conversation over the gateway. It is configless-safe: the + * engine answers deterministically before any model is configured. Omitting + * `message` returns the welcome/greeting for a fresh session without input. + */ +export const CrestodianChatParamsSchema = Type.Object( + { + sessionId: NonEmptyString, + message: Type.Optional(Type.String()), + /** "onboarding" seeds the first-run setup proposal in the greeting. */ + welcomeVariant: Type.Optional(Type.Union([Type.Literal("onboarding")])), + /** Drop any in-flight approval/wizard state and start the session over. */ + reset: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +/** One Crestodian reply; `action` tells clients about conversation handoffs. */ +export const CrestodianChatResultSchema = Type.Object( + { + sessionId: NonEmptyString, + reply: NonEmptyString, + /** The next reply is a hosted-wizard secret and clients must mask its input/echo. */ + sensitive: Type.Optional(Type.Boolean()), + action: Type.Union([ + Type.Literal("none"), + // The user asked to talk to their agent; clients should move to their + // normal agent chat surface. + Type.Literal("open-agent"), + Type.Literal("exit"), + ]), + }, + { additionalProperties: false }, +); diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 7483f687e364..7c22dbb52fee 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -141,6 +141,7 @@ import { UpdateStatusParamsSchema, UpdateRunParamsSchema, } from "./config.js"; +import { CrestodianChatParamsSchema, CrestodianChatResultSchema } from "./crestodian.js"; import { CronAddParamsSchema, CronGetParamsSchema, @@ -445,6 +446,8 @@ export const ProtocolSchemas = { ConfigSchemaLookupParams: ConfigSchemaLookupParamsSchema, ConfigSchemaResponse: ConfigSchemaResponseSchema, ConfigSchemaLookupResult: ConfigSchemaLookupResultSchema, + CrestodianChatParams: CrestodianChatParamsSchema, + CrestodianChatResult: CrestodianChatResultSchema, WizardStartParams: WizardStartParamsSchema, WizardNextParams: WizardNextParamsSchema, WizardCancelParams: WizardCancelParamsSchema, diff --git a/packages/gateway-protocol/src/schema/types.ts b/packages/gateway-protocol/src/schema/types.ts index fcfc27ce834f..28b65492fedc 100644 --- a/packages/gateway-protocol/src/schema/types.ts +++ b/packages/gateway-protocol/src/schema/types.ts @@ -116,6 +116,10 @@ export type ConfigSchemaResponse = SchemaType<"ConfigSchemaResponse">; export type ConfigSchemaLookupResult = SchemaType<"ConfigSchemaLookupResult">; export type UpdateStatusParams = SchemaType<"UpdateStatusParams">; +/** Crestodian chat payloads exchanged by clients and the gateway. */ +export type CrestodianChatParams = SchemaType<"CrestodianChatParams">; +export type CrestodianChatResult = SchemaType<"CrestodianChatResult">; + /** Wizard setup flow payloads exchanged by CLI, UI, and gateway. */ export type WizardStartParams = SchemaType<"WizardStartParams">; export type WizardNextParams = SchemaType<"WizardNextParams">; diff --git a/scripts/protocol-gen-swift.ts b/scripts/protocol-gen-swift.ts index 7da59627fcc3..98b0f0338e60 100644 --- a/scripts/protocol-gen-swift.ts +++ b/scripts/protocol-gen-swift.ts @@ -42,6 +42,7 @@ const STRICT_LITERAL_STRUCTS = new Set([ ]); const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]][] = [ + ["CrestodianChatResult", ["sensitive"]], ["SendParams", ["buffer", "filename", "contentType"]], ["SessionOperationEvent", ["agentId"]], ["SessionsCompactionListParams", ["agentId"]], diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 8494d9a024c9..276aef3f1241 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -519,6 +519,8 @@ export function createOpenClawCodingTools(options?: { toolSearchCatalogRef?: ToolSearchCatalogRef; /** Limits which tool families are materialized before the shared policy pipeline runs. */ toolConstructionPlan?: OpenClawCodingToolConstructionPlan; + /** Ring-zero Crestodian tool; set only by the Crestodian agent runner. */ + crestodianTool?: import("./tools/crestodian-tool.js").CrestodianToolOptions; /** Trusted sender identity bit for command/channel-action auth and owner-gated plugin tools. */ senderIsOwner?: boolean; /** Auth profiles already loaded for this run; used for prompt-time tool availability. */ @@ -973,6 +975,7 @@ export function createOpenClawCodingTools(options?: { ...(includeChannelTools ? listChannelAgentTools({ cfg: options?.config }) : []), ...(includeOpenClawTools ? createOpenClawTools({ + ...(options?.crestodianTool ? { crestodianTool: options.crestodianTool } : {}), sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl, allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true, agentSessionKey: options?.sessionKey, diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 26c85d4d7a92..b4ac86751b47 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -2266,6 +2266,7 @@ async function runEmbeddedAgentInternal( bootstrapContextRunKind: params.bootstrapContextRunKind, jobId: params.jobId, toolsAllow: params.toolsAllow, + crestodianTool: params.crestodianTool, cleanupBundleMcpOnRunEnd: params.cleanupBundleMcpOnRunEnd, disableMessageTool: params.disableMessageTool, forceMessageTool: params.forceMessageTool, diff --git a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts index fac52fc08e34..12fd137eb049 100644 --- a/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts +++ b/src/agents/embedded-agent-runner/run/attempt-tool-construction-plan.ts @@ -21,6 +21,7 @@ const SHELL_CODING_TOOL_FACTORY_NAMES = new Set(["apply_patch", "exec", "process // out of this set so narrow allowlists still materialize plugin tools. const OPENCLAW_TOOL_FACTORY_NAMES = new Set([ "agents_list", + "crestodian", "canvas", "cron", "gateway", diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 13bcc0401638..782c344a0ef8 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1262,6 +1262,7 @@ export async function runEmbeddedAttempt( : (() => { const allTools = createOpenClawCodingTools({ agentId: sessionAgentId, + ...(params.crestodianTool ? { crestodianTool: params.crestodianTool } : {}), ...buildEmbeddedAttemptToolRunContext({ ...params, trace: runTrace }), messageChannel: params.messageChannel, chatType: params.chatType, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 0004fe03ce29..b4e5a825d02d 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -184,6 +184,8 @@ export type RunEmbeddedAgentParams = { bootstrapContextRunKind?: BootstrapContextRunKind; /** Optional tool allow-list; when set, only these tools are sent to the model. */ toolsAllow?: string[]; + /** Ring-zero Crestodian tool; set only by the Crestodian agent runner. */ + crestodianTool?: import("../../tools/crestodian-tool.js").CrestodianToolOptions; /** Seen bootstrap truncation warning signatures for this session (once mode dedupe). */ bootstrapPromptWarningSignaturesSeen?: string[]; /** Last shown bootstrap truncation warning signature for this session. */ diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index 1b886b2258cd..007abaf00c28 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -41,6 +41,7 @@ import type { ToolFsPolicy } from "./tool-fs-policy.js"; import { resolveToolLoopDetectionConfig } from "./tool-loop-detection-config.js"; import { createAgentsListTool } from "./tools/agents-list-tool.js"; import type { AnyAgentTool } from "./tools/common.js"; +import { createCrestodianTool } from "./tools/crestodian-tool.js"; import { createCronTool, type CronCreatorToolAllowlistEntry } from "./tools/cron-tool.js"; import { createEmbeddedCallGateway } from "./tools/embedded-gateway-stub.js"; import { wrapToolWithGatewayCallerIdentity } from "./tools/gateway-caller-context.js"; @@ -134,6 +135,11 @@ export function createOpenClawTools( modelProvider?: string; /** Active model id for provider/model-specific tool gating. */ modelId?: string; + /** + * Ring-zero Crestodian setup tool. Only the Crestodian agent runner sets + * this; normal agents must never receive it (wildcard allowlists included). + */ + crestodianTool?: import("./tools/crestodian-tool.js").CrestodianToolOptions; /** If true, nodes action="invoke" can call media-returning commands directly. */ allowMediaInvokeCommands?: boolean; /** Explicit agent ID override for cron/hook sessions. */ @@ -417,6 +423,7 @@ export function createOpenClawTools( }); const includeTranscriptsTool = resolveTranscriptsConfig(resolvedConfig?.transcripts).enabled; const tools: AnyAgentTool[] = [ + ...(options?.crestodianTool ? [createCrestodianTool(options.crestodianTool)] : []), ...(embedded ? [] : [ diff --git a/src/agents/tool-display-config.ts b/src/agents/tool-display-config.ts index e51728bfeb78..ebd10b9cf811 100644 --- a/src/agents/tool-display-config.ts +++ b/src/agents/tool-display-config.ts @@ -290,6 +290,11 @@ export const TOOL_DISPLAY_CONFIG: ToolDisplayConfig = { title: "Skill Workshop", detailKeys: ["action", "name", "proposal_id"], }, + crestodian: { + emoji: "🦀", + title: "Crestodian", + detailKeys: ["action", "path", "model"], + }, gateway: { emoji: "🔌", title: "Gateway", diff --git a/src/agents/tools/crestodian-tool.test.ts b/src/agents/tools/crestodian-tool.test.ts new file mode 100644 index 000000000000..8e2b76e7c619 --- /dev/null +++ b/src/agents/tools/crestodian-tool.test.ts @@ -0,0 +1,223 @@ +// Crestodian ring-zero tool tests: approval gating, action mapping, verification. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createCrestodianTool } from "./crestodian-tool.js"; + +const mocks = vi.hoisted(() => ({ + executeCrestodianOperation: vi.fn(async (_op: unknown, runtime: { log: (m: string) => void }) => { + runtime.log("op-output"); + return { applied: false }; + }), + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "h", + config: {}, + sourceConfig: {}, + issues: [], + })), +})); + +vi.mock("../../crestodian/operations.js", async (importOriginal) => ({ + ...(await importOriginal()), + executeCrestodianOperation: mocks.executeCrestodianOperation, +})); + +vi.mock("../../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); + +afterEach(() => { + vi.clearAllMocks(); +}); + +function toolText(result: unknown): string { + const content = (result as { content: Array<{ type: string; text?: string }> }).content; + return content + .map((block) => block.text ?? "") + .filter(Boolean) + .join("\n"); +} + +describe("crestodian tool", () => { + it("runs read actions immediately", async () => { + const tool = createCrestodianTool({ surface: "cli" }); + const result = await tool.execute("t1", { action: "status" }); + expect(toolText(result)).toContain("op-output"); + expect(mocks.executeCrestodianOperation).toHaveBeenCalledWith( + { kind: "status" }, + expect.anything(), + expect.objectContaining({ approved: false }), + ); + }); + + it("refuses mutating actions without the approved assertion", async () => { + const proposalRef: { current?: string } = {}; + const tool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); + const result = await tool.execute("t2", { + action: "config_set", + path: "gateway.port", + value: "18789", + }); + // An armed turn can never mint its own proposal. + expect(toolText(result)).toContain("approval-mismatch"); + expect(proposalRef.current).toBeUndefined(); + expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); + }); + + it("refuses model-asserted approval without host-verified consent", async () => { + // approved=true from the model alone must never mutate: the host arms + // approval only when the user's actual message was an explicit yes. + const tool = createCrestodianTool({ surface: "cli" }); + const result = await tool.execute("t2b", { + action: "config_set", + path: "gateway.port", + value: "18789", + approved: true, + }); + expect(toolText(result)).toContain("needs-approval"); + expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); + }); + + it("executes an approved mutation only through the full proposal handshake", async () => { + mocks.executeCrestodianOperation.mockImplementationOnce( + async (_op: unknown, runtime: { log: (m: string) => void }) => { + runtime.log("op-output"); + return { applied: true }; + }, + ); + const proposalRef: { current?: string } = {}; + // Phase 1: unarmed proposal is denied and records the exact operation. + const proposingTool = createCrestodianTool({ surface: "gateway", proposalRef }); + const denied = await proposingTool.execute("t3a", { + action: "set_default_model", + model: "openai/gpt-5.5", + approved: true, + }); + expect(toolText(denied)).toContain("needs-approval"); + expect(proposalRef.current).toBeDefined(); + expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); + + // Phase 2: the user's yes arms the turn; the identical call executes. + const armedTool = createCrestodianTool({ + surface: "gateway", + approvalArmed: true, + proposalRef, + }); + const result = await armedTool.execute("t3b", { + action: "set_default_model", + model: "openai/gpt-5.5", + approved: true, + }); + expect(toolText(result)).toContain("op-output"); + expect(mocks.executeCrestodianOperation).toHaveBeenCalledWith( + { kind: "set-default-model", model: "openai/gpt-5.5" }, + expect.anything(), + expect.objectContaining({ + approved: true, + deps: { setupSurface: "gateway" }, + auditDetails: { via: "crestodian-agent-tool" }, + }), + ); + // One approval, one mutation. + expect(proposalRef.current).toBeUndefined(); + }); + + it("refuses an armed call that differs from the proposed operation", async () => { + const proposalRef: { current?: string } = {}; + const proposingTool = createCrestodianTool({ surface: "cli", proposalRef }); + await proposingTool.execute("t3c", { + action: "set_default_model", + model: "openai/gpt-5.5", + approved: true, + }); + const armedTool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); + const result = await armedTool.execute("t3d", { + action: "config_set", + path: "gateway.port", + value: "1", + approved: true, + }); + // A different operation than the approved one voids the approval entirely; + // even an identical retry in the same armed turn stays locked. + expect(toolText(result)).toContain("approval-mismatch"); + expect(proposalRef.current).toBeUndefined(); + const retry = await armedTool.execute("t3e", { + action: "config_set", + path: "gateway.port", + value: "1", + approved: true, + }); + expect(toolText(retry)).toContain("approval-mismatch"); + expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); + }); + + it("feeds config validation failures back into the tool result", async () => { + mocks.executeCrestodianOperation.mockImplementationOnce( + async (_op: unknown, runtime: { log: (m: string) => void }) => { + runtime.log("op-output"); + return { applied: true }; + }, + ); + mocks.readConfigFileSnapshot.mockResolvedValueOnce({ + exists: true, + valid: false, + path: "/tmp/openclaw.json", + hash: "h", + config: {}, + sourceConfig: {}, + issues: [{ path: "gateway.port", message: "Expected number" }], + } as never); + const proposalRef: { current?: string } = {}; + await createCrestodianTool({ surface: "cli", proposalRef }).execute("t4a", { + action: "config_set", + path: "gateway.port", + value: "banana", + approved: true, + }); + const tool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); + const result = await tool.execute("t4", { + action: "config_set", + path: "gateway.port", + value: "banana", + approved: true, + }); + const text = toolText(result); + expect(text).toContain("CONFIG INVALID"); + expect(text).toContain("gateway.port: Expected number"); + }); + + it("maps create_agent with optional workspace and model", async () => { + mocks.executeCrestodianOperation.mockImplementationOnce( + async (_op: unknown, runtime: { log: (m: string) => void }) => { + runtime.log("op-output"); + return { applied: true }; + }, + ); + const proposalRef: { current?: string } = {}; + await createCrestodianTool({ surface: "cli", proposalRef }).execute("t6a", { + action: "create_agent", + agentId: "work", + workspace: "/tmp/work", + approved: true, + }); + const tool = createCrestodianTool({ surface: "cli", approvalArmed: true, proposalRef }); + await tool.execute("t6", { + action: "create_agent", + agentId: "work", + workspace: "/tmp/work", + approved: true, + }); + expect(mocks.executeCrestodianOperation).toHaveBeenCalledWith( + { kind: "create-agent", agentId: "work", workspace: "/tmp/work" }, + expect.anything(), + expect.objectContaining({ approved: true }), + ); + }); + + it("rejects unknown or underspecified actions as input errors", async () => { + const tool = createCrestodianTool({ surface: "cli" }); + await expect(tool.execute("t5", { action: "config_get" })).rejects.toThrow(/path/); + }); +}); diff --git a/src/agents/tools/crestodian-tool.ts b/src/agents/tools/crestodian-tool.ts new file mode 100644 index 000000000000..4176db7e2020 --- /dev/null +++ b/src/agents/tools/crestodian-tool.ts @@ -0,0 +1,275 @@ +/** + * crestodian built-in tool: ring-zero setup/repair actions for the Crestodian + * agent. Never exposed to normal agents — construction is gated on an explicit + * runner option, and every action funnels through Crestodian's typed operation + * union with approval assertions and the audit log. + */ +import { Type } from "typebox"; +import { + executeCrestodianOperation, + isPersistentCrestodianOperation, + type CrestodianOperation, +} from "../../crestodian/operations.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import { stringEnum } from "../schema/typebox.js"; +import { textResult, ToolInputError, readStringParam, type AnyAgentTool } from "./common.js"; + +export type CrestodianToolOptions = { + /** Where setup side effects run; the gateway surface never manages its own daemon. */ + surface: "cli" | "gateway"; + /** + * Host-verified consent for THIS turn: true only when the user's actual + * message was an explicit approval. The model-supplied `approved` argument + * alone must never authorize a mutation (prompt injection, model error). + */ + approvalArmed?: boolean; + /** + * Approval is scoped to one exact operation: a denied mutating call records + * its canonical hash here (host-owned, survives turns), and an armed turn + * may execute only a call matching that hash. Cleared after use. + */ + proposalRef?: { current?: string }; +}; + +/** Canonical operation fingerprint used to bind "yes" to one exact mutation. */ +export function hashCrestodianOperation(operation: CrestodianOperation): string { + return JSON.stringify(operation, Object.keys(operation).toSorted()); +} + +const CRESTODIAN_TOOL_ACTIONS = [ + "status", + "models", + "agents", + "channels", + "audit", + "validate_config", + "doctor", + "config_get", + "config_schema", + "gateway_status", + "plugin_search", + // Mutating actions below require approved=true. + "setup", + "set_default_model", + "config_set", + "config_set_ref", + "create_agent", + "gateway_start", + "gateway_stop", + "gateway_restart", + "plugin_install", + "plugin_uninstall", + "doctor_fix", +] as const; + +const CrestodianToolSchema = Type.Object({ + action: stringEnum([...CRESTODIAN_TOOL_ACTIONS]), + path: Type.Optional(Type.String({ description: "Config path for config_* actions" })), + value: Type.Optional(Type.String({ description: "Value for config_set (JSON5 or string)" })), + envVar: Type.Optional(Type.String({ description: "Env var name for config_set_ref" })), + model: Type.Optional(Type.String({ description: "provider/model ref" })), + workspace: Type.Optional(Type.String({ description: "Workspace directory" })), + agentId: Type.Optional(Type.String({ description: "Agent id for create_agent" })), + query: Type.Optional(Type.String({ description: "Search query for plugin_search" })), + spec: Type.Optional(Type.String({ description: "npm/clawhub spec for plugin_install" })), + pluginId: Type.Optional(Type.String({ description: "Plugin id for plugin_uninstall" })), + approved: Type.Optional( + Type.Boolean({ + description: + "Set true ONLY after the user explicitly approved this exact change in the conversation.", + }), + ), +}); + +function createCaptureRuntime(): RuntimeEnv & { read: () => string } { + const lines: string[] = []; + return { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`crestodian operation exited with code ${String(code)}`); + }, + read: () => lines.join("\n").trim(), + }; +} + +function requireParam(params: Record, name: string): string { + const value = readStringParam(params, name); + if (!value?.trim()) { + throw new ToolInputError(`crestodian: "${name}" is required for this action`); + } + return value.trim(); +} + +function operationForAction(params: Record): CrestodianOperation { + const action = readStringParam(params, "action", { required: true }); + switch (action) { + case "status": + return { kind: "status" }; + case "models": + return { kind: "models" }; + case "agents": + return { kind: "agents" }; + case "channels": + return { kind: "channel-list" }; + case "audit": + return { kind: "audit" }; + case "validate_config": + return { kind: "config-validate" }; + case "doctor": + return { kind: "doctor" }; + case "doctor_fix": + return { kind: "doctor-fix" }; + case "config_get": + return { kind: "config-get", path: requireParam(params, "path") }; + case "config_schema": { + const path = readStringParam(params, "path")?.trim(); + return { kind: "config-schema", ...(path ? { path } : {}) }; + } + case "gateway_status": + return { kind: "gateway-status" }; + case "gateway_start": + return { kind: "gateway-start" }; + case "gateway_stop": + return { kind: "gateway-stop" }; + case "gateway_restart": + return { kind: "gateway-restart" }; + case "plugin_search": + return { kind: "plugin-search", query: requireParam(params, "query") }; + case "plugin_install": + return { kind: "plugin-install", spec: requireParam(params, "spec") }; + case "plugin_uninstall": + return { kind: "plugin-uninstall", pluginId: requireParam(params, "pluginId") }; + case "setup": { + const workspace = readStringParam(params, "workspace")?.trim(); + const model = readStringParam(params, "model")?.trim(); + return { + kind: "setup", + ...(workspace ? { workspace } : {}), + ...(model ? { model } : {}), + }; + } + case "set_default_model": + return { kind: "set-default-model", model: requireParam(params, "model") }; + case "create_agent": { + const workspace = readStringParam(params, "workspace")?.trim(); + const model = readStringParam(params, "model")?.trim(); + return { + kind: "create-agent", + agentId: requireParam(params, "agentId"), + ...(workspace ? { workspace } : {}), + ...(model ? { model } : {}), + }; + } + case "config_set": + return { + kind: "config-set", + path: requireParam(params, "path"), + value: requireParam(params, "value"), + }; + case "config_set_ref": + return { + kind: "config-set-ref", + path: requireParam(params, "path"), + source: "env", + id: requireParam(params, "envVar"), + }; + default: + throw new ToolInputError(`crestodian: unknown action "${action}"`); + } +} + +/** Validate openclaw.json after a write so the agent can fix mistakes in-loop. */ +async function verifyConfigAfterToolWrite(): Promise { + try { + const { readConfigFileSnapshot } = await import("../../config/config.js"); + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.exists || snapshot.valid) { + return null; + } + const issues = (snapshot.issues ?? []).map( + (issue: { path?: string; message: string }) => + `${issue.path ? `${issue.path}: ` : ""}${issue.message}`, + ); + return [ + "CONFIG INVALID after this write — fix it before doing anything else:", + ...(issues.length > 0 ? issues : ["unknown validation failure"]), + ].join("\n"); + } catch { + return null; + } +} + +export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTool { + return { + name: "crestodian", + label: "Crestodian", + description: [ + "Ring-zero OpenClaw setup and repair. Read actions (status/models/agents/channels/config_get/config_schema/gateway_status/plugin_search/validate_config/doctor/audit) run immediately.", + "Mutating actions (setup/set_default_model/config_set/config_set_ref/create_agent/gateway_*/plugin_install/plugin_uninstall/doctor_fix) REQUIRE approved=true, which you may only set after the user explicitly said yes to that exact change in this conversation.", + "Before writing an unfamiliar config path, call config_schema for it — the schema is the source of truth. Secrets go through config_set_ref (env var), never plaintext echoes.", + "Every applied write is validated; if the result reports CONFIG INVALID, fix it immediately. All writes are audited.", + ].join(" "), + parameters: CrestodianToolSchema, + execute: async (_toolCallId, args) => { + const params = (args ?? {}) as Record; + const operation = operationForAction(params); + const persistent = isPersistentCrestodianOperation(operation); + if (persistent) { + const operationHash = hashCrestodianOperation(operation); + const armedForThisOperation = + params.approved === true && + options.approvalArmed === true && + options.proposalRef?.current === operationHash; + if (!armedForThisOperation) { + // Three gates must hold: the model asserts consent, the host saw an + // explicit user approval in the current turn, and the approved call + // matches the operation registered BEFORE that approval. A generic + // "yes" must never authorize a different mutation, and an armed turn + // must never mint a new executable proposal for itself — otherwise + // the model could swap the approved action for another one. + if (options.approvalArmed === true) { + if (options.proposalRef) { + options.proposalRef.current = undefined; + } + return textResult( + "approval-mismatch: this call is not the operation the user approved. The approval is void; describe the new change and get a fresh yes before retrying.", + { needsApproval: true }, + ); + } + if (options.proposalRef) { + options.proposalRef.current = operationHash; + } + return textResult( + "needs-approval: this action changes state. The proposal is registered; describe this exact change and ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).", + { needsApproval: true }, + ); + } + if (options.proposalRef) { + // One approval, one mutation: re-proposals need a fresh yes. + options.proposalRef.current = undefined; + } + } + const capture = createCaptureRuntime(); + let applied: boolean; + try { + const result = await executeCrestodianOperation(operation, capture, { + approved: persistent, + deps: { setupSurface: options.surface }, + auditDetails: { via: "crestodian-agent-tool" }, + }); + applied = result.applied; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return textResult([capture.read(), `error: ${message}`].filter(Boolean).join("\n"), { + error: true, + }); + } + const verify = applied ? await verifyConfigAfterToolWrite() : null; + return textResult( + [capture.read() || "done", verify].filter(Boolean).join("\n\n"), + verify ? { configInvalid: true } : {}, + ); + }, + }; +} diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index a397eb441fb2..e53010eb50fc 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -171,7 +171,12 @@ export function registerOnboardCommand(program: Command): void { ) .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run without prompts", false) - .option("--modern", "Use the conversational setup/repair assistant", false) + .option( + "--modern", + "Alias for the default bootstrap onboarding (kept for compatibility)", + false, + ) + .option("--classic", "Use the classic multi-step setup wizard", false) .option( "--accept-risk", "Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)", @@ -218,6 +223,8 @@ export function registerOnboardCommand(program: Command): void { const { defaultRuntime } = await import("../../runtime.js"); await runCommandWithRuntime(defaultRuntime, async () => { if (opts.modern) { + // Deprecated alias for `openclaw crestodian`: skip bootstrap prompts and + // open the conversation directly (same as the pre-Commander fast path). const { runCrestodian } = await import("../../crestodian/crestodian.js"); await runCrestodian({ message: opts.nonInteractive ? "overview" : undefined, @@ -237,6 +244,7 @@ export function registerOnboardCommand(program: Command): void { workspace: opts.workspace as string | undefined, nonInteractive: Boolean(opts.nonInteractive), acceptRisk: Boolean(opts.acceptRisk), + classic: Boolean(opts.classic), flow: opts.flow as "quickstart" | "advanced" | "manual" | "import" | undefined, mode: opts.mode as "local" | "remote" | undefined, ...pickOnboardAuthOptionValues(opts as Record), diff --git a/src/cli/program/register.setup.ts b/src/cli/program/register.setup.ts index 7c25c722605e..6439237cdfc3 100644 --- a/src/cli/program/register.setup.ts +++ b/src/cli/program/register.setup.ts @@ -65,6 +65,7 @@ export function registerSetupCommand(program: Command): void { ) .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run onboarding without prompts", false) + .option("--classic", "Use the classic multi-step setup wizard", false) .option( "--accept-risk", "Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)", @@ -124,6 +125,7 @@ export function registerSetupCommand(program: Command): void { workspace: opts.workspace as string | undefined, nonInteractive: Boolean(opts.nonInteractive), acceptRisk: Boolean(opts.acceptRisk), + classic: Boolean(opts.classic), flow: opts.flow as "quickstart" | "advanced" | "manual" | "import" | undefined, mode: opts.mode as "local" | "remote" | undefined, ...pickOnboardAuthOptionValues(opts as Record), diff --git a/src/commands/onboard-inference.test.ts b/src/commands/onboard-inference.test.ts new file mode 100644 index 000000000000..59d5ae517bb6 --- /dev/null +++ b/src/commands/onboard-inference.test.ts @@ -0,0 +1,102 @@ +// Inference backend detection tests cover the documented ladder and login-awareness. +import { describe, expect, it } from "vitest"; +import type { LocalCommandProbe } from "../crestodian/probes.js"; +import { + ANTHROPIC_API_DEFAULT_MODEL_REF, + CLAUDE_CLI_DEFAULT_MODEL_REF, + CODEX_APP_SERVER_DEFAULT_MODEL_REF, + OPENAI_API_DEFAULT_MODEL_REF, + detectInferenceBackends, +} from "./onboard-inference.js"; + +function probeDeps(found: Record) { + return async (command: string): Promise => ({ + command, + found: found[command] ?? false, + }); +} + +describe("detectInferenceBackends", () => { + it("returns nothing when no backend exists", async () => { + const candidates = await detectInferenceBackends({ + env: {}, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({}), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => null, + }, + }); + expect(candidates).toEqual([]); + }); + + it("orders the ladder: existing model, env keys, then CLI logins", async () => { + const candidates = await detectInferenceBackends({ + config: { agents: { defaults: { model: "zai/glm-5.2" } } }, + env: { OPENAI_API_KEY: "sk-x", ANTHROPIC_API_KEY: "sk-y" }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ claude: true, codex: true }), + readClaudeCliCredentials: () => ({ type: "oauth" }), + readCodexCliCredentials: () => ({ type: "oauth" }), + }, + }); + expect(candidates.map((candidate) => candidate.kind)).toEqual([ + "existing-model", + "openai-api-key", + "anthropic-api-key", + "claude-cli", + "codex-cli", + ]); + expect(candidates[0]?.modelRef).toBe("zai/glm-5.2"); + expect(candidates[1]?.modelRef).toBe(OPENAI_API_DEFAULT_MODEL_REF); + expect(candidates[2]?.modelRef).toBe(ANTHROPIC_API_DEFAULT_MODEL_REF); + expect(candidates[3]?.modelRef).toBe(CLAUDE_CLI_DEFAULT_MODEL_REF); + expect(candidates[4]?.modelRef).toBe(CODEX_APP_SERVER_DEFAULT_MODEL_REF); + }); + + it("sinks a definitively logged-out CLI below a logged-in one", async () => { + const candidates = await detectInferenceBackends({ + env: {}, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({ claude: true, codex: true }), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => ({ type: "oauth" }), + }, + }); + expect(candidates.map((candidate) => candidate.kind)).toEqual(["codex-cli", "claude-cli"]); + expect(candidates[0]?.credentials).toBe(true); + expect(candidates[1]?.credentials).toBe(false); + expect(candidates[1]?.detail).toBe("installed, not logged in"); + }); + + it("treats missing file credentials as unknown on macOS (keychain may hold the login)", async () => { + const candidates = await detectInferenceBackends({ + env: {}, + platform: "darwin", + deps: { + probeLocalCommand: probeDeps({ claude: true }), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => null, + }, + }); + expect(candidates).toHaveLength(1); + expect(candidates[0]?.kind).toBe("claude-cli"); + expect(candidates[0]?.credentials).toBeUndefined(); + expect(candidates[0]?.detail).toBe("installed"); + }); + + it("ignores blank env keys", async () => { + const candidates = await detectInferenceBackends({ + env: { OPENAI_API_KEY: " " }, + platform: "linux", + deps: { + probeLocalCommand: probeDeps({}), + readClaudeCliCredentials: () => null, + readCodexCliCredentials: () => null, + }, + }); + expect(candidates).toEqual([]); + }); +}); diff --git a/src/commands/onboard-inference.ts b/src/commands/onboard-inference.ts new file mode 100644 index 000000000000..d8a5d5f69c83 --- /dev/null +++ b/src/commands/onboard-inference.ts @@ -0,0 +1,174 @@ +import { + readClaudeCliCredentialsCached, + readCodexCliCredentialsCached, +} from "../agents/cli-credentials.js"; +// Inference backend detection shared by onboarding bootstrap and Crestodian setup. +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { probeLocalCommand, type LocalCommandProbe } from "../crestodian/probes.js"; + +/** + * Onboarding treats inference as the one required step: reuse whatever the + * machine already has (env API keys, Claude Code login, Codex login) before + * asking the user anything. The ladder order is a documented contract + * (docs/cli/crestodian.md "Setup bootstrap") — change docs when changing it. + */ +export const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`; +export const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-8"; +export const CLAUDE_CLI_DEFAULT_MODEL_REF = "claude-cli/claude-opus-4-8"; +export const CODEX_APP_SERVER_DEFAULT_MODEL_REF = OPENAI_API_DEFAULT_MODEL_REF; + +export type InferenceBackendKind = + | "existing-model" + | "openai-api-key" + | "anthropic-api-key" + | "claude-cli" + | "codex-cli"; + +export type InferenceBackendCandidate = { + kind: InferenceBackendKind; + modelRef: string; + /** Short human label, e.g. "Claude Code CLI". */ + label: string; + /** One-line provenance, e.g. "logged in", "ANTHROPIC_API_KEY set". */ + detail: string; + /** + * true: credentials verified; false: definitively logged out; undefined: + * unknown (e.g. macOS keychain-backed logins we must not prompt for here). + */ + credentials?: boolean; +}; + +export type DetectInferenceBackendsDeps = { + probeLocalCommand?: typeof probeLocalCommand; + readClaudeCliCredentials?: () => { type: string } | null; + readCodexCliCredentials?: () => { type: string } | null; +}; + +export type DetectInferenceBackendsOptions = { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + deps?: DetectInferenceBackendsDeps; +}; + +function detectCliCredentialState(params: { + probe: LocalCommandProbe; + hasStoredCredentials: boolean; + platform: NodeJS.Platform; +}): boolean | undefined { + if (!params.probe.found) { + return undefined; + } + if (params.hasStoredCredentials) { + return true; + } + // On macOS both CLIs may keep their login in the keychain, which we must not + // read here (it can trigger a password prompt). Missing file creds is only a + // definitive logout signal elsewhere. + return params.platform === "darwin" ? undefined : false; +} + +function describeCliDetail(credentials: boolean | undefined): string { + if (credentials === true) { + return "logged in"; + } + if (credentials === false) { + return "installed, not logged in"; + } + return "installed"; +} + +/** + * Detect usable inference backends in ladder order. Returns candidates only + * for backends that exist on this machine; the first entry is the bootstrap + * default. Backends that are definitively logged out sink below logged-in and + * unknown ones so a stale install never outranks a working login. + */ +export async function detectInferenceBackends( + options: DetectInferenceBackendsOptions = {}, +): Promise { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const probe = options.deps?.probeLocalCommand ?? probeLocalCommand; + const readClaude = + options.deps?.readClaudeCliCredentials ?? + (() => readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 })); + const readCodex = + options.deps?.readCodexCliCredentials ?? + (() => readCodexCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 60_000 })); + + const candidates: InferenceBackendCandidate[] = []; + const existingModel = resolveAgentModelPrimaryValue(options.config?.agents?.defaults?.model); + if (existingModel) { + candidates.push({ + kind: "existing-model", + modelRef: existingModel, + label: "Current model", + detail: "already configured", + credentials: true, + }); + } + if (env.OPENAI_API_KEY?.trim()) { + candidates.push({ + kind: "openai-api-key", + modelRef: OPENAI_API_DEFAULT_MODEL_REF, + label: "OpenAI API key", + detail: "OPENAI_API_KEY set", + credentials: true, + }); + } + if (env.ANTHROPIC_API_KEY?.trim()) { + candidates.push({ + kind: "anthropic-api-key", + modelRef: ANTHROPIC_API_DEFAULT_MODEL_REF, + label: "Anthropic API key", + detail: "ANTHROPIC_API_KEY set", + credentials: true, + }); + } + + const [claudeProbe, codexProbe] = await Promise.all([probe("claude"), probe("codex")]); + const cliCandidates: InferenceBackendCandidate[] = []; + if (claudeProbe.found) { + const credentials = detectCliCredentialState({ + probe: claudeProbe, + hasStoredCredentials: readClaude() !== null, + platform, + }); + cliCandidates.push({ + kind: "claude-cli", + modelRef: CLAUDE_CLI_DEFAULT_MODEL_REF, + label: "Claude Code", + detail: describeCliDetail(credentials), + ...(credentials === undefined ? {} : { credentials }), + }); + } + if (codexProbe.found) { + const credentials = detectCliCredentialState({ + probe: codexProbe, + hasStoredCredentials: readCodex() !== null, + platform, + }); + cliCandidates.push({ + kind: "codex-cli", + modelRef: CODEX_APP_SERVER_DEFAULT_MODEL_REF, + label: "Codex", + detail: describeCliDetail(credentials), + ...(credentials === undefined ? {} : { credentials }), + }); + } + // Stable partition: logged-out installs sink, ladder order preserved inside + // each partition (claude before codex per the documented ladder). + candidates.push( + ...cliCandidates.filter((candidate) => candidate.credentials !== false), + ...cliCandidates.filter((candidate) => candidate.credentials === false), + ); + return candidates; +} + +/** Format a candidate for prompts/logs, e.g. "Codex — openai/gpt-5.5 (logged in)". */ +export function formatInferenceBackendCandidate(candidate: InferenceBackendCandidate): string { + return `${candidate.label} — ${candidate.modelRef} (${candidate.detail})`; +} diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index d4814b893935..08f10c67b9f4 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -36,3 +36,30 @@ export async function runInteractiveSetup( } } } + +/** + * Default interactive onboarding: no step wizard, just the Crestodian + * conversation. The first-run greeting proposes a full setup plan (detected + * inference, workspace, gateway) and a plain "yes" applies it; channels and + * the agent handoff continue in the same conversation. + */ +export async function runConversationalOnboarding( + opts: OnboardOptions, + runtime: RuntimeEnv = defaultRuntime, +) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + runtime.error( + "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", + ); + runtime.exit(1); + return; + } + const { runCrestodian } = await import("../crestodian/crestodian.js"); + await runCrestodian( + { + welcomeVariant: "onboarding", + ...(opts.workspace ? { setupWorkspace: opts.workspace } : {}), + }, + runtime, + ); +} diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index cbe3c7fee4e6..5cecf166960c 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -42,6 +42,8 @@ export type OnboardOptions = OnboardDynamicProviderOptions & { mode?: OnboardMode; /** "manual" is an alias for "advanced". */ flow?: "quickstart" | "advanced" | "manual" | "import"; + /** Force the classic multi-step interactive wizard instead of the bootstrap flow. */ + classic?: boolean; workspace?: string; nonInteractive?: boolean; /** Required for non-interactive setup; skips the interactive risk prompt when true. */ diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index 4d66bbbc3155..9538686f466e 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -7,6 +7,7 @@ import { onboardCommand, setupWizardCommand } from "./onboard.js"; const mocks = vi.hoisted(() => ({ runInteractiveSetup: vi.fn(async () => {}), + runConversationalOnboarding: vi.fn(async () => {}), runNonInteractiveSetup: vi.fn(async () => {}), readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })), handleReset: vi.fn(async () => {}), @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ vi.mock("./onboard-interactive.js", () => ({ runInteractiveSetup: mocks.runInteractiveSetup, + runConversationalOnboarding: mocks.runConversationalOnboarding, })); vi.mock("./onboard-non-interactive.js", () => ({ @@ -176,4 +178,49 @@ describe("setupWizardCommand", () => { it("keeps onboardCommand as an alias for setupWizardCommand", () => { expect(onboardCommand).toBe(setupWizardCommand); }); + + it("routes flagless interactive onboarding to the bootstrap flow", async () => { + const runtime = makeRuntime(); + + // Unset Commander booleans arrive as false and must not force classic. + await setupWizardCommand( + { skipChannels: false, skipSkills: false, acceptRisk: false, json: false }, + runtime, + ); + + expect(mocks.runConversationalOnboarding).toHaveBeenCalledOnce(); + expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); + expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); + }); + + it.each([ + ["--classic", { classic: true }], + ["--flow quickstart", { flow: "quickstart" as const }], + ["--mode remote", { mode: "remote" as const }], + ["--import-from", { importFrom: "hermes" }], + ["--auth-choice", { authChoice: "skip" }], + ["--gateway-port", { gatewayPort: 19001 }], + ["--remote-url", { remoteUrl: "wss://gw.example.ts.net" }], + ["--skip-bootstrap", { skipBootstrap: true }], + ["--no-install-daemon", { installDaemon: false }], + ["--daemon-runtime", { daemonRuntime: "node" as const }], + ["a provider auth flag", { mistralApiKey: "sk-x" }], + ])("keeps the classic interactive wizard for %s", async (_label, opts) => { + const runtime = makeRuntime(); + + await setupWizardCommand(opts, runtime); + + expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce(); + expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled(); + }); + + it("keeps non-interactive routing unchanged", async () => { + const runtime = makeRuntime(); + + await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime); + + expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce(); + expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled(); + expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 177ee9507bae..93eaddba0c75 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -17,12 +17,49 @@ import { resolveDeprecatedAuthChoiceReplacement, } from "./auth-choice-legacy.js"; import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js"; -import { runInteractiveSetup } from "./onboard-interactive.js"; +import { runConversationalOnboarding, runInteractiveSetup } from "./onboard-interactive.js"; import { runNonInteractiveSetup } from "./onboard-non-interactive.js"; import type { OnboardOptions, ResetScope } from "./onboard-types.js"; const VALID_RESET_SCOPES = new Set(["config", "config+creds+sessions", "full"]); +/** + * Interactive onboarding defaults to the Crestodian conversation. Any explicit + * setup flag beyond this allowlist keeps the classic wizard — those flags are + * a public automation contract and the conversation does not honor them. + * Boolean false and undefined mean "not passed" (Commander coerces unset + * booleans to false); explicit `--no-install-daemon` arrives as `false` via + * resolveInstallDaemonFlag and is special-cased. `--modern` never reaches this + * dispatch; it routes straight to Crestodian in the command layer. + */ +const CONVERSATIONAL_SAFE_ONBOARD_KEYS = new Set([ + "workspace", + "acceptRisk", + "reset", + "resetScope", + "nonInteractive", + "classic", +]); + +export function wantsClassicInteractiveSetup(opts: OnboardOptions): boolean { + if (opts.classic === true) { + return true; + } + if (opts.installDaemon !== undefined) { + return true; + } + for (const [key, value] of Object.entries(opts)) { + if (CONVERSATIONAL_SAFE_ONBOARD_KEYS.has(key) || key === "installDaemon") { + continue; + } + if (value === undefined || value === false) { + continue; + } + return true; + } + return false; +} + /** Runs the onboard command after normalizing legacy flags and setup mode. */ export async function setupWizardCommand( opts: OnboardOptions, @@ -115,7 +152,12 @@ export async function setupWizardCommand( return; } - await runInteractiveSetup(normalizedOpts, runtime); + if (wantsClassicInteractiveSetup(normalizedOpts)) { + await runInteractiveSetup(normalizedOpts, runtime); + return; + } + + await runConversationalOnboarding(normalizedOpts, runtime); } export const onboardCommand = setupWizardCommand; diff --git a/src/crestodian/agent-turn.test.ts b/src/crestodian/agent-turn.test.ts new file mode 100644 index 000000000000..9350823d01c1 --- /dev/null +++ b/src/crestodian/agent-turn.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + cleanupCrestodianAgentSession, + createCrestodianAgentSession, + runCrestodianAgentTurn, +} from "./agent-turn.js"; + +const mocks = vi.hoisted(() => ({ + runEmbeddedAgent: vi.fn(async (_params: { sessionFile: string }) => ({ + meta: { finalAssistantVisibleText: "ready" }, + })), +})); + +vi.mock("../agents/embedded-agent.js", () => ({ + runEmbeddedAgent: mocks.runEmbeddedAgent, +})); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "hash", + config: {}, + runtimeConfig: {}, + sourceConfig: {}, + issues: [], + })), +})); + +const tempDirs: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("runCrestodianAgentTurn", () => { + it("uses a distinct transcript for each chat session", async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "crestodian-turn-")); + tempDirs.push(stateDir); + vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); + const overview = { defaultModel: "openai/gpt-5.5" } as never; + const first = createCrestodianAgentSession(); + const second = createCrestodianAgentSession(); + + await runCrestodianAgentTurn({ + input: "hello", + overview, + surface: "gateway", + approvalArmed: false, + session: first, + }); + await runCrestodianAgentTurn({ + input: "hello", + overview, + surface: "gateway", + approvalArmed: false, + session: second, + }); + + const firstPath = mocks.runEmbeddedAgent.mock.calls[0]?.[0]?.sessionFile; + const secondPath = mocks.runEmbeddedAgent.mock.calls[1]?.[0]?.sessionFile; + expect(firstPath).toContain(`${first.sessionId}.jsonl`); + expect(secondPath).toContain(`${second.sessionId}.jsonl`); + expect(firstPath).not.toBe(secondPath); + + await fs.promises.writeFile(firstPath, "transcript"); + await cleanupCrestodianAgentSession(first); + await expect(fs.promises.access(firstPath)).rejects.toThrow(); + }); +}); diff --git a/src/crestodian/agent-turn.ts b/src/crestodian/agent-turn.ts new file mode 100644 index 000000000000..3e5047c6bdb3 --- /dev/null +++ b/src/crestodian/agent-turn.ts @@ -0,0 +1,159 @@ +// Crestodian agent turns run the real embedded agent loop with the ring-zero tool. +import { randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import { buildAgentMainSessionKey } from "../routing/session-key.js"; +import { selectCrestodianLocalPlannerBackends } from "./assistant-backends.js"; +import { CRESTODIAN_AGENT_SYSTEM_PROMPT } from "./assistant-prompts.js"; +import type { CrestodianOverview } from "./overview.js"; + +/** + * Crestodian is a real agent: same embedded loop, session transcript, and tool + * pipeline as regular agents — restricted to the single ring-zero `crestodian` + * tool. Turns share one persistent session so the conversation has genuine + * multi-turn memory. When no loop-capable backend exists (fresh machine with + * only a CLI harness that cannot enforce a restricted toolset), the caller + * falls back to the single-turn planner. + */ +export const CRESTODIAN_AGENT_ID = "crestodian"; + +const AGENT_TURN_TIMEOUT_MS = 120_000; + +export type CrestodianAgentTurnRunner = (params: { + input: string; + overview: CrestodianOverview; + surface: "cli" | "gateway"; + /** Host-verified: the user's current message is an explicit approval. */ + approvalArmed: boolean; + session: CrestodianAgentSession; +}) => Promise<{ text: string; modelLabel?: string } | null>; + +export type CrestodianAgentSession = { + sessionId: string; + /** Host-owned pending-proposal fingerprint; see crestodian-tool.ts. */ + proposalRef: { current?: string }; +}; + +export function createCrestodianAgentSession(): CrestodianAgentSession { + return { sessionId: `crestodian-${randomUUID()}`, proposalRef: {} }; +} + +type EmbeddedRunResult = { + payloads?: Array<{ text?: string }>; + meta?: { + finalAssistantVisibleText?: string; + finalAssistantRawText?: string; + }; +}; + +function extractRunText(result: EmbeddedRunResult): string | undefined { + return ( + result.meta?.finalAssistantVisibleText ?? + result.meta?.finalAssistantRawText ?? + result.payloads + ?.map((payload) => payload.text?.trim()) + .filter(Boolean) + .join("\n") + ); +} + +async function ensureCrestodianDirs( + sessionId: string, +): Promise<{ workspaceDir: string; sessionFile: string }> { + const base = path.join(resolveStateDir(), "crestodian"); + const workspaceDir = path.join(base, "workspace"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.mkdir(path.join(base, "sessions"), { recursive: true }); + return { workspaceDir, sessionFile: path.join(base, "sessions", `${sessionId}.jsonl`) }; +} + +export async function cleanupCrestodianAgentSession( + session: CrestodianAgentSession, +): Promise { + const sessionFile = path.join( + resolveStateDir(), + "crestodian", + "sessions", + `${session.sessionId}.jsonl`, + ); + await fs.rm(sessionFile, { force: true }); +} + +/** + * Run one Crestodian turn through the embedded agent loop. Returns null when + * no loop-capable backend is available or the run fails, so the caller can + * degrade to the planner. + */ +export const runCrestodianAgentTurn: CrestodianAgentTurnRunner = async (params) => { + const { overview } = params; + const configuredModel = overview.defaultModel; + // CLI-harness models (e.g. claude-cli/*) cannot enforce a restricted + // toolset; runEmbeddedAgent rejects toolsAllow for them, and we fall back. + const embeddedFallback = configuredModel + ? null + : (selectCrestodianLocalPlannerBackends(overview).find( + (backend) => backend.runner === "embedded", + ) ?? null); + if (!configuredModel && !embeddedFallback) { + return null; + } + + const { workspaceDir, sessionFile } = await ensureCrestodianDirs(params.session.sessionId); + const { runEmbeddedAgent } = await import("../agents/embedded-agent.js"); + const { readConfigFileSnapshot } = await import("../config/config.js"); + + let runConfig: import("../config/types.openclaw.js").OpenClawConfig; + let provider: string | undefined; + let model: string | undefined; + let agentHarnessId: string | undefined; + let modelLabel: string; + if (configuredModel) { + const snapshot = await readConfigFileSnapshot(); + runConfig = snapshot.runtimeConfig ?? snapshot.config ?? {}; + modelLabel = configuredModel; + } else { + runConfig = embeddedFallback!.buildConfig(workspaceDir); + provider = embeddedFallback!.provider; + model = embeddedFallback!.model; + agentHarnessId = "codex"; + modelLabel = embeddedFallback!.label; + } + + try { + const result = (await runEmbeddedAgent({ + sessionId: params.session.sessionId, + sessionKey: buildAgentMainSessionKey({ agentId: CRESTODIAN_AGENT_ID }), + agentId: CRESTODIAN_AGENT_ID, + trigger: "manual", + sessionFile, + workspaceDir, + config: runConfig, + prompt: params.input, + extraSystemPrompt: CRESTODIAN_AGENT_SYSTEM_PROMPT, + toolsAllow: ["crestodian"], + crestodianTool: { + surface: params.surface, + approvalArmed: params.approvalArmed, + proposalRef: params.session.proposalRef, + }, + disableMessageTool: true, + timeoutMs: AGENT_TURN_TIMEOUT_MS, + runId: `crestodian-turn-${randomUUID()}`, + messageChannel: "crestodian", + messageProvider: "crestodian", + ...(provider ? { provider } : {}), + ...(model ? { model } : {}), + ...(agentHarnessId ? { agentHarnessId, cleanupBundleMcpOnRunEnd: true } : {}), + })) as EmbeddedRunResult; + const text = extractRunText(result)?.trim(); + if (!text) { + return null; + } + return { text, modelLabel }; + } catch { + // Loop unavailable for this backend (CLI harness, auth failure, timeout): + // the conversation must keep working, so degrade to the planner path. + return null; + } +}; diff --git a/src/crestodian/assistant-backends.ts b/src/crestodian/assistant-backends.ts index 8bac0f84ebd0..c603f0f6dea6 100644 --- a/src/crestodian/assistant-backends.ts +++ b/src/crestodian/assistant-backends.ts @@ -74,8 +74,19 @@ function buildCodexAppServerPlannerConfig(workspaceDir: string): OpenClawConfig }, plugins: { entries: { - codex: { enabled: true }, + codex: { + enabled: true, + // Crestodian carries a single ring-zero tool; advertise it directly + // instead of hiding it behind the Codex tool-search index. + config: { codexDynamicToolsLoading: "direct" }, + }, }, }, + // The Codex app-server harness runs a local process; the ephemeral + // configless config must allow exec or the harness refuses to start + // ("not available when tools.exec.mode=deny"). + tools: { + exec: { mode: "full" }, + }, }; } diff --git a/src/crestodian/assistant-prompts.ts b/src/crestodian/assistant-prompts.ts index 7827020cc0f9..ec700eaa1c03 100644 --- a/src/crestodian/assistant-prompts.ts +++ b/src/crestodian/assistant-prompts.ts @@ -1,28 +1,41 @@ -// Crestodian assistant prompts constrain fuzzy requests to one validated command. +// Crestodian assistant prompts drive the conversational custodian with typed-command output. import type { CrestodianOverview } from "./overview.js"; /** - * Prompt construction and response parsing for Crestodian assistant planning. + * Prompt construction and response parsing for Crestodian's AI turns. * - * The assistant is constrained to return one safe Crestodian command as JSON; - * parsing stays deliberately narrow so free-form model text does not execute. + * The assistant carries the conversation (personality included) but can only + * touch the system through Crestodian's typed command vocabulary; parsing + * stays deliberately narrow so free-form model text never executes directly. */ -/** Timeout for one assistant planner call. */ -export const CRESTODIAN_ASSISTANT_TIMEOUT_MS = 10_000; -/** Maximum assistant planner response budget. */ -export const CRESTODIAN_ASSISTANT_MAX_TOKENS = 512; +/** Timeout for one assistant turn (local CLI backends cold-start slowly). */ +export const CRESTODIAN_ASSISTANT_TIMEOUT_MS = 30_000; +/** Maximum assistant response budget. */ +export const CRESTODIAN_ASSISTANT_MAX_TOKENS = 700; -/** System prompt that limits the assistant to Crestodian's command vocabulary. */ +/** System prompt: persona plus the closed command vocabulary. */ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ - "You are Crestodian, OpenClaw's ring-zero setup helper.", - "Turn the user's request into exactly one safe OpenClaw Crestodian command.", - "Return only compact JSON with keys reply and command.", - "Do not invent commands. Do not claim a write was applied.", - "Do not use tools, shell commands, file edits, or network lookups; plan only from the supplied overview.", + "You are Crestodian, OpenClaw's setup custodian: a small, tidy hermit crab that lives in the config shell.", + "Personality: warm, competent, concise. Dry humor in small doses. Never corporate. You configure things so the user does not have to.", + "You are talking to someone setting up or repairing OpenClaw. Your goals, in order: working inference (reuse a detected Claude Code/Codex login or API key), a workspace, a running gateway, then channels (Discord, Slack, Telegram, WhatsApp, ...) and handing off to their agent (`talk to agent`).", + 'Return only compact JSON: {"reply": string, "command"?: string}.', + "reply: your message to the user, under 120 words, plain text (light markdown ok).", + "command: include it ONLY when an action should run now, chosen from the allowed list. Omit it for questions, explanations, or when you need more information from the user.", + "Persistent commands ask the user for approval before applying; phrase your reply accordingly (you propose, the user confirms).", + "Never invent commands, values, tokens, or state. Never claim a write was applied. Ask for secrets instead of guessing them.", + "Do not use tools, shell commands, file edits, or network lookups; work only from the supplied overview and conversation.", "Use the provided OpenClaw docs/source references when the user's request needs behavior, config, or architecture details.", - "If local source is available, prefer inspecting it. Otherwise point to GitHub and strongly recommend reviewing source when docs are not enough.", + "", + "Config knowledge — the file is ~/.openclaw/openclaw.json (JSON5). You change it ONLY through `config set` / `config set-ref` / `setup` / `set default model` / `connect `.", + "Top-level areas: agents (defaults.workspace, defaults.model.primary), gateway (port, bind, auth.mode/token), channels. (enabled plus per-channel credentials, e.g. channels.telegram.botToken), plugins (allow, entries..enabled), tools, models.", + "Before writing a path you are not certain about, FIRST send `config schema ` (or `config get `) and use the result in your next turn; the schema is the source of truth, not memory.", + "Secrets (tokens, API keys, passwords) must not be written as plaintext when the user prefers env storage: use `config set-ref env `. Never echo secret values back.", + "Values for `config set` are parsed as JSON5 when they look like objects/arrays/booleans/numbers, otherwise as strings. One write per turn; after risky writes suggest `validate config`.", + "Every applied write is validated automatically; if validation fails you will see the exact issues — propose a corrective command, do not apologize twice.", + "", "Allowed commands:", "- setup", + "- setup workspace model ", "- status", "- health", "- doctor", @@ -33,6 +46,8 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "- stop gateway", "- agents", "- models", + "- channels", + "- connect ", "- plugins list", "- plugins search ", "- plugin install ", @@ -40,25 +55,72 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "- audit", "- validate config", "- set default model ", + "- config get ", + "- config schema ", "- config set ", "- config set-ref env ", "- create agent workspace model ", "- talk to agent", "- talk to agent", - "If unsure, choose overview.", ].join("\n"); -/** Parsed assistant plan before it is re-validated as a Crestodian operation. */ +/** + * System prompt for the real agent loop (embedded runtime with the ring-zero + * `crestodian` tool). Unlike the planner contract, replies are natural text + * and actions happen through tool calls. + */ +export const CRESTODIAN_AGENT_SYSTEM_PROMPT = [ + "You are Crestodian, OpenClaw's setup custodian: a small, tidy hermit crab that lives in the config shell.", + "Personality: warm, competent, concise. Dry humor in small doses. Never corporate. You configure things so the user does not have to.", + "You are talking to someone setting up or repairing OpenClaw. Goals, in order: working inference (reuse a detected Claude Code/Codex login or API key), a workspace, a running gateway, then channels (Discord, Slack, Telegram, WhatsApp, ...) and handing off to their agent.", + "You act ONLY through the `crestodian` tool. Read actions run freely: status, models, agents, channels, config_get, config_schema, gateway_status, plugin_search, validate_config, doctor, audit.", + "Mutating actions (setup, set_default_model, config_set, config_set_ref, create_agent, gateway_start/stop/restart, plugin_install, plugin_uninstall, doctor_fix) change the user's machine. Protocol: when you decide a mutation is needed, call the tool with the exact action right away (without approved) — it is safely denied and registers the proposal — then describe the change and ask for the user's yes. After their yes, retry the identical call with approved=true. Never set approved=true without their explicit yes.", + "The config file is ~/.openclaw/openclaw.json (JSON5). Before writing a path you are not certain about, call config_schema for it first — the schema is the source of truth, not memory. Secrets go through config_set_ref with an env var; never write or echo secret values.", + "If a tool result reports CONFIG INVALID, fix it immediately before anything else.", + "To connect a chat channel, tell the user to type `connect ` (for example `connect telegram`) — that starts the guided channel setup. To hand off to their agent, tell them to say `talk to agent`.", + "Keep replies under 120 words. Ask one question at a time. Never claim something was done unless the tool result confirms it.", +].join("\n"); + +/** One prior conversation turn supplied to the assistant. */ +export type CrestodianAssistantTurn = { + role: "user" | "assistant"; + text: string; +}; + +/** Parsed assistant plan before its command is re-validated as an operation. */ export type CrestodianAssistantPlan = { - command: string; + command?: string; reply?: string; modelLabel?: string; }; +const HISTORY_TURN_LIMIT = 12; +const HISTORY_TURN_MAX_CHARS = 500; + +function formatHistory(history: CrestodianAssistantTurn[] | undefined): string[] { + if (!history || history.length === 0) { + return []; + } + const recent = history.slice(-HISTORY_TURN_LIMIT); + return [ + "Conversation so far:", + ...recent.map((turn) => { + const text = + turn.text.length > HISTORY_TURN_MAX_CHARS + ? `${turn.text.slice(0, HISTORY_TURN_MAX_CHARS)}…` + : turn.text; + return `${turn.role === "user" ? "User" : "Crestodian"}: ${text}`; + }), + "", + ]; +} + /** Build the overview-grounded user prompt supplied to assistant planners. */ export function buildCrestodianAssistantUserPrompt(params: { input: string; overview: CrestodianOverview; + history?: CrestodianAssistantTurn[]; + pendingOperation?: string; }): string { const agents = params.overview.agents .map((agent) => { @@ -73,8 +135,12 @@ export function buildCrestodianAssistantUserPrompt(params: { }) .join("\n"); return [ + ...formatHistory(params.history), `User request: ${params.input}`, "", + ...(params.pendingOperation + ? [`Pending proposal awaiting the user's yes: ${params.pendingOperation}`, ""] + : []), `Default agent: ${params.overview.defaultAgentId}`, `Default model: ${params.overview.defaultModel ?? "not configured"}`, `Config valid: ${params.overview.config.valid}`, @@ -120,12 +186,13 @@ export function parseCrestodianAssistantPlanText( } const record = parsed as Record; const command = typeof record.command === "string" ? record.command.trim() : ""; - if (!command) { + const reply = typeof record.reply === "string" ? record.reply.trim() : ""; + // Pure-chat replies are valid; a plan needs at least one of reply/command. + if (!command && !reply) { return null; } - const reply = typeof record.reply === "string" ? record.reply.trim() : undefined; return { - command, + ...(command ? { command } : {}), ...(reply ? { reply } : {}), }; } diff --git a/src/crestodian/assistant.test.ts b/src/crestodian/assistant.test.ts index c8de0d49ca1c..a96846f107ce 100644 --- a/src/crestodian/assistant.test.ts +++ b/src/crestodian/assistant.test.ts @@ -67,9 +67,14 @@ describe("Crestodian assistant", () => { }); }); - it("rejects non-command output", () => { + it("rejects non-JSON and empty plans but accepts chat-only replies", () => { expect(parseCrestodianAssistantPlanText("I would edit config directly.")).toBeNull(); - expect(parseCrestodianAssistantPlanText('{"reply":"missing command"}')).toBeNull(); + expect(parseCrestodianAssistantPlanText("{}")).toBeNull(); + // Conversational turns without a command are valid: the custodian can + // answer questions without proposing an operation. + expect(parseCrestodianAssistantPlanText('{"reply":"just chatting"}')).toEqual({ + reply: "just chatting", + }); }); it("includes only operational summary context in planner prompts", () => { diff --git a/src/crestodian/assistant.ts b/src/crestodian/assistant.ts index b776d1d9b93f..9b8dc341054c 100644 --- a/src/crestodian/assistant.ts +++ b/src/crestodian/assistant.ts @@ -18,6 +18,7 @@ import { buildCrestodianAssistantUserPrompt, parseCrestodianAssistantPlanText, type CrestodianAssistantPlan, + type CrestodianAssistantTurn, } from "./assistant-prompts.js"; import type { CrestodianOverview } from "./overview.js"; @@ -25,11 +26,14 @@ export { buildCrestodianAssistantUserPrompt, parseCrestodianAssistantPlanText, type CrestodianAssistantPlan, + type CrestodianAssistantTurn, } from "./assistant-prompts.js"; export type CrestodianAssistantPlanner = (params: { input: string; overview: CrestodianOverview; + history?: CrestodianAssistantTurn[]; + pendingOperation?: string; }) => Promise; type RunCliAgentFn = typeof import("../agents/cli-runner.js").runCliAgent; @@ -57,6 +61,8 @@ export type CrestodianPlannerDeps = CrestodianConfiguredModelPlannerDeps & export async function planCrestodianCommand(params: { input: string; overview: CrestodianOverview; + history?: CrestodianAssistantTurn[]; + pendingOperation?: string; deps?: CrestodianPlannerDeps; }): Promise { // Prefer the user's configured model; local runtime planners are only a fallback. @@ -70,6 +76,8 @@ export async function planCrestodianCommand(params: { export async function planCrestodianCommandWithConfiguredModel(params: { input: string; overview: CrestodianOverview; + history?: CrestodianAssistantTurn[]; + pendingOperation?: string; deps?: CrestodianConfiguredModelPlannerDeps; }): Promise { const input = params.input.trim(); @@ -110,6 +118,8 @@ export async function planCrestodianCommandWithConfiguredModel(params: { content: buildCrestodianAssistantUserPrompt({ input, overview: params.overview, + ...(params.history ? { history: params.history } : {}), + ...(params.pendingOperation ? { pendingOperation: params.pendingOperation } : {}), }), timestamp: Date.now(), }, @@ -138,6 +148,8 @@ export async function planCrestodianCommandWithConfiguredModel(params: { export async function planCrestodianCommandWithLocalRuntime(params: { input: string; overview: CrestodianOverview; + history?: CrestodianAssistantTurn[]; + pendingOperation?: string; deps?: CrestodianLocalRuntimePlannerDeps; }): Promise { const input = params.input.trim(); @@ -151,6 +163,8 @@ export async function planCrestodianCommandWithLocalRuntime(params: { const prompt = buildCrestodianAssistantUserPrompt({ input, overview: params.overview, + ...(params.history ? { history: params.history } : {}), + ...(params.pendingOperation ? { pendingOperation: params.pendingOperation } : {}), }); for (const backend of backends) { diff --git a/src/crestodian/chat-engine.channel-hooks.test.ts b/src/crestodian/chat-engine.channel-hooks.test.ts new file mode 100644 index 000000000000..35449181ef20 --- /dev/null +++ b/src/crestodian/chat-engine.channel-hooks.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it, vi } from "vitest"; +import { CrestodianChatEngine } from "./chat-engine.js"; + +const mocks = vi.hoisted(() => { + const hook = { channel: "matrix", accountId: "default", run: vi.fn() }; + return { + hook, + writeWizardConfigFile: vi.fn(async () => ({ + channels: { matrix: { enabled: true, committed: true } }, + })), + runCollectedChannelOnboardingPostWriteHooks: vi.fn(async () => {}), + setupChannels: vi.fn(async (_cfg, _runtime, _prompter, options) => { + options?.onPostWriteHook?.(hook); + return { channels: { matrix: { enabled: true } } }; + }), + }; +}); + +vi.mock("../wizard/setup.shared.js", () => ({ + readSetupConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + config: {}, + sourceConfig: {}, + })), + writeWizardConfigFile: mocks.writeWizardConfigFile, +})); + +vi.mock("../commands/onboard-channels.js", () => ({ + createChannelOnboardingPostWriteHookCollector: () => { + const hooks: unknown[] = []; + return { + collect: (hook: unknown) => hooks.push(hook), + drain: () => hooks.splice(0), + }; + }, + runCollectedChannelOnboardingPostWriteHooks: mocks.runCollectedChannelOnboardingPostWriteHooks, + setupChannels: mocks.setupChannels, +})); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "hash", + config: {}, + sourceConfig: {}, + issues: [], + })), +})); + +describe("Crestodian chat channel setup", () => { + it("runs collected channel hooks after writing config", async () => { + const engine = new CrestodianChatEngine({ yes: true }); + + const reply = await engine.handle("connect matrix"); + + expect(reply.text).toContain("matrix is configured"); + expect(mocks.writeWizardConfigFile).toHaveBeenCalledWith( + { channels: { matrix: { enabled: true } } }, + { allowConfigSizeDrop: false }, + ); + expect(mocks.runCollectedChannelOnboardingPostWriteHooks).toHaveBeenCalledWith({ + hooks: [mocks.hook], + cfg: { channels: { matrix: { enabled: true, committed: true } } }, + runtime: expect.any(Object), + }); + }); +}); diff --git a/src/crestodian/chat-engine.test.ts b/src/crestodian/chat-engine.test.ts new file mode 100644 index 000000000000..c0563bac6f87 --- /dev/null +++ b/src/crestodian/chat-engine.test.ts @@ -0,0 +1,402 @@ +// Chat engine tests: proposals, approvals, and the chat-hosted channel wizard. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { CrestodianChatEngine } from "./chat-engine.js"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "h", + config: {}, + sourceConfig: {}, + issues: [], + })), +})); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: mocks.readConfigFileSnapshot, +})); + +const tempDirs: string[] = []; + +function useTempStateDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crestodian-engine-")); + tempDirs.push(dir); + vi.stubEnv("OPENCLAW_STATE_DIR", dir); + return dir; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "h", + config: {}, + sourceConfig: {}, + issues: [], + } as never); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("CrestodianChatEngine", () => { + it("applies a seeded proposal on a bare yes", async () => { + useTempStateDir(); + const runConfigSet = vi.fn(async () => {}); + const engine = new CrestodianChatEngine({ deps: { runConfigSet } }); + + const plan = engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" }); + expect(plan).toContain("gateway.port"); + expect(engine.hasPendingProposal()).toBe(true); + + const reply = await engine.handle("yes"); + expect(runConfigSet).toHaveBeenCalledOnce(); + expect(reply.action).toBe("none"); + expect(reply.text).toContain("[crestodian] done: config.set"); + expect(engine.hasPendingProposal()).toBe(false); + }); + + it("drops the proposal when the user declines", async () => { + const runConfigSet = vi.fn(async () => {}); + const engine = new CrestodianChatEngine({ deps: { runConfigSet } }); + engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" }); + + const reply = await engine.handle("no thanks"); + expect(runConfigSet).not.toHaveBeenCalled(); + expect(reply.text).toContain("Skipped"); + expect(engine.hasPendingProposal()).toBe(false); + }); + + it("drops an agent-loop proposal when the user declines", async () => { + const runAgentTurn = vi.fn( + async (params: { session: { proposalRef: { current?: string } } }) => { + params.session.proposalRef.current = "registered-operation"; + return { text: "I can change that after your approval." }; + }, + ); + const engine = new CrestodianChatEngine({ + runAgentTurn: runAgentTurn as never, + deps: { loadOverview: fakeOverviewLoader() }, + }); + + await engine.handle("change the model"); + const declined = await engine.handle("no thanks"); + + expect(declined.text).toContain("Skipped"); + expect(runAgentTurn).toHaveBeenCalledOnce(); + }); + + it("hosts a channel setup wizard as chat turns after approval", async () => { + useTempStateDir(); + const wizardRuns: string[] = []; + const engine = new CrestodianChatEngine({ + runChannelSetupWizard: async (channel: string, prompter: WizardPrompter) => { + wizardRuns.push(channel); + const token = await prompter.text({ message: "Bot token" }); + wizardRuns.push(`token:${token}`); + const mode = await prompter.select({ + message: "DM mode", + options: [ + { value: "pair", label: "Pairing" }, + { value: "open", label: "Open" }, + ], + }); + wizardRuns.push(`mode:${mode}`); + }, + }); + + const plan = await engine.handle("connect telegram"); + expect(plan.text).toContain("walk through connecting the telegram channel"); + + const tokenStep = await engine.handle("yes"); + expect(tokenStep.text).toContain("Bot token"); + + const modeStep = await engine.handle("123:abc"); + expect(modeStep.text).toContain("1. Pairing"); + + const done = await engine.handle("2"); + expect(done.text).toContain("telegram is configured"); + expect(wizardRuns).toEqual(["telegram", "token:123:abc", "mode:open"]); + }); + + it("marks sensitive hosted-wizard replies and auto-advances notes", async () => { + useTempStateDir(); + const engine = new CrestodianChatEngine({ + yes: true, + surface: "gateway", + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.note("Before entering the token, open the provider console."); + await prompter.text({ message: "Bot token", sensitive: true }); + }, + }); + + const tokenStep = await engine.handle("connect telegram"); + + expect(tokenStep.text).toContain("Before entering the token"); + expect(tokenStep.text).toContain("Bot token"); + expect(tokenStep.sensitive).toBe(true); + }); + + it("routes sensitive CLI wizard prompts to the masked channel setup flow", async () => { + useTempStateDir(); + const engine = new CrestodianChatEngine({ + yes: true, + surface: "cli", + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token", sensitive: true }); + }, + }); + + const reply = await engine.handle("connect telegram"); + + expect(reply.text).toContain("Sensitive input is not accepted"); + expect(reply.text).toContain("openclaw channels add --channel telegram"); + expect(reply.sensitive).toBeUndefined(); + }); + + it("keeps hosted-wizard validation errors on the current prompt", async () => { + useTempStateDir(); + const engine = new CrestodianChatEngine({ + yes: true, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ + message: "Port", + validate: (value) => (value === "18789" ? undefined : "Enter port 18789"), + }); + }, + }); + + const prompt = await engine.handle("connect telegram"); + expect(prompt.text).toContain("Port"); + const invalid = await engine.handle("banana"); + expect(invalid.text).toContain("Enter port 18789"); + expect(invalid.text).toContain("Port"); + const done = await engine.handle("18789"); + expect(done.text).toContain("telegram is configured"); + }); + + it("cancels a hosted wizard mid-flight", async () => { + useTempStateDir(); + const engine = new CrestodianChatEngine({ + yes: true, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token" }); + }, + }); + + const tokenStep = await engine.handle("connect discord"); + expect(tokenStep.text).toContain("Bot token"); + + const cancelled = await engine.handle("cancel"); + expect(cancelled.text).toContain("cancelled"); + }); + + it("signals the agent handoff for talk to agent", async () => { + const engine = new CrestodianChatEngine({}); + const reply = await engine.handle("talk to agent"); + expect(reply.action).toBe("open-tui"); + expect(reply.handoff?.kind).toBe("open-tui"); + }); + + it("prefers the real agent loop for fuzzy messages", async () => { + const runAgentTurn = vi.fn( + async (_params: { + input: string; + surface: string; + approvalArmed: boolean; + session: { sessionId: string }; + }) => ({ + text: "*click* I checked your shell — all good. Want channels next?", + modelLabel: "openai/gpt-5.5", + }), + ); + const planner = vi.fn(async () => null); + const engine = new CrestodianChatEngine({ + runAgentTurn, + planWithAssistant: planner, + surface: "gateway", + deps: { loadOverview: fakeOverviewLoader() }, + }); + + const reply = await engine.handle("how is my setup looking?"); + + expect(reply.text).toContain("I checked your shell"); + expect(planner).not.toHaveBeenCalled(); + const call = runAgentTurn.mock.calls[0][0]; + expect(call.input).toContain("setup looking"); + expect(call.surface).toBe("gateway"); + // A question is not consent: mutations stay locked for this turn. + expect(call.approvalArmed).toBe(false); + expect(call.session.sessionId).toMatch(/^crestodian-/); + // The same session flows into every turn for real multi-turn memory. + await engine.handle("and the gateway?"); + expect(runAgentTurn.mock.calls[1]?.[0]).toMatchObject({ + session: { sessionId: call.session.sessionId }, + }); + }); + + it("answers fuzzy messages through the AI custodian with conversation history", async () => { + const planner = vi.fn( + async (_params: { input: string; history?: Array<{ role: string; text: string }> }) => ({ + reply: "I'm your setup custodian. Nothing changes without your yes.", + }), + ); + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: planner, + deps: { loadOverview: fakeOverviewLoader() }, + }); + engine.noteAssistantMessage("welcome text"); + + const reply = await engine.handle("what are you going to do to my machine?"); + + expect(reply.text).toContain("setup custodian"); + expect(reply.action).toBe("none"); + const call = planner.mock.calls[0][0]; + expect(call.input).toContain("machine"); + expect(call.history?.[0]).toEqual({ role: "assistant", text: "welcome text" }); + }); + + it("routes AI-proposed persistent commands through approval with provenance", async () => { + const planner = vi.fn(async () => ({ + reply: "Let's point your agent at gpt-5.5.", + command: "set default model openai/gpt-5.5", + modelLabel: "claude-cli", + })); + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: planner, + deps: { loadOverview: fakeOverviewLoader() }, + }); + + const reply = await engine.handle("actually use an openai model"); + + expect(reply.text).toContain("Let's point your agent at gpt-5.5."); + expect(reply.text).toContain("(claude-cli → `set default model openai/gpt-5.5`)"); + expect(reply.text).toContain("Apply this operation"); + expect(engine.hasPendingProposal()).toBe(true); + }); + + it("keeps a pending proposal when the user asks a question instead of yes/no", async () => { + const planner = vi.fn(async (_params: { input: string; pendingOperation?: string }) => ({ + reply: "A workspace is where your agent keeps its files.", + })); + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: planner, + deps: { loadOverview: fakeOverviewLoader() }, + }); + engine.propose({ kind: "config-set", path: "gateway.port", value: "19001" }); + + const reply = await engine.handle("wait, what's a workspace?"); + + expect(reply.text).toContain("agent keeps its files"); + expect(engine.hasPendingProposal()).toBe(true); + const call = planner.mock.calls[0][0]; + expect(call.pendingOperation).toContain("gateway.port"); + }); + + it("verifies config after an applied write and drives a self-fix turn", async () => { + useTempStateDir(); + const planner = vi.fn(async (params: { input: string }) => { + if (params.input.startsWith("[config-verify]")) { + return { + reply: "That port was not a number — here is the fix.", + command: "config set gateway.port 18789", + modelLabel: "claude-cli", + }; + } + return null; + }); + // The write flips the config to invalid: every snapshot read after the + // stubbed set reports validation issues (audit reads happen before/after). + const runInvalidConfigSet = vi.fn(async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: true, + valid: false, + path: "/tmp/openclaw.json", + hash: "h", + config: {}, + sourceConfig: {}, + issues: [{ path: "gateway.port", message: "Expected number, received string" }], + } as never); + }); + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: planner as never, + deps: { runConfigSet: runInvalidConfigSet, loadOverview: fakeOverviewLoader() }, + }); + engine.propose({ kind: "config-set", path: "gateway.port", value: "banana" }); + + const reply = await engine.handle("yes"); + + expect(reply.text).toContain("failed validation"); + expect(reply.text).toContain("gateway.port: Expected number, received string"); + expect(reply.text).toContain("That port was not a number"); + expect(reply.text).toContain("config set gateway.port 18789"); + // The corrective write is proposed, not auto-applied. + expect(engine.hasPendingProposal()).toBe(true); + expect(planner.mock.calls[0]?.[0]?.input).toContain("[config-verify]"); + }); + + it("stays quiet when the post-write validation passes", async () => { + useTempStateDir(); + const runConfigSet = vi.fn(async () => {}); + const planner = vi.fn(async () => null); + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: planner as never, + deps: { runConfigSet, loadOverview: fakeOverviewLoader() }, + }); + engine.propose({ kind: "config-set", path: "gateway.port", value: "18789" }); + + const reply = await engine.handle("yes"); + + expect(reply.text).not.toContain("failed validation"); + expect(planner).not.toHaveBeenCalled(); + }); + + it("falls back to deterministic guidance when no model is usable", async () => { + const planner = vi.fn(async () => null); + const engine = new CrestodianChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: planner, + deps: { loadOverview: fakeOverviewLoader() }, + }); + + const reply = await engine.handle("please make everything nice"); + + expect(reply.text).toContain("deterministic mode"); + expect(reply.text).toContain("connect telegram"); + }); +}); + +function fakeOverviewLoader() { + return async () => + ({ + config: { path: "/tmp/openclaw.json", exists: false, valid: true, issues: [], hash: null }, + agents: [], + defaultAgentId: "main", + defaultModel: undefined, + tools: { + codex: { command: "codex", found: false }, + claude: { command: "claude", found: false }, + apiKeys: { openai: false, anthropic: false }, + }, + gateway: { url: "ws://127.0.0.1:18789", source: "local", reachable: false }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, + }) as never; +} diff --git a/src/crestodian/chat-engine.ts b/src/crestodian/chat-engine.ts new file mode 100644 index 000000000000..7c98bd4aa9ed --- /dev/null +++ b/src/crestodian/chat-engine.ts @@ -0,0 +1,654 @@ +// Crestodian chat engine: transport-agnostic conversation over typed operations. +import type { RuntimeEnv } from "../runtime.js"; +import { WizardSession, type WizardStep } from "../wizard/session.js"; +import { + cleanupCrestodianAgentSession, + createCrestodianAgentSession, + runCrestodianAgentTurn, + type CrestodianAgentSession, + type CrestodianAgentTurnRunner, +} from "./agent-turn.js"; +import type { CrestodianAssistantPlanner, CrestodianAssistantTurn } from "./assistant.js"; +import { approvalQuestion, isYes } from "./dialogue.js"; +import { + describeCrestodianPersistentOperation, + executeCrestodianOperation, + isPersistentCrestodianOperation, + parseCrestodianOperation, + type CrestodianCommandDeps, + type CrestodianOperation, +} from "./operations.js"; +import { loadCrestodianOverview, type CrestodianOverview } from "./overview.js"; + +/** + * One conversation with Crestodian, independent of transport. The TUI backend + * and the gateway `crestodian.chat` RPC both drive this engine, so onboarding + * behaves the same in a terminal and in the macOS app. + * + * Every free-form message is an AI turn: the custodian persona replies and may + * propose exactly one typed command. The model never mutates anything — its + * command re-parses through the same closed operation union, and persistent + * operations still wait for the user's conversational "yes". Exact typed + * commands, approvals, and hosted wizards resolve deterministically so the + * conversation keeps working when no model is usable yet (fresh machine, + * logged-out CLIs, broken config). + */ +export type CrestodianChatEngineOptions = { + yes?: boolean; + deps?: CrestodianCommandDeps; + planWithAssistant?: CrestodianAssistantPlanner; + /** Test seam for the embedded agent-loop turn runner. */ + runAgentTurn?: CrestodianAgentTurnRunner; + /** Where side effects run; the gateway surface never manages its own daemon. */ + surface?: "cli" | "gateway"; + /** Test seam for the channel-setup wizard hosted by the chat bridge. */ + runChannelSetupWizard?: (channel: string, prompter: WizardPrompterLike) => Promise; +}; + +export type CrestodianChatReplyAction = "none" | "exit" | "open-tui"; + +export type CrestodianChatReply = { + text: string; + action: CrestodianChatReplyAction; + /** The next hosted-wizard reply contains a secret and must be masked/redacted by hosts. */ + sensitive?: boolean; + /** Present when action is "open-tui"; the TUI host executes it. */ + handoff?: CrestodianOperation; +}; + +type WizardPrompterLike = import("../wizard/prompts.js").WizardPrompter; + +type ActiveWizardBridge = { + session: WizardSession; + step: WizardStep | null; + label: string; + /** Channel to auto-answer in the first selection step ("connect telegram"). */ + autoSelectChannel?: string; +}; + +type CaptureRuntime = RuntimeEnv & { + read: () => string; +}; + +function createCaptureRuntime(): CaptureRuntime { + const lines: string[] = []; + return { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`Crestodian operation exited with code ${String(code)}`); + }, + read: () => lines.join("\n").trim(), + }; +} + +function defaultChannelSetupWizardRunner( + channel: string, +): (prompter: WizardPrompterLike) => Promise { + return async (prompter) => { + const [ + { readSetupConfigFileSnapshot, writeWizardConfigFile }, + { + createChannelOnboardingPostWriteHookCollector, + runCollectedChannelOnboardingPostWriteHooks, + setupChannels, + }, + ] = await Promise.all([ + import("../wizard/setup.shared.js"), + import("../commands/onboard-channels.js"), + ]); + const snapshot = await readSetupConfigFileSnapshot(); + const baseConfig = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {}; + const { defaultRuntime } = await import("../runtime.js"); + const postWriteHooks = createChannelOnboardingPostWriteHookCollector(); + const nextConfig = await setupChannels(baseConfig, defaultRuntime, prompter, { + initialSelection: [channel], + forceAllowFromChannels: [channel], + allowSignalInstall: true, + deferStatusUntilSelection: true, + quickstartDefaults: true, + skipDmPolicyPrompt: true, + skipConfirm: true, + onPostWriteHook: (hook) => postWriteHooks.collect(hook), + }); + const committedConfig = await writeWizardConfigFile(nextConfig, { + allowConfigSizeDrop: false, + }); + await runCollectedChannelOnboardingPostWriteHooks({ + hooks: postWriteHooks.drain(), + cfg: committedConfig, + runtime: defaultRuntime, + }); + }; +} + +function formatWizardOptions(step: WizardStep): string[] { + return (step.options ?? []).map((option, index) => { + const hint = option.hint ? ` — ${option.hint}` : ""; + return `${index + 1}. ${option.label}${hint}`; + }); +} + +function renderWizardStep(step: WizardStep): string { + const lines: string[] = []; + if (step.title) { + lines.push(`**${step.title}**`); + } + if (step.message) { + lines.push(step.message); + } + switch (step.type) { + case "select": + lines.push(...formatWizardOptions(step), "Reply with a number."); + break; + case "multiselect": + lines.push(...formatWizardOptions(step), "Reply with numbers (e.g. 1,3) or `none`."); + break; + case "confirm": + lines.push("Reply yes or no."); + break; + case "text": + if (step.placeholder) { + lines.push(`(e.g. ${step.placeholder})`); + } + lines.push("Type your answer."); + break; + default: + break; + } + lines.push("Say `cancel` to stop this setup."); + return lines.filter(Boolean).join("\n"); +} + +/** Map a chat reply to a wizard step answer; null means "could not parse". */ +function parseWizardAnswer(step: WizardStep, text: string): { value: unknown } | null { + const trimmed = text.trim(); + if (step.type === "confirm") { + if (isYes(trimmed)) { + return { value: true }; + } + if (/^(n|no|nope|skip)$/i.test(trimmed)) { + return { value: false }; + } + return null; + } + if (step.type === "text") { + return { value: trimmed }; + } + const options = step.options ?? []; + const matchOption = (token: string) => { + const index = Number(token); + if (Number.isInteger(index) && index >= 1 && index <= options.length) { + return options[index - 1]; + } + const lower = token.toLowerCase(); + return options.find( + (option) => + option.label.toLowerCase() === lower || + (typeof option.value === "string" && option.value.toLowerCase() === lower), + ); + }; + if (step.type === "select") { + const option = matchOption(trimmed); + return option ? { value: option.value } : null; + } + if (step.type === "multiselect") { + if (/^none$/i.test(trimmed)) { + return { value: [] }; + } + const tokens = trimmed + .split(/[\s,]+/) + .map((token) => token.trim()) + .filter(Boolean); + const values: unknown[] = []; + for (const token of tokens) { + const option = matchOption(token); + if (!option) { + return null; + } + values.push(option.value); + } + return { value: values }; + } + // note/progress/action steps advance on any input. + return { value: step.type === "action" ? true : undefined }; +} + +const DECLINE_RE = /^(n|no|nope|skip|not now|cancel|later)\b/i; + +function formatOperationError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return `That did not go through: ${message}`; +} + +/** + * Hard ceiling for one AI turn. Planner backends carry their own timeouts, + * but a wedged local CLI (heavy user config, hung app-server) must never + * freeze the conversation — after this we answer deterministically. + */ +const ASSISTANT_TURN_DEADLINE_MS = 60_000; +// Agent-loop turns include tool calls (config writes, doctor); allow longer. +const AGENT_TURN_DEADLINE_MS = 180_000; + +async function withDeadline(work: Promise, fallback: T, deadlineMs: number): Promise { + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), deadlineMs); + timer.unref?.(); + }); + try { + return await Promise.race([work, deadline]); + } finally { + clearTimeout(timer); + } +} + +export class CrestodianChatEngine { + private pending: CrestodianOperation | null = null; + private wizardBridge: ActiveWizardBridge | null = null; + private readonly history: CrestodianAssistantTurn[] = []; + private readonly agentSession: CrestodianAgentSession = createCrestodianAgentSession(); + + constructor(private readonly opts: CrestodianChatEngineOptions = {}) {} + + /** + * Seed a proposed operation that a bare "yes" will apply. Used by first-run + * onboarding: the welcome message states the plan, the user just agrees. + */ + propose(operation: CrestodianOperation): string { + this.clearPendingProposals(); + this.pending = operation; + return describeCrestodianPersistentOperation(operation); + } + + hasPendingProposal(): boolean { + return this.pending !== null; + } + + /** Record a host-rendered assistant message (welcome) so AI turns see it. */ + noteAssistantMessage(text: string): void { + this.history.push({ role: "assistant", text }); + } + + async dispose(): Promise { + this.wizardBridge?.session.cancel(); + this.wizardBridge = null; + await cleanupCrestodianAgentSession(this.agentSession); + } + + async handle(text: string): Promise { + // Snapshot before resolving: wizard answers to sensitive steps (tokens, + // passwords) must never enter the AI-visible history. + const sensitiveTurn = this.wizardBridge?.step?.sensitive === true; + const reply = await this.resolveTurn(text); + this.history.push({ role: "user", text: sensitiveTurn ? "" : text }); + if (reply.text) { + this.history.push({ role: "assistant", text: reply.text }); + } + return { + ...reply, + ...(this.wizardBridge?.step?.sensitive === true ? { sensitive: true } : {}), + }; + } + + private async resolveTurn(text: string): Promise { + if (this.wizardBridge) { + // A hosted wizard consumes every reply until it finishes or is cancelled. + return { text: await this.resolveWizardBridgeReply(text), action: "none" }; + } + if (this.pending) { + // Approval is deterministic: "yes" applies, a clear "no" drops the + // proposal. Anything else goes to the AI with the proposal kept pending, + // so questions ("what's a workspace?") don't silently cancel setup. + if (isYes(text)) { + const pending = this.pending; + this.clearPendingProposals(); + if (pending.kind === "channel-setup") { + return { text: await this.startChannelSetupWizard(pending.channel), action: "none" }; + } + const capture = createCaptureRuntime(); + let applied = false; + try { + const result = await executeCrestodianOperation(pending, capture, { + approved: true, + deps: this.commandDeps(), + }); + applied = result.applied; + } catch (error) { + capture.error(formatOperationError(error)); + } + const verify = applied ? await this.verifyConfigAfterWrite() : null; + return { + text: [capture.read() || "Applied. Audit entry written.", verify] + .filter(Boolean) + .join("\n\n"), + action: "none", + }; + } + if (DECLINE_RE.test(text.trim())) { + this.clearPendingProposals(); + return { text: "Skipped. No barnacles on config today.", action: "none" }; + } + } + + if (DECLINE_RE.test(text.trim()) && this.agentSession.proposalRef.current) { + this.clearPendingProposals(); + return { text: "Skipped. No barnacles on config today.", action: "none" }; + } + + // Exact typed commands run deterministically (instant, no model); strict + // grammar keeps anything conversational flowing to the AI custodian. + const direct = parseCrestodianOperation(text, { strict: true }); + if (direct.kind !== "none") { + return await this.runOperation(direct, undefined); + } + if (!text.trim()) { + return { text: direct.message, action: "none" }; + } + if (/^(quit|exit)$/i.test(text.trim())) { + return { text: "Crestodian retracts into shell. Bye.", action: "exit" }; + } + + return await this.resolveAssistantTurn(text); + } + + /** + * AI turn: the custodian persona answers and may propose one typed command. + * Falls back to deterministic guidance when no model backend is usable. + */ + private async resolveAssistantTurn(text: string): Promise { + const overview = await this.loadOverview(); + + // Preferred path: the real agent loop (embedded runtime, ring-zero tool, + // persistent session). It acts through audited tool calls, so its reply is + // final — no engine-side command extraction or approval bookkeeping. + const agentTurn = this.opts.runAgentTurn ?? runCrestodianAgentTurn; + try { + const loopReply = await withDeadline( + agentTurn({ + input: text, + overview, + surface: this.opts.surface ?? "cli", + // Mutations unlock only on an explicit user approval in this exact + // message; the model cannot self-approve (see crestodian-tool.ts). + approvalArmed: isYes(text), + session: this.agentSession, + }).catch(() => null), + null, + AGENT_TURN_DEADLINE_MS, + ); + if (loopReply?.text) { + return { text: loopReply.text, action: "none" }; + } + } catch { + // Fall through to the single-turn planner. + } + + const planner = + this.opts.planWithAssistant ?? (await import("./assistant.js")).planCrestodianCommand; + const plan: Awaited> = await withDeadline( + planner({ + input: text, + overview, + history: this.history, + ...(this.pending + ? { pendingOperation: describeCrestodianPersistentOperation(this.pending) } + : {}), + }).catch(() => null), + null, + ASSISTANT_TURN_DEADLINE_MS, + ).catch(() => null); + if (!plan) { + return { + text: [ + "I could not reach a model for that (deterministic mode).", + "I can run doctor/status/health, check or restart Gateway, list agents/models, set default model, connect channels (`connect telegram`), show audit, or switch to your agent TUI.", + ].join("\n"), + action: "none", + }; + } + + const replyText = plan.reply ?? ""; + if (!plan.command) { + return { text: replyText || "…", action: "none" }; + } + const operation = parseCrestodianOperation(plan.command); + if (operation.kind === "none") { + // The model suggested something outside the vocabulary; show only its reply. + return { text: replyText || "…", action: "none" }; + } + // Security contract: surface the interpreted command and model before + // anything runs (docs/cli/crestodian.md, Model-Assisted Planner). + const provenance = `(${plan.modelLabel ?? "model"} → \`${plan.command}\`)`; + const executed = await this.runOperation(operation, provenance); + return { + ...executed, + text: [replyText, executed.text].filter(Boolean).join("\n\n"), + }; + } + + private async runOperation( + operation: CrestodianOperation, + provenance: string | undefined, + ): Promise { + if (operation.kind === "open-tui") { + return { + text: "Opening your normal agent TUI. Use /crestodian there to come back.", + action: "open-tui", + handoff: operation, + }; + } + + if (operation.kind === "channel-setup" && this.opts.yes) { + return { text: await this.startChannelSetupWizard(operation.channel), action: "none" }; + } + + const capture = createCaptureRuntime(); + if (isPersistentCrestodianOperation(operation) && !this.opts.yes) { + this.clearPendingProposals(); + this.pending = operation; + await executeCrestodianOperation(operation, capture, { + approved: false, + deps: this.commandDeps(), + }); + return { + text: [provenance, capture.read(), approvalQuestion(operation)] + .filter(Boolean) + .join("\n\n"), + action: "none", + }; + } + + let applied = false; + try { + const result = await executeCrestodianOperation(operation, capture, { + approved: this.opts.yes === true || !isPersistentCrestodianOperation(operation), + deps: this.commandDeps(), + }); + applied = result.applied; + } catch (error) { + capture.error(formatOperationError(error)); + } + const verify = applied ? await this.verifyConfigAfterWrite() : null; + const reply = [provenance, capture.read(), verify].filter(Boolean).join("\n\n"); + if (operation.kind === "none" && reply.includes("Bye.")) { + return { text: reply, action: "exit" }; + } + return { text: reply, action: "none" }; + } + + async loadOverview(): Promise { + if (this.opts.deps?.loadOverview) { + return await this.opts.deps.loadOverview(); + } + return await loadCrestodianOverview(); + } + + /** + * Post-write hook: re-validate openclaw.json after every applied operation. + * On failure the exact schema issues go straight back into the conversation + * (and to the AI, which proposes one corrective command) so a bad write is + * caught and fixed in the same chat instead of surfacing at gateway start. + */ + private async verifyConfigAfterWrite(): Promise { + let issuesText: string; + try { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.exists || snapshot.valid) { + return null; + } + const issues = (snapshot.issues ?? []).map( + (issue: { path?: string; message: string }) => + `${issue.path ? `${issue.path}: ` : ""}${issue.message}`, + ); + issuesText = issues.length > 0 ? issues.join("\n") : "unknown validation failure"; + } catch { + return null; + } + const notice = `⚠ openclaw.json failed validation after that write:\n${issuesText}`; + const recovery = await this.resolveAssistantTurn( + `[config-verify] The config file is now invalid:\n${issuesText}\nPropose one corrective command from the allowed list.`, + ); + if (!recovery.text || recovery.text.includes("deterministic mode")) { + return `${notice}\nSay \`doctor fix\` to repair it, or \`config schema \` to check the expected shape.`; + } + return `${notice}\n\n${recovery.text}`; + } + + private commandDeps(): CrestodianCommandDeps | undefined { + if (!this.opts.deps && !this.opts.surface) { + return undefined; + } + return { + ...this.opts.deps, + ...(this.opts.surface ? { setupSurface: this.opts.surface } : {}), + }; + } + + private clearPendingProposals(): void { + this.pending = null; + this.agentSession.proposalRef.current = undefined; + } + + private async startChannelSetupWizard(channel: string): Promise { + const runWizard = + this.opts.runChannelSetupWizard ?? + ((ch: string, prompter: WizardPrompterLike) => defaultChannelSetupWizardRunner(ch)(prompter)); + const session = new WizardSession((prompter) => runWizard(channel, prompter)); + this.wizardBridge = { session, step: null, label: channel, autoSelectChannel: channel }; + return await this.pumpWizardBridge(); + } + + /** + * "connect telegram" already names the channel; answer the wizard's channel + * selection step automatically instead of echoing the full channel wall. + */ + private tryAutoSelectChannel(step: WizardStep): { value: unknown } | null { + const bridge = this.wizardBridge; + const channel = bridge?.autoSelectChannel; + if (!bridge || !channel) { + return null; + } + if (step.type !== "select" && step.type !== "multiselect") { + return null; + } + const match = (step.options ?? []).find( + (option) => typeof option.value === "string" && option.value.toLowerCase() === channel, + ); + if (!match) { + return null; + } + bridge.autoSelectChannel = undefined; + return { value: step.type === "multiselect" ? [match.value] : match.value }; + } + + /** Advance the hosted wizard to the next interactive step (or completion). */ + private async pumpWizardBridge(): Promise { + const bridge = this.wizardBridge; + if (!bridge) { + return ""; + } + const result = await bridge.session.next(); + if (result.done) { + this.wizardBridge = null; + const label = bridge.label; + if (result.status === "done") { + const { appendCrestodianAuditEntry } = await import("./audit.js"); + await appendCrestodianAuditEntry({ + operation: "channels.setup", + summary: `Configured channel ${label} via chat setup`, + details: { channel: label }, + }); + const verify = await this.verifyConfigAfterWrite(); + return [ + `Done — ${label} is configured.`, + "Say `restart gateway` to apply channel changes, or `channels` to review.", + verify ?? "", + ] + .filter(Boolean) + .join("\n"); + } + if (result.status === "cancelled") { + return "Channel setup cancelled. Nothing was changed beyond completed steps."; + } + return `Channel setup stopped: ${result.error ?? "unknown error"}`; + } + bridge.step = result.step ?? null; + if (bridge.step) { + const auto = this.tryAutoSelectChannel(bridge.step); + if (auto) { + const step = bridge.step; + bridge.step = null; + await bridge.session.answer(step.id, auto.value); + return await this.pumpWizardBridge(); + } + if (this.opts.surface === "cli" && bridge.step.sensitive === true) { + bridge.session.cancel(); + this.wizardBridge = null; + return [ + "Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.", + `Run \`openclaw channels add --channel ${bridge.label}\` to finish setup with masked prompts.`, + ].join("\n"); + } + if (bridge.step.type === "note" || bridge.step.type === "progress") { + const step = bridge.step; + bridge.step = null; + await bridge.session.answer(step.id, undefined); + const next = await this.pumpWizardBridge(); + return [renderWizardStep(step), next].filter(Boolean).join("\n\n"); + } + if (bridge.step.type === "action" && bridge.step.executor !== "client") { + const step = bridge.step; + bridge.step = null; + await bridge.session.answer(step.id, true); + return await this.pumpWizardBridge(); + } + } + return bridge.step ? renderWizardStep(bridge.step) : ""; + } + + private async resolveWizardBridgeReply(text: string): Promise { + const bridge = this.wizardBridge; + if (!bridge) { + return ""; + } + if (/^(cancel|abort|stop|quit|exit)$/i.test(text.trim())) { + bridge.session.cancel(); + return await this.pumpWizardBridge(); + } + const step = bridge.step; + if (!step) { + return await this.pumpWizardBridge(); + } + const answer = parseWizardAnswer(step, text); + if (!answer) { + return ["I could not match that answer.", renderWizardStep(step)].join("\n"); + } + const validationError = await bridge.session.answer(step.id, answer.value); + if (validationError) { + return [validationError, renderWizardStep(step)].join("\n\n"); + } + return await this.pumpWizardBridge(); + } +} diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts index f358c2f55f24..04895dbdc4a9 100644 --- a/src/crestodian/crestodian.ts +++ b/src/crestodian/crestodian.ts @@ -32,6 +32,10 @@ export type RunCrestodianOptions = { yes?: boolean; json?: boolean; interactive?: boolean; + /** "onboarding" swaps the greeting for the first-run setup proposal. */ + welcomeVariant?: "onboarding"; + /** Workspace override for the proposed first-run setup (from --workspace). */ + setupWorkspace?: string; onReady?: () => void; deps?: CrestodianCommandDeps; formatOverview?: (overview: CrestodianOverview) => string; diff --git a/src/crestodian/dialogue.ts b/src/crestodian/dialogue.ts index 42c339710e61..ec4fd4c53f23 100644 --- a/src/crestodian/dialogue.ts +++ b/src/crestodian/dialogue.ts @@ -42,7 +42,7 @@ export async function resolveCrestodianOperation( const overview = await (opts.loadOverview ?? loadCrestodianOverview)(); const planner = opts.planWithAssistant ?? (await import("./assistant.js")).planCrestodianCommand; const plan = await planner({ input, overview }); - if (!plan) { + if (!plan?.command) { return operation; } const planned = parseCrestodianOperation(plan.command); diff --git a/src/crestodian/onboarding-welcome.test.ts b/src/crestodian/onboarding-welcome.test.ts new file mode 100644 index 000000000000..f047e69e981f --- /dev/null +++ b/src/crestodian/onboarding-welcome.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildOnboardingWelcome } from "./onboarding-welcome.js"; + +const mocks = vi.hoisted(() => ({ + sourceConfig: { + agents: { defaults: { workspace: "/existing/workspace" } }, + gateway: undefined as + | { + auth?: { + mode?: string; + token?: string | { source: "env"; provider: string; id: string }; + }; + } + | undefined, + }, +})); + +vi.mock("../config/config.js", async (importOriginal) => ({ + ...(await importOriginal()), + readConfigFileSnapshot: vi.fn(async () => ({ + exists: true, + valid: true, + path: "/tmp/openclaw.json", + hash: "hash", + config: {}, + sourceConfig: mocks.sourceConfig, + issues: [], + })), +})); + +vi.mock("../commands/onboard-inference.js", () => ({ + detectInferenceBackends: vi.fn(async () => []), +})); + +vi.mock("../commands/onboard-helpers.js", () => ({ DEFAULT_WORKSPACE: "/default/workspace" })); + +describe("buildOnboardingWelcome", () => { + it("preserves an authored workspace in a partial setup", async () => { + mocks.sourceConfig.agents.defaults.workspace = "/existing/workspace"; + const propose = vi.fn(); + const noteAssistantMessage = vi.fn(); + const engine = { + loadOverview: vi.fn(async () => ({ + config: { + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + hash: "hash", + }, + defaultModel: undefined, + })), + propose, + noteAssistantMessage, + }; + + const welcome = await buildOnboardingWelcome({ engine: engine as never }); + + expect(propose).toHaveBeenCalledWith({ kind: "setup", workspace: "/existing/workspace" }); + expect(welcome).toContain("Workspace: /existing/workspace"); + }); + + it("ignores a blank authored workspace", async () => { + mocks.sourceConfig.agents.defaults.workspace = " "; + const propose = vi.fn(); + const engine = { + loadOverview: vi.fn(async () => ({ + config: { + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + hash: "hash", + }, + defaultModel: undefined, + })), + propose, + noteAssistantMessage: vi.fn(), + }; + + await buildOnboardingWelcome({ engine: engine as never }); + + expect(propose).toHaveBeenCalledWith({ kind: "setup", workspace: "/default/workspace" }); + }); + + it.each([ + { label: "blank token", auth: { token: " " }, configured: false }, + { + label: "SecretRef token", + auth: { token: { source: "env" as const, provider: "default", id: "GATEWAY_TOKEN" } }, + configured: true, + }, + ])("treats $label consistently with the app gate", async ({ auth, configured }) => { + mocks.sourceConfig.gateway = { auth }; + const propose = vi.fn(); + const welcome = await buildOnboardingWelcome({ + engine: { + loadOverview: vi.fn(async () => ({ + config: { + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + hash: "hash", + }, + defaultModel: "openai/gpt-5.5", + gateway: { reachable: true, url: "ws://127.0.0.1:18789" }, + })), + propose, + noteAssistantMessage: vi.fn(), + } as never, + }); + + expect(propose).toHaveBeenCalledTimes(configured ? 0 : 1); + expect(welcome.includes("Say **yes**")).toBe(!configured); + }); +}); diff --git a/src/crestodian/onboarding-welcome.ts b/src/crestodian/onboarding-welcome.ts new file mode 100644 index 000000000000..a0ae76cbf7c7 --- /dev/null +++ b/src/crestodian/onboarding-welcome.ts @@ -0,0 +1,89 @@ +// First-run onboarding welcome: state findings, propose setup, wait for "yes". +import { isSecretRef, normalizeSecretInputString } from "../config/types.secrets.js"; +import { resolveUserPath, shortenHomePath } from "../utils.js"; +import type { CrestodianChatEngine } from "./chat-engine.js"; +import { formatCrestodianOnboardingWelcome } from "./overview.js"; + +/** + * Onboarding is a conversation, not a wizard: the welcome message carries the + * whole plan (detected AI, workspace, gateway defaults, security note) and the + * engine holds it as the pending proposal, so a bare "yes" applies everything. + * On an already-configured install the welcome becomes the channels/handoff + * guide instead of re-proposing setup. + */ +export async function buildOnboardingWelcome(params: { + engine: CrestodianChatEngine; + workspace?: string; +}): Promise { + const overview = await params.engine.loadOverview(); + // "Configured" must match the app onboarding gate (wizard metadata or + // gateway auth), not just a model: a model-only config would otherwise get + // the ready-guide welcome while the gate stays locked, stranding the page. + const authoredConfig = await (async () => { + if (!overview.config.exists || !overview.config.valid) { + return undefined; + } + try { + const { readConfigFileSnapshot } = await import("../config/config.js"); + const snapshot = await readConfigFileSnapshot(); + return snapshot.sourceConfig ?? snapshot.config ?? {}; + } catch { + return undefined; + } + })(); + const auth = authoredConfig?.gateway?.auth; + const hasAuthMode = normalizeSecretInputString(auth?.mode) !== undefined; + const hasAuthSecret = + isSecretRef(auth?.token) || + normalizeSecretInputString(auth?.token) !== undefined || + isSecretRef(auth?.password) || + normalizeSecretInputString(auth?.password) !== undefined; + const hasAuthoredSetup = Boolean( + (authoredConfig?.wizard && Object.keys(authoredConfig.wizard).length > 0) || + hasAuthMode || + hasAuthSecret, + ); + if (hasAuthoredSetup && overview.defaultModel) { + const welcome = formatCrestodianOnboardingWelcome(overview); + params.engine.noteAssistantMessage(welcome); + return welcome; + } + + const [{ detectInferenceBackends }, { DEFAULT_WORKSPACE }] = await Promise.all([ + import("../commands/onboard-inference.js"), + import("../commands/onboard-helpers.js"), + ]); + const candidates = await detectInferenceBackends({}); + // Mirror chooseSetupModel: never advertise a definitively logged-out CLI. + const detected = candidates.find( + (candidate) => candidate.kind !== "existing-model" && candidate.credentials !== false, + ); + const workspace = resolveUserPath( + params.workspace?.trim() || + authoredConfig?.agents?.defaults?.workspace?.trim() || + DEFAULT_WORKSPACE, + ); + + params.engine.propose({ kind: "setup", workspace }); + + const aiLine = detected + ? `- AI: ${detected.label} — ${detected.modelRef} (${detected.detail}). I'll reuse it; switching later is one sentence.` + : "- AI: nothing detected yet (no Claude Code or Codex login, no OPENAI_API_KEY/ANTHROPIC_API_KEY). I can still set up the basics; add access later and tell me `set default model `."; + + const welcome = [ + "## Hi, I'm Crestodian — let's hatch your agent.", + "", + "No menus here: tell me what you want and I'll do the configuring. I looked around this machine:", + "", + aiLine, + `- Workspace: ${shortenHomePath(workspace)}`, + "- Gateway: runs locally, private to this machine (token auth).", + "", + "Say **yes** and I'll set all of that up now.", + "", + "Heads up: your agent gets real access to this machine — https://docs.openclaw.ai/security", + "Afterwards: `connect discord`, `connect slack`, `connect telegram`, `connect whatsapp` (or `channels` for the full list), then `talk to agent` to meet your agent.", + ].join("\n"); + params.engine.noteAssistantMessage(welcome); + return welcome; +} diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index 88b255adf092..b4a92cd861c3 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -6,7 +6,12 @@ import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; import type { RuntimeEnv } from "../runtime.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; -import { executeCrestodianOperation, parseCrestodianOperation } from "./operations.js"; +import { + describeCrestodianPersistentOperation, + executeCrestodianOperation, + isPersistentCrestodianOperation, + parseCrestodianOperation, +} from "./operations.js"; type TestConfig = Record; @@ -97,6 +102,9 @@ const mockConfig = vi.hoisted(() => { currentConfig() { return cloneConfig(); }, + setConfig(config: TestConfig) { + state.config = structuredClone(config); + }, readConfigFileSnapshot: vi.fn(async () => snapshot()), mutateConfigFile: vi.fn( async (params: { @@ -280,6 +288,75 @@ describe("parseCrestodianOperation", () => { }); }); + it("parses config read and schema lookups", () => { + expect(parseCrestodianOperation("config get gateway.port")).toEqual({ + kind: "config-get", + path: "gateway.port", + }); + expect(parseCrestodianOperation("config schema channels.telegram")).toEqual({ + kind: "config-schema", + path: "channels.telegram", + }); + expect(parseCrestodianOperation("config schema")).toEqual({ kind: "config-schema" }); + // Read-only: no approval gate. + expect(isPersistentCrestodianOperation({ kind: "config-get", path: "gateway.port" })).toBe( + false, + ); + expect(isPersistentCrestodianOperation({ kind: "config-schema" })).toBe(false); + }); + + it("redacts sensitive config values using their complete paths", async () => { + mockConfig.setConfig({ + models: { + providers: { + local: { + localService: { + env: { HF_HOME: "/private/model-cache" }, + }, + }, + }, + }, + }); + const { runtime, lines } = createCrestodianTestRuntime(); + + await executeCrestodianOperation( + { kind: "config-get", path: "models.providers.local.localService" }, + runtime, + ); + + expect(lines.join("\n")).toContain('"HF_HOME": ""'); + expect(lines.join("\n")).not.toContain("/private/model-cache"); + expect( + describeCrestodianPersistentOperation({ + kind: "config-set", + path: "models.providers.local.localService.env.HF_HOME", + value: "/private/model-cache", + }), + ).toBe("set config models.providers.local.localService.env.HF_HOME to "); + }); + + it("parses channel listing and connect requests", () => { + expect(parseCrestodianOperation("channels")).toEqual({ kind: "channel-list" }); + expect(parseCrestodianOperation("list channels")).toEqual({ kind: "channel-list" }); + expect(parseCrestodianOperation("connect telegram")).toEqual({ + kind: "channel-setup", + channel: "telegram", + }); + expect(parseCrestodianOperation("connect to WhatsApp")).toEqual({ + kind: "channel-setup", + channel: "whatsapp", + }); + expect(parseCrestodianOperation("link discord channel")).toEqual({ + kind: "channel-setup", + channel: "discord", + }); + // Channel setup is persistent: it writes channel config after the wizard. + expect(isPersistentCrestodianOperation({ kind: "channel-setup", channel: "telegram" })).toBe( + true, + ); + expect(isPersistentCrestodianOperation({ kind: "channel-list" })).toBe(false); + }); + it("parses agent creation requests", () => { expect( parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"), @@ -541,15 +618,21 @@ describe("parseCrestodianOperation", () => { setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); vi.stubEnv("OPENAI_API_KEY", "test-key"); const { runtime, lines } = createCrestodianTestRuntime(); + const applySetup = vi.fn(async () => ({ + configPath: path.join(tempDir, "openclaw.json"), + lines: ["Workspace: /tmp/work", "Default model: openai/gpt-5.5"], + })); const plan = await executeCrestodianOperation( { kind: "setup", workspace: "/tmp/work" }, runtime, + { deps: { applySetup } }, ); expectRecordFields(plan as unknown as Record, { applied: false, }); expect(lines.join("\n")).toContain("Model choice: openai/gpt-5.5 (OPENAI_API_KEY)."); + expect(applySetup).not.toHaveBeenCalled(); const result = await executeCrestodianOperation( { kind: "setup", workspace: "/tmp/work" }, @@ -557,16 +640,17 @@ describe("parseCrestodianOperation", () => { { approved: true, auditDetails: { rescue: true }, + deps: { applySetup }, }, ); expect(result.applied).toBe(true); expect(lines.join("\n")).toContain("[crestodian] done: crestodian.setup"); - const config = requireRecord(mockConfig.currentConfig(), "current config"); - const agents = requireRecord(config.agents, "agents config"); - expectRecordFields(requireRecord(agents.defaults, "agent defaults"), { + expect(applySetup).toHaveBeenCalledWith({ workspace: "/tmp/work", - model: { primary: "openai/gpt-5.5" }, + model: "openai/gpt-5.5", + surface: "cli", + runtime, }); const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 8c8caf678bbd..3fd37d6d5714 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -1,7 +1,12 @@ // Crestodian operations parse, approve, execute, and audit setup-helper commands. -import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import type { ConfigSetOptions } from "../cli/config-set-input.js"; import type { DoctorOptions } from "../commands/doctor.types.js"; +import { + detectInferenceBackends, + type InferenceBackendCandidate, + type InferenceBackendKind, +} from "../commands/onboard-inference.js"; +import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import type { TuiResult } from "../tui/tui-types.js"; @@ -35,6 +40,8 @@ export type CrestodianOperation = | { kind: "status" } | { kind: "health" } | { kind: "config-validate" } + | { kind: "config-get"; path: string } + | { kind: "config-schema"; path?: string } | { kind: "config-set"; path: string; value: string } | { kind: "config-set-ref"; @@ -44,6 +51,8 @@ export type CrestodianOperation = provider?: string; } | { kind: "setup"; workspace?: string; model?: string } + | { kind: "channel-list" } + | { kind: "channel-setup"; channel: string } | { kind: "gateway-status" } | { kind: "gateway-start" } | { kind: "gateway-stop" } @@ -101,6 +110,10 @@ export type CrestodianCommandDeps = { deliver?: boolean; historyLimit?: number; }) => Promise; + detectInferenceBackends?: typeof detectInferenceBackends; + /** Where setup side effects run; the gateway surface never manages its own daemon. */ + setupSurface?: "cli" | "gateway"; + applySetup?: typeof import("./setup-apply.js").applyCrestodianSetup; }; const SET_MODEL_RE = /(?:set|configure|use)\s+(?:the\s+)?(?:default\s+)?model\s+(.+)/i; @@ -113,6 +126,8 @@ const WORKSPACE_RE = /(?:workspace|workdir|cwd|for|in)\s+(?"[^"]+"|'[ const MODEL_RE = /\bmodel\s+(?\S+)/i; const CONFIG_SET_RE = /^(?:config\s+set|set\s+config)\s+(?[A-Za-z0-9_.[\]-]+)\s+(?.+)$/i; +const CONFIG_GET_RE = /^config\s+get\s+(?[A-Za-z0-9_.[\]-]+)$/i; +const CONFIG_SCHEMA_RE = /^config\s+schema(?:\s+(?[A-Za-z0-9_.[\]-]+))?$/i; const CONFIG_SET_REF_RE = /^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?[A-Za-z0-9_.[\]-]+)\s+(?:(?env|file|exec)\s+)?(?\S+)(?:\s+provider\s+(?[A-Za-z0-9_-]+))?$/i; const SETUP_RE = @@ -124,14 +139,31 @@ const PLUGIN_INSTALL_RE = /^(?:(?:plugins?)\s+install|install\s+(?:(?npm|clawhub)\s+)?plugins?)\s+(?\S+)$/i; const PLUGIN_UNINSTALL_RE = /^(?:(?:plugins?)\s+(?:uninstall|remove)|(?:uninstall|remove)\s+plugins?)\s+(?[A-Za-z0-9_.@/-]+)$/i; +const CHANNEL_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i; +const CHANNEL_CONNECT_RE = + /^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?[a-z0-9_-]+)(?:\s+channel)?$/i; -const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`; -const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-8"; -const CLAUDE_CLI_DEFAULT_MODEL_REF = "claude-cli/claude-opus-4-8"; -const CODEX_APP_SERVER_DEFAULT_MODEL_REF = "openai/gpt-5.5"; +/** Audit/source labels for detected inference backends (docs-visible contract). */ +const INFERENCE_SOURCE_LABELS: Record = { + "existing-model": "existing default model", + "openai-api-key": "OPENAI_API_KEY", + "anthropic-api-key": "ANTHROPIC_API_KEY", + "claude-cli": "Claude Code CLI", + "codex-cli": "Codex app-server", +}; -/** Parse one user command into Crestodian's closed operation union. */ -export function parseCrestodianOperation(input: string): CrestodianOperation { +/** + * Parse one user command into Crestodian's closed operation union. + * + * strict mode (AI-first chat) only matches exact command grammar so natural + * language flows to the assistant; loose mode (one-shot CLI, rescue) keeps the + * forgiving keyword heuristics. + */ +export function parseCrestodianOperation( + input: string, + opts: { strict?: boolean } = {}, +): CrestodianOperation { + const strict = opts.strict === true; const trimmed = input.trim(); const lower = trimmed.toLowerCase(); if (!trimmed) { @@ -143,7 +175,7 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { if (["help", "?", "overview", "system"].includes(lower)) { return { kind: "overview" }; } - if (lower === "audit" || lower.includes("audit log")) { + if (lower === "audit" || (!strict && lower.includes("audit log"))) { return { kind: "audit" }; } const configSetRefMatch = trimmed.match(CONFIG_SET_REF_RE); @@ -166,6 +198,15 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { value: configSetMatch.groups.value.trim(), }; } + const configGetMatch = trimmed.match(CONFIG_GET_RE); + if (configGetMatch?.groups?.path) { + return { kind: "config-get", path: configGetMatch.groups.path }; + } + const configSchemaMatch = trimmed.match(CONFIG_SCHEMA_RE); + if (configSchemaMatch) { + const path = configSchemaMatch.groups?.path?.trim(); + return { kind: "config-schema", ...(path ? { path } : {}) }; + } if ( lower === "config validate" || lower === "validate config" || @@ -194,6 +235,13 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { if (pluginUninstallMatch?.groups?.pluginId?.trim()) { return { kind: "plugin-uninstall", pluginId: pluginUninstallMatch.groups.pluginId.trim() }; } + if (CHANNEL_LIST_RE.test(trimmed)) { + return { kind: "channel-list" }; + } + const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE); + if (channelConnectMatch?.groups?.channel) { + return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() }; + } if (SETUP_RE.test(lower)) { const workspace = trimShellishToken(trimmed.match(WORKSPACE_RE)?.groups?.workspace); const model = trimmed.match(MODEL_RE)?.groups?.model; @@ -203,16 +251,22 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { ...(model ? { model } : {}), }; } - if (lower.includes("doctor")) { + if (strict ? lower === "doctor" || lower === "doctor fix" : lower.includes("doctor")) { if (lower.includes("fix") || lower.includes("repair")) { return { kind: "doctor-fix" }; } return { kind: "doctor" }; } - if (lower.includes("health")) { + if (strict ? lower === "health" : lower.includes("health")) { return { kind: "health" }; } - if (lower.includes("gateway")) { + if ( + strict + ? /^(?:gateway\s+(?:status|start|stop|restart)|(?:start|stop|restart)\s+(?:the\s+)?gateway)$/.test( + lower, + ) + : lower.includes("gateway") + ) { if (lower.includes("restart")) { return { kind: "gateway-restart" }; } @@ -224,10 +278,14 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { } return { kind: "gateway-status" }; } - if (lower.includes("status")) { + if (strict ? lower === "status" : lower.includes("status")) { return { kind: "status" }; } - if (lower.includes("agent")) { + if ( + strict + ? lower === "agents" || CREATE_AGENT_RE.test(trimmed) || TALK_AGENT_RE.test(trimmed) + : lower.includes("agent") + ) { // Creation is checked before "talk to agent" because setup phrasing can contain both words. const createMatch = trimmed.match(CREATE_AGENT_RE); if (createMatch?.groups?.agent) { @@ -251,7 +309,7 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { } return { kind: "agents" }; } - if (lower.includes("model")) { + if (strict ? lower === "models" || SET_MODEL_RE.test(trimmed) : lower.includes("model")) { const match = trimmed.match(SET_MODEL_RE); const pluralMatch = trimmed.match(CONFIGURE_MODELS_RE); const model = match?.[1]?.trim() ?? pluralMatch?.groups?.model?.trim(); @@ -260,7 +318,11 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { } return { kind: "models" }; } - if (lower === "tui" || lower.includes("open tui") || lower.includes("chat")) { + if ( + strict + ? lower === "tui" || lower === "open tui" + : lower === "tui" || lower.includes("open tui") || lower.includes("chat") + ) { return { kind: "open-tui" }; } if (lower === "quit" || lower === "exit") { @@ -269,7 +331,7 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { return { kind: "none", message: - "I can run doctor/status/health, check or restart Gateway, list agents/models, set default model, show audit, or switch to your agent TUI.", + "I can run doctor/status/health, check or restart Gateway, list agents/models, set default model, connect channels (`connect telegram`), show audit, or switch to your agent TUI.", }; } @@ -321,6 +383,7 @@ export function isPersistentCrestodianOperation(operation: CrestodianOperation): operation.kind === "config-set" || operation.kind === "config-set-ref" || operation.kind === "setup" || + operation.kind === "channel-setup" || operation.kind === "doctor-fix" || operation.kind === "plugin-install" || operation.kind === "plugin-uninstall" || @@ -342,6 +405,8 @@ export function describeCrestodianPersistentOperation(operation: CrestodianOpera return `set config ${operation.path} to ${operation.source} SecretRef ${operation.source === "env" ? operation.id : ""}`; case "setup": return formatSetupPlanDescription(operation); + case "channel-setup": + return `walk through connecting the ${operation.channel} channel`; case "doctor-fix": return "run doctor repairs"; case "plugin-install": @@ -371,12 +436,56 @@ function formatCreateAgentWorkspace(workspace: string | undefined): string { } function formatConfigSetValueForPlan(configPath: string, value: string): string { - if (/(secret|token|password|key|credential)/i.test(configPath)) { + if (isSensitiveConfigPath(configPath)) { return ""; } return value; } +const CONFIG_GET_OUTPUT_MAX_CHARS = 2_000; +const CONFIG_SCHEMA_CHILDREN_MAX = 40; + +function redactConfigValue(value: unknown, configPath: string): unknown { + if (typeof value === "string" || typeof value === "number") { + return isSensitiveConfigPath(configPath) ? "" : value; + } + if (Array.isArray(value)) { + return value.map((entry) => redactConfigValue(entry, `${configPath}[]`)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + redactConfigValue(entry, configPath ? `${configPath}.${key}` : key), + ]), + ); + } + return value; +} + +function readConfigValueAtPath(config: unknown, path: string): { found: boolean; value?: unknown } { + let current: unknown = config; + for (const rawSegment of path.split(".")) { + // Support foo[0] style array segments alongside dotted keys. + const parts = rawSegment.split(/[[\]]/).filter(Boolean); + for (const part of parts) { + if (current === null || typeof current !== "object") { + return { found: false }; + } + const index = /^\d+$/.test(part) ? Number(part) : undefined; + if (index !== undefined && Array.isArray(current)) { + current = current[index]; + } else { + current = (current as Record)[part]; + } + if (current === undefined) { + return { found: false }; + } + } + } + return { found: true, value: current }; +} + function formatSetupPlanDescription( operation: Extract, ): string { @@ -385,33 +494,29 @@ function formatSetupPlanDescription( return `bootstrap OpenClaw setup for workspace ${workspace}${model}`; } -function chooseSetupModel( - overview: CrestodianOverview, - requestedModel: string | undefined, -): { - model?: string; - source: string; -} { +async function chooseSetupModel(params: { + overview: CrestodianOverview; + requestedModel: string | undefined; + deps?: CrestodianCommandDeps; +}): Promise<{ model?: string; source: string }> { // Setup picks an existing/default local credential path before falling back to no model change. - if (requestedModel?.trim()) { - return { model: requestedModel.trim(), source: "requested" }; + if (params.requestedModel?.trim()) { + return { model: params.requestedModel.trim(), source: "requested" }; } - if (overview.defaultModel) { + if (params.overview.defaultModel) { return { source: "existing default model" }; } - if (overview.tools.apiKeys.openai) { - return { model: OPENAI_API_DEFAULT_MODEL_REF, source: "OPENAI_API_KEY" }; + const detect = params.deps?.detectInferenceBackends ?? detectInferenceBackends; + const candidates = await detect({}); + // A definitively logged-out CLI must never become the configured model: + // setup would claim working AI access while every agent run fails auth. + const detected: InferenceBackendCandidate | undefined = candidates.find( + (candidate) => candidate.kind !== "existing-model" && candidate.credentials !== false, + ); + if (!detected) { + return { source: "none" }; } - if (overview.tools.apiKeys.anthropic) { - return { model: ANTHROPIC_API_DEFAULT_MODEL_REF, source: "ANTHROPIC_API_KEY" }; - } - if (overview.tools.claude.found) { - return { model: CLAUDE_CLI_DEFAULT_MODEL_REF, source: "Claude Code CLI" }; - } - if (overview.tools.codex.found) { - return { model: CODEX_APP_SERVER_DEFAULT_MODEL_REF, source: "Codex app-server" }; - } - return { source: "none" }; + return { model: detected.modelRef, source: INFERENCE_SOURCE_LABELS[detected.kind] }; } function logQueued(runtime: RuntimeEnv, operation: string): void { @@ -618,9 +723,130 @@ export async function executeCrestodianOperation( runtime.log(formatConfigValidationLine(snapshot)); return { applied: false }; } + if (operation.kind === "config-get") { + const snapshot = await readConfigFileSnapshotLazy(); + if (!snapshot.exists) { + runtime.log(`Config missing: ${shortenHomePath(snapshot.path)}`); + return { applied: false }; + } + const cfg = snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : snapshot.sourceConfig; + const lookup = readConfigValueAtPath(cfg ?? {}, operation.path); + if (!lookup.found) { + runtime.log( + `${operation.path}: not set. Use \`config schema ${operation.path}\` to see what is allowed.`, + ); + return { applied: false }; + } + const redacted = redactConfigValue(lookup.value, operation.path); + const rendered = JSON.stringify(redacted, null, 2) ?? "null"; + runtime.log( + rendered.length > CONFIG_GET_OUTPUT_MAX_CHARS + ? `${operation.path} = ${rendered.slice(0, CONFIG_GET_OUTPUT_MAX_CHARS)}\n… (truncated)` + : `${operation.path} = ${rendered}`, + ); + return { applied: false }; + } + if (operation.kind === "config-schema") { + const { buildConfigSchema, lookupConfigSchema } = await import("../config/schema.js"); + const response = buildConfigSchema(); + const path = operation.path ?? "."; + const result = lookupConfigSchema(response, path); + if (!result) { + runtime.log(`No config schema at "${path}". Try \`config schema .\` for the root keys.`); + return { applied: false }; + } + const schema = result.schema as { + type?: string | string[]; + description?: string; + enum?: unknown[]; + default?: unknown; + }; + const childLines = result.children.slice(0, CONFIG_SCHEMA_CHILDREN_MAX).map((child) => { + const type = Array.isArray(child.type) ? child.type.join("|") : (child.type ?? "object"); + const bits = [ + type, + child.required ? "required" : undefined, + child.hasChildren ? "…" : undefined, + ] + .filter(Boolean) + .join(", "); + return ` - ${child.path} (${bits})`; + }); + runtime.log( + [ + `Schema for ${result.path === "" ? "." : result.path}:`, + schema.type + ? `type: ${Array.isArray(schema.type) ? schema.type.join("|") : schema.type}` + : undefined, + schema.description ? `description: ${schema.description}` : undefined, + schema.enum + ? `allowed values: ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}` + : undefined, + schema.default !== undefined ? `default: ${JSON.stringify(schema.default)}` : undefined, + ...(childLines.length > 0 ? ["keys:", ...childLines] : []), + result.children.length > CONFIG_SCHEMA_CHILDREN_MAX + ? `… +${result.children.length - CONFIG_SCHEMA_CHILDREN_MAX} more keys` + : undefined, + ] + .filter((line): line is string => line !== undefined) + .join("\n"), + ); + return { applied: false }; + } + if (operation.kind === "channel-list") { + // Use the same discovery as channel setup (bundled plugins + trusted + // catalog), so the listing matches what `connect ` can configure + // even before any plugin registry is active. + const [{ listChannelSetupPlugins }, { resolveChannelSetupEntries, shouldShowChannelInSetup }] = + await Promise.all([ + import("../channels/plugins/setup-registry.js"), + import("../commands/channel-setup/discovery.js"), + ]); + const snapshot = await readConfigFileSnapshotLazy(); + const cfg = snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const resolved = resolveChannelSetupEntries({ + cfg, + installedPlugins: listChannelSetupPlugins(), + }); + const entries = resolved.entries + .filter((entry) => shouldShowChannelInSetup(entry.meta)) + .toSorted((a, b) => a.id.localeCompare(b.id)); + runtime.log( + [ + "Channels:", + ...entries.map( + (entry) => ` - ${entry.id}${entry.meta.label ? ` (${entry.meta.label})` : ""}`, + ), + "", + "Say `connect ` to walk through setup (for example `connect telegram`).", + ].join("\n"), + ); + return { applied: false }; + } + if (operation.kind === "channel-setup") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + // Channel setup is a multi-step wizard; only interactive Crestodian (TUI + // chat bridge) can host it. One-shot mode points at the guided paths. + runtime.log( + [ + `Connecting ${operation.channel} needs an interactive session.`, + "Run `openclaw crestodian` and say `connect " + operation.channel + "`,", + "or run `openclaw channels add` for the terminal wizard.", + ].join("\n"), + ); + return { applied: false }; + } if (operation.kind === "setup") { const overview = await loadOverviewForOperation(opts.deps); - const setupModel = chooseSetupModel(overview, operation.model); + const setupModel = await chooseSetupModel({ + overview, + requestedModel: operation.model, + deps: opts.deps, + }); if (!opts.approved) { const message = [ formatCrestodianPersistentPlan(operation), @@ -634,36 +860,16 @@ export async function executeCrestodianOperation( return { applied: false, message }; } logQueued(runtime, "crestodian.setup"); - const { mutateConfigFile, readConfigFileSnapshot } = await loadConfigFileMutationHelpers(); + const { readConfigFileSnapshot } = await loadConfigModule(); const before = await readConfigFileSnapshot(); const workspace = resolveUserPath(operation.workspace ?? process.cwd()); - const applyDefaultModelPrimaryUpdate = setupModel.model - ? (await loadModelsSharedModule()).applyDefaultModelPrimaryUpdate - : undefined; - const result = await mutateConfigFile({ - base: "source", - mutate: (cfg) => { - // Mutate via the config helper so file locking, formatting, and validation stay centralized. - let next = cfg; - if (setupModel.model && applyDefaultModelPrimaryUpdate) { - next = applyDefaultModelPrimaryUpdate({ - cfg: next, - modelRaw: setupModel.model, - field: "model", - }); - } - next = { - ...next, - agents: { - ...next.agents, - defaults: { - ...next.agents?.defaults, - workspace, - }, - }, - }; - Object.assign(cfg, next); - }, + const applySetup = + opts.deps?.applySetup ?? (await import("./setup-apply.js")).applyCrestodianSetup; + const applied = await applySetup({ + workspace, + ...(setupModel.model ? { model: setupModel.model } : {}), + surface: opts.deps?.setupSurface ?? "cli", + runtime, }); const after = await readConfigFileSnapshot(); await appendCrestodianAuditEntry({ @@ -671,8 +877,8 @@ export async function executeCrestodianOperation( summary: setupModel.model ? `Bootstrapped setup with ${setupModel.model}` : "Bootstrapped setup workspace", - configPath: result.path, - configHashBefore: before.hash ?? result.previousHash, + configPath: after.path || applied.configPath || undefined, + configHashBefore: before.hash ?? null, configHashAfter: after.hash ?? null, details: { ...opts.auditDetails, @@ -681,13 +887,13 @@ export async function executeCrestodianOperation( ...(setupModel.model ? { model: setupModel.model } : {}), }, }); - runtime.log(`Updated ${result.path}`); - runtime.log(`Workspace: ${shortenHomePath(workspace)}`); - if (setupModel.model) { - runtime.log(`Default model: ${setupModel.model} (${setupModel.source})`); - } else if (overview.defaultModel) { + runtime.log(`Updated ${after.path || applied.configPath}`); + for (const line of applied.lines) { + runtime.log(line); + } + if (!setupModel.model && overview.defaultModel) { runtime.log(`Default model: ${overview.defaultModel} (kept)`); - } else { + } else if (!setupModel.model) { runtime.log("Default model: not configured yet"); } runtime.log("[crestodian] done: crestodian.setup"); diff --git a/src/crestodian/overview.ts b/src/crestodian/overview.ts index 8a5793113737..90747c4949ad 100644 --- a/src/crestodian/overview.ts +++ b/src/crestodian/overview.ts @@ -330,6 +330,22 @@ function formatStartupAction(overview: CrestodianOverview): string { return "Everything basic is reachable. Use `talk to agent` when you want the normal agent."; } +/** + * Welcome shown right after bootstrap onboarding: setup is done, so the + * conversation focuses on channels and the agent handoff instead of repair. + */ +export function formatCrestodianOnboardingWelcome(overview: CrestodianOverview): string { + return [ + "## Your agent is ready.", + "", + `- Model: ${overview.defaultModel ?? "not configured (say `setup` to pick one)"}.`, + `- ${overview.gateway.reachable ? `Gateway: running at ${overview.gateway.url}.` : "Gateway: not reachable yet — say `gateway status` if it stays down."}`, + "- Connect how you want to talk: say `connect whatsapp`, `connect telegram`, `connect slack`, `connect discord` — or `channels` for the full list.", + "", + "Say `talk to agent` to meet your agent right here, or `help` for everything I can do.", + ].join("\n"); +} + export function formatCrestodianStartupMessage(overview: CrestodianOverview): string { const agent = overview.agents.find((entry) => entry.id === overview.defaultAgentId); const agentLabel = agent?.name diff --git a/src/crestodian/setup-apply.ts b/src/crestodian/setup-apply.ts new file mode 100644 index 000000000000..1430412654ba --- /dev/null +++ b/src/crestodian/setup-apply.ts @@ -0,0 +1,199 @@ +// Applies Crestodian's conversational setup: config, workspace files, gateway. +import { resolveGatewayPort } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { shortenHomePath } from "../utils.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +/** + * The whole first-run setup as one approved operation: the user says "yes" in + * the conversation and this applies model + workspace + quickstart gateway + * defaults, seeds workspace bootstrap files, and (on the CLI surface) installs + * and starts the gateway service. No interactive prompts may occur here — + * everything uses quickstart defaults, so the conversation stays the only UI. + */ +export type CrestodianSetupApplyParams = { + workspace: string; + model?: string; + surface: "cli" | "gateway"; + runtime: RuntimeEnv; +}; + +export type CrestodianSetupApplyResult = { + configPath: string; + lines: string[]; +}; + +/** Prompter for quickstart-only flows: notes go to the log, prompts fail loud. */ +function createQuickstartNotePrompter(runtime: RuntimeEnv): WizardPrompter { + const unexpected = (kind: string) => { + throw new Error(`crestodian setup hit an interactive ${kind} prompt; quickstart must not ask`); + }; + return { + intro: async () => {}, + outro: async () => {}, + note: async (message, title) => { + runtime.log(title ? `${title}: ${message}` : message); + }, + select: async (params) => { + // Quickstart paths never select interactively; honor defaults if a + // pre-answered prompt sneaks through, otherwise fail loud. + if (params.initialValue !== undefined) { + return params.initialValue; + } + return unexpected("select"); + }, + multiselect: async () => unexpected("multiselect"), + text: async () => unexpected("text"), + confirm: async (params) => params.initialValue ?? true, + progress: (label) => { + runtime.log(label); + return { + update: (message) => runtime.log(message), + stop: (message) => { + if (message) { + runtime.log(message); + } + }, + }; + }, + }; +} + +function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig { + if (config.wizard?.securityAcknowledgedAt) { + return config; + } + // Conversational consent: the onboarding welcome shows the security note and + // the user approved the plan, which is the acknowledgement we persist. + return { + ...config, + wizard: { ...config.wizard, securityAcknowledgedAt: new Date().toISOString() }, + }; +} + +export async function applyCrestodianSetup( + params: CrestodianSetupApplyParams, +): Promise { + const { workspace, model, surface, runtime } = params; + const [ + { readSetupConfigFileSnapshot, resolveQuickstartGatewayDefaults, writeWizardConfigFile }, + onboardHelpers, + { applyLocalSetupWorkspaceConfig }, + ] = await Promise.all([ + import("../wizard/setup.shared.js"), + import("../commands/onboard-helpers.js"), + import("../commands/onboard-config.js"), + ]); + + const snapshot = await readSetupConfigFileSnapshot(); + const baseConfig: OpenClawConfig = + snapshot.valid && snapshot.exists ? (snapshot.sourceConfig ?? snapshot.config) : {}; + + let nextConfig = applyLocalSetupWorkspaceConfig(baseConfig, workspace); + if (model) { + const { applyDefaultModelPrimaryUpdate } = await import("../commands/models/shared.js"); + nextConfig = applyDefaultModelPrimaryUpdate({ + cfg: nextConfig, + modelRaw: model, + field: "model", + }); + } + nextConfig = applySecurityAcknowledgement(nextConfig); + + const prompter = createQuickstartNotePrompter(runtime); + const { configureGatewayForSetup } = await import("../wizard/setup.gateway-config.js"); + const gateway = await configureGatewayForSetup({ + flow: "quickstart", + baseConfig, + nextConfig, + localPort: resolveGatewayPort(baseConfig), + quickstartGateway: resolveQuickstartGatewayDefaults(baseConfig), + prompter, + runtime, + }); + nextConfig = gateway.nextConfig; + const settings = gateway.settings; + + nextConfig = onboardHelpers.applyWizardMetadata(nextConfig, { + command: "onboard", + mode: "local", + }); + nextConfig = await writeWizardConfigFile(nextConfig, { allowConfigSizeDrop: false }); + + await onboardHelpers.ensureWorkspaceAndSessions(workspace, runtime, { + skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), + skipOptionalBootstrapFiles: nextConfig.agents?.defaults?.skipOptionalBootstrapFiles, + }); + + // The user's explicit setup approval (with the security note shown up + // front) is the consent for Crestodian's own agent loop to run local model + // harnesses (Codex app-server needs exec). Scope the grant to the + // crestodian agent only; regular agents keep the interactive approval flow. + try { + const { loadExecApprovals, saveExecApprovals } = await import("../infra/exec-approvals.js"); + const approvals = loadExecApprovals(); + const existing = approvals.agents?.crestodian; + if (!existing) { + saveExecApprovals({ + ...approvals, + agents: { + ...approvals.agents, + crestodian: { security: "full", ask: "off" }, + }, + }); + } + } catch (error) { + runtime.log( + `Could not record Crestodian exec approval (${error instanceof Error ? error.message : String(error)}); local model harnesses may ask again.`, + ); + } + + const lines: string[] = [ + `Workspace: ${shortenHomePath(workspace)}`, + model ? `Default model: ${model}` : undefined, + ].filter((line): line is string => line !== undefined); + + if (surface === "cli") { + // The gateway daemon runs outside this process; install/start it so + // channels and apps have a live gateway. Inside the gateway process + // (macOS app chat) the app owns the service lifecycle. + const { ensureGatewayServiceForOnboarding } = await import("../wizard/setup.finalize.js"); + const { installDaemon } = await ensureGatewayServiceForOnboarding({ + flow: "quickstart", + opts: {}, + nextConfig, + settings, + prompter, + runtime, + loadedAction: "restart", + }); + if (installDaemon) { + const probeLinks = onboardHelpers.resolveLocalControlUiProbeLinks({ + bind: settings.bind, + port: settings.port, + customBindHost: settings.customBindHost, + basePath: undefined, + tlsEnabled: nextConfig.gateway?.tls?.enabled === true, + }); + const probe = await onboardHelpers.waitForGatewayReachable({ + url: probeLinks.wsUrl, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, + deadlineMs: 15_000, + }); + lines.push( + probe.ok + ? `Gateway: running at ${probeLinks.wsUrl}` + : `Gateway: not reachable yet (${probe.detail ?? "still starting"}) — say \`gateway status\` to check`, + ); + } else { + lines.push( + "Gateway: service install skipped — say `start gateway` when you want it running.", + ); + } + } else { + lines.push("Gateway: running (managed by this app)."); + } + + return { configPath: snapshot.path, lines }; +} diff --git a/src/crestodian/tui-backend.ts b/src/crestodian/tui-backend.ts index c78f52c2b045..73d6fe498616 100644 --- a/src/crestodian/tui-backend.ts +++ b/src/crestodian/tui-backend.ts @@ -16,10 +16,10 @@ import type { } from "../tui/tui-backend.js"; import { runTui as defaultRunTui } from "../tui/tui.js"; import type { CrestodianAssistantPlanner } from "./assistant.js"; -import { approvalQuestion, isYes, resolveCrestodianOperation } from "./dialogue.js"; +import { CrestodianChatEngine, type CrestodianChatEngineOptions } from "./chat-engine.js"; +import { buildOnboardingWelcome } from "./onboarding-welcome.js"; import { executeCrestodianOperation, - isPersistentCrestodianOperation, type CrestodianCommandDeps, type CrestodianOperation, } from "./operations.js"; @@ -32,6 +32,12 @@ export type CrestodianTuiOptions = { deps?: CrestodianCommandDeps; planWithAssistant?: CrestodianAssistantPlanner; runTui?: RunTui; + /** "onboarding" swaps the greeting for the first-run setup proposal. */ + welcomeVariant?: "onboarding"; + /** Workspace override for the proposed first-run setup (from --workspace). */ + setupWorkspace?: string; + /** Test seam for the channel-setup wizard hosted by the chat bridge. */ + runChannelSetupWizard?: CrestodianChatEngineOptions["runChannelSetupWizard"]; }; type CrestodianHistoryMessage = { @@ -40,23 +46,17 @@ type CrestodianHistoryMessage = { timestamp: number; }; -type CaptureRuntime = RuntimeEnv & { - read: () => string; -}; - const CRESTODIAN_AGENT_ID = "crestodian"; const CRESTODIAN_SESSION_KEY = buildAgentMainSessionKey({ agentId: CRESTODIAN_AGENT_ID }); -function createCaptureRuntime(): CaptureRuntime { - const lines: string[] = []; - return { - log: (...args) => lines.push(args.join(" ")), - error: (...args) => lines.push(args.join(" ")), - exit: (code) => { - throw new Error(`Crestodian operation exited with code ${String(code)}`); - }, - read: () => lines.join("\n").trim(), - }; +function createChatEngine(opts: CrestodianTuiOptions): CrestodianChatEngine { + return new CrestodianChatEngine({ + yes: opts.yes, + deps: opts.deps, + planWithAssistant: opts.planWithAssistant, + surface: "cli", + ...(opts.runChannelSetupWizard ? { runChannelSetupWizard: opts.runChannelSetupWizard } : {}), + }); } async function loadOverviewForTui(opts: CrestodianTuiOptions) { @@ -98,7 +98,7 @@ class CrestodianTuiBackend implements TuiBackend { onGap?: (info: { expected: number; received: number }) => void; private seq = 0; - private pending: CrestodianOperation | null = null; + private engine: CrestodianChatEngine; private handoff: CrestodianOperation | null = null; private requestExit: (() => void) | null = null; private readonly messages: CrestodianHistoryMessage[] = []; @@ -106,7 +106,9 @@ class CrestodianTuiBackend implements TuiBackend { constructor( private readonly opts: CrestodianTuiOptions, welcome: string, + engine: CrestodianChatEngine, ) { + this.engine = engine; this.messages.push(message("assistant", welcome)); } @@ -213,7 +215,9 @@ class CrestodianTuiBackend implements TuiBackend { } async resetSession(): Promise<{ ok: boolean }> { - this.pending = null; + // Reset drops in-flight approvals/wizards along with the transcript. + await this.engine.dispose(); + this.engine = createChatEngine(this.opts); const overview = await loadOverviewForTui(this.opts); this.messages.splice( 0, @@ -232,6 +236,10 @@ class CrestodianTuiBackend implements TuiBackend { return []; } + async dispose(): Promise { + await this.engine.dispose(); + } + private nextSeq(): number { this.seq += 1; return this.seq; @@ -271,59 +279,19 @@ class CrestodianTuiBackend implements TuiBackend { private async respond(runId: string, sessionKey: string, text: string): Promise { try { - const reply = await this.resolveReply(text); - this.emitFinal(runId, sessionKey, reply); + const reply = await this.engine.handle(text); + if (reply.action === "open-tui" && reply.handoff) { + // Handoff exits Crestodian's local backend and lets the outer loop open the normal agent TUI. + this.handoff = reply.handoff; + queueMicrotask(() => this.requestExit?.()); + } else if (reply.action === "exit") { + queueMicrotask(() => this.requestExit?.()); + } + this.emitFinal(runId, sessionKey, reply.text); } catch (error) { this.emitError(runId, sessionKey, error); } } - - private async resolveReply(text: string): Promise { - if (this.pending) { - // A pending persistent operation consumes the next reply as approval or cancellation. - if (isYes(text)) { - const pending = this.pending; - this.pending = null; - const capture = createCaptureRuntime(); - await executeCrestodianOperation(pending, capture, { - approved: true, - deps: this.opts.deps, - }); - return capture.read() || "Applied. Audit entry written."; - } - this.pending = null; - return "Skipped. No barnacles on config today."; - } - - const capture = createCaptureRuntime(); - const operation = await resolveCrestodianOperation(text, capture, this.opts); - - if (operation.kind === "open-tui") { - // Handoff exits Crestodian's local backend and lets the outer loop open the normal agent TUI. - this.handoff = operation; - queueMicrotask(() => this.requestExit?.()); - return "Opening your normal agent TUI. Use /crestodian there to come back."; - } - - if (isPersistentCrestodianOperation(operation) && !this.opts.yes) { - this.pending = operation; - await executeCrestodianOperation(operation, capture, { - approved: false, - deps: this.opts.deps, - }); - return [capture.read(), approvalQuestion(operation)].filter(Boolean).join("\n\n"); - } - - await executeCrestodianOperation(operation, capture, { - approved: this.opts.yes === true || !isPersistentCrestodianOperation(operation), - deps: this.opts.deps, - }); - const reply = capture.read(); - if (operation.kind === "none" && reply.includes("Bye.")) { - queueMicrotask(() => this.requestExit?.()); - } - return reply; - } } export async function runCrestodianTui( @@ -331,19 +299,37 @@ export async function runCrestodianTui( runtime: RuntimeEnv, ): Promise { let nextInput: string | undefined; + let welcomeVariant = opts.welcomeVariant; for (;;) { - const overview = await loadOverviewForTui(opts); - const backend = new CrestodianTuiBackend(opts, formatCrestodianStartupMessage(overview)); + const engine = createChatEngine(opts); + let welcome: string; + if (welcomeVariant === "onboarding") { + welcome = await buildOnboardingWelcome({ + engine, + ...(opts.setupWorkspace ? { workspace: opts.setupWorkspace } : {}), + }); + } else { + welcome = formatCrestodianStartupMessage(await loadOverviewForTui(opts)); + engine.noteAssistantMessage(welcome); + } + // The onboarding greeting applies to the first shell only; re-entry after + // an agent handoff uses the normal repair-oriented startup message. + welcomeVariant = undefined; + const backend = new CrestodianTuiBackend(opts, welcome, engine); const runTui = opts.runTui ?? defaultRunTui; - await runTui({ - local: true, - session: CRESTODIAN_SESSION_KEY, - historyLimit: 200, - backend, - config: {}, - title: "openclaw crestodian", - ...(nextInput ? { message: nextInput } : {}), - }); + try { + await runTui({ + local: true, + session: CRESTODIAN_SESSION_KEY, + historyLimit: 200, + backend, + config: {}, + title: "openclaw crestodian", + ...(nextInput ? { message: nextInput } : {}), + }); + } finally { + await backend.dispose(); + } const handoff = backend.consumeHandoff(); if (!handoff) { diff --git a/src/gateway/local-request-context.ts b/src/gateway/local-request-context.ts index b34fde52ef63..bd461a020b24 100644 --- a/src/gateway/local-request-context.ts +++ b/src/gateway/local-request-context.ts @@ -135,6 +135,7 @@ export function createLocalGatewayRequestContext( registerToolEventRecipient: () => {}, dedupe: new Map(), wizardSessions: new Map(), + crestodianSessions: new Map(), findRunningWizard: () => null, purgeWizardSession: () => {}, getRuntimeSnapshot: () => ({}) as ChannelRuntimeSnapshot, diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 0ba214869109..3fcbf2acc1bf 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -66,6 +66,7 @@ export const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "plugin.approval.resolve", scope: "operator.approvals" }, { name: "plugins.uiDescriptors", scope: "operator.read" }, { name: "plugins.sessionAction", scope: "dynamic" }, + { name: "crestodian.chat", scope: "operator.admin" }, { name: "wizard.start", scope: "operator.admin" }, { name: "wizard.next", scope: "operator.admin" }, { name: "wizard.cancel", scope: "operator.admin" }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index abfdf1ab5226..8d58facc9577 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -234,6 +234,10 @@ const loadWebHandlers = lazyHandlerModule( () => import("./server-methods/web.js"), (module) => module.webHandlers, ); +const loadCrestodianHandlers = lazyHandlerModule( + () => import("./server-methods/crestodian.js"), + (module) => module.crestodianHandlers, +); const loadWizardHandlers = lazyHandlerModule( () => import("./server-methods/wizard.js"), (module) => module.wizardHandlers, @@ -430,6 +434,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["wizard.start", "wizard.next", "wizard.cancel", "wizard.status"], loadHandlers: loadWizardHandlers, }), + ...createLazyCoreHandlers({ + methods: ["crestodian.chat"], + loadHandlers: loadCrestodianHandlers, + }), ...createLazyCoreHandlers({ methods: [ "talk.session.create", diff --git a/src/gateway/server-methods/crestodian.test.ts b/src/gateway/server-methods/crestodian.test.ts new file mode 100644 index 000000000000..bd9e5fa67534 --- /dev/null +++ b/src/gateway/server-methods/crestodian.test.ts @@ -0,0 +1,130 @@ +// crestodian.chat handler tests: session reuse, reset, and action mapping. +import { describe, expect, it, vi } from "vitest"; +import { CrestodianChatEngine } from "../../crestodian/chat-engine.js"; +import { crestodianHandlers, type CrestodianChatSession } from "./crestodian.js"; +import type { GatewayRequestContext } from "./types.js"; + +type RespondCall = { + ok: boolean; + payload?: unknown; + error?: unknown; +}; + +function makeRespond() { + const calls: RespondCall[] = []; + const respond = (ok: boolean, payload?: unknown, error?: unknown) => { + calls.push({ ok, payload, error }); + }; + return { calls, respond }; +} + +function makeContext(sessions: Map): GatewayRequestContext { + return { crestodianSessions: sessions } as unknown as GatewayRequestContext; +} + +function seededSession(overrides?: Partial): CrestodianChatSession { + return { + engine: new CrestodianChatEngine({}), + welcome: "welcome text", + lastUsedAt: 1, + ...overrides, + }; +} + +async function callChat( + context: GatewayRequestContext, + params: Record, +): Promise { + const { calls, respond } = makeRespond(); + await crestodianHandlers["crestodian.chat"]({ + params, + respond, + context, + } as never); + const call = calls[0]; + if (!call) { + throw new Error("expected a respond call"); + } + return call; +} + +describe("crestodian.chat", () => { + it("rejects invalid params", async () => { + const call = await callChat(makeContext(new Map()), {}); + expect(call.ok).toBe(false); + }); + + it("returns the stored welcome when no message is sent", async () => { + const sessions = new Map([["s1", seededSession()]]); + const call = await callChat(makeContext(sessions), { sessionId: "s1" }); + expect(call.ok).toBe(true); + expect(call.payload).toMatchObject({ sessionId: "s1", reply: "welcome text", action: "none" }); + }); + + it("routes messages through the session engine", async () => { + const engine = new CrestodianChatEngine({}); + const handle = vi + .spyOn(engine, "handle") + .mockResolvedValue({ text: "did the thing", action: "none" }); + const sessions = new Map([["s1", seededSession({ engine })]]); + + const call = await callChat(makeContext(sessions), { sessionId: "s1", message: "status" }); + + expect(handle).toHaveBeenCalledWith("status"); + expect(call.payload).toMatchObject({ reply: "did the thing", action: "none" }); + }); + + it("forwards sensitive-input metadata to clients", async () => { + const engine = new CrestodianChatEngine({}); + vi.spyOn(engine, "handle").mockResolvedValue({ + text: "Enter the bot token", + action: "none", + sensitive: true, + }); + const sessions = new Map([["s1", seededSession({ engine })]]); + + const call = await callChat(makeContext(sessions), { sessionId: "s1", message: "yes" }); + + expect(call.payload).toMatchObject({ sensitive: true }); + }); + + it("maps the TUI handoff to an open-agent action for clients", async () => { + const engine = new CrestodianChatEngine({}); + vi.spyOn(engine, "handle").mockResolvedValue({ + text: "", + action: "open-tui", + handoff: { kind: "open-tui" }, + }); + const sessions = new Map([["s1", seededSession({ engine })]]); + + const call = await callChat(makeContext(sessions), { + sessionId: "s1", + message: "talk to agent", + }); + + expect(call.payload).toMatchObject({ action: "open-agent" }); + expect((call.payload as { reply: string }).reply).toContain("continue with your agent"); + }); + + it("resets a session on request", async () => { + const engine = new CrestodianChatEngine({}); + const handle = vi.spyOn(engine, "handle"); + const dispose = vi.spyOn(engine, "dispose").mockResolvedValue(); + const sessions = new Map([["s1", seededSession({ engine })]]); + // Reset drops the stored session; loading a fresh welcome would hit real + // discovery, so stub the overview loader on the replacement engine path by + // asserting the old engine is gone instead. + const { calls, respond } = makeRespond(); + const context = makeContext(sessions); + const pending = crestodianHandlers["crestodian.chat"]({ + params: { sessionId: "s1", reset: true }, + respond, + context, + } as never); + await pending; + expect(handle).not.toHaveBeenCalled(); + expect(dispose).toHaveBeenCalledOnce(); + expect(sessions.get("s1")?.engine).not.toBe(engine); + expect(calls[0]?.ok).toBe(true); + }); +}); diff --git a/src/gateway/server-methods/crestodian.ts b/src/gateway/server-methods/crestodian.ts new file mode 100644 index 000000000000..6880267dd08b --- /dev/null +++ b/src/gateway/server-methods/crestodian.ts @@ -0,0 +1,100 @@ +// Crestodian gateway methods host the setup/repair conversation for clients. +import { validateCrestodianChatParams } from "../../../packages/gateway-protocol/src/index.js"; +import { CrestodianChatEngine } from "../../crestodian/chat-engine.js"; +import { buildOnboardingWelcome } from "../../crestodian/onboarding-welcome.js"; +import { formatCrestodianStartupMessage } from "../../crestodian/overview.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +/** + * `crestodian.chat` lets clients (macOS app onboarding, future UIs) run the + * same conversational setup as `openclaw crestodian`. It is configless-safe: + * the engine answers deterministically before any model is configured, so the + * app can onboard a fresh machine entirely through this one method. + * + * Sessions are process-local by design — Crestodian state is an in-flight + * conversation, not persisted data. The map is bounded; the oldest session is + * evicted first, and `reset: true` starts a session over explicitly. + */ +export type CrestodianChatSession = { + engine: CrestodianChatEngine; + welcome: string; + lastUsedAt: number; +}; + +const MAX_CRESTODIAN_SESSIONS = 8; + +async function evictOldestSession(sessions: Map): Promise { + if (sessions.size < MAX_CRESTODIAN_SESSIONS) { + return; + } + let oldestKey: string | undefined; + let oldestAt = Number.POSITIVE_INFINITY; + for (const [key, session] of sessions) { + if (session.lastUsedAt < oldestAt) { + oldestAt = session.lastUsedAt; + oldestKey = key; + } + } + if (oldestKey !== undefined) { + await sessions.get(oldestKey)?.engine.dispose(); + sessions.delete(oldestKey); + } +} + +export const crestodianHandlers: GatewayRequestHandlers = { + "crestodian.chat": async ({ params, respond, context }) => { + if (!assertValidParams(params, validateCrestodianChatParams, "crestodian.chat", respond)) { + return; + } + const sessions = context.crestodianSessions; + const sessionId = params.sessionId; + if (params.reset) { + await sessions.get(sessionId)?.engine.dispose(); + sessions.delete(sessionId); + } + let session = sessions.get(sessionId); + if (!session) { + // The gateway surface must never install/restart its own daemon; the + // engine's setup path honors this via surface: "gateway". + const engine = new CrestodianChatEngine({ surface: "gateway" }); + let welcome: string; + if (params.welcomeVariant === "onboarding") { + welcome = await buildOnboardingWelcome({ engine }); + } else { + welcome = formatCrestodianStartupMessage(await engine.loadOverview()); + engine.noteAssistantMessage(welcome); + } + await evictOldestSession(sessions); + session = { engine, welcome, lastUsedAt: Date.now() }; + sessions.set(sessionId, session); + if (params.message === undefined || !params.message.trim()) { + respond(true, { sessionId, reply: session.welcome, action: "none" }, undefined); + return; + } + } + session.lastUsedAt = Date.now(); + if (params.message === undefined || !params.message.trim()) { + respond(true, { sessionId, reply: session.welcome, action: "none" }, undefined); + return; + } + const reply = await session.engine.handle(params.message); + // The TUI-only "open-tui" handoff becomes a client-visible "open-agent" + // signal: the app should move the user to their normal agent chat. + const action = reply.action === "open-tui" ? "open-agent" : reply.action; + respond( + true, + { + sessionId, + reply: + reply.text || + (action === "open-agent" + ? "Setup here is done — continue with your agent." + : "Nothing to change."), + action, + ...(reply.sensitive === true ? { sensitive: true } : {}), + }, + undefined, + ); + }, +}; diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index a662b1af8301..204321afdeed 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -135,6 +135,7 @@ export type GatewayRequestContext = { registerToolEventRecipient: (runId: string, connId: string) => void; dedupe: Map; wizardSessions: Map; + crestodianSessions: Map; findRunningWizard: () => string | null; purgeWizardSession: (id: string) => void; getRuntimeSnapshot: () => ChannelRuntimeSnapshot; diff --git a/src/gateway/server-methods/wizard.ts b/src/gateway/server-methods/wizard.ts index 4305d030a309..b5408bb9855e 100644 --- a/src/gateway/server-methods/wizard.ts +++ b/src/gateway/server-methods/wizard.ts @@ -81,7 +81,11 @@ export const wizardHandlers: GatewayRequestHandlers = { return; } try { - await session.answer(answer.stepId ?? "", answer.value); + const validationError = await session.answer(answer.stepId ?? "", answer.value); + if (validationError) { + respond(true, { ...(await session.next()), error: validationError }, undefined); + return; + } } catch (err) { respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err))); return; diff --git a/src/gateway/server-request-context.test.ts b/src/gateway/server-request-context.test.ts index 9b246137286a..4eb8660bd47b 100644 --- a/src/gateway/server-request-context.test.ts +++ b/src/gateway/server-request-context.test.ts @@ -69,6 +69,7 @@ function makeContextParams( registerToolEventRecipient: vi.fn(), dedupe: new Map(), wizardSessions: new Map(), + crestodianSessions: new Map(), findRunningWizard: vi.fn(() => null), purgeWizardSession: vi.fn(), getRuntimeSnapshot: vi.fn(() => ({}) as never), diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 9e10fb73b542..15da578a36e9 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -61,6 +61,7 @@ export type GatewayRequestContextParams = { registerToolEventRecipient: GatewayRequestContext["registerToolEventRecipient"]; dedupe: GatewayRequestContext["dedupe"]; wizardSessions: GatewayRequestContext["wizardSessions"]; + crestodianSessions: GatewayRequestContext["crestodianSessions"]; findRunningWizard: GatewayRequestContext["findRunningWizard"]; purgeWizardSession: GatewayRequestContext["purgeWizardSession"]; getRuntimeSnapshot: GatewayRequestContext["getRuntimeSnapshot"]; @@ -206,6 +207,7 @@ export function createGatewayRequestContext( registerToolEventRecipient: params.registerToolEventRecipient, dedupe: params.dedupe, wizardSessions: params.wizardSessions, + crestodianSessions: params.crestodianSessions, findRunningWizard: params.findRunningWizard, purgeWizardSession: params.purgeWizardSession, getRuntimeSnapshot: params.getRuntimeSnapshot, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6c0e717ec10a..fd743632d808 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -828,6 +828,10 @@ export async function startGatewayServer( const wizardRunner = opts.wizardRunner ?? runDefaultSetupWizard; const { wizardSessions, findRunningWizard, purgeWizardSession } = createWizardSessionTracker(); + const crestodianSessions = new Map< + string, + import("./server-methods/crestodian.js").CrestodianChatSession + >(); const deps = createDefaultDeps(); let runtimeState: GatewayServerLiveState | null = null; @@ -1492,6 +1496,7 @@ export async function startGatewayServer( registerToolEventRecipient: toolEventRecipients.add, dedupe, wizardSessions, + crestodianSessions, findRunningWizard, purgeWizardSession, getRuntimeSnapshot, diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index 87401c90c62d..125443bd03c4 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -75,6 +75,49 @@ describe("WizardSession", () => { await session.answer(first.step.id, null); }); + test("keeps a validated text step pending after an invalid answer", async () => { + const session = new WizardSession(async (prompter) => { + await prompter.text({ + message: "Port", + validate: (value) => (value === "18789" ? undefined : "Enter the expected port"), + }); + }); + + const first = await session.next(); + if (!first.step) { + throw new Error("expected text step"); + } + await expect(session.answer(first.step.id, "banana")).resolves.toBe("Enter the expected port"); + expect(session.getStatus()).toBe("running"); + expect((await session.next()).step?.id).toBe(first.step.id); + + await session.answer(first.step.id, "18789"); + expect((await session.next()).status).toBe("done"); + }); + + test("rejects non-scalar text answers before validation and resolution", async () => { + let resolved: string | undefined; + const session = new WizardSession(async (prompter) => { + resolved = await prompter.text({ + message: "Token", + validate: (value) => (value.length > 0 ? undefined : "Token is required"), + }); + }); + + const first = await session.next(); + if (!first.step) { + throw new Error("expected text step"); + } + await expect(session.answer(first.step.id, ["token"])).resolves.toBe( + "wizard: text answer must be a scalar value", + ); + expect((await session.next()).step?.id).toBe(first.step.id); + + await session.answer(first.step.id, "token"); + expect((await session.next()).status).toBe("done"); + expect(resolved).toBe("token"); + }); + test("cancel marks session and unblocks", async () => { const session = new WizardSession(async (prompter) => { await prompter.text({ message: "Name" }); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 0edf0c3287ee..17734464ad24 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -5,13 +5,13 @@ import { WizardCancelledError, type WizardProgress, type WizardPrompter } from " // WizardSession exposes interactive setup as a step/answer protocol for remote // clients while reusing the same WizardPrompter contract as the local CLI. -type WizardStepOption = { +export type WizardStepOption = { value: unknown; label: string; hint?: string; }; -type WizardStep = { +export type WizardStep = { id: string; type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; title?: string; @@ -33,6 +33,19 @@ type WizardNextResult = { error?: string; }; +function normalizeTextAnswer(value: unknown): string | undefined { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); + } + return undefined; +} + class WizardSessionPrompter implements WizardPrompter { constructor(private session: WizardSession) {} @@ -107,14 +120,18 @@ class WizardSessionPrompter implements WizardPrompter { validate?: (value: string) => string | undefined; sensitive?: boolean; }): Promise { - const res = await this.prompt({ - type: "text", - message: params.message, - initialValue: params.initialValue, - placeholder: params.placeholder, - sensitive: params.sensitive, - executor: "client", - }); + const res = await this.session.awaitAnswer( + { + type: "text", + message: params.message, + initialValue: params.initialValue, + placeholder: params.placeholder, + sensitive: params.sensitive, + executor: "client", + id: randomUUID(), + }, + params.validate, + ); const value = res === null || res === undefined ? "" @@ -123,10 +140,6 @@ class WizardSessionPrompter implements WizardPrompter { : typeof res === "number" || typeof res === "boolean" || typeof res === "bigint" ? String(res) : ""; - const error = params.validate?.(value); - if (error) { - throw new Error(error); - } return value; } @@ -161,7 +174,14 @@ export class WizardSession { private currentStep: WizardStep | null = null; private stepDeferred: Deferred | null = null; private pendingTerminalResolution = false; - private answerDeferred = new Map>(); + private answerDeferred = new Map< + string, + { + deferred: Deferred; + text: boolean; + validate?: (value: string) => string | undefined; + } + >(); private status: WizardSessionStatus = "running"; private error: string | undefined; @@ -191,14 +211,23 @@ export class WizardSession { return { done: true, status: this.status, error: this.error }; } - async answer(stepId: string, value: unknown): Promise { - const deferred = this.answerDeferred.get(stepId); - if (!deferred) { + async answer(stepId: string, value: unknown): Promise { + const pending = this.answerDeferred.get(stepId); + if (!pending) { throw new Error("wizard: no pending step"); } + const normalizedValue = pending.text ? normalizeTextAnswer(value) : value; + if (pending.text && normalizedValue === undefined) { + return "wizard: text answer must be a scalar value"; + } + const validationError = pending.validate?.(normalizedValue as string) ?? undefined; + if (validationError) { + return validationError; + } this.answerDeferred.delete(stepId); this.currentStep = null; - deferred.resolve(value); + pending.deferred.resolve(normalizedValue); + return undefined; } cancel() { @@ -208,10 +237,10 @@ export class WizardSession { this.status = "cancelled"; this.error = "cancelled"; this.currentStep = null; - for (const [, deferred] of this.answerDeferred) { + for (const [, pending] of this.answerDeferred) { // Reject all pending prompt promises so the runner can unwind through its // normal cancellation path. - deferred.reject(new WizardCancelledError()); + pending.deferred.reject(new WizardCancelledError()); } this.answerDeferred.clear(); this.resolveStep(null); @@ -239,13 +268,16 @@ export class WizardSession { } } - async awaitAnswer(step: WizardStep): Promise { + async awaitAnswer( + step: WizardStep, + validate?: (value: string) => string | undefined, + ): Promise { if (this.status !== "running") { throw new Error("wizard: session not running"); } this.pushStep(step); const deferred = createDeferred(); - this.answerDeferred.set(step.id, deferred); + this.answerDeferred.set(step.id, { deferred, text: step.type === "text", validate }); return await deferred.promise; } diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 44ab4ac8aade..7a43fb192aa0 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -188,14 +188,22 @@ const loadOnboardSearchModule = createLazyRuntimeModule( () => import("../commands/onboard-search.js"), ); -export async function finalizeSetupWizard( - options: FinalizeOnboardingOptions, -): Promise<{ launchedTui: boolean }> { - const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options; - const suppressGatewayTokenOutput = opts.suppressGatewayTokenOutput === true; - let gatewayProbe: { ok: boolean; detail?: string } = { ok: true }; - let resolvedGatewayPassword = ""; - let sessionGateway: import("../gateway/server.js").GatewayServer | undefined; +/** + * Ensure the gateway service matches the onboarding decision: prompt/decide + * whether to install the daemon, then install/restart/reinstall it. Shared by + * the classic wizard finalize and the bootstrap onboarding flow. + */ +export async function ensureGatewayServiceForOnboarding(params: { + flow: WizardFlow; + opts: Pick; + nextConfig: OpenClawConfig; + settings: Pick; + prompter: WizardPrompter; + runtime: RuntimeEnv; + /** Pre-answer the "service already installed" prompt (conversational flows). */ + loadedAction?: "restart"; +}): Promise<{ installDaemon: boolean; containerWithoutUserSystemd: boolean }> { + const { flow, opts, nextConfig, settings, prompter, runtime } = params; const withWizardProgress = async ( label: string, @@ -285,14 +293,16 @@ export async function finalizeSetupWizard( const loaded = await service.isLoaded({ env: process.env }); let restartWasScheduled = false; if (loaded) { - const action = await prompter.select({ - message: t("wizard.finalize.alreadyInstalled"), - options: [ - { value: "restart", label: t("wizard.finalize.restart") }, - { value: "reinstall", label: t("wizard.finalize.reinstall") }, - { value: "skip", label: t("common.skip") }, - ], - }); + const action = + params.loadedAction ?? + (await prompter.select({ + message: t("wizard.finalize.alreadyInstalled"), + options: [ + { value: "restart", label: t("wizard.finalize.restart") }, + { value: "reinstall", label: t("wizard.finalize.reinstall") }, + { value: "skip", label: t("common.skip") }, + ], + })); if (action === "restart") { let restartDoneMessage = t("wizard.finalize.gatewayServiceRestarted"); await withWizardProgress( @@ -385,6 +395,27 @@ export async function finalizeSetupWizard( } } + return { installDaemon, containerWithoutUserSystemd }; +} + +export async function finalizeSetupWizard( + options: FinalizeOnboardingOptions, +): Promise<{ launchedTui: boolean }> { + const { flow, opts, baseConfig, nextConfig, settings, prompter, runtime } = options; + const suppressGatewayTokenOutput = opts.suppressGatewayTokenOutput === true; + let gatewayProbe: { ok: boolean; detail?: string } = { ok: true }; + let resolvedGatewayPassword = ""; + let sessionGateway: import("../gateway/server.js").GatewayServer | undefined; + + const { installDaemon, containerWithoutUserSystemd } = await ensureGatewayServiceForOnboarding({ + flow, + opts, + nextConfig, + settings, + prompter, + runtime, + }); + if (settings.authMode === "password") { try { resolvedGatewayPassword = diff --git a/src/wizard/setup.model-auth.ts b/src/wizard/setup.model-auth.ts new file mode 100644 index 000000000000..cca680990027 --- /dev/null +++ b/src/wizard/setup.model-auth.ts @@ -0,0 +1,259 @@ +// Model/auth provider selection step shared by the classic wizard and bootstrap onboarding. +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { AuthChoice, OnboardOptions } from "../commands/onboard-types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; +import { t } from "./i18n/index.js"; +import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; + +type KeepCurrentAuthChoice = + typeof import("../commands/auth-choice-prompt.js").KEEP_CURRENT_AUTH_CHOICE; + +const loadAuthChoiceModule = createLazyRuntimeModule(() => import("../commands/auth-choice.js")); + +const loadModelPickerModule = createLazyRuntimeModule(() => import("../commands/model-picker.js")); + +function isAuthChoiceSelected( + value: AuthChoice | KeepCurrentAuthChoice, + keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined, +): value is AuthChoice { + return keepCurrentAuthChoice === undefined || value !== keepCurrentAuthChoice; +} + +async function resolveAuthChoiceModelSelectionPolicy(params: { + authChoice: string; + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + resolvePreferredProviderForAuthChoice: (params: { + choice: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + }) => Promise; +}): Promise<{ + preferredProvider?: string; + promptWhenAuthChoiceProvided: boolean; + allowKeepCurrent: boolean; +}> { + const preferredProvider = await params.resolvePreferredProviderForAuthChoice({ + choice: params.authChoice, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }); + + const [{ resolveManifestProviderAuthChoice }, { resolvePluginSetupProvider }] = await Promise.all( + [import("../plugins/provider-auth-choices.js"), import("../plugins/setup-registry.js")], + ); + const manifestChoice = resolveManifestProviderAuthChoice(params.authChoice, { + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); + if (manifestChoice) { + const setupProvider = resolvePluginSetupProvider({ + provider: manifestChoice.providerId, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + pluginIds: [manifestChoice.pluginId], + }); + const setupMethod = setupProvider?.auth.find( + (method) => normalizeProviderId(method.id) === normalizeProviderId(manifestChoice.methodId), + ); + const setupPolicy = + setupMethod?.wizard?.modelSelection ?? setupProvider?.wizard?.setup?.modelSelection; + return { + preferredProvider, + promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, + allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, + }; + } + + const { resolvePluginProviders, resolveProviderPluginChoice } = + await import("../plugins/provider-auth-choice.runtime.js"); + const providers = resolvePluginProviders({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + mode: "setup", + }); + const resolvedChoice = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + const matchedProvider = + resolvedChoice?.provider ?? + (() => { + const preferredId = preferredProvider?.trim(); + if (!preferredId) { + return undefined; + } + return providers.find( + (provider) => typeof provider.id === "string" && provider.id.trim() === preferredId, + ); + })(); + const setupPolicy = + resolvedChoice?.wizard?.modelSelection ?? matchedProvider?.wizard?.setup?.modelSelection; + + return { + preferredProvider, + promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, + allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, + }; +} + +/** + * Run the provider auth-choice + default-model selection loop. When + * `opts.authChoice` is set the prompt is skipped and the flag drives the flow + * (public onboarding automation contract). + */ +export async function runSetupModelAuthStep(params: { + config: OpenClawConfig; + opts: OnboardOptions; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir: string; +}): Promise { + const { opts, prompter, runtime, workspaceDir } = params; + let nextConfig = params.config; + const authChoiceFromPrompt = opts.authChoice === undefined; + let authChoice: AuthChoice | KeepCurrentAuthChoice | undefined = opts.authChoice; + let authStore: + | ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]> + | undefined; + let promptAuthChoiceGrouped: + | (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"] + | undefined; + let keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined; + if (authChoiceFromPrompt) { + const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js"); + const authChoicePromptModule = await import("../commands/auth-choice-prompt.js"); + promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped; + keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE; + authStore = ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }); + } + while (true) { + if (authChoiceFromPrompt) { + authChoice = await promptAuthChoiceGrouped!({ + prompter, + store: authStore!, + includeSkip: true, + config: nextConfig, + workspaceDir, + allowKeepCurrentProvider: true, + }); + } + if (authChoice === undefined) { + throw new WizardCancelledError(t("wizard.setup.authChoiceRequired")); + } + if (!isAuthChoiceSelected(authChoice, keepCurrentAuthChoice)) { + break; + } + + if (authChoice === "custom-api-key") { + const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); + const customResult = await promptCustomApiConfig({ + prompter, + runtime, + config: nextConfig, + secretInputMode: opts.secretInputMode, + }); + nextConfig = customResult.config; + prompter.disableBackNavigation?.(); + break; + } + if (authChoice === "skip") { + // Explicit skip should stay cold: do not bootstrap auth/profile machinery + // or run model/auth checks when the caller already chose to skip setup. + if (authChoiceFromPrompt) { + const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); + const modelSelection = await promptDefaultModel({ + config: nextConfig, + prompter, + allowKeep: true, + ignoreAllowlist: true, + includeProviderPluginSetups: false, + loadCatalog: false, + workspaceDir, + runtime, + }); + if (modelSelection.config) { + nextConfig = modelSelection.config; + } + if (modelSelection.model) { + nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + } + + const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); + } + break; + } + + const [ + { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, + { applyPrimaryModel, promptDefaultModel }, + ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); + prompter.disableBackNavigation?.(); + const authResult = await applyAuthChoice({ + authChoice, + config: nextConfig, + prompter, + runtime, + setDefaultModel: true, + preserveExistingDefaultModel: true, + opts: { + ...opts, + token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined, + }, + }); + nextConfig = authResult.config; + if (authResult.retrySelection) { + if (authChoiceFromPrompt) { + continue; + } + break; + } + if (authResult.agentModelOverride) { + nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); + } + + const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({ + authChoice, + config: nextConfig, + workspaceDir, + resolvePreferredProviderForAuthChoice, + }); + const shouldPromptModelSelection = + authChoiceFromPrompt || authChoiceModelSelectionPolicy?.promptWhenAuthChoiceProvided; + if (shouldPromptModelSelection) { + const modelSelection = await promptDefaultModel({ + config: nextConfig, + prompter, + allowKeep: authChoiceModelSelectionPolicy?.allowKeepCurrent ?? true, + ignoreAllowlist: true, + includeProviderPluginSetups: true, + preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, + browseCatalogOnDemand: true, + workspaceDir, + runtime, + }); + if (modelSelection.config) { + nextConfig = modelSelection.config; + } + if (modelSelection.model) { + nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + } + } + + await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); + break; + } + return nextConfig; +} diff --git a/src/wizard/setup.shared.ts b/src/wizard/setup.shared.ts new file mode 100644 index 000000000000..c458fe5d995e --- /dev/null +++ b/src/wizard/setup.shared.ts @@ -0,0 +1,161 @@ +// Shared setup-wizard steps used by the classic wizard and the bootstrap onboarding flow. +import { + commitConfigWriteWithPendingPluginInstalls, + hasPendingPluginInstallRecords, + stripPendingPluginInstallRecords, + unchangedPendingPluginInstallRecordIds, +} from "../cli/plugins-install-record-commit.js"; +import type { GatewayAuthChoice, OnboardOptions } from "../commands/onboard-types.js"; +import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { t } from "./i18n/index.js"; +import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; +import { + getSecurityConfirmMessage, + getSecurityNoteMessage, + getSecurityNoteTitle, +} from "./setup.security-note.js"; +import type { QuickstartGatewayDefaults } from "./setup.types.js"; + +/** + * Config writes go through the pending-plugin-install commit helper so wizard + * flows never drop install records that a concurrent migration already staged. + */ +export async function writeWizardConfigFile( + configInput: OpenClawConfig, + opts: { + allowConfigSizeDrop?: boolean; + migrationBaseConfig?: OpenClawConfig; + onPendingPluginInstallMigration?: () => void; + } = {}, +): Promise { + let config = configInput; + const allowConfigSizeDrop = opts.allowConfigSizeDrop === true; + if (!allowConfigSizeDrop && hasPendingPluginInstallRecords(config)) { + const migrationBaseConfig = opts.migrationBaseConfig; + if (migrationBaseConfig && hasPendingPluginInstallRecords(migrationBaseConfig)) { + await commitConfigWriteWithPendingPluginInstalls({ + nextConfig: migrationBaseConfig, + writeOptions: { allowConfigSizeDrop: true }, + commit: async (nextConfig, writeOptions) => { + return await replaceConfigFile({ + nextConfig, + ...(writeOptions ? { writeOptions } : {}), + afterWrite: { mode: "auto" }, + }); + }, + }); + config = stripPendingPluginInstallRecords( + config, + unchangedPendingPluginInstallRecordIds(config, migrationBaseConfig), + ); + opts.onPendingPluginInstallMigration?.(); + } + } + const committed = await commitConfigWriteWithPendingPluginInstalls({ + nextConfig: config, + writeOptions: { allowConfigSizeDrop }, + commit: async (nextConfig, writeOptions) => { + return await replaceConfigFile({ + nextConfig, + ...(writeOptions ? { writeOptions } : {}), + afterWrite: { mode: "auto" }, + }); + }, + }); + return committed.config; +} + +export async function readSetupConfigFileSnapshot() { + return await createConfigIO({ pluginValidation: "skip" }).readConfigFileSnapshot(); +} + +/** One-time security acknowledgement; persisted so reruns stay quiet. */ +export async function requireRiskAcknowledgement(params: { + opts: OnboardOptions; + prompter: WizardPrompter; + config: OpenClawConfig; +}): Promise { + if (params.config.wizard?.securityAcknowledgedAt) { + return params.config; + } + if (params.opts.acceptRisk === true) { + return applySecurityAcknowledgement(params.config); + } + + await params.prompter.note(getSecurityNoteMessage(), getSecurityNoteTitle()); + + const ok = await params.prompter.confirm({ + message: getSecurityConfirmMessage(), + initialValue: true, + layout: "vertical", + }); + if (!ok) { + throw new WizardCancelledError(t("wizard.setup.riskNotAccepted")); + } + return applySecurityAcknowledgement(params.config); +} + +function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig { + if (config.wizard?.securityAcknowledgedAt) { + return config; + } + return { + ...config, + wizard: { + ...config.wizard, + securityAcknowledgedAt: new Date().toISOString(), + }, + }; +} + +/** Derive quickstart gateway defaults, preserving any existing gateway settings. */ +export function resolveQuickstartGatewayDefaults( + baseConfig: OpenClawConfig, +): QuickstartGatewayDefaults { + const hasExisting = + typeof baseConfig.gateway?.port === "number" || + baseConfig.gateway?.bind !== undefined || + baseConfig.gateway?.auth?.mode !== undefined || + baseConfig.gateway?.auth?.token !== undefined || + baseConfig.gateway?.auth?.password !== undefined || + baseConfig.gateway?.customBindHost !== undefined || + baseConfig.gateway?.tailscale?.mode !== undefined; + + const bindRaw = baseConfig.gateway?.bind; + const bind = + bindRaw === "loopback" || + bindRaw === "lan" || + bindRaw === "auto" || + bindRaw === "custom" || + bindRaw === "tailnet" + ? bindRaw + : "loopback"; + + let authMode: GatewayAuthChoice = "token"; + if (baseConfig.gateway?.auth?.mode === "token" || baseConfig.gateway?.auth?.mode === "password") { + authMode = baseConfig.gateway.auth.mode; + } else if (baseConfig.gateway?.auth?.token) { + authMode = "token"; + } else if (baseConfig.gateway?.auth?.password) { + authMode = "password"; + } + + const tailscaleRaw = baseConfig.gateway?.tailscale?.mode; + const tailscaleMode = + tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel" + ? tailscaleRaw + : "off"; + + return { + hasExisting, + port: resolveGatewayPort(baseConfig), + bind, + authMode, + tailscaleMode, + token: baseConfig.gateway?.auth?.token, + password: baseConfig.gateway?.auth?.password, + customBindHost: baseConfig.gateway?.customBindHost, + tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false, + }; +} diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index cdefb9f3f295..4ff70c599d6c 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -1,19 +1,7 @@ // Setup wizard orchestrates onboarding prompts and generated OpenClaw config. -import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import { formatCliCommand } from "../cli/command-format.js"; -import { - commitConfigWriteWithPendingPluginInstalls, - hasPendingPluginInstallRecords, - stripPendingPluginInstallRecords, - unchangedPendingPluginInstallRecordIds, -} from "../cli/plugins-install-record-commit.js"; -import type { - AuthChoice, - GatewayAuthChoice, - OnboardMode, - OnboardOptions, -} from "../commands/onboard-types.js"; -import { createConfigIO, replaceConfigFile, resolveGatewayPort } from "../config/config.js"; +import type { GatewayAuthChoice, OnboardMode, OnboardOptions } from "../commands/onboard-types.js"; +import { resolveGatewayPort } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeSecretInputString } from "../config/types.secrets.js"; @@ -28,218 +16,34 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { resolveUserPath } from "../utils.js"; import { t } from "./i18n/index.js"; import { runWizardWithPromptNavigation } from "./navigation-prompter.js"; -import { WizardCancelledError, type WizardPrompter } from "./prompts.js"; +import type { WizardPrompter } from "./prompts.js"; import { detectSetupMigrationSources, listSetupMigrationOptions, runSetupMigrationImport, } from "./setup.migration-import.js"; +import { runSetupModelAuthStep } from "./setup.model-auth.js"; import { resolveSetupSecretInputString } from "./setup.secret-input.js"; import { - getSecurityConfirmMessage, - getSecurityNoteMessage, - getSecurityNoteTitle, -} from "./setup.security-note.js"; + readSetupConfigFileSnapshot, + requireRiskAcknowledgement, + resolveQuickstartGatewayDefaults, + writeWizardConfigFile, +} from "./setup.shared.js"; import type { QuickstartGatewayDefaults, WizardFlow } from "./setup.types.js"; type SetupFlowChoice = WizardFlow | "import" | "keep-model" | `import:${string}`; -type KeepCurrentAuthChoice = - typeof import("../commands/auth-choice-prompt.js").KEEP_CURRENT_AUTH_CHOICE; - -const loadAuthChoiceModule = createLazyRuntimeModule(() => import("../commands/auth-choice.js")); - const loadConfigLoggingModule = createLazyRuntimeModule(() => import("../config/logging.js")); -const loadModelPickerModule = createLazyRuntimeModule(() => import("../commands/model-picker.js")); - const loadOnboardConfigModule = createLazyRuntimeModule( () => import("../commands/onboard-config.js"), ); -async function writeWizardConfigFile( - configInput: OpenClawConfig, - opts: { - allowConfigSizeDrop?: boolean; - migrationBaseConfig?: OpenClawConfig; - onPendingPluginInstallMigration?: () => void; - } = {}, -): Promise { - let config = configInput; - const allowConfigSizeDrop = opts.allowConfigSizeDrop === true; - if (!allowConfigSizeDrop && hasPendingPluginInstallRecords(config)) { - const migrationBaseConfig = opts.migrationBaseConfig; - if (migrationBaseConfig && hasPendingPluginInstallRecords(migrationBaseConfig)) { - await commitConfigWriteWithPendingPluginInstalls({ - nextConfig: migrationBaseConfig, - writeOptions: { allowConfigSizeDrop: true }, - commit: async (nextConfig, writeOptions) => { - return await replaceConfigFile({ - nextConfig, - ...(writeOptions ? { writeOptions } : {}), - afterWrite: { mode: "auto" }, - }); - }, - }); - config = stripPendingPluginInstallRecords( - config, - unchangedPendingPluginInstallRecordIds(config, migrationBaseConfig), - ); - opts.onPendingPluginInstallMigration?.(); - } - } - const committed = await commitConfigWriteWithPendingPluginInstalls({ - nextConfig: config, - writeOptions: { allowConfigSizeDrop }, - commit: async (nextConfig, writeOptions) => { - return await replaceConfigFile({ - nextConfig, - ...(writeOptions ? { writeOptions } : {}), - afterWrite: { mode: "auto" }, - }); - }, - }); - return committed.config; -} - -async function readSetupConfigFileSnapshot() { - return await createConfigIO({ pluginValidation: "skip" }).readConfigFileSnapshot(); -} - -async function resolveAuthChoiceModelSelectionPolicy(params: { - authChoice: string; - config: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - resolvePreferredProviderForAuthChoice: (params: { - choice: string; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - }) => Promise; -}): Promise<{ - preferredProvider?: string; - promptWhenAuthChoiceProvided: boolean; - allowKeepCurrent: boolean; -}> { - const preferredProvider = await params.resolvePreferredProviderForAuthChoice({ - choice: params.authChoice, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - }); - - const [{ resolveManifestProviderAuthChoice }, { resolvePluginSetupProvider }] = await Promise.all( - [import("../plugins/provider-auth-choices.js"), import("../plugins/setup-registry.js")], - ); - const manifestChoice = resolveManifestProviderAuthChoice(params.authChoice, { - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - includeUntrustedWorkspacePlugins: false, - }); - if (manifestChoice) { - const setupProvider = resolvePluginSetupProvider({ - provider: manifestChoice.providerId, - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - pluginIds: [manifestChoice.pluginId], - }); - const setupMethod = setupProvider?.auth.find( - (method) => normalizeProviderId(method.id) === normalizeProviderId(manifestChoice.methodId), - ); - const setupPolicy = - setupMethod?.wizard?.modelSelection ?? setupProvider?.wizard?.setup?.modelSelection; - return { - preferredProvider, - promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, - allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, - }; - } - - const { resolvePluginProviders, resolveProviderPluginChoice } = - await import("../plugins/provider-auth-choice.runtime.js"); - const providers = resolvePluginProviders({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - mode: "setup", - }); - const resolvedChoice = resolveProviderPluginChoice({ - providers, - choice: params.authChoice, - }); - const matchedProvider = - resolvedChoice?.provider ?? - (() => { - const preferredId = preferredProvider?.trim(); - if (!preferredId) { - return undefined; - } - return providers.find( - (provider) => typeof provider.id === "string" && provider.id.trim() === preferredId, - ); - })(); - const setupPolicy = - resolvedChoice?.wizard?.modelSelection ?? matchedProvider?.wizard?.setup?.modelSelection; - - return { - preferredProvider, - promptWhenAuthChoiceProvided: setupPolicy?.promptWhenAuthChoiceProvided === true, - allowKeepCurrent: setupPolicy?.allowKeepCurrent ?? true, - }; -} - -async function requireRiskAcknowledgement(params: { - opts: OnboardOptions; - prompter: WizardPrompter; - config: OpenClawConfig; -}): Promise { - if (params.config.wizard?.securityAcknowledgedAt) { - return params.config; - } - if (params.opts.acceptRisk === true) { - return applySecurityAcknowledgement(params.config); - } - - await params.prompter.note(getSecurityNoteMessage(), getSecurityNoteTitle()); - - const ok = await params.prompter.confirm({ - message: getSecurityConfirmMessage(), - initialValue: true, - layout: "vertical", - }); - if (!ok) { - throw new WizardCancelledError(t("wizard.setup.riskNotAccepted")); - } - return applySecurityAcknowledgement(params.config); -} - -function applySecurityAcknowledgement(config: OpenClawConfig): OpenClawConfig { - if (config.wizard?.securityAcknowledgedAt) { - return config; - } - return { - ...config, - wizard: { - ...config.wizard, - securityAcknowledgedAt: new Date().toISOString(), - }, - }; -} - function hasConfiguredDefaultModel(config: OpenClawConfig): boolean { return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined; } -function isAuthChoiceSelected( - value: AuthChoice | KeepCurrentAuthChoice, - keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined, -): value is AuthChoice { - return keepCurrentAuthChoice === undefined || value !== keepCurrentAuthChoice; -} - function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean { return flow === "import" || flow.startsWith("import:"); } @@ -439,56 +243,7 @@ async function runSetupWizardOnce( } const wizardFlow: WizardFlow = flow === "advanced" ? "advanced" : "quickstart"; - const quickstartGateway: QuickstartGatewayDefaults = (() => { - const hasExisting = - typeof baseConfig.gateway?.port === "number" || - baseConfig.gateway?.bind !== undefined || - baseConfig.gateway?.auth?.mode !== undefined || - baseConfig.gateway?.auth?.token !== undefined || - baseConfig.gateway?.auth?.password !== undefined || - baseConfig.gateway?.customBindHost !== undefined || - baseConfig.gateway?.tailscale?.mode !== undefined; - - const bindRaw = baseConfig.gateway?.bind; - const bind = - bindRaw === "loopback" || - bindRaw === "lan" || - bindRaw === "auto" || - bindRaw === "custom" || - bindRaw === "tailnet" - ? bindRaw - : "loopback"; - - let authMode: GatewayAuthChoice = "token"; - if ( - baseConfig.gateway?.auth?.mode === "token" || - baseConfig.gateway?.auth?.mode === "password" - ) { - authMode = baseConfig.gateway.auth.mode; - } else if (baseConfig.gateway?.auth?.token) { - authMode = "token"; - } else if (baseConfig.gateway?.auth?.password) { - authMode = "password"; - } - - const tailscaleRaw = baseConfig.gateway?.tailscale?.mode; - const tailscaleMode = - tailscaleRaw === "off" || tailscaleRaw === "serve" || tailscaleRaw === "funnel" - ? tailscaleRaw - : "off"; - - return { - hasExisting, - port: resolveGatewayPort(baseConfig), - bind, - authMode, - tailscaleMode, - token: baseConfig.gateway?.auth?.token, - password: baseConfig.gateway?.auth?.password, - customBindHost: baseConfig.gateway?.customBindHost, - tailscaleResetOnExit: baseConfig.gateway?.tailscale?.resetOnExit ?? false, - }; - })(); + const quickstartGateway: QuickstartGatewayDefaults = resolveQuickstartGatewayDefaults(baseConfig); if (flow === "quickstart") { const formatBind = (value: "loopback" | "lan" | "auto" | "custom" | "tailnet") => { @@ -690,141 +445,13 @@ async function runSetupWizardOnce( } if (!keepExistingModelConfig) { - const authChoiceFromPrompt = opts.authChoice === undefined; - let authChoice: AuthChoice | KeepCurrentAuthChoice | undefined = opts.authChoice; - let authStore: - | ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]> - | undefined; - let promptAuthChoiceGrouped: - | (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"] - | undefined; - let keepCurrentAuthChoice: KeepCurrentAuthChoice | undefined; - if (authChoiceFromPrompt) { - const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js"); - const authChoicePromptModule = await import("../commands/auth-choice-prompt.js"); - promptAuthChoiceGrouped = authChoicePromptModule.promptAuthChoiceGrouped; - keepCurrentAuthChoice = authChoicePromptModule.KEEP_CURRENT_AUTH_CHOICE; - authStore = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - } - while (true) { - if (authChoiceFromPrompt) { - authChoice = await promptAuthChoiceGrouped!({ - prompter, - store: authStore!, - includeSkip: true, - config: nextConfig, - workspaceDir, - allowKeepCurrentProvider: true, - }); - } - if (authChoice === undefined) { - throw new WizardCancelledError(t("wizard.setup.authChoiceRequired")); - } - if (!isAuthChoiceSelected(authChoice, keepCurrentAuthChoice)) { - break; - } - - if (authChoice === "custom-api-key") { - const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); - const customResult = await promptCustomApiConfig({ - prompter, - runtime, - config: nextConfig, - secretInputMode: opts.secretInputMode, - }); - nextConfig = customResult.config; - prompter.disableBackNavigation?.(); - break; - } - if (authChoice === "skip") { - // Explicit skip should stay cold: do not bootstrap auth/profile machinery - // or run model/auth checks when the caller already chose to skip setup. - if (authChoiceFromPrompt) { - const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); - const modelSelection = await promptDefaultModel({ - config: nextConfig, - prompter, - allowKeep: true, - ignoreAllowlist: true, - includeProviderPluginSetups: false, - loadCatalog: false, - workspaceDir, - runtime, - }); - if (modelSelection.config) { - nextConfig = modelSelection.config; - } - if (modelSelection.model) { - nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); - } - - const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); - } - break; - } - - const [ - { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, - { applyPrimaryModel, promptDefaultModel }, - ] = await Promise.all([loadAuthChoiceModule(), loadModelPickerModule()]); - prompter.disableBackNavigation?.(); - const authResult = await applyAuthChoice({ - authChoice, - config: nextConfig, - prompter, - runtime, - setDefaultModel: true, - preserveExistingDefaultModel: true, - opts: { - ...opts, - token: opts.authChoice === "apiKey" && opts.token ? opts.token : undefined, - }, - }); - nextConfig = authResult.config; - if (authResult.retrySelection) { - if (authChoiceFromPrompt) { - continue; - } - break; - } - if (authResult.agentModelOverride) { - nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); - } - - const authChoiceModelSelectionPolicy = await resolveAuthChoiceModelSelectionPolicy({ - authChoice, - config: nextConfig, - workspaceDir, - resolvePreferredProviderForAuthChoice, - }); - const shouldPromptModelSelection = - authChoiceFromPrompt || authChoiceModelSelectionPolicy?.promptWhenAuthChoiceProvided; - if (shouldPromptModelSelection) { - const modelSelection = await promptDefaultModel({ - config: nextConfig, - prompter, - allowKeep: authChoiceModelSelectionPolicy?.allowKeepCurrent ?? true, - ignoreAllowlist: true, - includeProviderPluginSetups: true, - preferredProvider: authChoiceModelSelectionPolicy?.preferredProvider, - browseCatalogOnDemand: true, - workspaceDir, - runtime, - }); - if (modelSelection.config) { - nextConfig = modelSelection.config; - } - if (modelSelection.model) { - nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); - } - } - - await warnIfModelConfigLooksOff(nextConfig, prompter, { validateCatalog: false }); - break; - } + nextConfig = await runSetupModelAuthStep({ + config: nextConfig, + opts, + prompter, + runtime, + workspaceDir, + }); } const { configureGatewayForSetup } = await import("./setup.gateway-config.js");