diff --git a/CHANGELOG.md b/CHANGELOG.md index e5bdf045cd1..2a69920a43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ Docs: https://docs.openclaw.ai ### Changes +- CLI/Crestodian: add a configless setup and repair helper for bare `openclaw`, typed config operations, agent handoff, audit logging, docs/source discovery, and guarded message-channel rescue. - Gateway/nodes: add disabled-by-default `gateway.nodes.pairing.autoApproveCidrs` for first-time node pairing from explicit trusted CIDRs, while keeping operator/browser pairing and all upgrade flows manual. Fixes #60800. Thanks @sahilsatralkar. - Browser: add viewport coordinate clicks for managed and existing-session automation, plus `openclaw browser click-coords` for CLI use. (#54452) Thanks @dluttz. - Browser: add `browser.actionTimeoutMs` and use a 60s default action budget so healthy long browser waits do not fail at the client transport boundary. (#62589) Thanks @andyylin. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2175ddeed37..7b6f5e3262b 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -f1fd4557473391980caf6d6b32f78e4de25f8504b29dfe083f7f9e325d0b204c config-baseline.json -68e0784ca0f9279d49b40ce4493e1cb2c416e1fb70a137a853a10a8c078c97ca config-baseline.core.json +83677b2666da2169511e5372f26c20c794001ec8acc7e9c2e1935043010c05d6 config-baseline.json +fa38a1bde88d8858ae0a11e7e17fa42fe107c34268b568f51877afbde81922e8 config-baseline.core.json d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json 0504c4f38d4c753fffeb465c93540d829df6b0fcef921eb0e2226ac16bdbbe07 config-baseline.plugin.json diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md new file mode 100644 index 00000000000..d0d79f0be0c --- /dev/null +++ b/docs/cli/crestodian.md @@ -0,0 +1,290 @@ +--- +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 need a configless-safe way to inspect or repair OpenClaw + - You are designing or enabling message-channel rescue mode +title: "Crestodian" +--- + +# `openclaw 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. + +## What Crestodian shows + +On startup, Crestodian prints a compact system overview: + +- config path and validity +- configured agents and the default agent +- default model +- local Codex and Claude Code CLI availability +- OpenAI and Anthropic API-key presence +- planner mode (`deterministic` or model-assisted through the configured model) +- local docs path or the public docs URL +- local source path for Git checkouts, otherwise the OpenClaw GitHub source URL +- gateway reachability +- the immediate recommended next step + +It does not dump secrets or load plugin CLI commands just to start. + +Crestodian uses the same OpenClaw reference discovery as regular agents. In a Git checkout, +it points itself at local `docs/` and the local source tree. In an npm package install, it +uses the bundled package docs and links to +[https://github.com/openclaw/openclaw](https://github.com/openclaw/openclaw), with explicit +guidance to review source whenever the docs are not enough. + +## Examples + +```bash +openclaw +openclaw crestodian +openclaw crestodian --json +openclaw crestodian --message "models" +openclaw crestodian --message "validate config" +openclaw crestodian --message "setup workspace ~/Projects/work model openai/gpt-5.5" --yes +openclaw crestodian --message "set default model openai/gpt-5.5" --yes +openclaw onboard --modern +``` + +Inside the interactive prompt: + +```text +status +health +doctor +doctor fix +validate config +setup +setup workspace ~/Projects/work model openai/gpt-5.5 +config set gateway.port 19001 +config set-ref gateway.auth.token env OPENCLAW_GATEWAY_TOKEN +gateway status +restart gateway +agents +create agent work workspace ~/Projects/work +models +set default model openai/gpt-5.5 +talk to work agent +talk to agent for ~/Projects/work +audit +quit +``` + +## Safe startup + +Crestodian's startup path is deliberately small. It can run when: + +- `openclaw.json` is missing +- `openclaw.json` is invalid +- the Gateway is down +- plugin command registration is unavailable +- 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. + +## Operations and approval + +Crestodian uses typed operations instead of editing config ad hoc. + +Read-only operations can run immediately: + +- show overview +- list agents +- show model/backend status +- run status or health checks +- check Gateway reachability +- run doctor without interactive fixes +- validate config +- show the audit-log path + +Persistent operations require conversational approval in interactive mode unless +you pass `--yes` for a one-shot command: + +- write config +- run `config set` +- set supported SecretRef values through `config set-ref` +- run setup/onboarding bootstrap +- change the default model +- start, stop, or restart the Gateway +- create agents +- run doctor repairs that rewrite config or state + +Applied writes are recorded in: + +```text +~/.openclaw/audit/crestodian.jsonl +``` + +Discovery is not audited. Only applied operations and writes are logged. + +`openclaw onboard --modern` starts Crestodian as the modern onboarding preview. +Plain `openclaw onboard` still runs classic onboarding. + +## Setup Bootstrap + +`setup` is the chat-first onboarding bootstrap. It writes only through typed +config operations and asks for approval first. + +```text +setup +setup workspace ~/Projects/work +setup workspace ~/Projects/work model openai/gpt-5.5 +``` + +When no model is configured, setup selects the first usable backend in this +order and tells you what it chose: + +- existing explicit model, if already configured +- `OPENAI_API_KEY` -> `openai/gpt-5.5` +- `ANTHROPIC_API_KEY` -> `anthropic/claude-opus-4-7` +- Claude Code CLI -> `claude-cli/claude-opus-4-7` +- Codex CLI -> `codex-cli/gpt-5.5` + +If none are available, setup still writes the default workspace and leaves the +model unset. Install or log into Codex/Claude Code, or expose +`OPENAI_API_KEY`/`ANTHROPIC_API_KEY`, then run setup again. + +## Model-Assisted Planner + +Crestodian always starts in deterministic mode. Once a valid OpenClaw model is +configured, local Crestodian can make one bounded model call for fuzzy commands +that the deterministic parser does not understand. + +The model-assisted planner cannot mutate config directly. It must translate the +request into one of Crestodian's typed commands, then the normal approval and +audit rules apply. Crestodian prints the model it used and the interpreted +command before it runs anything. + +Message-channel rescue mode does not use the model-assisted planner. Remote +rescue stays deterministic so a broken or compromised normal agent path cannot +be used as a config editor. + +## Switching to an agent + +Use a natural-language selector to leave Crestodian and open the normal TUI: + +```text +talk to agent +talk to work agent +switch to main agent +``` + +`openclaw tui`, `openclaw chat`, and `openclaw terminal` still open the normal +agent TUI directly. They do not start Crestodian. + +After switching into the normal TUI, use `/crestodian` to return to Crestodian. +You can include a follow-up request: + +```text +/crestodian +/crestodian restart gateway +``` + +Agent switches inside the TUI leave a breadcrumb that `/crestodian` is available. + +## Message rescue mode + +Message rescue mode is the message-channel entrypoint for Crestodian. It is for +the case where your normal agent is dead, but a trusted channel such as WhatsApp +still receives commands. + +Supported text command: + +- `/crestodian ` + +Operator flow: + +```text +You, in a trusted owner DM: /crestodian status +OpenClaw: Crestodian rescue mode. Gateway reachable: no. Config valid: no. +You: /crestodian restart gateway +OpenClaw: Plan: restart the Gateway. Reply /crestodian yes to apply. +You: /crestodian yes +OpenClaw: Applied. Audit entry written. +``` + +Agent creation can also be queued from the local prompt or rescue mode: + +```text +create agent work workspace ~/Projects/work model openai/gpt-5.5 +/crestodian create agent work workspace ~/Projects/work +``` + +Remote rescue mode is an admin surface. It must be treated like remote config +repair, not like normal chat. + +Security contract for remote rescue: + +- Disabled when sandboxing is active. If an agent/session is sandboxed, + Crestodian must refuse remote rescue and explain that local CLI repair is + required. +- Default effective state is `auto`: allow remote rescue only in trusted YOLO + operation, where the runtime already has unsandboxed local authority. +- Require an explicit owner identity. Rescue must not accept wildcard sender + rules, open group policy, unauthenticated webhooks, or anonymous channels. +- Owner DMs only by default. Group/channel rescue requires explicit opt-in and + should still route approval prompts to the owner DM. +- Remote rescue cannot open the local TUI or switch into an interactive agent + session. Use local `openclaw` for agent handoff. +- Persistent writes still require approval, even in rescue mode. +- Audit every applied rescue operation, including channel, account, sender, + session key, operation, config hash before, and config hash after. +- Never echo secrets. SecretRef inspection should report availability, not + values. +- If the Gateway is alive, prefer Gateway typed operations. If the Gateway is + dead, use only the minimal local repair surface that does not depend on the + normal agent loop. + +Config shape: + +```jsonc +{ + "crestodian": { + "rescue": { + "enabled": "auto", + "ownerDmOnly": true, + }, + }, +} +``` + +`enabled` should accept: + +- `"auto"`: default. Allow only when the effective runtime is YOLO and + sandboxing is off. +- `false`: never allow message-channel rescue. +- `true`: explicitly allow rescue when the owner/channel checks pass. This + still must not bypass the sandboxing denial. + +The default `"auto"` YOLO posture is: + +- sandbox mode resolves to `off` +- `tools.exec.security` resolves to `full` +- `tools.exec.ask` resolves to `off` + +Remote rescue is covered by the Docker lane: + +```bash +pnpm test:docker:crestodian-rescue +``` + +Fresh configless setup through Crestodian is covered by: + +```bash +pnpm test:docker:crestodian-first-run +``` + +## Related + +- [CLI reference](/cli) +- [Doctor](/cli/doctor) +- [TUI](/cli/tui) +- [Sandbox](/cli/sandbox) +- [Security](/cli/security) diff --git a/docs/cli/index.md b/docs/cli/index.md index c1a0b31ece6..ca034ccbad9 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,22 +13,22 @@ apply across the CLI. ## Command pages -| Area | Commands | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Setup and onboarding | [`setup`](/cli/setup) · [`onboard`](/cli/onboard) · [`configure`](/cli/configure) · [`config`](/cli/config) · [`completion`](/cli/completion) · [`doctor`](/cli/doctor) · [`dashboard`](/cli/dashboard) | -| Reset and uninstall | [`backup`](/cli/backup) · [`reset`](/cli/reset) · [`uninstall`](/cli/uninstall) · [`update`](/cli/update) | -| Messaging and agents | [`message`](/cli/message) · [`agent`](/cli/agent) · [`agents`](/cli/agents) · [`acp`](/cli/acp) · [`mcp`](/cli/mcp) | -| Health and sessions | [`status`](/cli/status) · [`health`](/cli/health) · [`sessions`](/cli/sessions) | -| Gateway and logs | [`gateway`](/cli/gateway) · [`logs`](/cli/logs) · [`system`](/cli/system) | -| Models and inference | [`models`](/cli/models) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`wiki`](/cli/wiki) | -| Network and nodes | [`directory`](/cli/directory) · [`nodes`](/cli/nodes) · [`devices`](/cli/devices) · [`node`](/cli/node) | -| Runtime and sandbox | [`approvals`](/cli/approvals) · `exec-policy` (see [`approvals`](/cli/approvals)) · [`sandbox`](/cli/sandbox) · [`tui`](/cli/tui) · `chat`/`terminal` (aliases for [`tui --local`](/cli/tui)) · [`browser`](/cli/browser) | -| Automation | [`cron`](/cli/cron) · [`tasks`](/cli/tasks) · [`hooks`](/cli/hooks) · [`webhooks`](/cli/webhooks) | -| Discovery and docs | [`dns`](/cli/dns) · [`docs`](/cli/docs) | -| Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) | -| Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) | -| Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) | -| Plugins (optional) | [`voicecall`](/cli/voicecall) (if installed) | +| Area | Commands | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Setup and onboarding | [`crestodian`](/cli/crestodian) · [`setup`](/cli/setup) · [`onboard`](/cli/onboard) · [`configure`](/cli/configure) · [`config`](/cli/config) · [`completion`](/cli/completion) · [`doctor`](/cli/doctor) · [`dashboard`](/cli/dashboard) | +| Reset and uninstall | [`backup`](/cli/backup) · [`reset`](/cli/reset) · [`uninstall`](/cli/uninstall) · [`update`](/cli/update) | +| Messaging and agents | [`message`](/cli/message) · [`agent`](/cli/agent) · [`agents`](/cli/agents) · [`acp`](/cli/acp) · [`mcp`](/cli/mcp) | +| Health and sessions | [`status`](/cli/status) · [`health`](/cli/health) · [`sessions`](/cli/sessions) | +| Gateway and logs | [`gateway`](/cli/gateway) · [`logs`](/cli/logs) · [`system`](/cli/system) | +| Models and inference | [`models`](/cli/models) · [`infer`](/cli/infer) · `capability` (alias for [`infer`](/cli/infer)) · [`memory`](/cli/memory) · [`wiki`](/cli/wiki) | +| Network and nodes | [`directory`](/cli/directory) · [`nodes`](/cli/nodes) · [`devices`](/cli/devices) · [`node`](/cli/node) | +| Runtime and sandbox | [`approvals`](/cli/approvals) · `exec-policy` (see [`approvals`](/cli/approvals)) · [`sandbox`](/cli/sandbox) · [`tui`](/cli/tui) · `chat`/`terminal` (aliases for [`tui --local`](/cli/tui)) · [`browser`](/cli/browser) | +| Automation | [`cron`](/cli/cron) · [`tasks`](/cli/tasks) · [`hooks`](/cli/hooks) · [`webhooks`](/cli/webhooks) | +| Discovery and docs | [`dns`](/cli/dns) · [`docs`](/cli/docs) | +| Pairing and channels | [`pairing`](/cli/pairing) · [`qr`](/cli/qr) · [`channels`](/cli/channels) | +| Security and plugins | [`security`](/cli/security) · [`secrets`](/cli/secrets) · [`skills`](/cli/skills) · [`plugins`](/cli/plugins) · [`proxy`](/cli/proxy) | +| Legacy aliases | [`daemon`](/cli/daemon) (gateway service) · [`clawbot`](/cli/clawbot) (namespace) | +| Plugins (optional) | [`voicecall`](/cli/voicecall) (if installed) | ## Global flags @@ -57,6 +57,7 @@ Palette source of truth: `src/terminal/palette.ts`. ``` openclaw [--dev] [--profile ] + crestodian setup onboard configure diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 19bf771f4c5..f8045e31477 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -21,12 +21,16 @@ Interactive onboarding for local or remote Gateway setup. ```bash openclaw onboard +openclaw onboard --modern openclaw onboard --flow quickstart openclaw onboard --flow manual openclaw onboard --skip-bootstrap 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. + For plaintext private-network `ws://` targets (trusted networks only), set `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment. There is no `openclaw.json` equivalent for this client-side transport diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index e6c261713da..40521da24d7 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -202,12 +202,18 @@ as `memory_get`, live tool results, and post-compaction AGENTS.md refreshes. ## Documentation -When available, the system prompt includes a **Documentation** section that points to the -local OpenClaw docs directory (either `docs/` in the repo workspace or the bundled npm -package docs) and also notes the public mirror, source repo, community Discord, and -ClawHub ([https://clawhub.ai](https://clawhub.ai)) for skills discovery. The prompt instructs the model to consult local docs first -for OpenClaw behavior, commands, configuration, or architecture, and to run -`openclaw status` itself when possible (asking the user only when it lacks access). +The system prompt includes a **Documentation** section. When local docs are available, it +points to the local OpenClaw docs directory (`docs/` in a Git checkout or the bundled npm +package docs). If local docs are unavailable, it falls back to +[https://docs.openclaw.ai](https://docs.openclaw.ai). + +The same section also includes the OpenClaw source location. Git checkouts expose the local +source root so the agent can inspect code directly. Package installs include the GitHub +source URL and tell the agent to review source there whenever the docs are incomplete or +stale. The prompt also notes the public docs mirror, community Discord, and ClawHub +([https://clawhub.ai](https://clawhub.ai)) for skills discovery. It tells the model to +consult docs first for OpenClaw behavior, commands, configuration, or architecture, and to +run `openclaw status` itself when possible (asking the user only when it lacks access). ## Related diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index e5710783cc6..9dd552f1517 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -107,6 +107,7 @@ Built-in commands available today: - `/commands` shows the generated command catalog. - `/tools [compact|verbose]` shows what the current agent can use right now. - `/status` shows execution/runtime status, including `Execution`/`Runtime` labels and provider usage/quota when available. +- `/crestodian ` runs the Crestodian setup and repair helper from an owner DM. - `/tasks` lists active/recent background tasks for the current session. - `/context [list|detail|json]` explains how context is assembled. - `/export-session [path]` exports the current session to HTML. Alias: `/export`. @@ -194,6 +195,9 @@ Notes: - If the agent is idle, the next run uses it right away. - If a run is already active, OpenClaw marks a live switch as pending and only restarts into the new model at a clean retry point. - If tool activity or reply output has already started, the pending switch can stay queued until a later retry opportunity or the next user turn. +- In the local TUI, `/crestodian [request]` returns from the normal agent TUI to + Crestodian. This is separate from message-channel rescue mode and does not + grant remote config authority. - **Fast path:** command-only messages from allowlisted senders are handled immediately (bypass queue + model). - **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements. - **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text. diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 4c177a1d387..565474bc529 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -926,7 +926,7 @@ describe("createTelegramBot", () => { inline_keyboard: [ [ { text: "◀ Prev", callback_data: "commands_page_1:main" }, - { text: "2/5", callback_data: "commands_page_noop:main" }, + { text: "2/6", callback_data: "commands_page_noop:main" }, { text: "Next ▶", callback_data: "commands_page_3:main" }, ], ], diff --git a/package.json b/package.json index bb990ce2917..fec9820121f 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,8 @@ "!dist/plugin-sdk/src/plugin-sdk/qa-lab.d.ts", "!dist/plugin-sdk/src/plugin-sdk/qa-runtime.d.ts", "!dist/qa-runtime-*.js", - "docs/reference/templates/", + "docs/", + "!docs/.generated/**", "skills/", "scripts/npm-runner.mjs", "scripts/preinstall-package-manager-warning.mjs", @@ -1477,6 +1478,8 @@ "test:docker:bundled-channel-deps:fast": "OPENCLAW_BUNDLED_CHANNEL_SCENARIOS=0 OPENCLAW_BUNDLED_CHANNEL_UPDATE_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_ROOT_OWNED_SCENARIO=0 OPENCLAW_BUNDLED_CHANNEL_SETUP_ENTRY_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_DISABLED_CONFIG_SCENARIO=1 OPENCLAW_BUNDLED_CHANNEL_LOAD_FAILURE_SCENARIO=1 bash scripts/e2e/bundled-channel-runtime-deps-docker.sh", "test:docker:cleanup": "bash scripts/test-cleanup-docker.sh", "test:docker:config-reload": "bash scripts/e2e/config-reload-source-docker.sh", + "test:docker:crestodian-first-run": "bash scripts/e2e/crestodian-first-run-docker.sh", + "test:docker:crestodian-rescue": "bash scripts/e2e/crestodian-rescue-docker.sh", "test:docker:cron-mcp-cleanup": "bash scripts/e2e/cron-mcp-cleanup-docker.sh", "test:docker:doctor-switch": "bash scripts/e2e/doctor-install-switch-docker.sh", "test:docker:e2e-build": "bash scripts/e2e/build-image.sh", diff --git a/scripts/e2e/crestodian-first-run-docker-client.ts b/scripts/e2e/crestodian-first-run-docker-client.ts new file mode 100644 index 00000000000..07bba98df3d --- /dev/null +++ b/scripts/e2e/crestodian-first-run-docker-client.ts @@ -0,0 +1,110 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { runCli, shouldStartCrestodianForBareRoot } from "../../src/cli/run-main.js"; +import { clearConfigCache } from "../../src/config/config.js"; +import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; +import { runCrestodian } from "../../src/crestodian/crestodian.js"; +import type { RuntimeEnv } from "../../src/runtime.js"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { + const lines: string[] = []; + return { + lines, + runtime: { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`exit ${code}`); + }, + }, + }; +} + +async function main() { + const stateDir = + process.env.OPENCLAW_STATE_DIR ?? + (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-crestodian-first-run-"))); + const configPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json"); + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.rm(stateDir, { recursive: true, force: true }); + await fs.mkdir(stateDir, { recursive: true }); + clearConfigCache(); + + assert( + shouldStartCrestodianForBareRoot(["node", "openclaw"]), + "bare openclaw invocation did not route to Crestodian", + ); + process.exitCode = undefined; + await runCli(["node", "openclaw", "onboard", "--modern", "--non-interactive", "--json"]); + assert( + process.exitCode === undefined || process.exitCode === 0, + "modern onboard overview exited nonzero", + ); + + const overviewRuntime = createRuntime(); + await runCrestodian({ message: "overview", interactive: false }, overviewRuntime.runtime); + const overviewOutput = overviewRuntime.lines.join("\n"); + assert( + overviewOutput.includes("Config: missing"), + "fresh overview did not report missing config", + ); + assert( + overviewOutput.includes('Next: say "setup" to create a starter config'), + "fresh overview did not include setup recommendation", + ); + + const setupRuntime = createRuntime(); + await runCrestodian( + { + message: "setup workspace /tmp/openclaw-first-run model openai/gpt-5.2", + yes: true, + interactive: false, + }, + setupRuntime.runtime, + ); + const setupOutput = setupRuntime.lines.join("\n"); + assert( + setupOutput.includes("[crestodian] done: crestodian.setup"), + "Crestodian setup did not apply", + ); + + clearConfigCache(); + const validateRuntime = createRuntime(); + await runCrestodian({ message: "validate config", interactive: false }, validateRuntime.runtime); + assert( + validateRuntime.lines.join("\n").includes("Config valid:"), + "post-setup config validation did not pass", + ); + + const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + assert( + config.agents?.defaults?.workspace === "/tmp/openclaw-first-run", + "first-run setup did not write default workspace", + ); + assert( + config.agents?.defaults?.model && + typeof config.agents.defaults.model === "object" && + "primary" in config.agents.defaults.model && + config.agents.defaults.model.primary === "openai/gpt-5.2", + "first-run setup did not write default model", + ); + + const auditPath = path.join(stateDir, "audit", "crestodian.jsonl"); + const audit = (await fs.readFile(auditPath, "utf8")).trim(); + assert(audit.includes('"operation":"crestodian.setup"'), "setup audit entry missing"); + + console.log("Crestodian first-run Docker E2E passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/crestodian-first-run-docker.sh b/scripts/e2e/crestodian-first-run-docker.sh new file mode 100644 index 00000000000..473f907b1d8 --- /dev/null +++ b/scripts/e2e/crestodian-first-run-docker.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-crestodian-first-run-e2e" OPENCLAW_CRESTODIAN_FIRST_RUN_E2E_IMAGE)" +CONTAINER_NAME="openclaw-crestodian-first-run-e2e-$$" +RUN_LOG="$(mktemp -t openclaw-crestodian-first-run-log.XXXXXX)" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -f "$RUN_LOG" +} +trap cleanup EXIT + +docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-first-run + +echo "Running in-container Crestodian first-run smoke..." +set +e +docker run --rm \ + --name "$CONTAINER_NAME" \ + -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ + -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail + node --import tsx scripts/e2e/crestodian-first-run-docker-client.ts + " >"$RUN_LOG" 2>&1 +status=${PIPESTATUS[0]} +set -e + +if [ "$status" -ne 0 ]; then + echo "Docker Crestodian first-run smoke failed" + cat "$RUN_LOG" + exit "$status" +fi + +cat "$RUN_LOG" +echo "OK" diff --git a/scripts/e2e/crestodian-rescue-docker-client.ts b/scripts/e2e/crestodian-rescue-docker-client.ts new file mode 100644 index 00000000000..98ca2c83b99 --- /dev/null +++ b/scripts/e2e/crestodian-rescue-docker-client.ts @@ -0,0 +1,267 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { handleCrestodianCommand } from "../../src/auto-reply/reply/commands-crestodian.js"; +import { clearConfigCache } from "../../src/config/config.js"; +import type { OpenClawConfig } from "../../src/config/types.openclaw.js"; +import { runCrestodianRescueMessage } from "../../src/crestodian/rescue-message.js"; + +type CommandResult = Awaited>; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function makeParams(commandBody: string, cfg: OpenClawConfig, isGroup = false) { + return { + cfg, + command: { + surface: "whatsapp", + channel: "whatsapp", + channelId: "whatsapp", + ownerList: ["user:owner"], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "user:owner", + rawBodyNormalized: commandBody, + commandBodyNormalized: commandBody, + from: "user:owner", + to: "account:default", + }, + agentId: "default", + isGroup, + } as Parameters[0]; +} + +async function invoke(commandBody: string, cfg: OpenClawConfig, isGroup = false): Promise { + const result: CommandResult = await handleCrestodianCommand( + makeParams(commandBody, cfg, isGroup), + true, + ); + assert(result, `Command was not handled: ${commandBody}`); + assert(!result.shouldContinue, `Command should stop normal agent dispatch: ${commandBody}`); + const text = result.reply?.text; + assert(typeof text === "string", `Command did not return text: ${commandBody}`); + return text; +} + +async function main() { + const stateDir = + process.env.OPENCLAW_STATE_DIR ?? + (await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-crestodian-"))); + const configPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json"); + process.env.OPENCLAW_STATE_DIR = stateDir; + process.env.OPENCLAW_CONFIG_PATH = configPath; + await fs.mkdir(stateDir, { recursive: true }); + await fs.writeFile( + configPath, + JSON.stringify( + { + meta: { lastTouchedVersion: "docker-e2e", lastTouchedAt: new Date(0).toISOString() }, + agents: { defaults: {} }, + }, + null, + 2, + ), + ); + clearConfigCache(); + + const denied = await invoke("/crestodian status", { + crestodian: { rescue: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }); + assert(denied.includes("sandboxing is active"), "sandboxed rescue was not denied"); + + const cfg: OpenClawConfig = {}; + const refusedTui = await invoke("/crestodian talk to agent", cfg); + assert( + refusedTui.includes("cannot open the local TUI"), + "remote rescue TUI handoff was not refused", + ); + + const plan = await invoke("/crestodian set default model openai/gpt-5.2", cfg); + assert( + plan.includes("Reply /crestodian yes to apply"), + "persistent change did not require approval", + ); + const applied = await invoke("/crestodian yes", cfg); + assert(applied.includes("Default model: openai/gpt-5.2"), "approved change did not apply"); + + const configValid = await invoke("/crestodian validate config", cfg); + assert(configValid.includes("Config valid:"), "config validation did not report valid config"); + + const configSetPlan = await invoke("/crestodian config set gateway.port 19001", cfg); + assert( + configSetPlan.includes("Reply /crestodian yes to apply"), + "generic config set did not require approval", + ); + const configSetApplied = await invoke("/crestodian yes", cfg); + assert(configSetApplied.includes("[crestodian] done: config.set"), "generic config set failed"); + + const refPlan = await invoke( + "/crestodian config set-ref gateway.auth.token env OPENCLAW_GATEWAY_TOKEN", + cfg, + ); + assert( + refPlan.includes("Reply /crestodian yes to apply"), + "SecretRef set did not require approval", + ); + const refApplied = await invoke("/crestodian yes", cfg); + assert(refApplied.includes("[crestodian] done: config.setRef"), "SecretRef set failed"); + + const agentPlan = await invoke("/crestodian create agent work workspace /tmp/openclaw-work", cfg); + assert( + agentPlan.includes("Reply /crestodian yes to apply"), + "agent creation did not require approval", + ); + const agentApplied = await invoke("/crestodian yes", cfg); + assert(agentApplied.includes("[crestodian] done: agents.create"), "agent creation did not apply"); + + const setupPlan = await invoke( + "/crestodian setup workspace /tmp/openclaw-setup model openai/gpt-5.2", + cfg, + ); + assert(setupPlan.includes("Reply /crestodian yes to apply"), "setup did not require approval"); + const setupApplied = await invoke("/crestodian yes", cfg); + assert(setupApplied.includes("[crestodian] done: crestodian.setup"), "setup did not apply"); + + const gatewayRestarts: string[] = []; + const gatewayCommand = makeParams("/crestodian restart gateway", cfg).command; + const gatewayPlan = await runCrestodianRescueMessage({ + cfg, + command: gatewayCommand, + commandBody: "/crestodian restart gateway", + agentId: "default", + isGroup: false, + deps: { + runGatewayRestart: async () => { + gatewayRestarts.push("restart"); + }, + }, + }); + assert( + gatewayPlan?.includes("Reply /crestodian yes to apply"), + "gateway restart did not require approval", + ); + const gatewayApplied = await runCrestodianRescueMessage({ + cfg, + command: gatewayCommand, + commandBody: "/crestodian yes", + agentId: "default", + isGroup: false, + deps: { + runGatewayRestart: async () => { + gatewayRestarts.push("restart"); + }, + }, + }); + assert( + gatewayApplied?.includes("[crestodian] done: gateway.restart"), + "gateway restart did not apply", + ); + assert(gatewayRestarts.length === 1, "gateway restart dependency was not invoked once"); + + const doctorRuns: string[] = []; + const doctorCommand = makeParams("/crestodian doctor fix", cfg).command; + const doctorPlan = await runCrestodianRescueMessage({ + cfg, + command: doctorCommand, + commandBody: "/crestodian doctor fix", + agentId: "default", + isGroup: false, + deps: { + runDoctor: async (_runtime, options) => { + doctorRuns.push(options.repair ? "repair" : "check"); + }, + }, + }); + assert( + doctorPlan?.includes("Reply /crestodian yes to apply"), + "doctor fix did not require approval", + ); + const doctorApplied = await runCrestodianRescueMessage({ + cfg, + command: doctorCommand, + commandBody: "/crestodian yes", + agentId: "default", + isGroup: false, + deps: { + runDoctor: async (_runtime, options) => { + doctorRuns.push(options.repair ? "repair" : "check"); + }, + }, + }); + assert(doctorApplied?.includes("[crestodian] done: doctor.fix"), "doctor fix did not apply"); + assert(doctorRuns.join(",") === "repair", "doctor repair dependency was not invoked once"); + + const updatedConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + assert( + updatedConfig.agents?.defaults?.model && + typeof updatedConfig.agents.defaults.model === "object" && + "primary" in updatedConfig.agents.defaults.model && + updatedConfig.agents.defaults.model.primary === "openai/gpt-5.2", + "config default model was not updated", + ); + assert(updatedConfig.gateway?.port === 19001, "generic config set did not update gateway.port"); + assert( + updatedConfig.gateway?.auth?.token && + typeof updatedConfig.gateway.auth.token === "object" && + "id" in updatedConfig.gateway.auth.token && + updatedConfig.gateway.auth.token.id === "OPENCLAW_GATEWAY_TOKEN", + "SecretRef set did not update gateway.auth.token", + ); + assert( + updatedConfig.agents?.defaults?.workspace === "/tmp/openclaw-setup", + "setup did not update default workspace", + ); + assert( + updatedConfig.agents?.list?.some( + (agent) => agent.id === "work" && agent.workspace === "/tmp/openclaw-work", + ), + "agent config was not updated", + ); + + const auditPath = path.join(stateDir, "audit", "crestodian.jsonl"); + const auditLines = (await fs.readFile(auditPath, "utf8")).trim().split("\n"); + assert(auditLines.length >= 2, "audit log did not record both operations"); + const audits = auditLines.map((line) => JSON.parse(line)); + assert( + audits.some((audit) => audit.operation === "config.setDefaultModel"), + "model audit operation missing", + ); + assert( + audits.some((audit) => audit.operation === "config.set"), + "config set audit missing", + ); + assert( + audits.some((audit) => audit.operation === "config.setRef"), + "SecretRef config audit missing", + ); + assert( + audits.some((audit) => audit.operation === "crestodian.setup"), + "setup audit missing", + ); + const agentAudit = audits.find((audit) => audit.operation === "agents.create"); + assert(agentAudit, "agent audit operation missing"); + assert(agentAudit.details?.rescue === true, "audit rescue marker missing"); + assert(agentAudit.details?.channel === "whatsapp", "audit channel missing"); + assert(agentAudit.details?.senderId === "user:owner", "audit sender missing"); + assert(agentAudit.details?.agentId === "work", "audit agent missing"); + assert( + audits.some((audit) => audit.operation === "gateway.restart"), + "gateway restart audit operation missing", + ); + assert( + audits.some((audit) => audit.operation === "doctor.fix"), + "doctor fix audit missing", + ); + + console.log("Crestodian rescue Docker E2E passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/e2e/crestodian-rescue-docker.sh b/scripts/e2e/crestodian-rescue-docker.sh new file mode 100755 index 00000000000..4ba9c96ac75 --- /dev/null +++ b/scripts/e2e/crestodian-rescue-docker.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh" +IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-crestodian-rescue-e2e" OPENCLAW_CRESTODIAN_RESCUE_E2E_IMAGE)" +CONTAINER_NAME="openclaw-crestodian-rescue-e2e-$$" +RUN_LOG="$(mktemp -t openclaw-crestodian-rescue-log.XXXXXX)" + +cleanup() { + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + rm -f "$RUN_LOG" +} +trap cleanup EXIT + +docker_e2e_build_or_reuse "$IMAGE_NAME" crestodian-rescue + +echo "Running in-container Crestodian rescue smoke..." +set +e +docker run --rm \ + --name "$CONTAINER_NAME" \ + -e "OPENCLAW_STATE_DIR=/tmp/openclaw-state" \ + -e "OPENCLAW_CONFIG_PATH=/tmp/openclaw-state/openclaw.json" \ + "$IMAGE_NAME" \ + bash -lc "set -euo pipefail + node --import tsx scripts/e2e/crestodian-rescue-docker-client.ts + " >"$RUN_LOG" 2>&1 +status=${PIPESTATUS[0]} +set -e + +if [ "$status" -ne 0 ]; then + echo "Docker Crestodian rescue smoke failed" + cat "$RUN_LOG" + exit "$status" +fi + +cat "$RUN_LOG" +echo "OK" diff --git a/scripts/test-docker-all.mjs b/scripts/test-docker-all.mjs index f7dd50c9e5b..c529d15acdd 100644 --- a/scripts/test-docker-all.mjs +++ b/scripts/test-docker-all.mjs @@ -171,6 +171,7 @@ const lanes = [ weight: 3, }), lane("pi-bundle-mcp-tools", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:pi-bundle-mcp-tools"), + lane("crestodian-rescue", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-rescue"), serviceLane( "cron-mcp-cleanup", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:cron-mcp-cleanup", @@ -184,6 +185,10 @@ const lanes = [ serviceLane("config-reload", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:config-reload"), ...bundledScenarioLanes, lane("openai-image-auth", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:openai-image-auth"), + lane( + "crestodian-first-run", + "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:crestodian-first-run", + ), lane("qr", "pnpm test:docker:qr"), ]; diff --git a/src/agents/cli-runner.test-support.ts b/src/agents/cli-runner.test-support.ts index 28c82698555..baf5db15bb5 100644 --- a/src/agents/cli-runner.test-support.ts +++ b/src/agents/cli-runner.test-support.ts @@ -56,7 +56,7 @@ setCliRunnerExecuteTestDeps({ setCliRunnerPrepareTestDeps({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, - resolveOpenClawDocsPath: async () => null, + resolveOpenClawReferencePaths: async () => ({ docsPath: null, sourcePath: null }), }); type MockRunExit = { @@ -118,7 +118,7 @@ export function restoreCliRunnerPrepareTestDeps() { setCliRunnerPrepareTestDeps({ makeBootstrapWarn: () => () => {}, resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, - resolveOpenClawDocsPath: async () => null, + resolveOpenClawReferencePaths: async () => ({ docsPath: null, sourcePath: null }), }); } diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index a9eb07c9738..e2c80bd082a 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -71,6 +71,7 @@ export function buildSystemPrompt(params: { ownerNumbers?: string[]; heartbeatPrompt?: string; docsPath?: string; + sourcePath?: string; tools: AgentTool[]; contextFiles?: EmbeddedContextFile[]; skillsPrompt?: string; @@ -109,6 +110,7 @@ export function buildSystemPrompt(params: { reasoningTagHint: false, heartbeatPrompt: params.heartbeatPrompt, docsPath: params.docsPath, + sourcePath: params.sourcePath, acpEnabled: params.config?.acp?.enabled !== false, runtimeInfo, toolNames: params.tools.map((tool) => tool.name), diff --git a/src/agents/cli-runner/prepare.test.ts b/src/agents/cli-runner/prepare.test.ts index 4d394ff8b9e..96d68a2b7f0 100644 --- a/src/agents/cli-runner/prepare.test.ts +++ b/src/agents/cli-runner/prepare.test.ts @@ -126,7 +126,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => { bootstrapFiles: [], contextFiles: [], })), - resolveOpenClawDocsPath: vi.fn(async () => null), + resolveOpenClawReferencePaths: vi.fn(async () => ({ docsPath: null, sourcePath: null })), }); mockGetGlobalHookRunner.mockReturnValue(null); mockBuildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(undefined); diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index fc655959d90..25f20ee6fbf 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -51,9 +51,9 @@ const prepareDeps = { getActiveMcpLoopbackRuntime, ensureMcpLoopbackServer, createMcpLoopbackServerConfig, - resolveOpenClawDocsPath: async ( - params: Parameters[0], - ) => (await import("../docs-path.js")).resolveOpenClawDocsPath(params), + resolveOpenClawReferencePaths: async ( + params: Parameters[0], + ) => (await import("../docs-path.js")).resolveOpenClawReferencePaths(params), }; export function setCliRunnerPrepareTestDeps(overrides: Partial): void { @@ -264,7 +264,7 @@ export async function prepareCliRunContext( agentId: sessionAgentId, defaultAgentId, }); - const docsPath = await prepareDeps.resolveOpenClawDocsPath({ + const openClawReferences = await prepareDeps.resolveOpenClawReferencePaths({ workspaceDir, argv1: process.argv[1], cwd: process.cwd(), @@ -288,7 +288,8 @@ export async function prepareCliRunContext( extraSystemPrompt, ownerNumbers: params.ownerNumbers, heartbeatPrompt, - docsPath: docsPath ?? undefined, + docsPath: openClawReferences.docsPath ?? undefined, + sourcePath: openClawReferences.sourcePath ?? undefined, skillsPrompt, tools: [], contextFiles, diff --git a/src/agents/docs-path.test.ts b/src/agents/docs-path.test.ts new file mode 100644 index 00000000000..a0b9f33b11d --- /dev/null +++ b/src/agents/docs-path.test.ts @@ -0,0 +1,75 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + resolveOpenClawDocsPath, + resolveOpenClawReferencePaths, + resolveOpenClawSourcePath, +} from "./docs-path.js"; + +async function makePackageRoot(prefix: string): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + await fs.writeFile(path.join(root, "package.json"), '{"name":"openclaw"}\n'); + return root; +} + +async function writeDocsJson(root: string): Promise { + await fs.mkdir(path.join(root, "docs"), { recursive: true }); + await fs.writeFile(path.join(root, "docs", "docs.json"), "{}\n"); +} + +describe("resolveOpenClawDocsPath", () => { + it("uses the workspace docs directory when it has canonical docs metadata", async () => { + const root = await makePackageRoot("openclaw-docs-workspace-"); + await writeDocsJson(root); + + await expect(resolveOpenClawDocsPath({ workspaceDir: root })).resolves.toBe( + path.join(root, "docs"), + ); + }); + + it("finds bundled package docs from a nested package path", async () => { + const root = await makePackageRoot("openclaw-docs-package-"); + await writeDocsJson(root); + const nested = path.join(root, "dist", "agents"); + await fs.mkdir(nested, { recursive: true }); + + await expect(resolveOpenClawDocsPath({ cwd: nested })).resolves.toBe(path.join(root, "docs")); + }); + + it("does not accept incomplete template-only docs directories", async () => { + const root = await makePackageRoot("openclaw-docs-incomplete-"); + await fs.mkdir(path.join(root, "docs", "reference", "templates"), { recursive: true }); + + await expect(resolveOpenClawDocsPath({ cwd: root })).resolves.toBeNull(); + }); +}); + +describe("resolveOpenClawSourcePath", () => { + it("returns the package root only for git checkouts", async () => { + const root = await makePackageRoot("openclaw-source-git-"); + await fs.mkdir(path.join(root, ".git")); + + await expect(resolveOpenClawSourcePath({ cwd: root })).resolves.toBe(root); + }); + + it("omits source path for npm-style package installs", async () => { + const root = await makePackageRoot("openclaw-source-npm-"); + + await expect(resolveOpenClawSourcePath({ cwd: root })).resolves.toBeNull(); + }); +}); + +describe("resolveOpenClawReferencePaths", () => { + it("returns docs and local source together for git checkouts", async () => { + const root = await makePackageRoot("openclaw-reference-git-"); + await writeDocsJson(root); + await fs.mkdir(path.join(root, ".git")); + + await expect(resolveOpenClawReferencePaths({ cwd: root })).resolves.toEqual({ + docsPath: path.join(root, "docs"), + sourcePath: root, + }); + }); +}); diff --git a/src/agents/docs-path.ts b/src/agents/docs-path.ts index 2227d3e7221..22dcac3bc9d 100644 --- a/src/agents/docs-path.ts +++ b/src/agents/docs-path.ts @@ -2,6 +2,24 @@ import fs from "node:fs"; import path from "node:path"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; +export const OPENCLAW_DOCS_URL = "https://docs.openclaw.ai"; +export const OPENCLAW_SOURCE_URL = "https://github.com/openclaw/openclaw"; + +type ResolveOpenClawReferencePathParams = { + workspaceDir?: string; + argv1?: string; + cwd?: string; + moduleUrl?: string; +}; + +function isUsableDocsDir(docsDir: string): boolean { + return fs.existsSync(path.join(docsDir, "docs.json")); +} + +function isGitCheckout(rootDir: string): boolean { + return fs.existsSync(path.join(rootDir, ".git")); +} + export async function resolveOpenClawDocsPath(params: { workspaceDir?: string; argv1?: string; @@ -11,7 +29,7 @@ export async function resolveOpenClawDocsPath(params: { const workspaceDir = params.workspaceDir?.trim(); if (workspaceDir) { const workspaceDocs = path.join(workspaceDir, "docs"); - if (fs.existsSync(workspaceDocs)) { + if (isUsableDocsDir(workspaceDocs)) { return workspaceDocs; } } @@ -26,5 +44,32 @@ export async function resolveOpenClawDocsPath(params: { } const packageDocs = path.join(packageRoot, "docs"); - return fs.existsSync(packageDocs) ? packageDocs : null; + return isUsableDocsDir(packageDocs) ? packageDocs : null; +} + +export async function resolveOpenClawSourcePath( + params: ResolveOpenClawReferencePathParams, +): Promise { + const packageRoot = await resolveOpenClawPackageRoot({ + cwd: params.cwd, + argv1: params.argv1, + moduleUrl: params.moduleUrl, + }); + if (!packageRoot || !isGitCheckout(packageRoot)) { + return null; + } + return packageRoot; +} + +export async function resolveOpenClawReferencePaths( + params: ResolveOpenClawReferencePathParams, +): Promise<{ + docsPath: string | null; + sourcePath: string | null; +}> { + const [docsPath, sourcePath] = await Promise.all([ + resolveOpenClawDocsPath(params), + resolveOpenClawSourcePath(params), + ]); + return { docsPath, sourcePath }; } diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index 9b63537f17a..36057b7ddf6 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -360,7 +360,10 @@ export async function loadCompactHooksHarness(): Promise<{ })); vi.doMock("../docs-path.js", () => ({ - resolveOpenClawDocsPath: vi.fn(async () => undefined), + resolveOpenClawReferencePaths: vi.fn(async () => ({ + docsPath: undefined, + sourcePath: undefined, + })), })); vi.doMock("../channel-tools.js", () => ({ diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 169b6e1e204..0bcaddf4a6e 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -55,7 +55,7 @@ import { import { resolveContextWindowInfo } from "../context-window-guard.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { resolveOpenClawDocsPath } from "../docs-path.js"; +import { resolveOpenClawReferencePaths } from "../docs-path.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js"; import { applyAuthHeaderOverride, @@ -476,18 +476,19 @@ export async function compactEmbeddedPiSessionDirect( const sessionLabel = params.sessionKey ?? params.sessionId; const resolvedMessageProvider = params.messageChannel ?? params.messageProvider; const contextInjectionMode = resolveContextInjectionMode(params.config); - const { contextFiles } = contextInjectionMode === "never" - ? { contextFiles: [] } - : await resolveBootstrapContextForRun({ - workspaceDir: effectiveWorkspace, - config: params.config, - sessionKey: params.sessionKey, - sessionId: params.sessionId, - warn: makeBootstrapWarn({ - sessionLabel, - warn: (message) => log.warn(message), - }), - }); + const { contextFiles } = + contextInjectionMode === "never" + ? { contextFiles: [] } + : await resolveBootstrapContextForRun({ + workspaceDir: effectiveWorkspace, + config: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + warn: makeBootstrapWarn({ + sessionLabel, + warn: (message) => log.warn(message), + }), + }); // Apply contextTokens cap to model so pi-coding-agent's auto-compaction // threshold uses the effective limit, not the native context window. const runtimeModelWithContext = runtimeModel as ProviderRuntimeModel; @@ -714,7 +715,7 @@ export async function compactEmbeddedPiSessionDirect( isSubagentSessionKey(params.sessionKey) || isCronSessionKey(params.sessionKey) ? "minimal" : "full"; - const docsPath = await resolveOpenClawDocsPath({ + const openClawReferences = await resolveOpenClawReferencePaths({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], cwd: effectiveWorkspace, @@ -758,7 +759,8 @@ export async function compactEmbeddedPiSessionDirect( defaultAgentId, }), skillsPrompt, - docsPath: docsPath ?? undefined, + docsPath: openClawReferences.docsPath ?? undefined, + sourcePath: openClawReferences.sourcePath ?? undefined, ttsHint, promptMode, acpEnabled: params.config?.acp?.enabled !== false, diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 099f684686e..62f03bfc539 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -277,7 +277,7 @@ vi.mock("../context-engine-maintenance.js", () => ({ })); vi.mock("../../docs-path.js", () => ({ - resolveOpenClawDocsPath: async () => undefined, + resolveOpenClawReferencePaths: async () => ({ docsPath: undefined, sourcePath: undefined }), })); vi.mock("../../pi-project-settings.js", () => ({ diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 1b9cdcc5fee..76191d3f705 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -72,7 +72,7 @@ import { resolveChannelReactionGuidance, } from "../../channel-tools.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; -import { resolveOpenClawDocsPath } from "../../docs-path.js"; +import { resolveOpenClawReferencePaths } from "../../docs-path.js"; import { isTimeoutError } from "../../failover-error.js"; import { resolveHeartbeatPromptForSystemPrompt } from "../../heartbeat-system-prompt.js"; import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; @@ -1053,7 +1053,7 @@ export async function runEmbeddedAttempt( // When toolsAllow is set, use minimal prompt and strip skills catalog const effectivePromptMode = params.toolsAllow?.length ? ("minimal" as const) : promptMode; const effectiveSkillsPrompt = params.toolsAllow?.length ? undefined : skillsPrompt; - const docsPath = await resolveOpenClawDocsPath({ + const openClawReferences = await resolveOpenClawReferencePaths({ workspaceDir: effectiveWorkspace, argv1: process.argv[1], cwd: effectiveWorkspace, @@ -1110,7 +1110,8 @@ export async function runEmbeddedAttempt( reasoningTagHint, heartbeatPrompt, skillsPrompt: effectiveSkillsPrompt, - docsPath: docsPath ?? undefined, + docsPath: openClawReferences.docsPath ?? undefined, + sourcePath: openClawReferences.sourcePath ?? undefined, ttsHint, workspaceNotes: workspaceNotes?.length ? workspaceNotes : undefined, reactionGuidance, diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 65911368e8d..8a9b8df7f43 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -21,6 +21,7 @@ export function buildEmbeddedSystemPrompt(params: { heartbeatPrompt?: string; skillsPrompt?: string; docsPath?: string; + sourcePath?: string; ttsHint?: string; reactionGuidance?: { level: "minimal" | "extensive"; @@ -69,6 +70,7 @@ export function buildEmbeddedSystemPrompt(params: { heartbeatPrompt: params.heartbeatPrompt, skillsPrompt: params.skillsPrompt, docsPath: params.docsPath, + sourcePath: params.sourcePath, ttsHint: params.ttsHint, workspaceNotes: params.workspaceNotes, reactionGuidance: params.reactionGuidance, diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 08b84ed912c..c874339a2f0 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -398,13 +398,30 @@ describe("buildAgentSystemPrompt", () => { const prompt = buildAgentSystemPrompt({ workspaceDir: "/tmp/openclaw", docsPath: "/tmp/openclaw/docs", + sourcePath: "/tmp/openclaw", }); expect(prompt).toContain("## Documentation"); expect(prompt).toContain("OpenClaw docs: /tmp/openclaw/docs"); + expect(prompt).toContain("Local source: /tmp/openclaw"); expect(prompt).toContain( "For OpenClaw behavior, commands, config, or architecture: consult local docs first.", ); + expect(prompt).toContain( + "If docs are incomplete or stale, inspect the local OpenClaw source code before answering.", + ); + }); + + it("falls back to public docs and GitHub source guidance when local docs are unavailable", () => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/work", + }); + + expect(prompt).toContain("OpenClaw docs: https://docs.openclaw.ai"); + expect(prompt).toContain("Source: https://github.com/openclaw/openclaw"); + expect(prompt).toContain( + "If docs are incomplete or stale, review the OpenClaw source on GitHub before answering.", + ); }); it("includes workspace notes when provided", () => { diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 4659cae1fa1..9d624b8bff2 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -392,22 +392,35 @@ function buildVoiceSection(params: { isMinimal: boolean; ttsHint?: string }) { return ["## Voice (TTS)", hint, ""]; } -function buildDocsSection(params: { docsPath?: string; isMinimal: boolean; readToolName: string }) { +function buildDocsSection(params: { + docsPath?: string; + sourcePath?: string; + isMinimal: boolean; + readToolName: string; +}) { const docsPath = params.docsPath?.trim(); - if (!docsPath || params.isMinimal) { + const sourcePath = params.sourcePath?.trim(); + if (params.isMinimal) { return []; } - return [ + const lines = [ "## Documentation", - `OpenClaw docs: ${docsPath}`, + docsPath ? `OpenClaw docs: ${docsPath}` : "OpenClaw docs: https://docs.openclaw.ai", "Mirror: https://docs.openclaw.ai", + sourcePath ? `Local source: ${sourcePath}` : undefined, "Source: https://github.com/openclaw/openclaw", "Community: https://discord.com/invite/clawd", "Find new skills: https://clawhub.ai", - "For OpenClaw behavior, commands, config, or architecture: consult local docs first.", + docsPath + ? "For OpenClaw behavior, commands, config, or architecture: consult local docs first." + : "For OpenClaw behavior, commands, config, or architecture: consult the docs mirror first.", + sourcePath + ? "If docs are incomplete or stale, inspect the local OpenClaw source code before answering." + : "If docs are incomplete or stale, review the OpenClaw source on GitHub before answering.", "When diagnosing issues, run `openclaw status` yourself when possible; only ask the user if you lack access (e.g., sandboxed).", "", ]; + return lines.filter((line): line is string => line !== undefined); } function formatFullAccessBlockedReason(reason?: EmbeddedFullAccessBlockedReason): string { @@ -441,6 +454,7 @@ export function buildAgentSystemPrompt(params: { skillsPrompt?: string; heartbeatPrompt?: string; docsPath?: string; + sourcePath?: string; workspaceNotes?: string[]; ttsHint?: string; /** Controls which hardcoded sections to include. Defaults to "full". */ @@ -663,6 +677,7 @@ export function buildAgentSystemPrompt(params: { }); const docsSection = buildDocsSection({ docsPath: params.docsPath, + sourcePath: params.sourcePath, isMinimal, readToolName, }); diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts index 0f74725807c..3a8cddbd66c 100644 --- a/src/auto-reply/commands-registry.shared.ts +++ b/src/auto-reply/commands-registry.shared.ts @@ -190,6 +190,15 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] { category: "status", tier: "essential", }), + defineChatCommand({ + key: "crestodian", + description: "Run the Crestodian setup and repair helper.", + textAlias: "/crestodian", + acceptsArgs: true, + scope: "text", + category: "management", + tier: "essential", + }), defineChatCommand({ key: "tasks", nativeName: "tasks", diff --git a/src/auto-reply/reply/commands-crestodian.ts b/src/auto-reply/reply/commands-crestodian.ts new file mode 100644 index 00000000000..a275d2a8316 --- /dev/null +++ b/src/auto-reply/reply/commands-crestodian.ts @@ -0,0 +1,32 @@ +import { logVerbose } from "../../globals.js"; +import type { CommandHandler } from "./commands-types.js"; + +export const handleCrestodianCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) { + return null; + } + const { extractCrestodianRescueMessage, runCrestodianRescueMessage } = + await import("../../crestodian/rescue-message.js"); + if (extractCrestodianRescueMessage(params.command.commandBodyNormalized) === null) { + return null; + } + if (!params.command.isAuthorizedSender) { + logVerbose( + `Ignoring /crestodian from unauthorized sender: ${params.command.senderId || ""}`, + ); + return { shouldContinue: false }; + } + return { + shouldContinue: false, + reply: { + text: + (await runCrestodianRescueMessage({ + cfg: params.cfg, + command: params.command, + commandBody: params.command.commandBodyNormalized, + agentId: params.agentId, + isGroup: params.isGroup, + })) ?? "Crestodian did not find a rescue request.", + }, + }; +}; diff --git a/src/auto-reply/reply/commands-handlers.runtime.ts b/src/auto-reply/reply/commands-handlers.runtime.ts index 489d1ec0d2d..d86a813194c 100644 --- a/src/auto-reply/reply/commands-handlers.runtime.ts +++ b/src/auto-reply/reply/commands-handlers.runtime.ts @@ -6,6 +6,7 @@ import { handleBtwCommand } from "./commands-btw.js"; import { handleCompactCommand } from "./commands-compact.js"; import { handleConfigCommand, handleDebugCommand } from "./commands-config.js"; import { handleContextCommand } from "./commands-context-command.js"; +import { handleCrestodianCommand } from "./commands-crestodian.js"; import { handleCommandsListCommand, handleExportTrajectoryCommand, @@ -57,6 +58,7 @@ export function loadCommandHandlers(): CommandHandler[] { handleExportSessionCommand, handleExportTrajectoryCommand, handleWhoamiCommand, + handleCrestodianCommand, handleSubagentsCommand, handleAcpCommand, handleMcpCommand, diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index fb471a7328c..d3b01987d9b 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -32,6 +32,10 @@ export type CliCommandCatalogEntry = { }; export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ + { + commandPath: ["crestodian"], + policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false }, + }, { commandPath: ["agent"], policy: { loadPlugins: "always" } }, { commandPath: ["message"], policy: { loadPlugins: "always" } }, { commandPath: ["channels"], policy: { loadPlugins: "always" } }, diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 12b0b872833..8dc3be74ef4 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -5,6 +5,7 @@ import { ensureConfigReady, installBaseProgramMocks, installSmokeProgramMocks, + runCrestodian, runTui, runtime, setupCommand, @@ -39,6 +40,7 @@ describe("cli program (smoke)", () => { program = createProgram(); vi.clearAllMocks(); runTui.mockResolvedValue(undefined); + runCrestodian.mockResolvedValue(undefined); ensureConfigReady.mockResolvedValue(undefined); }); @@ -53,6 +55,13 @@ describe("cli program (smoke)", () => { expect(runTui).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 45000 })); }); + it("runs crestodian one-shot requests", async () => { + await runProgram(["crestodian", "--message", "status"]); + expect(runCrestodian).toHaveBeenCalledWith( + expect.objectContaining({ message: "status", yes: false, json: false }), + ); + }); + it("warns and ignores invalid tui timeout override", async () => { await runProgram(["tui", "--timeout-ms", "nope"]); expect(runtime.error).toHaveBeenCalledWith('warning: invalid --timeout-ms "nope"; ignoring'); diff --git a/src/cli/program.test-mocks.ts b/src/cli/program.test-mocks.ts index ec0a2109ff7..1590006fb99 100644 --- a/src/cli/program.test-mocks.ts +++ b/src/cli/program.test-mocks.ts @@ -29,6 +29,7 @@ const programMocks = vi.hoisted(() => { runChannelLogin: vi.fn(), runChannelLogout: vi.fn(), runTui: vi.fn(), + runCrestodian: vi.fn(), loadAndMaybeMigrateDoctorConfig: vi.fn(), ensureConfigReady: vi.fn(), ensurePluginRegistryLoaded: vi.fn(), @@ -47,6 +48,7 @@ export const callGateway = programMocks.callGateway as AnyMock; export const runChannelLogin = programMocks.runChannelLogin as AnyMock; export const runChannelLogout = programMocks.runChannelLogout as AnyMock; export const runTui = programMocks.runTui as AnyMock; +export const runCrestodian = programMocks.runCrestodian as AnyMock; export const loadAndMaybeMigrateDoctorConfig = programMocks.loadAndMaybeMigrateDoctorConfig as AnyMock; export const ensureConfigReady = programMocks.ensureConfigReady as AnyMock; @@ -95,6 +97,7 @@ vi.mock("./channel-auth.js", () => ({ runChannelLogout: programMocks.runChannelLogout, })); vi.mock("../tui/tui.js", () => ({ runTui: programMocks.runTui })); +vi.mock("../crestodian/crestodian.js", () => ({ runCrestodian: programMocks.runCrestodian })); vi.mock("../gateway/call.js", () => ({ callGateway: programMocks.callGateway, randomIdempotencyKey: () => "idem-test", diff --git a/src/cli/program/command-registry-core.ts b/src/cli/program/command-registry-core.ts index 37622826981..6604387b096 100644 --- a/src/cli/program/command-registry-core.ts +++ b/src/cli/program/command-registry-core.ts @@ -51,6 +51,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec< >[] = [ ...withProgramOnlySpecs( defineImportedProgramCommandGroupSpecs([ + { + commandNames: ["crestodian"], + loadModule: () => import("./register.crestodian.js"), + exportName: "registerCrestodianCommand", + }, { commandNames: ["setup"], loadModule: () => import("./register.setup.js"), diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index b3db95e0549..6f06e7f1a20 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -37,6 +37,12 @@ vi.mock("./register.status-health-sessions.js", () => ({ }, })); +vi.mock("./register.crestodian.js", () => ({ + registerCrestodianCommand: (program: Command) => { + program.command("crestodian"); + }, +})); + import { getCoreCliCommandNames, getCoreCliCommandsWithSubcommands, @@ -67,6 +73,7 @@ describe("command-registry", () => { it("includes both agent and agents in core CLI command names", () => { const names = getCoreCliCommandNames(); + expect(names).toContain("crestodian"); expect(names).toContain("mcp"); expect(names).toContain("agent"); expect(names).toContain("agents"); @@ -81,6 +88,7 @@ describe("command-registry", () => { expect(names).toContain("sessions"); expect(names).toContain("tasks"); expect(names).not.toContain("agent"); + expect(names).not.toContain("crestodian"); expect(names).not.toContain("status"); expect(names).not.toContain("doctor"); }); diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index e557f1a568e..42f916bbe5a 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -4,6 +4,11 @@ import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; export type CoreCliCommandDescriptor = NamedCommandDescriptor; const coreCliCommandCatalog = defineCommandDescriptorCatalog([ + { + name: "crestodian", + description: "Open the ring-zero setup and repair helper", + hasSubcommands: false, + }, { name: "setup", description: "Initialize local config and agent workspace", diff --git a/src/cli/program/register.crestodian.ts b/src/cli/program/register.crestodian.ts new file mode 100644 index 00000000000..a008156a71d --- /dev/null +++ b/src/cli/program/register.crestodian.ts @@ -0,0 +1,37 @@ +import type { Command } from "commander"; +import { runCrestodian } from "../../crestodian/crestodian.js"; +import { defaultRuntime } from "../../runtime.js"; +import { theme } from "../../terminal/theme.js"; +import { runCommandWithRuntime } from "../cli-utils.js"; +import { formatHelpExamples } from "../help-format.js"; + +export function registerCrestodianCommand(program: Command) { + program + .command("crestodian") + .description("Open the ring-zero setup and repair helper") + .option("-m, --message ", "Run one Crestodian request") + .option("--yes", "Approve persistent config writes for this request", false) + .option("--json", "Output startup overview as JSON", false) + .addHelpText( + "after", + () => + `\n${theme.heading("Examples:")}\n${formatHelpExamples([ + ["openclaw", "Start Crestodian."], + ["openclaw crestodian", "Start Crestodian explicitly."], + ['openclaw crestodian -m "status"', "Run one status request."], + [ + 'openclaw crestodian -m "set default model openai/gpt-5.2" --yes', + "Apply a typed config write.", + ], + ])}`, + ) + .action(async (opts) => { + await runCommandWithRuntime(defaultRuntime, async () => { + await runCrestodian({ + message: opts.message as string | undefined, + yes: Boolean(opts.yes), + json: Boolean(opts.json), + }); + }); + }); +} diff --git a/src/cli/program/register.onboard.test.ts b/src/cli/program/register.onboard.test.ts index 97bb395ba25..a6b11661be3 100644 --- a/src/cli/program/register.onboard.test.ts +++ b/src/cli/program/register.onboard.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerOnboardCommand } from "./register.onboard.js"; const mocks = vi.hoisted(() => ({ + runCrestodian: vi.fn(), setupWizardCommandMock: vi.fn(), runtime: { log: vi.fn(), @@ -14,10 +15,6 @@ const mocks = vi.hoisted(() => ({ const setupWizardCommandMock = mocks.setupWizardCommandMock; const runtime = mocks.runtime; -vi.mock("../../commands/auth-choice-options.static.js", () => ({ - formatStaticAuthChoiceChoicesForCli: () => "token|oauth", -})); - vi.mock("../../commands/auth-choice-options.js", () => ({ formatAuthChoiceChoicesForCli: () => "token|oauth|openai-api-key", })); @@ -46,6 +43,10 @@ vi.mock("../../commands/onboard.js", () => ({ setupWizardCommand: mocks.setupWizardCommandMock, })); +vi.mock("../../crestodian/crestodian.js", () => ({ + runCrestodian: mocks.runCrestodian, +})); + vi.mock("../../runtime.js", () => ({ defaultRuntime: mocks.runtime, })); @@ -59,6 +60,7 @@ describe("registerOnboardCommand", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.runCrestodian.mockResolvedValue(undefined); setupWizardCommandMock.mockResolvedValue(undefined); }); @@ -71,6 +73,7 @@ describe("registerOnboardCommand", () => { }), runtime, ); + expect(mocks.runCrestodian).not.toHaveBeenCalled(); }); it("sets installDaemon from explicit install flags and prioritizes --skip-daemon", async () => { @@ -171,4 +174,28 @@ describe("registerOnboardCommand", () => { expect(runtime.error).toHaveBeenCalledWith("Error: setup failed"); expect(runtime.exit).toHaveBeenCalledWith(1); }); + + it("routes --modern to Crestodian", async () => { + await runCli(["onboard", "--modern", "--json"]); + + expect(setupWizardCommandMock).not.toHaveBeenCalled(); + expect(mocks.runCrestodian).toHaveBeenCalledWith({ + message: undefined, + yes: false, + json: true, + interactive: true, + }); + }); + + it("uses a noninteractive overview for modern noninteractive onboarding", async () => { + await runCli(["onboard", "--modern", "--non-interactive"]); + + expect(setupWizardCommandMock).not.toHaveBeenCalled(); + expect(mocks.runCrestodian).toHaveBeenCalledWith({ + message: "overview", + yes: false, + json: false, + interactive: false, + }); + }); }); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index f1b7caa1e9d..19b08706132 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -76,6 +76,7 @@ export function registerOnboardCommand(program: Command) { ) .option("--reset-scope ", "Reset scope: config|config+creds+sessions|full") .option("--non-interactive", "Run without prompts", false) + .option("--modern", "Use the Crestodian conversational onboarding preview", false) .option( "--accept-risk", "Acknowledge that agents are powerful and full system access is risky (required for --non-interactive)", @@ -142,6 +143,16 @@ export function registerOnboardCommand(program: Command) { command.action(async (opts, commandRuntime) => { await runCommandWithRuntime(defaultRuntime, async () => { + if (opts.modern) { + const { runCrestodian } = await import("../../crestodian/crestodian.js"); + await runCrestodian({ + message: opts.nonInteractive ? "overview" : undefined, + yes: false, + json: Boolean(opts.json), + interactive: !opts.nonInteractive, + }); + return; + } const installDaemon = resolveInstallDaemonFlag(commandRuntime, { installDaemon: Boolean(opts.installDaemon), }); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index b528fef8aa8..70f4914f157 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -4,6 +4,8 @@ import { rewriteUpdateFlagArgv, resolveMissingPluginCommandMessage, shouldEnsureCliPath, + shouldStartCrestodianForBareRoot, + shouldStartCrestodianForModernOnboard, shouldUseRootHelpFastPath, } from "./run-main.js"; @@ -68,6 +70,8 @@ describe("shouldEnsureCliPath", () => { }); it("skips path bootstrap for read-only fast paths", () => { + expect(shouldEnsureCliPath(["node", "openclaw"])).toBe(false); + expect(shouldEnsureCliPath(["node", "openclaw", "--profile", "work"])).toBe(false); expect(shouldEnsureCliPath(["node", "openclaw", "status"])).toBe(false); expect(shouldEnsureCliPath(["node", "openclaw", "--log-level", "debug", "status"])).toBe(false); expect(shouldEnsureCliPath(["node", "openclaw", "sessions", "--json"])).toBe(false); @@ -82,6 +86,42 @@ describe("shouldEnsureCliPath", () => { }); }); +describe("shouldStartCrestodianForBareRoot", () => { + it("starts Crestodian for bare root invocations", () => { + expect(shouldStartCrestodianForBareRoot(["node", "openclaw"])).toBe(true); + expect(shouldStartCrestodianForBareRoot(["node", "openclaw", "--profile", "work"])).toBe(true); + expect(shouldStartCrestodianForBareRoot(["node", "openclaw", "--dev"])).toBe(true); + }); + + it("does not start Crestodian for help, version, or commands", () => { + expect(shouldStartCrestodianForBareRoot(["node", "openclaw", "--help"])).toBe(false); + expect(shouldStartCrestodianForBareRoot(["node", "openclaw", "-V"])).toBe(false); + expect(shouldStartCrestodianForBareRoot(["node", "openclaw", "status"])).toBe(false); + }); +}); + +describe("shouldStartCrestodianForModernOnboard", () => { + it("starts Crestodian before heavy command registration for modern onboard", () => { + expect( + shouldStartCrestodianForModernOnboard([ + "node", + "openclaw", + "onboard", + "--modern", + "--non-interactive", + "--json", + ]), + ).toBe(true); + }); + + it("keeps classic onboard and help on the normal command path", () => { + expect(shouldStartCrestodianForModernOnboard(["node", "openclaw", "onboard"])).toBe(false); + expect( + shouldStartCrestodianForModernOnboard(["node", "openclaw", "onboard", "--modern", "--help"]), + ).toBe(false); + }); +}); + describe("shouldUseRootHelpFastPath", () => { it("uses the fast path for root help only", () => { expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index ce1ce5811ff..8dbff0aeaae 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -61,7 +61,7 @@ export function rewriteUpdateFlagArgv(argv: string[]): string[] { export function shouldEnsureCliPath(argv: string[]): boolean { const invocation = resolveCliArgvInvocation(argv); - if (invocation.hasHelpOrVersion) { + if (invocation.hasHelpOrVersion || shouldStartCrestodianForBareRoot(argv)) { return false; } return shouldEnsureCliPathForCommandPath(invocation.commandPath); @@ -71,6 +71,20 @@ export function shouldUseRootHelpFastPath(argv: string[]): boolean { return resolveCliArgvInvocation(argv).isRootHelpInvocation; } +export function shouldStartCrestodianForBareRoot(argv: string[]): boolean { + const invocation = resolveCliArgvInvocation(argv); + return invocation.commandPath.length === 0 && !invocation.hasHelpOrVersion; +} + +export function shouldStartCrestodianForModernOnboard(argv: string[]): boolean { + const invocation = resolveCliArgvInvocation(argv); + return ( + invocation.commandPath[0] === "onboard" && + argv.includes("--modern") && + !invocation.hasHelpOrVersion + ); +} + export function resolveMissingPluginCommandMessage( pluginId: string, config?: OpenClawConfig, @@ -203,6 +217,31 @@ export async function runCli(argv: string[] = process.argv) { return; } + if (shouldStartCrestodianForBareRoot(normalizedArgv)) { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + console.error( + 'Crestodian needs an interactive TTY. Use `openclaw crestodian --message "status"` for one command.', + ); + process.exitCode = 1; + return; + } + const { runCrestodian } = await import("../crestodian/crestodian.js"); + await runCrestodian(); + return; + } + + if (shouldStartCrestodianForModernOnboard(normalizedArgv)) { + const { runCrestodian } = await import("../crestodian/crestodian.js"); + const nonInteractive = normalizedArgv.includes("--non-interactive"); + await runCrestodian({ + message: nonInteractive ? "overview" : undefined, + yes: false, + json: normalizedArgv.includes("--json"), + interactive: !nonInteractive, + }); + return; + } + if (await tryRouteCli(normalizedArgv)) { return; } diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 12a519c88f7..9bc052cabe1 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -65,6 +65,45 @@ describe("plugins.slots.contextEngine", () => { }); }); +describe("crestodian.rescue", () => { + it("accepts documented rescue config", () => { + const result = OpenClawSchema.safeParse({ + crestodian: { + rescue: { + enabled: "auto", + ownerDmOnly: false, + pendingTtlMinutes: 5, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("accepts boolean rescue enablement", () => { + const result = OpenClawSchema.safeParse({ + crestodian: { + rescue: { + enabled: true, + ownerDmOnly: true, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it("rejects unknown rescue keys", () => { + const result = OpenClawSchema.safeParse({ + crestodian: { + rescue: { + enabled: true, + shell: true, + }, + }, + }); + expect(result.success).toBe(false); + }); +}); + describe("diagnostics.otel.captureContent", () => { it("accepts boolean and granular OTEL content capture config", () => { for (const captureContent of [ diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 723bfa2cd64..47f7571e591 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -502,6 +502,37 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { description: "CLI presentation controls for local command output behavior such as banner and tagline style. Use this section to keep startup output aligned with operator preference without changing runtime behavior.", }, + crestodian: { + type: "object", + properties: { + rescue: { + type: "object", + properties: { + enabled: { + anyOf: [ + { + type: "string", + const: "auto", + }, + { + type: "boolean", + }, + ], + }, + ownerDmOnly: { + type: "boolean", + }, + pendingTtlMinutes: { + type: "integer", + exclusiveMinimum: 0, + maximum: 9007199254740991, + }, + }, + additionalProperties: false, + }, + }, + additionalProperties: false, + }, update: { type: "object", properties: { diff --git a/src/config/types.crestodian.ts b/src/config/types.crestodian.ts new file mode 100644 index 00000000000..8ee7eb8171e --- /dev/null +++ b/src/config/types.crestodian.ts @@ -0,0 +1,15 @@ +export type CrestodianRescueConfig = { + /** + * Remote message rescue gate. + * "auto" enables only for YOLO host posture with sandboxing off. + */ + enabled?: "auto" | boolean; + /** Restrict rescue to owner DMs. Default: true. */ + ownerDmOnly?: boolean; + /** Pending write approval TTL in minutes. Default: 15. */ + pendingTtlMinutes?: number; +}; + +export type CrestodianConfig = { + rescue?: CrestodianRescueConfig; +}; diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index 52a5494690e..d167e4d1c90 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -10,6 +10,7 @@ import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from import type { BrowserConfig } from "./types.browser.js"; import type { ChannelsConfig } from "./types.channels.js"; import type { CliConfig } from "./types.cli.js"; +import type { CrestodianConfig } from "./types.crestodian.js"; import type { CronConfig } from "./types.cron.js"; import type { CanvasHostConfig, @@ -74,6 +75,7 @@ export type OpenClawConfig = { diagnostics?: DiagnosticsConfig; logging?: LoggingConfig; cli?: CliConfig; + crestodian?: CrestodianConfig; update?: { /** Update channel for git + npm installs ("stable", "beta", or "dev"). */ channel?: "stable" | "beta" | "dev"; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 456b4c00931..f922efb2cf9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -236,6 +236,20 @@ const McpConfigSchema = z .strict() .optional(); +const CrestodianSchema = z + .object({ + rescue: z + .object({ + enabled: z.union([z.literal("auto"), z.boolean()]).optional(), + ownerDmOnly: z.boolean().optional(), + pendingTtlMinutes: z.number().int().positive().optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(); + export const OpenClawSchema = z .object({ $schema: z.string().optional(), @@ -358,6 +372,7 @@ export const OpenClawSchema = z }) .strict() .optional(), + crestodian: CrestodianSchema, update: z .object({ channel: z.union([z.literal("stable"), z.literal("beta"), z.literal("dev")]).optional(), diff --git a/src/crestodian/assistant.test.ts b/src/crestodian/assistant.test.ts new file mode 100644 index 00000000000..803032f0ff6 --- /dev/null +++ b/src/crestodian/assistant.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { + buildCrestodianAssistantUserPrompt, + parseCrestodianAssistantPlanText, +} from "./assistant.js"; +import type { CrestodianOverview } from "./overview.js"; + +function overviewFixture(): CrestodianOverview { + return { + config: { + path: "/tmp/openclaw.json", + exists: true, + valid: true, + issues: [], + hash: "hash", + }, + agents: [ + { + id: "main", + name: "Main", + isDefault: true, + model: "openai/gpt-5.5", + workspace: "/tmp/main", + }, + ], + defaultAgentId: "main", + defaultModel: "openai/gpt-5.5", + tools: { + codex: { command: "codex", found: true, version: "codex 1.0.0" }, + claude: { command: "claude", found: false }, + apiKeys: { openai: true, anthropic: false }, + }, + gateway: { + url: "ws://127.0.0.1:18200", + source: "local loopback", + reachable: false, + }, + references: { + docsPath: "/tmp/openclaw/docs", + docsUrl: "https://docs.openclaw.ai", + sourcePath: "/tmp/openclaw", + sourceUrl: "https://github.com/openclaw/openclaw", + }, + }; +} + +describe("parseCrestodianAssistantPlanText", () => { + it("extracts compact planner JSON", () => { + expect( + parseCrestodianAssistantPlanText( + 'tiny claw says {"reply":"I can restart it.","command":"restart gateway"}', + ), + ).toEqual({ + reply: "I can restart it.", + command: "restart gateway", + }); + }); + + it("rejects non-command output", () => { + expect(parseCrestodianAssistantPlanText("I would edit config directly.")).toBeNull(); + expect(parseCrestodianAssistantPlanText('{"reply":"missing command"}')).toBeNull(); + }); +}); + +describe("buildCrestodianAssistantUserPrompt", () => { + it("includes only operational summary context", () => { + const prompt = buildCrestodianAssistantUserPrompt({ + input: "fix my setup", + overview: overviewFixture(), + }); + + expect(prompt).toContain("User request: fix my setup"); + expect(prompt).toContain("Default model: openai/gpt-5.5"); + expect(prompt).toContain("id=main, name=Main, workspace=/tmp/main"); + expect(prompt).toContain("OpenAI API key: found"); + expect(prompt).toContain("OpenClaw docs: /tmp/openclaw/docs"); + expect(prompt).toContain("OpenClaw source: /tmp/openclaw"); + }); +}); diff --git a/src/crestodian/assistant.ts b/src/crestodian/assistant.ts new file mode 100644 index 00000000000..6a14c127c25 --- /dev/null +++ b/src/crestodian/assistant.ts @@ -0,0 +1,195 @@ +import { resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { extractAssistantText } from "../agents/pi-embedded-utils.js"; +import { + completeWithPreparedSimpleCompletionModel, + prepareSimpleCompletionModelForAgent, +} from "../agents/simple-completion-runtime.js"; +import { readConfigFileSnapshot } from "../config/config.js"; +import type { CrestodianOverview } from "./overview.js"; + +const CRESTODIAN_ASSISTANT_TIMEOUT_MS = 10_000; +const CRESTODIAN_ASSISTANT_MAX_TOKENS = 512; + +const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ + "You are Crestodian, OpenClaw's ring-zero setup helper.", + "Turn the user's request into exactly one safe OpenClaw Crestodian command.", + "Return only compact JSON with keys reply and command.", + "Do not invent commands. Do not claim a write was applied.", + "Use the provided OpenClaw docs/source references when the user's request needs behavior, config, or architecture details.", + "If local source is available, prefer inspecting it. Otherwise point to GitHub and strongly recommend reviewing source when docs are not enough.", + "Allowed commands:", + "- setup", + "- status", + "- health", + "- doctor", + "- doctor fix", + "- gateway status", + "- restart gateway", + "- start gateway", + "- stop gateway", + "- agents", + "- models", + "- audit", + "- validate config", + "- set default model ", + "- config set ", + "- config set-ref env ", + "- create agent workspace model ", + "- talk to agent", + "- talk to agent", + "If unsure, choose overview.", +].join("\n"); + +export type CrestodianAssistantPlan = { + command: string; + reply?: string; + modelLabel?: string; +}; + +export type CrestodianAssistantPlanner = (params: { + input: string; + overview: CrestodianOverview; +}) => Promise; + +export async function planCrestodianCommandWithConfiguredModel(params: { + input: string; + overview: CrestodianOverview; +}): Promise { + const input = params.input.trim(); + if (!input) { + return null; + } + const snapshot = await readConfigFileSnapshot(); + if (!snapshot.valid) { + return null; + } + const cfg = snapshot.runtimeConfig ?? snapshot.config; + const agentId = resolveDefaultAgentId(cfg); + const prepared = await prepareSimpleCompletionModelForAgent({ + cfg, + agentId, + allowMissingApiKeyModes: ["aws-sdk"], + }); + if ("error" in prepared) { + return null; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CRESTODIAN_ASSISTANT_TIMEOUT_MS); + try { + const response = await completeWithPreparedSimpleCompletionModel({ + model: prepared.model, + auth: prepared.auth, + context: { + systemPrompt: CRESTODIAN_ASSISTANT_SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: buildCrestodianAssistantUserPrompt({ + input, + overview: params.overview, + }), + timestamp: Date.now(), + }, + ], + }, + options: { + maxTokens: CRESTODIAN_ASSISTANT_MAX_TOKENS, + signal: controller.signal, + }, + }); + const parsed = parseCrestodianAssistantPlanText(extractAssistantText(response)); + if (!parsed) { + return null; + } + return { + ...parsed, + modelLabel: `${prepared.selection.provider}/${prepared.selection.modelId}`, + }; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +export function buildCrestodianAssistantUserPrompt(params: { + input: string; + overview: CrestodianOverview; +}): string { + const agents = params.overview.agents + .map((agent) => { + const fields = [ + `id=${agent.id}`, + agent.name ? `name=${agent.name}` : undefined, + agent.workspace ? `workspace=${agent.workspace}` : undefined, + agent.model ? `model=${agent.model}` : undefined, + agent.isDefault ? "default=true" : undefined, + ].filter(Boolean); + return `- ${fields.join(", ")}`; + }) + .join("\n"); + return [ + `User request: ${params.input}`, + "", + `Default agent: ${params.overview.defaultAgentId}`, + `Default model: ${params.overview.defaultModel ?? "not configured"}`, + `Config valid: ${params.overview.config.valid}`, + `Gateway reachable: ${params.overview.gateway.reachable}`, + `Codex CLI: ${params.overview.tools.codex.found ? "found" : "not found"}`, + `Claude Code CLI: ${params.overview.tools.claude.found ? "found" : "not found"}`, + `OpenAI API key: ${params.overview.tools.apiKeys.openai ? "found" : "not found"}`, + `Anthropic API key: ${params.overview.tools.apiKeys.anthropic ? "found" : "not found"}`, + `OpenClaw docs: ${params.overview.references.docsPath ?? params.overview.references.docsUrl}`, + `OpenClaw source: ${ + params.overview.references.sourcePath ?? params.overview.references.sourceUrl + }`, + params.overview.references.sourcePath + ? "Source mode: local git checkout; inspect source directly when docs are insufficient." + : "Source mode: package/install; use GitHub source when docs are insufficient.", + "", + "Agents:", + agents || "- none", + ].join("\n"); +} + +export function parseCrestodianAssistantPlanText( + rawText: string | undefined, +): CrestodianAssistantPlan | null { + const text = rawText?.trim(); + if (!text) { + return null; + } + const jsonText = extractFirstJsonObject(text); + if (!jsonText) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + return null; + } + if (!parsed || typeof parsed !== "object") { + return null; + } + const record = parsed as Record; + const command = typeof record.command === "string" ? record.command.trim() : ""; + if (!command) { + return null; + } + const reply = typeof record.reply === "string" ? record.reply.trim() : undefined; + return { + command, + ...(reply ? { reply } : {}), + }; +} + +function extractFirstJsonObject(text: string): string | null { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + if (start < 0 || end <= start) { + return null; + } + return text.slice(start, end + 1); +} diff --git a/src/crestodian/audit.test.ts b/src/crestodian/audit.test.ts new file mode 100644 index 00000000000..7506c0411fe --- /dev/null +++ b/src/crestodian/audit.test.ts @@ -0,0 +1,39 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { appendCrestodianAuditEntry, resolveCrestodianAuditPath } from "./audit.js"; + +describe("Crestodian audit log", () => { + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + + afterEach(() => { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + }); + + it("writes jsonl records under the OpenClaw audit dir", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-audit-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + + const auditPath = await appendCrestodianAuditEntry({ + operation: "config.setDefaultModel", + summary: "Set default model to openai/gpt-5.2", + configHashBefore: "before", + configHashAfter: "after", + }); + + expect(auditPath).toBe(resolveCrestodianAuditPath()); + const lines = (await fs.readFile(auditPath, "utf8")).trim().split("\n"); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0] ?? "{}")).toMatchObject({ + operation: "config.setDefaultModel", + summary: "Set default model to openai/gpt-5.2", + configHashBefore: "before", + configHashAfter: "after", + }); + }); +}); diff --git a/src/crestodian/audit.ts b/src/crestodian/audit.ts new file mode 100644 index 00000000000..7a300b1328d --- /dev/null +++ b/src/crestodian/audit.ts @@ -0,0 +1,37 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; + +export type CrestodianAuditEntry = { + timestamp: string; + operation: string; + summary: string; + configPath?: string; + configHashBefore?: string | null; + configHashAfter?: string | null; + details?: Record; +}; + +export function resolveCrestodianAuditPath( + env: NodeJS.ProcessEnv = process.env, + stateDir = resolveStateDir(env), +): string { + return path.join(stateDir, "audit", "crestodian.jsonl"); +} + +export async function appendCrestodianAuditEntry( + entry: Omit, + opts: { env?: NodeJS.ProcessEnv; auditPath?: string } = {}, +): Promise { + const auditPath = opts.auditPath ?? resolveCrestodianAuditPath(opts.env); + await fs.mkdir(path.dirname(auditPath), { recursive: true }); + const line = JSON.stringify({ + timestamp: new Date().toISOString(), + ...entry, + } satisfies CrestodianAuditEntry); + await fs.appendFile(auditPath, `${line}\n`, { encoding: "utf8", mode: 0o600 }); + await fs.chmod(auditPath, 0o600).catch(() => { + // Best-effort on platforms/filesystems without POSIX modes. + }); + return auditPath; +} diff --git a/src/crestodian/crestodian.test.ts b/src/crestodian/crestodian.test.ts new file mode 100644 index 00000000000..b0509595a74 --- /dev/null +++ b/src/crestodian/crestodian.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import { runCrestodian } from "./crestodian.js"; + +function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { + const lines: string[] = []; + return { + lines, + runtime: { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`exit ${code}`); + }, + }, + }; +} + +describe("runCrestodian", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("uses the assistant planner only to choose typed operations", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + const runGatewayRestart = vi.fn(async () => {}); + + await runCrestodian( + { + message: "the local bridge looks sleepy, poke it", + deps: { runGatewayRestart }, + planWithAssistant: async () => ({ + reply: "I can queue a Gateway restart.", + command: "restart gateway", + modelLabel: "openai/gpt-5.5", + }), + }, + runtime, + ); + + expect(runGatewayRestart).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("[crestodian] planner: openai/gpt-5.5"); + expect(lines.join("\n")).toContain("[crestodian] interpreted: restart gateway"); + expect(lines.join("\n")).toContain("Plan: restart the Gateway. Say yes to apply."); + }); + + it("keeps deterministic parsing ahead of the assistant planner", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-run-deterministic-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + const planner = vi.fn(async () => ({ command: "restart gateway" })); + + await runCrestodian( + { + message: "models", + planWithAssistant: planner, + }, + runtime, + ); + + expect(planner).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("Default model:"); + }); +}); diff --git a/src/crestodian/crestodian.ts b/src/crestodian/crestodian.ts new file mode 100644 index 00000000000..27c7a9da1a3 --- /dev/null +++ b/src/crestodian/crestodian.ts @@ -0,0 +1,193 @@ +import { stdin as defaultStdin, stdout as defaultStdout } from "node:process"; +import readline from "node:readline/promises"; +import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +import { + planCrestodianCommandWithConfiguredModel, + type CrestodianAssistantPlan, + type CrestodianAssistantPlanner, +} from "./assistant.js"; +import { + executeCrestodianOperation, + describeCrestodianPersistentOperation, + isPersistentCrestodianOperation, + parseCrestodianOperation, + type CrestodianCommandDeps, + type CrestodianOperation, +} from "./operations.js"; +import { + formatCrestodianOverview, + loadCrestodianOverview, + type CrestodianOverview, +} from "./overview.js"; + +export type RunCrestodianOptions = { + message?: string; + yes?: boolean; + json?: boolean; + interactive?: boolean; + deps?: CrestodianCommandDeps; + planWithAssistant?: CrestodianAssistantPlanner; + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +}; + +function approvalQuestion(operation: CrestodianOperation): string { + return `Apply this operation: ${describeCrestodianPersistentOperation(operation)}?`; +} + +function isYes(input: string): boolean { + return /^(y|yes|apply|do it|approved?)$/i.test(input.trim()); +} + +async function runOneShot( + input: string, + runtime: RuntimeEnv, + opts: RunCrestodianOptions, +): Promise { + const operation = await resolveCrestodianOperation(input, runtime, opts); + await executeCrestodianOperation(operation, runtime, { + approved: opts.yes === true || !isPersistentCrestodianOperation(operation), + deps: opts.deps, + }); +} + +async function runResolvedOperation( + operation: CrestodianOperation, + runtime: RuntimeEnv, + opts: RunCrestodianOptions, +): Promise<{ exitsInteractive: boolean; nextInput?: string }> { + const result = await executeCrestodianOperation(operation, runtime, { + approved: opts.yes === true || !isPersistentCrestodianOperation(operation), + deps: opts.deps, + }); + return { + exitsInteractive: result.exitsInteractive === true, + nextInput: result.nextInput, + }; +} + +async function resolveCrestodianOperation( + input: string, + runtime: RuntimeEnv, + opts: RunCrestodianOptions, +): Promise { + const operation = parseCrestodianOperation(input); + if (!shouldAskAssistant(input, operation)) { + return operation; + } + const overview = await loadCrestodianOverview(); + const planner = opts.planWithAssistant ?? planCrestodianCommandWithConfiguredModel; + const plan = await planner({ input, overview }); + if (!plan) { + return operation; + } + const planned = parseCrestodianOperation(plan.command); + if (planned.kind === "none") { + return operation; + } + logAssistantPlan(runtime, plan, overview); + return planned; +} + +function shouldAskAssistant(input: string, operation: CrestodianOperation): boolean { + if (operation.kind !== "none") { + return false; + } + const trimmed = input.trim().toLowerCase(); + if (!trimmed || trimmed === "quit" || trimmed === "exit") { + return false; + } + return true; +} + +function logAssistantPlan( + runtime: RuntimeEnv, + plan: CrestodianAssistantPlan, + overview: CrestodianOverview, +): void { + const modelLabel = plan.modelLabel ?? overview.defaultModel ?? "configured model"; + runtime.log(`[crestodian] planner: ${modelLabel}`); + if (plan.reply) { + runtime.log(plan.reply); + } + runtime.log(`[crestodian] interpreted: ${plan.command}`); +} + +export async function runCrestodian( + opts: RunCrestodianOptions = {}, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const overview = await loadCrestodianOverview(); + if (opts.json) { + writeRuntimeJson(runtime, overview); + return; + } + runtime.log(formatCrestodianOverview(overview)); + runtime.log(""); + runtime.log( + "Say: status, doctor, health, gateway status, restart gateway, agents, models, set default model , talk to agent, audit, or quit.", + ); + + if (opts.message?.trim()) { + await runOneShot(opts.message, runtime, opts); + return; + } + + const interactive = opts.interactive ?? true; + const input = opts.input ?? defaultStdin; + const output = opts.output ?? defaultStdout; + const inputIsTty = (input as { isTTY?: boolean }).isTTY === true; + const outputIsTty = (output as { isTTY?: boolean }).isTTY === true; + if (!interactive || !inputIsTty || !outputIsTty) { + runtime.error("Crestodian needs an interactive TTY. Use --message for one command."); + return; + } + + const rl = readline.createInterface({ input, output }); + let pending: CrestodianOperation | null = null; + try { + for (;;) { + const answer = await rl.question("crestodian> "); + if (pending) { + if (isYes(answer)) { + const result = await executeCrestodianOperation(pending, runtime, { + approved: true, + deps: opts.deps, + }); + pending = null; + if (result.exitsInteractive) { + break; + } + continue; + } + runtime.log("Skipped. No barnacles on config today."); + pending = null; + continue; + } + const operation = await resolveCrestodianOperation(answer, runtime, opts); + if (isPersistentCrestodianOperation(operation) && !opts.yes) { + runtime.log(approvalQuestion(operation)); + pending = operation; + continue; + } + const result = await runResolvedOperation(operation, runtime, opts); + if (result.exitsInteractive) { + break; + } + if (result.nextInput?.trim()) { + const followUp = await resolveCrestodianOperation(result.nextInput, runtime, opts); + if (isPersistentCrestodianOperation(followUp) && !opts.yes) { + runtime.log(approvalQuestion(followUp)); + pending = followUp; + continue; + } + const followUpResult = await runResolvedOperation(followUp, runtime, opts); + if (followUpResult.exitsInteractive) { + break; + } + } + } + } finally { + rl.close(); + } +} diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts new file mode 100644 index 00000000000..41175aff67a --- /dev/null +++ b/src/crestodian/operations.test.ts @@ -0,0 +1,423 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; +import { + executeCrestodianOperation, + parseCrestodianOperation, + type CrestodianOperationResult, +} from "./operations.js"; + +function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { + const lines: string[] = []; + return { + lines, + runtime: { + log: (...args) => lines.push(args.join(" ")), + error: (...args) => lines.push(args.join(" ")), + exit: (code) => { + throw new Error(`exit ${code}`); + }, + }, + }; +} + +describe("parseCrestodianOperation", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("parses typed model writes", () => { + expect(parseCrestodianOperation("set default model openai/gpt-5.2")).toEqual({ + kind: "set-default-model", + model: "openai/gpt-5.2", + }); + expect(parseCrestodianOperation("configure models openai/gpt-5.2")).toEqual({ + kind: "set-default-model", + model: "openai/gpt-5.2", + }); + }); + + it("parses verbal agent switching", () => { + expect(parseCrestodianOperation("talk to work agent")).toEqual({ + kind: "open-tui", + agentId: "work", + }); + }); + + it("keeps ambiguous model requests read-only", () => { + expect(parseCrestodianOperation("models please")).toEqual({ kind: "models" }); + }); + + it("parses gateway lifecycle operations", () => { + expect(parseCrestodianOperation("gateway status")).toEqual({ kind: "gateway-status" }); + expect(parseCrestodianOperation("restart gateway")).toEqual({ kind: "gateway-restart" }); + expect(parseCrestodianOperation("start gateway")).toEqual({ kind: "gateway-start" }); + expect(parseCrestodianOperation("stop gateway")).toEqual({ kind: "gateway-stop" }); + }); + + it("parses config and doctor repair operations", () => { + expect(parseCrestodianOperation("validate config")).toEqual({ kind: "config-validate" }); + expect(parseCrestodianOperation("config set gateway.port 19001")).toEqual({ + kind: "config-set", + path: "gateway.port", + value: "19001", + }); + expect(parseCrestodianOperation("config set-ref gateway.auth.token env GATEWAY_TOKEN")).toEqual( + { + kind: "config-set-ref", + path: "gateway.auth.token", + source: "env", + id: "GATEWAY_TOKEN", + }, + ); + expect(parseCrestodianOperation("doctor fix")).toEqual({ kind: "doctor-fix" }); + }); + + it("parses agent creation requests", () => { + expect( + parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"), + ).toEqual({ + kind: "create-agent", + agentId: "work", + workspace: "/tmp/work", + model: "openai/gpt-5.2", + }); + expect(parseCrestodianOperation("add agent ops")).toEqual({ + kind: "create-agent", + agentId: "ops", + }); + expect(parseCrestodianOperation("setup workspace /tmp/work model openai/gpt-5.5")).toEqual({ + kind: "setup", + workspace: "/tmp/work", + model: "openai/gpt-5.5", + }); + expect(parseCrestodianOperation("setup agent ops")).toEqual({ + kind: "create-agent", + agentId: "ops", + }); + }); + + it("requires approval before restarting gateway", async () => { + const { runtime, lines } = createRuntime(); + const runGatewayRestart = vi.fn(async () => {}); + + const result = await executeCrestodianOperation({ kind: "gateway-restart" }, runtime, { + deps: { runGatewayRestart }, + }); + + expect(result).toMatchObject({ + applied: false, + message: "Plan: restart the Gateway. Say yes to apply.", + }); + expect(lines.join("\n")).toContain("Plan: restart the Gateway"); + expect(runGatewayRestart).not.toHaveBeenCalled(); + }); + + it("restarts gateway through typed deps and writes an audit entry", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-gateway-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createRuntime(); + const runGatewayRestart = vi.fn(async () => {}); + + await expect( + executeCrestodianOperation({ kind: "gateway-restart" }, runtime, { + approved: true, + deps: { runGatewayRestart }, + auditDetails: { rescue: true, channel: "whatsapp" }, + }), + ).resolves.toMatchObject({ applied: true }); + + expect(runGatewayRestart).toHaveBeenCalledTimes(1); + expect(lines.join("\n")).toContain("[crestodian] done: gateway.restart"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "gateway.restart", + summary: "Restarted Gateway", + details: { rescue: true, channel: "whatsapp" }, + }); + }); + + it("creates agents through typed deps and writes an audit entry", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-agent-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createRuntime(); + const runAgentsAdd = vi.fn(async () => {}); + + await expect( + executeCrestodianOperation( + { + kind: "create-agent", + agentId: "work", + workspace: "/tmp/work", + model: "openai/gpt-5.2", + }, + runtime, + { + approved: true, + deps: { runAgentsAdd }, + auditDetails: { rescue: true, channel: "whatsapp" }, + }, + ), + ).resolves.toMatchObject({ applied: true }); + + expect(runAgentsAdd).toHaveBeenCalledWith( + { + name: "work", + workspace: "/tmp/work", + model: "openai/gpt-5.2", + nonInteractive: true, + }, + runtime, + { hasFlags: true }, + ); + expect(lines.join("\n")).toContain("[crestodian] done: agents.create"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "agents.create", + summary: "Created agent work", + details: { + rescue: true, + channel: "whatsapp", + agentId: "work", + workspace: "/tmp/work", + model: "openai/gpt-5.2", + }, + }); + }); + + it("validates missing config without exiting the process", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-validate-")); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + + await expect( + executeCrestodianOperation({ kind: "config-validate" }, runtime), + ).resolves.toMatchObject({ applied: false }); + + expect(lines.join("\n")).toContain("Config missing:"); + }); + + it("applies config set through typed deps and writes an audit entry", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-config-set-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + const runConfigSet = vi.fn(async () => {}); + + await expect( + executeCrestodianOperation( + { kind: "config-set", path: "gateway.port", value: "19001" }, + runtime, + { + approved: true, + deps: { runConfigSet }, + auditDetails: { rescue: true, channel: "whatsapp" }, + }, + ), + ).resolves.toMatchObject({ applied: true }); + + expect(runConfigSet).toHaveBeenCalledWith({ + path: "gateway.port", + value: "19001", + cliOptions: {}, + }); + expect(lines.join("\n")).toContain("[crestodian] done: config.set"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "config.set", + summary: "Set config gateway.port", + details: { + rescue: true, + channel: "whatsapp", + path: "gateway.port", + }, + }); + }); + + it("applies SecretRef config set through typed deps and writes an audit entry", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-config-ref-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + const runConfigSet = vi.fn(async () => {}); + + await expect( + executeCrestodianOperation( + { + kind: "config-set-ref", + path: "gateway.auth.token", + source: "env", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + runtime, + { + approved: true, + deps: { runConfigSet }, + auditDetails: { rescue: true, channel: "whatsapp" }, + }, + ), + ).resolves.toMatchObject({ applied: true }); + + expect(runConfigSet).toHaveBeenCalledWith({ + path: "gateway.auth.token", + cliOptions: { + refProvider: "default", + refSource: "env", + refId: "OPENCLAW_GATEWAY_TOKEN", + }, + }); + expect(lines.join("\n")).toContain("[crestodian] done: config.setRef"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "config.setRef", + summary: "Set config gateway.auth.token SecretRef", + details: { + rescue: true, + channel: "whatsapp", + path: "gateway.auth.token", + source: "env", + provider: "default", + }, + }); + }); + + it("runs setup bootstrap only after approval and audits it", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-setup-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + vi.stubEnv("OPENAI_API_KEY", "test-key"); + const { runtime, lines } = createRuntime(); + + const plan = await executeCrestodianOperation( + { kind: "setup", workspace: "/tmp/work" }, + runtime, + ); + expect(plan).toMatchObject({ + applied: false, + }); + expect(lines.join("\n")).toContain("Model choice: openai/gpt-5.5 (OPENAI_API_KEY)."); + + await expect( + executeCrestodianOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { + approved: true, + auditDetails: { rescue: true }, + }), + ).resolves.toMatchObject({ applied: true }); + + expect(lines.join("\n")).toContain("[crestodian] done: crestodian.setup"); + const config = JSON.parse( + await fs.readFile(path.join(tempDir, "openclaw.json"), "utf8"), + ) as Record; + expect(config).toMatchObject({ + agents: { + defaults: { + workspace: "/tmp/work", + model: { primary: "openai/gpt-5.5" }, + }, + }, + }); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "crestodian.setup", + summary: "Bootstrapped setup with openai/gpt-5.5", + details: { + rescue: true, + workspace: "/tmp/work", + model: "openai/gpt-5.5", + modelSource: "OPENAI_API_KEY", + }, + }); + }); + + it("runs doctor repairs only after approval and audits them", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-doctor-fix-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + const { runtime, lines } = createRuntime(); + const runDoctor = vi.fn(async () => {}); + + const plan = await executeCrestodianOperation({ kind: "doctor-fix" }, runtime, { + deps: { runDoctor }, + }); + expect(plan).toMatchObject({ + applied: false, + message: "Plan: run doctor repairs. Say yes to apply.", + }); + expect(runDoctor).not.toHaveBeenCalled(); + + await expect( + executeCrestodianOperation({ kind: "doctor-fix" }, runtime, { + approved: true, + deps: { runDoctor }, + auditDetails: { rescue: true }, + }), + ).resolves.toMatchObject({ applied: true }); + + expect(runDoctor).toHaveBeenCalledWith(runtime, { + nonInteractive: true, + repair: true, + yes: true, + }); + expect(lines.join("\n")).toContain("[crestodian] done: doctor.fix"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim().split("\n").at(-1)!); + expect(audit).toMatchObject({ + operation: "doctor.fix", + summary: "Ran doctor repairs", + details: { rescue: true }, + }); + }); + + it("returns from the agent TUI back to Crestodian", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-tui-return-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", path.join(tempDir, "openclaw.json")); + await fs.writeFile( + path.join(tempDir, "openclaw.json"), + JSON.stringify( + { + agents: { + defaults: { model: { primary: "openai/gpt-5.2" } }, + list: [{ id: "main", default: true }, { id: "work" }], + }, + }, + null, + 2, + ), + ); + const { runtime, lines } = createRuntime(); + const runTui = vi.fn(async () => ({ + exitReason: "return-to-crestodian" as const, + crestodianMessage: "restart gateway", + })); + + const result = await executeCrestodianOperation( + { kind: "open-tui", agentId: "work" }, + runtime, + { + deps: { runTui }, + }, + ); + + expect(runTui).toHaveBeenCalledWith({ + local: true, + session: "agent:work:main", + deliver: false, + historyLimit: 200, + }); + expect(result).toMatchObject({ + applied: false, + nextInput: "restart gateway", + }); + expect(lines.join("\n")).toContain( + "[crestodian] returned from agent with request: restart gateway", + ); + }); +}); diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts new file mode 100644 index 00000000000..a495c298513 --- /dev/null +++ b/src/crestodian/operations.ts @@ -0,0 +1,817 @@ +import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; +import type { ConfigSetOptions } from "../cli/config-set-input.js"; +import { doctorCommand } from "../commands/doctor.js"; +import type { DoctorOptions } from "../commands/doctor.types.js"; +import { healthCommand } from "../commands/health.js"; +import { applyDefaultModelPrimaryUpdate } from "../commands/models/shared.js"; +import { statusCommand } from "../commands/status.command.js"; +import { mutateConfigFile, readConfigFileSnapshot } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import type { TuiResult } from "../tui/tui-types.js"; +import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { appendCrestodianAuditEntry, resolveCrestodianAuditPath } from "./audit.js"; +import { formatCrestodianOverview, loadCrestodianOverview } from "./overview.js"; + +export type CrestodianOperation = + | { kind: "none"; message: string } + | { kind: "overview" } + | { kind: "doctor" } + | { kind: "doctor-fix" } + | { kind: "status" } + | { kind: "health" } + | { kind: "config-validate" } + | { kind: "config-set"; path: string; value: string } + | { + kind: "config-set-ref"; + path: string; + source: "env" | "file" | "exec"; + id: string; + provider?: string; + } + | { kind: "setup"; workspace?: string; model?: string } + | { kind: "gateway-status" } + | { kind: "gateway-start" } + | { kind: "gateway-stop" } + | { kind: "gateway-restart" } + | { kind: "agents" } + | { kind: "models" } + | { kind: "audit" } + | { kind: "create-agent"; agentId: string; workspace?: string; model?: string } + | { kind: "open-tui"; agentId?: string; workspace?: string } + | { kind: "set-default-model"; model: string }; + +export type CrestodianOperationResult = { + applied: boolean; + exitsInteractive?: boolean; + message?: string; + nextInput?: string; +}; + +export type CrestodianCommandDeps = { + runAgentsAdd?: ( + opts: { + name?: string; + workspace?: string; + model?: string; + nonInteractive?: boolean; + json?: boolean; + }, + runtime: RuntimeEnv, + params?: { hasFlags?: boolean }, + ) => Promise; + runConfigSet?: (opts: { + path?: string; + value?: string; + cliOptions: ConfigSetOptions; + }) => Promise; + runDoctor?: (runtime: RuntimeEnv, options: DoctorOptions) => Promise; + runGatewayRestart?: () => Promise; + runGatewayStart?: () => Promise; + runGatewayStop?: () => Promise; + runTui?: (opts: { + local: boolean; + session?: string; + deliver?: boolean; + historyLimit?: number; + }) => Promise; +}; + +const SET_MODEL_RE = /(?:set|configure|use)\s+(?:the\s+)?(?:default\s+)?model\s+(.+)/i; +const CONFIGURE_MODELS_RE = /(?:set|configure|use)\s+models?\s+(?\S+)/i; +const CREATE_AGENT_RE = + /(?:create|add|setup|set\s+up)\s+(?:(?:an?|new|my)\s+)?agent\s+(?[a-z0-9_-]+)/i; +const TALK_AGENT_RE = + /(?:talk\s+to|switch\s+to|open|enter)\s+(?:(?:my|the)\s+)?(?:(?[a-z0-9_-]+)\s+)?agent/i; +const WORKSPACE_RE = /(?:workspace|workdir|cwd|for|in)\s+(?"[^"]+"|'[^']+'|\S+)/i; +const MODEL_RE = /\bmodel\s+(?\S+)/i; +const CONFIG_SET_RE = + /^(?:config\s+set|set\s+config)\s+(?[A-Za-z0-9_.[\]-]+)\s+(?.+)$/i; +const CONFIG_SET_REF_RE = + /^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?[A-Za-z0-9_.[\]-]+)\s+(?:(?env|file|exec)\s+)?(?\S+)(?:\s+provider\s+(?[A-Za-z0-9_-]+))?$/i; +const SETUP_RE = + /^(?:setup(?!\s+agent\b)|set\s+me\s+up|set\s+up\s+openclaw|onboard|onboard\s+me|bootstrap|first\s+run)(?:\b|$)/i; + +const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`; +const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-7"; +const CLAUDE_CLI_DEFAULT_MODEL_REF = "claude-cli/claude-opus-4-7"; +const CODEX_CLI_DEFAULT_MODEL_REF = "codex-cli/gpt-5.5"; + +export function parseCrestodianOperation(input: string): CrestodianOperation { + const trimmed = input.trim(); + const lower = trimmed.toLowerCase(); + if (!trimmed) { + return { + kind: "none", + message: "Tiny claw tap: say status, doctor, models, agents, or talk to agent.", + }; + } + if (["help", "?", "overview", "system"].includes(lower)) { + return { kind: "overview" }; + } + if (lower === "audit" || lower.includes("audit log")) { + return { kind: "audit" }; + } + const configSetRefMatch = trimmed.match(CONFIG_SET_REF_RE); + if (configSetRefMatch?.groups?.path && configSetRefMatch.groups.id?.trim()) { + const source = configSetRefMatch.groups.source?.toLowerCase() ?? "env"; + return { + kind: "config-set-ref", + path: configSetRefMatch.groups.path, + source: source as "env" | "file" | "exec", + id: configSetRefMatch.groups.id.trim(), + ...(configSetRefMatch.groups.provider ? { provider: configSetRefMatch.groups.provider } : {}), + }; + } + const configSetMatch = trimmed.match(CONFIG_SET_RE); + if (configSetMatch?.groups?.path && configSetMatch.groups.value?.trim()) { + return { + kind: "config-set", + path: configSetMatch.groups.path, + value: configSetMatch.groups.value.trim(), + }; + } + if ( + lower === "config validate" || + lower === "validate config" || + lower.includes("validate config") + ) { + return { kind: "config-validate" }; + } + if (SETUP_RE.test(lower)) { + const workspace = trimShellishToken(trimmed.match(WORKSPACE_RE)?.groups?.workspace); + const model = trimmed.match(MODEL_RE)?.groups?.model; + return { + kind: "setup", + ...(workspace ? { workspace } : {}), + ...(model ? { model } : {}), + }; + } + if (lower.includes("doctor")) { + if (lower.includes("fix") || lower.includes("repair")) { + return { kind: "doctor-fix" }; + } + return { kind: "doctor" }; + } + if (lower.includes("health")) { + return { kind: "health" }; + } + if (lower.includes("gateway")) { + if (lower.includes("restart")) { + return { kind: "gateway-restart" }; + } + if (lower.includes("start")) { + return { kind: "gateway-start" }; + } + if (lower.includes("stop")) { + return { kind: "gateway-stop" }; + } + return { kind: "gateway-status" }; + } + if (lower.includes("status")) { + return { kind: "status" }; + } + if (lower.includes("agent")) { + const createMatch = trimmed.match(CREATE_AGENT_RE); + if (createMatch?.groups?.agent) { + const workspace = trimShellishToken(trimmed.match(WORKSPACE_RE)?.groups?.workspace); + const model = trimmed.match(MODEL_RE)?.groups?.model; + return { + kind: "create-agent", + agentId: normalizeAgentId(createMatch.groups.agent), + ...(workspace ? { workspace } : {}), + ...(model ? { model } : {}), + }; + } + const talkMatch = trimmed.match(TALK_AGENT_RE); + if (talkMatch) { + const workspace = trimShellishToken(trimmed.match(WORKSPACE_RE)?.groups?.workspace); + return { + kind: "open-tui", + agentId: talkMatch.groups?.agent, + ...(workspace ? { workspace } : {}), + }; + } + return { kind: "agents" }; + } + if (lower.includes("model")) { + const match = trimmed.match(SET_MODEL_RE); + const pluralMatch = trimmed.match(CONFIGURE_MODELS_RE); + const model = match?.[1]?.trim() ?? pluralMatch?.groups?.model?.trim(); + if (model) { + return { kind: "set-default-model", model }; + } + return { kind: "models" }; + } + if (lower === "tui" || lower.includes("open tui") || lower.includes("chat")) { + return { kind: "open-tui" }; + } + if (lower === "quit" || lower === "exit") { + return { kind: "none", message: "Crestodian retracts into shell. Bye." }; + } + return { + kind: "none", + message: + "I can run doctor/status/health, check or restart Gateway, list agents/models, set default model, show audit, or switch to your agent TUI.", + }; +} + +function trimShellishToken(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) { + return undefined; + } + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim() || undefined; + } + return trimmed; +} + +export function isPersistentCrestodianOperation(operation: CrestodianOperation): boolean { + return ( + operation.kind === "set-default-model" || + operation.kind === "config-set" || + operation.kind === "config-set-ref" || + operation.kind === "setup" || + operation.kind === "doctor-fix" || + operation.kind === "create-agent" || + operation.kind === "gateway-start" || + operation.kind === "gateway-stop" || + operation.kind === "gateway-restart" + ); +} + +export function describeCrestodianPersistentOperation(operation: CrestodianOperation): string { + switch (operation.kind) { + case "set-default-model": + return `set agents.defaults.model.primary to ${operation.model}`; + case "config-set": + return `set config ${operation.path} to ${formatConfigSetValueForPlan(operation.path, operation.value)}`; + case "config-set-ref": + return `set config ${operation.path} to ${operation.source} SecretRef ${operation.source === "env" ? operation.id : ""}`; + case "setup": + return formatSetupPlanDescription(operation); + case "doctor-fix": + return "run doctor repairs"; + case "create-agent": + return `create agent ${operation.agentId} with workspace ${formatCreateAgentWorkspace(operation.workspace)}`; + case "gateway-start": + return "start the Gateway"; + case "gateway-stop": + return "stop the Gateway"; + case "gateway-restart": + return "restart the Gateway"; + default: + return "apply this action"; + } +} + +export function formatCrestodianPersistentPlan(operation: CrestodianOperation): string { + return `Plan: ${describeCrestodianPersistentOperation(operation)}. Say yes to apply.`; +} + +function formatCreateAgentWorkspace(workspace: string | undefined): string { + return workspace ? shortenHomePath(resolveUserPath(workspace)) : shortenHomePath(process.cwd()); +} + +function formatConfigSetValueForPlan(configPath: string, value: string): string { + if (/(secret|token|password|key|credential)/i.test(configPath)) { + return ""; + } + return value; +} + +function formatSetupPlanDescription( + operation: Extract, +): string { + const workspace = shortenHomePath(resolveUserPath(operation.workspace ?? process.cwd())); + const model = operation.model ? ` and default model ${operation.model}` : ""; + return `bootstrap OpenClaw setup for workspace ${workspace}${model}`; +} + +function chooseSetupModel( + overview: Awaited>, + requestedModel: string | undefined, +): { model?: string; source: string } { + if (requestedModel?.trim()) { + return { model: requestedModel.trim(), source: "requested" }; + } + if (overview.defaultModel) { + return { source: "existing default model" }; + } + if (overview.tools.apiKeys.openai) { + return { model: OPENAI_API_DEFAULT_MODEL_REF, source: "OPENAI_API_KEY" }; + } + if (overview.tools.apiKeys.anthropic) { + return { model: ANTHROPIC_API_DEFAULT_MODEL_REF, source: "ANTHROPIC_API_KEY" }; + } + if (overview.tools.claude.found) { + return { model: CLAUDE_CLI_DEFAULT_MODEL_REF, source: "Claude Code CLI" }; + } + if (overview.tools.codex.found) { + return { model: CODEX_CLI_DEFAULT_MODEL_REF, source: "Codex CLI" }; + } + return { source: "none" }; +} + +function logQueued(runtime: RuntimeEnv, operation: string): void { + runtime.log(`[crestodian] queued: ${operation}`); + runtime.log(`[crestodian] running: ${operation}`); +} + +function formatGatewayStatusLine( + overview: Awaited>, +): string { + return [ + `Gateway: ${overview.gateway.reachable ? "reachable" : "not reachable"}`, + `URL: ${overview.gateway.url}`, + `Source: ${overview.gateway.source}`, + overview.gateway.error ? `Note: ${overview.gateway.error}` : undefined, + ] + .filter((line): line is string => line !== undefined) + .join("\n"); +} + +async function runGatewayLifecycle(operation: "start" | "stop" | "restart"): Promise { + const lifecycle = await import("../cli/daemon-cli/lifecycle.js"); + if (operation === "start") { + await lifecycle.runDaemonStart(); + return; + } + if (operation === "stop") { + await lifecycle.runDaemonStop(); + return; + } + await lifecycle.runDaemonRestart(); +} + +function formatConfigValidationLine( + snapshot: Awaited>, +): string { + if (!snapshot.exists) { + return `Config missing: ${shortenHomePath(snapshot.path)}`; + } + if (snapshot.valid) { + return `Config valid: ${shortenHomePath(snapshot.path)}`; + } + return [ + `Config invalid: ${shortenHomePath(snapshot.path)}`, + ...snapshot.issues.map((issue) => { + const issuePath = issue.path ? `${issue.path}: ` : ""; + return ` - ${issuePath}${issue.message}`; + }), + ].join("\n"); +} + +function createNoExitRuntime(runtime: RuntimeEnv): RuntimeEnv { + return { + ...runtime, + exit: (code) => { + throw new Error(`operation exited with code ${code}`); + }, + }; +} + +async function resolveTuiAgentId(params: { + requestedAgentId: string | undefined; + requestedWorkspace?: string; +}): Promise { + const overview = await loadCrestodianOverview(); + const workspace = params.requestedWorkspace + ? resolveUserPath(params.requestedWorkspace) + : undefined; + if (workspace) { + const workspaceMatch = overview.agents.find((agent) => { + return agent.workspace ? resolveUserPath(agent.workspace) === workspace : false; + }); + if (workspaceMatch) { + return workspaceMatch.id; + } + } + if (!params.requestedAgentId?.trim()) { + return overview.defaultAgentId; + } + const requested = normalizeAgentId(params.requestedAgentId); + const match = overview.agents.find((agent) => { + return ( + normalizeAgentId(agent.id) === requested || + (agent.name ? normalizeAgentId(agent.name) === requested : false) + ); + }); + return match?.id ?? requested; +} + +export async function executeCrestodianOperation( + operation: CrestodianOperation, + runtime: RuntimeEnv, + opts: { + approved?: boolean; + deps?: CrestodianCommandDeps; + auditDetails?: Record; + } = {}, +): Promise { + if (operation.kind === "none") { + runtime.log(operation.message); + return { applied: false, exitsInteractive: operation.message.includes("Bye.") }; + } + if (operation.kind === "overview") { + const overview = await loadCrestodianOverview(); + runtime.log(formatCrestodianOverview(overview)); + return { applied: false }; + } + if (operation.kind === "agents") { + const overview = await loadCrestodianOverview(); + runtime.log( + [ + "Agents:", + ...overview.agents.map((agent) => { + const bits = [ + agent.id, + agent.isDefault ? "default" : undefined, + agent.name ? `name=${agent.name}` : undefined, + agent.workspace + ? `workspace=${shortenHomePath(resolveUserPath(agent.workspace))}` + : undefined, + ].filter(Boolean); + return ` - ${bits.join(" | ")}`; + }), + ].join("\n"), + ); + return { applied: false }; + } + if (operation.kind === "models") { + const overview = await loadCrestodianOverview(); + runtime.log( + [ + `Default model: ${overview.defaultModel ?? "not configured"}`, + `Codex: ${overview.tools.codex.found ? "found" : "not found"}`, + `Claude Code: ${overview.tools.claude.found ? "found" : "not found"}`, + `OpenAI key: ${overview.tools.apiKeys.openai ? "found" : "not found"}`, + `Anthropic key: ${overview.tools.apiKeys.anthropic ? "found" : "not found"}`, + ].join("\n"), + ); + return { applied: false }; + } + if (operation.kind === "audit") { + runtime.log(`Audit log: ${resolveCrestodianAuditPath()}`); + runtime.log("Only applied writes/actions are recorded; discovery stays quiet."); + return { applied: false }; + } + if (operation.kind === "config-validate") { + const snapshot = await readConfigFileSnapshot(); + runtime.log(formatConfigValidationLine(snapshot)); + return { applied: false }; + } + if (operation.kind === "setup") { + const overview = await loadCrestodianOverview(); + const setupModel = chooseSetupModel(overview, operation.model); + if (!opts.approved) { + const message = [ + formatCrestodianPersistentPlan(operation), + setupModel.model + ? `Model choice: ${setupModel.model} (${setupModel.source}).` + : setupModel.source === "existing default model" + ? `Model choice: keep existing default ${overview.defaultModel}.` + : "Model choice: none found yet. I will only set the workspace; install/login Codex or Claude Code, or set OPENAI_API_KEY/ANTHROPIC_API_KEY, then run setup again.", + ].join("\n"); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "crestodian.setup"); + const before = await readConfigFileSnapshot(); + const workspace = resolveUserPath(operation.workspace ?? process.cwd()); + const result = await mutateConfigFile({ + base: "source", + mutate: (cfg) => { + let next = cfg; + if (setupModel.model) { + next = applyDefaultModelPrimaryUpdate({ + cfg: next, + modelRaw: setupModel.model, + field: "model", + }); + } + next = { + ...next, + agents: { + ...next.agents, + defaults: { + ...next.agents?.defaults, + workspace, + }, + }, + }; + Object.assign(cfg, next); + }, + }); + const after = await readConfigFileSnapshot(); + await appendCrestodianAuditEntry({ + operation: "crestodian.setup", + summary: setupModel.model + ? `Bootstrapped setup with ${setupModel.model}` + : "Bootstrapped setup workspace", + configPath: result.path, + configHashBefore: before.hash ?? result.previousHash, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + workspace, + modelSource: setupModel.source, + ...(setupModel.model ? { model: setupModel.model } : {}), + }, + }); + runtime.log(`Updated ${result.path}`); + runtime.log(`Workspace: ${shortenHomePath(workspace)}`); + if (setupModel.model) { + runtime.log(`Default model: ${setupModel.model} (${setupModel.source})`); + } else if (overview.defaultModel) { + runtime.log(`Default model: ${overview.defaultModel} (kept)`); + } else { + runtime.log("Default model: not configured yet"); + } + runtime.log("[crestodian] done: crestodian.setup"); + return { applied: true }; + } + if (operation.kind === "config-set") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "config.set"); + const before = await readConfigFileSnapshot(); + const runConfigSet = + opts.deps?.runConfigSet ?? + (async (setOpts: { path?: string; value?: string; cliOptions: ConfigSetOptions }) => { + const { runConfigSet: importedRunConfigSet } = await import("../cli/config-cli.js"); + await importedRunConfigSet({ + ...setOpts, + runtime: createNoExitRuntime(runtime), + }); + }); + await runConfigSet({ + path: operation.path, + value: operation.value, + cliOptions: {}, + }); + const after = await readConfigFileSnapshot(); + await appendCrestodianAuditEntry({ + operation: "config.set", + summary: `Set config ${operation.path}`, + configPath: after.path || before.path || undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + path: operation.path, + }, + }); + runtime.log("[crestodian] done: config.set"); + return { applied: true }; + } + if (operation.kind === "config-set-ref") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "config.setRef"); + const before = await readConfigFileSnapshot(); + const runConfigSet = + opts.deps?.runConfigSet ?? + (async (setOpts: { path?: string; value?: string; cliOptions: ConfigSetOptions }) => { + const { runConfigSet: importedRunConfigSet } = await import("../cli/config-cli.js"); + await importedRunConfigSet({ + ...setOpts, + runtime: createNoExitRuntime(runtime), + }); + }); + await runConfigSet({ + path: operation.path, + cliOptions: { + refProvider: operation.provider ?? "default", + refSource: operation.source, + refId: operation.id, + }, + }); + const after = await readConfigFileSnapshot(); + await appendCrestodianAuditEntry({ + operation: "config.setRef", + summary: `Set config ${operation.path} SecretRef`, + configPath: after.path || before.path || undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + path: operation.path, + source: operation.source, + provider: operation.provider ?? "default", + }, + }); + runtime.log("[crestodian] done: config.setRef"); + return { applied: true }; + } + if (operation.kind === "create-agent") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "agents.create"); + const before = await readConfigFileSnapshot(); + const workspace = resolveUserPath(operation.workspace ?? process.cwd()); + const runAgentsAdd = + opts.deps?.runAgentsAdd ?? + (await import("../commands/agents.commands.add.js")).agentsAddCommand; + await runAgentsAdd( + { + name: operation.agentId, + workspace, + ...(operation.model ? { model: operation.model } : {}), + nonInteractive: true, + }, + runtime, + { hasFlags: true }, + ); + const after = await readConfigFileSnapshot(); + await appendCrestodianAuditEntry({ + operation: "agents.create", + summary: `Created agent ${operation.agentId}`, + configPath: after.path || before.path || undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + agentId: operation.agentId, + workspace, + ...(operation.model ? { model: operation.model } : {}), + }, + }); + runtime.log("[crestodian] done: agents.create"); + return { applied: true }; + } + if (operation.kind === "doctor") { + logQueued(runtime, "doctor"); + const runDoctor = opts.deps?.runDoctor ?? doctorCommand; + await runDoctor(runtime, { nonInteractive: true }); + runtime.log("[crestodian] done: doctor"); + return { applied: false }; + } + if (operation.kind === "doctor-fix") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "doctor.fix"); + const before = await readConfigFileSnapshot(); + const runDoctor = opts.deps?.runDoctor ?? doctorCommand; + await runDoctor(runtime, { nonInteractive: true, repair: true, yes: true }); + const after = await readConfigFileSnapshot(); + await appendCrestodianAuditEntry({ + operation: "doctor.fix", + summary: "Ran doctor repairs", + configPath: after.path || before.path || undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: opts.auditDetails, + }); + runtime.log("[crestodian] done: doctor.fix"); + return { applied: true }; + } + if (operation.kind === "status") { + logQueued(runtime, "status.check"); + await statusCommand({ timeoutMs: 10_000 }, runtime); + runtime.log("[crestodian] done: status.check"); + return { applied: false }; + } + if (operation.kind === "health") { + logQueued(runtime, "health.check"); + await healthCommand({ timeoutMs: 10_000 }, runtime); + runtime.log("[crestodian] done: health.check"); + return { applied: false }; + } + if (operation.kind === "gateway-status") { + const overview = await loadCrestodianOverview(); + runtime.log(formatGatewayStatusLine(overview)); + return { applied: false }; + } + if (operation.kind === "gateway-start") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "gateway.start"); + const runGatewayStart = opts.deps?.runGatewayStart ?? (() => runGatewayLifecycle("start")); + await runGatewayStart(); + await appendCrestodianAuditEntry({ + operation: "gateway.start", + summary: "Started Gateway", + details: opts.auditDetails, + }); + runtime.log("[crestodian] done: gateway.start"); + return { applied: true }; + } + if (operation.kind === "gateway-stop") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "gateway.stop"); + const runGatewayStop = opts.deps?.runGatewayStop ?? (() => runGatewayLifecycle("stop")); + await runGatewayStop(); + await appendCrestodianAuditEntry({ + operation: "gateway.stop", + summary: "Stopped Gateway", + details: opts.auditDetails, + }); + runtime.log("[crestodian] done: gateway.stop"); + return { applied: true }; + } + if (operation.kind === "gateway-restart") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "gateway.restart"); + const runGatewayRestart = + opts.deps?.runGatewayRestart ?? (() => runGatewayLifecycle("restart")); + await runGatewayRestart(); + await appendCrestodianAuditEntry({ + operation: "gateway.restart", + summary: "Restarted Gateway", + details: opts.auditDetails, + }); + runtime.log("[crestodian] done: gateway.restart"); + return { applied: true }; + } + if (operation.kind === "open-tui") { + logQueued(runtime, "tui.open"); + const agentId = await resolveTuiAgentId({ + requestedAgentId: operation.agentId, + requestedWorkspace: operation.workspace, + }); + const session = agentId ? buildAgentMainSessionKey({ agentId }) : undefined; + const runTui = opts.deps?.runTui ?? (await import("../tui/tui.js")).runTui; + const result = await runTui({ local: true, session, deliver: false, historyLimit: 200 }); + if (result?.exitReason === "return-to-crestodian") { + runtime.log( + result.crestodianMessage + ? `[crestodian] returned from agent with request: ${result.crestodianMessage}` + : "[crestodian] returned from agent", + ); + return { + applied: false, + nextInput: result.crestodianMessage, + }; + } + return { applied: false, exitsInteractive: true }; + } + if (operation.kind === "set-default-model") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "config.setDefaultModel"); + const before = await readConfigFileSnapshot(); + const result = await mutateConfigFile({ + base: "source", + mutate: (cfg) => { + const next = applyDefaultModelPrimaryUpdate({ + cfg, + modelRaw: operation.model, + field: "model", + }); + Object.assign(cfg, next); + }, + }); + const after = await readConfigFileSnapshot(); + await appendCrestodianAuditEntry({ + operation: "config.setDefaultModel", + summary: `Set default model to ${operation.model}`, + configPath: result.path, + configHashBefore: before.hash ?? result.previousHash, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + requestedModel: operation.model, + effectiveModel: resolveAgentModelPrimaryValue(result.nextConfig.agents?.defaults?.model), + }, + }); + runtime.log(`Updated ${result.path}`); + runtime.log( + `Default model: ${resolveAgentModelPrimaryValue(result.nextConfig.agents?.defaults?.model) ?? operation.model}`, + ); + runtime.log("[crestodian] done: config.setDefaultModel"); + return { applied: true }; + } + return { applied: false }; +} diff --git a/src/crestodian/overview.test.ts b/src/crestodian/overview.test.ts new file mode 100644 index 00000000000..58591bebcb0 --- /dev/null +++ b/src/crestodian/overview.test.ts @@ -0,0 +1,78 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetConfigRuntimeState } from "../config/config.js"; + +vi.mock("./probes.js", () => ({ + probeLocalCommand: vi.fn(async (command: string) => ({ + command, + found: command === "codex", + version: command === "codex" ? "codex 1.0.0" : undefined, + })), + probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), +})); + +describe("loadCrestodianOverview", () => { + const previousStateDir = process.env.OPENCLAW_STATE_DIR; + const previousTestFast = process.env.OPENCLAW_TEST_FAST; + + afterEach(() => { + resetConfigRuntimeState(); + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } + if (previousTestFast === undefined) { + delete process.env.OPENCLAW_TEST_FAST; + } else { + process.env.OPENCLAW_TEST_FAST = previousTestFast; + } + }); + + it("summarizes config, agents, model, tools, and gateway", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-overview-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + await fs.writeFile( + path.join(tempDir, "openclaw.json"), + `${JSON.stringify( + { + agents: { + defaults: { model: { primary: "openai/gpt-5.2" } }, + list: [ + { id: "main", default: true }, + { id: "work", name: "Work" }, + ], + }, + gateway: { port: 19001 }, + }, + null, + 2, + )}\n`, + ); + + const { formatCrestodianOverview, loadCrestodianOverview } = await import("./overview.js"); + const overview = await loadCrestodianOverview(); + + expect(overview.config).toMatchObject({ + exists: true, + valid: true, + }); + expect(overview.defaultAgentId).toBe("main"); + expect(overview.defaultModel).toBe("openai/gpt-5.2"); + expect(overview.agents.map((agent) => agent.id)).toEqual(["main", "work"]); + expect(overview.tools.codex.found).toBe(true); + expect(overview.tools.claude.found).toBe(false); + expect(overview.gateway).toMatchObject({ + url: "ws://127.0.0.1:19001", + reachable: false, + }); + expect(overview.references.docsPath).toMatch(/docs$/); + expect(overview.references.sourceUrl).toBe("https://github.com/openclaw/openclaw"); + expect(formatCrestodianOverview(overview)).toContain( + 'Next: say "gateway status" or "restart gateway"', + ); + }); +}); diff --git a/src/crestodian/overview.ts b/src/crestodian/overview.ts new file mode 100644 index 00000000000..ba8640445e3 --- /dev/null +++ b/src/crestodian/overview.ts @@ -0,0 +1,252 @@ +import { + listAgentEntries, + resolveAgentEffectiveModelPrimary, + resolveDefaultAgentId, +} from "../agents/agent-scope.js"; +import { + OPENCLAW_DOCS_URL, + OPENCLAW_SOURCE_URL, + resolveOpenClawReferencePaths, +} from "../agents/docs-path.js"; +import { + readConfigFileSnapshot, + resolveConfigPath, + resolveGatewayPort, + type ConfigFileSnapshot, + type OpenClawConfig, +} from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { probeGatewayUrl, probeLocalCommand, type LocalCommandProbe } from "./probes.js"; + +export type CrestodianAgentSummary = { + id: string; + name?: string; + isDefault: boolean; + model?: string; + workspace?: string; +}; + +export type CrestodianOverview = { + config: { + path: string; + exists: boolean; + valid: boolean; + issues: string[]; + hash: string | null; + }; + agents: CrestodianAgentSummary[]; + defaultAgentId: string; + defaultModel?: string; + tools: { + codex: LocalCommandProbe; + claude: LocalCommandProbe; + apiKeys: { + openai: boolean; + anthropic: boolean; + }; + }; + gateway: { + url: string; + source: string; + reachable: boolean; + error?: string; + }; + references: { + docsPath?: string; + docsUrl: string; + sourcePath?: string; + sourceUrl: string; + }; +}; + +function issueMessages(snapshot: ConfigFileSnapshot): string[] { + return snapshot.issues.map((issue) => { + const path = issue.path ? `${issue.path}: ` : ""; + return `${path}${issue.message}`; + }); +} + +function buildAgentSummaries(cfg: OpenClawConfig): CrestodianAgentSummary[] { + const defaultAgentId = resolveDefaultAgentId(cfg); + const entries = listAgentEntries(cfg); + if (entries.length === 0) { + return [ + { + id: defaultAgentId, + isDefault: true, + model: resolveAgentEffectiveModelPrimary(cfg, defaultAgentId), + }, + ]; + } + const seen = new Set(); + const summaries: CrestodianAgentSummary[] = []; + for (const entry of entries) { + const id = normalizeAgentId(entry.id); + if (seen.has(id)) { + continue; + } + seen.add(id); + const summary: CrestodianAgentSummary = { + id, + isDefault: id === defaultAgentId, + }; + if (typeof entry.name === "string") { + summary.name = entry.name; + } + const model = resolveAgentEffectiveModelPrimary(cfg, id); + if (model) { + summary.model = model; + } + if (typeof entry.workspace === "string") { + summary.workspace = entry.workspace; + } + summaries.push(summary); + } + return summaries; +} + +export async function loadCrestodianOverview( + opts: { env?: NodeJS.ProcessEnv } = {}, +): Promise { + const env = opts.env ?? process.env; + const snapshot = await readConfigFileSnapshot(); + const cfg = snapshot.runtimeConfig ?? snapshot.sourceConfig ?? {}; + const defaultAgentId = resolveDefaultAgentId(cfg); + const defaultModel = + resolveAgentEffectiveModelPrimary(cfg, defaultAgentId) ?? + resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model); + const configPath = snapshot.path || resolveConfigPath(env); + let gatewayUrl = `ws://127.0.0.1:${resolveGatewayPort(cfg, env)}`; + let gatewaySource = "local loopback"; + let gatewayError: string | undefined; + try { + const details = buildGatewayConnectionDetails({ config: cfg, configPath }); + gatewayUrl = details.url; + gatewaySource = details.urlSource; + gatewayError = details.remoteFallbackNote; + } catch (err) { + gatewayError = err instanceof Error ? err.message : String(err); + } + const [codex, claude, gateway, references] = await Promise.all([ + probeLocalCommand("codex"), + probeLocalCommand("claude"), + probeGatewayUrl(gatewayUrl), + resolveOpenClawReferencePaths({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url, + }), + ]); + return { + config: { + path: configPath, + exists: snapshot.exists, + valid: snapshot.valid, + issues: issueMessages(snapshot), + hash: snapshot.hash ?? null, + }, + agents: buildAgentSummaries(cfg), + defaultAgentId, + defaultModel, + tools: { + codex, + claude, + apiKeys: { + openai: Boolean(env.OPENAI_API_KEY?.trim()), + anthropic: Boolean(env.ANTHROPIC_API_KEY?.trim()), + }, + }, + gateway: { + url: gateway.url, + source: gatewaySource, + reachable: gateway.reachable, + error: gateway.error ?? gatewayError, + }, + references: { + docsPath: references.docsPath ?? undefined, + docsUrl: OPENCLAW_DOCS_URL, + sourcePath: references.sourcePath ?? undefined, + sourceUrl: OPENCLAW_SOURCE_URL, + }, + }; +} + +function formatCommandProbe(probe: LocalCommandProbe): string { + if (!probe.found) { + return "not found"; + } + if (probe.version) { + return probe.version; + } + return probe.error ? `found (${probe.error})` : "found"; +} + +export function formatCrestodianOverview(overview: CrestodianOverview): string { + const agentLines = overview.agents.map((agent) => { + const bits = [ + agent.id, + agent.isDefault ? "default" : undefined, + agent.name ? `name=${agent.name}` : undefined, + agent.model ? `model=${agent.model}` : undefined, + agent.workspace ? `workspace=${agent.workspace}` : undefined, + ].filter(Boolean); + return ` - ${bits.join(" | ")}`; + }); + const configStatus = overview.config.valid + ? overview.config.exists + ? "valid" + : "missing (configless rescue mode)" + : "invalid"; + const issueLines = + overview.config.issues.length > 0 + ? ["Config issues:", ...overview.config.issues.map((issue) => ` - ${issue}`)] + : []; + return [ + "Crestodian online. Little claws, typed tools.", + "", + `Config: ${configStatus}`, + `Path: ${overview.config.path}`, + `Default agent: ${overview.defaultAgentId}`, + `Default model: ${overview.defaultModel ?? "not configured"}`, + "Agents:", + ...agentLines, + `Codex: ${formatCommandProbe(overview.tools.codex)}`, + `Claude Code: ${formatCommandProbe(overview.tools.claude)}`, + `API keys: OpenAI ${overview.tools.apiKeys.openai ? "found" : "not found"}, Anthropic ${ + overview.tools.apiKeys.anthropic ? "found" : "not found" + }`, + `Planner: ${ + overview.defaultModel + ? `model-assisted via ${overview.defaultModel} for fuzzy local commands` + : "deterministic only until a model is configured" + }`, + `Docs: ${overview.references.docsPath ?? overview.references.docsUrl}`, + overview.references.sourcePath + ? `Source: ${overview.references.sourcePath}` + : `Source: ${overview.references.sourceUrl}`, + `Gateway: ${overview.gateway.reachable ? "reachable" : "not reachable"} (${overview.gateway.url}, ${overview.gateway.source})`, + overview.gateway.error ? `Gateway note: ${overview.gateway.error}` : undefined, + `Next: ${recommendCrestodianNextStep(overview)}`, + ...issueLines, + ] + .filter((line): line is string => line !== undefined) + .join("\n"); +} + +export function recommendCrestodianNextStep(overview: CrestodianOverview): string { + if (!overview.config.exists) { + return 'say "setup" to create a starter config'; + } + if (!overview.config.valid) { + return 'say "validate config" or "doctor" to inspect the config'; + } + if (!overview.defaultModel) { + return 'say "setup" or "set default model "'; + } + if (!overview.gateway.reachable) { + return 'say "gateway status" or "restart gateway"'; + } + return 'say "talk to agent" to enter your default agent'; +} diff --git a/src/crestodian/probes.ts b/src/crestodian/probes.ts new file mode 100644 index 00000000000..703c9f6f713 --- /dev/null +++ b/src/crestodian/probes.ts @@ -0,0 +1,85 @@ +import { spawn } from "node:child_process"; + +export type LocalCommandProbe = { + command: string; + found: boolean; + version?: string; + error?: string; +}; + +export async function probeLocalCommand( + command: string, + args: string[] = ["--version"], + opts: { timeoutMs?: number } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 1_500; + return await new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + const child = spawn(command, args, { + stdio: ["ignore", "pipe", "pipe"], + }); + const finish = (result: LocalCommandProbe) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + finish({ command, found: true, error: `timed out after ${timeoutMs}ms` }); + }, timeoutMs); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += String(chunk); + }); + child.stderr.on("data", (chunk) => { + stderr += String(chunk); + }); + child.on("error", (err: NodeJS.ErrnoException) => { + finish({ + command, + found: err.code !== "ENOENT", + error: err.code === "ENOENT" ? "not found" : err.message, + }); + }); + child.on("close", (code) => { + const text = `${stdout}\n${stderr}`.trim().split(/\r?\n/)[0]?.trim(); + finish({ + command, + found: code === 0 || Boolean(text), + version: text || undefined, + error: code === 0 ? undefined : `exited ${String(code)}`, + }); + }); + }); +} + +export async function probeGatewayUrl( + url: string, + opts: { timeoutMs?: number } = {}, +): Promise<{ reachable: boolean; url: string; error?: string }> { + const httpUrl = url.replace(/^ws:/, "http:").replace(/^wss:/, "https:"); + const healthUrl = new URL("/healthz", httpUrl).toString(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? 900); + try { + const response = await fetch(healthUrl, { + method: "GET", + signal: controller.signal, + }); + return { reachable: response.ok, url, error: response.ok ? undefined : response.statusText }; + } catch (err) { + return { + reachable: false, + url, + error: err instanceof Error ? err.message : String(err), + }; + } finally { + clearTimeout(timeout); + } +} diff --git a/src/crestodian/rescue-message.test.ts b/src/crestodian/rescue-message.test.ts new file mode 100644 index 00000000000..df748cea547 --- /dev/null +++ b/src/crestodian/rescue-message.test.ts @@ -0,0 +1,183 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CommandContext } from "../auto-reply/reply/commands-types.js"; +import { clearConfigCache } from "../config/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { extractCrestodianRescueMessage, runCrestodianRescueMessage } from "./rescue-message.js"; + +const originalStateDir = process.env.OPENCLAW_STATE_DIR; +const originalConfigPath = process.env.OPENCLAW_CONFIG_PATH; + +function commandContext(overrides: Partial = {}): CommandContext { + return { + surface: "whatsapp", + channel: "whatsapp", + channelId: "whatsapp", + ownerList: ["user:owner"], + senderIsOwner: true, + isAuthorizedSender: true, + senderId: "user:owner", + rawBodyNormalized: "/crestodian models", + commandBodyNormalized: "/crestodian models", + from: "user:owner", + to: "account:default", + ...overrides, + }; +} + +async function runRescue( + commandBody: string, + cfg: OpenClawConfig, + ctx = commandContext(), + deps?: Parameters[0]["deps"], +) { + return await runCrestodianRescueMessage({ + cfg, + command: { ...ctx, commandBodyNormalized: commandBody }, + commandBody, + isGroup: false, + deps, + }); +} + +describe("Crestodian rescue message", () => { + afterEach(() => { + clearConfigCache(); + if (originalStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = originalStateDir; + } + if (originalConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = originalConfigPath; + } + }); + + it("recognizes the Crestodian rescue command", () => { + expect(extractCrestodianRescueMessage("/crestodian status")).toBe("status"); + expect(extractCrestodianRescueMessage("/crestodian")).toBe(""); + expect(extractCrestodianRescueMessage("/status")).toBeNull(); + }); + + it("denies rescue when sandboxing is active", async () => { + await expect( + runRescue("/crestodian status", { + crestodian: { rescue: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }), + ).resolves.toContain("sandboxing is active"); + }); + + it("refuses TUI handoff from remote rescue", async () => { + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + const deps = { + runTui: vi.fn(async () => { + throw new Error("remote rescue must not open the TUI"); + }), + }; + + await expect( + runRescue("/crestodian talk to agent", cfg, commandContext(), deps), + ).resolves.toContain("cannot open the local TUI"); + await expect(runRescue("/crestodian chat", cfg, commandContext(), deps)).resolves.toContain( + "cannot open the local TUI", + ); + expect(deps.runTui).not.toHaveBeenCalled(); + }); + + it("queues and applies persistent writes through conversational approval", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-rescue-")); + const configPath = path.join(tempDir, "openclaw.json"); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath); + await fs.writeFile( + configPath, + JSON.stringify( + { + meta: { lastTouchedVersion: "test", lastTouchedAt: new Date(0).toISOString() }, + agents: { defaults: {} }, + }, + null, + 2, + ), + ); + + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + await expect(runRescue("/crestodian set default model openai/gpt-5.2", cfg)).resolves.toContain( + "Reply /crestodian yes to apply", + ); + await expect(runRescue("/crestodian yes", cfg)).resolves.toContain( + "Default model: openai/gpt-5.2", + ); + + const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + expect(config.agents?.defaults?.model).toMatchObject({ primary: "openai/gpt-5.2" }); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit.details).toMatchObject({ + rescue: true, + channel: "whatsapp", + senderId: "user:owner", + }); + }); + + it("queues and applies gateway restart through conversational approval", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-rescue-gateway-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + const deps = { runGatewayRestart: vi.fn(async () => {}) }; + + await expect( + runRescue("/crestodian restart gateway", cfg, commandContext(), deps), + ).resolves.toBe("Plan: restart the Gateway. Reply /crestodian yes to apply."); + await expect(runRescue("/crestodian yes", cfg, commandContext(), deps)).resolves.toContain( + "[crestodian] done: gateway.restart", + ); + + expect(deps.runGatewayRestart).toHaveBeenCalledTimes(1); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "gateway.restart", + details: { + rescue: true, + channel: "whatsapp", + senderId: "user:owner", + }, + }); + }); + + it("queues and applies agent creation through conversational approval", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-rescue-agent-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + const deps = { runAgentsAdd: vi.fn(async () => {}) }; + + await expect( + runRescue("/crestodian create agent work workspace /tmp/work", cfg, commandContext(), deps), + ).resolves.toBe( + "Plan: create agent work with workspace /tmp/work. Reply /crestodian yes to apply.", + ); + await expect(runRescue("/crestodian yes", cfg, commandContext(), deps)).resolves.toContain( + "[crestodian] done: agents.create", + ); + + expect(deps.runAgentsAdd).toHaveBeenCalledTimes(1); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "agents.create", + details: { + rescue: true, + channel: "whatsapp", + senderId: "user:owner", + agentId: "work", + workspace: "/tmp/work", + }, + }); + }); +}); diff --git a/src/crestodian/rescue-message.ts b/src/crestodian/rescue-message.ts new file mode 100644 index 00000000000..7395285d5da --- /dev/null +++ b/src/crestodian/rescue-message.ts @@ -0,0 +1,196 @@ +import { createHash, randomUUID } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { CommandContext } from "../auto-reply/reply/commands-types.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + executeCrestodianOperation, + formatCrestodianPersistentPlan, + isPersistentCrestodianOperation, + parseCrestodianOperation, + type CrestodianCommandDeps, + type CrestodianOperation, +} from "./operations.js"; +import { resolveCrestodianRescuePolicy } from "./rescue-policy.js"; + +type RescuePendingOperation = { + id: string; + createdAt: string; + expiresAt: string; + operation: CrestodianOperation; + auditDetails: Record; +}; + +export type CrestodianRescueMessageInput = { + cfg: OpenClawConfig; + command: CommandContext; + commandBody: string; + agentId?: string; + isGroup: boolean; + env?: NodeJS.ProcessEnv; + deps?: CrestodianCommandDeps; +}; + +const CRESTODIAN_COMMAND = "/crestodian"; +const APPROVAL_RE = /^(yes|y|apply|approve|approved|do it)$/i; + +function createCaptureRuntime(): { runtime: RuntimeEnv; read: () => string } { + const lines: string[] = []; + const push = (...args: unknown[]) => { + lines.push(args.map((arg) => (typeof arg === "string" ? arg : JSON.stringify(arg))).join(" ")); + }; + return { + runtime: { + log: push, + error: push, + exit: (code) => { + throw new Error(`Crestodian operation exited with code ${code}`); + }, + }, + read: () => lines.join("\n").trim(), + }; +} + +export function extractCrestodianRescueMessage(commandBody: string): string | null { + const normalized = commandBody.trim(); + const lower = normalized.toLowerCase(); + if (lower !== CRESTODIAN_COMMAND && !lower.startsWith(`${CRESTODIAN_COMMAND} `)) { + return null; + } + return normalized.slice(CRESTODIAN_COMMAND.length).trim(); +} + +function resolvePendingDir(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveStateDir(env), "crestodian", "rescue-pending"); +} + +function resolvePendingPath(input: CrestodianRescueMessageInput): string { + const key = JSON.stringify({ + channel: input.command.channelId ?? input.command.channel, + from: input.command.from, + senderId: input.command.senderId, + }); + const digest = createHash("sha256").update(key).digest("hex").slice(0, 32); + return path.join(resolvePendingDir(input.env), `${digest}.json`); +} + +async function readPending( + pendingPath: string, + now = new Date(), +): Promise { + try { + const parsed = JSON.parse(await fs.readFile(pendingPath, "utf8")) as RescuePendingOperation; + if (Date.parse(parsed.expiresAt) <= now.getTime()) { + await fs.rm(pendingPath, { force: true }); + return null; + } + return parsed; + } catch { + return null; + } +} + +async function writePending(pendingPath: string, pending: RescuePendingOperation): Promise { + await fs.mkdir(path.dirname(pendingPath), { recursive: true }); + await fs.writeFile(pendingPath, `${JSON.stringify(pending, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.chmod(pendingPath, 0o600).catch(() => { + // Best-effort on platforms/filesystems without POSIX modes. + }); +} + +function buildAuditDetails(input: CrestodianRescueMessageInput): Record { + return { + rescue: true, + channel: input.command.channelId ?? input.command.channel, + accountId: input.command.to, + senderId: input.command.senderId, + from: input.command.from, + }; +} + +function formatPersistentPlan(operation: CrestodianOperation): string { + return formatCrestodianPersistentPlan(operation).replace( + "Say yes to apply.", + "Reply /crestodian yes to apply.", + ); +} + +function formatUnsupportedRemoteOperation(operation: CrestodianOperation): string | null { + if (operation.kind === "open-tui") { + return [ + "Crestodian rescue cannot open the local TUI from a message channel.", + "Use local `openclaw` for agent handoff, or ask for status, doctor, config, gateway, agents, or models.", + ].join(" "); + } + return null; +} + +export async function runCrestodianRescueMessage( + input: CrestodianRescueMessageInput, +): Promise { + const rescueMessage = extractCrestodianRescueMessage(input.commandBody); + if (rescueMessage === null) { + return null; + } + const policy = resolveCrestodianRescuePolicy({ + cfg: input.cfg, + agentId: input.agentId, + senderIsOwner: input.command.senderIsOwner, + isDirectMessage: !input.isGroup, + }); + if (!policy.allowed) { + return policy.message; + } + + const pendingPath = resolvePendingPath(input); + if (APPROVAL_RE.test(rescueMessage)) { + const pending = await readPending(pendingPath); + if (!pending) { + return "No pending Crestodian rescue change is waiting for approval."; + } + const unsupported = formatUnsupportedRemoteOperation(pending.operation); + if (unsupported) { + await fs.rm(pendingPath, { force: true }); + return unsupported; + } + const capture = createCaptureRuntime(); + await executeCrestodianOperation(pending.operation, capture.runtime, { + approved: true, + auditDetails: pending.auditDetails, + deps: input.deps, + }); + await fs.rm(pendingPath, { force: true }); + return capture.read() || "Crestodian rescue change applied."; + } + + const operation = parseCrestodianOperation(rescueMessage); + const unsupported = formatUnsupportedRemoteOperation(operation); + if (unsupported) { + return unsupported; + } + if (isPersistentCrestodianOperation(operation)) { + const now = new Date(); + const expiresAt = new Date(now.getTime() + policy.pendingTtlMinutes * 60_000); + await writePending(pendingPath, { + id: randomUUID(), + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + operation, + auditDetails: buildAuditDetails(input), + }); + return formatPersistentPlan(operation); + } + + const capture = createCaptureRuntime(); + await executeCrestodianOperation(operation, capture.runtime, { + approved: true, + auditDetails: buildAuditDetails(input), + deps: input.deps, + }); + return capture.read() || "Crestodian listened, clicked a claw, and found nothing to change."; +} diff --git a/src/crestodian/rescue-policy.test.ts b/src/crestodian/rescue-policy.test.ts new file mode 100644 index 00000000000..30f0c59e2c6 --- /dev/null +++ b/src/crestodian/rescue-policy.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveCrestodianRescuePolicy } from "./rescue-policy.js"; + +function decide(cfg: OpenClawConfig, overrides = {}) { + return resolveCrestodianRescuePolicy({ + cfg, + senderIsOwner: true, + isDirectMessage: true, + ...overrides, + }); +} + +describe("resolveCrestodianRescuePolicy", () => { + it("allows auto rescue for owner DMs in YOLO host posture with sandboxing off", () => { + expect(decide({}).allowed).toBe(true); + }); + + it("hard-denies rescue when sandboxing is active even if explicitly enabled", () => { + const decision = decide({ + crestodian: { rescue: { enabled: true } }, + agents: { defaults: { sandbox: { mode: "all" } } }, + }); + expect(decision).toMatchObject({ + allowed: false, + reason: "sandbox-active", + }); + }); + + it("keeps auto rescue closed outside YOLO host posture", () => { + const decision = decide({ + tools: { exec: { security: "allowlist", ask: "on-miss" } }, + }); + expect(decision).toMatchObject({ + allowed: false, + reason: "disabled", + }); + }); + + it("requires owner identity and direct messages by default", () => { + expect(decide({}, { senderIsOwner: false })).toMatchObject({ + allowed: false, + reason: "not-owner", + }); + expect(decide({}, { isDirectMessage: false })).toMatchObject({ + allowed: false, + reason: "not-direct-message", + }); + }); + + it("allows explicit group rescue when ownerDmOnly is disabled", () => { + expect( + decide({ crestodian: { rescue: { ownerDmOnly: false } } }, { isDirectMessage: false }) + .allowed, + ).toBe(true); + }); +}); diff --git a/src/crestodian/rescue-policy.ts b/src/crestodian/rescue-policy.ts new file mode 100644 index 00000000000..de1f0860a82 --- /dev/null +++ b/src/crestodian/rescue-policy.ts @@ -0,0 +1,132 @@ +import { resolveAgentConfig } from "../agents/agent-scope.js"; +import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export type CrestodianRescueDecision = + | { + allowed: true; + enabled: true; + ownerDmOnly: boolean; + pendingTtlMinutes: number; + yolo: true; + sandboxActive: false; + } + | { + allowed: false; + enabled: boolean; + ownerDmOnly: boolean; + pendingTtlMinutes: number; + yolo: boolean; + sandboxActive: boolean; + reason: "disabled" | "sandbox-active" | "not-yolo" | "not-owner" | "not-direct-message"; + message: string; + }; + +export type CrestodianRescuePolicyInput = { + cfg: OpenClawConfig; + agentId?: string; + senderIsOwner: boolean; + isDirectMessage: boolean; +}; + +function resolvePendingTtlMinutes(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 15; +} + +function resolveScopedExecConfig(cfg: OpenClawConfig, agentId?: string) { + const agentConfig = agentId ? resolveAgentConfig(cfg, agentId) : undefined; + return agentConfig?.tools?.exec; +} + +function isYoloHostPosture(cfg: OpenClawConfig, agentId?: string): boolean { + const scopedExec = resolveScopedExecConfig(cfg, agentId); + const globalExec = cfg.tools?.exec; + const security = scopedExec?.security ?? globalExec?.security ?? "full"; + const ask = scopedExec?.ask ?? globalExec?.ask ?? "off"; + return security === "full" && ask === "off"; +} + +export function resolveCrestodianRescuePolicy( + input: CrestodianRescuePolicyInput, +): CrestodianRescueDecision { + const rescue = input.cfg.crestodian?.rescue; + const configuredEnabled = rescue?.enabled ?? "auto"; + const ownerDmOnly = rescue?.ownerDmOnly ?? true; + const pendingTtlMinutes = resolvePendingTtlMinutes(rescue?.pendingTtlMinutes); + const sandbox = resolveSandboxConfigForAgent(input.cfg, input.agentId); + const sandboxActive = sandbox.mode !== "off"; + const yolo = !sandboxActive && isYoloHostPosture(input.cfg, input.agentId); + const enabled = configuredEnabled === "auto" ? yolo : configuredEnabled; + + if (!enabled) { + return { + allowed: false, + enabled, + ownerDmOnly, + pendingTtlMinutes, + yolo, + sandboxActive, + reason: "disabled", + message: + "Crestodian rescue is disabled. Set crestodian.rescue.enabled=true or use YOLO host posture with sandboxing off.", + }; + } + if (sandboxActive) { + return { + allowed: false, + enabled, + ownerDmOnly, + pendingTtlMinutes, + yolo, + sandboxActive, + reason: "sandbox-active", + message: + "Crestodian rescue is blocked because OpenClaw sandboxing is active. Fix the install locally or disable sandboxing before using remote rescue.", + }; + } + if (configuredEnabled === "auto" && !yolo) { + return { + allowed: false, + enabled, + ownerDmOnly, + pendingTtlMinutes, + yolo, + sandboxActive, + reason: "not-yolo", + message: + "Crestodian rescue auto-mode only opens in YOLO host posture: tools.exec.security=full, tools.exec.ask=off, and sandboxing off.", + }; + } + if (!input.senderIsOwner) { + return { + allowed: false, + enabled, + ownerDmOnly, + pendingTtlMinutes, + yolo, + sandboxActive, + reason: "not-owner", + message: "Crestodian rescue only accepts commands from an OpenClaw owner.", + }; + } + if (ownerDmOnly && !input.isDirectMessage) { + return { + allowed: false, + enabled, + ownerDmOnly, + pendingTtlMinutes, + yolo, + sandboxActive, + reason: "not-direct-message", + message: "Crestodian rescue is restricted to owner DMs by default.", + }; + } + return { + allowed: true, + enabled: true, + ownerDmOnly, + pendingTtlMinutes, + yolo: true, + sandboxActive: false, + }; +} diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts index 62120dbd2e2..3a5779ae722 100644 --- a/src/tui/commands.test.ts +++ b/src/tui/commands.test.ts @@ -33,8 +33,10 @@ describe("getSlashCommands", () => { const commands = getSlashCommands(); const status = commands.find((command) => command.name === "status"); const gatewayStatus = commands.find((command) => command.name === "gateway-status"); + const crestodian = commands.find((command) => command.name === "crestodian"); expect(status?.description).toBe("Show current status."); expect(gatewayStatus?.description).toBe("Show gateway status summary"); + expect(crestodian?.description).toBe("Return to Crestodian"); }); }); @@ -45,5 +47,6 @@ describe("helpText", () => { expect(output).toContain("/elev "); expect(output).toContain("/gateway-status"); expect(output).toContain("/gwstatus"); + expect(output).toContain("/crestodian [request]"); }); }); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index baa2e07ebd4..119357a3e2f 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -70,6 +70,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman ...(options.local ? [{ name: "auth", description: "Run provider auth/login flow" }] : []), { name: "agent", description: "Switch agent (or open picker)" }, { name: "agents", description: "Open agent picker" }, + { name: "crestodian", description: "Return to Crestodian" }, { name: "session", description: "Switch session (or open picker)" }, { name: "sessions", description: "Open session picker" }, { @@ -161,6 +162,7 @@ export function helpText(options: SlashCommandOptions = {}): string { "/gwstatus", ...(options.local ? ["/auth [provider]"] : []), "/agent (or /agents)", + "/crestodian [request]", "/session (or /sessions)", "/model (or /models)", `/think <${thinkLevels}>`, diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 2f56d81b64c..612659afa37 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -37,12 +37,14 @@ function createHarness(params?: { const refreshSessionInfo = params?.refreshSessionInfo ?? vi.fn().mockResolvedValue(undefined); const applySessionInfoFromPatch = params?.applySessionInfoFromPatch ?? vi.fn(); const setActivityStatus = params?.setActivityStatus ?? (vi.fn() as SetActivityStatusMock); + const requestExit = vi.fn(); const runAuthFlow: RunAuthFlow | undefined = params?.runAuthFlow ?? (params?.opts?.local ? (vi.fn().mockResolvedValue({ exitCode: 0, signal: null }) as unknown as RunAuthFlow) : undefined); const state = { + currentAgentId: "main", currentSessionKey: "agent:main:main", activeChatRunId: params?.activeChatRunId ?? null, pendingOptimisticUserMessage: params?.pendingOptimisticUserMessage ?? false, @@ -72,7 +74,7 @@ function createHarness(params?: { forgetLocalRunId: vi.fn(), forgetLocalBtwRunId: vi.fn(), runAuthFlow, - requestExit: vi.fn(), + requestExit, }); return { @@ -92,6 +94,7 @@ function createHarness(params?: { setActivityStatus, noteLocalRunId, noteLocalBtwRunId, + requestExit, state, }; } @@ -173,6 +176,29 @@ describe("tui command handlers", () => { expect(addSystem).toHaveBeenCalledWith("Version: 1.2.3"); }); + it("returns to Crestodian with an optional request", async () => { + const { handleCommand, addSystem, requestExit, sendChat } = createHarness(); + + await handleCommand("/crestodian restart gateway"); + + expect(sendChat).not.toHaveBeenCalled(); + expect(addSystem).toHaveBeenCalledWith("returning to Crestodian with request: restart gateway"); + expect(requestExit).toHaveBeenCalledWith({ + exitReason: "return-to-crestodian", + crestodianMessage: "restart gateway", + }); + }); + + it("leaves a Crestodian breadcrumb after switching agents", async () => { + const { handleCommand, addSystem, setSession, state } = createHarness(); + + await handleCommand("/agent Work"); + + expect(state.currentAgentId).toBe("work"); + expect(setSession).toHaveBeenCalledWith(""); + expect(addSystem).toHaveBeenCalledWith("agent set to work; use /crestodian to return"); + }); + it("defers local run binding until gateway events provide a real run id", async () => { const { handleCommand, noteLocalRunId, state } = createHarness(); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 8be0ae6b41d..01460743748 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -22,6 +22,7 @@ import { formatStatusSummary } from "./tui-status-summary.js"; import type { AgentSummary, GatewayStatusSummary, + TuiResult, TuiOptions, TuiStateAccess, } from "./tui-types.js"; @@ -50,7 +51,7 @@ type CommandHandlerContext = { runAuthFlow?: (params: { provider?: string; }) => Promise<{ exitCode: number | null; signal: NodeJS.Signals | null }>; - requestExit: () => void; + requestExit: (result?: Partial) => void; }; function isBtwCommand(text: string): boolean { @@ -85,6 +86,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { const setAgent = async (id: string) => { state.currentAgentId = normalizeAgentId(id); await setSession(""); + chatLog.addSystem(`agent set to ${state.currentAgentId}; use /crestodian to return`); }; const closeOverlayAndRender = () => { @@ -329,6 +331,15 @@ export function createCommandHandlers(context: CommandHandlerContext) { case "agents": await openAgentSelector(); break; + case "crestodian": + chatLog.addSystem( + args ? `returning to Crestodian with request: ${args}` : "returning to Crestodian", + ); + requestExit({ + exitReason: "return-to-crestodian", + ...(args ? { crestodianMessage: args } : {}), + }); + break; case "session": if (!args) { await openSessionSelector(); diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts index 547f3b5aa48..2e11a344646 100644 --- a/src/tui/tui-types.ts +++ b/src/tui/tui-types.ts @@ -11,6 +11,13 @@ export type TuiOptions = { message?: string; }; +export type TuiExitReason = "exit" | "return-to-crestodian"; + +export type TuiResult = { + exitReason: TuiExitReason; + crestodianMessage?: string; +}; + export type ChatEvent = { runId: string; sessionKey: string; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 822d9d50c3e..c5cb27c7edb 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -46,6 +46,7 @@ import type { SessionInfo, SessionScope, TuiOptions, + TuiResult, TuiStateAccess, } from "./tui-types.js"; import { buildWaitingStatusMessage, defaultWaitingPhrases } from "./tui-waiting.js"; @@ -283,7 +284,7 @@ export function resolveCtrlCAction(params: { }; } -export async function runTui(opts: TuiOptions) { +export async function runTui(opts: TuiOptions): Promise { const isLocalMode = opts.local === true; const config = loadConfig(); const initialSessionInput = (opts.session ?? "").trim(); @@ -318,6 +319,7 @@ export async function runTui(opts: TuiOptions) { let sessionInfo: SessionInfo = {}; let lastCtrlCAt = 0; let exitRequested = false; + let exitResult: TuiResult = { exitReason: "exit" }; let activityStatus = "idle"; let connectionStatus = isLocalMode ? "starting local runtime" : "connecting"; let statusTimeout: NodeJS.Timeout | null = null; @@ -906,14 +908,19 @@ export async function runTui(opts: TuiOptions) { clearLocalBtwRunIds, }); - const requestExit = () => { + let finishTui: (() => void) | null = null; + const requestExit = (result?: Partial) => { if (exitRequested) { return; } exitRequested = true; + exitResult = { + exitReason: result?.exitReason ?? "exit", + ...(result?.crestodianMessage ? { crestodianMessage: result.crestodianMessage } : {}), + }; client.stop(); void drainAndStopTuiSafely(tui).then(() => { - process.exit(0); + finishTui?.(); }); }; @@ -1113,8 +1120,12 @@ export async function runTui(opts: TuiOptions) { } process.removeListener("SIGINT", sigintHandler); process.removeListener("SIGTERM", sigtermHandler); + process.removeListener("exit", finish); + finishTui = null; resolve(); }; + finishTui = finish; process.once("exit", finish); }); + return exitResult; }