feat(crestodian): run CLI harnesses on the agent loop via a ring-zero MCP server (#100029)

CLI harnesses (claude-cli, gemini-cli) cannot enforce runtime toolsAllow, so
Crestodian previously fell back to the single-turn planner for them. The
openclaw-tools stdio MCP entry can now serve the ring-zero crestodian tool
(OPENCLAW_TOOLS_MCP_TOOLS=crestodian), and Crestodian CLI runs inject it as the
run's exclusive MCP surface: no loopback server, no plugin/user MCP servers,
with the server kept under the "openclaw" name so existing tool pre-approvals
apply. agent-turn routes configured or detected CLI-harness models through
runCliAgent with native session resume across turns, keeps embedded toolsAllow
enforcement for API-key models and the Codex app-server fallback, and still
degrades to the planner when no loop backend works.

The host-verified approval contract survives the process boundary: per-turn
arming and the pending exact-operation hash travel to the stdio server via the
generated MCP config env, and the host mirrors proposal transitions back from
harness tool events (denial registers the hash, mismatch voids it, execution
consumes it) so a generic yes can never authorize a different mutation. The
trust model (the CLI owns its native tools) is documented in
docs/cli/crestodian.md.
This commit is contained in:
Peter Steinberger 2026-07-05 01:59:07 -07:00 committed by GitHub
parent f8dd649485
commit 3957ccba4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 792 additions and 84 deletions

View file

@ -118,15 +118,45 @@ If none are available, setup still writes the default workspace and leaves the m
## Model-assisted planner
Crestodian always starts in deterministic mode. For fuzzy commands the deterministic parser does not understand, it can make one bounded planner turn through OpenClaw's normal runtime paths, using the configured OpenClaw model. If none is usable yet, it falls back to a local runtime already present on the machine:
Interactive Crestodian is AI-first. Exact typed commands run instantly and deterministically. Every other message runs through the same embedded agent loop as regular OpenClaw agents, restricted to one ring-zero `crestodian` tool that wraps the typed operations: read actions run freely, mutations require your conversational yes for that exact operation, and every applied write is audited and re-validated. The agent session persists, so the custodian has real multi-turn memory. It first uses the configured OpenClaw model; with no usable model it falls back to a local runtime already present on the machine:
- Claude Code CLI: `claude-cli/claude-opus-4-8`
- Codex app-server harness: `openai/gpt-5.5`
- Claude Code CLI: `claude-cli/claude-opus-4-8` (agent loop; the ring-zero tool is served over MCP, see the trust model below)
- Codex app-server harness: `openai/gpt-5.5` (agent loop with an enforced single-tool allow-list)
The planner cannot mutate config directly; it must translate the request into one of Crestodian's typed commands, and normal approval/audit rules apply. Crestodian prints the model it used and the interpreted command before running anything. Fallback planner turns are temporary, tool-disabled where the runtime supports it, and use a temporary workspace/session.
When the agent loop is unavailable, Crestodian degrades to a bounded single-turn planner, and without any model to deterministic typed commands. The planner cannot mutate config directly; it must translate the request into one of Crestodian's typed commands, and normal approval/audit rules apply. Crestodian prints the model it used and the interpreted command before running anything. Fallback planner turns are temporary, tool-disabled where the runtime supports it, and use a temporary workspace/session.
Message-channel rescue mode never uses the model-assisted planner. Remote rescue stays deterministic so a broken or compromised normal agent path cannot be used as a config editor.
### CLI harness trust model
Embedded runtimes and the Codex app-server harness enforce the ring-zero
restriction directly: the run carries a tool allow-list with only the
`crestodian` tool. CLI harnesses (Claude Code, Gemini CLI) cannot enforce an
OpenClaw tool allow-list — the CLI owns its native tools and its own permission
policy, so OpenClaw fails closed if asked to restrict one. For CLI-harness
models Crestodian instead:
- injects a dedicated MCP server that serves only the `crestodian` tool and
replaces OpenClaw's normal MCP tool surface for the run (for Claude Code the
generated config is applied with `--strict-mcp-config`, so no other MCP
servers are loaded),
- keeps every config mutation inside the tool's approval and audit contract —
reads run freely, writes require your conversational yes, and every applied
write is audited and re-validated,
- leaves native tools (file reads, shell) to the harness. They follow the same
permission posture as normal OpenClaw agent runs on this machine: with
OpenClaw's default exec settings Claude Code runs with permissions bypassed,
and a restricted `tools.exec` config falls back to the CLI's own permission
policy.
Only Crestodian sessions get the crestodian MCP server; normal agent runs
never see this tool. Treat a Crestodian session on a CLI-harness model like a
normal local agent run on the same host: the ring-zero tool adds an audited,
approval-gated path for config repair, but it does not prevent the harness's
native tools from touching files directly. The Codex app-server fallback and
API-key models enforce the strict single-tool loop; prefer those when you want
the hard restriction.
## Switching to an agent
Use a natural-language selector to leave Crestodian and open the normal TUI:

View file

