feat(onboard): guided CLI onboarding with live AI verification and classic fallback (#101880)

* feat(onboard): guided CLI onboarding with live AI verification and classic fallback

Interactive `openclaw onboard` (and bare `openclaw` on a fresh install) now
runs a guided flow with macOS-app parity: detect existing AI access, live-test
candidates with a real completion before persisting anything, walk down the
ladder on failure with mapped reasons, and offer verified manual API-key entry
from installed provider manifests (masked input). In-flow escapes: classic
wizard, Crestodian chat, skip-AI. Classic wizard gains an optional post-auth
live verification step. `--classic`, `--modern`, and `--non-interactive`
contracts unchanged. Docs corrected for post-#99935 routing.

Closes #101851

* improve(onboard): quiet probe diagnostics in wizard TTY, carry risk ack into classic escape

Candidate live-tests during guided setup are probes: rename their run id and
lane to the existing probe conventions (logging/subsystem.ts console
suppression, command-queue quiet probe lanes) so expected failures stop
leaking raw diagnostics into the Clack UI; file diagnostics unchanged. The
classic-wizard escape now passes the already-collected risk acknowledgement
through instead of re-prompting in the same session.

* fix(onboard): quiet the session-derived setup-inference probe lane too

The live-test run enqueues on two lanes: the explicit probe lane and one
derived from its temp session key. Extend the shared quiet-probe predicate to
cover the derived lane so a failing candidate cannot leak lane-task
diagnostics into the wizard TTY.

* improve(onboard): suppress subsystem console output during wizard live tests

Provider-transport subsystem loggers (model-fetch start/response, transport
errors) carry no run id, so probe suppression cannot catch them and a failing
candidate printed raw log lines into the Clack TTY. Reuse the TUI console
subsystem-filter seam via a finally-safe scoped helper around guided
activation and the classic live-verify; file logging is unchanged and the
gateway (macOS app) surface is unaffected.

* fix(onboard): never auto-replace a configured model when its live check fails

The re-run verification probe executes outside the configured workspace (setup
never runs workspace plugins), so a workspace-backed current model can fail
the check while working fine in the agent. Stop the auto ladder on an
existing-model failure and hand the decision to the manual stage instead of
silently persisting a different candidate as the default. Docs note the
fail-safe and the workspace caveat.

* feat(onboard): two-way switching between Crestodian chat and the menu wizards

From the chat, `open setup wizard`, `open classic wizard`, and `open channel
wizard for <channel>` hand off to the guided flow, the classic wizard, or the
masked `channels add` wizard after the chat TUI tears down (mirrors the
open-tui handoff; gateway surface gets a text pointer instead). The hosted
channel wizard no longer dead-ends at sensitive steps — it offers the switch
and remembers the channel. New read-only `channel info <channel>` operation
and ring-zero action surface label, blurb, configured state, and the real
docs URL from channel-setup discovery so the assistant can explain Slack or
Telegram prerequisites instead of guessing; both prompts instruct it to use
them. `channels add --channel <id>` now preselects the channel. Docs cover
the interchangeable flows.

* fix(onboard): avoid param reassignment in open-setup handoff

* improve(onboard): separate ask-about vs connect intent in channel prompt guidance

Live test showed the agent detouring an explicit connect request through
channel_info because the guidance said to consult it first. Both prompts now
distinguish asking about a channel (channel info + docs link) from asking to
connect (connect right away).

* fix(channels): mark channel token entry as sensitive input

The shared single-token prompt lacked sensitive:true, so terminal wizards
echoed pasted channel tokens and the Crestodian chat bridge (which refuses
plain-text secrets based on this flag) hosted the Telegram token step in
visible chat. Found live-testing the chat-to-wizard switch; pre-existing on
main but load-bearing for the masked-wizard contract this PR documents.

* fix(onboard): restore terminal state around the guided flow's TUI launch

Mirror the classic finalize handoff so the chat TUI never inherits the wizard
prompter's raw/paused terminal state on the default first-run path.

* fix(channels): type the token prompter mock for the sensitive-flag assertion

* fix(gateway): map the TUI-only open-setup action to none for app clients

Engine-side surface gating already prevents open-setup replies on the gateway
surface; this keeps the client-visible action enum stable even if that gate
ever regresses. (Reviewed with the switching round; missed in its commit.)

* docs: regenerate docs map for onboarding page changes
This commit is contained in:
Peter Steinberger 2026-07-09 12:40:55 +01:00 committed by GitHub
parent a0267273d0
commit e2a112a556
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2227 additions and 130 deletions

View file

@ -15,7 +15,7 @@ Crestodian is OpenClaw's local setup, repair, and configuration helper. It stays
Running `openclaw` with no subcommand routes based on config state: Running `openclaw` with no subcommand routes based on config state:
- Config missing, or exists with no authored settings (empty, or only `$schema`/`meta` keys): starts classic onboarding. - Config missing, or exists with no authored settings (empty, or only `$schema`/`meta` keys): starts guided onboarding with live AI verification.
- Config exists but fails validation: starts Crestodian. - Config exists but fails validation: starts Crestodian.
- Config exists and is valid: opens the normal agent TUI (against a reachable configured Gateway, or locally if none is reachable). Use `/crestodian` inside the TUI, or run `openclaw crestodian` directly, to reach Crestodian. - Config exists and is valid: opens the normal agent TUI (against a reachable configured Gateway, or locally if none is reachable). Use `/crestodian` inside the TUI, or run `openclaw crestodian` directly, to reach Crestodian.
@ -23,7 +23,9 @@ Running `openclaw crestodian` always starts Crestodian explicitly, regardless of
Noninteractive bare `openclaw` (no TTY) exits with a short message instead of printing root help: it points to non-interactive onboarding on a fresh install, to `openclaw crestodian --message "status"` when config is invalid, or to `openclaw agent --local ...` when config is valid. Noninteractive bare `openclaw` (no TTY) exits with a short message instead of printing root help: it points to non-interactive onboarding on a fresh install, to `openclaw crestodian --message "status"` when config is invalid, or to `openclaw agent --local ...` when config is valid.
`openclaw onboard --modern` starts Crestodian as the modern onboarding preview. Plain `openclaw onboard` keeps classic onboarding. `openclaw onboard --modern` starts Crestodian directly. Plain `openclaw onboard`
starts guided onboarding; `openclaw onboard --classic` opens the full
step-by-step wizard.
## What Crestodian shows ## What Crestodian shows
@ -72,6 +74,12 @@ create agent work workspace ~/Projects/work
models models
configure model provider configure model provider
set default model openai/gpt-5.5 set default model openai/gpt-5.5
channels
channel info slack
connect slack
open setup wizard
open classic wizard
open channel wizard for slack
plugins list plugins list
plugins search slack plugins search slack
plugin install clawhub:openclaw-codex-app-server plugin install clawhub:openclaw-codex-app-server
@ -96,10 +104,27 @@ Approval is given in your own words: unambiguous replies ("yes", "sure", "go ahe
Applied writes are recorded in `~/.openclaw/audit/crestodian.jsonl`. Discovery is not audited; only applied operations and writes are. 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 Channel setup can run as a hosted conversation until it reaches a secret. The
input. The local Crestodian TUI does not accept sensitive wizard answers; local Crestodian TUI does not accept sensitive wizard answers because terminal
instead it directs you to `openclaw channels add --channel <channel>`, whose chat input is visible. It offers `open channel wizard` immediately, carrying
interactive prompts mask credentials. the selected channel into the masked terminal wizard; you can also run
`openclaw channels add --channel <channel>` later.
### Switching to the menu wizards
The local chat can hand control back to any terminal menu flow:
```text
open setup wizard
open classic wizard
open channel wizard for slack
channel info slack
```
`open setup wizard` opens guided onboarding. `open classic wizard` opens the
full classic setup. `open channel wizard for <channel>` opens masked channel
setup after the chat TUI closes. Use `channel info <channel>` first for the
channel label, setup state, prerequisites summary, and docs link.
Model-provider setup uses the same provider/auth and default-model steps as Model-provider setup uses the same provider/auth and default-model steps as
`openclaw onboard`. In the local Crestodian TUI, approval exits the chat shell, `openclaw onboard`. In the local Crestodian TUI, approval exits the chat shell,

View file

@ -7,7 +7,10 @@ title: "Onboard"
# `openclaw onboard` # `openclaw onboard`
Guided setup for model auth, workspace, gateway, channels, skills, and health in one flow. `openclaw setup` is the same entry point; `openclaw setup --baseline` only writes the baseline config/workspace. Guided setup that detects existing AI access, verifies it with a live completion,
and configures the workspace and local Gateway. `openclaw setup` is the same
entry point; `openclaw setup --baseline` only writes the baseline
config/workspace.
<CardGroup cols={2}> <CardGroup cols={2}>
<Card title="CLI onboarding hub" href="/start/wizard" icon="rocket"> <Card title="CLI onboarding hub" href="/start/wizard" icon="rocket">
@ -31,6 +34,7 @@ Guided setup for model auth, workspace, gateway, channels, skills, and health in
```bash ```bash
openclaw onboard openclaw onboard
openclaw onboard --classic
openclaw onboard --modern openclaw onboard --modern
openclaw onboard --flow quickstart openclaw onboard --flow quickstart
openclaw onboard --flow manual openclaw onboard --flow manual
@ -40,16 +44,52 @@ openclaw onboard --skip-bootstrap
openclaw onboard --mode remote --remote-url wss://gateway-host:18789 openclaw onboard --mode remote --remote-url wss://gateway-host:18789
``` ```
- `--flow quickstart`: minimal prompts, auto-generates a gateway token. - `--classic`: opens the full step-by-step wizard.
- `--flow manual` (alias `advanced`): full prompts for port, bind, and auth. - `--flow quickstart`: opens the classic wizard with minimal prompts and
auto-generates a gateway token.
- `--flow manual` (alias `advanced`): opens the classic wizard with full prompts
for port, bind, and auth.
- `--flow import`: runs a detected migration provider (for example Hermes via `--import-from hermes`), previews the plan, then applies after confirmation. Import only runs against a fresh OpenClaw setup - reset config, credentials, sessions, and workspace state first if any exist. Use [`openclaw migrate`](/cli/migrate) for dry-run plans, overwrite mode, reports, and exact mappings. - `--flow import`: runs a detected migration provider (for example Hermes via `--import-from hermes`), previews the plan, then applies after confirmation. Import only runs against a fresh OpenClaw setup - reset config, credentials, sessions, and workspace state first if any exist. Use [`openclaw migrate`](/cli/migrate) for dry-run plans, overwrite mode, reports, and exact mappings.
- `--modern` starts the Crestodian conversational setup/repair assistant instead of the classic flow. - `--modern` starts the Crestodian conversational setup/repair assistant.
## Guided flow
Plain `openclaw onboard` starts the guided flow. It shows the security notice,
asks for a workspace, detects AI access already available through configured
models, API-key environment variables, and supported local CLIs, then tests the
recommended candidate with a real completion. If that candidate fails,
onboarding shows the reason and automatically tries the next usable candidate.
If automatic detection is exhausted, choose another detected candidate, enter
a provider API key in a masked prompt, open Crestodian chat, switch to the
classic wizard, or skip AI setup for now. A manual key is tested through the
same live completion path. OpenClaw persists the selected model, workspace, and
QuickStart Gateway settings only after the test succeeds; a failed candidate
does not replace the configured model or save the attempted credential.
Guided setup, the classic wizard, and Crestodian chat are interchangeable. The
guided flow offers chat and classic choices; inside Crestodian, use `open setup
wizard`, `open classic wizard`, or `open channel wizard for <channel>` to switch
back. Channel credentials are always collected in a masked terminal wizard.
On a configured install, running `openclaw onboard` again verifies the current
default model first, so the same flow acts as a verification and repair pass.
If that check fails, the configured model is never replaced automatically —
onboarding stops and asks how to continue. The check runs outside your
workspace, so a model provided by a workspace plugin can fail here while still
working in the agent.
Use `openclaw onboard --classic` for provider-specific auth, channels, skills,
remote Gateway setup, imports, or full Gateway controls. For conversational
setup and repair, run `openclaw crestodian`; `openclaw onboard --modern` opens
the same chat for onboarding. After configuring model/auth, the classic wizard
can optionally verify the default model with a live completion; verification
failure never blocks completion.
In an interactive terminal, bare `openclaw` (no subcommand) routes by config In an interactive terminal, bare `openclaw` (no subcommand) routes by config
state: state:
- If the active config file is missing or has no authored settings (empty or - If the active config file is missing or has no authored settings (empty or
metadata-only), it starts this classic onboarding flow. metadata-only), it starts guided onboarding.
- If the config file exists but fails validation, it starts - If the config file exists but fails validation, it starts
[Crestodian](/cli/crestodian) for repair. [Crestodian](/cli/crestodian) for repair.
- If the config file is valid, it opens the normal agent TUI, either locally - If the config file is valid, it opens the normal agent TUI, either locally

View file

@ -1372,6 +1372,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: What Crestodian shows - H2: What Crestodian shows
- H2: Examples - H2: Examples
- H2: Operations and approval - H2: Operations and approval
- H3: Switching to the menu wizards
- H2: Setup bootstrap - H2: Setup bootstrap
- H2: AI conversation - H2: AI conversation
- H3: CLI harness trust model - H3: CLI harness trust model
@ -1730,6 +1731,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Headings: - Headings:
- H1: openclaw onboard - H1: openclaw onboard
- H2: Examples - H2: Examples
- H2: Guided flow
- H2: Reset - H2: Reset
- H2: Locale - H2: Locale
- H2: Non-interactive setup - H2: Non-interactive setup
@ -8961,8 +8963,9 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /start/wizard - Route: /start/wizard
- Headings: - Headings:
- H2: Locale - H2: Locale
- H2: QuickStart vs Advanced - H2: Guided default
- H2: What onboarding configures - H2: Classic wizard: QuickStart vs Advanced
- H2: What classic onboarding configures
- H2: Add another agent - H2: Add another agent
- H2: Full reference - H2: Full reference
- H2: Related docs - H2: Related docs

View file

@ -7,15 +7,16 @@ title: "Onboarding overview"
sidebarTitle: "Onboarding Overview" sidebarTitle: "Onboarding Overview"
--- ---
OpenClaw has two onboarding paths. Both configure auth, the Gateway, and OpenClaw has terminal and macOS app onboarding. Both can detect existing AI
optional chat channels — they just differ in how you interact with the setup. access, verify it with a live completion, and configure a workspace and Gateway.
The terminal flow also offers the full classic wizard for detailed setup.
## Which path should I use? ## Which path should I use?
| | CLI onboarding | macOS app onboarding | | | CLI onboarding | macOS app onboarding |
| -------------- | -------------------------------------- | --------------------------- | | -------------- | -------------------------------------- | --------------------------- |
| **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only | | **Platforms** | macOS, Linux, Windows (native or WSL2) | macOS only |
| **Interface** | Terminal wizard | Guided UI + Crestodian chat | | **Interface** | Guided, classic, and Crestodian chat | Guided UI + Crestodian chat |
| **Best for** | Servers, headless, full control | Desktop Mac, visual setup | | **Best for** | Servers, headless, full control | Desktop Mac, visual setup |
| **Automation** | `--non-interactive` for scripts | Manual only | | **Automation** | `--non-interactive` for scripts | Manual only |
| **Command** | `openclaw onboard` | Launch the app | | **Command** | `openclaw onboard` | Launch the app |
@ -25,15 +26,19 @@ you the most control.
## What onboarding configures ## What onboarding configures
Regardless of which path you choose, onboarding sets up: Guided onboarding sets up:
1. **Model provider and auth**API key, OAuth, or setup token for your chosen provider 1. **Model provider and auth**detected access or a verified API key
2. **Workspace** — directory for agent files, bootstrap templates, and memory 2. **Workspace** — directory for agent files, bootstrap templates, and memory
3. **Gateway** — port, bind address, auth mode 3. **Gateway** — port, bind address, auth mode
4. **Channels** (optional) — built-in and bundled chat channels such as 4. **Gateway service** — installs, starts, and probes the local Gateway
The classic CLI wizard can additionally configure:
1. **Channels** (optional) — built-in and bundled chat channels such as
Discord, Feishu, Google Chat, iMessage, Mattermost, Microsoft Teams, Discord, Feishu, Google Chat, iMessage, Mattermost, Microsoft Teams,
Telegram, WhatsApp, and more Telegram, WhatsApp, and more
5. **Daemon** (optional) — background service so the Gateway starts automatically 2. **Advanced Gateway controls** — remote mode, network settings, and daemon choices
## CLI onboarding ## CLI onboarding
@ -43,7 +48,20 @@ Run in any terminal:
openclaw onboard openclaw onboard
``` ```
Add `--install-daemon` to also install the background service in one step. The guided flow detects existing AI access, live-tests candidates in order,
falls through on failure, and offers masked manual key entry. It saves the
model and credential only after a passing completion. From the same flow you
can open Crestodian chat, switch to `openclaw onboard --classic`, or skip AI
setup for now.
These CLI interfaces switch both ways: guided onboarding offers Crestodian and
the classic wizard, while Crestodian can open guided setup, classic setup, or a
masked channel wizard without making you restart the command manually.
Use `openclaw onboard --classic` for detailed model/auth, channel, skill,
remote Gateway, or import setup. Adding `--install-daemon` also selects the
classic flow and installs the background service in one step. Use `openclaw
onboard --modern` or `openclaw crestodian` for conversational setup and repair.
Full reference: [Onboarding (CLI)](/start/wizard) Full reference: [Onboarding (CLI)](/start/wizard)
CLI command docs: [`openclaw onboard`](/cli/onboard) CLI command docs: [`openclaw onboard`](/cli/onboard)
@ -62,8 +80,8 @@ Full reference: [Onboarding (macOS App)](/start/onboarding)
## Custom or unlisted providers ## Custom or unlisted providers
If your provider is not listed in onboarding, choose **Custom Provider** and If your provider is not listed, open the classic wizard, choose **Custom
enter: Provider**, and enter:
- Endpoint compatibility: OpenAI-compatible (`/chat/completions`), OpenAI Responses-compatible (`/responses`), Anthropic-compatible (`/messages`), or unknown (probes all three and auto-detects) - Endpoint compatibility: OpenAI-compatible (`/chat/completions`), OpenAI Responses-compatible (`/responses`), Anthropic-compatible (`/messages`), or unknown (probes all three and auto-detects)
- Base URL and API key (API key is optional if the endpoint does not require one) - Base URL and API key (API key is optional if the endpoint does not require one)

View file

@ -12,19 +12,25 @@ openclaw onboard
``` ```
CLI onboarding is the recommended terminal setup path on macOS, Linux, and CLI onboarding is the recommended terminal setup path on macOS, Linux, and
Windows (native or WSL2). It configures a local Gateway (or a connection to a Windows (native or WSL2). By default it detects AI access already available on
remote Gateway), plus channels, skills, and workspace defaults in one guided the machine, verifies it with a real completion, and configures a workspace and
flow. `openclaw setup` runs the same flow ([Setup](/cli/setup) covers the local Gateway. `openclaw setup` runs the same flow ([Setup](/cli/setup) covers
`--baseline` config-only variant). Windows desktop users can also start from the `--baseline` config-only variant). Windows desktop users can also start
[Windows Hub](/platforms/windows). from [Windows Hub](/platforms/windows).
Provider sign-in, channel pairing, daemon install, and skill downloads can The guided flow offers the classic wizard for provider sign-in, remote Gateway
extend a quick setup; optional steps can be skipped and revisited later with setup, channel pairing, daemon controls, skills, and imports. You can also open
`openclaw configure`. Crestodian chat or skip AI setup and return later.
Guided setup, the classic wizard, and Crestodian chat are interchangeable. The
guided flow offers chat and classic choices; inside Crestodian, use `open setup
wizard`, `open classic wizard`, or `open channel wizard for <channel>` to switch
back. Channel setup that needs secrets always continues in a masked terminal
wizard.
<Info> <Info>
Fastest first chat: skip channel setup entirely. Run `openclaw dashboard` and Fastest first chat: finish guided setup, run `openclaw dashboard`, and chat in
chat in the browser through the Control UI. Docs: [Dashboard](/web/dashboard). the browser through the Control UI. Docs: [Dashboard](/web/dashboard).
</Info> </Info>
## Locale ## Locale
@ -52,18 +58,41 @@ openclaw agents add <name>
</Note> </Note>
<Tip> <Tip>
Onboarding includes a web search step where you can pick a provider: Brave, The classic wizard includes a web search step where you can pick a provider: Brave,
DuckDuckGo, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Ollama Web DuckDuckGo, Exa, Firecrawl, Gemini, Grok, Kimi, MiniMax Search, Ollama Web
Search, Perplexity, SearXNG, or Tavily. Some need an API key; others are Search, Perplexity, SearXNG, or Tavily. Some need an API key; others are
key-free. Configure this later with `openclaw configure --section web`. Docs: key-free. Configure this later with `openclaw configure --section web`. Docs:
[Web tools](/tools/web). [Web tools](/tools/web).
</Tip> </Tip>
## QuickStart vs Advanced ## Guided default
Onboarding opens with a choice between **QuickStart** (defaults) and Plain `openclaw onboard` follows this path:
**Advanced** (full control). Pass `--flow quickstart` or `--flow advanced`
(alias `manual`) to skip the prompt. 1. Accept the security notice and choose the workspace.
2. Detect configured models, API-key environment variables, and supported local
AI CLIs.
3. Test the recommended candidate with a real completion. On failure, show the
reason and continue to the next usable candidate.
4. If detection is exhausted, try another detected candidate, enter a provider
API key in a masked prompt, open Crestodian chat, use the classic wizard, or
skip AI setup.
5. Persist the model, credential, workspace, and QuickStart Gateway settings
only after a passing test. Then install/start the Gateway service and probe
it for reachability.
Re-running the command on a configured installation tests the current default
model first, making the guided flow a verification and repair pass. A failing
check never replaces the configured model automatically; onboarding stops and
asks how to continue. Run `openclaw channels add` or `openclaw configure` for
later additions.
## Classic wizard: QuickStart vs Advanced
Run `openclaw onboard --classic` to open the full wizard. It starts with a
choice between **QuickStart** (defaults) and **Advanced** (full control). Pass
`--flow quickstart` or `--flow advanced` (alias `manual`) to select the classic
flow and skip that prompt.
<Tabs> <Tabs>
<Tab title="QuickStart (defaults)"> <Tab title="QuickStart (defaults)">
@ -87,7 +116,7 @@ Remote mode (`--mode remote`) always uses the advanced flow; it only
configures this machine to connect to a Gateway elsewhere and never installs configures this machine to connect to a Gateway elsewhere and never installs
or changes anything on the remote host. or changes anything on the remote host.
## What onboarding configures ## What classic onboarding configures
Local mode (default) walks through these steps: Local mode (default) walks through these steps:
@ -102,7 +131,9 @@ Local mode (default) walks through these steps:
instead of plaintext API key values; the referenced env var must already instead of plaintext API key values; the referenced env var must already
be set, or onboarding fails fast. Interactive secret reference mode can be set, or onboarding fails fast. Interactive secret reference mode can
point at an environment variable or a configured provider ref (`file` or point at an environment variable or a configured provider ref (`file` or
`exec`), with a fast preflight check before saving. `exec`), with a fast preflight check before saving. After model/auth setup,
the wizard offers an optional live completion test; a failure can return to
model/auth setup once or be ignored without blocking the rest of onboarding.
2. **Workspace** - directory for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files. 2. **Workspace** - directory for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
3. **Gateway** - port, bind address, auth mode, Tailscale exposure. In 3. **Gateway** - port, bind address, auth mode, Tailscale exposure. In
interactive token mode, choose plaintext token storage (default) or opt interactive token mode, choose plaintext token storage (default) or opt
@ -130,11 +161,11 @@ config is invalid or contains legacy keys, onboarding asks you to run
`openclaw doctor` first. `openclaw doctor` first.
</Note> </Note>
`--flow import` runs a detected migration flow (for example Hermes) instead of `--flow import` runs a detected migration flow (for example Hermes) in the
fresh setup; see [Migrate](/cli/migrate) and the migration guides under classic wizard instead of fresh setup; see [Migrate](/cli/migrate) and the migration guides under
[Install](/install/migrating-hermes). `openclaw onboard --modern` starts [Install](/install/migrating-hermes). `openclaw onboard --modern` starts
[Crestodian](/cli/crestodian), a conversational setup/repair assistant, in [Crestodian](/cli/crestodian), a conversational setup/repair assistant.
place of the classic wizard. `openclaw crestodian` opens the same assistant directly.
## Add another agent ## Add another agent

View file

@ -56,6 +56,13 @@ describe("crestodian tool", () => {
expect.anything(), expect.anything(),
expect.objectContaining({ approved: false }), expect.objectContaining({ approved: false }),
); );
await tool.execute("t1b", { action: "channel_info", channel: "Slack" });
expect(mocks.executeCrestodianOperation).toHaveBeenCalledWith(
{ kind: "channel-info", channel: "slack" },
expect.anything(),
expect.objectContaining({ approved: false }),
);
}); });
it("refuses mutating actions without the approved assertion", async () => { it("refuses mutating actions without the approved assertion", async () => {
@ -246,6 +253,18 @@ describe("crestodian tool", () => {
expect(toolText(open)).toContain("directive:"); expect(toolText(open)).toContain("directive:");
expect(directiveRef.current).toEqual({ kind: "open-tui", agentId: "work" }); expect(directiveRef.current).toEqual({ kind: "open-tui", agentId: "work" });
const setup = await tool.execute("t7", {
action: "open_setup",
target: "channels",
channel: "Slack",
});
expect(toolText(setup)).toContain("directive:");
expect(directiveRef.current).toEqual({
kind: "open-setup",
target: "channels",
channel: "slack",
});
// Directives are host handoffs, never operation executions. // Directives are host handoffs, never operation executions.
expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled(); expect(mocks.executeCrestodianOperation).not.toHaveBeenCalled();
}); });
@ -269,6 +288,12 @@ describe("crestodian tool", () => {
resultText: "directive: the host now starts masked model-provider setup.", resultText: "directive: the host now starts masked model-provider setup.",
}), }),
).toEqual({ kind: "model-setup", workspace: "/tmp/work" }); ).toEqual({ kind: "model-setup", workspace: "/tmp/work" });
expect(
resolveCrestodianDirectiveTransition({
args: { action: "open_setup", target: "classic" },
resultText: "directive: the host now opens the classic setup wizard.",
}),
).toEqual({ kind: "open-setup", target: "classic" });
// Non-directive results and other actions never mirror. // Non-directive results and other actions never mirror.
expect( expect(
resolveCrestodianDirectiveTransition({ args: { action: "status" }, resultText: "ok" }), resolveCrestodianDirectiveTransition({ args: { action: "status" }, resultText: "ok" }),

View file

@ -42,7 +42,8 @@ export type CrestodianToolOptions = {
export type CrestodianToolDirective = export type CrestodianToolDirective =
| { kind: "channel-setup"; channel: string } | { kind: "channel-setup"; channel: string }
| { kind: "model-setup"; workspace?: string } | { kind: "model-setup"; workspace?: string }
| { kind: "open-tui"; agentId?: string; workspace?: string }; | { kind: "open-tui"; agentId?: string; workspace?: string }
| Extract<CrestodianOperation, { kind: "open-setup" }>;
/** Canonical operation fingerprint used to bind "yes" to one exact mutation. */ /** Canonical operation fingerprint used to bind "yes" to one exact mutation. */
export function hashCrestodianOperation(operation: CrestodianOperation): string { export function hashCrestodianOperation(operation: CrestodianOperation): string {
@ -90,6 +91,9 @@ function directiveForOperation(operation: CrestodianOperation): CrestodianToolDi
...(operation.workspace ? { workspace: operation.workspace } : {}), ...(operation.workspace ? { workspace: operation.workspace } : {}),
}; };
} }
if (operation.kind === "open-setup") {
return operation;
}
return null; return null;
} }
@ -127,6 +131,7 @@ const CRESTODIAN_TOOL_ACTIONS = [
"models", "models",
"agents", "agents",
"channels", "channels",
"channel_info",
"audit", "audit",
"validate_config", "validate_config",
"doctor", "doctor",
@ -138,6 +143,7 @@ const CRESTODIAN_TOOL_ACTIONS = [
"connect_channel", "connect_channel",
"configure_model_provider", "configure_model_provider",
"open_agent", "open_agent",
"open_setup",
// Mutating actions below require approved=true. // Mutating actions below require approved=true.
"setup", "setup",
"set_default_model", "set_default_model",
@ -161,7 +167,14 @@ const CrestodianToolSchema = Type.Object({
workspace: Type.Optional(Type.String({ description: "Workspace directory" })), workspace: Type.Optional(Type.String({ description: "Workspace directory" })),
agentId: Type.Optional(Type.String({ description: "Agent id for create_agent/open_agent" })), agentId: Type.Optional(Type.String({ description: "Agent id for create_agent/open_agent" })),
channel: Type.Optional( channel: Type.Optional(
Type.String({ description: "Channel id for connect_channel (e.g. telegram)" }), Type.String({
description: "Channel id for connect_channel, channel_info, or open_setup channels",
}),
),
target: Type.Optional(
stringEnum(["guided", "classic", "channels"], {
description: "Setup wizard target for open_setup (defaults to guided)",
}),
), ),
query: Type.Optional(Type.String({ description: "Search query for plugin_search" })), query: Type.Optional(Type.String({ description: "Search query for plugin_search" })),
spec: Type.Optional(Type.String({ description: "npm/clawhub spec for plugin_install" })), spec: Type.Optional(Type.String({ description: "npm/clawhub spec for plugin_install" })),
@ -194,6 +207,14 @@ function requireParam(params: Record<string, unknown>, name: string): string {
return value.trim(); return value.trim();
} }
function readSetupTarget(params: Record<string, unknown>): "guided" | "classic" | "channels" {
const target = readStringParam(params, "target")?.trim() ?? "guided";
if (target === "guided" || target === "classic" || target === "channels") {
return target;
}
throw new ToolInputError(`crestodian: unknown setup target "${target}"`);
}
function operationForAction(params: Record<string, unknown>): CrestodianOperation { function operationForAction(params: Record<string, unknown>): CrestodianOperation {
const action = readStringParam(params, "action", { required: true }); const action = readStringParam(params, "action", { required: true });
switch (action) { switch (action) {
@ -205,6 +226,8 @@ function operationForAction(params: Record<string, unknown>): CrestodianOperatio
return { kind: "agents" }; return { kind: "agents" };
case "channels": case "channels":
return { kind: "channel-list" }; return { kind: "channel-list" };
case "channel_info":
return { kind: "channel-info", channel: requireParam(params, "channel").toLowerCase() };
case "audit": case "audit":
return { kind: "audit" }; return { kind: "audit" };
case "validate_config": case "validate_config":
@ -236,6 +259,15 @@ function operationForAction(params: Record<string, unknown>): CrestodianOperatio
...(workspace ? { workspace } : {}), ...(workspace ? { workspace } : {}),
}; };
} }
case "open_setup": {
const target = readSetupTarget(params);
const channel = readStringParam(params, "channel")?.trim().toLowerCase();
return {
kind: "open-setup",
target,
...(channel ? { channel } : {}),
};
}
case "gateway_start": case "gateway_start":
return { kind: "gateway-start" }; return { kind: "gateway-start" };
case "gateway_stop": case "gateway_stop":
@ -313,8 +345,8 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo
name: "crestodian", name: "crestodian",
label: "Crestodian", label: "Crestodian",
description: [ 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.", "Ring-zero OpenClaw setup and repair. Read actions (status/models/agents/channels/channel_info/config_get/config_schema/gateway_status/plugin_search/validate_config/doctor/audit) run immediately.",
"connect_channel(channel) starts guided channel setup; configure_model_provider starts masked provider/default-model setup; open_agent hands the user to their normal agent. These interactive handoffs run immediately.", "connect_channel(channel) starts guided channel setup in this chat; configure_model_provider starts masked provider/default-model setup; open_agent hands off to the normal agent; open_setup hands off to a menu wizard. All 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 clearly agreed to that exact change in this conversation.", "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 clearly agreed 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.", "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.", "Every applied write is validated; if the result reports CONFIG INVALID, fix it immediately. All writes are audited.",
@ -335,7 +367,9 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo
? `${CRESTODIAN_DIRECTIVE_PREFIX} the host chat now starts the guided ${directive.channel} setup with the user. Tell the user the setup questions come next; do not describe steps yourself.` ? `${CRESTODIAN_DIRECTIVE_PREFIX} the host chat now starts the guided ${directive.channel} setup with the user. Tell the user the setup questions come next; do not describe steps yourself.`
: directive.kind === "model-setup" : directive.kind === "model-setup"
? `${CRESTODIAN_DIRECTIVE_PREFIX} the host now starts masked model-provider setup. Tell the user the provider questions come next; do not ask for credentials yourself.` ? `${CRESTODIAN_DIRECTIVE_PREFIX} the host now starts masked model-provider setup. Tell the user the provider questions come next; do not ask for credentials yourself.`
: `${CRESTODIAN_DIRECTIVE_PREFIX} the host now hands the user over to their normal agent. Say goodbye briefly.`, : directive.kind === "open-tui"
? `${CRESTODIAN_DIRECTIVE_PREFIX} the host now hands the user over to their normal agent. Say goodbye briefly.`
: `${CRESTODIAN_DIRECTIVE_PREFIX} the host now opens the ${directive.target} setup wizard. Tell the user the menu wizard comes next.`,
{}, {},
); );
} }

