feat: start onboarding for fresh CLI installs (#85519)

Summary:
- This PR routes bare `openclaw` to classic onboarding for missing, empty, or metadata-only configs; keeps aut ... cs/changelog/tests; and narrows a Docker E2E boundary-check exception for an existing source-checkout lane.
- Reproducibility: not applicable. this is a feature/default-routing PR rather than a bug report. The branch p ... ill includes a fresh-state terminal run reaching `OpenClaw setup` and tests for the relevant config states.

Automerge notes:
- PR branch already contained follow-up commit before automerge: feat: start onboarding for fresh CLI installs

Validation:
- ClawSweeper review passed for head f4b2572f2e.
- Required merge gates passed before the squash merge.

Prepared head SHA: f4b2572f2e
Review: https://github.com/openclaw/openclaw/pull/85519#issuecomment-4522938004

Co-authored-by: FullerStackDev <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-22 22:00:21 +00:00
committed by GitHub
parent 64d13c017a
commit 464ffc1003
9 changed files with 244 additions and 20 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Crabbox: keep the local wrapper's provider validation synced with the installed Crabbox binary while preserving supported aliases such as `docker` and `blacksmith`. (#85302) Thanks @hxy91819.
- Maintainer skills: add `openclaw-landable-bug-sweep` for producing five small, reviewed, CI-green OpenClaw bugfix PRs from issue/PR sweeps.
- Control UI/chat: add search and Load More pagination to the chat session picker, keeping initial session loads bounded while making older conversations reachable. (#85237) Thanks @amknight.
- CLI/onboarding: start classic onboarding when bare `openclaw` runs before an authored config exists, while keeping configured installs on Crestodian. (#72343) Thanks @fuller-stack-dev.
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
- xAI/Grok: reuse xAI OAuth auth profiles for Grok `web_search`, thread active-agent auth through web search, add Grok model aliases, and let media providers declare default operation timeouts. (#85182) Thanks @fuller-stack-dev.
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.

View File

@@ -1,7 +1,7 @@
---
summary: "CLI reference and security model for Crestodian, the configless-safe setup and repair helper"
read_when:
- You run openclaw with no command and want to understand Crestodian
- You run openclaw with no command after setup and want to understand Crestodian
- You need a configless-safe way to inspect or repair OpenClaw
- You are designing or enabling message-channel rescue mode
title: "Crestodian"
@@ -12,8 +12,11 @@ title: "Crestodian"
Crestodian is OpenClaw's local setup, repair, and configuration helper. It is
designed to stay reachable when the normal agent path is broken.
Running `openclaw` with no command starts Crestodian in an interactive terminal.
Running `openclaw crestodian` starts the same helper explicitly.
Running `openclaw` with no command starts classic onboarding first when the
active config file is missing or has no authored settings (empty or
metadata-only). After a config file has authored settings, running `openclaw`
with no command starts Crestodian in an interactive terminal. Running
`openclaw crestodian` starts the same helper explicitly.
## What Crestodian shows
@@ -92,8 +95,9 @@ Crestodian's startup path is deliberately small. It can run when:
- no agent has been configured yet
`openclaw --help` and `openclaw --version` still use the normal fast paths.
Noninteractive `openclaw` exits with a short message instead of printing root
help, because the no-command product is Crestodian.
Noninteractive bare `openclaw` exits with a short message instead of printing
root help. On a fresh install, the message points to non-interactive onboarding;
after setup, it points to one-shot Crestodian commands.
## Operations and approval
@@ -308,16 +312,17 @@ persistent approval roundtrip through the rescue handler:
pnpm test:live:crestodian-rescue-channel
```
Fresh configless setup through Crestodian is covered by:
Configless setup through explicit Crestodian commands is covered by:
```bash
pnpm test:docker:crestodian-first-run
```
That lane starts with an empty state dir, routes bare `openclaw` to Crestodian,
sets the default model, creates an additional agent, configures Discord through
a plugin enablement plus token SecretRef, validates config, and checks the audit
log. QA Lab also has a repo-backed scenario for the same Ring 0 flow:
That lane starts with an empty state dir, verifies the modern onboard Crestodian
entrypoint, sets the default model, creates an additional agent, configures
Discord through a plugin enablement plus token SecretRef, validates config, and
checks the audit log. QA Lab also has a repo-backed scenario for the same Ring 0
flow:
```bash
pnpm openclaw qa suite --scenario crestodian-ring-zero-setup

View File

@@ -47,6 +47,11 @@ openclaw onboard --mode remote --remote-url wss://gateway-host:18789
`--modern` starts the Crestodian conversational onboarding preview. Without
`--modern`, `openclaw onboard` keeps the classic onboarding flow.
On a fresh install where the active config file is missing or has no authored
settings (empty or metadata-only), bare `openclaw` also starts the classic
onboarding flow. Once a config file has authored settings, bare `openclaw`
opens Crestodian instead.
Plaintext `ws://` is accepted for loopback, private IP literals, `.local`, and
Tailnet `*.ts.net` gateway URLs. For other trusted private-DNS names, set
`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment.

View File

@@ -98,10 +98,10 @@ When debugging real providers/models (requires real creds):
and verifies the fuzzy planner fallback translates into an audited typed
config write.
- Crestodian first-run Docker smoke: `pnpm test:docker:crestodian-first-run`
- Starts from an empty OpenClaw state dir, routes bare `openclaw` to
Crestodian, applies setup/model/agent/Discord plugin + SecretRef writes,
validates config, and verifies audit entries. The same Ring 0 setup path is
also covered in QA Lab by
- Starts from an empty OpenClaw state dir, verifies the modern onboard
Crestodian entrypoint, applies setup/model/agent/Discord plugin + SecretRef
writes, validates config, and verifies audit entries. The same Ring 0 setup
path is also covered in QA Lab by
`pnpm openclaw qa suite --scenario crestodian-ring-zero-setup`.
- Moonshot/Kimi cost smoke: with `MOONSHOT_API_KEY` set, run
`openclaw models list --provider moonshot --json`, then run an isolated

View File

@@ -47,7 +47,7 @@ Notes:
- `openclaw chat` and `openclaw terminal` are aliases for `openclaw tui --local`.
- `--local` cannot be combined with `--url`, `--token`, or `--password`.
- Local mode uses the embedded agent runtime directly. Most local tools work, but Gateway-only features are unavailable.
- `openclaw` and `openclaw crestodian` also use this TUI shell, with Crestodian as the local setup and repair chat backend.
- After a config file has authored settings, `openclaw` and `openclaw crestodian` also use this TUI shell, with Crestodian as the local setup and repair chat backend.
## What you see

View File

@@ -20,6 +20,9 @@ const livePackageBackedLanes = new Set([
"openai-chat-tools",
"openwebui",
]);
// These lanes intentionally build a focused source-checkout image instead of
// consuming the shared package E2E images.
const sourceCheckoutImageLanes = new Set(["plugin-binding-command-escape"]);
function readText(relativePath) {
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");
@@ -67,6 +70,7 @@ function validateUniqueLanes(label, lanes) {
function validateLane(label, lane) {
const resources = laneResources(lane);
const sourceCheckoutImageLane = sourceCheckoutImageLanes.has(lane.name);
if (!lane.name || typeof lane.name !== "string") {
errors.push(`${label}: Docker E2E lane is missing a string name`);
}
@@ -82,9 +86,14 @@ function validateLane(label, lane) {
if (lane.live && lane.e2eImageKind && !livePackageBackedLanes.has(lane.name)) {
errors.push(`${label}: live Docker E2E lane '${lane.name}' must not require a package image`);
}
if (!lane.live && !lane.e2eImageKind) {
if (!lane.live && !lane.e2eImageKind && !sourceCheckoutImageLane) {
errors.push(`${label}: package Docker E2E lane '${lane.name}' must declare an e2e image kind`);
}
if (sourceCheckoutImageLane && !/\bOPENCLAW_SKIP_DOCKER_BUILD=0\b/u.test(lane.command)) {
errors.push(
`${label}: source-checkout Docker E2E lane '${lane.name}' must force a local image build`,
);
}
if (laneWeight(lane) < 1) {
errors.push(`${label}: Docker E2E lane '${lane.name}' must have positive weight`);
}

View File

@@ -4,7 +4,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { runCli, shouldStartCrestodianForBareRoot } from "../../dist/cli/run-main.js";
import {
runCli,
shouldStartCrestodianForModernOnboard,
shouldStartOnboardingForFreshInstall,
} from "../../dist/cli/run-main.js";
import { clearConfigCache } from "../../dist/config/config.js";
import type { OpenClawConfig } from "../../dist/config/types.openclaw.js";
import { runCrestodian } from "../../dist/crestodian/crestodian.js";
@@ -74,8 +78,12 @@ async function main() {
clearConfigCache();
assert(
shouldStartCrestodianForBareRoot(["node", "openclaw"]),
"bare openclaw invocation did not route to Crestodian",
await shouldStartOnboardingForFreshInstall(["node", "openclaw"]),
"fresh bare OpenClaw invocation did not route to onboarding",
);
assert(
shouldStartCrestodianForModernOnboard(["node", "openclaw", "onboard", "--modern"]),
"modern onboard invocation did not route to Crestodian",
);
process.exitCode = undefined;
await runCli(["node", "openclaw", "onboard", "--modern", "--non-interactive", "--json"]);

View File

@@ -5,6 +5,12 @@ import { loggingState } from "../logging/state.js";
import type { RootHelpRenderOptions } from "./program/root-help.js";
import { runCli, shouldStartProxyForCli } from "./run-main.js";
type ConfigSnapshotStub = {
exists: boolean;
valid: boolean;
sourceConfig: Record<string, unknown>;
};
const tryRouteCliMock = vi.hoisted(() => vi.fn());
const loadDotEnvMock = vi.hoisted(() => vi.fn());
const normalizeEnvMock = vi.hoisted(() => vi.fn());
@@ -38,6 +44,14 @@ const resolveManifestCliCommandSurfaceOwnerMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false));
const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn());
const readConfigFileSnapshotMock = vi.hoisted(() =>
vi.fn<() => Promise<ConfigSnapshotStub>>(async () => ({
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
})),
);
const setupWizardCommandMock = vi.hoisted(() => vi.fn(async () => {}));
const runCrestodianMock = vi.hoisted(() =>
vi.fn<(options?: unknown) => Promise<void>>(async () => {}),
);
@@ -230,6 +244,14 @@ vi.mock("../infra/net/undici-global-dispatcher.js", () => ({
ensureGlobalUndiciEnvProxyDispatcher: ensureGlobalUndiciEnvProxyDispatcherMock,
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: readConfigFileSnapshotMock,
}));
vi.mock("../commands/onboard.js", () => ({
setupWizardCommand: setupWizardCommandMock,
}));
vi.mock("../crestodian/crestodian.js", () => ({
runCrestodian: runCrestodianMock,
}));
@@ -255,9 +277,35 @@ function makeProxyHandle() {
};
}
async function withInteractiveTty(fn: () => Promise<void>): Promise<void> {
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
await fn();
} finally {
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
}
describe("runCli exit behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
readConfigFileSnapshotMock.mockResolvedValue({
exists: true,
valid: true,
sourceConfig: { gateway: { mode: "local" } },
});
hasMemoryRuntimeMock.mockReturnValue(false);
listAgentHarnessIdsMock.mockReturnValue([]);
outputPrecomputedBrowserHelpTextMock.mockReturnValue(false);
@@ -899,6 +947,117 @@ describe("runCli exit behavior", () => {
}
});
it("starts onboarding for bare root invocations before config exists", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: false,
valid: true,
sourceConfig: {},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("starts onboarding for bare root invocations when config is empty", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("starts onboarding for bare root invocations when config only has metadata", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
$schema: "https://openclaw.ai/config.json",
meta: { updatedBy: "fixture" },
},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
});
it("points noninteractive fresh bare root invocations to onboarding automation", async () => {
const previousExitCode = process.exitCode;
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: false,
valid: true,
sourceConfig: {},
});
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
try {
await runCli(["node", "openclaw"]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runCrestodianMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
});
it("keeps bare root invocations on Crestodian when config already exists", async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runCrestodianMock).toHaveBeenCalledOnce();
const crestodianOptions = requireRunCrestodianOptions();
expect(crestodianOptions).toEqual({ onReady: crestodianOptions.onReady });
expect(crestodianOptions.onReady).toBeTypeOf("function");
});
it("bootstraps env proxy before bare Crestodian startup", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
import { isMainModule } from "../infra/is-main.js";
import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js";
@@ -229,6 +229,31 @@ async function disposeCliAgentHarnesses(): Promise<void> {
}
}
const UNCONFIGURED_CONFIG_IGNORED_KEYS = new Set(["$schema", "meta"]);
function isUnconfiguredConfigSnapshot(
snapshot: Pick<ConfigFileSnapshot, "exists" | "valid" | "sourceConfig">,
): boolean {
if (!snapshot.exists) {
return true;
}
if (!snapshot.valid) {
return false;
}
return Object.keys(snapshot.sourceConfig).every((key) =>
UNCONFIGURED_CONFIG_IGNORED_KEYS.has(key),
);
}
export async function shouldStartOnboardingForFreshInstall(argv: string[]): Promise<boolean> {
if (!shouldStartCrestodianForBareRoot(argv)) {
return false;
}
const { readConfigFileSnapshot } = await import("../config/config.js");
const snapshot = await readConfigFileSnapshot();
return isUnconfiguredConfigSnapshot(snapshot);
}
function pauseNonTtyStdinForCliExit(): void {
const stdin = process.stdin;
if (stdin.isTTY) {
@@ -598,6 +623,18 @@ export async function runCli(argv: string[] = process.argv) {
}
if (shouldRunBareRootCrestodian) {
if (await shouldStartOnboardingForFreshInstall(normalizedArgv)) {
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
);
process.exitCode = 1;
return;
}
const { setupWizardCommand } = await import("../commands/onboard.js");
await setupWizardCommand({});
return;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.error(
'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.',