@ -41,6 +41,43 @@ describe("prepareCliBundleMcpConfig", () => {
await prepared.cleanup?.();
});
it("serves only the exclusive config, ignoring user and plugin servers", async () => {
const workspaceDir = await cliBundleMcpHarness.tempHarness.createTempDir(
"openclaw-cli-bundle-mcp-exclusive-",
);
const userConfig = path.join(workspaceDir, "user-mcp.json");
await fs.writeFile(
userConfig,
`${JSON.stringify({ mcpServers: { user: { command: "node", args: ["user.mjs"] } } })}\n`,
"utf-8",
);
const prepared = await prepareCliBundleMcpConfig({
enabled: true,
mode: "claude-config-file",
backend: {
command: "node",
args: ["./fake-claude.mjs", "--mcp-config", "user-mcp.json"],
},
workspaceDir,
config: { plugins: { enabled: false } },
exclusiveConfig: {
mcpServers: { openclaw: { command: "node", args: ["crestodian.mjs"] } },
},
});
expect(prepared.backend.args).toContain("--strict-mcp-config");
const generatedConfigPath = requireMcpConfigPath(prepared.backend.args);
const raw = JSON.parse(await fs.readFile(generatedConfigPath, "utf-8")) as {
mcpServers?: Record<string, { args?: string[] }>;
};
expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]);
expect(raw.mcpServers?.openclaw?.args).toEqual(["crestodian.mjs"]);
expect(prepared.mcpConfigHash).toMatch(/^[0-9a-f]{64}$/);
await prepared.cleanup?.();
});
it("injects a merged --mcp-config overlay for bundle-MCP-enabled backends", async () => {
const prepared = await prepareBundleProbeCliConfig();

View file

@ -182,6 +182,12 @@ export async function prepareCliBundleMcpConfig(params: {
workspaceDir: string;
config?: OpenClawConfig;
additionalConfig?: BundleMcpConfig;
/**
* Serve exactly these servers, skipping user/plugin/additional merges.
* Ring-zero Crestodian runs use this so the CLI harness sees only the
* crestodian MCP server instead of the normal openclaw tool surface.
*/
exclusiveConfig?: BundleMcpConfig;
env?: Record<string, string>;
warn?: (message: string) => void;
}): Promise<PreparedCliBundleMcpConfig> {
@ -190,6 +196,14 @@ export async function prepareCliBundleMcpConfig(params: {
}
const mode = resolveBundleMcpMode(params.mode);
if (params.exclusiveConfig) {
return await prepareModeSpecificBundleMcpConfig({
mode,
backend: params.backend,
mergedConfig: params.exclusiveConfig,
env: params.env,
});
}
const resumeMcpConfigPaths =
mode === "claude-config-file" ? findClaudeMcpConfigPaths(params.backend.resumeArgs) : [];
const existingMcpConfigPaths =

View file

@ -2964,6 +2964,65 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("serves only the crestodian MCP server for ring-zero runs", async () => {
const { dir, sessionFile } = createSessionFile();
try {
const getActiveMcpLoopbackRuntime = vi.fn(() => undefined);
setCliRunnerPrepareTestDeps({ getActiveMcpLoopbackRuntime });
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "claude-cli",
pluginId: "anthropic",
bundleMcp: true,
bundleMcpMode: "claude-config-file",
config: {
command: "claude",
args: ["--print"],
output: "jsonl",
jsonlDialect: "claude-stream-json",
input: "stdin",
sessionMode: "existing",
},
},
],
});
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "claude-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-crestodian-mcp",
config: createCliBackendConfig(),
crestodianTool: { surface: "cli" },
});
// Ring-zero runs never touch the loopback surface (no message tools).
expect(getActiveMcpLoopbackRuntime).not.toHaveBeenCalled();
expect(context.mcpDeliveryCapture).toBeUndefined();
const args = context.preparedBackend.backend.args ?? [];
expect(args).toContain("--strict-mcp-config");
const mcpConfigPath = args[args.indexOf("--mcp-config") + 1];
const raw = JSON.parse(fs.readFileSync(mcpConfigPath, "utf-8")) as {
mcpServers?: Record<string, { env?: Record<string, string> }>;
};
expect(Object.keys(raw.mcpServers ?? {})).toEqual(["openclaw"]);
expect(raw.mcpServers?.openclaw?.env).toMatchObject({
OPENCLAW_TOOLS_MCP_TOOLS: "crestodian",
OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE: "cli",
});
await context.preparedBackend.cleanup?.();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("fails closed for native tool-capable CLI backends when tools are disabled", async () => {
const { dir, sessionFile } = createSessionFile();
try {

View file

@ -19,6 +19,7 @@ import {
resolveMcpLoopbackBearerToken,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js";
import { buildCrestodianToolsMcpServerConfig } from "../../mcp/openclaw-tools-serve-config.js";
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
import type {
CliBackendAuthEpochMode,
@ -486,8 +487,18 @@ export async function prepareCliRunContext(
seenSignatures: params.bootstrapPromptWarningSignaturesSeen,
previousSignature: params.bootstrapPromptWarningSignature,
});
// Ring-zero Crestodian runs replace the bundle MCP surface entirely: no
// loopback server, no plugin/user servers. The generated MCP config carries
// only the crestodian stdio server, so the CLI harness sees exactly one
// OpenClaw tool (its own native tools stay under the harness's policy).
const crestodianMcpConfig = params.crestodianTool
? buildCrestodianToolsMcpServerConfig(params.crestodianTool)
: undefined;
const bundleMcpEnabled =
!isSideQuestion && backendResolved.bundleMcp && params.disableTools !== true;
!isSideQuestion &&
!crestodianMcpConfig &&
backendResolved.bundleMcp &&
params.disableTools !== true;
let mcpLoopbackRuntime = bundleMcpEnabled ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined;
if (bundleMcpEnabled && !mcpLoopbackRuntime) {
try {
@ -511,11 +522,12 @@ export async function prepareCliRunContext(
undefined;
try {
const preparedBackend = await prepareCliBundleMcpConfig({
enabled: bundleMcpEnabled,
enabled: bundleMcpEnabled || crestodianMcpConfig !== undefined,
mode: backendResolved.bundleMcpMode,
backend: backendResolved.config,
workspaceDir,
config: params.config,
...(crestodianMcpConfig ? { exclusiveConfig: crestodianMcpConfig } : {}),
additionalConfig: mcpLoopbackRuntime
? prepareDeps.createMcpLoopbackServerConfig(mcpLoopbackRuntime.port)
: undefined,

View file

@ -131,6 +131,12 @@ export type RunCliAgentParams = {
approvalReviewerDeviceId?: string;
/** Runtime tool allow-list. CLI harnesses fail closed when this is set. */
toolsAllow?: string[];
/**
* Ring-zero Crestodian tool served over a dedicated stdio MCP server; set
* only by the Crestodian agent runner. Replaces the normal bundle MCP
* surface for the run the harness still owns its native tools.
*/
crestodianTool?: import("../tools/crestodian-tool.js").CrestodianToolOptions;
disableTools?: boolean;
abortSignal?: AbortSignal;
onExecutionStarted?: () => void;

View file

@ -1,6 +1,10 @@
// Crestodian ring-zero tool tests: approval gating, action mapping, verification.
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCrestodianTool } from "./crestodian-tool.js";
import {
createCrestodianTool,
hashCrestodianOperation,
resolveCrestodianProposalTransition,
} from "./crestodian-tool.js";
const mocks = vi.hoisted(() => ({
executeCrestodianOperation: vi.fn(async (_op: unknown, runtime: { log: (m: string) => void }) => {
@ -220,4 +224,35 @@ describe("crestodian tool", () => {
const tool = createCrestodianTool({ surface: "cli" });
await expect(tool.execute("t5", { action: "config_get" })).rejects.toThrow(/path/);
});
it("mirrors proposal transitions for out-of-process (CLI MCP) hosts", () => {
const args = { action: "set_default_model", model: "openai/gpt-5.5" };
const hash = hashCrestodianOperation({ kind: "set-default-model", model: "openai/gpt-5.5" });
// Denial registers the exact-operation hash on the host.
expect(
resolveCrestodianProposalTransition({
args,
resultText: "needs-approval: this action changes state.",
}),
).toEqual({ proposal: hash });
// A voided approval clears it.
expect(
resolveCrestodianProposalTransition({
args,
resultText: "approval-mismatch: this call is not the operation the user approved.",
}),
).toEqual({ proposal: undefined });
// An executed mutation consumes it.
expect(
resolveCrestodianProposalTransition({ args, resultText: "Default model updated." }),
).toEqual({ proposal: undefined });
// Read actions and unparsable calls never touch the proposal.
expect(
resolveCrestodianProposalTransition({ args: { action: "status" }, resultText: "ok" }),
).toBeNull();
expect(
resolveCrestodianProposalTransition({ args: { action: "bogus" }, resultText: "ok" }),
).toBeNull();
});
});

View file

@ -36,6 +36,39 @@ export function hashCrestodianOperation(operation: CrestodianOperation): string
return JSON.stringify(operation, Object.keys(operation).toSorted());
}
/** Result markers shared with out-of-process hosts (CLI MCP runs). */
export const CRESTODIAN_NEEDS_APPROVAL_PREFIX = "needs-approval:";
export const CRESTODIAN_APPROVAL_MISMATCH_PREFIX = "approval-mismatch:";
/**
* Mirror a proposalRef transition from an out-of-process tool result. CLI MCP
* runs execute this tool in a stdio subprocess whose proposalRef dies with the
* run; the host replays the same lifecycle from harness tool events: denial
* registers the exact-operation hash, mismatch voids it, execution consumes it.
*/
export function resolveCrestodianProposalTransition(params: {
args: Record<string, unknown>;
resultText: string;
}): { proposal: string | undefined } | null {
let operation: CrestodianOperation;
try {
operation = operationForAction(params.args);
} catch {
return null;
}
if (!isPersistentCrestodianOperation(operation)) {
return null;
}
if (params.resultText.startsWith(CRESTODIAN_APPROVAL_MISMATCH_PREFIX)) {
return { proposal: undefined };
}
if (params.resultText.startsWith(CRESTODIAN_NEEDS_APPROVAL_PREFIX)) {
return { proposal: hashCrestodianOperation(operation) };
}
// Executed or errored mutation: an armed approval is single-use either way.
return { proposal: undefined };
}
const CRESTODIAN_TOOL_ACTIONS = [
"status",
"models",
@ -233,7 +266,7 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo
options.proposalRef.current = undefined;
}
return textResult(
"approval-mismatch: this call is not the operation the user approved. The approval is void; describe the new change and get a fresh yes before retrying.",
`${CRESTODIAN_APPROVAL_MISMATCH_PREFIX} this call is not the operation the user approved. The approval is void; describe the new change and get a fresh yes before retrying.`,
{ needsApproval: true },
);
}
@ -241,7 +274,7 @@ export function createCrestodianTool(options: CrestodianToolOptions): AnyAgentTo
options.proposalRef.current = operationHash;
}
return textResult(
"needs-approval: this action changes state. The proposal is registered; describe this exact change and ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).",
`${CRESTODIAN_NEEDS_APPROVAL_PREFIX} this action changes state. The proposal is registered; describe this exact change and ask the user to reply yes (their approval unlocks THIS action only — then retry the identical call with approved=true).`,
{ needsApproval: true },
);
}

View file

@ -9,12 +9,14 @@ import { CRESTODIAN_AGENT_SYSTEM_PROMPT } from "./assistant-prompts.js";
import type { CrestodianOverview } from "./overview.js";
/**
* Crestodian is a real agent: same embedded loop, session transcript, and tool
* pipeline as regular agents restricted to the single ring-zero `crestodian`
* tool. Turns share one persistent session so the conversation has genuine
* multi-turn memory. When no loop-capable backend exists (fresh machine with
* only a CLI harness that cannot enforce a restricted toolset), the caller
* falls back to the single-turn planner.
* Crestodian is a real agent: same loop, session transcript, and tool pipeline
* as regular agents restricted to the single ring-zero `crestodian` tool.
* Embedded runtimes enforce that restriction with toolsAllow; CLI harnesses
* (claude-cli, gemini-cli) cannot, so they get the tool over a dedicated stdio
* MCP server that replaces the normal bundle MCP surface for the run. Turns
* share one persistent session so the conversation has genuine multi-turn
* memory. When no loop-capable backend exists, the caller falls back to the
* single-turn planner.
*/
export const CRESTODIAN_AGENT_ID = "crestodian";
@ -33,17 +35,29 @@ export type CrestodianAgentSession = {
sessionId: string;
/** Host-owned pending-proposal fingerprint; see crestodian-tool.ts. */
proposalRef: { current?: string };
/** Native CLI session id captured after CLI-harness turns for --resume reuse. */
cliSessionId?: string;
};
export function createCrestodianAgentSession(): CrestodianAgentSession {
return { sessionId: `crestodian-${randomUUID()}`, proposalRef: {} };
}
export type CrestodianAgentTurnDeps = {
runEmbeddedAgent?: typeof import("../agents/embedded-agent.js").runEmbeddedAgent;
runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent;
readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot;
};
type EmbeddedRunResult = {
payloads?: Array<{ text?: string }>;
meta?: {
finalAssistantVisibleText?: string;
finalAssistantRawText?: string;
agentMeta?: {
cliSessionBinding?: { sessionId?: string };
clearCliSessionBinding?: boolean;
};
};
};
@ -80,80 +94,196 @@ export async function cleanupCrestodianAgentSession(
await fs.rm(sessionFile, { force: true });
}
type CrestodianAgentTurnParams = Parameters<CrestodianAgentTurnRunner>[0];
type RunConfig = import("../config/types.openclaw.js").OpenClawConfig;
type CrestodianAgentTurnPlan =
| { runner: "cli"; runConfig: RunConfig; modelLabel: string; provider: string; model: string }
| {
runner: "embedded";
runConfig: RunConfig;
modelLabel: string;
provider?: string;
model?: string;
agentHarnessId?: string;
};
async function planCrestodianAgentTurn(
params: CrestodianAgentTurnParams,
deps: CrestodianAgentTurnDeps,
workspaceDir: string,
): Promise<CrestodianAgentTurnPlan | null> {
const configuredModel = params.overview.defaultModel;
if (configuredModel) {
const readSnapshot =
deps.readConfigFileSnapshot ?? (await import("../config/config.js")).readConfigFileSnapshot;
const snapshot = await readSnapshot();
const runConfig = snapshot.runtimeConfig ?? snapshot.config ?? {};
const { isCliProvider, resolveDefaultModelForAgent } =
await import("../agents/model-selection.js");
const ref = resolveDefaultModelForAgent({ cfg: runConfig, agentId: CRESTODIAN_AGENT_ID });
if (isCliProvider(ref.provider, runConfig)) {
return {
runner: "cli",
runConfig,
modelLabel: configuredModel,
provider: ref.provider,
model: ref.model,
};
}
return { runner: "embedded", runConfig, modelLabel: configuredModel };
}
// No configured model: fall back to the first locally detected runtime, in
// the same order setup would pick one (Claude Code CLI before Codex).
const backend = selectCrestodianLocalPlannerBackends(params.overview)[0];
if (!backend) {
return null;
}
const base = {
runConfig: backend.buildConfig(workspaceDir),
modelLabel: backend.label,
provider: backend.provider,
model: backend.model,
};
return backend.runner === "cli"
? { runner: "cli", ...base }
: { runner: "embedded", agentHarnessId: "codex", ...base };
}
/**
* CLI harnesses run the crestodian tool in a stdio MCP subprocess, so the
* in-process proposalRef cannot be shared with the host. Mirror the tool's
* proposal transitions from the harness tool events instead: a denial
* registers the exact-operation hash, a mismatch voids it, and an executed
* mutation consumes it same lifecycle as crestodian-tool.ts enforces.
*/
async function mirrorCrestodianProposalFromToolEvents(params: {
runId: string;
proposalRef: { current?: string };
}): Promise<() => void> {
const [{ onAgentEvent }, { extractToolResultText }, { resolveCrestodianProposalTransition }] =
await Promise.all([
import("../infra/agent-events.js"),
import("../agents/embedded-agent-subscribe.tools.js"),
import("../agents/tools/crestodian-tool.js"),
]);
return onAgentEvent((evt) => {
if (evt.runId !== params.runId || evt.stream !== "tool" || evt.data.phase !== "result") {
return;
}
const name = typeof evt.data.name === "string" ? evt.data.name : "";
// CLI harnesses report MCP tools with transport prefixes (mcp__openclaw__crestodian).
if (name !== "crestodian" && !name.endsWith("__crestodian")) {
return;
}
const args =
typeof evt.data.args === "object" && evt.data.args !== null
? (evt.data.args as Record<string, unknown>)
: {};
const transition = resolveCrestodianProposalTransition({
args,
resultText: extractToolResultText(evt.data.result) ?? "",
});
if (transition) {
params.proposalRef.current = transition.proposal;
}
});
}
/**
* Run one Crestodian turn through the embedded agent loop. Returns null when
* no loop-capable backend is available or the run fails, so the caller can
* degrade to the planner.
*/
export const runCrestodianAgentTurn: CrestodianAgentTurnRunner = async (params) => {
const { overview } = params;
const configuredModel = overview.defaultModel;
// CLI-harness models (e.g. claude-cli/*) cannot enforce a restricted
// toolset; runEmbeddedAgent rejects toolsAllow for them, and we fall back.
const embeddedFallback = configuredModel
? null
: (selectCrestodianLocalPlannerBackends(overview).find(
(backend) => backend.runner === "embedded",
) ?? null);
if (!configuredModel && !embeddedFallback) {
export async function runCrestodianAgentTurnWithDeps(
params: CrestodianAgentTurnParams,
deps: CrestodianAgentTurnDeps = {},
): Promise<{ text: string; modelLabel?: string } | null> {
const { workspaceDir, sessionFile } = await ensureCrestodianDirs(params.session.sessionId);
const plan = await planCrestodianAgentTurn(params, deps, workspaceDir);
if (!plan) {
return null;
}
const { workspaceDir, sessionFile } = await ensureCrestodianDirs(params.session.sessionId);
const { runEmbeddedAgent } = await import("../agents/embedded-agent.js");
const { readConfigFileSnapshot } = await import("../config/config.js");
let runConfig: import("../config/types.openclaw.js").OpenClawConfig;
let provider: string | undefined;
let model: string | undefined;
let agentHarnessId: string | undefined;
let modelLabel: string;
if (configuredModel) {
const snapshot = await readConfigFileSnapshot();
runConfig = snapshot.runtimeConfig ?? snapshot.config ?? {};
modelLabel = configuredModel;
} else {
runConfig = embeddedFallback!.buildConfig(workspaceDir);
provider = embeddedFallback!.provider;
model = embeddedFallback!.model;
agentHarnessId = "codex";
modelLabel = embeddedFallback!.label;
}
const runId = `crestodian-turn-${randomUUID()}`;
const shared = {
sessionId: params.session.sessionId,
sessionKey: buildAgentMainSessionKey({ agentId: CRESTODIAN_AGENT_ID }),
agentId: CRESTODIAN_AGENT_ID,
trigger: "manual" as const,
sessionFile,
workspaceDir,
config: plan.runConfig,
prompt: params.input,
timeoutMs: AGENT_TURN_TIMEOUT_MS,
runId,
messageChannel: "crestodian",
messageProvider: "crestodian",
};
const crestodianTool = {
surface: params.surface,
approvalArmed: params.approvalArmed,
proposalRef: params.session.proposalRef,
};
try {
const result = (await runEmbeddedAgent({
sessionId: params.session.sessionId,
sessionKey: buildAgentMainSessionKey({ agentId: CRESTODIAN_AGENT_ID }),
agentId: CRESTODIAN_AGENT_ID,
trigger: "manual",
sessionFile,
workspaceDir,
config: runConfig,
prompt: params.input,
extraSystemPrompt: CRESTODIAN_AGENT_SYSTEM_PROMPT,
toolsAllow: ["crestodian"],
crestodianTool: {
surface: params.surface,
approvalArmed: params.approvalArmed,
let result: EmbeddedRunResult;
if (plan.runner === "cli") {
const runCli = deps.runCliAgent ?? (await import("../agents/cli-runner.js")).runCliAgent;
const stopProposalMirror = await mirrorCrestodianProposalFromToolEvents({
runId,
proposalRef: params.session.proposalRef,
},
disableMessageTool: true,
timeoutMs: AGENT_TURN_TIMEOUT_MS,
runId: `crestodian-turn-${randomUUID()}`,
messageChannel: "crestodian",
messageProvider: "crestodian",
...(provider ? { provider } : {}),
...(model ? { model } : {}),
...(agentHarnessId ? { agentHarnessId, cleanupBundleMcpOnRunEnd: true } : {}),
})) as EmbeddedRunResult;
});
try {
result = (await runCli({
...shared,
provider: plan.provider,
model: plan.model,
extraSystemPrompt: CRESTODIAN_AGENT_SYSTEM_PROMPT,
extraSystemPromptStatic: CRESTODIAN_AGENT_SYSTEM_PROMPT,
crestodianTool,
...(params.session.cliSessionId ? { cliSessionId: params.session.cliSessionId } : {}),
cleanupCliLiveSessionOnRunEnd: true,
})) as EmbeddedRunResult;
} finally {
stopProposalMirror();
}
// Thread the harness's own session forward so the next turn resumes the
// native CLI transcript instead of reseeding from scratch.
const agentMeta = result.meta?.agentMeta;
if (agentMeta?.clearCliSessionBinding) {
delete params.session.cliSessionId;
} else if (agentMeta?.cliSessionBinding?.sessionId) {
params.session.cliSessionId = agentMeta.cliSessionBinding.sessionId;
}
} else {
const runEmbedded =
deps.runEmbeddedAgent ?? (await import("../agents/embedded-agent.js")).runEmbeddedAgent;
result = (await runEmbedded({
...shared,
extraSystemPrompt: CRESTODIAN_AGENT_SYSTEM_PROMPT,
toolsAllow: ["crestodian"],
crestodianTool,
disableMessageTool: true,
...(plan.provider ? { provider: plan.provider } : {}),
...(plan.model ? { model: plan.model } : {}),
...(plan.agentHarnessId
? { agentHarnessId: plan.agentHarnessId, cleanupBundleMcpOnRunEnd: true }
: {}),
})) as EmbeddedRunResult;
}
const text = extractRunText(result)?.trim();
if (!text) {
return null;
}
return { text, modelLabel };
return { text, modelLabel: plan.modelLabel };
} catch {
// Loop unavailable for this backend (CLI harness, auth failure, timeout):
// Loop unavailable for this backend (missing CLI, auth failure, timeout):
// the conversation must keep working, so degrade to the planner path.
return null;
}
};
}
export const runCrestodianAgentTurn: CrestodianAgentTurnRunner = (params) =>
runCrestodianAgentTurnWithDeps(params);

View file

@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { WizardPrompter } from "../wizard/prompts.js";
import { runCrestodianAgentTurnWithDeps } from "./agent-turn.js";
import { CrestodianChatEngine } from "./chat-engine.js";
const mocks = vi.hoisted(() => ({
@ -381,16 +382,138 @@ describe("CrestodianChatEngine", () => {
});
});
function fakeOverviewLoader() {
describe("Crestodian agent loop backends", () => {
it("runs a configured claude-cli model through the CLI loop with the ring-zero MCP tool", async () => {
useTempStateDir();
const snapshot = {
exists: true,
valid: true,
path: "/tmp/openclaw.json",
hash: "h",
config: {},
sourceConfig: {},
runtimeConfig: {
agents: {
defaults: {
model: { primary: "claude-cli/claude-opus-4-8" },
cliBackends: { "claude-cli": {} },
},
},
},
issues: [],
};
const runCliAgent = vi.fn(async (_params: Record<string, unknown>) => ({
payloads: [{ text: "*click* CLI loop checked your shell." }],
meta: { agentMeta: { cliSessionBinding: { sessionId: "native-1" } } },
}));
const planner = vi.fn(async () => null);
const engine = new CrestodianChatEngine({
runAgentTurn: (params) =>
runCrestodianAgentTurnWithDeps(params, {
runCliAgent: runCliAgent as never,
readConfigFileSnapshot: (async () => snapshot) as never,
}),
planWithAssistant: planner,
deps: { loadOverview: fakeOverviewLoader({ defaultModel: "claude-cli/claude-opus-4-8" }) },
});
const reply = await engine.handle("how is my setup looking?");
expect(reply.text).toContain("CLI loop checked your shell");
expect(planner).not.toHaveBeenCalled();
const call = runCliAgent.mock.calls[0][0];
expect(call.provider).toBe("claude-cli");
expect(call.model).toBe("claude-opus-4-8");
expect(call.crestodianTool).toEqual({ surface: "cli", approvalArmed: false, proposalRef: {} });
// CLI harnesses reject toolsAllow; the restriction rides on the MCP config.
expect(call.toolsAllow).toBeUndefined();
expect(call.cliSessionId).toBeUndefined();
expect(call.cleanupCliLiveSessionOnRunEnd).toBe(true);
// The captured native CLI session resumes on the next turn.
await engine.handle("and the gateway?");
expect(runCliAgent.mock.calls[1][0].cliSessionId).toBe("native-1");
});
it("drives the detected Claude Code CLI through the loop when no model is configured", async () => {
useTempStateDir();
const runCliAgent = vi.fn(async (_params: Record<string, unknown>) => ({
payloads: [{ text: "detected loop reply" }],
meta: {},
}));
const engine = new CrestodianChatEngine({
runAgentTurn: (params) =>
runCrestodianAgentTurnWithDeps(params, { runCliAgent: runCliAgent as never }),
planWithAssistant: vi.fn(async () => null),
deps: { loadOverview: fakeOverviewLoader({ claudeFound: true }) },
});
const reply = await engine.handle("how do things look?");
expect(reply.text).toBe("detected loop reply");
const call = runCliAgent.mock.calls[0][0];
expect(call.provider).toBe("claude-cli");
expect(call.model).toBe("claude-opus-4-8");
expect(call.crestodianTool).toEqual({ surface: "cli", approvalArmed: false, proposalRef: {} });
const config = call.config as {
agents?: { defaults?: { model?: { primary?: string } } };
};
expect(config.agents?.defaults?.model?.primary).toBe("claude-cli/claude-opus-4-8");
});
it("falls back to the single-turn planner when the CLI loop fails", async () => {
useTempStateDir();
const runCliAgent = vi.fn(async () => {
throw new Error("claude exploded");
});
const planner = vi.fn(async () => ({ reply: "planner fallback reply" }));
const engine = new CrestodianChatEngine({
runAgentTurn: (params) =>
runCrestodianAgentTurnWithDeps(params, { runCliAgent: runCliAgent as never }),
planWithAssistant: planner,
deps: { loadOverview: fakeOverviewLoader({ claudeFound: true }) },
});
const reply = await engine.handle("do a health check");
expect(runCliAgent).toHaveBeenCalledOnce();
expect(reply.text).toContain("planner fallback reply");
});
it("keeps the codex embedded fallback with the enforced ring-zero toolset", async () => {
useTempStateDir();
const runEmbeddedAgent = vi.fn(async (_params: Record<string, unknown>) => ({
payloads: [{ text: "embedded reply" }],
}));
const engine = new CrestodianChatEngine({
runAgentTurn: (params) =>
runCrestodianAgentTurnWithDeps(params, { runEmbeddedAgent: runEmbeddedAgent as never }),
planWithAssistant: vi.fn(async () => null),
deps: { loadOverview: fakeOverviewLoader({ codexFound: true }) },
});
const reply = await engine.handle("hello there");
expect(reply.text).toBe("embedded reply");
const call = runEmbeddedAgent.mock.calls[0][0];
expect(call.toolsAllow).toEqual(["crestodian"]);
expect(call.agentHarnessId).toBe("codex");
expect(call.crestodianTool).toEqual({ surface: "cli", approvalArmed: false, proposalRef: {} });
});
});
function fakeOverviewLoader(
overrides: { defaultModel?: string; claudeFound?: boolean; codexFound?: boolean } = {},
) {
return async () =>
({
config: { path: "/tmp/openclaw.json", exists: false, valid: true, issues: [], hash: null },
agents: [],
defaultAgentId: "main",
defaultModel: undefined,
defaultModel: overrides.defaultModel,
tools: {
codex: { command: "codex", found: false },
claude: { command: "claude", found: false },
codex: { command: "codex", found: overrides.codexFound ?? false },
claude: { command: "claude", found: overrides.claudeFound ?? false },
apiKeys: { openai: false, anthropic: false },
},
gateway: { url: "ws://127.0.0.1:18789", source: "local", reachable: false },

View file

@ -0,0 +1,146 @@
/**
* Shared contract between the openclaw-tools MCP stdio entry and the callers
* that inject it into CLI harness runs. Keep this module free of MCP SDK and
* tool-runtime imports so CLI-runner prepare paths can build server configs
* without loading the server.
*/
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import type { CrestodianToolOptions } from "../agents/tools/crestodian-tool.js";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import type { BundleMcpConfig } from "../plugins/bundle-mcp.js";
export const OPENCLAW_TOOLS_MCP_TOOLS_ENV = "OPENCLAW_TOOLS_MCP_TOOLS";
export const OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV = "OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE";
export const OPENCLAW_TOOLS_MCP_CRESTODIAN_APPROVAL_ARMED_ENV =
"OPENCLAW_TOOLS_MCP_CRESTODIAN_APPROVAL_ARMED";
export const OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV =
"OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL";
export const OPENCLAW_TOOLS_MCP_TOOL_IDS = ["cron", "crestodian"] as const;
export type OpenClawToolsMcpToolId = (typeof OPENCLAW_TOOLS_MCP_TOOL_IDS)[number];
function isOpenClawToolsMcpToolId(value: string): value is OpenClawToolsMcpToolId {
return (OPENCLAW_TOOLS_MCP_TOOL_IDS as readonly string[]).includes(value);
}
/** Parse the served tool selection; the default stays cron for acpx bridges. */
export function resolveOpenClawToolsMcpToolSelection(
env: NodeJS.ProcessEnv = process.env,
): OpenClawToolsMcpToolId[] {
const raw = env[OPENCLAW_TOOLS_MCP_TOOLS_ENV]?.trim();
if (!raw) {
return ["cron"];
}
const entries = raw
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const selection = entries.filter(isOpenClawToolsMcpToolId);
if (selection.length === 0 || selection.length !== entries.length) {
throw new Error(
`${OPENCLAW_TOOLS_MCP_TOOLS_ENV} must be a comma list of: ${OPENCLAW_TOOLS_MCP_TOOL_IDS.join(", ")}`,
);
}
return selection;
}
/** Parse the Crestodian surface for served crestodian tools; defaults to cli. */
export function resolveOpenClawToolsMcpCrestodianSurface(
env: NodeJS.ProcessEnv = process.env,
): CrestodianToolOptions["surface"] {
const raw = env[OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV]?.trim();
if (!raw || raw === "cli") {
return "cli";
}
if (raw === "gateway") {
return "gateway";
}
throw new Error(`${OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV} must be "cli" or "gateway"`);
}
/**
* Reconstruct per-turn approval state for the served crestodian tool. The
* stdio server runs out of process, so the host passes the armed bit and the
* pending proposal hash through env; the host mirrors transitions back from
* tool events (see mirrorCrestodianProposalFromToolEvents in agent-turn.ts).
*/
export function resolveOpenClawToolsMcpCrestodianApproval(env: NodeJS.ProcessEnv = process.env): {
approvalArmed: boolean;
proposalRef: { current?: string };
} {
const pendingProposal = env[OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV]?.trim();
return {
approvalArmed: env[OPENCLAW_TOOLS_MCP_CRESTODIAN_APPROVAL_ARMED_ENV]?.trim() === "1",
proposalRef: pendingProposal ? { current: pendingProposal } : {},
};
}
function resolveTsxImportSpecifier(): string {
try {
return createRequire(import.meta.url).resolve("tsx");
} catch {
return "tsx";
}
}
function resolveOpenClawToolsServeCommand(): { command: string; args: string[] } {
const packageRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
moduleUrl: import.meta.url,
cwd: process.cwd(),
});
if (!packageRoot) {
throw new Error("openclaw-tools MCP: could not resolve the OpenClaw package root");
}
const distEntry = path.join(packageRoot, "dist", "mcp", "openclaw-tools-serve.js");
if (fs.existsSync(distEntry)) {
return { command: process.execPath, args: [distEntry] };
}
const sourceEntry = path.join(packageRoot, "src", "mcp", "openclaw-tools-serve.ts");
if (!fs.existsSync(sourceEntry)) {
throw new Error(`openclaw-tools MCP: no serve entry under ${packageRoot}`);
}
// Bun executes TypeScript entries directly; Node source checkouts need tsx.
if (process.versions.bun) {
return { command: process.execPath, args: [sourceEntry] };
}
return {
command: process.execPath,
args: ["--import", resolveTsxImportSpecifier(), sourceEntry],
};
}
/**
* Crestodian CLI-harness runs get exactly one MCP server: this stdio entry
* serving the ring-zero crestodian tool. The server keeps the "openclaw" name
* so backend tool pre-approvals (e.g. Claude's --allowedTools mcp__openclaw__*)
* apply without per-backend argument surgery.
*/
export function buildCrestodianToolsMcpServerConfig(
options: CrestodianToolOptions,
): BundleMcpConfig {
const entry = resolveOpenClawToolsServeCommand();
const pendingProposal = options.proposalRef?.current;
return {
mcpServers: {
openclaw: {
command: entry.command,
args: entry.args,
env: {
[OPENCLAW_TOOLS_MCP_TOOLS_ENV]: "crestodian" satisfies OpenClawToolsMcpToolId,
[OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV]: options.surface,
// Per-turn approval state travels with the per-run MCP config; the
// host mirrors proposal transitions back from tool events.
...(options.approvalArmed === true
? { [OPENCLAW_TOOLS_MCP_CRESTODIAN_APPROVAL_ARMED_ENV]: "1" }
: {}),
...(pendingProposal
? { [OPENCLAW_TOOLS_MCP_CRESTODIAN_PROPOSAL_ENV]: pendingProposal }
: {}),
},
},
},
};
}

View file

@ -1,5 +1,12 @@
// OpenClaw MCP tools tests cover core tool server startup and registration.
import { describe, expect, it } from "vitest";
import {
buildCrestodianToolsMcpServerConfig,
OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV,
OPENCLAW_TOOLS_MCP_TOOLS_ENV,
resolveOpenClawToolsMcpCrestodianSurface,
resolveOpenClawToolsMcpToolSelection,
} from "./openclaw-tools-serve-config.js";
import {
OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV,
resolveOpenClawToolsForMcp,
@ -30,4 +37,56 @@ describe("OpenClaw tools MCP server", () => {
}),
).toBe("agent:worker:main");
});
it("serves the ring-zero crestodian tool without an agent session key", async () => {
const handlers = createPluginToolsMcpHandlers(
resolveOpenClawToolsForMcp({ tools: ["crestodian"], crestodianSurface: "cli" }),
);
const listed = await handlers.listTools();
expect(listed.tools.map((tool) => tool.name)).toEqual(["crestodian"]);
});
it("parses the served tool selection from env and defaults to cron", () => {
expect(resolveOpenClawToolsMcpToolSelection({})).toEqual(["cron"]);
expect(
resolveOpenClawToolsMcpToolSelection({
[OPENCLAW_TOOLS_MCP_TOOLS_ENV]: " crestodian , cron ",
}),
).toEqual(["crestodian", "cron"]);
expect(() =>
resolveOpenClawToolsMcpToolSelection({ [OPENCLAW_TOOLS_MCP_TOOLS_ENV]: "exec" }),
).toThrow(OPENCLAW_TOOLS_MCP_TOOLS_ENV);
});
it("parses the crestodian surface from env and defaults to cli", () => {
expect(resolveOpenClawToolsMcpCrestodianSurface({})).toBe("cli");
expect(
resolveOpenClawToolsMcpCrestodianSurface({
[OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV]: "gateway",
}),
).toBe("gateway");
expect(() =>
resolveOpenClawToolsMcpCrestodianSurface({
[OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV]: "remote",
}),
).toThrow(OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV);
});
it("builds a crestodian-only stdio server config under the openclaw name", () => {
const config = buildCrestodianToolsMcpServerConfig({ surface: "gateway" });
expect(Object.keys(config.mcpServers)).toEqual(["openclaw"]);
const server = config.mcpServers.openclaw as {
command?: string;
args?: string[];
env?: Record<string, string>;
};
expect(server.command).toBe(process.execPath);
expect(server.args?.at(-1)).toMatch(/openclaw-tools-serve\.(js|ts)$/);
expect(server.env).toEqual({
[OPENCLAW_TOOLS_MCP_TOOLS_ENV]: "crestodian",
[OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV]: "gateway",
});
});
});

View file

@ -7,10 +7,23 @@
import { pathToFileURL } from "node:url";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { AnyAgentTool } from "../agents/tools/common.js";
import { createCrestodianTool } from "../agents/tools/crestodian-tool.js";
import type { CrestodianToolOptions } from "../agents/tools/crestodian-tool.js";
import { createCronTool } from "../agents/tools/cron-tool.js";
import { formatErrorMessage } from "../infra/errors.js";
import {
resolveOpenClawToolsMcpCrestodianApproval,
resolveOpenClawToolsMcpCrestodianSurface,
resolveOpenClawToolsMcpToolSelection,
type OpenClawToolsMcpToolId,
} from "./openclaw-tools-serve-config.js";
import { connectToolsMcpServerToStdio, createToolsMcpServer } from "./tools-stdio-server.js";
export {
OPENCLAW_TOOLS_MCP_CRESTODIAN_SURFACE_ENV,
OPENCLAW_TOOLS_MCP_TOOLS_ENV,
} from "./openclaw-tools-serve-config.js";
export const OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV = "OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY";
export function resolveOpenClawToolsMcpAgentSessionKey(
@ -22,15 +35,26 @@ export function resolveOpenClawToolsMcpAgentSessionKey(
export function resolveOpenClawToolsForMcp(
params: {
agentSessionKey?: string;
tools?: OpenClawToolsMcpToolId[];
crestodianSurface?: CrestodianToolOptions["surface"];
} = {},
): AnyAgentTool[] {
const agentSessionKey = (
params.agentSessionKey ?? resolveOpenClawToolsMcpAgentSessionKey()
)?.trim();
if (!agentSessionKey) {
throw new Error(`${OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV} is required`);
}
return [createCronTool({ agentSessionKey, creatorToolAllowlist: [{ name: "cron" }] })];
const selection = params.tools ?? resolveOpenClawToolsMcpToolSelection();
return selection.map((tool) => {
if (tool === "crestodian") {
return createCrestodianTool({
surface: params.crestodianSurface ?? resolveOpenClawToolsMcpCrestodianSurface(),
...resolveOpenClawToolsMcpCrestodianApproval(),
});
}
const agentSessionKey = (
params.agentSessionKey ?? resolveOpenClawToolsMcpAgentSessionKey()
)?.trim();
if (!agentSessionKey) {
throw new Error(`${OPENCLAW_TOOLS_MCP_AGENT_SESSION_KEY_ENV} is required`);
}
return createCronTool({ agentSessionKey, creatorToolAllowlist: [{ name: "cron" }] });
});
}
function createOpenClawToolsMcpServer(