View file

@ -158,7 +158,9 @@ function createTokenPrompter(params: { confirms: boolean[]; texts: string[] }) {
const texts = [...params.texts]; const texts = [...params.texts];
return { return {
confirm: vi.fn(async () => confirms.shift() ?? true), confirm: vi.fn(async () => confirms.shift() ?? true),
text: vi.fn(async () => texts.shift() ?? ""), text: vi.fn<(textParams: { sensitive?: boolean }) => Promise<string>>(
async () => texts.shift() ?? "",
),
}; };
} }
@ -519,6 +521,11 @@ describe("promptSingleChannelToken", () => {
}); });
expect(result).toEqual(expected); expect(result).toEqual(expected);
expect(prompter.text).toHaveBeenCalledTimes(expectTextCalls); expect(prompter.text).toHaveBeenCalledTimes(expectTextCalls);
// Token entry is a credential: masked in terminals, and the Crestodian
// chat bridge refuses plain-text secrets based on this flag.
for (const call of prompter.text.mock.calls) {
expect(call[0]).toMatchObject({ sensitive: true });
}
}); });
}); });

View file

@ -982,6 +982,9 @@ export async function promptSingleChannelToken(params: {
( (
await params.prompter.text({ await params.prompter.text({
message: params.inputPrompt, message: params.inputPrompt,
// Credential input: masked in terminal prompts, and the Crestodian
// chat bridge relies on this flag to refuse plain-text secret entry.
sensitive: true,
validate: (value) => (value?.trim() ? undefined : "Required"), validate: (value) => (value?.trim() ? undefined : "Required"),
}) })
).trim(); ).trim();

View file

@ -171,11 +171,7 @@ export function registerOnboardCommand(program: Command): void {
) )
.option("--reset-scope <scope>", "Reset scope: config|config+creds+sessions|full") .option("--reset-scope <scope>", "Reset scope: config|config+creds+sessions|full")
.option("--non-interactive", "Run without prompts", false) .option("--non-interactive", "Run without prompts", false)
.option( .option("--modern", "Open the Crestodian setup chat (kept for compatibility)", false)
"--modern",
"Alias for the default bootstrap onboarding (kept for compatibility)",
false,
)
.option("--classic", "Use the classic multi-step setup wizard", false) .option("--classic", "Use the classic multi-step setup wizard", false)
.option( .option(
"--accept-risk", "--accept-risk",

View file

@ -69,9 +69,15 @@ vi.mock("../channels/plugins/catalog.js", () => ({
listRawChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, listRawChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
})); }));
vi.mock("./channel-setup/discovery.js", () => ({ vi.mock("./channel-setup/discovery.js", async () => {
isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled, const actual = await vi.importActual<typeof import("./channel-setup/discovery.js")>(
})); "./channel-setup/discovery.js",
);
return {
...actual,
isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled,
};
});
vi.mock("../channels/plugins/bundled.js", async () => { vi.mock("../channels/plugins/bundled.js", async () => {
const actual = await vi.importActual<typeof import("../channels/plugins/bundled.js")>( const actual = await vi.importActual<typeof import("../channels/plugins/bundled.js")>(
@ -470,6 +476,22 @@ describe("channelsAddCommand", () => {
expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("No channel changes made."); expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("No channel changes made.");
}); });
it("preselects an installable catalog channel in guided setup", async () => {
const config: OpenClawConfig = { channels: {} };
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
sourceConfig: config,
config,
});
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
{ ...createExternalChatCatalogEntry(), origin: "workspace" },
]);
await channelsAddCommand({ channel: "external-chat" }, runtime, { hasFlags: false });
expect(setupOptions().initialSelection).toEqual(["external-chat"]);
});
it("exits quietly when guided channel setup is cancelled", async () => { it("exits quietly when guided channel setup is cancelled", async () => {
const { WizardCancelledError } = await import("../wizard/prompts.js"); const { WizardCancelledError } = await import("../wizard/prompts.js");
configMocks.readConfigFileSnapshot.mockResolvedValue({ configMocks.readConfigFileSnapshot.mockResolvedValue({

View file

@ -81,6 +81,32 @@ async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nul
}); });
} }
async function resolveInitialWizardChannel(
raw: string,
cfg: OpenClawConfig,
): Promise<ChannelChoice | undefined> {
const normalized = normalizeOptionalLowercaseString(raw);
if (!normalized) {
return undefined;
}
const [{ listActiveChannelSetupPlugins }, { resolveChannelSetupEntries }] = await Promise.all([
import("../../channels/plugins/setup-registry.js"),
import("../channel-setup/discovery.js"),
]);
const resolved = resolveChannelSetupEntries({
cfg,
installedPlugins: listActiveChannelSetupPlugins(),
workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)),
});
return resolved.entries.find(
(entry) =>
normalizeOptionalLowercaseString(entry.id) === normalized ||
(entry.meta.aliases ?? []).some(
(alias) => normalizeOptionalLowercaseString(alias) === normalized,
),
)?.id;
}
function parseOptionalInt(value: unknown, flag: string): number | undefined { function parseOptionalInt(value: unknown, flag: string): number | undefined {
if (value === undefined || value === null || value === "") { if (value === undefined || value === null || value === "") {
return undefined; return undefined;
@ -167,8 +193,10 @@ async function channelsAddCommandImpl(
let selection: ChannelChoice[] = []; let selection: ChannelChoice[] = [];
const accountIds: Partial<Record<ChannelChoice, string>> = {}; const accountIds: Partial<Record<ChannelChoice, string>> = {};
const resolvedPlugins = new Map<ChannelChoice, ChannelSetupPlugin>(); const resolvedPlugins = new Map<ChannelChoice, ChannelSetupPlugin>();
const initialChannel = await resolveInitialWizardChannel(opts.channel ?? "", cfg);
await prompter.intro("Channel setup"); await prompter.intro("Channel setup");
let nextConfigLocal = await onboardChannels.setupChannels(cfg, runtime, prompter, { let nextConfigLocal = await onboardChannels.setupChannels(cfg, runtime, prompter, {
...(initialChannel ? { initialSelection: [initialChannel] } : {}),
allowDisable: false, allowDisable: false,
allowIMessageInstall: true, allowIMessageInstall: true,
allowSignalInstall: true, allowSignalInstall: true,

View file

@ -0,0 +1,427 @@
import fs from "node:fs";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js";
import { createSuiteLogPathTracker } from "../logging/log-test-helpers.js";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter, WizardSelectParams } from "../wizard/prompts.js";
import { runGuidedOnboarding, type GuidedOnboardingDeps } from "./onboard-guided.js";
vi.mock("../../packages/terminal-core/src/restore.js", () => ({
restoreTerminalState: vi.fn(),
}));
vi.mock("./onboard-interactive-runner.js", async (importActual) => {
const actual = await importActual<typeof import("./onboard-interactive-runner.js")>();
return { ...actual, hasInteractiveOnboardingTty: () => true };
});
const readConfigFileSnapshot = vi.hoisted(() =>
vi.fn(async () => ({ exists: false, valid: true, config: {} })),
);
const logPathTracker = createSuiteLogPathTracker("openclaw-guided-onboard-log-");
vi.mock("../config/config.js", () => ({ readConfigFileSnapshot }));
vi.mock("./onboard-helpers.js", () => ({
DEFAULT_WORKSPACE: "/tmp/openclaw-workspace",
printWizardHeader: vi.fn(),
}));
function makeRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn() as unknown as RuntimeEnv["exit"],
};
}
function candidate(kind: "claude-cli" | "codex-cli", label: string) {
return {
kind,
label,
detail: "logged in",
modelRef: kind === "claude-cli" ? "claude-cli/opus" : "openai/gpt-5.5",
recommended: kind === "claude-cli",
credentials: true,
} as const;
}
function detection(
overrides: Partial<Awaited<ReturnType<NonNullable<GuidedOnboardingDeps["detect"]>>>> = {},
) {
return {
candidates: [candidate("claude-cli", "Claude Code")],
manualProviders: [],
workspace: "/tmp/openclaw-workspace",
setupComplete: false,
...overrides,
};
}
function setupDeps(params: {
prompter: WizardPrompter;
detect?: GuidedOnboardingDeps["detect"];
activate?: GuidedOnboardingDeps["activate"];
applySetup?: GuidedOnboardingDeps["applySetup"];
runClassicSetup?: GuidedOnboardingDeps["runClassicSetup"];
runCrestodianChat?: GuidedOnboardingDeps["runCrestodianChat"];
}) {
return {
createPrompter: () => params.prompter,
detect: params.detect ?? vi.fn(async () => detection()),
activate:
params.activate ??
vi.fn(async () => ({
ok: true as const,
modelRef: "claude-cli/opus",
latencyMs: 1250,
lines: ["Workspace: /tmp/work", "Gateway: running"],
})),
applySetup: params.applySetup,
runClassicSetup: params.runClassicSetup,
runCrestodianChat: params.runCrestodianChat,
launchTui: vi.fn(async () => {}),
} satisfies GuidedOnboardingDeps;
}
describe("runGuidedOnboarding", () => {
beforeAll(async () => {
await logPathTracker.setup();
});
beforeEach(() => {
readConfigFileSnapshot.mockReset();
readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: true, config: {} });
});
afterEach(() => {
loggingState.rawConsole = null;
resetLogger();
});
afterAll(async () => {
await logPathTracker.cleanup();
});
it("auto-connects one credentialed candidate and completes without manual setup", async () => {
const select = vi.fn(async () => "unexpected") as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: vi.fn(async () => "/tmp/work"),
select,
confirm: vi.fn(async () => false),
});
const deps = setupDeps({ prompter });
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
expect(deps.activate).toHaveBeenCalledWith(
expect.objectContaining({ kind: "claude-cli", workspace: "/tmp/work", surface: "cli" }),
);
expect(select).not.toHaveBeenCalled();
expect(prompter.outro).toHaveBeenCalledWith("OpenClaw is ready.");
});
it("suppresses activation subsystem output and restores it when activation throws", async () => {
const file = logPathTracker.nextPath();
setLoggerOverride({ level: "info", consoleLevel: "info", file });
const consoleLog = vi.fn();
loggingState.rawConsole = {
log: consoleLog,
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const transportLog = createSubsystemLogger("provider-transport-fetch");
const activationError = new Error("activation failed");
const activate = vi.fn(async () => {
transportLog.info("[model-fetch] response status=401");
expect(consoleLog).not.toHaveBeenCalled();
throw activationError;
}) as GuidedOnboardingDeps["activate"];
const prompter = createWizardPrompter({ text: vi.fn(async () => "/tmp/work") });
await expect(
runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), setupDeps({ prompter, activate })),
).rejects.toBe(activationError);
transportLog.info("after activation");
expect(consoleLog).toHaveBeenCalledOnce();
const fileLog = fs.readFileSync(file, "utf8");
expect(fileLog).toContain("[model-fetch] response status=401");
expect(fileLog).toContain("after activation");
});
it("never replaces a configured model by fallthrough when its check fails", async () => {
const existingModel = {
kind: "existing-model",
label: "Current model",
detail: "already configured",
modelRef: "acme/workspace-model",
recommended: true,
credentials: true,
} as const;
const select = vi.fn(async () => "action:skip") as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: vi.fn(async () => "/tmp/work"),
select,
confirm: vi.fn(async () => false),
});
const activate = vi.fn(async () => ({
ok: false as const,
status: "unavailable" as const,
error: "provider not loaded",
})) as GuidedOnboardingDeps["activate"];
const applySetup = vi.fn<NonNullable<GuidedOnboardingDeps["applySetup"]>>(async () => ({
configPath: "/tmp/config",
lines: ["Workspace"],
}));
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [existingModel, candidate("claude-cli", "Claude Code")],
}),
),
activate,
applySetup,
});
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
// Only the existing model was auto-tested; the other credentialed candidate
// must not run (and persist) without the user choosing it.
expect(activate).toHaveBeenCalledTimes(1);
expect(activate).toHaveBeenCalledWith(expect.objectContaining({ kind: "existing-model" }));
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("kept unchanged");
expect(select).toHaveBeenCalled();
});
it("falls through after an auth failure and surfaces both outcomes", async () => {
const prompter = createWizardPrompter({
text: vi.fn(async () => "/tmp/work"),
confirm: vi.fn(async () => false),
});
const activate = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" })
.mockResolvedValueOnce({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 900,
lines: ["Gateway: running"],
}) as GuidedOnboardingDeps["activate"];
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [candidate("claude-cli", "Claude Code"), candidate("codex-cli", "Codex")],
}),
),
activate,
});
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
expect(activate).toHaveBeenCalledTimes(2);
const notes = JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls);
expect(notes).toContain("Claude Code");
expect(notes).toContain("Authentication failed");
expect(notes).toContain("Gateway: running");
});
it("offers an auto-attempted transient failure for manual retry", async () => {
const select = vi.fn(async (params: WizardSelectParams) => {
expect(params.options).toContainEqual(
expect.objectContaining({
value: "candidate:claude-cli",
label: "Retry Claude Code (logged in)",
}),
);
return "candidate:claude-cli";
}) as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: vi.fn(async () => "/tmp/work"),
select,
confirm: vi.fn(async () => false),
});
const activate = vi
.fn()
.mockResolvedValueOnce({ ok: false, status: "rate_limit", error: "try later" })
.mockResolvedValueOnce({
ok: true,
modelRef: "claude-cli/opus",
latencyMs: 700,
lines: ["Gateway: running"],
}) as GuidedOnboardingDeps["activate"];
const deps = setupDeps({ prompter, activate });
await runGuidedOnboarding({ acceptRisk: true }, makeRuntime(), deps);
expect(activate).toHaveBeenCalledTimes(2);
expect(select).toHaveBeenCalledOnce();
expect(prompter.outro).toHaveBeenCalledWith("OpenClaw is ready.");
});
it("accepts and verifies a manual provider key without displaying it", async () => {
const enteredValue = "synthetic-value";
const text = vi.fn().mockResolvedValueOnce("/tmp/work").mockResolvedValueOnce(enteredValue);
const select = vi.fn(
async () => "manual:openai-api-key",
) as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: text as WizardPrompter["text"],
select,
confirm: vi.fn(async () => false),
});
const activate = vi.fn(async () => ({
ok: true as const,
modelRef: "openai/gpt-5.5",
latencyMs: 500,
lines: ["Default model: openai/gpt-5.5"],
})) as GuidedOnboardingDeps["activate"];
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [],
manualProviders: [{ id: "openai-api-key", label: "OpenAI", hint: "API key" }],
}),
),
activate,
});
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true }, runtime, deps);
expect(activate).toHaveBeenCalledWith(
expect.objectContaining({
kind: "api-key",
authChoice: "openai-api-key",
apiKey: enteredValue,
}),
);
expect(text).toHaveBeenLastCalledWith(expect.objectContaining({ sensitive: true }));
expect(JSON.stringify((prompter.note as ReturnType<typeof vi.fn>).mock.calls)).not.toContain(
enteredValue,
);
expect(JSON.stringify([runtime.log, runtime.error])).not.toContain(enteredValue);
});
it("can skip AI after a manual key fails", async () => {
const text = vi.fn().mockResolvedValueOnce("/tmp/work").mockResolvedValueOnce("bad-key");
const select = vi
.fn()
.mockResolvedValueOnce("manual:openai-api-key")
.mockResolvedValueOnce("action:skip") as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: text as WizardPrompter["text"],
select,
confirm: vi.fn(async () => false),
});
const applySetup = vi.fn<NonNullable<GuidedOnboardingDeps["applySetup"]>>(async () => ({
configPath: "/tmp/config",
lines: ["Workspace"],
}));
const deps = setupDeps({
prompter,
detect: vi.fn(async () =>
detection({
candidates: [],
manualProviders: [{ id: "openai-api-key", label: "OpenAI" }],
}),
),
activate: vi.fn(async () => ({
ok: false as const,
status: "auth" as const,
error: "bad key",
})),
applySetup,
});
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true }, runtime, deps);
expect(applySetup).toHaveBeenCalledWith({
workspace: "/tmp/work",
surface: "cli",
runtime,
});
expect(applySetup.mock.calls[0]?.[0]).not.toHaveProperty("model");
});
it("hands options to the classic escape with the collected risk acknowledgement", async () => {
const opts = { workspace: "/tmp/original" };
const select = vi.fn(async () => "action:classic") as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({
text: vi.fn(async () => "/tmp/work"),
select,
confirm: vi.fn(async () => true),
});
const runClassicSetup = vi.fn(async () => {});
const deps = setupDeps({
prompter,
detect: vi.fn(async () => detection({ candidates: [] })),
runClassicSetup,
});
const runtime = makeRuntime();
await runGuidedOnboarding(opts, runtime, deps);
// Guided already collected the risk acknowledgement; classic must not re-ask.
expect(runClassicSetup).toHaveBeenCalledWith(
{ workspace: "/tmp/original", acceptRisk: true },
runtime,
);
});
it("opens Crestodian chat with the selected workspace", async () => {
const select = vi.fn(async () => "action:crestodian") as unknown as WizardPrompter["select"];
const prompter = createWizardPrompter({ text: vi.fn(async () => "/tmp/work"), select });
const runCrestodianChat = vi.fn(async () => {});
const deps = setupDeps({
prompter,
detect: vi.fn(async () => detection({ candidates: [] })),
runCrestodianChat,
});
const runtime = makeRuntime();
await runGuidedOnboarding({ acceptRisk: true }, runtime, deps);
expect(runCrestodianChat).toHaveBeenCalledWith("/tmp/work", runtime, true);
});
it("cancels before detection or activation when risk is declined", async () => {
const prompter = createWizardPrompter({ confirm: vi.fn(async () => false) });
const deps = setupDeps({ prompter });
const runtime = makeRuntime();
await runGuidedOnboarding({}, runtime, deps);
expect(runtime.exit).toHaveBeenCalledWith(1);
expect(deps.detect).not.toHaveBeenCalled();
expect(deps.activate).not.toHaveBeenCalled();
});
it("opens Crestodian without writing when existing config is invalid", async () => {
readConfigFileSnapshot.mockResolvedValueOnce({
exists: true,
valid: false,
config: {},
});
const prompter = createWizardPrompter();
const runCrestodianChat = vi.fn(async () => {});
const deps = setupDeps({ prompter, runCrestodianChat });
const runtime = makeRuntime();
await runGuidedOnboarding({ workspace: "/tmp/repair" }, runtime, deps);
expect(runCrestodianChat).toHaveBeenCalledWith("/tmp/repair", runtime, false);
expect(deps.detect).not.toHaveBeenCalled();
expect(deps.activate).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,412 @@
// Guided onboarding: detect AI access, live-test it, then persist only a working route.
import type {
CrestodianSetupApplyParams,
CrestodianSetupApplyResult,
} from "../crestodian/setup-apply.js";
import type {
ActivateSetupInferenceResult,
SetupInferenceCandidate,
SetupInferenceDetection,
SetupInferenceStatus,
} from "../crestodian/setup-inference.js";
import { withConsoleSubsystemsSuppressed } from "../logging/console.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { t } from "../wizard/i18n/index.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { requireRiskAcknowledgement } from "../wizard/setup.shared.js";
import {
hasInteractiveOnboardingTty,
runInteractiveOnboarding,
} from "./onboard-interactive-runner.js";
import type { OnboardOptions } from "./onboard-types.js";
type ActivateSetupInference =
typeof import("../crestodian/setup-inference.js").activateSetupInference;
type DetectSetupInference = typeof import("../crestodian/setup-inference.js").detectSetupInference;
export type GuidedOnboardingDeps = {
detect?: DetectSetupInference;
activate?: ActivateSetupInference;
runClassicSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise<void>;
runCrestodianChat?: (
workspace: string,
runtime: RuntimeEnv,
acceptRisk: boolean,
) => Promise<void>;
applySetup?: (params: CrestodianSetupApplyParams) => Promise<CrestodianSetupApplyResult>;
createPrompter?: () => WizardPrompter | Promise<WizardPrompter>;
launchTui?: () => Promise<void>;
};
type GuidedSetupResult = { kind: "complete"; lines: string[] } | { kind: "delegated" };
type CandidateAttempt =
| { kind: "success"; result: Extract<ActivateSetupInferenceResult, { ok: true }> }
| { kind: "failure" };
const MANUAL_CLASSIC = "action:classic";
const MANUAL_CRESTODIAN = "action:crestodian";
const MANUAL_SKIP = "action:skip";
async function openCrestodianChat(
deps: GuidedOnboardingDeps,
workspace: string,
runtime: RuntimeEnv,
acceptRisk: boolean,
): Promise<void> {
const runChat =
deps.runCrestodianChat ??
(async (setupWorkspace: string, chatRuntime: RuntimeEnv, riskAccepted: boolean) => {
const { runConversationalOnboarding } = await import("./onboard-interactive.js");
await runConversationalOnboarding(
{
workspace: setupWorkspace,
...(riskAccepted ? { acceptRisk: true } : {}),
},
chatRuntime,
);
});
await runChat(workspace, runtime, acceptRisk);
}
const SETUP_FAILURE_REASON_KEYS: Record<SetupInferenceStatus, string> = {
auth: "wizard.guided.failureAuth",
rate_limit: "wizard.guided.failureRateLimit",
billing: "wizard.guided.failureBilling",
timeout: "wizard.guided.failureTimeout",
format: "wizard.guided.failureFormat",
unavailable: "wizard.guided.failureUnavailable",
ok: "wizard.guided.failureUnknown",
unknown: "wizard.guided.failureUnknown",
};
function setupFailureReason(status: SetupInferenceStatus): string {
return t(SETUP_FAILURE_REASON_KEYS[status]);
}
async function noteActivationFailure(params: {
prompter: WizardPrompter;
label: string;
result: Extract<ActivateSetupInferenceResult, { ok: false }>;
}): Promise<void> {
await params.prompter.note(
t("wizard.guided.testFailure", {
label: params.label,
reason: setupFailureReason(params.result.status),
detail: params.result.error,
}),
t("wizard.guided.aiAccessTitle"),
);
}
async function tryCandidate(params: {
candidate: SetupInferenceCandidate;
workspace: string;
runtime: RuntimeEnv;
prompter: WizardPrompter;
activate: ActivateSetupInference;
}): Promise<CandidateAttempt> {
const progress = params.prompter.progress(
t("wizard.guided.testingCandidate", {
label: params.candidate.label,
modelRef: params.candidate.modelRef,
}),
);
const result = await withConsoleSubsystemsSuppressed(() =>
params.activate({
kind: params.candidate.kind,
workspace: params.workspace,
surface: "cli",
runtime: params.runtime,
}),
);
progress.stop(result.ok ? t("wizard.guided.testPassed") : t("wizard.guided.testFailed"));
if (result.ok) {
return { kind: "success", result };
}
await noteActivationFailure({
prompter: params.prompter,
label: params.candidate.label,
result,
});
return { kind: "failure" };
}
async function runManualStage(params: {
detection: SetupInferenceDetection;
autoAttemptedKinds: ReadonlySet<SetupInferenceCandidate["kind"]>;
opts: OnboardOptions;
workspace: string;
runtime: RuntimeEnv;
prompter: WizardPrompter;
deps: GuidedOnboardingDeps;
activate: ActivateSetupInference;
}): Promise<GuidedSetupResult> {
while (true) {
const choice = await params.prompter.select({
message: t("wizard.guided.manualChoice"),
options: [
...params.detection.candidates.map((candidate) => ({
value: `candidate:${candidate.kind}`,
label: t(
params.autoAttemptedKinds.has(candidate.kind)
? "wizard.guided.retryCandidate"
: "wizard.guided.tryCandidate",
{
label: candidate.label,
detail: candidate.detail,
},
),
})),
...params.detection.manualProviders.map((provider) => ({
value: `manual:${provider.id}`,
label: t("wizard.guided.enterApiKey", { label: provider.label }),
...(provider.hint ? { hint: provider.hint } : {}),
})),
{
value: MANUAL_CRESTODIAN,
label: t("wizard.guided.openCrestodian"),
},
{
value: MANUAL_CLASSIC,
label: t("wizard.guided.useClassic"),
},
{
value: MANUAL_SKIP,
label: t("wizard.guided.skipAi"),
},
],
});
if (choice === MANUAL_CRESTODIAN) {
await openCrestodianChat(params.deps, params.workspace, params.runtime, true);
return { kind: "delegated" };
}
if (choice === MANUAL_CLASSIC) {
const runClassic =
params.deps.runClassicSetup ??
(async (opts: OnboardOptions, runtime: RuntimeEnv) => {
const { runInteractiveSetup } = await import("./onboard-interactive.js");
await runInteractiveSetup(opts, runtime);
});
// The classic escape owns its workspace/default handling. The risk
// acknowledgement was already collected by the guided flow, so pass it
// through — re-prompting the same session twice reads as a bug.
await runClassic({ ...params.opts, acceptRisk: true }, params.runtime);
return { kind: "delegated" };
}
if (choice === MANUAL_SKIP) {
const applySetup =
params.deps.applySetup ??
(await import("../crestodian/setup-apply.js")).applyCrestodianSetup;
const applied = await applySetup({
workspace: params.workspace,
surface: "cli",
runtime: params.runtime,
});
await params.prompter.note(t("wizard.guided.skipAiLater"), t("wizard.guided.aiAccessTitle"));
return { kind: "complete", lines: applied.lines };
}
if (choice.startsWith("candidate:")) {
const kind = choice.slice("candidate:".length);
const candidate = params.detection.candidates.find((item) => item.kind === kind);
if (!candidate) {
continue;
}
const attempt = await tryCandidate({
candidate,
workspace: params.workspace,
runtime: params.runtime,
prompter: params.prompter,
activate: params.activate,
});
if (attempt.kind === "success") {
return { kind: "complete", lines: activationLines(attempt.result) };
}
continue;
}
const providerId = choice.slice("manual:".length);
const provider = params.detection.manualProviders.find((item) => item.id === providerId);
if (!provider) {
continue;
}
const apiKey = await params.prompter.text({
message: t("wizard.guided.apiKeyPrompt", { label: provider.label }),
sensitive: true,
validate: (value) => (value.trim() ? undefined : t("common.required")),
});
const progress = params.prompter.progress(
t("wizard.guided.testingManualProvider", { label: provider.label }),
);
const result = await withConsoleSubsystemsSuppressed(() =>
params.activate({
kind: "api-key",
authChoice: provider.id,
apiKey,
workspace: params.workspace,
surface: "cli",
runtime: params.runtime,
}),
);
progress.stop(result.ok ? t("wizard.guided.testPassed") : t("wizard.guided.testFailed"));
if (result.ok) {
return { kind: "complete", lines: activationLines(result) };
}
await noteActivationFailure({ prompter: params.prompter, label: provider.label, result });
}
}
function activationLines(result: Extract<ActivateSetupInferenceResult, { ok: true }>): string[] {
return [
...result.lines,
t("wizard.guided.repliedIn", { seconds: (result.latencyMs / 1000).toFixed(1) }),
];
}
async function runGuidedOnboardingFlow(
opts: OnboardOptions,
runtime: RuntimeEnv,
deps: GuidedOnboardingDeps,
): Promise<void> {
const onboardHelpers = await import("./onboard-helpers.js");
const prompter = await (deps.createPrompter?.() ??
import("../wizard/clack-prompter.js").then(({ createClackPrompter }) => createClackPrompter()));
onboardHelpers.printWizardHeader(runtime);
await prompter.intro(t("wizard.guided.intro"));
await prompter.note(t("wizard.guided.escapeHatches"), t("wizard.guided.welcomeTitle"));
const { readConfigFileSnapshot } = await import("../config/config.js");
const snapshot = await readConfigFileSnapshot();
if (snapshot.exists && !snapshot.valid) {
await prompter.note(
t("wizard.guided.invalidConfigCrestodian"),
t("wizard.setup.invalidConfigTitle"),
);
await openCrestodianChat(
deps,
opts.workspace?.trim() || onboardHelpers.DEFAULT_WORKSPACE,
runtime,
false,
);
return;
}
const existingConfig =
snapshot.exists && snapshot.valid ? (snapshot.sourceConfig ?? snapshot.config) : {};
await requireRiskAcknowledgement({ opts, prompter, config: existingConfig });
const initialWorkspace =
opts.workspace?.trim() ||
existingConfig.agents?.defaults?.workspace?.trim() ||
onboardHelpers.DEFAULT_WORKSPACE;
const workspaceInput = await prompter.text({
message: t("wizard.guided.workspace"),
initialValue: initialWorkspace,
});
const workspace = resolveUserPath(workspaceInput.trim() || initialWorkspace);
const detect =
deps.detect ?? (await import("../crestodian/setup-inference.js")).detectSetupInference;
const detectionProgress = prompter.progress(t("wizard.guided.detecting"));
const detection = await detect();
detectionProgress.stop(t("wizard.guided.detected"));
if (detection.candidates.length === 0) {
await prompter.note(t("wizard.guided.foundNothing"), t("wizard.guided.detectedTitle"));
} else {
const orderedCandidates = [
...detection.candidates.filter((candidate) => candidate.recommended),
...detection.candidates.filter((candidate) => !candidate.recommended),
];
const candidates = orderedCandidates.map((candidate) =>
t("wizard.guided.detectedCandidate", {
label: candidate.label,
detail: candidate.detail,
recommended: candidate.recommended ? t("wizard.guided.recommendedSuffix") : "",
}),
);
await prompter.note(candidates.join("\n"), t("wizard.guided.detectedTitle"));
}
const activate =
deps.activate ?? (await import("../crestodian/setup-inference.js")).activateSetupInference;
const autoAttemptedKinds = new Set<SetupInferenceCandidate["kind"]>();
let result: GuidedSetupResult | undefined;
// Logged-out CLIs stay visible as manual choices, but auto-testing them would
// only produce predictable auth failures and slow the fallback ladder.
for (const candidate of detection.candidates.filter((item) => item.credentials !== false)) {
autoAttemptedKinds.add(candidate.kind);
const attempt = await tryCandidate({ candidate, workspace, runtime, prompter, activate });
if (attempt.kind === "success") {
result = { kind: "complete", lines: activationLines(attempt.result) };
break;
}
// The verification probe runs outside the configured workspace (setup never
// executes workspace plugins), so a failing current model can be a false
// negative. Never let the ladder silently replace a configured default —
// stop and let the user decide in the manual stage.
if (candidate.kind === "existing-model") {
await prompter.note(t("wizard.guided.existingModelKept"), t("wizard.guided.aiAccessTitle"));
break;
}
}
result ??= await runManualStage({
detection,
autoAttemptedKinds,
opts,
workspace,
runtime,
prompter,
deps,
activate,
});
if (result.kind === "delegated") {
return;
}
await prompter.note(result.lines.join("\n"), t("wizard.guided.appliedTitle"));
await prompter.note(
t("wizard.guided.nextSteps", { workspace: shortenHomePath(workspace) }),
t("wizard.guided.nextStepsTitle"),
);
const openChat = await prompter.confirm({
message: t("wizard.guided.openChatNow"),
initialValue: true,
});
if (openChat) {
const launchTui =
deps.launchTui ??
(async () => {
const { launchTuiCli } = await import("../tui/tui-launch.js");
const { restoreTerminalState } =
await import("../../packages/terminal-core/src/restore.js");
// Mirror the classic finalize handoff (setup.finalize.ts): the TUI must
// not inherit the wizard prompter's raw/paused terminal state.
restoreTerminalState("pre-setup tui", { resumeStdinIfPaused: false });
try {
await launchTuiCli({ deliver: false });
} finally {
restoreTerminalState("post-setup tui", { resumeStdinIfPaused: false });
}
});
await launchTui();
return;
}
await prompter.outro(t("wizard.guided.complete"));
}
export async function runGuidedOnboarding(
opts: OnboardOptions,
runtime: RuntimeEnv,
deps: GuidedOnboardingDeps = {},
): Promise<void> {
if (!hasInteractiveOnboardingTty()) {
runtime.error(t("wizard.guided.ttyRequired"));
runtime.exit(1);
return;
}
await runInteractiveOnboarding(
async () => await runGuidedOnboardingFlow(opts, runtime, deps),
runtime,
);
}

View file

@ -0,0 +1,30 @@
// Shared lifecycle handling for interactive onboarding entrypoints.
import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js";
import type { RuntimeEnv } from "../runtime.js";
import { WizardCancelledError } from "../wizard/prompts.js";
export function hasInteractiveOnboardingTty(): boolean {
return process.stdin.isTTY && process.stdout.isTTY;
}
export async function runInteractiveOnboarding(
action: () => Promise<void>,
runtime: RuntimeEnv,
): Promise<void> {
let exitCode: number | null = null;
try {
await action();
} catch (error) {
if (error instanceof WizardCancelledError) {
exitCode = 1;
return;
}
throw error;
} finally {
// Keep stdin paused so non-daemon runs can exit cleanly (e.g. Docker setup).
restoreTerminalState("setup finish", { resumeStdinIfPaused: false });
if (exitCode !== null) {
runtime.exit(exitCode);
}
}
}

View file

@ -4,12 +4,14 @@
* It wires the Clack prompter to the setup wizard and restores terminal state * It wires the Clack prompter to the setup wizard and restores terminal state
* on every exit path so canceled setup cannot leave stdin paused. * on every exit path so canceled setup cannot leave stdin paused.
*/ */
import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { createClackPrompter } from "../wizard/clack-prompter.js"; import { createClackPrompter } from "../wizard/clack-prompter.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import { runSetupWizard } from "../wizard/setup.js"; import { runSetupWizard } from "../wizard/setup.js";
import {
hasInteractiveOnboardingTty,
runInteractiveOnboarding,
} from "./onboard-interactive-runner.js";
import type { OnboardOptions } from "./onboard-types.js"; import type { OnboardOptions } from "./onboard-types.js";
/** Runs the interactive setup wizard and maps user cancellation to exit code 1. */ /** Runs the interactive setup wizard and maps user cancellation to exit code 1. */
@ -18,36 +20,22 @@ export async function runInteractiveSetup(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
const prompter = createClackPrompter(); const prompter = createClackPrompter();
let exitCode: number | null = null; await runInteractiveOnboarding(
try { async () => await runSetupWizard(opts, runtime, prompter),
await runSetupWizard(opts, runtime, prompter); runtime,
} catch (err) { );
if (err instanceof WizardCancelledError) {
// Best practice: cancellation is not a successful completion.
exitCode = 1;
return;
}
throw err;
} finally {
// Keep stdin paused so non-daemon runs can exit cleanly (e.g. Docker setup).
restoreTerminalState("setup finish", { resumeStdinIfPaused: false });
if (exitCode !== null) {
runtime.exit(exitCode);
}
}
} }
/** /**
* Default interactive onboarding: no step wizard, just the Crestodian * Opens the Crestodian onboarding conversation used by the guided escape hatch.
* conversation. The first-run greeting proposes a full setup plan (detected * The first-run greeting proposes a setup plan and keeps subsequent setup and
* inference, workspace, gateway) and a plain "yes" applies it; channels and * agent handoff in the same conversation.
* the agent handoff continue in the same conversation.
*/ */
export async function runConversationalOnboarding( export async function runConversationalOnboarding(
opts: OnboardOptions, opts: OnboardOptions,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
if (!process.stdin.isTTY || !process.stdout.isTTY) { if (!hasInteractiveOnboardingTty()) {
runtime.error( runtime.error(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
); );
@ -59,6 +47,7 @@ export async function runConversationalOnboarding(
{ {
welcomeVariant: "onboarding", welcomeVariant: "onboarding",
...(opts.workspace ? { setupWorkspace: opts.workspace } : {}), ...(opts.workspace ? { setupWorkspace: opts.workspace } : {}),
...(opts.acceptRisk === true ? { setupAcceptRisk: true } : {}),
}, },
runtime, runtime,
); );

View file

@ -42,7 +42,7 @@ export type OnboardOptions = OnboardDynamicProviderOptions & {
mode?: OnboardMode; mode?: OnboardMode;
/** "manual" is an alias for "advanced". */ /** "manual" is an alias for "advanced". */
flow?: "quickstart" | "advanced" | "manual" | "import"; flow?: "quickstart" | "advanced" | "manual" | "import";
/** Force the classic multi-step interactive wizard instead of the bootstrap flow. */ /** Force the classic multi-step interactive wizard instead of guided setup. */
classic?: boolean; classic?: boolean;
workspace?: string; workspace?: string;
nonInteractive?: boolean; nonInteractive?: boolean;

View file

@ -7,7 +7,7 @@ import { onboardCommand, setupWizardCommand } from "./onboard.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
runInteractiveSetup: vi.fn(async () => {}), runInteractiveSetup: vi.fn(async () => {}),
runConversationalOnboarding: vi.fn(async () => {}), runGuidedOnboarding: vi.fn(async () => {}),
runNonInteractiveSetup: vi.fn(async () => {}), runNonInteractiveSetup: vi.fn(async () => {}),
readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })), readConfigFileSnapshot: vi.fn(async () => ({ exists: false, valid: false, config: {} })),
handleReset: vi.fn(async () => {}), handleReset: vi.fn(async () => {}),
@ -15,7 +15,10 @@ const mocks = vi.hoisted(() => ({
vi.mock("./onboard-interactive.js", () => ({ vi.mock("./onboard-interactive.js", () => ({
runInteractiveSetup: mocks.runInteractiveSetup, runInteractiveSetup: mocks.runInteractiveSetup,
runConversationalOnboarding: mocks.runConversationalOnboarding, }));
vi.mock("./onboard-guided.js", () => ({
runGuidedOnboarding: mocks.runGuidedOnboarding,
})); }));
vi.mock("./onboard-non-interactive.js", () => ({ vi.mock("./onboard-non-interactive.js", () => ({
@ -179,7 +182,7 @@ describe("setupWizardCommand", () => {
expect(onboardCommand).toBe(setupWizardCommand); expect(onboardCommand).toBe(setupWizardCommand);
}); });
it("routes flagless interactive onboarding to the bootstrap flow", async () => { it("routes flagless interactive onboarding to the guided flow", async () => {
const runtime = makeRuntime(); const runtime = makeRuntime();
// Unset Commander booleans arrive as false and must not force classic. // Unset Commander booleans arrive as false and must not force classic.
@ -188,7 +191,7 @@ describe("setupWizardCommand", () => {
runtime, runtime,
); );
expect(mocks.runConversationalOnboarding).toHaveBeenCalledOnce(); expect(mocks.runGuidedOnboarding).toHaveBeenCalledOnce();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled();
}); });
@ -211,7 +214,7 @@ describe("setupWizardCommand", () => {
await setupWizardCommand(opts, runtime); await setupWizardCommand(opts, runtime);
expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce(); expect(mocks.runInteractiveSetup).toHaveBeenCalledOnce();
expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled(); expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
}); });
it("keeps non-interactive routing unchanged", async () => { it("keeps non-interactive routing unchanged", async () => {
@ -220,7 +223,7 @@ describe("setupWizardCommand", () => {
await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime); await setupWizardCommand({ nonInteractive: true, acceptRisk: true }, runtime);
expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce(); expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce();
expect(mocks.runConversationalOnboarding).not.toHaveBeenCalled(); expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled();
expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); expect(mocks.runInteractiveSetup).not.toHaveBeenCalled();
}); });
}); });

View file

@ -16,23 +16,24 @@ import {
normalizeLegacyOnboardAuthChoice, normalizeLegacyOnboardAuthChoice,
resolveDeprecatedAuthChoiceReplacement, resolveDeprecatedAuthChoiceReplacement,
} from "./auth-choice-legacy.js"; } from "./auth-choice-legacy.js";
import { runGuidedOnboarding } from "./onboard-guided.js";
import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js"; import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js";
import { runConversationalOnboarding, runInteractiveSetup } from "./onboard-interactive.js"; import { runInteractiveSetup } from "./onboard-interactive.js";
import { runNonInteractiveSetup } from "./onboard-non-interactive.js"; import { runNonInteractiveSetup } from "./onboard-non-interactive.js";
import type { OnboardOptions, ResetScope } from "./onboard-types.js"; import type { OnboardOptions, ResetScope } from "./onboard-types.js";
const VALID_RESET_SCOPES = new Set<ResetScope>(["config", "config+creds+sessions", "full"]); const VALID_RESET_SCOPES = new Set<ResetScope>(["config", "config+creds+sessions", "full"]);
/** /**
* Interactive onboarding defaults to the Crestodian conversation. Any explicit * Interactive onboarding defaults to guided setup. Any explicit
* setup flag beyond this allowlist keeps the classic wizard those flags are * setup flag beyond this allowlist keeps the classic wizard those flags are
* a public automation contract and the conversation does not honor them. * a public automation contract and guided setup does not honor them.
* Boolean false and undefined mean "not passed" (Commander coerces unset * Boolean false and undefined mean "not passed" (Commander coerces unset
* booleans to false); explicit `--no-install-daemon` arrives as `false` via * booleans to false); explicit `--no-install-daemon` arrives as `false` via
* resolveInstallDaemonFlag and is special-cased. `--modern` never reaches this * resolveInstallDaemonFlag and is special-cased. `--modern` never reaches this
* dispatch; it routes straight to Crestodian in the command layer. * dispatch; it routes straight to Crestodian in the command layer.
*/ */
const CONVERSATIONAL_SAFE_ONBOARD_KEYS = new Set([ const GUIDED_SAFE_ONBOARD_KEYS = new Set([
"workspace", "workspace",
"acceptRisk", "acceptRisk",
"reset", "reset",
@ -49,7 +50,7 @@ function wantsClassicInteractiveSetup(opts: OnboardOptions): boolean {
return true; return true;
} }
for (const [key, value] of Object.entries(opts)) { for (const [key, value] of Object.entries(opts)) {
if (CONVERSATIONAL_SAFE_ONBOARD_KEYS.has(key) || key === "installDaemon") { if (GUIDED_SAFE_ONBOARD_KEYS.has(key) || key === "installDaemon") {
continue; continue;
} }
if (value === undefined || value === false) { if (value === undefined || value === false) {
@ -157,7 +158,7 @@ export async function setupWizardCommand(
return; return;
} }
await runConversationalOnboarding(normalizedOpts, runtime); await runGuidedOnboarding(normalizedOpts, runtime);
} }
export const onboardCommand = setupWizardCommand; export const onboardCommand = setupWizardCommand;

View file

@ -32,6 +32,8 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [
"Secrets (tokens, API keys, passwords) must not be written as plaintext when the user prefers env storage: use `config set-ref <path> env <ENV_VAR>`. Never echo secret values back.", "Secrets (tokens, API keys, passwords) must not be written as plaintext when the user prefers env storage: use `config set-ref <path> env <ENV_VAR>`. 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`.", "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.", "Every applied write is validated automatically; if validation fails you will see the exact issues — propose a corrective command, do not apologize twice.",
"Switching: If the user prefers menus, a step-by-step wizard, or masked secret prompts, hand off: `open setup wizard` (guided), `open classic wizard`, or `open channel wizard for <channel>`.",
"Channel guidance: when the user asks ABOUT a channel or its prerequisites (bot tokens, app creation, e.g. Slack or Telegram), run `channel info <channel>` and use its docs link; never guess credentials or steps. When they ask to CONNECT a channel, run `connect <channel>` right away — do not detour through channel info.",
"", "",
"Allowed commands:", "Allowed commands:",
"- setup", "- setup",
@ -49,6 +51,10 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [
"- configure model provider", "- configure model provider",
"- channels", "- channels",
"- connect <channel>", "- connect <channel>",
"- channel info <channel>",
"- open setup wizard",
"- open classic wizard",
"- open channel wizard for <channel>",
"- plugins list", "- plugins list",
"- plugins search <query>", "- plugins search <query>",
"- plugin install <npm-or-clawhub-spec>", "- plugin install <npm-or-clawhub-spec>",
@ -78,7 +84,9 @@ export const CRESTODIAN_AGENT_SYSTEM_PROMPT = [
"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 the user to confirm. Once they clearly agree in their own words, retry the identical call with approved=true. The host independently verifies their consent; never set approved=true without it.", "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 the user to confirm. Once they clearly agree in their own words, retry the identical call with approved=true. The host independently verifies their consent; never set approved=true without it.",
"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.", "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.", "If a tool result reports CONFIG INVALID, fix it immediately before anything else.",
"To configure inference access, call configure_model_provider — the host starts masked provider and default-model setup. To connect a chat channel, call connect_channel with the channel id (for example telegram). To hand the user off to their normal agent, call open_agent.", "To configure inference access, call configure_model_provider — the host starts masked provider and default-model setup. To connect a chat channel, call connect_channel with the channel id (for example telegram) — the guided setup then runs right here in the chat. To hand the user off to their normal agent, call open_agent.",
"Switching: If the user prefers menus, a step-by-step wizard, or masked secret prompts, hand off with open_setup using target guided, classic, or channels (and channel for channel setup).",
"Channel guidance: when the user asks ABOUT a channel or its prerequisites (bot tokens, app creation, e.g. Slack or Telegram), call channel_info and use its docs link; never guess credentials or steps. When they ask to CONNECT a channel, call connect_channel right away — do not detour through channel_info.",
"Keep replies under 120 words. Ask one question at a time. Never claim something was done unless the tool result confirms it.", "Keep replies under 120 words. Ask one question at a time. Never claim something was done unless the tool result confirms it.",
].join("\n"); ].join("\n");

View file

@ -252,6 +252,44 @@ describe("CrestodianChatEngine", () => {
expect(reply.text).toContain("Sensitive input is not accepted"); expect(reply.text).toContain("Sensitive input is not accepted");
expect(reply.text).toContain("openclaw channels add --channel telegram"); expect(reply.text).toContain("openclaw channels add --channel telegram");
expect(reply.sensitive).toBeUndefined(); expect(reply.sensitive).toBeUndefined();
const handoff = await engine.handle("open channel wizard");
expect(handoff.action).toBe("open-setup");
expect(handoff.handoff).toEqual({
kind: "open-setup",
target: "channels",
channel: "telegram",
});
const channelRequired = await engine.handle("open channel wizard");
expect(channelRequired.action).toBe("none");
expect(channelRequired.text).toContain("Which channel");
const selectedChannel = await engine.handle("slack");
expect(selectedChannel.action).toBe("open-setup");
expect(selectedChannel.handoff).toEqual({
kind: "open-setup",
target: "channels",
channel: "slack",
});
});
it("hands typed setup switches to CLI hosts but not gateway hosts", async () => {
const common = {
runAgentTurn: async () => null,
planWithAssistant: async () => null,
deps: { loadOverview: fakeOverviewLoader() },
};
const cli = new CrestodianChatEngine({ ...common, surface: "cli" });
const cliReply = await cli.handle("open classic wizard");
expect(cliReply.action).toBe("open-setup");
expect(cliReply.handoff).toEqual({ kind: "open-setup", target: "classic" });
const gateway = new CrestodianChatEngine({ ...common, surface: "gateway" });
const gatewayReply = await gateway.handle("open setup wizard");
expect(gatewayReply.action).toBe("none");
expect(gatewayReply.handoff).toBeUndefined();
expect(gatewayReply.text).toContain("The app owns the setup screens here");
}); });
it("keeps hosted-wizard validation errors on the current prompt", async () => { it("keeps hosted-wizard validation errors on the current prompt", async () => {
@ -320,6 +358,21 @@ describe("CrestodianChatEngine", () => {
expect(reply.text).toContain("Handing you over"); expect(reply.text).toContain("Handing you over");
}); });
it("executes an open-setup directive from the agent loop", async () => {
const engine = new CrestodianChatEngine({
surface: "cli",
runAgentTurn: async () => ({
text: "Opening the menu wizard.",
directive: { kind: "open-setup" as const, target: "guided" as const },
}),
deps: { loadOverview: fakeOverviewLoader() },
});
const reply = await engine.handle("I would rather use menus");
expect(reply.action).toBe("open-setup");
expect(reply.handoff).toEqual({ kind: "open-setup", target: "guided" });
expect(reply.text).toContain("Opening the menu wizard");
});
it("starts the channel wizard from an agent-loop directive", async () => { it("starts the channel wizard from an agent-loop directive", async () => {
useTempStateDir(); useTempStateDir();
const engine = new CrestodianChatEngine({ const engine = new CrestodianChatEngine({

View file

@ -61,7 +61,7 @@ export type CrestodianChatEngineOptions = {
) => Promise<void>; ) => Promise<void>;
}; };
export type CrestodianChatReplyAction = "none" | "exit" | "open-tui"; export type CrestodianChatReplyAction = "none" | "exit" | "open-tui" | "open-setup";
export type CrestodianChatReply = { export type CrestodianChatReply = {
text: string; text: string;
@ -304,6 +304,8 @@ async function withDeadline<T>(work: Promise<T>, fallback: T, deadlineMs: number
export class CrestodianChatEngine { export class CrestodianChatEngine {
private pending: CrestodianOperation | null = null; private pending: CrestodianOperation | null = null;
private wizardBridge: ActiveWizardBridge | null = null; private wizardBridge: ActiveWizardBridge | null = null;
private lastSensitiveChannel: string | undefined;
private awaitingSetupChannel = false;
private readonly history: CrestodianAssistantTurn[] = []; private readonly history: CrestodianAssistantTurn[] = [];
private readonly agentSession: CrestodianAgentSession = createCrestodianAgentSession(); private readonly agentSession: CrestodianAgentSession = createCrestodianAgentSession();
/** Turns run strictly one at a time; interleaved handles corrupt wizard/pending state. */ /** Turns run strictly one at a time; interleaved handles corrupt wizard/pending state. */
@ -334,6 +336,8 @@ export class CrestodianChatEngine {
async dispose(): Promise<void> { async dispose(): Promise<void> {
this.wizardBridge?.session.cancel(); this.wizardBridge?.session.cancel();
this.wizardBridge = null; this.wizardBridge = null;
this.lastSensitiveChannel = undefined;
this.awaitingSetupChannel = false;
await cleanupCrestodianAgentSession(this.agentSession); await cleanupCrestodianAgentSession(this.agentSession);
} }
@ -378,6 +382,23 @@ export class CrestodianChatEngine {
// Leaving the process is a host action, not a conversation the AI owns. // Leaving the process is a host action, not a conversation the AI owns.
return { text: "Crestodian retracts into shell. Bye.", action: "exit" }; return { text: "Crestodian retracts into shell. Bye.", action: "exit" };
} }
if (this.awaitingSetupChannel) {
if (/^(cancel|abort|stop)$/i.test(trimmed)) {
this.awaitingSetupChannel = false;
return { text: "Channel wizard handoff cancelled.", action: "none" };
}
if (!/^[a-z0-9_-]+$/i.test(trimmed)) {
return {
text: "Reply with one channel id, such as `slack` or `telegram`, or say `cancel`.",
action: "none",
};
}
this.awaitingSetupChannel = false;
return await this.runOperation(
{ kind: "open-setup", target: "channels", channel: trimmed.toLowerCase() },
undefined,
);
}
// Secret hygiene: an exact `config set` on a sensitive path carries a raw // Secret hygiene: an exact `config set` on a sensitive path carries a raw
// token and must never reach a model. It runs on the deterministic path // token and must never reach a model. It runs on the deterministic path
@ -568,6 +589,13 @@ export class CrestodianChatEngine {
handoff: loopReply.directive, handoff: loopReply.directive,
}; };
} }
if (loopReply.directive?.kind === "open-setup") {
const handoff = await this.runOperation(loopReply.directive, undefined);
return {
...handoff,
text: [loopReply.text, handoff.text].filter(Boolean).join("\n\n"),
};
}
return { text: loopReply.text, action: "none" }; return { text: loopReply.text, action: "none" };
} }
@ -602,6 +630,40 @@ export class CrestodianChatEngine {
}; };
} }
if (operation.kind === "open-setup") {
if (this.opts.surface === "gateway") {
return {
text: "The app owns the setup screens here — use Settings, or run `openclaw onboard` in a terminal.",
action: "none",
};
}
let handoff = operation;
if (handoff.target === "channels" && !handoff.channel) {
const channel = this.lastSensitiveChannel;
if (!channel) {
this.awaitingSetupChannel = true;
return {
text: "Which channel should I open in the masked terminal wizard?",
action: "none",
};
}
this.lastSensitiveChannel = undefined;
handoff = { ...handoff, channel };
}
this.awaitingSetupChannel = false;
const label =
handoff.target === "guided"
? "guided setup"
: handoff.target === "classic"
? "classic setup"
: `${handoff.channel ?? "channel"} setup`;
return {
text: `Opening the ${label} wizard.`,
action: "open-setup",
handoff,
};
}
if (operation.kind === "channel-setup") { if (operation.kind === "channel-setup") {
// Starting the wizard is not a write; the wizard collects explicit // Starting the wizard is not a write; the wizard collects explicit
// answers and commits only at the end. // answers and commits only at the end.
@ -712,6 +774,7 @@ export class CrestodianChatEngine {
} }
private async startChannelSetupWizard(channel: string): Promise<string> { private async startChannelSetupWizard(channel: string): Promise<string> {
this.lastSensitiveChannel = undefined;
const runWizard = const runWizard =
this.opts.runChannelSetupWizard ?? this.opts.runChannelSetupWizard ??
((ch: string, prompter: WizardPrompterLike) => defaultChannelSetupWizardRunner(ch)(prompter)); ((ch: string, prompter: WizardPrompterLike) => defaultChannelSetupWizardRunner(ch)(prompter));
@ -826,15 +889,17 @@ export class CrestodianChatEngine {
if (this.opts.surface === "cli" && bridge.step.sensitive === true) { if (this.opts.surface === "cli" && bridge.step.sensitive === true) {
bridge.session.cancel(); bridge.session.cancel();
this.wizardBridge = null; this.wizardBridge = null;
return bridge.kind === "model" if (bridge.kind === "model") {
? [ return [
"Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.", "Sensitive input is not accepted in the Crestodian chat because terminal input is visible.",
"Run `openclaw configure --section model` to finish setup with masked prompts.", "Run `openclaw configure --section model` to finish setup with masked prompts.",
].join("\n") ].join("\n");
: [ }
"Sensitive input is not accepted in the Crestodian TUI because terminal input is visible.", this.lastSensitiveChannel = bridge.label;
`Run \`openclaw channels add --channel ${bridge.label}\` to finish setup with masked prompts.`, return [
].join("\n"); "Sensitive input is not accepted in the Crestodian chat because terminal input is visible.",
`Say \`open channel wizard\` and I'll hand you to the masked terminal wizard for ${bridge.label}, or run \`openclaw channels add --channel ${bridge.label}\` yourself later.`,
].join("\n");
} }
if (bridge.step.type === "note" || bridge.step.type === "progress") { if (bridge.step.type === "note" || bridge.step.type === "progress") {
const step = bridge.step; const step = bridge.step;

View file

@ -36,6 +36,8 @@ export type RunCrestodianOptions = {
welcomeVariant?: "onboarding"; welcomeVariant?: "onboarding";
/** Workspace override for the proposed first-run setup (from --workspace). */ /** Workspace override for the proposed first-run setup (from --workspace). */
setupWorkspace?: string; setupWorkspace?: string;
/** Risk acknowledgement already collected by the calling onboarding flow. */
setupAcceptRisk?: boolean;
onReady?: () => void; onReady?: () => void;
deps?: CrestodianCommandDeps; deps?: CrestodianCommandDeps;
formatOverview?: (overview: CrestodianOverview) => string; formatOverview?: (overview: CrestodianOverview) => string;

View file

@ -373,6 +373,108 @@ describe("parseCrestodianOperation", () => {
expect(isPersistentCrestodianOperation({ kind: "channel-list" })).toBe(false); expect(isPersistentCrestodianOperation({ kind: "channel-list" })).toBe(false);
}); });
it("parses anchored setup switches and channel info", () => {
for (const input of [
"open setup wizard",
"setup wizard",
"menu setup",
"use the setup wizard",
"use the wizard",
]) {
expect(parseCrestodianOperation(input)).toEqual({ kind: "open-setup", target: "guided" });
}
for (const input of ["open classic wizard", "open classic setup wizard", "classic setup"]) {
expect(parseCrestodianOperation(input)).toEqual({ kind: "open-setup", target: "classic" });
}
expect(parseCrestodianOperation("open channel wizard")).toEqual({
kind: "open-setup",
target: "channels",
});
expect(parseCrestodianOperation("open channel wizard for Slack")).toEqual({
kind: "open-setup",
target: "channels",
channel: "slack",
});
expect(parseCrestodianOperation("channel info Slack")).toEqual({
kind: "channel-info",
channel: "slack",
});
expect(parseCrestodianOperation("about Telegram channel")).toEqual({
kind: "channel-info",
channel: "telegram",
});
expect(parseCrestodianOperation("please open the setup wizard soon").kind).toBe("none");
expect(parseCrestodianOperation("channel info slack please").kind).toBe("none");
});
it("prints one-shot setup pointers", async () => {
const { runtime, lines } = createCrestodianTestRuntime();
for (const operation of [
{ kind: "open-setup", target: "guided" } as const,
{ kind: "open-setup", target: "classic" } as const,
{ kind: "open-setup", target: "channels", channel: "slack" } as const,
]) {
const result = await executeCrestodianOperation(operation, runtime);
expect(result.applied).toBe(false);
}
const output = lines.join("\n");
expect(output).toContain("openclaw onboard`");
expect(output).toContain("openclaw onboard --classic");
expect(output).toContain("openclaw channels add --channel slack");
});
it("prints discovered channel metadata and sorted unknown-channel choices", async () => {
const { runtime, lines } = createCrestodianTestRuntime();
const entries = [
{
id: "telegram",
meta: {
label: "Telegram",
blurb: "Telegram bot messaging.",
docsPath: "/channels/telegram",
},
},
{
id: "slack",
meta: {
label: "Slack",
blurb: "Slack app messaging.",
docsPath: "/channels/slack",
},
},
];
const deps = {
listChannelSetupPlugins: () => [{ id: "slack" }],
resolveChannelSetupEntries: () => ({
entries,
installedCatalogEntries: [],
installableCatalogEntries: [],
installedCatalogById: new Map(),
installableCatalogById: new Map(),
}),
isChannelConfigured: (_cfg: unknown, channel: string) => channel === "slack",
} as never;
await executeCrestodianOperation({ kind: "channel-info", channel: "slack" }, runtime, {
deps,
});
const knownOutput = lines.join("\n");
expect(knownOutput).toContain("Slack (slack)");
expect(knownOutput).toContain("Slack app messaging.");
expect(knownOutput).toContain("Configured: yes");
expect(knownOutput).toContain("Installed: yes");
expect(knownOutput).toContain("https://docs.openclaw.ai/channels/slack");
expect(knownOutput).toContain("open channel wizard for slack");
lines.length = 0;
await executeCrestodianOperation({ kind: "channel-info", channel: "matrix" }, runtime, {
deps,
});
expect(lines.join("\n")).toContain("Known channels: slack, telegram");
});
it("parses agent creation requests", () => { it("parses agent creation requests", () => {
expect( expect(
parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"), parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"),

View file

@ -56,7 +56,13 @@ export type CrestodianOperation =
| { kind: "setup"; workspace?: string; model?: string } | { kind: "setup"; workspace?: string; model?: string }
| { kind: "model-setup"; workspace?: string } | { kind: "model-setup"; workspace?: string }
| { kind: "channel-list" } | { kind: "channel-list" }
| { kind: "channel-info"; channel: string }
| { kind: "channel-setup"; channel: string } | { kind: "channel-setup"; channel: string }
| {
kind: "open-setup";
target: "guided" | "classic" | "channels";
channel?: string;
}
| { kind: "gateway-status" } | { kind: "gateway-status" }
| { kind: "gateway-start" } | { kind: "gateway-start" }
| { kind: "gateway-stop" } | { kind: "gateway-stop" }
@ -119,6 +125,9 @@ export type CrestodianCommandDeps = {
/** Where setup side effects run; the gateway surface never manages its own daemon. */ /** Where setup side effects run; the gateway surface never manages its own daemon. */
setupSurface?: "cli" | "gateway"; setupSurface?: "cli" | "gateway";
applySetup?: typeof import("./setup-apply.js").applyCrestodianSetup; applySetup?: typeof import("./setup-apply.js").applyCrestodianSetup;
listChannelSetupPlugins?: typeof import("../channels/plugins/setup-registry.js").listChannelSetupPlugins;
resolveChannelSetupEntries?: typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries;
isChannelConfigured?: typeof import("../config/channel-configured-shared.js").isStaticallyChannelConfigured;
}; };
// Grammar tokens. Workspace/path tokens accept quoted strings so paths with // Grammar tokens. Workspace/path tokens accept quoted strings so paths with
@ -172,9 +181,15 @@ const PLUGIN_UNINSTALL_RE =
const CHANNEL_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i; const CHANNEL_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i;
const CHANNEL_CONNECT_RE = const CHANNEL_CONNECT_RE =
/^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?<channel>[a-z0-9_-]+)(?:\s+channel)?$/i; /^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?<channel>[a-z0-9_-]+)(?:\s+channel)?$/i;
const CHANNEL_INFO_RE =
/^(?:channel\s+info\s+(?<channel>[a-z0-9_-]+)|about\s+(?<aboutChannel>[a-z0-9_-]+)\s+channel)$/i;
const OPEN_GUIDED_SETUP_RE =
/^(?:open\s+setup\s+wizard|setup\s+wizard|menu\s+setup|use\s+the\s+(?:setup\s+)?wizard)$/i;
const OPEN_CLASSIC_SETUP_RE = /^(?:open\s+classic(?:\s+setup)?\s+wizard|classic\s+setup)$/i;
const OPEN_CHANNEL_SETUP_RE = /^open\s+channel\s+wizard(?:\s+for\s+(?<channel>[a-z0-9_-]+))?$/i;
const NO_MATCH_MESSAGE = const NO_MATCH_MESSAGE =
"I can run doctor/status/health, check or restart Gateway, list agents/models, configure a model provider, set default model, connect channels (`connect telegram`), show audit, or switch to your agent TUI."; "I can run doctor/status/health, check or restart Gateway, list agents/models, configure a model provider, set default model, connect channels (`connect telegram`), show `channel info <channel>`, open the setup wizard, show audit, or switch to your agent TUI.";
/** Audit/source labels for detected inference backends (docs-visible contract). */ /** Audit/source labels for detected inference backends (docs-visible contract). */
const INFERENCE_SOURCE_LABELS: Record<InferenceBackendKind, string> = { const INFERENCE_SOURCE_LABELS: Record<InferenceBackendKind, string> = {
@ -289,6 +304,11 @@ export function parseCrestodianOperation(input: string): CrestodianOperation {
if (CHANNEL_LIST_RE.test(trimmed)) { if (CHANNEL_LIST_RE.test(trimmed)) {
return { kind: "channel-list" }; return { kind: "channel-list" };
} }
const channelInfoMatch = trimmed.match(CHANNEL_INFO_RE);
const channelInfo = channelInfoMatch?.groups?.channel ?? channelInfoMatch?.groups?.aboutChannel;
if (channelInfo) {
return { kind: "channel-info", channel: channelInfo.toLowerCase() };
}
const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE); const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE);
if (channelConnectMatch?.groups?.channel) { if (channelConnectMatch?.groups?.channel) {
return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() }; return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() };
@ -301,6 +321,21 @@ export function parseCrestodianOperation(input: string): CrestodianOperation {
...(workspace ? { workspace } : {}), ...(workspace ? { workspace } : {}),
}; };
} }
if (OPEN_GUIDED_SETUP_RE.test(trimmed)) {
return { kind: "open-setup", target: "guided" };
}
if (OPEN_CLASSIC_SETUP_RE.test(trimmed)) {
return { kind: "open-setup", target: "classic" };
}
const openChannelSetupMatch = trimmed.match(OPEN_CHANNEL_SETUP_RE);
if (openChannelSetupMatch) {
const channel = openChannelSetupMatch.groups?.channel?.toLowerCase();
return {
kind: "open-setup",
target: "channels",
...(channel ? { channel } : {}),
};
}
const setupMatch = trimmed.match(SETUP_RE); const setupMatch = trimmed.match(SETUP_RE);
if (setupMatch) { if (setupMatch) {
const workspace = trimShellishToken(setupMatch.groups?.workspace); const workspace = trimShellishToken(setupMatch.groups?.workspace);
@ -578,6 +613,38 @@ async function loadOverviewForOperation(
return await loadCrestodianOverview(); return await loadCrestodianOverview();
} }
async function resolveChannelSetupState(deps: CrestodianCommandDeps | undefined) {
const listPlugins =
deps?.listChannelSetupPlugins ??
(await import("../channels/plugins/setup-registry.js")).listChannelSetupPlugins;
const resolveEntries =
deps?.resolveChannelSetupEntries ??
(await import("../commands/channel-setup/discovery.js")).resolveChannelSetupEntries;
const isConfigured =
deps?.isChannelConfigured ??
(await import("../config/channel-configured-shared.js")).isStaticallyChannelConfigured;
const { shouldShowChannelInSetup } = await import("../commands/channel-setup/discovery.js");
const snapshot = await readConfigFileSnapshotLazy();
const cfg = snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
const installedPlugins = listPlugins();
const resolved = resolveEntries({ cfg, installedPlugins });
return {
cfg,
installedPlugins,
resolved: {
...resolved,
// Match the connect/list surfaces: setup-hidden channels stay invisible
// to chat listings and channel info alike.
entries: resolved.entries.filter((entry) => shouldShowChannelInSetup(entry.meta)),
},
isConfigured,
};
}
function formatChannelDocsUrl(docsPath: string): string {
return `https://docs.openclaw.ai${docsPath.startsWith("/") ? docsPath : `/${docsPath}`}`;
}
function formatConfigValidationLine(snapshot: ConfigFileSnapshot): string { function formatConfigValidationLine(snapshot: ConfigFileSnapshot): string {
if (!snapshot.exists) { if (!snapshot.exists) {
return `Config missing: ${shortenHomePath(snapshot.path)}`; return `Config missing: ${shortenHomePath(snapshot.path)}`;
@ -1018,22 +1085,8 @@ export async function executeCrestodianOperation(
// Use the same discovery as channel setup (bundled plugins + trusted // Use the same discovery as channel setup (bundled plugins + trusted
// catalog), so the listing matches what `connect <channel>` can configure // catalog), so the listing matches what `connect <channel>` can configure
// even before any plugin registry is active. // even before any plugin registry is active.
const [ const { resolved } = await resolveChannelSetupState(opts.deps);
{ listChannelSetupPlugins }, const entries = resolved.entries.toSorted((a, b) => a.id.localeCompare(b.id));
{ 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( runtime.log(
[ [
"Channels:", "Channels:",
@ -1046,6 +1099,38 @@ export async function executeCrestodianOperation(
); );
return { applied: false }; return { applied: false };
} }
case "channel-info": {
const { cfg, installedPlugins, resolved, isConfigured } = await resolveChannelSetupState(
opts.deps,
);
const channel = operation.channel.toLowerCase();
const entry = resolved.entries.find((candidate) => candidate.id === channel);
if (!entry) {
const knownIds = resolved.entries.map((candidate) => candidate.id).toSorted();
runtime.log(
[
`Unknown channel: ${channel}`,
`Known channels: ${knownIds.length > 0 ? knownIds.join(", ") : "none"}`,
].join("\n"),
);
return { applied: false };
}
const installed =
installedPlugins.some((plugin) => plugin.id === entry.id) ||
resolved.installedCatalogById.has(entry.id);
runtime.log(
[
`${entry.meta.label} (${entry.id})`,
entry.meta.blurb,
`Configured: ${isConfigured(cfg, entry.id) ? "yes" : "no"}`,
`Installed: ${installed ? "yes" : "no"}`,
`Docs: ${formatChannelDocsUrl(entry.meta.docsPath)}`,
"",
`Say \`connect ${entry.id}\` to set it up here, or \`open channel wizard for ${entry.id}\` for the masked terminal wizard.`,
].join("\n"),
);
return { applied: false };
}
case "channel-setup": case "channel-setup":
// Channel setup is a multi-step wizard; only interactive Crestodian (TUI // Channel setup is a multi-step wizard; only interactive Crestodian (TUI
// chat bridge or the gateway chat) can host it. One-shot mode points at // chat bridge or the gateway chat) can host it. One-shot mode points at
@ -1067,6 +1152,18 @@ export async function executeCrestodianOperation(
].join("\n"), ].join("\n"),
); );
return { applied: false }; return { applied: false };
case "open-setup": {
const command =
operation.target === "guided"
? "openclaw onboard"
: operation.target === "classic"
? "openclaw onboard --classic"
: `openclaw channels add${operation.channel ? ` --channel ${operation.channel}` : ""}`;
runtime.log(
`One-shot mode cannot open an interactive wizard. Run \`${command}\` in a terminal.`,
);
return { applied: false };
}
case "setup": case "setup":
return await executeSetup(operation, runtime, opts); return await executeSetup(operation, runtime, opts);
case "config-set": case "config-set":

View file

@ -14,6 +14,7 @@ import {
activateSetupInference, activateSetupInference,
detectSetupInference, detectSetupInference,
listSetupInferenceManualProviders, listSetupInferenceManualProviders,
verifySetupInference,
} from "./setup-inference.js"; } from "./setup-inference.js";
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
@ -208,6 +209,14 @@ describe("activateSetupInference", () => {
}, },
}); });
expect(result).toMatchObject({ ok: false, status: "format" }); expect(result).toMatchObject({ ok: false, status: "format" });
expect(runEmbeddedAgent).toHaveBeenCalledWith(
expect.objectContaining({
runId: expect.stringMatching(/^probe-setup-inference-/),
sessionId: expect.stringMatching(/^probe-setup-inference-.*-session$/),
sessionKey: expect.stringMatching(/^temp:setup-inference:probe-setup-inference-/),
lane: "session:probe-setup-inference:anthropic",
}),
);
expect(applySetup).not.toHaveBeenCalled(); expect(applySetup).not.toHaveBeenCalled();
}); });
@ -582,3 +591,58 @@ describe("activateSetupInference", () => {
}); });
}); });
}); });
describe("verifySetupInference", () => {
function configuredSnapshot() {
return {
exists: true,
valid: true,
config: {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
},
};
}
it("returns a passing live check without persisting setup", async () => {
const applySetup = vi.fn();
const updateConfig = vi.fn();
const result = await verifySetupInference({
runtime,
deps: {
readConfigFileSnapshot: vi.fn(async () => configuredSnapshot()) as never,
runEmbeddedAgent: vi.fn(async () => ({
meta: { finalAssistantVisibleText: "OK" },
})) as never,
applySetup: applySetup as never,
updateConfig: updateConfig as never,
createTempDir: makeTempDir,
},
});
expect(result).toMatchObject({ ok: true, modelRef: "openai/gpt-5.5" });
expect(applySetup).not.toHaveBeenCalled();
expect(updateConfig).not.toHaveBeenCalled();
});
it("maps live-check failures without writing config or auth", async () => {
const applySetup = vi.fn();
const updateConfig = vi.fn();
const result = await verifySetupInference({
runtime,
timeoutMs: 50,
deps: {
readConfigFileSnapshot: vi.fn(async () => configuredSnapshot()) as never,
runEmbeddedAgent: vi.fn(async () => {
throw new Error("401 invalid_api_key");
}) as never,
applySetup: applySetup as never,
updateConfig: updateConfig as never,
createTempDir: makeTempDir,
},
});
expect(result).toMatchObject({ ok: false, status: "auth" });
expect(applySetup).not.toHaveBeenCalled();
expect(updateConfig).not.toHaveBeenCalled();
});
});

View file

@ -99,6 +99,10 @@ export type ActivateSetupInferenceResult =
| { ok: true; modelRef: string; latencyMs: number; lines: string[] } | { ok: true; modelRef: string; latencyMs: number; lines: string[] }
| { ok: false; status: SetupInferenceStatus; error: string }; | { ok: false; status: SetupInferenceStatus; error: string };
export type VerifySetupInferenceResult =
| { ok: true; modelRef: string; latencyMs: number }
| { ok: false; status: SetupInferenceStatus; error: string };
export type ActivateSetupInferenceParams = { export type ActivateSetupInferenceParams = {
kind: InferenceBackendKind | "api-key"; kind: InferenceBackendKind | "api-key";
/** Manual step only: provider-auth choice returned by detection. */ /** Manual step only: provider-auth choice returned by detection. */
@ -653,6 +657,47 @@ export async function activateSetupInference(
} }
} }
/** Live-test the configured default model without changing config or auth state. */
export async function verifySetupInference(params: {
kind?: "existing-model";
runtime: RuntimeEnv;
timeoutMs?: number;
deps?: ActivateSetupInferenceDeps;
}): Promise<VerifySetupInferenceResult> {
const deps: ActivateSetupInferenceDeps = {
...params.deps,
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
};
const readSnapshot =
deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot;
const snapshot = await readSnapshot();
const cfg: OpenClawConfig =
snapshot.exists && snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {};
const tempDir = await (
deps.createTempDir ?? (() => fs.mkdtemp(path.join(os.tmpdir(), "openclaw-setup-inference-")))
)();
try {
const plan = await buildTestPlan({
kind: params.kind ?? "existing-model",
cfg,
workspaceDir: tempDir,
pluginWorkspaceDir: tempDir,
agentDir: path.join(tempDir, "agent"),
runtime: params.runtime,
deps,
});
if ("error" in plan) {
return { ok: false, status: "unavailable", error: plan.error };
}
const test = await runSetupInferenceTest({ plan, tempDir, deps });
return test.ok ? { ...test, modelRef: plan.modelRef } : test;
} finally {
await (deps.removeTempDir ?? ((dir: string) => fs.rm(dir, { recursive: true, force: true })))(
tempDir,
);
}
}
function applyManualAuthConfig( function applyManualAuthConfig(
config: OpenClawConfig, config: OpenClawConfig,
manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>, manualAuth: NonNullable<SetupInferenceTestPlan["manualAuth"]>,
@ -693,7 +738,9 @@ async function runSetupInferenceTest(params: {
{ ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string } { ok: true; latencyMs: number } | { ok: false; status: SetupInferenceStatus; error: string }
> { > {
const { plan, tempDir, deps } = params; const { plan, tempDir, deps } = params;
const runId = `setup-inference-${randomUUID()}`; // Keep these probe prefixes aligned with logging/subsystem.ts and process/command-queue.ts
// so expected setup failures stay off the interactive TTY.
const runId = `probe-setup-inference-${randomUUID()}`;
const sessionId = `${runId}-session`; const sessionId = `${runId}-session`;
const sessionFile = path.join(tempDir, "session.jsonl"); const sessionFile = path.join(tempDir, "session.jsonl");
const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS; const timeoutMs = deps.timeoutMs ?? SETUP_INFERENCE_TEST_TIMEOUT_MS;
@ -743,7 +790,7 @@ async function runSetupInferenceTest(params: {
: {}), : {}),
timeoutMs, timeoutMs,
runId, runId,
lane: `setup-inference:${plan.provider}`, lane: `session:probe-setup-inference:${plan.provider}`,
thinkLevel: "off", thinkLevel: "off",
reasoningLevel: "off", reasoningLevel: "off",
verboseLevel: "off", verboseLevel: "off",

View file

@ -1,5 +1,6 @@
// Crestodian TUI backend tests cover rescue status integration with the TUI backend. // Crestodian TUI backend tests cover rescue status integration with the TUI backend.
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { CrestodianOperation } from "./operations.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import type { CrestodianOverview } from "./overview.js"; import type { CrestodianOverview } from "./overview.js";
import { runCrestodianTui } from "./tui-backend.js"; import { runCrestodianTui } from "./tui-backend.js";
@ -300,4 +301,77 @@ describe("runCrestodianTui", () => {
expect(runModelSetup).toHaveBeenCalledOnce(); expect(runModelSetup).toHaveBeenCalledOnce();
expect(messages).toEqual([undefined, "configure model provider", undefined]); expect(messages).toEqual([undefined, "configure model provider", undefined]);
}); });
it("launches setup handoffs after the chat TUI is disposed", async () => {
const cases: Array<{
handoff: Extract<CrestodianOperation, { kind: "open-setup" }>;
expected: string;
}> = [
{
handoff: { kind: "open-setup", target: "guided" },
expected: "guided:/tmp/custom-workspace:true",
},
{
handoff: { kind: "open-setup", target: "classic" },
expected: "classic:true:/tmp/custom-workspace:true",
},
{
handoff: { kind: "open-setup", target: "channels", channel: "slack" },
expected: "channels:slack:false",
},
];
for (const { handoff, expected } of cases) {
const events: string[] = [];
await runCrestodianTui(
{
deps: { loadOverview: async () => overview },
setupWorkspace: "/tmp/custom-workspace",
setupAcceptRisk: true,
runTui: async (opts) => {
const backend = opts.backend as unknown as {
sendChat: (opts: { message: string }) => Promise<{ runId: string }>;
setRequestExitHandler: (handler: () => void) => void;
engine: {
handle: () => Promise<{
text: string;
action: "open-setup";
handoff: CrestodianOperation;
}>;
dispose: () => Promise<void>;
};
};
backend.engine.handle = async () => ({
text: "Opening setup.",
action: "open-setup",
handoff,
});
backend.engine.dispose = async () => {
events.push("disposed");
};
const requestedExit = new Promise<void>((resolve) => {
backend.setRequestExitHandler(resolve);
});
await backend.sendChat({ message: "open setup wizard" });
await requestedExit;
return { exitReason: "exit" };
},
runGuidedSetup: async (opts) => {
events.push(`guided:${opts.workspace ?? "default"}:${String(opts.acceptRisk)}`);
},
runClassicSetup: async (opts) => {
events.push(
`classic:${String(opts.classic)}:${opts.workspace ?? "default"}:${String(opts.acceptRisk)}`,
);
},
runChannelsAdd: async (opts, _runtime, params) => {
events.push(`channels:${opts.channel ?? "all"}:${String(params?.hasFlags)}`);
},
},
createRuntime(),
);
expect(events).toEqual(["disposed", expected]);
}
});
}); });

