Files
openclaw/test/e2e/qa-lab/runtime/crestodian-first-run-docker-client.ts
Peter Steinberger 96f0983a85 fix(onboarding): skip setup for configured gateways and require inference first (#102883)
* fix(crestodian): keep onboarding RPCs restart-safe

* fix(profiles): isolate approval state migrations

* fix(crestodian): bypass configured gateway setup

* test(crestodian): type onboarding mocks

* fix(onboarding): require inference before Crestodian

* fix(onboarding): enforce verified inference handoff

* fix(macos): reset setup on gateway endpoint edits

* chore(i18n): refresh native source inventory

* fix(gateway): keep socket on request cancellation

* test(packaging): require workspace templates

* fix(onboarding): bind setup to verified inference

* fix(onboarding): align inference gate contracts

* fix(crestodian): classify concurrent policy rejection

* test(crestodian): expect registry restoration

* fix(onboarding): bind setup to configured gateways

* fix(codex): preserve startup phase deadlines

* test(crestodian): match fail-closed policy ordering

* test(onboarding): assert bound gateway handoff

* fix(codex): bind runtime resolution to spawn cwd

* test(crestodian): assert policy rejection order

* fix(cli): preserve gateway routing across restarts

* fix(macos): fail closed during gateway edits

* test(macos): cover gateway route generation races

* chore: keep release notes out of onboarding PR

* fix(ci): refresh onboarding generated checks

* style(swift): align gateway channel formatting

* fix(ci): refresh plugin SDK surface budgets

* fix(ci): resync native string inventory

* refactor(swift): split gateway channel support

* test(doctor): isolate plugin compatibility registry

* test(macos): isolate gateway onboarding fixtures

* test(macos): assert gateway lease health ordering

* fix(codex): reconcile computer-use startup changes
2026-07-11 10:25:14 -07:00

386 lines
14 KiB
TypeScript

// Crestodian first-run Docker harness.
// Imports packaged dist modules so the Docker lane verifies the npm tarball,
// while this small test driver stays mounted from the checkout.
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { shouldStartOnboardingForFreshInstall } from "../../../../dist/cli/run-main.js";
import { clearConfigCache } from "../../../../dist/config/config.js";
import type { OpenClawConfig } from "../../../../dist/config/types.openclaw.js";
import { activateSetupInference } from "../../../../dist/crestodian/setup-inference.js";
import type { RuntimeEnv } from "../../../../dist/runtime.js";
import { createE2eStateDir } from "../../../../scripts/e2e/lib/temp-state-dir.ts";
type CrestodianFirstRunCommand = {
id: string;
message: string;
expectOutput: string;
approve: boolean;
planner?: boolean;
};
type CrestodianFirstRunSpec = {
dockerDefaultWorkspace: string;
dockerAgentWorkspace: string;
agentId: string;
model: string;
telegramEnv: string;
telegramToken: string;
commands: CrestodianFirstRunCommand[];
auditOperations: string[];
};
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
function setEnvValue(key: string, value: string): void {
Reflect.set(process.env, key, value);
}
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 readFirstRunSpec(): Promise<CrestodianFirstRunSpec> {
return JSON.parse(
await fs.readFile(
path.join(process.cwd(), "scripts", "e2e", "crestodian-first-run-spec.json"),
"utf8",
),
) as CrestodianFirstRunSpec;
}
function renderCommandTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{([A-Za-z0-9_]+)\}/g, (match, key: string) => vars[key] ?? match);
}
const FAKE_PLANNER_REPLY = "Fake Claude planner selected an inference-backed typed setup.";
const PACKAGED_CLI_TIMEOUT_MS = 60_000;
const INFERENCE_PROBE_PROMPT = "Reply with the single word OK";
async function readFakeClaudePromptLines(promptLogPath: string): Promise<string[]> {
return (await fs.readFile(promptLogPath, "utf8"))
.split("\n")
.filter((line) => line.trim().length > 0);
}
function countInferencePrompts(lines: string[]): number {
return lines.filter((line) => line.includes(INFERENCE_PROBE_PROMPT)).length;
}
function resolveDefaultModel(config: OpenClawConfig): string | undefined {
const model = config.agents?.defaults?.model;
return typeof model === "string" ? model : model?.primary;
}
async function installFakeClaudeCli(
fakeBinDir: string,
promptLogPath: string,
plannerCommand: string,
): Promise<void> {
await fs.mkdir(fakeBinDir, { recursive: true });
const packageRoot = path.dirname(fakeBinDir);
const scriptPath = path.join(fakeBinDir, "claude");
const plannerResult = JSON.stringify({
reply: FAKE_PLANNER_REPLY,
command: plannerCommand,
});
await fs.writeFile(
path.join(packageRoot, "package.json"),
`${JSON.stringify({
name: "@anthropic-ai/claude-code",
version: "99.0.0",
private: true,
bin: { claude: "bin/claude" },
})}\n`,
);
await fs.writeFile(
scriptPath,
[
`#!${process.execPath}`,
'const fs = require("node:fs");',
'if (process.argv[2] === "--version") {',
' console.log("claude 99.0.0");',
" process.exit(0);",
"}",
`const promptLogPath = ${JSON.stringify(promptLogPath)};`,
`const plannerResult = ${JSON.stringify(plannerResult)};`,
'const promptLine = fs.readFileSync(0, "utf8").split(/\\r?\\n/u, 1)[0] ?? "";',
'fs.appendFileSync(promptLogPath, `${promptLine}\\n`, "utf8");',
'const result = promptLine.includes("User request:") ? plannerResult : "OK";',
"console.log(",
" JSON.stringify({",
' type: "result",',
' session_id: "fake-claude-session",',
" result,",
" usage: { input_tokens: 1, output_tokens: 1 },",
" }),",
");",
].join("\n"),
{ mode: 0o755 },
);
await fs.chmod(scriptPath, 0o755);
}
async function runPackagedCli(args: string[]): Promise<{
code: number | null;
stdout: string;
stderr: string;
}> {
const child = spawn("openclaw", args, {
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, PACKAGED_CLI_TIMEOUT_MS);
let code: number | null;
try {
code = await new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("close", resolve);
});
} finally {
clearTimeout(timeout);
}
if (timedOut) {
throw new Error(
`Packaged CLI timed out after ${PACKAGED_CLI_TIMEOUT_MS}ms: openclaw ${args.join(" ")}\n${stdout}\n${stderr}`,
);
}
return { code, stdout, stderr };
}
async function main() {
const spec = await readFirstRunSpec();
const tempState = await createE2eStateDir("openclaw-crestodian-first-run-");
tempState.registerExitCleanup();
const stateDir = tempState.stateDir;
const configPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json");
// Keep mutable logs/config outside the hashed package tree. Every file below
// this root is part of the durable CLI owner checked before persistent setup.
const fakeBinDir = path.join(stateDir, "fake-claude-package", "bin");
const promptLogPath = path.join(stateDir, "fake-claude-prompts.jsonl");
setEnvValue("OPENCLAW_STATE_DIR", stateDir);
setEnvValue("OPENCLAW_CONFIG_PATH", configPath);
setEnvValue("PATH", `${fakeBinDir}:${process.env.PATH ?? ""}`);
Reflect.deleteProperty(process.env, "OPENAI_API_KEY");
Reflect.deleteProperty(process.env, "ANTHROPIC_API_KEY");
await fs.rm(stateDir, { recursive: true, force: true });
await fs.mkdir(stateDir, { recursive: true });
clearConfigCache();
assert(
await shouldStartOnboardingForFreshInstall(["node", "openclaw"]),
"fresh bare OpenClaw invocation did not route to onboarding",
);
const blocked = await runPackagedCli(["crestodian", "--message", "overview"]);
assert(blocked.code === 1, "Crestodian did not fail closed without inference");
assert(
`${blocked.stdout}\n${blocked.stderr}`.includes("openclaw onboard"),
"blocked Crestodian did not direct the user to inference onboarding",
);
const blockedModern = await runPackagedCli([
"onboard",
"--modern",
"--non-interactive",
"--accept-risk",
"--json",
]);
assert(
blockedModern.code === 1 &&
`${blockedModern.stdout}\n${blockedModern.stderr}`.includes('"ok": false'),
"modern compatibility entrypoint did not fail closed with structured JSON",
);
const plannerCommand = `setup workspace ${spec.dockerDefaultWorkspace}`;
await installFakeClaudeCli(fakeBinDir, promptLogPath, plannerCommand);
const activationRuntime = createRuntime();
const activation = await activateSetupInference({
kind: "claude-cli",
workspace: spec.dockerDefaultWorkspace,
surface: "cli",
runtime: activationRuntime.runtime,
});
assert(activation.ok, `fake Claude inference activation failed: ${JSON.stringify(activation)}`);
assert(
activation.modelRef === "claude-cli/claude-opus-4-8",
`activation selected the wrong model: ${activation.modelRef}`,
);
const inferenceConfig = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig;
assert(
resolveDefaultModel(inferenceConfig) === activation.modelRef,
"activation did not persist the verified inference route",
);
assert(
inferenceConfig.agents?.defaults?.workspace === undefined &&
inferenceConfig.gateway === undefined,
"inference activation configured the rest before Crestodian started",
);
const activationPrompts = await fs.readFile(promptLogPath, "utf8");
assert(
activationPrompts.includes(INFERENCE_PROBE_PROMPT),
"inference activation did not send the live model probe",
);
const modern = await runPackagedCli([
"onboard",
"--modern",
"--non-interactive",
"--accept-risk",
"--json",
]);
assert(
modern.code === 0 && `${modern.stdout}\n${modern.stderr}`.includes(activation.modelRef),
"modern compatibility entrypoint did not expose Crestodian after activation",
);
const overview = await runPackagedCli(["crestodian", "--message", "overview"]);
const overviewOutput = `${overview.stdout}\n${overview.stderr}`;
assert(overview.code === 0, `verified Crestodian CLI failed: ${overviewOutput}`);
assert(
overviewOutput.includes("claude-cli/claude-opus-4-8"),
"verified overview did not report the activated model",
);
setEnvValue(spec.telegramEnv, spec.telegramToken);
const commandVars = {
defaultWorkspace: spec.dockerDefaultWorkspace,
agentWorkspace: spec.dockerAgentWorkspace,
agentId: spec.agentId,
model: spec.model,
telegramEnv: spec.telegramEnv,
};
for (const command of spec.commands) {
const message = renderCommandTemplate(command.message, commandVars);
const probesBefore = countInferencePrompts(await readFakeClaudePromptLines(promptLogPath));
const result = await runPackagedCli([
"crestodian",
"--message",
message,
...(command.approve ? ["--yes"] : []),
]);
const output = `${result.stdout}\n${result.stderr}`;
assert(
result.code === 0 && output.includes(command.expectOutput),
`Crestodian first-run command ${command.id} did not apply: ${output}`,
);
if (command.planner) {
assert(
output.includes(`[crestodian] planner: ${spec.model}`) &&
output.includes(FAKE_PLANNER_REPLY) &&
output.includes(`[crestodian] interpreted: ${plannerCommand}`),
`Crestodian first-run command ${command.id} did not use the verified planner: ${output}`,
);
}
const probesAfter = countInferencePrompts(await readFakeClaudePromptLines(promptLogPath));
const probeDelta = probesAfter - probesBefore;
const minimumProbes = command.approve ? 2 : 1;
assert(
probeDelta >= minimumProbes,
`Crestodian command ${command.id} ran ${probeDelta} inference probes; expected at least ${minimumProbes} for preflight${command.approve ? " plus its persistent boundary" : ""}`,
);
}
const pluginList = await runPackagedCli(["plugins", "list", "--json"]);
assert(
pluginList.code === 0,
`packaged plugin listing failed: ${pluginList.stdout}\n${pluginList.stderr}`,
);
const pluginReport = JSON.parse(pluginList.stdout) as {
plugins?: Array<{ id?: string; enabled?: boolean }>;
};
const telegramPlugin = pluginReport.plugins?.find((plugin) => plugin.id === "telegram");
assert(
telegramPlugin?.enabled === true,
"Telegram channel config did not auto-enable the packaged Telegram plugin",
);
const probeLines = await readFakeClaudePromptLines(promptLogPath);
const inferencePrompts = probeLines.filter((line) => line.includes(INFERENCE_PROBE_PROMPT));
const plannerPrompts = probeLines.filter((line) => line.includes("User request:"));
const minimumEntryAndActivationProbes = spec.commands.length + 4;
const minimumPersistentBoundaryProbes = spec.auditOperations.length;
assert(
inferencePrompts.length >= minimumEntryAndActivationProbes + minimumPersistentBoundaryProbes,
`expected activation/preflight probes plus at least ${minimumPersistentBoundaryProbes} persistent-boundary probes; got ${inferencePrompts.length}`,
);
assert(
plannerPrompts.length === 1 && plannerPrompts[0]?.includes("finish basic setup"),
`expected one fuzzy setup planner prompt; got ${plannerPrompts.length}`,
);
assert(
probeLines.length === inferencePrompts.length + plannerPrompts.length,
`unexpected fake Claude prompt count: ${probeLines.length}`,
);
const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig;
assert(
config.agents?.defaults?.workspace === spec.dockerDefaultWorkspace,
"first-run setup did not write default workspace",
);
assert(resolveDefaultModel(config) === spec.model, "first-run setup did not write default model");
const reef = config.agents?.list?.find((agent) => agent.id === spec.agentId);
assert(reef, "Crestodian did not create reef agent");
assert(reef.workspace === spec.dockerAgentWorkspace, "Crestodian did not write reef workspace");
assert(
reef.model === undefined,
"Crestodian wrote a per-agent model instead of inheriting the verified default",
);
assert(config.channels?.telegram?.enabled === true, "Crestodian did not enable Telegram");
const telegramToken = config.channels?.telegram?.botToken;
assert(
telegramToken &&
typeof telegramToken === "object" &&
"source" in telegramToken &&
telegramToken.source === "env" &&
"id" in telegramToken &&
telegramToken.id === spec.telegramEnv,
"Crestodian did not write Telegram token SecretRef",
);
assert(
!JSON.stringify(config.channels.telegram).includes(spec.telegramToken),
"Crestodian persisted the raw Telegram token",
);
const auditPath = path.join(stateDir, "audit", "crestodian.jsonl");
const audit = (await fs.readFile(auditPath, "utf8")).trim();
for (const operation of spec.auditOperations) {
assert(audit.includes(`"operation":"${operation}"`), `${operation} audit entry missing`);
}
console.log("Crestodian first-run Docker E2E passed");
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});