View file

@ -4,6 +4,8 @@ import type {
SessionsPatchParams, SessionsPatchParams,
SessionsPatchResult, SessionsPatchResult,
} from "../../packages/gateway-protocol/src/index.js"; } from "../../packages/gateway-protocol/src/index.js";
import type { ChannelsAddOptions } from "../commands/channels/add.js";
import type { OnboardOptions } from "../commands/onboard-types.js";
import { buildAgentMainSessionKey } from "../routing/session-key.js"; import { buildAgentMainSessionKey } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { notifyListeners } from "../shared/listeners.js"; import { notifyListeners } from "../shared/listeners.js";
@ -39,6 +41,8 @@ export type CrestodianTuiOptions = {
welcomeVariant?: "onboarding"; welcomeVariant?: "onboarding";
/** Workspace override for the proposed first-run setup (from --workspace). */ /** Workspace override for the proposed first-run setup (from --workspace). */
setupWorkspace?: string; setupWorkspace?: string;
/** Risk acknowledgement already collected by the calling onboarding flow. */
setupAcceptRisk?: boolean;
/** Test seam for the channel-setup wizard hosted by the chat bridge. */ /** Test seam for the channel-setup wizard hosted by the chat bridge. */
runChannelSetupWizard?: CrestodianChatEngineOptions["runChannelSetupWizard"]; runChannelSetupWizard?: CrestodianChatEngineOptions["runChannelSetupWizard"];
/** Test seam for masked terminal model setup after the TUI exits. */ /** Test seam for masked terminal model setup after the TUI exits. */
@ -47,6 +51,13 @@ export type CrestodianTuiOptions = {
prompter: import("../wizard/prompts.js").WizardPrompter; prompter: import("../wizard/prompts.js").WizardPrompter;
runtime: RuntimeEnv; runtime: RuntimeEnv;
}) => Promise<CrestodianModelSetupResult>; }) => Promise<CrestodianModelSetupResult>;
runGuidedSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise<void>;
runClassicSetup?: (opts: OnboardOptions, runtime: RuntimeEnv) => Promise<void>;
runChannelsAdd?: (
opts: ChannelsAddOptions,
runtime: RuntimeEnv,
params?: { hasFlags?: boolean },
) => Promise<unknown>;
}; };
type CrestodianHistoryMessage = { type CrestodianHistoryMessage = {
@ -312,7 +323,7 @@ class CrestodianTuiBackend implements TuiBackend {
private async respond(runId: string, sessionKey: string, text: string): Promise<void> { private async respond(runId: string, sessionKey: string, text: string): Promise<void> {
try { try {
const reply = await this.engine.handle(text); const reply = await this.engine.handle(text);
if (reply.action === "open-tui" && reply.handoff) { if ((reply.action === "open-tui" || reply.action === "open-setup") && reply.handoff) {
// The outer loop owns interactive handoffs after the Crestodian TUI exits. // The outer loop owns interactive handoffs after the Crestodian TUI exits.
this.handoff = reply.handoff; this.handoff = reply.handoff;
queueMicrotask(() => this.requestExit?.()); queueMicrotask(() => this.requestExit?.());
@ -326,6 +337,44 @@ class CrestodianTuiBackend implements TuiBackend {
} }
} }
async function runSetupHandoff(
handoff: Extract<CrestodianOperation, { kind: "open-setup" }>,
opts: CrestodianTuiOptions,
runtime: RuntimeEnv,
): Promise<void> {
if (handoff.target === "guided") {
const runGuided =
opts.runGuidedSetup ?? (await import("../commands/onboard-guided.js")).runGuidedOnboarding;
await runGuided(
{
...(opts.setupWorkspace ? { workspace: opts.setupWorkspace } : {}),
...(opts.setupAcceptRisk === true ? { acceptRisk: true } : {}),
},
runtime,
);
return;
}
if (handoff.target === "classic") {
const runClassic =
opts.runClassicSetup ??
(await import("../commands/onboard-interactive.js")).runInteractiveSetup;
await runClassic(
{
classic: true,
...(opts.setupWorkspace ? { workspace: opts.setupWorkspace } : {}),
...(opts.setupAcceptRisk === true ? { acceptRisk: true } : {}),
},
runtime,
);
return;
}
const runChannelsAdd =
opts.runChannelsAdd ?? (await import("../commands/channels/add.js")).channelsAddCommand;
await runChannelsAdd(handoff.channel ? { channel: handoff.channel } : {}, runtime, {
hasFlags: false,
});
}
export async function runCrestodianTui( export async function runCrestodianTui(
opts: CrestodianTuiOptions, opts: CrestodianTuiOptions,
runtime: RuntimeEnv, runtime: RuntimeEnv,
@ -398,6 +447,10 @@ export async function runCrestodianTui(
} }
continue; continue;
} }
if (handoff.kind === "open-setup") {
await runSetupHandoff(handoff, opts, runtime);
return;
}
const result = await executeCrestodianOperation(handoff, runtime, { const result = await executeCrestodianOperation(handoff, runtime, {
approved: true, approved: true,
deps: opts.deps, deps: opts.deps,

View file

@ -136,7 +136,12 @@ export const crestodianHandlers: GatewayRequestHandlers = {
const reply = await session.engine.handle(params.message); const reply = await session.engine.handle(params.message);
// The TUI-only "open-tui" handoff becomes a client-visible "open-agent" // The TUI-only "open-tui" handoff becomes a client-visible "open-agent"
// signal: the app should move the user to their normal agent chat. // signal: the app should move the user to their normal agent chat.
const action = reply.action === "open-tui" ? "open-agent" : reply.action; const action =
reply.action === "open-tui"
? "open-agent"
: reply.action === "open-setup"
? "none"
: reply.action;
respond( respond(
true, true,
{ {

View file

@ -115,6 +115,19 @@ export function setConsoleSubsystemFilter(filters?: string[] | null): void {
loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null; loggingState.consoleSubsystemFilter = normalized.length > 0 ? normalized : null;
} }
/** Hides subsystem console lines for TTY-owned work while preserving file logging. */
export async function withConsoleSubsystemsSuppressed<T>(work: () => Promise<T>): Promise<T> {
const previousFilter = loggingState.consoleSubsystemFilter
? [...loggingState.consoleSubsystemFilter]
: null;
setConsoleSubsystemFilter(["__openclaw_tui_quiet__"]);
try {
return await work();
} finally {
setConsoleSubsystemFilter(previousFilter);
}
}
export function setConsoleTimestampPrefix(enabled: boolean): void { export function setConsoleTimestampPrefix(enabled: boolean): void {
loggingState.consoleTimestampPrefix = enabled; loggingState.consoleTimestampPrefix = enabled;
} }

View file

@ -148,6 +148,30 @@ describe("createSubsystemLogger().isEnabled", () => {
expect(warn).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled();
}); });
it("keeps setup-inference probe warnings in the file log while suppressing console", () => {
const file = logPathTracker.nextPath();
setLoggerOverride({ level: "warn", consoleLevel: "warn", file });
const warn = installConsoleMethodSpy("warn");
const log = createSubsystemLogger("agent/embedded");
log.warn("embedded run failover decision", {
runId: "probe-setup-inference-test-run",
provider: "openai",
consoleMessage: "embedded run failover decision: provider=openai error=Authentication failed",
});
log.warn("embedded run agent end", {
runId: "probe-setup-inference-test-run",
provider: "openai",
consoleMessage: "embedded run agent end: provider=openai error=Authentication failed",
});
expect(warn).not.toHaveBeenCalled();
const fileLog = fs.readFileSync(file, "utf8");
expect(fileLog).toContain("embedded run failover decision");
expect(fileLog).toContain("embedded run agent end");
expect(fileLog).toContain('"provider":"openai"');
});
it("does not suppress probe errors for embedded subsystems", () => { it("does not suppress probe errors for embedded subsystems", () => {
setLoggerOverride({ level: "silent", consoleLevel: "error" }); setLoggerOverride({ level: "silent", consoleLevel: "error" });
const error = installConsoleMethodSpy("error"); const error = installConsoleMethodSpy("error");

View file

@ -331,6 +331,26 @@ describe("command queue", () => {
).toBe(true); ).toBe(true);
}); });
it.each([
"session:probe-setup-inference:openai",
"session:temp:setup-inference:probe-setup-inference-test-uuid",
])("keeps setup-inference probe lane failures quiet: %s", async (lane) => {
const error = new Error("Authentication failed");
await expect(
enqueueCommandInLane(lane, async () => {
throw error;
}),
).rejects.toBe(error);
expect(diagnosticMocks.diag.error).not.toHaveBeenCalled();
expect(
diagnosticDebugMessages().some((message) =>
message.includes(`lane task interrupted: lane=${lane}`),
),
).toBe(false);
});
it("getActiveTaskCount returns count of currently executing tasks", async () => { it("getActiveTaskCount returns count of currently executing tasks", async () => {
const { task, release } = enqueueBlockedMainTask(); const { task, release } = enqueueBlockedMainTask();

View file

@ -104,6 +104,16 @@ function isExpectedNonErrorLaneFailure(err: unknown): boolean {
return err instanceof Error && err.name === "LiveSessionModelSwitchError"; return err instanceof Error && err.name === "LiveSessionModelSwitchError";
} }
function isQuietProbeLane(lane: string): boolean {
// setup-inference.ts retains its temp session key, so its derived session lane
// needs the same expected-failure treatment as the explicit probe lane.
return (
lane.startsWith("auth-probe:") ||
lane.startsWith("session:probe-") ||
lane.startsWith("session:temp:setup-inference:probe-setup-inference-")
);
}
/** /**
* Keep queue runtime state on globalThis so every bundled entry/chunk shares * Keep queue runtime state on globalThis so every bundled entry/chunk shares
* the same lanes, counters, and draining flag in production builds. * the same lanes, counters, and draining flag in production builds.
@ -423,7 +433,7 @@ function drainLane(lane: string) {
entry.resolve(result); entry.resolve(result);
} catch (err) { } catch (err) {
const completedCurrentGeneration = completeTask(state, taskId, taskGeneration); const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-"); const isProbeLane = isQuietProbeLane(lane);
if (!isProbeLane && !isExpectedNonErrorLaneFailure(err)) { if (!isProbeLane && !isExpectedNonErrorLaneFailure(err)) {
diag.error( diag.error(
`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`,
@ -464,7 +474,7 @@ export function isGatewayDraining(): boolean {
export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) { export function setCommandLaneConcurrency(lane: string, maxConcurrent: number) {
const cleaned = normalizeLane(lane); const cleaned = normalizeLane(lane);
const state = getLaneState(cleaned); const state = getLaneState(cleaned);
const isProbeLane = cleaned.startsWith("auth-probe:") || cleaned.startsWith("session:probe-"); const isProbeLane = isQuietProbeLane(cleaned);
const minConcurrent = isProbeLane ? 1 : 0; const minConcurrent = isProbeLane ? 1 : 0;
state.maxConcurrent = Math.max(minConcurrent, Math.floor(maxConcurrent)); state.maxConcurrent = Math.max(minConcurrent, Math.floor(maxConcurrent));
if (state.maxConcurrent > 0) { if (state.maxConcurrent > 0) {

View file

@ -233,6 +233,55 @@ export const en = {
validWebSocketUrl: "URL must start with ws:// or wss://", validWebSocketUrl: "URL must start with ws:// or wss://",
websocketUrl: "Gateway WebSocket URL", websocketUrl: "Gateway WebSocket URL",
}, },
guided: {
aiAccessTitle: "AI access",
apiKeyPrompt: "API key or token for {label}",
appliedTitle: "Setup applied",
complete: "OpenClaw is ready.",
detected: "AI detection complete.",
detectedCandidate: "{label} — {detail}{recommended}",
detectedTitle: "AI found",
detecting: "Looking for AI you already use…",
enterApiKey: "Enter API key — {label}",
existingModelKept:
"Your configured default model was kept unchanged. Choose how to continue below — retry it, connect another provider, or exit. The check runs outside your workspace, so a workspace-plugin model can fail here while still working in the agent.",
escapeHatches:
"For the full step-by-step wizard, run `openclaw onboard --classic`. You can open plain-language setup help anytime with `openclaw crestodian`.",
failureAuth: "Authentication failed. Sign in again or check the key.",
failureBilling: "Billing is not active for this model or account.",
failureFormat: "The model did not return a usable reply.",
failureRateLimit: "The provider is rate-limiting requests. Try again shortly.",
failureTimeout: "The completion timed out.",
failureUnavailable: "This AI route is not available right now.",
failureUnknown: "The completion failed for an unknown reason.",
foundNothing: "No existing AI access was detected on this machine.",
intro: "Connect your AI",
invalidConfigCrestodian:
"OpenClaw config is invalid. Opening Crestodian to inspect and repair it before onboarding writes anything.",
manualChoice: "How would you like to connect AI?",
nextSteps:
"Workspace: {workspace}\nAdd a channel: `openclaw channels add`\nPrefer chatting? Run `openclaw crestodian` and say `connect telegram` (or `connect slack`).\nOpen the dashboard: `openclaw dashboard`\nChat later: `openclaw`",
nextStepsTitle: "Next steps",
openChatNow: "Open the chat now?",
openCrestodian: "Open Crestodian chat (help in plain language)",
recommendedSuffix: " — recommended",
repliedIn: "AI check: replied in {seconds}s",
retryCandidate: "Retry {label} ({detail})",
skipAi: "Skip AI setup for now",
skipAiLater:
"To add AI later, set OPENAI_API_KEY or ANTHROPIC_API_KEY, or install and log into codex, claude, or gemini. Then re-run `openclaw onboard`.",
testFailed: "AI check failed.",
testFailure: "✗ {label}: {reason}\n{detail}",
testPassed: "AI check passed.",
testingCandidate: "Testing {label} ({modelRef}) — real completion, not a ping…",
testingManualProvider: "Testing {label} — real completion, not a ping…",
tryCandidate: "Try {label} ({detail})",
ttyRequired:
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
useClassic: "Use the classic step-by-step wizard",
welcomeTitle: "Setup choices",
workspace: "Workspace directory",
},
setup: { setup: {
authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.", authChoiceFailedRetry: "Pick another provider or auth method, or choose Skip for now.",
authChoiceFailedTitle: "Provider setup failed", authChoiceFailedTitle: "Provider setup failed",
@ -285,6 +334,14 @@ export const en = {
skipChannels: "Skipping channel setup.", skipChannels: "Skipping channel setup.",
skipSearch: "Skipping search setup.", skipSearch: "Skipping search setup.",
skipSkills: "Skipping skills setup.", skipSkills: "Skipping skills setup.",
testAiAccess: "Test AI access now with a live completion?",
testAiContinue: "Continue anyway",
testAiFailure: "AI access test failed: {reason}",
testAiFailureChoice: "How would you like to continue?",
testAiFix: "Fix model/auth setup",
testAiProgress: "Testing AI access with a live completion…",
testAiSuccess: "AI access works. Replied in {seconds}s.",
testAiTitle: "AI access test",
whatSetup: "What do you want to set up?", whatSetup: "What do you want to set up?",
workspaceDirectory: "Workspace directory", workspaceDirectory: "Workspace directory",
}, },

View file

@ -230,6 +230,55 @@ export const zh_CN = {
validWebSocketUrl: "URL 必须以 ws:// 或 wss:// 开头", validWebSocketUrl: "URL 必须以 ws:// 或 wss:// 开头",
websocketUrl: "Gateway WebSocket URL", websocketUrl: "Gateway WebSocket URL",
}, },
guided: {
aiAccessTitle: "AI 访问",
apiKeyPrompt: "{label} 的 API key 或 token",
appliedTitle: "设置已应用",
complete: "OpenClaw 已准备就绪。",
detected: "AI 检测完成。",
detectedCandidate: "{label} — {detail}{recommended}",
detectedTitle: "找到的 AI",
detecting: "正在查找你已使用的 AI…",
enterApiKey: "输入 API key — {label}",
existingModelKept:
"已配置的默认模型保持不变。请在下方选择如何继续——重试、连接其他提供商,或退出。此检查在工作区之外运行,因此工作区插件提供的模型可能在这里失败,但在 agent 中仍可正常工作。",
escapeHatches:
"如需完整的分步向导,请运行 `openclaw onboard --classic`。你也可以随时运行 `openclaw crestodian`,用自然语言获得设置帮助。",
failureAuth: "认证失败。请重新登录或检查 key。",
failureBilling: "此模型或账号尚未启用计费。",
failureFormat: "模型没有返回可用的回复。",
failureRateLimit: "Provider 正在限流。请稍后重试。",
failureTimeout: "Completion 超时。",
failureUnavailable: "此 AI 路由目前不可用。",
failureUnknown: "Completion 因未知原因失败。",
foundNothing: "未在此机器上检测到现有 AI 访问方式。",
intro: "连接你的 AI",
invalidConfigCrestodian:
"OpenClaw 配置无效。正在打开 Crestodian以便在 onboarding 写入任何内容前检查并修复配置。",
manualChoice: "你想如何连接 AI",
nextSteps:
"工作区:{workspace}\n添加频道`openclaw channels add`\n更喜欢聊天运行 `openclaw crestodian`,然后说 `connect telegram`(或 `connect slack`)。\n打开 dashboard`openclaw dashboard`\n稍后聊天`openclaw`",
nextStepsTitle: "下一步",
openChatNow: "现在打开聊天?",
openCrestodian: "打开 Crestodian 聊天(用自然语言获得帮助)",
recommendedSuffix: " — 推荐",
repliedIn: "AI 检查:在 {seconds} 秒内回复",
retryCandidate: "重试 {label}{detail}",
skipAi: "暂时跳过 AI 设置",
skipAiLater:
"稍后添加 AI设置 OPENAI_API_KEY 或 ANTHROPIC_API_KEY或者安装并登录 codex、claude 或 gemini。然后重新运行 `openclaw onboard`。",
testFailed: "AI 检查失败。",
testFailure: "✗ {label}{reason}\n{detail}",
testPassed: "AI 检查通过。",
testingCandidate: "正在测试 {label}{modelRef})— 真实 completion并非 ping…",
testingManualProvider: "正在测试 {label} — 真实 completion并非 ping…",
tryCandidate: "尝试 {label}{detail}",
ttyRequired:
"Onboarding 需要交互式 TTY。自动化请使用 `openclaw onboard --non-interactive --accept-risk ...`。",
useClassic: "使用经典分步向导",
welcomeTitle: "设置选项",
workspace: "工作区目录",
},
setup: { setup: {
authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。", authChoiceFailedRetry: "请选择其他提供商或认证方式,或选择暂时跳过。",
authChoiceFailedTitle: "提供商设置失败", authChoiceFailedTitle: "提供商设置失败",
@ -282,6 +331,14 @@ export const zh_CN = {
skipChannels: "跳过频道设置。", skipChannels: "跳过频道设置。",
skipSearch: "跳过搜索设置。", skipSearch: "跳过搜索设置。",
skipSkills: "跳过技能设置。", skipSkills: "跳过技能设置。",
testAiAccess: "现在用一次真实 completion 测试 AI 访问?",
testAiContinue: "仍然继续",
testAiFailure: "AI 访问测试失败:{reason}",
testAiFailureChoice: "你想如何继续?",
testAiFix: "修复模型/认证设置",
testAiProgress: "正在用真实 completion 测试 AI 访问…",
testAiSuccess: "AI 访问正常,在 {seconds} 秒内回复。",
testAiTitle: "AI 访问测试",
whatSetup: "你想设置什么?", whatSetup: "你想设置什么?",
workspaceDirectory: "工作区目录", workspaceDirectory: "工作区目录",
}, },

View file

@ -230,6 +230,55 @@ export const zh_TW = {
validWebSocketUrl: "URL 必須以 ws:// 或 wss:// 開頭", validWebSocketUrl: "URL 必須以 ws:// 或 wss:// 開頭",
websocketUrl: "Gateway WebSocket URL", websocketUrl: "Gateway WebSocket URL",
}, },
guided: {
aiAccessTitle: "AI 存取",
apiKeyPrompt: "{label} 的 API key 或 token",
appliedTitle: "設定已套用",
complete: "OpenClaw 已準備就緒。",
detected: "AI 偵測完成。",
detectedCandidate: "{label} — {detail}{recommended}",
detectedTitle: "找到的 AI",
detecting: "正在尋找你已使用的 AI…",
enterApiKey: "輸入 API key — {label}",
existingModelKept:
"已設定的預設模型保持不變。請在下方選擇如何繼續——重試、連接其他提供商,或離開。此檢查在工作區之外執行,因此工作區外掛提供的模型可能在這裡失敗,但在 agent 中仍可正常運作。",
escapeHatches:
"如需完整的分步精靈,請執行 `openclaw onboard --classic`。你也可以隨時執行 `openclaw crestodian`,用自然語言取得設定協助。",
failureAuth: "認證失敗。請重新登入或檢查 key。",
failureBilling: "此模型或帳號尚未啟用計費。",
failureFormat: "模型沒有傳回可用的回覆。",
failureRateLimit: "Provider 正在限流。請稍後重試。",
failureTimeout: "Completion 逾時。",
failureUnavailable: "此 AI 路由目前不可用。",
failureUnknown: "Completion 因未知原因失敗。",
foundNothing: "未在此機器上偵測到既有 AI 存取方式。",
intro: "連接你的 AI",
invalidConfigCrestodian:
"OpenClaw 設定無效。正在開啟 Crestodian以便在 onboarding 寫入任何內容前檢查並修復設定。",
manualChoice: "你想如何連接 AI",
nextSteps:
"工作區:{workspace}\n新增頻道`openclaw channels add`\n偏好聊天執行 `openclaw crestodian`,然後說 `connect telegram`(或 `connect slack`)。\n開啟 dashboard`openclaw dashboard`\n稍後聊天`openclaw`",
nextStepsTitle: "下一步",
openChatNow: "現在開啟聊天?",
openCrestodian: "開啟 Crestodian 聊天(用自然語言取得協助)",
recommendedSuffix: " — 建議",
repliedIn: "AI 檢查:在 {seconds} 秒內回覆",
retryCandidate: "重試 {label}{detail}",
skipAi: "暫時略過 AI 設定",
skipAiLater:
"稍後新增 AI設定 OPENAI_API_KEY 或 ANTHROPIC_API_KEY或者安裝並登入 codex、claude 或 gemini。然後重新執行 `openclaw onboard`。",
testFailed: "AI 檢查失敗。",
testFailure: "✗ {label}{reason}\n{detail}",
testPassed: "AI 檢查通過。",
testingCandidate: "正在測試 {label}{modelRef})— 真實 completion並非 ping…",
testingManualProvider: "正在測試 {label} — 真實 completion並非 ping…",
tryCandidate: "嘗試 {label}{detail}",
ttyRequired:
"Onboarding 需要互動式 TTY。自動化請使用 `openclaw onboard --non-interactive --accept-risk ...`。",
useClassic: "使用經典分步精靈",
welcomeTitle: "設定選項",
workspace: "工作區目錄",
},
setup: { setup: {
authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。", authChoiceFailedRetry: "請選擇其他提供商或認證方式,或選擇暫時跳過。",
authChoiceFailedTitle: "提供商設定失敗", authChoiceFailedTitle: "提供商設定失敗",
@ -282,6 +331,14 @@ export const zh_TW = {
skipChannels: "略過頻道設定。", skipChannels: "略過頻道設定。",
skipSearch: "略過搜尋設定。", skipSearch: "略過搜尋設定。",
skipSkills: "略過技能設定。", skipSkills: "略過技能設定。",
testAiAccess: "現在用一次真實 completion 測試 AI 存取?",
testAiContinue: "仍然繼續",
testAiFailure: "AI 存取測試失敗:{reason}",
testAiFailureChoice: "你想如何繼續?",
testAiFix: "修復模型/認證設定",
testAiProgress: "正在用真實 completion 測試 AI 存取…",
testAiSuccess: "AI 存取正常,在 {seconds} 秒內回覆。",
testAiTitle: "AI 存取測試",
whatSetup: "你想設定什麼?", whatSetup: "你想設定什麼?",
workspaceDirectory: "工作區目錄", workspaceDirectory: "工作區目錄",
}, },

View file

@ -110,6 +110,11 @@ const enableDefaultOnboardingInternalHooks = vi.hoisted(() =>
const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => [])); const detectSetupMigrationSources = vi.hoisted(() => vi.fn(async () => []));
const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => [])); const listSetupMigrationOptions = vi.hoisted(() => vi.fn(async () => []));
const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {})); const runSetupMigrationImport = vi.hoisted(() => vi.fn(async () => {}));
const verifySetupInference = vi.hoisted(() =>
vi.fn<() => Promise<import("../crestodian/setup-inference.js").VerifySetupInferenceResult>>(
async () => ({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 250 }),
),
);
const setupChannels = vi.hoisted(() => const setupChannels = vi.hoisted(() =>
vi.fn( vi.fn(
@ -283,6 +288,10 @@ vi.mock("./setup.migration-import.js", () => ({
runSetupMigrationImport, runSetupMigrationImport,
})); }));
vi.mock("../crestodian/setup-inference.js", () => ({
verifySetupInference,
}));
vi.mock("../config/config.js", () => ({ vi.mock("../config/config.js", () => ({
DEFAULT_GATEWAY_PORT: 18789, DEFAULT_GATEWAY_PORT: 18789,
createConfigIO, createConfigIO,
@ -447,6 +456,12 @@ describe("runSetupWizard", () => {
warnIfModelConfigLooksOff.mockResolvedValue(undefined); warnIfModelConfigLooksOff.mockResolvedValue(undefined);
buildPluginCompatibilitySnapshotNotices.mockReset(); buildPluginCompatibilitySnapshotNotices.mockReset();
buildPluginCompatibilitySnapshotNotices.mockReturnValue([]); buildPluginCompatibilitySnapshotNotices.mockReturnValue([]);
verifySetupInference.mockReset();
verifySetupInference.mockResolvedValue({
ok: true,
modelRef: "openai/gpt-5.5",
latencyMs: 250,
});
}); });
it("exits successfully after the auto-launched TUI returns", async () => { it("exits successfully after the auto-launched TUI returns", async () => {
@ -1715,4 +1730,101 @@ describe("runSetupWizard", () => {
"default model prompt params", "default model prompt params",
); );
}); });
it("offers a live AI check after classic model setup", async () => {
applyAuthChoice.mockResolvedValueOnce({
config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } },
});
const confirm = vi.fn(async () => true) as unknown as WizardPrompter["confirm"];
const prompter = buildWizardPrompter({ confirm });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({ message: "Test AI access now with a live completion?" }),
);
expect(verifySetupInference).toHaveBeenCalledOnce();
});
it("continues classic setup when live AI verification fails", async () => {
applyAuthChoice.mockResolvedValueOnce({
config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } },
});
verifySetupInference.mockResolvedValueOnce({
ok: false,
status: "auth",
error: "login expired",
});
const select = vi.fn(async () => "continue") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select });
await expect(
runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
),
).resolves.toBeUndefined();
expect(select).toHaveBeenCalledWith(
expect.objectContaining({ message: "How would you like to continue?" }),
);
expect(verifySetupInference).toHaveBeenCalledOnce();
});
it("re-enters model/auth setup once and re-verifies after a failed AI check", async () => {
applyAuthChoice.mockResolvedValue({
config: { agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } },
});
promptAuthChoiceGrouped.mockResolvedValue("demo-provider");
verifySetupInference
.mockResolvedValueOnce({ ok: false, status: "auth", error: "login expired" })
.mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 300 });
const select = vi.fn(async () => "fix") as unknown as WizardPrompter["select"];
const prompter = buildWizardPrompter({ confirm: vi.fn(async () => true), select });
await runSetupWizard(
{
acceptRisk: true,
flow: "quickstart",
authChoice: "demo-provider",
installDaemon: false,
skipChannels: true,
skipSkills: true,
skipSearch: true,
skipHealth: true,
skipUi: true,
},
createRuntime(),
prompter,
);
expect(applyAuthChoice).toHaveBeenCalledTimes(2);
expect(promptAuthChoiceGrouped).toHaveBeenCalledOnce();
expect(verifySetupInference).toHaveBeenCalledTimes(2);
});
}); });

View file

@ -6,6 +6,7 @@ import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeSecretInputString } from "../config/types.secrets.js"; import { normalizeSecretInputString } from "../config/types.secrets.js";
import { formatErrorMessage } from "../infra/errors.js"; import { formatErrorMessage } from "../infra/errors.js";
import { withConsoleSubsystemsSuppressed } from "../logging/console.js";
import { import {
buildPluginCompatibilitySnapshotNotices, buildPluginCompatibilitySnapshotNotices,
formatPluginCompatibilityNotice, formatPluginCompatibilityNotice,
@ -44,6 +45,70 @@ function hasConfiguredDefaultModel(config: OpenClawConfig): boolean {
return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined; return resolveAgentModelPrimaryValue(config.agents?.defaults?.model) !== undefined;
} }
async function offerLiveModelVerification(params: {
config: OpenClawConfig;
opts: OnboardOptions;
prompter: WizardPrompter;
runtime: RuntimeEnv;
workspaceDir: string;
writeConfig: (config: OpenClawConfig) => Promise<OpenClawConfig>;
}): Promise<OpenClawConfig> {
const shouldTest = await params.prompter.confirm({
message: t("wizard.setup.testAiAccess"),
initialValue: true,
});
if (!shouldTest) {
return params.config;
}
const { verifySetupInference } = await import("../crestodian/setup-inference.js");
const verify = async () => {
const progress = params.prompter.progress(t("wizard.setup.testAiProgress"));
const result = await withConsoleSubsystemsSuppressed(() =>
verifySetupInference({ runtime: params.runtime }),
);
progress.stop();
if (result.ok) {
await params.prompter.note(
t("wizard.setup.testAiSuccess", { seconds: (result.latencyMs / 1000).toFixed(1) }),
t("wizard.setup.testAiTitle"),
);
} else {
await params.prompter.note(
t("wizard.setup.testAiFailure", { reason: result.error }),
t("wizard.setup.testAiTitle"),
);
}
return result;
};
const firstResult = await verify();
if (firstResult.ok) {
return params.config;
}
const action = await params.prompter.select({
message: t("wizard.setup.testAiFailureChoice"),
options: [
{ value: "fix", label: t("wizard.setup.testAiFix") },
{ value: "continue", label: t("wizard.setup.testAiContinue") },
],
});
if (action === "continue") {
return params.config;
}
const fixedConfig = await runSetupModelAuthStep({
config: params.config,
opts: { ...params.opts, authChoice: undefined },
prompter: params.prompter,
runtime: params.runtime,
workspaceDir: params.workspaceDir,
});
const persistedConfig = await params.writeConfig(fixedConfig);
await verify();
return persistedConfig;
}
function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean { function isSetupImportFlowChoice(flow: SetupFlowChoice): boolean {
return flow === "import" || flow.startsWith("import:"); return flow === "import" || flow.startsWith("import:");
} }
@ -217,7 +282,8 @@ async function runSetupWizardOnce(
); );
} }
if (opts.importFrom || isSetupImportFlowChoice(flow)) { const usedImportFlow = Boolean(opts.importFrom || isSetupImportFlowChoice(flow));
if (usedImportFlow) {
const importFrom = opts.importFrom ?? resolveImportProviderFromFlowChoice(flow); const importFrom = opts.importFrom ?? resolveImportProviderFromFlowChoice(flow);
prompter.disableBackNavigation?.(); prompter.disableBackNavigation?.();
await runSetupMigrationImport({ await runSetupMigrationImport({
@ -475,6 +541,23 @@ async function runSetupWizardOnce(
allowConfigSizeDrop: false, allowConfigSizeDrop: false,
}); });
if (
opts.nonInteractive !== true &&
opts.authChoice !== "skip" &&
!usedImportFlow &&
hasConfiguredDefaultModel(nextConfig)
) {
nextConfig = await offerLiveModelVerification({
config: nextConfig,
opts,
prompter,
runtime,
workspaceDir,
writeConfig: async (config) =>
await writeSetupConfigFile(config, { allowConfigSizeDrop: false }),
});
}
prompter.disableBackNavigation?.(); prompter.disableBackNavigation?.();
if (opts.skipChannels ?? opts.skipProviders) { if (opts.skipChannels ?? opts.skipProviders) {
await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle")); await prompter.note(t("wizard.setup.skipChannels"), t("wizard.setup.channelsTitle"));