diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index d80c7794236f..2e8c8cd784ac 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -13,6 +13,11 @@ on: required: false default: "" type: string + allow_unreleased_changelog: + description: Allow current-tree packaging to use Unreleased notes; canonical release contexts stay strict + required: false + default: false + type: boolean provider: description: Provider lane for cross-OS onboarding and the end-to-end agent turn required: false @@ -177,6 +182,7 @@ jobs: CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} RELEASE_PROFILE: ${{ inputs.release_profile }} RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'stable' || inputs.release_profile == 'full' }} + ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.target_context_ref == '' && (inputs.allow_unreleased_changelog || inputs.ref == 'main' || inputs.ref == 'refs/heads/main') }} RERUN_GROUP: ${{ inputs.rerun_group }} LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }} @@ -188,6 +194,7 @@ jobs: echo "- Target SHA: \`${TARGET_SHA}\`" echo "- Child workflow ref: \`${CHILD_WORKFLOW_REF}\`" echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" + echo "- Allow Unreleased changelog packaging: \`${ALLOW_UNRELEASED_CHANGELOG}\`" echo "- Rerun group: \`${RERUN_GROUP}\`" if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" @@ -1042,6 +1049,7 @@ jobs: -f mode="$MODE" -f release_profile="$RELEASE_PROFILE" -f run_release_soak="$RUN_RELEASE_SOAK" + -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" -f rerun_group="$child_rerun_group" ) if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 5c102a8c1746..e68cd8ab3881 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -51,6 +51,11 @@ on: required: false default: false type: boolean + allow_unreleased_changelog: + description: Allow current-tree packaging to use Unreleased notes; release branches and tags stay strict + required: false + default: false + type: boolean rerun_group: description: Release check group to run required: false @@ -119,6 +124,7 @@ jobs: release_profile: ${{ steps.inputs.outputs.release_profile }} run_release_soak: ${{ steps.inputs.outputs.run_release_soak }} run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }} + allow_unreleased_changelog: ${{ steps.inputs.outputs.allow_unreleased_changelog }} rerun_group: ${{ steps.inputs.outputs.rerun_group }} live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }} cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }} @@ -293,6 +299,7 @@ jobs: RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }} RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }} RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }} + RELEASE_ALLOW_UNRELEASED_CHANGELOG_INPUT: ${{ inputs.allow_unreleased_changelog }} RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }} RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }} @@ -353,6 +360,16 @@ jobs: if [[ "$release_profile" == "stable" || "$release_profile" == "full" ]]; then run_release_soak=true fi + allow_unreleased_changelog="$(printf '%s' "$RELEASE_ALLOW_UNRELEASED_CHANGELOG_INPUT" | tr '[:upper:]' '[:lower:]')" + if [[ "$RELEASE_REF_INPUT" == "main" || "$RELEASE_REF_INPUT" == "refs/heads/main" ]]; then + allow_unreleased_changelog=true + elif [[ "$RELEASE_REF_INPUT" =~ ^(refs/heads/)?(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33|tideclaw/alpha/) ]] || [[ "$RELEASE_REF_INPUT" =~ ^(refs/tags/)?v[0-9]{4}\.[0-9]+\.[0-9]+ ]]; then + allow_unreleased_changelog=false + elif [[ "$allow_unreleased_changelog" != "true" && "$allow_unreleased_changelog" != "1" && "$allow_unreleased_changelog" != "yes" ]]; then + allow_unreleased_changelog=false + else + allow_unreleased_changelog=true + fi codex_plugin_spec="$RELEASE_CODEX_PLUGIN_SPEC_INPUT" if [[ -z "${codex_plugin_spec// }" && "$RELEASE_PACKAGE_SPEC_INPUT" =~ ^openclaw@(.+)$ ]]; then codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}" @@ -443,6 +460,7 @@ jobs: printf 'release_profile=%s\n' "$release_profile" printf 'run_release_soak=%s\n' "$run_release_soak" printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard" + printf 'allow_unreleased_changelog=%s\n' "$allow_unreleased_changelog" printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT" printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT" printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT" @@ -466,6 +484,7 @@ jobs: RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }} RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }} RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }} + ALLOW_UNRELEASED_CHANGELOG: ${{ steps.inputs.outputs.allow_unreleased_changelog }} RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }} RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }} RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }} @@ -484,6 +503,7 @@ jobs: echo "- Release profile: \`${RELEASE_PROFILE}\`" echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`" + echo "- Allow Unreleased changelog packaging: \`${ALLOW_UNRELEASED_CHANGELOG}\`" echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`" if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`" @@ -654,6 +674,7 @@ jobs: packages: read uses: ./.github/workflows/install-smoke-reusable.yml with: + allow_unreleased_changelog: ${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }} ref: ${{ needs.resolve_target.outputs.revision }} run_bun_global_install_smoke: true @@ -701,6 +722,7 @@ jobs: uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }} + allow_unreleased_changelog: ${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }} # Live-provider suites depend on third-party model deployments; beta # treats only those as advisory while repo E2E stays blocking and # stable/full keep everything blocking. @@ -774,6 +796,7 @@ jobs: uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }} + allow_unreleased_changelog: ${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }} ref: ${{ needs.resolve_target.outputs.revision }} include_repo_e2e: false include_release_path_suites: true diff --git a/AGENTS.md b/AGENTS.md index 7e1b479f3fe8..cf61f01edffb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,7 @@ Skills own workflows; root owns hard policy and routing. - Delegated Testbox rejects `--fresh-pr` and `--stop-after`; sync current checkout, workflow owns lifecycle. - PR review artifacts: keep template enum values; put evidence detail in summaries. - Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely. +- Blacksmith Testbox delegated runs: omit `--stop-after`; unsupported, cleanup is delegated. - Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked. - Trusted-source local agent work includes one/few focused tests, `git diff --check`, targeted formatting, and cheap static probes when dependencies already exist. Untrusted source executes none of its repository-controlled tooling locally. Computationally intensive work uses the selected remote box. - In Codex or linked worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof. @@ -161,6 +162,7 @@ Skills own workflows; root owns hard policy and routing. - Before handoff/push: prove touched surface. Before landing to `main`: proof matches actual risk. Bounded behavior-neutral refactor: focused tests/checks enough; no issue proof or full/broad suite by default. - Release-branch full validation: freeze the product-complete **Code SHA**, then use `node scripts/full-release-validation-at-sha.mjs --sha --target-ref release/YYYY.M.PATCH`; no raw dispatch without `target_context_ref`. - Pre-land/pre-commit code changes: mandatory fresh `$autoreview` until no accepted/actionable findings remain. Do not land code on CI, ClawSweeper, prior review comments, or your own manual review alone unless user explicitly opts out or scope is truly trivial/docs-only. If findings want refactor, refactor; no ugly fixes. +- Autoreview staged/uncommitted diff: use `--mode uncommitted`; no `staged` mode. - If proof is blocked, say exactly what is missing and why. - Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof. - Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed. @@ -188,7 +190,7 @@ Skills own workflows; root owns hard policy and routing. - `scripts/pr` review: checkout main baseline, then PR, before artifact validation. - Review artifacts: validate from PR-head mode; moving main invalidates main-baseline guard. - `scripts/pr` prepare/merge: `main` PRs only; non-main uses reviewed release-branch flow. -- PR head changed: rerun `scripts/pr review-init`; checkout alone leaves stale guard SHA. +- After every PR push, rerun `scripts/pr review-init`; checkout alone leaves stale guard SHA. - `rg`: options/globs before `--`; `--` immediately before a leading-dash pattern only. - `gh --jq` is not standalone `jq`; pipe JSON to `jq` for variables or `--arg`. - `gh api --paginate '' | jq -s ...`; gh `--slurp` may emit nothing and forbids `--jq`/`--template`. diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 4f134a42a97e..f46068548b10 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -245,7 +245,6 @@ extensions/qa-lab/src/cli.runtime.ts extensions/qa-lab/src/cli.ts extensions/qa-lab/src/confidence-report.ts extensions/qa-lab/src/evidence-gallery.ts -extensions/qa-lab/src/evidence-summary.ts extensions/qa-lab/src/gateway-child.test.ts extensions/qa-lab/src/gateway-child.ts extensions/qa-lab/src/gateway-process-boundary.ts diff --git a/scripts/full-release-validation-at-sha.mjs b/scripts/full-release-validation-at-sha.mjs index cb6dea875aad..3b197db1ab32 100755 --- a/scripts/full-release-validation-at-sha.mjs +++ b/scripts/full-release-validation-at-sha.mjs @@ -158,6 +158,12 @@ export function parseArgs(argv) { if (!["true", "false"].includes(args.inputs.reuse_evidence)) { throw new Error("reuse_evidence must be true or false"); } + if ( + Object.hasOwn(args.inputs, "allow_unreleased_changelog") && + !["true", "false"].includes(args.inputs.allow_unreleased_changelog) + ) { + throw new Error("allow_unreleased_changelog must be true or false"); + } if ( args.inputs.release_profile && !["beta", "stable", "full"].includes(args.inputs.release_profile) @@ -407,6 +413,7 @@ function main() { const args = parseArgs(process.argv.slice(2)); const targetSha = resolveSha(args.sha); args.inputs.release_profile ??= releaseProfileForTarget(targetSha); + args.inputs.allow_unreleased_changelog ??= args.targetRef ? "false" : "true"; const targetContextRef = verifyTargetRef(args.targetRef, targetSha); const workflowSha = resolveTrustedWorkflowSha(args.workflowSha); const shortSha = workflowSha.slice(0, 12); diff --git a/src/system-agent/operations-execute.ts b/src/system-agent/operations-execute.ts new file mode 100644 index 000000000000..4fb0838fc6f2 --- /dev/null +++ b/src/system-agent/operations-execute.ts @@ -0,0 +1,445 @@ +// Public operation dispatcher. Parsing and mutation helpers live in focused modules. +import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { isReservedSystemAgentId } from "./agent-id.js"; +import { resolveSystemAgentAuditPath } from "./audit.js"; +import { + CONFIG_GET_OUTPUT_MAX_CHARS, + CONFIG_SCHEMA_CHILDREN_MAX, + applyPersistentOperation, + assertConfigWriteDoesNotBypassInferenceVerification, + executePluginInstall, + executeSetDefaultModel, + executeSetup, + formatChannelDocsUrl, + formatConfigValidationLine, + formatGatewayStatusLine, + loadOverviewForOperation, + readConfigFileSnapshotLazy, + readConfigValueAtPath, + redactConfigValue, + resolveChannelSetupState, + resolveTuiAgentId, + runConfigSetOperation, + runGatewayLifecycle, + type ExecuteOptions, +} from "./operations-execution-helpers.js"; +import type { SystemAgentOperation, SystemAgentOperationResult } from "./operations-parse.js"; + +const loadOverviewModule = async () => await import("./overview.js"); + +/** Execute a parsed OpenClaw operation after applying approval gates and audit logging. */ +export async function executeSystemAgentOperation( + operation: SystemAgentOperation, + runtime: RuntimeEnv, + opts: ExecuteOptions = {}, +): Promise { + switch (operation.kind) { + case "none": + runtime.log(operation.message); + return { applied: false, exitsInteractive: operation.message.includes("Bye.") }; + case "overview": { + const overview = await loadOverviewForOperation(opts.deps); + if (opts.deps?.formatOverview) { + runtime.log(opts.deps.formatOverview(overview)); + } else { + const { formatSystemAgentOverview } = await loadOverviewModule(); + runtime.log(formatSystemAgentOverview(overview)); + } + return { applied: false }; + } + case "agents": { + const overview = await loadOverviewForOperation(opts.deps); + 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 }; + } + case "models": { + const overview = await loadOverviewForOperation(opts.deps); + 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"}`, + `Gemini CLI: ${overview.tools.gemini.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 }; + } + case "plugin-list": { + const runPluginsList = + opts.deps?.runPluginsList ?? + (async (pluginRuntime: RuntimeEnv) => { + const { runPluginsListCommand } = await import("../cli/plugins-list-command.js"); + await runPluginsListCommand({}, pluginRuntime); + }); + await runPluginsList(runtime); + return { applied: false }; + } + case "plugin-search": { + const runPluginsSearch = + opts.deps?.runPluginsSearch ?? + (async (query: string, pluginRuntime: RuntimeEnv) => { + const { runPluginsSearchCommand } = await import("../cli/plugins-search-command.js"); + await runPluginsSearchCommand(query, {}, pluginRuntime); + }); + await runPluginsSearch(operation.query, runtime); + return { applied: false }; + } + case "audit": + runtime.log(`Audit log: ${resolveSystemAgentAuditPath()}`); + runtime.log("Only applied writes/actions are recorded; discovery stays quiet."); + return { applied: false }; + case "config-validate": { + const snapshot = await readConfigFileSnapshotLazy(); + runtime.log(formatConfigValidationLine(snapshot)); + return { applied: false }; + } + case "config-get": { + const snapshot = await readConfigFileSnapshotLazy(); + if (!snapshot.exists) { + runtime.log(`Config missing: ${shortenHomePath(snapshot.path)}`); + return { applied: false }; + } + const cfg = snapshot.valid + ? (snapshot.sourceConfig ?? snapshot.config) + : snapshot.sourceConfig; + const lookup = readConfigValueAtPath(cfg ?? {}, operation.path); + if (!lookup.found) { + runtime.log( + `${operation.path}: not set. Use \`config schema ${operation.path}\` to see what is allowed.`, + ); + return { applied: false }; + } + const redacted = redactConfigValue(lookup.value, operation.path); + const rendered = JSON.stringify(redacted, null, 2) ?? "null"; + runtime.log( + rendered.length > CONFIG_GET_OUTPUT_MAX_CHARS + ? `${operation.path} = ${truncateUtf16Safe(rendered, CONFIG_GET_OUTPUT_MAX_CHARS)}\n… (truncated)` + : `${operation.path} = ${rendered}`, + ); + return { applied: false }; + } + case "config-schema": { + const { buildConfigSchema, lookupConfigSchema } = await import("../config/schema.js"); + const response = buildConfigSchema(); + const path = operation.path ?? "."; + const result = lookupConfigSchema(response, path); + if (!result) { + runtime.log(`No config schema at "${path}". Try \`config schema .\` for the root keys.`); + return { applied: false }; + } + const schema = result.schema as { + type?: string | string[]; + description?: string; + enum?: unknown[]; + default?: unknown; + }; + const childLines = result.children.slice(0, CONFIG_SCHEMA_CHILDREN_MAX).map((child) => { + const type = Array.isArray(child.type) ? child.type.join("|") : (child.type ?? "object"); + const bits = [ + type, + child.required ? "required" : undefined, + child.hasChildren ? "…" : undefined, + ] + .filter(Boolean) + .join(", "); + return ` - ${child.path} (${bits})`; + }); + runtime.log( + [ + `Schema for ${result.path === "" ? "." : result.path}:`, + schema.type + ? `type: ${Array.isArray(schema.type) ? schema.type.join("|") : schema.type}` + : undefined, + schema.description ? `description: ${schema.description}` : undefined, + schema.enum + ? `allowed values: ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}` + : undefined, + schema.default !== undefined ? `default: ${JSON.stringify(schema.default)}` : undefined, + ...(childLines.length > 0 ? ["keys:", ...childLines] : []), + result.children.length > CONFIG_SCHEMA_CHILDREN_MAX + ? `… +${result.children.length - CONFIG_SCHEMA_CHILDREN_MAX} more keys` + : undefined, + ] + .filter((line): line is string => line !== undefined) + .join("\n"), + ); + return { applied: false }; + } + case "channel-list": { + // Use the same discovery as channel setup (bundled plugins + trusted + // catalog), so the listing matches what `connect ` can configure + // even before any plugin registry is active. + const { resolved } = await resolveChannelSetupState(opts.deps); + const entries = resolved.entries.toSorted((a, b) => a.id.localeCompare(b.id)); + runtime.log( + [ + "Channels:", + ...entries.map( + (entry) => ` - ${entry.id}${entry.meta.label ? ` (${entry.meta.label})` : ""}`, + ), + "", + "Say `connect ` to walk through setup (for example `connect telegram`).", + ].join("\n"), + ); + return { applied: false }; + } + case "channel-info": { + const { cfg, installedPlugins, resolved, isConfigured } = await resolveChannelSetupState( + opts.deps, + ); + const channel = operation.channel.toLowerCase(); + const entry = resolved.entries.find((candidate) => candidate.id === channel); + if (!entry) { + const knownIds = resolved.entries.map((candidate) => candidate.id).toSorted(); + runtime.log( + [ + `Unknown channel: ${channel}`, + `Known channels: ${knownIds.length > 0 ? knownIds.join(", ") : "none"}`, + ].join("\n"), + ); + return { applied: false }; + } + const installed = + installedPlugins.some((plugin) => plugin.id === entry.id) || + resolved.installedCatalogById.has(entry.id); + runtime.log( + [ + `${entry.meta.label} (${entry.id})`, + entry.meta.blurb, + `Configured: ${isConfigured(cfg, entry.id) ? "yes" : "no"}`, + `Installed: ${installed ? "yes" : "no"}`, + `Docs: ${formatChannelDocsUrl(entry.meta.docsPath)}`, + "", + `Say \`connect ${entry.id}\` to set it up here, or \`open channel wizard for ${entry.id}\` for the masked terminal wizard.`, + ].join("\n"), + ); + return { applied: false }; + } + case "channel-setup": + // Channel setup is a multi-step wizard; only interactive OpenClaw (TUI + // chat bridge or the gateway chat) can host it. One-shot mode points at + // the guided paths. + runtime.log( + [ + `Connecting ${operation.channel} needs an interactive session.`, + "Run `openclaw setup` and say `connect " + operation.channel + "`,", + "or run `openclaw channels add` for the terminal wizard.", + ].join("\n"), + ); + return { applied: false }; + case "model-setup": + runtime.log( + [ + "Changing model providers must happen outside the inference session that powers OpenClaw.", + "Exit OpenClaw and run `openclaw onboard`; it stages credentials, live-tests the candidate route, and saves only a passing setup.", + ].join("\n"), + ); + return { applied: false }; + case "open-setup": { + const command = + operation.target === "guided" + ? "openclaw onboard" + : operation.target === "classic" + ? "openclaw onboard --classic" + : `openclaw channels add${operation.channel ? ` --channel ${operation.channel}` : ""}`; + runtime.log( + `One-shot mode cannot open an interactive wizard. Run \`${command}\` in a terminal.`, + ); + return { applied: false }; + } + case "setup": + return await executeSetup(operation, runtime, opts); + case "config-set": + await assertConfigWriteDoesNotBypassInferenceVerification(operation); + return await applyPersistentOperation({ + auditOperation: "config.set", + operation, + runtime, + opts, + run: async (ctx) => { + await runConfigSetOperation({ operation, ctx }); + return { summary: `Set config ${operation.path}`, details: { path: operation.path } }; + }, + }); + case "config-set-ref": + await assertConfigWriteDoesNotBypassInferenceVerification(operation); + return await applyPersistentOperation({ + auditOperation: "config.setRef", + operation, + runtime, + opts, + run: async (ctx) => { + await runConfigSetOperation({ operation, ctx }); + return { + summary: `Set config ${operation.path} SecretRef`, + details: { + path: operation.path, + source: operation.source, + provider: operation.provider ?? "default", + }, + }; + }, + }); + case "plugin-install": + return await executePluginInstall(operation, runtime, opts); + case "plugin-uninstall": { + const message = [ + "OpenClaw cannot prove that uninstalling a plugin will preserve its own active inference route.", + `Exit OpenClaw and run \`openclaw plugins uninstall ${operation.pluginId}\` from a terminal.`, + ].join("\n"); + runtime.log(message); + return { applied: false, message }; + } + case "create-agent": { + if (isReservedSystemAgentId(operation.agentId)) { + throw new Error( + `Agent id "${normalizeAgentId(operation.agentId)}" is reserved for the system agent. Choose a different agent id.`, + ); + } + if (operation.model?.trim()) { + throw new Error( + "OpenClaw cannot save an explicit per-agent model until that new route can be live-tested. Retry without `model`; the new agent will inherit the already verified default model.", + ); + } + const workspace = resolveUserPath(operation.workspace ?? process.cwd()); + return await applyPersistentOperation({ + auditOperation: "agents.create", + operation, + runtime, + opts, + run: async (ctx) => { + const runAgentsAdd = + ctx.deps?.runAgentsAdd ?? + (await import("../commands/agents.commands.add.js")).agentsAddCommand; + await ctx.commit(async () => { + await runAgentsAdd( + { + name: operation.agentId, + workspace, + nonInteractive: true, + }, + ctx.runtime, + { hasFlags: true }, + ); + }); + return { + summary: `Created agent ${operation.agentId}`, + details: { + agentId: operation.agentId, + workspace, + }, + }; + }, + }); + } + case "doctor": { + const runDoctor = + opts.deps?.runDoctor ?? (await import("../commands/doctor.js")).doctorCommand; + await runDoctor(runtime, { nonInteractive: true }); + return { applied: false }; + } + case "doctor-fix": + runtime.log( + "Doctor repairs can change the inference route that powers this session. Exit OpenClaw and run `openclaw doctor --fix` in a terminal.", + ); + return { applied: false }; + case "status": { + const { statusCommand } = await import("../commands/status.command.js"); + await statusCommand({ timeoutMs: 10_000 }, runtime); + return { applied: false }; + } + case "health": { + const { healthCommand } = await import("../commands/health.js"); + await healthCommand({ timeoutMs: 10_000 }, runtime); + return { applied: false }; + } + case "gateway-status": { + const overview = await loadOverviewForOperation(opts.deps); + runtime.log(formatGatewayStatusLine(overview)); + return { applied: false }; + } + case "gateway-start": + return await applyPersistentOperation({ + auditOperation: "gateway.start", + operation, + runtime, + opts, + run: async (ctx) => { + const runGatewayStart = ctx.deps?.runGatewayStart ?? (() => runGatewayLifecycle("start")); + await ctx.commit(runGatewayStart); + return { summary: "Started Gateway" }; + }, + }); + case "gateway-stop": + return await applyPersistentOperation({ + auditOperation: "gateway.stop", + operation, + runtime, + opts, + run: async (ctx) => { + const runGatewayStop = ctx.deps?.runGatewayStop ?? (() => runGatewayLifecycle("stop")); + await ctx.commit(runGatewayStop); + return { summary: "Stopped Gateway" }; + }, + }); + case "gateway-restart": + return await applyPersistentOperation({ + auditOperation: "gateway.restart", + operation, + runtime, + opts, + run: async (ctx) => { + const runGatewayRestart = + ctx.deps?.runGatewayRestart ?? (() => runGatewayLifecycle("restart")); + const restarted = await ctx.commit(runGatewayRestart); + if (restarted === false) { + throw new Error("Gateway restart did not complete"); + } + return { summary: "Restarted Gateway" }; + }, + }); + case "open-tui": { + const agentId = await resolveTuiAgentId({ + requestedAgentId: operation.agentId, + requestedWorkspace: operation.workspace, + deps: opts.deps, + }); + 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-system-agent") { + runtime.log( + result.systemAgentMessage + ? `[openclaw] returned from agent with request: ${result.systemAgentMessage}` + : "[openclaw] returned from agent", + ); + return { applied: false, returnToShell: true, nextInput: result.systemAgentMessage }; + } + return { applied: false, exitsInteractive: true }; + } + case "set-default-model": + return await executeSetDefaultModel(operation, runtime, opts); + default: + return { applied: false }; + } +} diff --git a/src/system-agent/operations-execution-helpers.ts b/src/system-agent/operations-execution-helpers.ts new file mode 100644 index 000000000000..bf836fe07903 --- /dev/null +++ b/src/system-agent/operations-execution-helpers.ts @@ -0,0 +1,638 @@ +// Shared execution helpers keep the public dispatcher small and reviewable. +import type { ConfigSetOptions } from "../cli/config-set-input.js"; +import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { appendSystemAgentAuditEntry } from "./audit.js"; +import { + projectDefaultInferenceRoute, + sameDefaultInferenceRoute, + type DefaultInferenceRouteProjection, +} from "./inference-route.js"; +import type { + SystemAgentCommandDeps, + SystemAgentOperation, + SystemAgentOperationResult, +} from "./operations-parse.js"; +import { formatSystemAgentPersistentPlan } from "./operations-parse.js"; +import type { SystemAgentOverview } from "./overview.js"; +import { validateSystemAgentPluginInstallSpec } from "./plugin-install.js"; + +type ConfigModule = typeof import("../config/config.js"); +type ConfigFileSnapshot = Awaited>; +const loadConfigModule = async () => await import("../config/config.js"); +const loadOverviewModule = async () => await import("./overview.js"); + +export const CONFIG_GET_OUTPUT_MAX_CHARS = 2_000; +export const CONFIG_SCHEMA_CHILDREN_MAX = 40; + +export function redactConfigValue(value: unknown, configPath: string): unknown { + if (typeof value === "string" || typeof value === "number") { + return isSensitiveConfigPath(configPath) ? "" : value; + } + if (Array.isArray(value)) { + return value.map((entry) => redactConfigValue(entry, `${configPath}[]`)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + redactConfigValue(entry, configPath ? `${configPath}.${key}` : key), + ]), + ); + } + return value; +} + +export function readConfigValueAtPath( + config: unknown, + path: string, +): { found: boolean; value?: unknown } { + let current: unknown = config; + for (const rawSegment of path.split(".")) { + // Support foo[0] style array segments alongside dotted keys. + const parts = rawSegment.split(/[[\]]/).filter(Boolean); + for (const part of parts) { + if (current === null || typeof current !== "object") { + return { found: false }; + } + const index = /^\d+$/.test(part) ? Number(part) : undefined; + if (index !== undefined && Array.isArray(current)) { + current = current[index]; + } else { + current = (current as Record)[part]; + } + if (current === undefined) { + return { found: false }; + } + } + } + return { found: true, value: current }; +} + +export function formatGatewayStatusLine(overview: SystemAgentOverview): 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"); +} + +export 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; + } + return await lifecycle.runDaemonRestart(); +} + +export async function readConfigFileSnapshotLazy(): Promise { + const { readConfigFileSnapshot } = await loadConfigModule(); + return await readConfigFileSnapshot(); +} + +export async function loadOverviewForOperation( + deps: SystemAgentCommandDeps | undefined, +): Promise { + if (deps?.loadOverview) { + return await deps.loadOverview(); + } + const { loadSystemAgentOverview } = await loadOverviewModule(); + return await loadSystemAgentOverview(); +} + +export async function resolveChannelSetupState(deps: SystemAgentCommandDeps | undefined) { + const listPlugins = + deps?.listChannelSetupPlugins ?? + (await import("../channels/plugins/setup-registry.js")).listChannelSetupPlugins; + const resolveEntries = + deps?.resolveChannelSetupEntries ?? + (await import("../commands/channel-setup/discovery.js")).resolveChannelSetupEntries; + const isConfigured = + deps?.isChannelConfigured ?? + (await import("../config/channel-configured-shared.js")).isStaticallyChannelConfigured; + const { shouldShowChannelInSetup } = await import("../commands/channel-setup/discovery.js"); + const snapshot = await readConfigFileSnapshotLazy(); + const cfg = snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; + const installedPlugins = listPlugins(); + const resolved = resolveEntries({ cfg, installedPlugins }); + return { + cfg, + installedPlugins, + resolved: { + ...resolved, + // Match the connect/list surfaces: setup-hidden channels stay invisible + // to chat listings and channel info alike. + entries: resolved.entries.filter((entry) => shouldShowChannelInSetup(entry.meta)), + }, + isConfigured, + }; +} + +export function formatChannelDocsUrl(docsPath: string): string { + return `https://docs.openclaw.ai${docsPath.startsWith("/") ? docsPath : `/${docsPath}`}`; +} + +export function formatConfigValidationLine(snapshot: ConfigFileSnapshot): 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}`); + }, + }; +} + +export async function resolveTuiAgentId(params: { + requestedAgentId: string | undefined; + requestedWorkspace?: string; + deps?: SystemAgentCommandDeps; +}): Promise { + const overview = await loadOverviewForOperation(params.deps); + 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 type ExecuteOptions = { + approved?: boolean; + deps?: SystemAgentCommandDeps; + auditDetails?: Record; + /** + * Authority check used by the guarded commit seam for host-approved writes. + * A multi-step operation may invoke it more than once; every invocation is + * immediately followed by the persistent effect it authorizes. + */ + beforePersistentApply?: () => Promise; +}; + +/** + * One persistent operation = one audited apply. The shared wrapper owns the + * approval gate, before/after config hashes, the audit record, and the + * `[openclaw] running/done` markers the e2e lanes assert on; each spec only + * describes what to run and what to record. + */ +type PersistentApplyContext = { + runtime: RuntimeEnv; + deps?: SystemAgentCommandDeps; + /** Re-check authority, then enter one persistent side-effect boundary. */ + commit(effect: () => Promise | T): Promise; +}; + +type PersistentApplyOutcome = { + summary: string; + details?: Record; + /** Overrides the after-snapshot config path in the audit record. */ + configPath?: string; +}; + +export async function applyPersistentOperation(params: { + auditOperation: string; + operation: SystemAgentOperation; + runtime: RuntimeEnv; + opts: ExecuteOptions; + run: (ctx: PersistentApplyContext) => Promise; +}): Promise { + const { auditOperation, runtime, opts } = params; + if (!opts.approved) { + const message = formatSystemAgentPersistentPlan(params.operation); + runtime.log(message); + return { applied: false, message }; + } + runtime.log(`[openclaw] running: ${auditOperation}`); + const { readConfigFileSnapshot } = await loadConfigModule(); + const before = await readConfigFileSnapshot(); + const commit: PersistentApplyContext["commit"] = async (effect) => { + await opts.beforePersistentApply?.(); + return await effect(); + }; + const outcome = await params.run({ runtime, deps: opts.deps, commit }); + const after = await readConfigFileSnapshot(); + try { + await appendSystemAgentAuditEntry({ + operation: auditOperation, + summary: outcome.summary, + configPath: outcome.configPath ?? after.path ?? before.path ?? undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: { ...opts.auditDetails, ...outcome.details }, + }); + } catch (error) { + // The mutation already committed. Keep success truthful while making the + // missing audit record visible to every CLI/chat capture surface. + runtime.error( + `${outcome.summary}, but OpenClaw could not record its audit entry: ${formatErrorMessage(error)}`, + ); + } + runtime.log(`[openclaw] done: ${auditOperation}`); + return { applied: true }; +} + +export async function runConfigSetOperation(params: { + operation: Extract; + ctx: PersistentApplyContext; +}): Promise { + const { operation, ctx } = params; + const runConfigSet = + ctx.deps?.runConfigSet ?? + (async (setOpts: { path?: string; value?: string; cliOptions: ConfigSetOptions }) => { + const { runConfigSet: importedRunConfigSet } = await import("../cli/config-cli.js"); + await importedRunConfigSet({ + ...setOpts, + runtime: createNoExitRuntime(ctx.runtime), + }); + }); + if (operation.kind === "config-set") { + await ctx.commit(async () => { + await runConfigSet({ path: operation.path, value: operation.value, cliOptions: {} }); + }); + return; + } + await ctx.commit(async () => { + await runConfigSet({ + path: operation.path, + cliOptions: { + refProvider: operation.provider ?? "default", + refSource: operation.source, + refId: operation.id, + }, + }); + }); +} + +function isInferenceRouteConfigPath(path: readonly string[]): boolean { + const segments = path.map((segment) => segment.trim().toLowerCase()).filter(Boolean); + const [root, scope, ownerOrField, field] = segments; + if (["$include", "auth", "env", "models", "plugins", "secrets", "tools"].includes(root ?? "")) { + return true; + } + if (root !== "agents") { + return false; + } + if (!scope || (scope === "defaults" && !ownerOrField) || (scope === "list" && !ownerOrField)) { + return true; + } + if (scope === "defaults") { + return ["agentruntime", "clibackends", "model", "models", "params", "tools"].includes( + ownerOrField ?? "", + ); + } + if (scope !== "list") { + return false; + } + if (/^\d+$/.test(ownerOrField ?? "") && !field) { + return true; + } + const routeField = /^\d+$/.test(ownerOrField ?? "") ? field : ownerOrField; + return [ + "agentdir", + "agentruntime", + "clibackends", + "default", + "id", + "model", + "models", + "params", + "tools", + ].includes(routeField ?? ""); +} + +export async function assertConfigWriteDoesNotBypassInferenceVerification( + operation: Extract, +): Promise { + const { parseConfigSetPath } = await import("../cli/config-cli.js"); + if (!isInferenceRouteConfigPath(parseConfigSetPath(operation.path))) { + return; + } + throw new Error( + "Direct config writes cannot change inference routing or include alternate config. Use `set default model ` for an already configured route, or exit OpenClaw and run `openclaw onboard` to change provider/auth access.", + ); +} + +async function verifyCurrentSetupInference( + runtime: RuntimeEnv, + deps?: SystemAgentCommandDeps, +): Promise<{ + modelRef: string; + route: DefaultInferenceRouteProjection; + latencyMs: number; +}> { + const { readConfigFileSnapshot } = await loadConfigModule(); + const before = await readConfigFileSnapshot(); + if (!before.exists || !before.valid) { + throw new Error( + "OpenClaw setup requires a valid configured inference route. Exit OpenClaw and run `openclaw onboard`, then retry.", + ); + } + const beforeConfig = before.runtimeConfig ?? before.config; + const beforeRoute = await projectDefaultInferenceRoute(beforeConfig); + if (!beforeRoute.route) { + throw new Error( + "OpenClaw setup requires working inference first. Exit OpenClaw and run `openclaw onboard`, then retry.", + ); + } + const verifyInferenceConfig = + deps?.verifyInferenceConfig ?? + (await import("./setup-inference.js")).verifySetupInferenceConfig; + const verification = await verifyInferenceConfig({ config: beforeConfig, runtime }); + if (!verification.ok) { + throw new Error( + `OpenClaw setup requires working inference first. The configured route failed a live check: ${verification.error} Exit OpenClaw and run \`openclaw onboard\`, then retry.`, + ); + } + + const after = await readConfigFileSnapshot(); + if (!after.exists || !after.valid) { + throw new Error( + "The default-agent inference route changed during setup verification, so setup was not applied. Review the current config and retry.", + ); + } + const afterConfig = after.runtimeConfig ?? after.config; + const afterRoute = await projectDefaultInferenceRoute(afterConfig); + if ( + !sameDefaultInferenceRoute(beforeRoute, afterRoute) || + verification.modelRef !== afterRoute.route?.modelLabel + ) { + throw new Error( + "The default-agent inference route changed during setup verification, so setup was not applied. Review the current model/auth/runtime settings and retry.", + ); + } + return { + modelRef: verification.modelRef, + route: afterRoute, + latencyMs: verification.latencyMs, + }; +} + +export async function executeSetup( + operation: Extract, + runtime: RuntimeEnv, + opts: ExecuteOptions, +): Promise { + const overview = await loadOverviewForOperation(opts.deps); + const defaultModel = overview.defaultModel?.trim(); + if (!defaultModel) { + throw new Error( + "OpenClaw setup requires working inference first. Run `openclaw onboard` to configure and verify a default model, then start OpenClaw again.", + ); + } + const requestedModel = operation.model?.trim(); + if (requestedModel && requestedModel !== defaultModel) { + throw new Error( + `OpenClaw setup will preserve the verified default model ${defaultModel}. Exit OpenClaw and run \`openclaw onboard\` to stage, live-test, and save a different inference route.`, + ); + } + if (!opts.approved) { + const message = [ + formatSystemAgentPersistentPlan(operation), + `Model choice: keep verified default ${defaultModel}.`, + ].join("\n"); + runtime.log(message); + return { applied: false, message }; + } + const verified = await verifyCurrentSetupInference(runtime, opts.deps); + if (requestedModel && requestedModel !== verified.modelRef) { + throw new Error( + `The verified default model is now ${verified.modelRef}, not ${requestedModel}. Review the current route or exit OpenClaw and run \`openclaw onboard\` before retrying setup.`, + ); + } + const workspace = resolveUserPath(operation.workspace ?? process.cwd()); + return await applyPersistentOperation({ + auditOperation: "openclaw.setup", + operation, + runtime, + opts, + run: async (ctx) => { + const applySetup = + ctx.deps?.applySetup ?? (await import("./setup-apply.js")).applySystemAgentSetup; + const surface = ctx.deps?.setupSurface ?? "cli"; + // The outer boundary covers injected implementations. The production + // setup helper also uses this same seam for each of its internal writes. + const applied = await ctx.commit( + async () => + await applySetup( + { + workspace, + expectedInferenceRoute: verified.route, + surface, + runtime: ctx.runtime, + }, + { commit: async (effect) => await ctx.commit(effect) }, + ), + ); + const after = await readConfigFileSnapshotLazy(); + ctx.runtime.log(`Updated ${after.path || applied.configPath || "config"}`); + for (const line of applied.lines) { + ctx.runtime.log(line); + } + ctx.runtime.log(`Default model: ${verified.modelRef} (verified and kept)`); + return { + summary: "Bootstrapped setup workspace", + configPath: after.path || applied.configPath, + details: { + workspace, + model: verified.modelRef, + modelSource: "live-verified default model", + inferenceLatencyMs: verified.latencyMs, + }, + }; + }, + }); +} + +export async function executeSetDefaultModel( + operation: Extract, + runtime: RuntimeEnv, + opts: ExecuteOptions, +): Promise { + return await applyPersistentOperation({ + auditOperation: "config.setDefaultModel", + operation, + runtime, + opts, + run: async (ctx) => { + const { mutateConfigFile, readConfigFileSnapshot } = await loadConfigModule(); + const { applySystemAgentModelSelection, createSystemAgentModelSelectionUpdater } = + await import("./setup-apply.js"); + const snapshot = await readConfigFileSnapshot(); + const stagedConfig = await applySystemAgentModelSelection({ + config: snapshot.sourceConfig, + model: operation.model, + }); + const beforeRoute = await projectDefaultInferenceRoute(snapshot.sourceConfig); + const verifiedRoute = await projectDefaultInferenceRoute(stagedConfig); + const verifyInferenceConfig = + ctx.deps?.verifyInferenceConfig ?? + (await import("./setup-inference.js")).verifySetupInferenceConfig; + const initialVerification = await verifyInferenceConfig({ + config: stagedConfig, + runtime: ctx.runtime, + requireExecutionOwner: true, + }); + if (!initialVerification.ok) { + throw new Error( + `The requested model failed a live inference test, so the current default model was not changed. ${initialVerification.error} Fix provider authentication or model access, then retry.`, + ); + } + const verifiedModelRef = verifiedRoute.route?.modelLabel; + if (!verifiedModelRef || initialVerification.modelRef !== verifiedModelRef) { + throw new Error( + "The live inference test did not verify the exact model route that would be saved, so the current default model was not changed. Review model aliases and runtime routing, then retry.", + ); + } + let persistedVerification = initialVerification; + let selectedRouteForCommit = verifiedRoute; + const selectModel = await createSystemAgentModelSelectionUpdater({ + model: operation.model, + }); + const result = await mutateConfigFile({ + base: "source", + writeOptions: { + preCommitRuntimePreflight: async (sourceConfig) => { + const commitRoute = await projectDefaultInferenceRoute(sourceConfig); + if (!sameDefaultInferenceRoute(commitRoute, selectedRouteForCommit)) { + throw new Error( + "The selected inference route changed while preparing the config write, so the requested model was not saved. Review the current model/auth/runtime settings and retry.", + ); + } + await opts.beforePersistentApply?.(); + const latestVerification = await verifyInferenceConfig({ + config: sourceConfig, + runtime: ctx.runtime, + requireExecutionOwner: true, + }); + if (!latestVerification.ok) { + throw new Error( + `The requested model no longer passes live inference at the config commit boundary, so it was not saved. ${latestVerification.error} Review concurrent configuration changes and retry.`, + ); + } + if (latestVerification.modelRef !== commitRoute.route?.modelLabel) { + throw new Error( + "The final live inference test did not verify the exact model route at the config commit boundary, so the requested model was not saved. Review model aliases and runtime routing, then retry.", + ); + } + // The live probe can outlive the original OpenClaw authority. + // Re-check it last, immediately before the writer crosses to disk. + await opts.beforePersistentApply?.(); + persistedVerification = latestVerification; + }, + }, + mutate: async (cfg) => { + // Verification may take time. Preserve unrelated edits, but never + // combine the passing result with a concurrently changed route. + const currentRoute = await projectDefaultInferenceRoute(cfg); + if (!sameDefaultInferenceRoute(currentRoute, beforeRoute)) { + throw new Error( + "The default-agent inference route changed during verification, so the requested model was not saved. Review the current model/auth/runtime settings and retry.", + ); + } + const selected = selectModel(cfg); + const selectedRoute = await projectDefaultInferenceRoute(selected); + if (selectedRoute.route?.modelLabel !== verifiedModelRef) { + throw new Error( + "The model selection no longer resolves to the exact model that passed live inference. Review the current model/auth/runtime settings and retry.", + ); + } + // Unrelated concurrent edits can change how the selected model is + // represented. Bind the commit gate to this deterministic projection; + // the final live probe below verifies these exact bytes before write. + selectedRouteForCommit = selectedRoute; + cfg.agents = selected.agents; + }, + }); + ctx.runtime.log(`Updated ${result.path}`); + ctx.runtime.log(`Default model: ${persistedVerification.modelRef}`); + return { + summary: `Set default model to ${operation.model}`, + configPath: result.path, + details: { + requestedModel: operation.model, + effectiveModel: persistedVerification.modelRef, + inferenceVerified: true, + inferenceLatencyMs: persistedVerification.latencyMs, + }, + }; + }, + }); +} + +export async function executePluginInstall( + operation: Extract, + runtime: RuntimeEnv, + opts: ExecuteOptions, +): Promise { + if (opts.approved) { + const validationError = validateSystemAgentPluginInstallSpec(operation.spec); + if (validationError) { + throw new Error(validationError); + } + } + const result = await applyPersistentOperation({ + auditOperation: "plugin.install", + operation, + runtime, + opts, + run: async (ctx) => { + const runPluginInstall = + ctx.deps?.runPluginInstall ?? + (async (spec: string, pluginRuntime: RuntimeEnv) => { + const { runPluginInstallCommand } = await import("../cli/plugins-install-command.js"); + await runPluginInstallCommand({ raw: spec, opts: {}, runtime: pluginRuntime }); + }); + await ctx.commit(async () => { + await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime)); + }); + return { summary: `Installed plugin ${operation.spec}`, details: { spec: operation.spec } }; + }, + }); + if (result.applied) { + runtime.log("Restart the Gateway to apply installed plugin changes."); + } + return result; +} diff --git a/src/system-agent/operations-parse.ts b/src/system-agent/operations-parse.ts new file mode 100644 index 000000000000..37deb13db02c --- /dev/null +++ b/src/system-agent/operations-parse.ts @@ -0,0 +1,466 @@ +// OpenClaw operation grammar, approval descriptions, and public types. +import type { ConfigSetOptions } from "../cli/config-set-input.js"; +import type { DoctorOptions } from "../commands/doctor.types.js"; +import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; +import { 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 { isReservedSystemAgentId } from "./agent-id.js"; +import type { SystemAgentOverview } from "./overview.js"; +import { validateSystemAgentPluginInstallSpec } from "./plugin-install.js"; + +type SystemAgentOverviewLoader = () => Promise; +type SystemAgentOverviewFormatter = (overview: SystemAgentOverview) => string; + +/** Parsed OpenClaw operation before approval/execution. */ +export type SystemAgentOperation = + | { kind: "none"; message: string } + | { kind: "overview" } + | { kind: "doctor" } + | { kind: "doctor-fix" } + | { kind: "status" } + | { kind: "health" } + | { kind: "config-validate" } + | { kind: "config-get"; path: string } + | { kind: "config-schema"; path?: string } + | { 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: "model-setup"; workspace?: string } + | { kind: "channel-list" } + | { kind: "channel-info"; channel: string } + | { kind: "channel-setup"; channel: string } + | { + kind: "open-setup"; + target: "guided" | "classic" | "channels"; + channel?: string; + } + | { kind: "gateway-status" } + | { kind: "gateway-start" } + | { kind: "gateway-stop" } + | { kind: "gateway-restart" } + | { kind: "agents" } + | { kind: "models" } + | { kind: "plugin-list" } + | { kind: "plugin-search"; query: string } + | { kind: "plugin-install"; spec: string } + | { kind: "plugin-uninstall"; pluginId: string } + | { kind: "audit" } + | { kind: "create-agent"; agentId: string; workspace?: string; model?: string } + | { kind: "open-tui"; agentId?: string; workspace?: string } + | { kind: "set-default-model"; model: string }; + +/** Result returned by the operation executor. */ +export type SystemAgentOperationResult = { + applied: boolean; + exitsInteractive?: boolean; + message?: string; + nextInput?: string; + /** Agent TUI exited via /openclaw: re-enter the shell even without a request. */ + returnToShell?: boolean; + followUp?: Extract; +}; + +/** Injectable command dependencies used by tests and alternate runners. */ +export type SystemAgentCommandDeps = { + readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot; + ensureAuthProfileStore?: typeof import("../agents/auth-profiles/store.js").ensureAuthProfileStore; + resolveCliAuthBindingFingerprint?: typeof import("../agents/cli-auth-epoch.js").resolveCliAuthBindingFingerprint; + resolveApiKeyForProvider?: typeof import("../agents/model-auth.js").resolveApiKeyForProvider; + formatOverview?: SystemAgentOverviewFormatter; + loadOverview?: SystemAgentOverviewLoader; + 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; + runPluginInstall?: (spec: string, runtime: RuntimeEnv) => Promise; + runPluginUninstall?: (pluginId: string, runtime: RuntimeEnv) => Promise; + runPluginsList?: (runtime: RuntimeEnv) => Promise; + runPluginsSearch?: (query: string, runtime: RuntimeEnv) => Promise; + runTui?: (opts: { + local: boolean; + session?: string; + deliver?: boolean; + historyLimit?: number; + }) => Promise; + /** Where setup side effects run; the gateway surface never manages its own daemon. */ + setupSurface?: "cli" | "gateway"; + applySetup?: typeof import("./setup-apply.js").applySystemAgentSetup; + verifyInferenceConfig?: typeof import("./setup-inference.js").verifySetupInferenceConfig; + listChannelSetupPlugins?: typeof import("../channels/plugins/setup-registry.js").listChannelSetupPlugins; + resolveChannelSetupEntries?: typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; + isChannelConfigured?: typeof import("../config/channel-configured-shared.js").isStaticallyChannelConfigured; +}; + +// Grammar tokens. Workspace/path tokens accept quoted strings so paths with +// spaces survive; model refs and ids stay single tokens. +const ARG_WORD = String.raw`(?:"[^"]+"|'[^']+'|\S+)`; +const CONFIG_PATH = String.raw`[A-Za-z0-9_.[\]-]+`; + +// Every command pattern is anchored to the whole input. Optional clauses use a +// fixed order (workspace before model) so filler words never become values. +const CONFIG_SET_RE = new RegExp( + String.raw`^(?:config\s+set|set\s+config)\s+(?${CONFIG_PATH})\s+(?.+)$`, + "i", +); +const CONFIG_GET_RE = new RegExp(String.raw`^config\s+get\s+(?${CONFIG_PATH})$`, "i"); +const CONFIG_SCHEMA_RE = new RegExp( + String.raw`^config\s+schema(?:\s+(?${CONFIG_PATH}))?$`, + "i", +); +const CONFIG_SET_REF_RE = new RegExp( + String.raw`^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?${CONFIG_PATH})\s+(?:(?env|file|exec)\s+)?(?\S+)(?:\s+provider\s+(?[A-Za-z0-9_-]+))?$`, + "i", +); +const SETUP_RE = new RegExp( + String.raw`^(?:setup|set\s+me\s+up|set\s+up\s+openclaw|onboard(?:\s+me)?|bootstrap|first\s+run)(?:\s+workspace\s+(?${ARG_WORD}))?(?:\s+model\s+(?\S+))?$`, + "i", +); +const MODEL_SETUP_RE = new RegExp( + String.raw`^(?:configure\s+(?:a\s+)?model\s+provider|set\s*up\s+(?:a\s+)?model\s+provider|model\s+setup)(?:\s+workspace\s+(?${ARG_WORD}))?$`, + "i", +); +const CREATE_AGENT_RE = new RegExp( + String.raw`^(?:create|add|set\s*up|new)\s+(?:(?:an?|new|my)\s+)?agent\s+(?[a-z0-9_-]+)(?:\s+workspace\s+(?${ARG_WORD}))?(?:\s+model\s+(?\S+))?$`, + "i", +); +// "talk to agent for ~/Projects/work" is a documented selector; "for|in" are +// only valid here, after the literal word "agent", never as generic fillers. +const TALK_AGENT_RE = new RegExp( + String.raw`^(?:talk\s+to|switch\s+to|open|enter)\s+(?:(?:my|the)\s+)?(?:(?[a-z0-9_-]+)\s+)?agent(?:\s+(?:for|in|workspace)\s+(?${ARG_WORD}))?$`, + "i", +); +const SET_MODEL_RE = /^(?:set|configure|use)\s+(?:the\s+)?(?:default\s+)?models?\s+(?\S+)$/i; +const GATEWAY_RE = + /^(?:gateway\s+(?status|start|stop|restart)|(?start|stop|restart)\s+(?:the\s+)?gateway)$/i; +const PLUGIN_LIST_RE = /^(?:(?:plugins?|clawhub)\s+list|list\s+plugins?)$/i; +const PLUGIN_SEARCH_RE = + /^(?:(?:plugins?|clawhub)\s+search|search\s+plugins?(?:\s+for)?)\s+(?.+)$/i; +const PLUGIN_INSTALL_RE = + /^(?:plugins?\s+install|install\s+(?:(?npm|clawhub)\s+)?plugins?)\s+(?\S+)$/i; +const PLUGIN_UNINSTALL_RE = + /^(?:plugins?\s+(?:uninstall|remove)|(?:uninstall|remove)\s+plugins?)\s+(?[A-Za-z0-9_.@/-]+)$/i; +const CHANNEL_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i; +const CHANNEL_CONNECT_RE = + /^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?[a-z0-9_-]+)(?:\s+channel)?$/i; +const CHANNEL_INFO_RE = + /^(?:channel\s+info\s+(?[a-z0-9_-]+)|about\s+(?[a-z0-9_-]+)\s+channel)$/i; +const OPEN_GUIDED_SETUP_RE = + /^(?:open\s+setup\s+wizard|setup\s+wizard|menu\s+setup|use\s+the\s+(?:setup\s+)?wizard)$/i; +const OPEN_CLASSIC_SETUP_RE = /^(?:open\s+classic(?:\s+setup)?\s+wizard|classic\s+setup)$/i; +const OPEN_CHANNEL_SETUP_RE = /^open\s+channel\s+wizard(?:\s+for\s+(?[a-z0-9_-]+))?$/i; + +const NO_MATCH_MESSAGE = + "I can run doctor/status/health, check or restart Gateway, list agents/models, configure a model provider, set default model, connect channels (`connect telegram`), show `channel info `, open the setup wizard, show audit, or switch to your agent TUI."; +/** + * Parse one user command into OpenClaw's closed operation union. Anything + * that does not match the anchored grammar exactly returns kind "none" so the + * caller can route it to the system agent (or show guidance). + */ +export function parseSystemAgentOperation(input: string): SystemAgentOperation { + 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" }; + } + switch (lower) { + case "audit": + case "audit log": + case "show audit": + return { kind: "audit" }; + case "status": + return { kind: "status" }; + case "health": + return { kind: "health" }; + case "doctor": + return { kind: "doctor" }; + case "doctor fix": + case "doctor repair": + return { kind: "doctor-fix" }; + case "config validate": + case "validate config": + return { kind: "config-validate" }; + case "agents": + case "list agents": + return { kind: "agents" }; + case "models": + case "list models": + return { kind: "models" }; + case "tui": + case "open tui": + case "chat": + return { kind: "open-tui" }; + case "quit": + case "exit": + return { kind: "none", message: "OpenClaw retracts into shell. Bye." }; + default: + break; + } + const configSetRefMatch = trimmed.match(CONFIG_SET_REF_RE); + if (configSetRefMatch?.groups?.path && configSetRefMatch.groups.id?.trim()) { + // SecretRef commands store references only; raw secret values are never embedded here. + 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(), + }; + } + const configGetMatch = trimmed.match(CONFIG_GET_RE); + if (configGetMatch?.groups?.path) { + return { kind: "config-get", path: configGetMatch.groups.path }; + } + const configSchemaMatch = trimmed.match(CONFIG_SCHEMA_RE); + if (configSchemaMatch) { + const path = configSchemaMatch.groups?.path?.trim(); + return { kind: "config-schema", ...(path ? { path } : {}) }; + } + if (PLUGIN_LIST_RE.test(trimmed)) { + return { kind: "plugin-list" }; + } + const pluginSearchMatch = trimmed.match(PLUGIN_SEARCH_RE); + if (pluginSearchMatch?.groups?.query?.trim()) { + return { kind: "plugin-search", query: pluginSearchMatch.groups.query.trim() }; + } + const pluginInstallMatch = trimmed.match(PLUGIN_INSTALL_RE); + if (pluginInstallMatch?.groups?.spec?.trim()) { + const spec = normalizePluginInstallSpec( + pluginInstallMatch.groups.spec.trim(), + pluginInstallMatch.groups.source, + ); + const validationError = validateSystemAgentPluginInstallSpec(spec); + if (validationError) { + return { kind: "none", message: validationError }; + } + return { kind: "plugin-install", spec }; + } + const pluginUninstallMatch = trimmed.match(PLUGIN_UNINSTALL_RE); + if (pluginUninstallMatch?.groups?.pluginId?.trim()) { + return { kind: "plugin-uninstall", pluginId: pluginUninstallMatch.groups.pluginId.trim() }; + } + if (CHANNEL_LIST_RE.test(trimmed)) { + return { kind: "channel-list" }; + } + const channelInfoMatch = trimmed.match(CHANNEL_INFO_RE); + const channelInfo = channelInfoMatch?.groups?.channel ?? channelInfoMatch?.groups?.aboutChannel; + if (channelInfo) { + return { kind: "channel-info", channel: channelInfo.toLowerCase() }; + } + const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE); + if (channelConnectMatch?.groups?.channel) { + return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() }; + } + const modelSetupMatch = trimmed.match(MODEL_SETUP_RE); + if (modelSetupMatch) { + const workspace = trimShellishToken(modelSetupMatch.groups?.workspace); + return { + kind: "model-setup", + ...(workspace ? { workspace } : {}), + }; + } + if (OPEN_GUIDED_SETUP_RE.test(trimmed)) { + return { kind: "open-setup", target: "guided" }; + } + if (OPEN_CLASSIC_SETUP_RE.test(trimmed)) { + return { kind: "open-setup", target: "classic" }; + } + const openChannelSetupMatch = trimmed.match(OPEN_CHANNEL_SETUP_RE); + if (openChannelSetupMatch) { + const channel = openChannelSetupMatch.groups?.channel?.toLowerCase(); + return { + kind: "open-setup", + target: "channels", + ...(channel ? { channel } : {}), + }; + } + const setupMatch = trimmed.match(SETUP_RE); + if (setupMatch) { + const workspace = trimShellishToken(setupMatch.groups?.workspace); + const model = setupMatch.groups?.model; + return { + kind: "setup", + ...(workspace ? { workspace } : {}), + ...(model ? { model } : {}), + }; + } + const gatewayMatch = trimmed.match(GATEWAY_RE); + if (gatewayMatch) { + const action = (gatewayMatch.groups?.sub ?? gatewayMatch.groups?.verb ?? "").toLowerCase(); + if (action === "start") { + return { kind: "gateway-start" }; + } + if (action === "stop") { + return { kind: "gateway-stop" }; + } + if (action === "restart") { + return { kind: "gateway-restart" }; + } + return { kind: "gateway-status" }; + } + const createMatch = trimmed.match(CREATE_AGENT_RE); + if (createMatch?.groups?.agent) { + const workspace = trimShellishToken(createMatch.groups.workspace); + const model = createMatch.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(talkMatch.groups?.workspace); + return { + kind: "open-tui", + ...(talkMatch.groups?.agent ? { agentId: talkMatch.groups.agent } : {}), + ...(workspace ? { workspace } : {}), + }; + } + const setModelMatch = trimmed.match(SET_MODEL_RE); + if (setModelMatch?.groups?.model) { + return { kind: "set-default-model", model: setModelMatch.groups.model }; + } + return { kind: "none", message: NO_MATCH_MESSAGE }; +} + +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; +} + +function normalizePluginInstallSpec(spec: string, source: string | undefined): string { + const trimmed = spec.trim(); + const normalizedSource = source?.toLowerCase(); + if (normalizedSource === "npm" && !trimmed.toLowerCase().startsWith("npm:")) { + return `npm:${trimmed}`; + } + if (normalizedSource === "clawhub" && !trimmed.toLowerCase().startsWith("clawhub:")) { + return `clawhub:${trimmed}`; + } + return trimmed; +} + +/** + * Return whether an operation can change local state or process lifecycle. + * Guided setup operations are intentionally absent: starting a wizard is not + * itself a write; the wizard owns approval and persistence for its answers. + */ +export function isPersistentSystemAgentOperation(operation: SystemAgentOperation): boolean { + return ( + operation.kind === "set-default-model" || + operation.kind === "config-set" || + operation.kind === "config-set-ref" || + operation.kind === "setup" || + operation.kind === "plugin-install" || + (operation.kind === "create-agent" && + !operation.model?.trim() && + !isReservedSystemAgentId(operation.agentId)) || + operation.kind === "gateway-start" || + operation.kind === "gateway-stop" || + operation.kind === "gateway-restart" + ); +} + +/** Format a user-facing description for an operation requiring approval. */ +export function describeSystemAgentPersistentOperation(operation: SystemAgentOperation): 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 "model-setup": + return "configure a model provider and default model"; + case "doctor-fix": + return "exit OpenClaw and run openclaw doctor --fix"; + case "plugin-install": + return `install plugin ${operation.spec}`; + case "plugin-uninstall": + return `uninstall plugin ${operation.pluginId}`; + 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"; + } +} + +/** Format the standard approval plan text for a persistent operation. */ +export function formatSystemAgentPersistentPlan(operation: SystemAgentOperation): string { + return `Plan: ${describeSystemAgentPersistentOperation(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 (isSensitiveConfigPath(configPath)) { + return ""; + } + return value; +} + +function formatSetupPlanDescription( + operation: Extract, +): string { + const workspace = shortenHomePath(resolveUserPath(operation.workspace ?? process.cwd())); + return `bootstrap OpenClaw setup for workspace ${workspace}`; +} diff --git a/src/system-agent/operations.setup.test.ts b/src/system-agent/operations.setup.test.ts new file mode 100644 index 000000000000..b274109600d7 --- /dev/null +++ b/src/system-agent/operations.setup.test.ts @@ -0,0 +1,1026 @@ +// OpenClaw operation tests cover rescue operation planning and execution. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; +import { SystemAgentInferenceUnavailableError } from "./inference-error.js"; +import { executeSystemAgentOperation, isPersistentSystemAgentOperation } from "./operations.js"; +import { createSystemAgentTestRuntime } from "./system-agent.test-helpers.js"; + +type TestConfig = Record; + +function parseLastJsonLine(raw: string): unknown { + const lastLine = raw.trim().split("\n").at(-1); + if (!lastLine) { + throw new Error("Expected audit log to contain at least one JSON line"); + } + return JSON.parse(lastLine) as unknown; +} + +function requireRecord(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null) { + throw new Error(`${label} was not an object`); + } + return value as Record; +} + +function expectRecordFields(record: Record, fields: Record) { + for (const [key, value] of Object.entries(fields)) { + expect(record[key]).toEqual(value); + } +} + +function expectAuditRecord( + audit: unknown, + fields: Record, + detailFields: Record, +) { + const auditRecord = requireRecord(audit, "audit record"); + expectRecordFields(auditRecord, fields); + expectRecordFields(requireRecord(auditRecord.details, "audit details"), detailFields); +} + +const mockConfig = vi.hoisted(() => { + const initial = {}; + const state = { + path: "/tmp/openclaw.json", + exists: true, + config: initial as TestConfig, + hash: "mock-hash-0" as string | undefined, + }; + const cloneConfig = () => structuredClone(state.config); + const snapshot = () => { + const config = cloneConfig(); + return { + path: state.path, + exists: state.exists, + raw: state.exists ? `${JSON.stringify(config)}\n` : null, + parsed: state.exists ? config : undefined, + sourceConfig: config, + resolved: config, + valid: state.exists, + runtimeConfig: config, + config, + hash: state.hash, + issues: state.exists ? [] : [{ path: "", message: "missing config" }], + warnings: [], + legacyIssues: [], + }; + }; + return { + reset() { + state.path = "/tmp/openclaw.json"; + state.exists = true; + state.config = {}; + state.hash = "mock-hash-0"; + }, + missing(pathLocal: string) { + state.path = pathLocal; + state.exists = false; + state.config = {}; + state.hash = undefined; + }, + currentConfig() { + return cloneConfig(); + }, + setConfig(config: TestConfig) { + state.config = structuredClone(config); + }, + readConfigFileSnapshot: vi.fn(async () => snapshot()), + mutateConfigFile: vi.fn( + async (params: { + writeOptions?: { + preCommitRuntimePreflight?: (sourceConfig: TestConfig) => Promise; + }; + mutate: ( + draft: TestConfig, + context: { snapshot: ReturnType }, + ) => Promise | void; + }) => { + const before = snapshot(); + const draft = cloneConfig(); + await params.mutate(draft, { snapshot: before }); + await params.writeOptions?.preCommitRuntimePreflight?.(structuredClone(draft)); + state.exists = true; + state.config = draft; + state.hash = "mock-hash-1"; + return { + path: state.path, + previousHash: before.hash ?? null, + persistedHash: before.hash ?? null, + snapshot: before, + nextConfig: cloneConfig(), + result: undefined, + }; + }, + ), + }; +}); + +vi.mock("./probes.js", () => ({ + probeLocalCommand: vi.fn(async (command: string) => ({ + command, + found: false, + error: "not found", + })), + probeGatewayUrl: vi.fn(async (url: string) => ({ reachable: false, url, error: "offline" })), +})); + +vi.mock("./overview.js", () => ({ + formatSystemAgentOverview: () => "Default model: openai/gpt-5.5", + loadSystemAgentOverview: vi.fn(async () => ({ + defaultAgentId: "main", + defaultModel: undefined, + agents: [ + { id: "main", isDefault: true }, + { id: "work", isDefault: false, model: "openai/gpt-5.2" }, + ], + config: { path: "/tmp/openclaw.json", exists: true, valid: true, issues: [], hash: null }, + tools: { + codex: { command: "codex", found: false, error: "not found" }, + claude: { command: "claude", found: false, error: "not found" }, + gemini: { command: "gemini", found: false, error: "not found" }, + apiKeys: { openai: true, anthropic: false }, + }, + gateway: { + url: "ws://127.0.0.1:18789", + source: "local loopback", + reachable: false, + error: "offline", + }, + references: { + docsUrl: "https://docs.openclaw.ai", + sourceUrl: "https://github.com/openclaw/openclaw", + }, + })), +})); + +vi.mock("../config/config.js", () => ({ + mutateConfigFile: mockConfig.mutateConfigFile, + readConfigFileSnapshot: mockConfig.readConfigFileSnapshot, +})); +const opTempDirs = useAutoCleanupTempDirTracker(afterEach); + +describe("parseSystemAgentOperation", () => { + let stateDirSnapshot: ReturnType | undefined; + + beforeEach(() => { + mockConfig.reset(); + stateDirSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); + vi.stubEnv("OPENCLAW_TEST_FAST", "1"); + }); + + afterEach(() => { + stateDirSnapshot?.restore(); + vi.unstubAllEnvs(); + }); + + it("runs setup bootstrap only after approval and audits it", async () => { + const tempDir = opTempDirs.make("openclaw-setup-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createSystemAgentTestRuntime(); + mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); + const applySetup = vi.fn(async () => ({ + configPath: path.join(tempDir, "openclaw.json"), + configHashBefore: "mock-hash-0", + configHashAfter: "mock-hash-1", + lines: ["Workspace: /tmp/work"], + })); + const deps = { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + verifyInferenceConfig: vi.fn(async () => ({ + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 12, + })), + }; + + const plan = await executeSystemAgentOperation( + { kind: "setup", workspace: "/tmp/work" }, + runtime, + { deps }, + ); + expectRecordFields(plan as unknown as Record, { + applied: false, + }); + expect(lines.join("\n")).toContain("Model choice: keep verified default openai/gpt-5.5."); + expect(applySetup).not.toHaveBeenCalled(); + + const result = await executeSystemAgentOperation( + { kind: "setup", workspace: "/tmp/work" }, + runtime, + { + approved: true, + auditDetails: { rescue: true }, + deps, + }, + ); + expect(result.applied).toBe(true); + + expect(lines.join("\n")).toContain("[openclaw] done: openclaw.setup"); + expect(applySetup).toHaveBeenCalledWith( + { + workspace: "/tmp/work", + expectedInferenceRoute: expect.any(Object), + surface: "cli", + runtime, + }, + { commit: expect.any(Function) }, + ); + expect(lines.join("\n")).toContain("Default model: openai/gpt-5.5 (verified and kept)"); + const auditPath = path.join(tempDir, "audit", "system-agent.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expectAuditRecord( + audit, + { + operation: "openclaw.setup", + summary: "Bootstrapped setup workspace", + }, + { + rescue: true, + workspace: "/tmp/work", + model: "openai/gpt-5.5", + modelSource: "live-verified default model", + inferenceLatencyMs: 12, + }, + ); + }); + + it("rejects setup without a default model before any workspace or Gateway write", async () => { + const tempDir = opTempDirs.make("openclaw-no-inference-setup-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createSystemAgentTestRuntime(); + const applySetup = vi.fn(); + const deps = { + applySetup, + setupSurface: "gateway" as const, + loadOverview: async () => ({ defaultModel: undefined }) as never, + }; + + await expect( + executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { + approved: true, + deps, + }), + ).rejects.toThrow("requires working inference first"); + + expect(applySetup).not.toHaveBeenCalled(); + expect(lines.join("\n")).not.toContain("[openclaw] running: openclaw.setup"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("rejects setup when the current route fails its live inference check", async () => { + const tempDir = opTempDirs.make("openclaw-failed-inference-setup-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); + const { runtime, lines } = createSystemAgentTestRuntime(); + const applySetup = vi.fn(); + + await expect( + executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { + approved: true, + deps: { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + verifyInferenceConfig: async () => ({ + ok: false as const, + status: "auth" as const, + error: "not authenticated", + }), + }, + }), + ).rejects.toThrow("failed a live check"); + + expect(applySetup).not.toHaveBeenCalled(); + expect(lines.join("\n")).not.toContain("[openclaw] running: openclaw.setup"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("rejects route drift during setup verification but preserves the concurrent edit", async () => { + mockConfig.setConfig({ + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + auth: { order: { openai: ["openai:old"] } }, + }); + const { runtime } = createSystemAgentTestRuntime(); + const applySetup = vi.fn(); + + await expect( + executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { + approved: true, + deps: { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + verifyInferenceConfig: async () => { + mockConfig.setConfig({ + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + auth: { order: { openai: ["openai:new"] } }, + }); + return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 8 }; + }, + }, + }), + ).rejects.toThrow("changed during setup verification"); + + expect(applySetup).not.toHaveBeenCalled(); + expect(mockConfig.currentConfig()).toMatchObject({ + auth: { order: { openai: ["openai:new"] } }, + }); + }); + + it("preserves unrelated concurrent edits after re-verifying the same setup route", async () => { + mockConfig.setConfig({ + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + gateway: { port: 18789 }, + }); + const { runtime } = createSystemAgentTestRuntime(); + const applySetup = vi.fn(async () => ({ + configPath: "/tmp/openclaw.json", + configHashBefore: "mock-hash-0", + configHashAfter: "mock-hash-1", + lines: [], + })); + + const result = await executeSystemAgentOperation( + { kind: "setup", workspace: "/tmp/work" }, + runtime, + { + approved: true, + deps: { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + verifyInferenceConfig: async () => { + mockConfig.setConfig({ + agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, + gateway: { port: 19000 }, + }); + return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 7 }; + }, + }, + }, + ); + + expect(result.applied).toBe(true); + expect(mockConfig.currentConfig()).toMatchObject({ gateway: { port: 19000 } }); + expect(applySetup).toHaveBeenCalledWith( + expect.objectContaining({ expectedInferenceRoute: expect.any(Object) }), + { commit: expect.any(Function) }, + ); + }); + + it("rejects a setup model switch before writing", async () => { + const tempDir = opTempDirs.make("openclaw-model-switch-setup-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const { runtime } = createSystemAgentTestRuntime(); + const applySetup = vi.fn(); + + await expect( + executeSystemAgentOperation( + { kind: "setup", workspace: "/tmp/work", model: "acme/different" }, + runtime, + { + approved: true, + deps: { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + }, + }, + ), + ).rejects.toThrow("Exit OpenClaw and run `openclaw onboard`"); + + expect(applySetup).not.toHaveBeenCalled(); + }); + + it("allows the same requested model while preserving it without a model write", async () => { + const tempDir = opTempDirs.make("openclaw-same-model-setup-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const { runtime } = createSystemAgentTestRuntime(); + mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); + const applySetup = vi.fn(async () => ({ + configPath: path.join(tempDir, "openclaw.json"), + configHashBefore: "mock-hash-0", + configHashAfter: "mock-hash-1", + lines: ["Workspace: /tmp/work"], + })); + + const result = await executeSystemAgentOperation( + { kind: "setup", workspace: "/tmp/work", model: "openai/gpt-5.5" }, + runtime, + { + approved: true, + deps: { + applySetup, + loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, + verifyInferenceConfig: async () => ({ + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 5, + }), + }, + }, + ); + + expect(result).toEqual({ applied: true }); + expect(applySetup).toHaveBeenCalledWith( + { + workspace: "/tmp/work", + expectedInferenceRoute: expect.any(Object), + surface: "cli", + runtime, + }, + { commit: expect.any(Function) }, + ); + }); + + it("live-verifies a staged default model before writing and preserves concurrent edits", async () => { + const tempDir = opTempDirs.make("openclaw-verified-model-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + mockConfig.setConfig({ + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.2"] }, + }, + list: [{ id: "main", default: true, workspace: "/tmp/main" }], + }, + gateway: { port: 18789 }, + models: { providers: { openai: { baseUrl: "https://api.openai.com/v1" } } }, + }); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + let verificationCalls = 0; + const verifyInferenceConfig = vi.fn(async ({ config }: { config: TestConfig }) => { + verificationCalls += 1; + const stagedDefaults = requireRecord( + requireRecord(config.agents, "agents").defaults, + "defaults", + ); + expect(stagedDefaults.model).toEqual({ + primary: "openai/gpt-5.5", + fallbacks: ["openai/gpt-5.2"], + }); + expect( + requireRecord( + requireRecord( + requireRecord(mockConfig.currentConfig().agents, "agents").defaults, + "defaults", + ).model, + "persisted model", + ).primary, + ).toBe("anthropic/claude-sonnet-4-6"); + if (verificationCalls === 1) { + const current = mockConfig.currentConfig(); + const currentModels = requireRecord(current.models, "models"); + const currentProviders = requireRecord(currentModels.providers, "providers"); + mockConfig.setConfig({ + ...current, + auth: { + profiles: { "google:other": { provider: "google", mode: "api_key" } }, + }, + models: { + ...currentModels, + providers: { + ...currentProviders, + google: { + baseUrl: "https://example.invalid", + models: [{ id: "unrelated", name: "Unrelated", contextWindow: 1, maxTokens: 1 }], + }, + }, + }, + agents: { + ...requireRecord(current.agents, "agents"), + defaults: { + ...requireRecord(requireRecord(current.agents, "agents").defaults, "defaults"), + models: { "google/unrelated": { agentRuntime: { id: "openclaw" } } }, + }, + list: [ + { id: "main", default: true, workspace: "/tmp/main" }, + { id: "work", workspace: "/tmp/work" }, + ], + }, + channels: { telegram: { enabled: true } }, + }); + } + return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 17 }; + }); + + const result = await executeSystemAgentOperation( + { kind: "set-default-model", model: "openai/gpt-5.5" }, + runtime, + { approved: true, deps: { verifyInferenceConfig } }, + ); + + expect(result).toEqual({ applied: true }); + expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); + expect(verifyInferenceConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ requireExecutionOwner: true }), + ); + expect(verifyInferenceConfig).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ requireExecutionOwner: true }), + ); + expect(mockConfig.mutateConfigFile).toHaveBeenCalledOnce(); + expect(mockConfig.mutateConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + writeOptions: { preCommitRuntimePreflight: expect.any(Function) }, + }), + ); + const persisted = mockConfig.currentConfig(); + expect( + requireRecord(requireRecord(persisted.agents, "agents").defaults, "defaults").model, + ).toEqual({ primary: "openai/gpt-5.5", fallbacks: ["openai/gpt-5.2"] }); + expect(requireRecord(persisted.agents, "agents").list).toEqual([ + { id: "main", default: true, workspace: "/tmp/main" }, + { id: "work", workspace: "/tmp/work" }, + ]); + expect(requireRecord(persisted.auth, "auth").profiles).toEqual({ + "google:other": { provider: "google", mode: "api_key" }, + }); + expect( + requireRecord(requireRecord(persisted.models, "models").providers, "providers"), + ).toMatchObject({ + openai: { baseUrl: "https://api.openai.com/v1" }, + google: expect.any(Object), + }); + expect( + requireRecord( + requireRecord(requireRecord(persisted.agents, "agents").defaults, "defaults").models, + "default models", + ), + ).toHaveProperty("google/unrelated"); + expect(persisted.channels).toEqual({ telegram: { enabled: true } }); + expect(lines.join("\n")).toContain("Default model: openai/gpt-5.5"); + + const audit = parseLastJsonLine( + await fs.readFile(path.join(tempDir, "audit", "system-agent.jsonl"), "utf8"), + ); + expectAuditRecord( + audit, + { + operation: "config.setDefaultModel", + summary: "Set default model to openai/gpt-5.5", + }, + { + requestedModel: "openai/gpt-5.5", + effectiveModel: "openai/gpt-5.5", + inferenceVerified: true, + inferenceLatencyMs: 17, + }, + ); + }); + + it.each([ + { + field: "default agent", + initial: { + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + list: [{ id: "main", default: true }, { id: "work" }], + }, + }, + change: (config: TestConfig) => { + const next = structuredClone(config); + const list = requireRecord(next.agents, "agents").list as Array<{ + id: string; + default?: boolean; + }>; + delete list[0]?.default; + list[1]!.default = true; + return next; + }, + }, + { + field: "default marker", + initial: { + agents: { + defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, + list: [{ id: "main", default: true }, { id: "work" }], + }, + }, + change: (config: TestConfig) => { + const next = structuredClone(config); + const list = requireRecord(next.agents, "agents").list as Array<{ + id: string; + default?: boolean; + }>; + delete list[0]?.default; + return next; + }, + }, + { + field: "auth profile order", + initial: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + auth: { order: { anthropic: ["anthropic:one"] } }, + }, + change: (config: TestConfig) => ({ + ...structuredClone(config), + auth: { order: { anthropic: ["anthropic:two"] } }, + }), + }, + { + field: "runtime metadata", + initial: { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, + }, + }, + }, + }, + change: (config: TestConfig) => { + const next = structuredClone(config); + const defaults = requireRecord(requireRecord(next.agents, "agents").defaults, "defaults"); + defaults.models = { + "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "openclaw" } }, + }; + return next; + }, + }, + { + field: "model", + initial: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }, + change: (config: TestConfig) => { + const next = structuredClone(config); + const defaults = requireRecord(requireRecord(next.agents, "agents").defaults, "defaults"); + defaults.model = { primary: "anthropic/claude-opus-4-6" }; + return next; + }, + }, + { + field: "config-backed environment", + initial: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + env: { vars: { ANTHROPIC_API_KEY: "first" } }, + }, + change: (config: TestConfig) => ({ + ...structuredClone(config), + env: { vars: { ANTHROPIC_API_KEY: "second" } }, + }), + }, + { + field: "secret provider policy", + initial: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + secrets: { defaults: { env: "first" } }, + }, + change: (config: TestConfig) => ({ + ...structuredClone(config), + secrets: { defaults: { env: "second" } }, + }), + }, + { + field: "plugin load policy", + initial: { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + plugins: { enabled: true }, + }, + change: (config: TestConfig) => ({ + ...structuredClone(config), + plugins: { enabled: false }, + }), + }, + ])( + "aborts when concurrent $field changes invalidate the verified route", + async ({ initial, change }) => { + const tempDir = opTempDirs.make("openclaw-route-conflict-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + mockConfig.setConfig(initial); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + const verifyInferenceConfig = vi.fn(async () => { + mockConfig.setConfig(change(mockConfig.currentConfig())); + return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 7 }; + }); + + await expect( + executeSystemAgentOperation( + { kind: "set-default-model", model: "openai/gpt-5.5" }, + runtime, + { + approved: true, + deps: { verifyInferenceConfig }, + }, + ), + ).rejects.toThrow("inference route changed during verification"); + + expect(mockConfig.mutateConfigFile).toHaveBeenCalledOnce(); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }, + ); + + it("keeps the working model and writes no audit when live inference fails", async () => { + const tempDir = opTempDirs.make("openclaw-rejected-model-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const originalConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + gateway: { port: 18789 }, + }; + mockConfig.setConfig(originalConfig); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + const verifyInferenceConfig = vi.fn(async () => ({ + ok: false as const, + status: "auth" as const, + error: "Provider authentication failed.", + })); + + await expect( + executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { + approved: true, + deps: { verifyInferenceConfig }, + }), + ).rejects.toThrow( + "The requested model failed a live inference test, so the current default model was not changed. Provider authentication failed. Fix provider authentication or model access, then retry.", + ); + + expect(mockConfig.currentConfig()).toEqual(originalConfig); + expect(mockConfig.mutateConfigFile).not.toHaveBeenCalled(); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("writes nothing when the exact latest route fails its locked recheck", async () => { + const tempDir = opTempDirs.make("openclaw-latest-route-rejected-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const originalConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }; + mockConfig.setConfig(originalConfig); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + const verifyInferenceConfig = vi + .fn() + .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 5 }) + .mockResolvedValueOnce({ ok: false, status: "auth", error: "credential changed" }); + + await expect( + executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { + approved: true, + deps: { verifyInferenceConfig }, + }), + ).rejects.toThrow("no longer passes live inference at the config commit boundary"); + + expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); + expect(mockConfig.currentConfig()).toEqual(originalConfig); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("rejects a live result from a different model before opening the write boundary", async () => { + const tempDir = opTempDirs.make("openclaw-mismatched-model-result-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const originalConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }; + mockConfig.setConfig(originalConfig); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + const verifyInferenceConfig = vi.fn(async () => ({ + ok: true as const, + modelRef: "openai/gpt-5.4", + latencyMs: 5, + })); + + await expect( + executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { + approved: true, + deps: { verifyInferenceConfig }, + }), + ).rejects.toThrow("did not verify the exact model route"); + + expect(verifyInferenceConfig).toHaveBeenCalledOnce(); + expect(mockConfig.mutateConfigFile).not.toHaveBeenCalled(); + expect(mockConfig.currentConfig()).toEqual(originalConfig); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("rejects a different model result from the final commit-boundary probe", async () => { + const tempDir = opTempDirs.make("openclaw-final-mismatched-model-result-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const originalConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }; + mockConfig.setConfig(originalConfig); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + const verifyInferenceConfig = vi + .fn() + .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 5 }) + .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.4", latencyMs: 5 }); + + await expect( + executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { + approved: true, + deps: { verifyInferenceConfig }, + }), + ).rejects.toThrow("did not verify the exact model route at the config commit boundary"); + + expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); + expect(mockConfig.currentConfig()).toEqual(originalConfig); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("rechecks the existing inference binding inside the locked model transform", async () => { + const tempDir = opTempDirs.make("openclaw-model-binding-rotated-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const originalConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }; + mockConfig.setConfig(originalConfig); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + let bindingOwner = "verified"; + const verifyInferenceConfig = vi.fn(async () => { + bindingOwner = "rotated"; + return { + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 5, + }; + }); + const beforePersistentApply = vi.fn(async () => { + if (bindingOwner !== "verified") { + throw new SystemAgentInferenceUnavailableError("conversation"); + } + }); + + await expect( + executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { + approved: true, + deps: { verifyInferenceConfig }, + beforePersistentApply, + }), + ).rejects.toBeInstanceOf(SystemAgentInferenceUnavailableError); + + expect(verifyInferenceConfig).toHaveBeenCalledOnce(); + expect(beforePersistentApply).toHaveBeenCalledOnce(); + expect(mockConfig.currentConfig()).toEqual(originalConfig); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("rechecks the existing inference binding after the candidate's final live probe", async () => { + const tempDir = opTempDirs.make("openclaw-model-binding-final-probe-rotated-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const originalConfig = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + }; + mockConfig.setConfig(originalConfig); + mockConfig.mutateConfigFile.mockClear(); + const { runtime, lines } = createSystemAgentTestRuntime(); + let bindingOwner = "verified"; + let verificationCalls = 0; + const verifyInferenceConfig = vi.fn(async () => { + verificationCalls += 1; + if (verificationCalls === 2) { + bindingOwner = "rotated"; + } + return { + ok: true as const, + modelRef: "openai/gpt-5.5", + latencyMs: 5, + }; + }); + const beforePersistentApply = vi.fn(async () => { + if (bindingOwner !== "verified") { + throw new SystemAgentInferenceUnavailableError("conversation"); + } + }); + + await expect( + executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { + approved: true, + deps: { verifyInferenceConfig }, + beforePersistentApply, + }), + ).rejects.toBeInstanceOf(SystemAgentInferenceUnavailableError); + + expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); + expect(beforePersistentApply).toHaveBeenCalledTimes(2); + expect(mockConfig.currentConfig()).toEqual(originalConfig); + expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("stages and persists model changes at the effective default-agent owner", async () => { + const tempDir = opTempDirs.make("openclaw-default-agent-model-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + mockConfig.setConfig({ + agents: { + defaults: { model: { primary: "anthropic/global-default" } }, + list: [ + { + id: "work", + default: true, + model: { primary: "anthropic/work-default" }, + }, + ], + }, + }); + const { runtime } = createSystemAgentTestRuntime(); + const verifyInferenceConfig = vi.fn(async ({ config }: { config: TestConfig }) => { + const agents = requireRecord(config.agents, "agents"); + expect(requireRecord(agents.defaults, "defaults").model).toEqual({ + primary: "anthropic/global-default", + }); + const list = agents.list as Array<{ id: string; model: unknown }>; + expect(list.find((agent) => agent.id === "work")?.model).toEqual({ + primary: "openai/gpt-5.5", + }); + return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 9 }; + }); + + await executeSystemAgentOperation( + { kind: "set-default-model", model: "openai/gpt-5.5" }, + runtime, + { approved: true, deps: { verifyInferenceConfig } }, + ); + + const agents = requireRecord(mockConfig.currentConfig().agents, "agents"); + expect(requireRecord(agents.defaults, "defaults").model).toEqual({ + primary: "anthropic/global-default", + }); + const list = agents.list as Array<{ id: string; model: unknown }>; + expect(list.find((agent) => agent.id === "work")?.model).toEqual({ + primary: "openai/gpt-5.5", + }); + }); + + it("refuses doctor repairs before any write or audit", async () => { + const tempDir = opTempDirs.make("openclaw-doctor-fix-refused-"); + setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createSystemAgentTestRuntime(); + const runDoctor = vi.fn(async () => {}); + + const result = await executeSystemAgentOperation({ kind: "doctor-fix" }, runtime, { + approved: true, + deps: { runDoctor }, + auditDetails: { rescue: true }, + }); + expect(result).toEqual({ applied: false }); + expect(isPersistentSystemAgentOperation({ kind: "doctor-fix" })).toBe(false); + expect(runDoctor).not.toHaveBeenCalled(); + expect(lines.join("\n")).toContain("Exit OpenClaw"); + expect(lines.join("\n")).toContain("openclaw doctor --fix"); + expect(lines.join("\n")).not.toContain("[openclaw] running: doctor.fix"); + await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); + }); + + it("returns from the agent TUI back to OpenClaw", async () => { + const { runtime, lines } = createSystemAgentTestRuntime(); + const runTui = vi.fn(async () => ({ + exitReason: "return-to-system-agent" as const, + systemAgentMessage: "restart gateway", + })); + + const result = await executeSystemAgentOperation( + { kind: "open-tui", agentId: "work" }, + runtime, + { + deps: { runTui }, + }, + ); + + expect(runTui).toHaveBeenCalledWith({ + local: true, + session: "agent:work:main", + deliver: false, + historyLimit: 200, + }); + expectRecordFields(result as unknown as Record, { + applied: false, + returnToShell: true, + nextInput: "restart gateway", + }); + expect(lines.join("\n")).toContain( + "[openclaw] returned from agent with request: restart gateway", + ); + }); + + it("re-enters the OpenClaw shell when the agent TUI returns without a request", async () => { + const { runtime, lines } = createSystemAgentTestRuntime(); + const runTui = vi.fn(async () => ({ + exitReason: "return-to-system-agent" as const, + })); + + const result = await executeSystemAgentOperation({ kind: "open-tui" }, runtime, { + deps: { runTui }, + }); + + expectRecordFields(result as unknown as Record, { + applied: false, + returnToShell: true, + }); + expect((result as { nextInput?: string }).nextInput).toBeUndefined(); + expect(lines.join("\n")).toContain("[openclaw] returned from agent"); + }); +}); diff --git a/src/system-agent/operations.test.ts b/src/system-agent/operations.test.ts index 860d4e0fddff..bdd90ebe61ad 100644 --- a/src/system-agent/operations.test.ts +++ b/src/system-agent/operations.test.ts @@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { RuntimeEnv } from "../runtime.js"; import { captureEnv, setTestEnvValue } from "../test-utils/env.js"; -import { SystemAgentInferenceUnavailableError } from "./inference-error.js"; import { describeSystemAgentPersistentOperation, executeSystemAgentOperation, @@ -16,14 +15,6 @@ import { createSystemAgentTestRuntime } from "./system-agent.test-helpers.js"; type TestConfig = Record; -function parseLastJsonLine(raw: string): unknown { - const lastLine = raw.trim().split("\n").at(-1); - if (!lastLine) { - throw new Error("Expected audit log to contain at least one JSON line"); - } - return JSON.parse(lastLine) as unknown; -} - function requireRecord(value: unknown, label: string): Record { if (typeof value !== "object" || value === null) { throw new Error(`${label} was not an object`); @@ -136,7 +127,6 @@ const mockConfig = vi.hoisted(() => { ), }; }); - vi.mock("./probes.js", () => ({ probeLocalCommand: vi.fn(async (command: string) => ({ command, @@ -934,852 +924,4 @@ describe("parseSystemAgentOperation", () => { expect(lines.join("\n")).toContain("cannot prove that uninstalling a plugin"); expect(lines.join("\n")).toContain("openclaw plugins uninstall openclaw-demo"); }); - - it("runs setup bootstrap only after approval and audits it", async () => { - const tempDir = opTempDirs.make("openclaw-setup-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const { runtime, lines } = createSystemAgentTestRuntime(); - mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); - const applySetup = vi.fn(async () => ({ - configPath: path.join(tempDir, "openclaw.json"), - configHashBefore: "mock-hash-0", - configHashAfter: "mock-hash-1", - lines: ["Workspace: /tmp/work"], - })); - const deps = { - applySetup, - loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, - verifyInferenceConfig: vi.fn(async () => ({ - ok: true as const, - modelRef: "openai/gpt-5.5", - latencyMs: 12, - })), - }; - - const plan = await executeSystemAgentOperation( - { kind: "setup", workspace: "/tmp/work" }, - runtime, - { deps }, - ); - expectRecordFields(plan as unknown as Record, { - applied: false, - }); - expect(lines.join("\n")).toContain("Model choice: keep verified default openai/gpt-5.5."); - expect(applySetup).not.toHaveBeenCalled(); - - const result = await executeSystemAgentOperation( - { kind: "setup", workspace: "/tmp/work" }, - runtime, - { - approved: true, - auditDetails: { rescue: true }, - deps, - }, - ); - expect(result.applied).toBe(true); - - expect(lines.join("\n")).toContain("[openclaw] done: openclaw.setup"); - expect(applySetup).toHaveBeenCalledWith( - { - workspace: "/tmp/work", - expectedInferenceRoute: expect.any(Object), - surface: "cli", - runtime, - }, - { commit: expect.any(Function) }, - ); - expect(lines.join("\n")).toContain("Default model: openai/gpt-5.5 (verified and kept)"); - const auditPath = path.join(tempDir, "audit", "system-agent.jsonl"); - const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); - expectAuditRecord( - audit, - { - operation: "openclaw.setup", - summary: "Bootstrapped setup workspace", - }, - { - rescue: true, - workspace: "/tmp/work", - model: "openai/gpt-5.5", - modelSource: "live-verified default model", - inferenceLatencyMs: 12, - }, - ); - }); - - it("rejects setup without a default model before any workspace or Gateway write", async () => { - const tempDir = opTempDirs.make("openclaw-no-inference-setup-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const { runtime, lines } = createSystemAgentTestRuntime(); - const applySetup = vi.fn(); - const deps = { - applySetup, - setupSurface: "gateway" as const, - loadOverview: async () => ({ defaultModel: undefined }) as never, - }; - - await expect( - executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { - approved: true, - deps, - }), - ).rejects.toThrow("requires working inference first"); - - expect(applySetup).not.toHaveBeenCalled(); - expect(lines.join("\n")).not.toContain("[openclaw] running: openclaw.setup"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("rejects setup when the current route fails its live inference check", async () => { - const tempDir = opTempDirs.make("openclaw-failed-inference-setup-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); - const { runtime, lines } = createSystemAgentTestRuntime(); - const applySetup = vi.fn(); - - await expect( - executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { - approved: true, - deps: { - applySetup, - loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, - verifyInferenceConfig: async () => ({ - ok: false as const, - status: "auth" as const, - error: "not authenticated", - }), - }, - }), - ).rejects.toThrow("failed a live check"); - - expect(applySetup).not.toHaveBeenCalled(); - expect(lines.join("\n")).not.toContain("[openclaw] running: openclaw.setup"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("rejects route drift during setup verification but preserves the concurrent edit", async () => { - mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, - auth: { order: { openai: ["openai:old"] } }, - }); - const { runtime } = createSystemAgentTestRuntime(); - const applySetup = vi.fn(); - - await expect( - executeSystemAgentOperation({ kind: "setup", workspace: "/tmp/work" }, runtime, { - approved: true, - deps: { - applySetup, - loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, - verifyInferenceConfig: async () => { - mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, - auth: { order: { openai: ["openai:new"] } }, - }); - return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 8 }; - }, - }, - }), - ).rejects.toThrow("changed during setup verification"); - - expect(applySetup).not.toHaveBeenCalled(); - expect(mockConfig.currentConfig()).toMatchObject({ - auth: { order: { openai: ["openai:new"] } }, - }); - }); - - it("preserves unrelated concurrent edits after re-verifying the same setup route", async () => { - mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, - gateway: { port: 18789 }, - }); - const { runtime } = createSystemAgentTestRuntime(); - const applySetup = vi.fn(async () => ({ - configPath: "/tmp/openclaw.json", - configHashBefore: "mock-hash-0", - configHashAfter: "mock-hash-1", - lines: [], - })); - - const result = await executeSystemAgentOperation( - { kind: "setup", workspace: "/tmp/work" }, - runtime, - { - approved: true, - deps: { - applySetup, - loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, - verifyInferenceConfig: async () => { - mockConfig.setConfig({ - agents: { defaults: { model: { primary: "openai/gpt-5.5" } } }, - gateway: { port: 19000 }, - }); - return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 7 }; - }, - }, - }, - ); - - expect(result.applied).toBe(true); - expect(mockConfig.currentConfig()).toMatchObject({ gateway: { port: 19000 } }); - expect(applySetup).toHaveBeenCalledWith( - expect.objectContaining({ expectedInferenceRoute: expect.any(Object) }), - { commit: expect.any(Function) }, - ); - }); - - it("rejects a setup model switch before writing", async () => { - const tempDir = opTempDirs.make("openclaw-model-switch-setup-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const { runtime } = createSystemAgentTestRuntime(); - const applySetup = vi.fn(); - - await expect( - executeSystemAgentOperation( - { kind: "setup", workspace: "/tmp/work", model: "acme/different" }, - runtime, - { - approved: true, - deps: { - applySetup, - loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, - }, - }, - ), - ).rejects.toThrow("Exit OpenClaw and run `openclaw onboard`"); - - expect(applySetup).not.toHaveBeenCalled(); - }); - - it("allows the same requested model while preserving it without a model write", async () => { - const tempDir = opTempDirs.make("openclaw-same-model-setup-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const { runtime } = createSystemAgentTestRuntime(); - mockConfig.setConfig({ agents: { defaults: { model: { primary: "openai/gpt-5.5" } } } }); - const applySetup = vi.fn(async () => ({ - configPath: path.join(tempDir, "openclaw.json"), - configHashBefore: "mock-hash-0", - configHashAfter: "mock-hash-1", - lines: ["Workspace: /tmp/work"], - })); - - const result = await executeSystemAgentOperation( - { kind: "setup", workspace: "/tmp/work", model: "openai/gpt-5.5" }, - runtime, - { - approved: true, - deps: { - applySetup, - loadOverview: async () => ({ defaultModel: "openai/gpt-5.5" }) as never, - verifyInferenceConfig: async () => ({ - ok: true as const, - modelRef: "openai/gpt-5.5", - latencyMs: 5, - }), - }, - }, - ); - - expect(result).toEqual({ applied: true }); - expect(applySetup).toHaveBeenCalledWith( - { - workspace: "/tmp/work", - expectedInferenceRoute: expect.any(Object), - surface: "cli", - runtime, - }, - { commit: expect.any(Function) }, - ); - }); - - it("live-verifies a staged default model before writing and preserves concurrent edits", async () => { - const tempDir = opTempDirs.make("openclaw-verified-model-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - mockConfig.setConfig({ - agents: { - defaults: { - model: { primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.2"] }, - }, - list: [{ id: "main", default: true, workspace: "/tmp/main" }], - }, - gateway: { port: 18789 }, - models: { providers: { openai: { baseUrl: "https://api.openai.com/v1" } } }, - }); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - let verificationCalls = 0; - const verifyInferenceConfig = vi.fn(async ({ config }: { config: TestConfig }) => { - verificationCalls += 1; - const stagedDefaults = requireRecord( - requireRecord(config.agents, "agents").defaults, - "defaults", - ); - expect(stagedDefaults.model).toEqual({ - primary: "openai/gpt-5.5", - fallbacks: ["openai/gpt-5.2"], - }); - expect( - requireRecord( - requireRecord( - requireRecord(mockConfig.currentConfig().agents, "agents").defaults, - "defaults", - ).model, - "persisted model", - ).primary, - ).toBe("anthropic/claude-sonnet-4-6"); - if (verificationCalls === 1) { - const current = mockConfig.currentConfig(); - const currentModels = requireRecord(current.models, "models"); - const currentProviders = requireRecord(currentModels.providers, "providers"); - mockConfig.setConfig({ - ...current, - auth: { - profiles: { "google:other": { provider: "google", mode: "api_key" } }, - }, - models: { - ...currentModels, - providers: { - ...currentProviders, - google: { - baseUrl: "https://example.invalid", - models: [{ id: "unrelated", name: "Unrelated", contextWindow: 1, maxTokens: 1 }], - }, - }, - }, - agents: { - ...requireRecord(current.agents, "agents"), - defaults: { - ...requireRecord(requireRecord(current.agents, "agents").defaults, "defaults"), - models: { "google/unrelated": { agentRuntime: { id: "openclaw" } } }, - }, - list: [ - { id: "main", default: true, workspace: "/tmp/main" }, - { id: "work", workspace: "/tmp/work" }, - ], - }, - channels: { telegram: { enabled: true } }, - }); - } - return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 17 }; - }); - - const result = await executeSystemAgentOperation( - { kind: "set-default-model", model: "openai/gpt-5.5" }, - runtime, - { approved: true, deps: { verifyInferenceConfig } }, - ); - - expect(result).toEqual({ applied: true }); - expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); - expect(verifyInferenceConfig).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ requireExecutionOwner: true }), - ); - expect(verifyInferenceConfig).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ requireExecutionOwner: true }), - ); - expect(mockConfig.mutateConfigFile).toHaveBeenCalledOnce(); - expect(mockConfig.mutateConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ - writeOptions: { preCommitRuntimePreflight: expect.any(Function) }, - }), - ); - const persisted = mockConfig.currentConfig(); - expect( - requireRecord(requireRecord(persisted.agents, "agents").defaults, "defaults").model, - ).toEqual({ primary: "openai/gpt-5.5", fallbacks: ["openai/gpt-5.2"] }); - expect(requireRecord(persisted.agents, "agents").list).toEqual([ - { id: "main", default: true, workspace: "/tmp/main" }, - { id: "work", workspace: "/tmp/work" }, - ]); - expect(requireRecord(persisted.auth, "auth").profiles).toEqual({ - "google:other": { provider: "google", mode: "api_key" }, - }); - expect( - requireRecord(requireRecord(persisted.models, "models").providers, "providers"), - ).toMatchObject({ - openai: { baseUrl: "https://api.openai.com/v1" }, - google: expect.any(Object), - }); - expect( - requireRecord( - requireRecord(requireRecord(persisted.agents, "agents").defaults, "defaults").models, - "default models", - ), - ).toHaveProperty("google/unrelated"); - expect(persisted.channels).toEqual({ telegram: { enabled: true } }); - expect(lines.join("\n")).toContain("Default model: openai/gpt-5.5"); - - const audit = parseLastJsonLine( - await fs.readFile(path.join(tempDir, "audit", "system-agent.jsonl"), "utf8"), - ); - expectAuditRecord( - audit, - { - operation: "config.setDefaultModel", - summary: "Set default model to openai/gpt-5.5", - }, - { - requestedModel: "openai/gpt-5.5", - effectiveModel: "openai/gpt-5.5", - inferenceVerified: true, - inferenceLatencyMs: 17, - }, - ); - }); - - it.each([ - { - field: "default agent", - initial: { - agents: { - defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, - list: [{ id: "main", default: true }, { id: "work" }], - }, - }, - change: (config: TestConfig) => { - const next = structuredClone(config); - const list = requireRecord(next.agents, "agents").list as Array<{ - id: string; - default?: boolean; - }>; - delete list[0]?.default; - list[1]!.default = true; - return next; - }, - }, - { - field: "default marker", - initial: { - agents: { - defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } }, - list: [{ id: "main", default: true }, { id: "work" }], - }, - }, - change: (config: TestConfig) => { - const next = structuredClone(config); - const list = requireRecord(next.agents, "agents").list as Array<{ - id: string; - default?: boolean; - }>; - delete list[0]?.default; - return next; - }, - }, - { - field: "auth profile order", - initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - auth: { order: { anthropic: ["anthropic:one"] } }, - }, - change: (config: TestConfig) => ({ - ...structuredClone(config), - auth: { order: { anthropic: ["anthropic:two"] } }, - }), - }, - { - field: "runtime metadata", - initial: { - agents: { - defaults: { - model: { primary: "anthropic/claude-sonnet-4-6" }, - models: { - "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }, - change: (config: TestConfig) => { - const next = structuredClone(config); - const defaults = requireRecord(requireRecord(next.agents, "agents").defaults, "defaults"); - defaults.models = { - "anthropic/claude-sonnet-4-6": { agentRuntime: { id: "openclaw" } }, - }; - return next; - }, - }, - { - field: "model", - initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - }, - change: (config: TestConfig) => { - const next = structuredClone(config); - const defaults = requireRecord(requireRecord(next.agents, "agents").defaults, "defaults"); - defaults.model = { primary: "anthropic/claude-opus-4-6" }; - return next; - }, - }, - { - field: "config-backed environment", - initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - env: { vars: { ANTHROPIC_API_KEY: "first" } }, - }, - change: (config: TestConfig) => ({ - ...structuredClone(config), - env: { vars: { ANTHROPIC_API_KEY: "second" } }, - }), - }, - { - field: "secret provider policy", - initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - secrets: { defaults: { env: "first" } }, - }, - change: (config: TestConfig) => ({ - ...structuredClone(config), - secrets: { defaults: { env: "second" } }, - }), - }, - { - field: "plugin load policy", - initial: { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - plugins: { enabled: true }, - }, - change: (config: TestConfig) => ({ - ...structuredClone(config), - plugins: { enabled: false }, - }), - }, - ])( - "aborts when concurrent $field changes invalidate the verified route", - async ({ initial, change }) => { - const tempDir = opTempDirs.make("openclaw-route-conflict-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - mockConfig.setConfig(initial); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - const verifyInferenceConfig = vi.fn(async () => { - mockConfig.setConfig(change(mockConfig.currentConfig())); - return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 7 }; - }); - - await expect( - executeSystemAgentOperation( - { kind: "set-default-model", model: "openai/gpt-5.5" }, - runtime, - { - approved: true, - deps: { verifyInferenceConfig }, - }, - ), - ).rejects.toThrow("inference route changed during verification"); - - expect(mockConfig.mutateConfigFile).toHaveBeenCalledOnce(); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }, - ); - - it("keeps the working model and writes no audit when live inference fails", async () => { - const tempDir = opTempDirs.make("openclaw-rejected-model-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - gateway: { port: 18789 }, - }; - mockConfig.setConfig(originalConfig); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - const verifyInferenceConfig = vi.fn(async () => ({ - ok: false as const, - status: "auth" as const, - error: "Provider authentication failed.", - })); - - await expect( - executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { - approved: true, - deps: { verifyInferenceConfig }, - }), - ).rejects.toThrow( - "The requested model failed a live inference test, so the current default model was not changed. Provider authentication failed. Fix provider authentication or model access, then retry.", - ); - - expect(mockConfig.currentConfig()).toEqual(originalConfig); - expect(mockConfig.mutateConfigFile).not.toHaveBeenCalled(); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("writes nothing when the exact latest route fails its locked recheck", async () => { - const tempDir = opTempDirs.make("openclaw-latest-route-rejected-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - }; - mockConfig.setConfig(originalConfig); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - const verifyInferenceConfig = vi - .fn() - .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 5 }) - .mockResolvedValueOnce({ ok: false, status: "auth", error: "credential changed" }); - - await expect( - executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { - approved: true, - deps: { verifyInferenceConfig }, - }), - ).rejects.toThrow("no longer passes live inference at the config commit boundary"); - - expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); - expect(mockConfig.currentConfig()).toEqual(originalConfig); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("rejects a live result from a different model before opening the write boundary", async () => { - const tempDir = opTempDirs.make("openclaw-mismatched-model-result-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - }; - mockConfig.setConfig(originalConfig); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - const verifyInferenceConfig = vi.fn(async () => ({ - ok: true as const, - modelRef: "openai/gpt-5.4", - latencyMs: 5, - })); - - await expect( - executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { - approved: true, - deps: { verifyInferenceConfig }, - }), - ).rejects.toThrow("did not verify the exact model route"); - - expect(verifyInferenceConfig).toHaveBeenCalledOnce(); - expect(mockConfig.mutateConfigFile).not.toHaveBeenCalled(); - expect(mockConfig.currentConfig()).toEqual(originalConfig); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("rejects a different model result from the final commit-boundary probe", async () => { - const tempDir = opTempDirs.make("openclaw-final-mismatched-model-result-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - }; - mockConfig.setConfig(originalConfig); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - const verifyInferenceConfig = vi - .fn() - .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.5", latencyMs: 5 }) - .mockResolvedValueOnce({ ok: true, modelRef: "openai/gpt-5.4", latencyMs: 5 }); - - await expect( - executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { - approved: true, - deps: { verifyInferenceConfig }, - }), - ).rejects.toThrow("did not verify the exact model route at the config commit boundary"); - - expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); - expect(mockConfig.currentConfig()).toEqual(originalConfig); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("rechecks the existing inference binding inside the locked model transform", async () => { - const tempDir = opTempDirs.make("openclaw-model-binding-rotated-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - }; - mockConfig.setConfig(originalConfig); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - let bindingOwner = "verified"; - const verifyInferenceConfig = vi.fn(async () => { - bindingOwner = "rotated"; - return { - ok: true as const, - modelRef: "openai/gpt-5.5", - latencyMs: 5, - }; - }); - const beforePersistentApply = vi.fn(async () => { - if (bindingOwner !== "verified") { - throw new SystemAgentInferenceUnavailableError("conversation"); - } - }); - - await expect( - executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { - approved: true, - deps: { verifyInferenceConfig }, - beforePersistentApply, - }), - ).rejects.toBeInstanceOf(SystemAgentInferenceUnavailableError); - - expect(verifyInferenceConfig).toHaveBeenCalledOnce(); - expect(beforePersistentApply).toHaveBeenCalledOnce(); - expect(mockConfig.currentConfig()).toEqual(originalConfig); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("rechecks the existing inference binding after the candidate's final live probe", async () => { - const tempDir = opTempDirs.make("openclaw-model-binding-final-probe-rotated-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const originalConfig = { - agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, - }; - mockConfig.setConfig(originalConfig); - mockConfig.mutateConfigFile.mockClear(); - const { runtime, lines } = createSystemAgentTestRuntime(); - let bindingOwner = "verified"; - let verificationCalls = 0; - const verifyInferenceConfig = vi.fn(async () => { - verificationCalls += 1; - if (verificationCalls === 2) { - bindingOwner = "rotated"; - } - return { - ok: true as const, - modelRef: "openai/gpt-5.5", - latencyMs: 5, - }; - }); - const beforePersistentApply = vi.fn(async () => { - if (bindingOwner !== "verified") { - throw new SystemAgentInferenceUnavailableError("conversation"); - } - }); - - await expect( - executeSystemAgentOperation({ kind: "set-default-model", model: "openai/gpt-5.5" }, runtime, { - approved: true, - deps: { verifyInferenceConfig }, - beforePersistentApply, - }), - ).rejects.toBeInstanceOf(SystemAgentInferenceUnavailableError); - - expect(verifyInferenceConfig).toHaveBeenCalledTimes(2); - expect(beforePersistentApply).toHaveBeenCalledTimes(2); - expect(mockConfig.currentConfig()).toEqual(originalConfig); - expect(lines.join("\n")).not.toContain("[openclaw] done: config.setDefaultModel"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("stages and persists model changes at the effective default-agent owner", async () => { - const tempDir = opTempDirs.make("openclaw-default-agent-model-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - mockConfig.setConfig({ - agents: { - defaults: { model: { primary: "anthropic/global-default" } }, - list: [ - { - id: "work", - default: true, - model: { primary: "anthropic/work-default" }, - }, - ], - }, - }); - const { runtime } = createSystemAgentTestRuntime(); - const verifyInferenceConfig = vi.fn(async ({ config }: { config: TestConfig }) => { - const agents = requireRecord(config.agents, "agents"); - expect(requireRecord(agents.defaults, "defaults").model).toEqual({ - primary: "anthropic/global-default", - }); - const list = agents.list as Array<{ id: string; model: unknown }>; - expect(list.find((agent) => agent.id === "work")?.model).toEqual({ - primary: "openai/gpt-5.5", - }); - return { ok: true as const, modelRef: "openai/gpt-5.5", latencyMs: 9 }; - }); - - await executeSystemAgentOperation( - { kind: "set-default-model", model: "openai/gpt-5.5" }, - runtime, - { approved: true, deps: { verifyInferenceConfig } }, - ); - - const agents = requireRecord(mockConfig.currentConfig().agents, "agents"); - expect(requireRecord(agents.defaults, "defaults").model).toEqual({ - primary: "anthropic/global-default", - }); - const list = agents.list as Array<{ id: string; model: unknown }>; - expect(list.find((agent) => agent.id === "work")?.model).toEqual({ - primary: "openai/gpt-5.5", - }); - }); - - it("refuses doctor repairs before any write or audit", async () => { - const tempDir = opTempDirs.make("openclaw-doctor-fix-refused-"); - setTestEnvValue("OPENCLAW_STATE_DIR", tempDir); - const { runtime, lines } = createSystemAgentTestRuntime(); - const runDoctor = vi.fn(async () => {}); - - const result = await executeSystemAgentOperation({ kind: "doctor-fix" }, runtime, { - approved: true, - deps: { runDoctor }, - auditDetails: { rescue: true }, - }); - expect(result).toEqual({ applied: false }); - expect(isPersistentSystemAgentOperation({ kind: "doctor-fix" })).toBe(false); - expect(runDoctor).not.toHaveBeenCalled(); - expect(lines.join("\n")).toContain("Exit OpenClaw"); - expect(lines.join("\n")).toContain("openclaw doctor --fix"); - expect(lines.join("\n")).not.toContain("[openclaw] running: doctor.fix"); - await expect(fs.access(path.join(tempDir, "audit", "system-agent.jsonl"))).rejects.toThrow(); - }); - - it("returns from the agent TUI back to OpenClaw", async () => { - const { runtime, lines } = createSystemAgentTestRuntime(); - const runTui = vi.fn(async () => ({ - exitReason: "return-to-system-agent" as const, - systemAgentMessage: "restart gateway", - })); - - const result = await executeSystemAgentOperation( - { kind: "open-tui", agentId: "work" }, - runtime, - { - deps: { runTui }, - }, - ); - - expect(runTui).toHaveBeenCalledWith({ - local: true, - session: "agent:work:main", - deliver: false, - historyLimit: 200, - }); - expectRecordFields(result as unknown as Record, { - applied: false, - returnToShell: true, - nextInput: "restart gateway", - }); - expect(lines.join("\n")).toContain( - "[openclaw] returned from agent with request: restart gateway", - ); - }); - - it("re-enters the OpenClaw shell when the agent TUI returns without a request", async () => { - const { runtime, lines } = createSystemAgentTestRuntime(); - const runTui = vi.fn(async () => ({ - exitReason: "return-to-system-agent" as const, - })); - - const result = await executeSystemAgentOperation({ kind: "open-tui" }, runtime, { - deps: { runTui }, - }); - - expectRecordFields(result as unknown as Record, { - applied: false, - returnToShell: true, - }); - expect((result as { nextInput?: string }).nextInput).toBeUndefined(); - expect(lines.join("\n")).toContain("[openclaw] returned from agent"); - }); }); diff --git a/src/system-agent/operations.ts b/src/system-agent/operations.ts index 4ddfef7fc41b..aa26409f26dd 100644 --- a/src/system-agent/operations.ts +++ b/src/system-agent/operations.ts @@ -1,1514 +1,2 @@ -// OpenClaw operations parse, approve, execute, and audit setup-helper commands. -import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; -import type { ConfigSetOptions } from "../cli/config-set-input.js"; -import type { DoctorOptions } from "../commands/doctor.types.js"; -import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; -import { formatErrorMessage } from "../infra/errors.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 { isReservedSystemAgentId } from "./agent-id.js"; -import { appendSystemAgentAuditEntry, resolveSystemAgentAuditPath } from "./audit.js"; -import { - projectDefaultInferenceRoute, - sameDefaultInferenceRoute, - type DefaultInferenceRouteProjection, -} from "./inference-route.js"; -import type { SystemAgentOverview } from "./overview.js"; -import { validateSystemAgentPluginInstallSpec } from "./plugin-install.js"; - -/** - * OpenClaw command parser and operation executor. - * - * The grammar is a single anchored command language: every pattern must match - * the whole input. Natural language never parses into an operation — it flows - * to the system agent instead (chat) or to the planner (one-shot). This is a - * security property, not a convenience: unanchored keyword matching used to - * turn questions like "why did my gateway stop" into mutation proposals. - * - * Persistent operations require explicit approval, write audit records, and - * lazy-load heavy CLI modules only when the selected operation needs them. - */ -type ConfigModule = typeof import("../config/config.js"); -type ConfigFileSnapshot = Awaited>; -type SystemAgentOverviewLoader = () => Promise; -type SystemAgentOverviewFormatter = (overview: SystemAgentOverview) => string; - -const loadConfigModule = async () => await import("../config/config.js"); -const loadOverviewModule = async () => await import("./overview.js"); - -/** Parsed OpenClaw operation before approval/execution. */ -export type SystemAgentOperation = - | { kind: "none"; message: string } - | { kind: "overview" } - | { kind: "doctor" } - | { kind: "doctor-fix" } - | { kind: "status" } - | { kind: "health" } - | { kind: "config-validate" } - | { kind: "config-get"; path: string } - | { kind: "config-schema"; path?: string } - | { 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: "model-setup"; workspace?: string } - | { kind: "channel-list" } - | { kind: "channel-info"; channel: string } - | { kind: "channel-setup"; channel: string } - | { - kind: "open-setup"; - target: "guided" | "classic" | "channels"; - channel?: string; - } - | { kind: "gateway-status" } - | { kind: "gateway-start" } - | { kind: "gateway-stop" } - | { kind: "gateway-restart" } - | { kind: "agents" } - | { kind: "models" } - | { kind: "plugin-list" } - | { kind: "plugin-search"; query: string } - | { kind: "plugin-install"; spec: string } - | { kind: "plugin-uninstall"; pluginId: string } - | { kind: "audit" } - | { kind: "create-agent"; agentId: string; workspace?: string; model?: string } - | { kind: "open-tui"; agentId?: string; workspace?: string } - | { kind: "set-default-model"; model: string }; - -/** Result returned by the operation executor. */ -export type SystemAgentOperationResult = { - applied: boolean; - exitsInteractive?: boolean; - message?: string; - nextInput?: string; - /** Agent TUI exited via /openclaw: re-enter the shell even without a request. */ - returnToShell?: boolean; - followUp?: Extract; -}; - -/** Injectable command dependencies used by tests and alternate runners. */ -export type SystemAgentCommandDeps = { - readConfigFileSnapshot?: typeof import("../config/config.js").readConfigFileSnapshot; - ensureAuthProfileStore?: typeof import("../agents/auth-profiles/store.js").ensureAuthProfileStore; - resolveCliAuthBindingFingerprint?: typeof import("../agents/cli-auth-epoch.js").resolveCliAuthBindingFingerprint; - resolveApiKeyForProvider?: typeof import("../agents/model-auth.js").resolveApiKeyForProvider; - formatOverview?: SystemAgentOverviewFormatter; - loadOverview?: SystemAgentOverviewLoader; - 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; - runPluginInstall?: (spec: string, runtime: RuntimeEnv) => Promise; - runPluginUninstall?: (pluginId: string, runtime: RuntimeEnv) => Promise; - runPluginsList?: (runtime: RuntimeEnv) => Promise; - runPluginsSearch?: (query: string, runtime: RuntimeEnv) => Promise; - runTui?: (opts: { - local: boolean; - session?: string; - deliver?: boolean; - historyLimit?: number; - }) => Promise; - /** Where setup side effects run; the gateway surface never manages its own daemon. */ - setupSurface?: "cli" | "gateway"; - applySetup?: typeof import("./setup-apply.js").applySystemAgentSetup; - verifyInferenceConfig?: typeof import("./setup-inference.js").verifySetupInferenceConfig; - listChannelSetupPlugins?: typeof import("../channels/plugins/setup-registry.js").listChannelSetupPlugins; - resolveChannelSetupEntries?: typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; - isChannelConfigured?: typeof import("../config/channel-configured-shared.js").isStaticallyChannelConfigured; -}; - -// Grammar tokens. Workspace/path tokens accept quoted strings so paths with -// spaces survive; model refs and ids stay single tokens. -const ARG_WORD = String.raw`(?:"[^"]+"|'[^']+'|\S+)`; -const CONFIG_PATH = String.raw`[A-Za-z0-9_.[\]-]+`; - -// Every command pattern is anchored to the whole input. Optional clauses use a -// fixed order (workspace before model) so filler words never become values. -const CONFIG_SET_RE = new RegExp( - String.raw`^(?:config\s+set|set\s+config)\s+(?${CONFIG_PATH})\s+(?.+)$`, - "i", -); -const CONFIG_GET_RE = new RegExp(String.raw`^config\s+get\s+(?${CONFIG_PATH})$`, "i"); -const CONFIG_SCHEMA_RE = new RegExp( - String.raw`^config\s+schema(?:\s+(?${CONFIG_PATH}))?$`, - "i", -); -const CONFIG_SET_REF_RE = new RegExp( - String.raw`^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?${CONFIG_PATH})\s+(?:(?env|file|exec)\s+)?(?\S+)(?:\s+provider\s+(?[A-Za-z0-9_-]+))?$`, - "i", -); -const SETUP_RE = new RegExp( - String.raw`^(?:setup|set\s+me\s+up|set\s+up\s+openclaw|onboard(?:\s+me)?|bootstrap|first\s+run)(?:\s+workspace\s+(?${ARG_WORD}))?(?:\s+model\s+(?\S+))?$`, - "i", -); -const MODEL_SETUP_RE = new RegExp( - String.raw`^(?:configure\s+(?:a\s+)?model\s+provider|set\s*up\s+(?:a\s+)?model\s+provider|model\s+setup)(?:\s+workspace\s+(?${ARG_WORD}))?$`, - "i", -); -const CREATE_AGENT_RE = new RegExp( - String.raw`^(?:create|add|set\s*up|new)\s+(?:(?:an?|new|my)\s+)?agent\s+(?[a-z0-9_-]+)(?:\s+workspace\s+(?${ARG_WORD}))?(?:\s+model\s+(?\S+))?$`, - "i", -); -// "talk to agent for ~/Projects/work" is a documented selector; "for|in" are -// only valid here, after the literal word "agent", never as generic fillers. -const TALK_AGENT_RE = new RegExp( - String.raw`^(?:talk\s+to|switch\s+to|open|enter)\s+(?:(?:my|the)\s+)?(?:(?[a-z0-9_-]+)\s+)?agent(?:\s+(?:for|in|workspace)\s+(?${ARG_WORD}))?$`, - "i", -); -const SET_MODEL_RE = /^(?:set|configure|use)\s+(?:the\s+)?(?:default\s+)?models?\s+(?\S+)$/i; -const GATEWAY_RE = - /^(?:gateway\s+(?status|start|stop|restart)|(?start|stop|restart)\s+(?:the\s+)?gateway)$/i; -const PLUGIN_LIST_RE = /^(?:(?:plugins?|clawhub)\s+list|list\s+plugins?)$/i; -const PLUGIN_SEARCH_RE = - /^(?:(?:plugins?|clawhub)\s+search|search\s+plugins?(?:\s+for)?)\s+(?.+)$/i; -const PLUGIN_INSTALL_RE = - /^(?:plugins?\s+install|install\s+(?:(?npm|clawhub)\s+)?plugins?)\s+(?\S+)$/i; -const PLUGIN_UNINSTALL_RE = - /^(?:plugins?\s+(?:uninstall|remove)|(?:uninstall|remove)\s+plugins?)\s+(?[A-Za-z0-9_.@/-]+)$/i; -const CHANNEL_LIST_RE = /^(?:channels|list\s+channels|show\s+channels)$/i; -const CHANNEL_CONNECT_RE = - /^(?:connect|link)\s+(?:channel\s+)?(?:to\s+)?(?[a-z0-9_-]+)(?:\s+channel)?$/i; -const CHANNEL_INFO_RE = - /^(?:channel\s+info\s+(?[a-z0-9_-]+)|about\s+(?[a-z0-9_-]+)\s+channel)$/i; -const OPEN_GUIDED_SETUP_RE = - /^(?:open\s+setup\s+wizard|setup\s+wizard|menu\s+setup|use\s+the\s+(?:setup\s+)?wizard)$/i; -const OPEN_CLASSIC_SETUP_RE = /^(?:open\s+classic(?:\s+setup)?\s+wizard|classic\s+setup)$/i; -const OPEN_CHANNEL_SETUP_RE = /^open\s+channel\s+wizard(?:\s+for\s+(?[a-z0-9_-]+))?$/i; - -const NO_MATCH_MESSAGE = - "I can run doctor/status/health, check or restart Gateway, list agents/models, configure a model provider, set default model, connect channels (`connect telegram`), show `channel info `, open the setup wizard, show audit, or switch to your agent TUI."; -/** - * Parse one user command into OpenClaw's closed operation union. Anything - * that does not match the anchored grammar exactly returns kind "none" so the - * caller can route it to the system agent (or show guidance). - */ -export function parseSystemAgentOperation(input: string): SystemAgentOperation { - 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" }; - } - switch (lower) { - case "audit": - case "audit log": - case "show audit": - return { kind: "audit" }; - case "status": - return { kind: "status" }; - case "health": - return { kind: "health" }; - case "doctor": - return { kind: "doctor" }; - case "doctor fix": - case "doctor repair": - return { kind: "doctor-fix" }; - case "config validate": - case "validate config": - return { kind: "config-validate" }; - case "agents": - case "list agents": - return { kind: "agents" }; - case "models": - case "list models": - return { kind: "models" }; - case "tui": - case "open tui": - case "chat": - return { kind: "open-tui" }; - case "quit": - case "exit": - return { kind: "none", message: "OpenClaw retracts into shell. Bye." }; - default: - break; - } - const configSetRefMatch = trimmed.match(CONFIG_SET_REF_RE); - if (configSetRefMatch?.groups?.path && configSetRefMatch.groups.id?.trim()) { - // SecretRef commands store references only; raw secret values are never embedded here. - 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(), - }; - } - const configGetMatch = trimmed.match(CONFIG_GET_RE); - if (configGetMatch?.groups?.path) { - return { kind: "config-get", path: configGetMatch.groups.path }; - } - const configSchemaMatch = trimmed.match(CONFIG_SCHEMA_RE); - if (configSchemaMatch) { - const path = configSchemaMatch.groups?.path?.trim(); - return { kind: "config-schema", ...(path ? { path } : {}) }; - } - if (PLUGIN_LIST_RE.test(trimmed)) { - return { kind: "plugin-list" }; - } - const pluginSearchMatch = trimmed.match(PLUGIN_SEARCH_RE); - if (pluginSearchMatch?.groups?.query?.trim()) { - return { kind: "plugin-search", query: pluginSearchMatch.groups.query.trim() }; - } - const pluginInstallMatch = trimmed.match(PLUGIN_INSTALL_RE); - if (pluginInstallMatch?.groups?.spec?.trim()) { - const spec = normalizePluginInstallSpec( - pluginInstallMatch.groups.spec.trim(), - pluginInstallMatch.groups.source, - ); - const validationError = validateSystemAgentPluginInstallSpec(spec); - if (validationError) { - return { kind: "none", message: validationError }; - } - return { kind: "plugin-install", spec }; - } - const pluginUninstallMatch = trimmed.match(PLUGIN_UNINSTALL_RE); - if (pluginUninstallMatch?.groups?.pluginId?.trim()) { - return { kind: "plugin-uninstall", pluginId: pluginUninstallMatch.groups.pluginId.trim() }; - } - if (CHANNEL_LIST_RE.test(trimmed)) { - return { kind: "channel-list" }; - } - const channelInfoMatch = trimmed.match(CHANNEL_INFO_RE); - const channelInfo = channelInfoMatch?.groups?.channel ?? channelInfoMatch?.groups?.aboutChannel; - if (channelInfo) { - return { kind: "channel-info", channel: channelInfo.toLowerCase() }; - } - const channelConnectMatch = trimmed.match(CHANNEL_CONNECT_RE); - if (channelConnectMatch?.groups?.channel) { - return { kind: "channel-setup", channel: channelConnectMatch.groups.channel.toLowerCase() }; - } - const modelSetupMatch = trimmed.match(MODEL_SETUP_RE); - if (modelSetupMatch) { - const workspace = trimShellishToken(modelSetupMatch.groups?.workspace); - return { - kind: "model-setup", - ...(workspace ? { workspace } : {}), - }; - } - if (OPEN_GUIDED_SETUP_RE.test(trimmed)) { - return { kind: "open-setup", target: "guided" }; - } - if (OPEN_CLASSIC_SETUP_RE.test(trimmed)) { - return { kind: "open-setup", target: "classic" }; - } - const openChannelSetupMatch = trimmed.match(OPEN_CHANNEL_SETUP_RE); - if (openChannelSetupMatch) { - const channel = openChannelSetupMatch.groups?.channel?.toLowerCase(); - return { - kind: "open-setup", - target: "channels", - ...(channel ? { channel } : {}), - }; - } - const setupMatch = trimmed.match(SETUP_RE); - if (setupMatch) { - const workspace = trimShellishToken(setupMatch.groups?.workspace); - const model = setupMatch.groups?.model; - return { - kind: "setup", - ...(workspace ? { workspace } : {}), - ...(model ? { model } : {}), - }; - } - const gatewayMatch = trimmed.match(GATEWAY_RE); - if (gatewayMatch) { - const action = (gatewayMatch.groups?.sub ?? gatewayMatch.groups?.verb ?? "").toLowerCase(); - if (action === "start") { - return { kind: "gateway-start" }; - } - if (action === "stop") { - return { kind: "gateway-stop" }; - } - if (action === "restart") { - return { kind: "gateway-restart" }; - } - return { kind: "gateway-status" }; - } - const createMatch = trimmed.match(CREATE_AGENT_RE); - if (createMatch?.groups?.agent) { - const workspace = trimShellishToken(createMatch.groups.workspace); - const model = createMatch.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(talkMatch.groups?.workspace); - return { - kind: "open-tui", - ...(talkMatch.groups?.agent ? { agentId: talkMatch.groups.agent } : {}), - ...(workspace ? { workspace } : {}), - }; - } - const setModelMatch = trimmed.match(SET_MODEL_RE); - if (setModelMatch?.groups?.model) { - return { kind: "set-default-model", model: setModelMatch.groups.model }; - } - return { kind: "none", message: NO_MATCH_MESSAGE }; -} - -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; -} - -function normalizePluginInstallSpec(spec: string, source: string | undefined): string { - const trimmed = spec.trim(); - const normalizedSource = source?.toLowerCase(); - if (normalizedSource === "npm" && !trimmed.toLowerCase().startsWith("npm:")) { - return `npm:${trimmed}`; - } - if (normalizedSource === "clawhub" && !trimmed.toLowerCase().startsWith("clawhub:")) { - return `clawhub:${trimmed}`; - } - return trimmed; -} - -/** - * Return whether an operation can change local state or process lifecycle. - * Guided setup operations are intentionally absent: starting a wizard is not - * itself a write; the wizard owns approval and persistence for its answers. - */ -export function isPersistentSystemAgentOperation(operation: SystemAgentOperation): boolean { - return ( - operation.kind === "set-default-model" || - operation.kind === "config-set" || - operation.kind === "config-set-ref" || - operation.kind === "setup" || - operation.kind === "plugin-install" || - (operation.kind === "create-agent" && - !operation.model?.trim() && - !isReservedSystemAgentId(operation.agentId)) || - operation.kind === "gateway-start" || - operation.kind === "gateway-stop" || - operation.kind === "gateway-restart" - ); -} - -/** Format a user-facing description for an operation requiring approval. */ -export function describeSystemAgentPersistentOperation(operation: SystemAgentOperation): 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 "model-setup": - return "configure a model provider and default model"; - case "doctor-fix": - return "exit OpenClaw and run openclaw doctor --fix"; - case "plugin-install": - return `install plugin ${operation.spec}`; - case "plugin-uninstall": - return `uninstall plugin ${operation.pluginId}`; - 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"; - } -} - -/** Format the standard approval plan text for a persistent operation. */ -export function formatSystemAgentPersistentPlan(operation: SystemAgentOperation): string { - return `Plan: ${describeSystemAgentPersistentOperation(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 (isSensitiveConfigPath(configPath)) { - return ""; - } - return value; -} - -const CONFIG_GET_OUTPUT_MAX_CHARS = 2_000; -const CONFIG_SCHEMA_CHILDREN_MAX = 40; - -function redactConfigValue(value: unknown, configPath: string): unknown { - if (typeof value === "string" || typeof value === "number") { - return isSensitiveConfigPath(configPath) ? "" : value; - } - if (Array.isArray(value)) { - return value.map((entry) => redactConfigValue(entry, `${configPath}[]`)); - } - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record).map(([key, entry]) => [ - key, - redactConfigValue(entry, configPath ? `${configPath}.${key}` : key), - ]), - ); - } - return value; -} - -function readConfigValueAtPath(config: unknown, path: string): { found: boolean; value?: unknown } { - let current: unknown = config; - for (const rawSegment of path.split(".")) { - // Support foo[0] style array segments alongside dotted keys. - const parts = rawSegment.split(/[[\]]/).filter(Boolean); - for (const part of parts) { - if (current === null || typeof current !== "object") { - return { found: false }; - } - const index = /^\d+$/.test(part) ? Number(part) : undefined; - if (index !== undefined && Array.isArray(current)) { - current = current[index]; - } else { - current = (current as Record)[part]; - } - if (current === undefined) { - return { found: false }; - } - } - } - return { found: true, value: current }; -} - -function formatSetupPlanDescription( - operation: Extract, -): string { - const workspace = shortenHomePath(resolveUserPath(operation.workspace ?? process.cwd())); - return `bootstrap OpenClaw setup for workspace ${workspace}`; -} - -function formatGatewayStatusLine(overview: SystemAgentOverview): 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; - } - return await lifecycle.runDaemonRestart(); -} - -async function readConfigFileSnapshotLazy(): Promise { - const { readConfigFileSnapshot } = await loadConfigModule(); - return await readConfigFileSnapshot(); -} - -async function loadOverviewForOperation( - deps: SystemAgentCommandDeps | undefined, -): Promise { - if (deps?.loadOverview) { - return await deps.loadOverview(); - } - const { loadSystemAgentOverview } = await loadOverviewModule(); - return await loadSystemAgentOverview(); -} - -async function resolveChannelSetupState(deps: SystemAgentCommandDeps | undefined) { - const listPlugins = - deps?.listChannelSetupPlugins ?? - (await import("../channels/plugins/setup-registry.js")).listChannelSetupPlugins; - const resolveEntries = - deps?.resolveChannelSetupEntries ?? - (await import("../commands/channel-setup/discovery.js")).resolveChannelSetupEntries; - const isConfigured = - deps?.isChannelConfigured ?? - (await import("../config/channel-configured-shared.js")).isStaticallyChannelConfigured; - const { shouldShowChannelInSetup } = await import("../commands/channel-setup/discovery.js"); - const snapshot = await readConfigFileSnapshotLazy(); - const cfg = snapshot.valid ? (snapshot.runtimeConfig ?? snapshot.config) : {}; - const installedPlugins = listPlugins(); - const resolved = resolveEntries({ cfg, installedPlugins }); - return { - cfg, - installedPlugins, - resolved: { - ...resolved, - // Match the connect/list surfaces: setup-hidden channels stay invisible - // to chat listings and channel info alike. - entries: resolved.entries.filter((entry) => shouldShowChannelInSetup(entry.meta)), - }, - isConfigured, - }; -} - -function formatChannelDocsUrl(docsPath: string): string { - return `https://docs.openclaw.ai${docsPath.startsWith("/") ? docsPath : `/${docsPath}`}`; -} - -function formatConfigValidationLine(snapshot: ConfigFileSnapshot): string { - 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; - deps?: SystemAgentCommandDeps; -}): Promise { - const overview = await loadOverviewForOperation(params.deps); - 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; -} - -type ExecuteOptions = { - approved?: boolean; - deps?: SystemAgentCommandDeps; - auditDetails?: Record; - /** - * Authority check used by the guarded commit seam for host-approved writes. - * A multi-step operation may invoke it more than once; every invocation is - * immediately followed by the persistent effect it authorizes. - */ - beforePersistentApply?: () => Promise; -}; - -/** - * One persistent operation = one audited apply. The shared wrapper owns the - * approval gate, before/after config hashes, the audit record, and the - * `[openclaw] running/done` markers the e2e lanes assert on; each spec only - * describes what to run and what to record. - */ -type PersistentApplyContext = { - runtime: RuntimeEnv; - deps?: SystemAgentCommandDeps; - /** Re-check authority, then enter one persistent side-effect boundary. */ - commit(effect: () => Promise | T): Promise; -}; - -type PersistentApplyOutcome = { - summary: string; - details?: Record; - /** Overrides the after-snapshot config path in the audit record. */ - configPath?: string; -}; - -async function applyPersistentOperation(params: { - auditOperation: string; - operation: SystemAgentOperation; - runtime: RuntimeEnv; - opts: ExecuteOptions; - run: (ctx: PersistentApplyContext) => Promise; -}): Promise { - const { auditOperation, runtime, opts } = params; - if (!opts.approved) { - const message = formatSystemAgentPersistentPlan(params.operation); - runtime.log(message); - return { applied: false, message }; - } - runtime.log(`[openclaw] running: ${auditOperation}`); - const { readConfigFileSnapshot } = await loadConfigModule(); - const before = await readConfigFileSnapshot(); - const commit: PersistentApplyContext["commit"] = async (effect) => { - await opts.beforePersistentApply?.(); - return await effect(); - }; - const outcome = await params.run({ runtime, deps: opts.deps, commit }); - const after = await readConfigFileSnapshot(); - try { - await appendSystemAgentAuditEntry({ - operation: auditOperation, - summary: outcome.summary, - configPath: outcome.configPath ?? after.path ?? before.path ?? undefined, - configHashBefore: before.hash ?? null, - configHashAfter: after.hash ?? null, - details: { ...opts.auditDetails, ...outcome.details }, - }); - } catch (error) { - // The mutation already committed. Keep success truthful while making the - // missing audit record visible to every CLI/chat capture surface. - runtime.error( - `${outcome.summary}, but OpenClaw could not record its audit entry: ${formatErrorMessage(error)}`, - ); - } - runtime.log(`[openclaw] done: ${auditOperation}`); - return { applied: true }; -} - -async function runConfigSetOperation(params: { - operation: Extract; - ctx: PersistentApplyContext; -}): Promise { - const { operation, ctx } = params; - const runConfigSet = - ctx.deps?.runConfigSet ?? - (async (setOpts: { path?: string; value?: string; cliOptions: ConfigSetOptions }) => { - const { runConfigSet: importedRunConfigSet } = await import("../cli/config-cli.js"); - await importedRunConfigSet({ - ...setOpts, - runtime: createNoExitRuntime(ctx.runtime), - }); - }); - if (operation.kind === "config-set") { - await ctx.commit(async () => { - await runConfigSet({ path: operation.path, value: operation.value, cliOptions: {} }); - }); - return; - } - await ctx.commit(async () => { - await runConfigSet({ - path: operation.path, - cliOptions: { - refProvider: operation.provider ?? "default", - refSource: operation.source, - refId: operation.id, - }, - }); - }); -} - -function isInferenceRouteConfigPath(path: readonly string[]): boolean { - const segments = path.map((segment) => segment.trim().toLowerCase()).filter(Boolean); - const [root, scope, ownerOrField, field] = segments; - if (["$include", "auth", "env", "models", "plugins", "secrets", "tools"].includes(root ?? "")) { - return true; - } - if (root !== "agents") { - return false; - } - if (!scope || (scope === "defaults" && !ownerOrField) || (scope === "list" && !ownerOrField)) { - return true; - } - if (scope === "defaults") { - return ["agentruntime", "clibackends", "model", "models", "params", "tools"].includes( - ownerOrField ?? "", - ); - } - if (scope !== "list") { - return false; - } - if (/^\d+$/.test(ownerOrField ?? "") && !field) { - return true; - } - const routeField = /^\d+$/.test(ownerOrField ?? "") ? field : ownerOrField; - return [ - "agentdir", - "agentruntime", - "clibackends", - "default", - "id", - "model", - "models", - "params", - "tools", - ].includes(routeField ?? ""); -} - -async function assertConfigWriteDoesNotBypassInferenceVerification( - operation: Extract, -): Promise { - const { parseConfigSetPath } = await import("../cli/config-cli.js"); - if (!isInferenceRouteConfigPath(parseConfigSetPath(operation.path))) { - return; - } - throw new Error( - "Direct config writes cannot change inference routing or include alternate config. Use `set default model ` for an already configured route, or exit OpenClaw and run `openclaw onboard` to change provider/auth access.", - ); -} - -async function verifyCurrentSetupInference( - runtime: RuntimeEnv, - deps?: SystemAgentCommandDeps, -): Promise<{ - modelRef: string; - route: DefaultInferenceRouteProjection; - latencyMs: number; -}> { - const { readConfigFileSnapshot } = await loadConfigModule(); - const before = await readConfigFileSnapshot(); - if (!before.exists || !before.valid) { - throw new Error( - "OpenClaw setup requires a valid configured inference route. Exit OpenClaw and run `openclaw onboard`, then retry.", - ); - } - const beforeConfig = before.runtimeConfig ?? before.config; - const beforeRoute = await projectDefaultInferenceRoute(beforeConfig); - if (!beforeRoute.route) { - throw new Error( - "OpenClaw setup requires working inference first. Exit OpenClaw and run `openclaw onboard`, then retry.", - ); - } - const verifyInferenceConfig = - deps?.verifyInferenceConfig ?? - (await import("./setup-inference.js")).verifySetupInferenceConfig; - const verification = await verifyInferenceConfig({ config: beforeConfig, runtime }); - if (!verification.ok) { - throw new Error( - `OpenClaw setup requires working inference first. The configured route failed a live check: ${verification.error} Exit OpenClaw and run \`openclaw onboard\`, then retry.`, - ); - } - - const after = await readConfigFileSnapshot(); - if (!after.exists || !after.valid) { - throw new Error( - "The default-agent inference route changed during setup verification, so setup was not applied. Review the current config and retry.", - ); - } - const afterConfig = after.runtimeConfig ?? after.config; - const afterRoute = await projectDefaultInferenceRoute(afterConfig); - if ( - !sameDefaultInferenceRoute(beforeRoute, afterRoute) || - verification.modelRef !== afterRoute.route?.modelLabel - ) { - throw new Error( - "The default-agent inference route changed during setup verification, so setup was not applied. Review the current model/auth/runtime settings and retry.", - ); - } - return { - modelRef: verification.modelRef, - route: afterRoute, - latencyMs: verification.latencyMs, - }; -} - -async function executeSetup( - operation: Extract, - runtime: RuntimeEnv, - opts: ExecuteOptions, -): Promise { - const overview = await loadOverviewForOperation(opts.deps); - const defaultModel = overview.defaultModel?.trim(); - if (!defaultModel) { - throw new Error( - "OpenClaw setup requires working inference first. Run `openclaw onboard` to configure and verify a default model, then start OpenClaw again.", - ); - } - const requestedModel = operation.model?.trim(); - if (requestedModel && requestedModel !== defaultModel) { - throw new Error( - `OpenClaw setup will preserve the verified default model ${defaultModel}. Exit OpenClaw and run \`openclaw onboard\` to stage, live-test, and save a different inference route.`, - ); - } - if (!opts.approved) { - const message = [ - formatSystemAgentPersistentPlan(operation), - `Model choice: keep verified default ${defaultModel}.`, - ].join("\n"); - runtime.log(message); - return { applied: false, message }; - } - const verified = await verifyCurrentSetupInference(runtime, opts.deps); - if (requestedModel && requestedModel !== verified.modelRef) { - throw new Error( - `The verified default model is now ${verified.modelRef}, not ${requestedModel}. Review the current route or exit OpenClaw and run \`openclaw onboard\` before retrying setup.`, - ); - } - const workspace = resolveUserPath(operation.workspace ?? process.cwd()); - return await applyPersistentOperation({ - auditOperation: "openclaw.setup", - operation, - runtime, - opts, - run: async (ctx) => { - const applySetup = - ctx.deps?.applySetup ?? (await import("./setup-apply.js")).applySystemAgentSetup; - const surface = ctx.deps?.setupSurface ?? "cli"; - // The outer boundary covers injected implementations. The production - // setup helper also uses this same seam for each of its internal writes. - const applied = await ctx.commit( - async () => - await applySetup( - { - workspace, - expectedInferenceRoute: verified.route, - surface, - runtime: ctx.runtime, - }, - { commit: async (effect) => await ctx.commit(effect) }, - ), - ); - const after = await readConfigFileSnapshotLazy(); - ctx.runtime.log(`Updated ${after.path || applied.configPath || "config"}`); - for (const line of applied.lines) { - ctx.runtime.log(line); - } - ctx.runtime.log(`Default model: ${verified.modelRef} (verified and kept)`); - return { - summary: "Bootstrapped setup workspace", - configPath: after.path || applied.configPath, - details: { - workspace, - model: verified.modelRef, - modelSource: "live-verified default model", - inferenceLatencyMs: verified.latencyMs, - }, - }; - }, - }); -} - -async function executeSetDefaultModel( - operation: Extract, - runtime: RuntimeEnv, - opts: ExecuteOptions, -): Promise { - return await applyPersistentOperation({ - auditOperation: "config.setDefaultModel", - operation, - runtime, - opts, - run: async (ctx) => { - const { mutateConfigFile, readConfigFileSnapshot } = await loadConfigModule(); - const { applySystemAgentModelSelection, createSystemAgentModelSelectionUpdater } = - await import("./setup-apply.js"); - const snapshot = await readConfigFileSnapshot(); - const stagedConfig = await applySystemAgentModelSelection({ - config: snapshot.sourceConfig, - model: operation.model, - }); - const beforeRoute = await projectDefaultInferenceRoute(snapshot.sourceConfig); - const verifiedRoute = await projectDefaultInferenceRoute(stagedConfig); - const verifyInferenceConfig = - ctx.deps?.verifyInferenceConfig ?? - (await import("./setup-inference.js")).verifySetupInferenceConfig; - const initialVerification = await verifyInferenceConfig({ - config: stagedConfig, - runtime: ctx.runtime, - requireExecutionOwner: true, - }); - if (!initialVerification.ok) { - throw new Error( - `The requested model failed a live inference test, so the current default model was not changed. ${initialVerification.error} Fix provider authentication or model access, then retry.`, - ); - } - const verifiedModelRef = verifiedRoute.route?.modelLabel; - if (!verifiedModelRef || initialVerification.modelRef !== verifiedModelRef) { - throw new Error( - "The live inference test did not verify the exact model route that would be saved, so the current default model was not changed. Review model aliases and runtime routing, then retry.", - ); - } - let persistedVerification = initialVerification; - let selectedRouteForCommit = verifiedRoute; - const selectModel = await createSystemAgentModelSelectionUpdater({ - model: operation.model, - }); - const result = await mutateConfigFile({ - base: "source", - writeOptions: { - preCommitRuntimePreflight: async (sourceConfig) => { - const commitRoute = await projectDefaultInferenceRoute(sourceConfig); - if (!sameDefaultInferenceRoute(commitRoute, selectedRouteForCommit)) { - throw new Error( - "The selected inference route changed while preparing the config write, so the requested model was not saved. Review the current model/auth/runtime settings and retry.", - ); - } - await opts.beforePersistentApply?.(); - const latestVerification = await verifyInferenceConfig({ - config: sourceConfig, - runtime: ctx.runtime, - requireExecutionOwner: true, - }); - if (!latestVerification.ok) { - throw new Error( - `The requested model no longer passes live inference at the config commit boundary, so it was not saved. ${latestVerification.error} Review concurrent configuration changes and retry.`, - ); - } - if (latestVerification.modelRef !== commitRoute.route?.modelLabel) { - throw new Error( - "The final live inference test did not verify the exact model route at the config commit boundary, so the requested model was not saved. Review model aliases and runtime routing, then retry.", - ); - } - // The live probe can outlive the original OpenClaw authority. - // Re-check it last, immediately before the writer crosses to disk. - await opts.beforePersistentApply?.(); - persistedVerification = latestVerification; - }, - }, - mutate: async (cfg) => { - // Verification may take time. Preserve unrelated edits, but never - // combine the passing result with a concurrently changed route. - const currentRoute = await projectDefaultInferenceRoute(cfg); - if (!sameDefaultInferenceRoute(currentRoute, beforeRoute)) { - throw new Error( - "The default-agent inference route changed during verification, so the requested model was not saved. Review the current model/auth/runtime settings and retry.", - ); - } - const selected = selectModel(cfg); - const selectedRoute = await projectDefaultInferenceRoute(selected); - if (selectedRoute.route?.modelLabel !== verifiedModelRef) { - throw new Error( - "The model selection no longer resolves to the exact model that passed live inference. Review the current model/auth/runtime settings and retry.", - ); - } - // Unrelated concurrent edits can change how the selected model is - // represented. Bind the commit gate to this deterministic projection; - // the final live probe below verifies these exact bytes before write. - selectedRouteForCommit = selectedRoute; - cfg.agents = selected.agents; - }, - }); - ctx.runtime.log(`Updated ${result.path}`); - ctx.runtime.log(`Default model: ${persistedVerification.modelRef}`); - return { - summary: `Set default model to ${operation.model}`, - configPath: result.path, - details: { - requestedModel: operation.model, - effectiveModel: persistedVerification.modelRef, - inferenceVerified: true, - inferenceLatencyMs: persistedVerification.latencyMs, - }, - }; - }, - }); -} - -async function executePluginInstall( - operation: Extract, - runtime: RuntimeEnv, - opts: ExecuteOptions, -): Promise { - if (opts.approved) { - const validationError = validateSystemAgentPluginInstallSpec(operation.spec); - if (validationError) { - throw new Error(validationError); - } - } - const result = await applyPersistentOperation({ - auditOperation: "plugin.install", - operation, - runtime, - opts, - run: async (ctx) => { - const runPluginInstall = - ctx.deps?.runPluginInstall ?? - (async (spec: string, pluginRuntime: RuntimeEnv) => { - const { runPluginInstallCommand } = await import("../cli/plugins-install-command.js"); - await runPluginInstallCommand({ raw: spec, opts: {}, runtime: pluginRuntime }); - }); - await ctx.commit(async () => { - await runPluginInstall(operation.spec, createNoExitRuntime(ctx.runtime)); - }); - return { summary: `Installed plugin ${operation.spec}`, details: { spec: operation.spec } }; - }, - }); - if (result.applied) { - runtime.log("Restart the Gateway to apply installed plugin changes."); - } - return result; -} - -/** Execute a parsed OpenClaw operation after applying approval gates and audit logging. */ -export async function executeSystemAgentOperation( - operation: SystemAgentOperation, - runtime: RuntimeEnv, - opts: ExecuteOptions = {}, -): Promise { - switch (operation.kind) { - case "none": - runtime.log(operation.message); - return { applied: false, exitsInteractive: operation.message.includes("Bye.") }; - case "overview": { - const overview = await loadOverviewForOperation(opts.deps); - if (opts.deps?.formatOverview) { - runtime.log(opts.deps.formatOverview(overview)); - } else { - const { formatSystemAgentOverview } = await loadOverviewModule(); - runtime.log(formatSystemAgentOverview(overview)); - } - return { applied: false }; - } - case "agents": { - const overview = await loadOverviewForOperation(opts.deps); - 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 }; - } - case "models": { - const overview = await loadOverviewForOperation(opts.deps); - 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"}`, - `Gemini CLI: ${overview.tools.gemini.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 }; - } - case "plugin-list": { - const runPluginsList = - opts.deps?.runPluginsList ?? - (async (pluginRuntime: RuntimeEnv) => { - const { runPluginsListCommand } = await import("../cli/plugins-list-command.js"); - await runPluginsListCommand({}, pluginRuntime); - }); - await runPluginsList(runtime); - return { applied: false }; - } - case "plugin-search": { - const runPluginsSearch = - opts.deps?.runPluginsSearch ?? - (async (query: string, pluginRuntime: RuntimeEnv) => { - const { runPluginsSearchCommand } = await import("../cli/plugins-search-command.js"); - await runPluginsSearchCommand(query, {}, pluginRuntime); - }); - await runPluginsSearch(operation.query, runtime); - return { applied: false }; - } - case "audit": - runtime.log(`Audit log: ${resolveSystemAgentAuditPath()}`); - runtime.log("Only applied writes/actions are recorded; discovery stays quiet."); - return { applied: false }; - case "config-validate": { - const snapshot = await readConfigFileSnapshotLazy(); - runtime.log(formatConfigValidationLine(snapshot)); - return { applied: false }; - } - case "config-get": { - const snapshot = await readConfigFileSnapshotLazy(); - if (!snapshot.exists) { - runtime.log(`Config missing: ${shortenHomePath(snapshot.path)}`); - return { applied: false }; - } - const cfg = snapshot.valid - ? (snapshot.sourceConfig ?? snapshot.config) - : snapshot.sourceConfig; - const lookup = readConfigValueAtPath(cfg ?? {}, operation.path); - if (!lookup.found) { - runtime.log( - `${operation.path}: not set. Use \`config schema ${operation.path}\` to see what is allowed.`, - ); - return { applied: false }; - } - const redacted = redactConfigValue(lookup.value, operation.path); - const rendered = JSON.stringify(redacted, null, 2) ?? "null"; - runtime.log( - rendered.length > CONFIG_GET_OUTPUT_MAX_CHARS - ? `${operation.path} = ${truncateUtf16Safe(rendered, CONFIG_GET_OUTPUT_MAX_CHARS)}\n… (truncated)` - : `${operation.path} = ${rendered}`, - ); - return { applied: false }; - } - case "config-schema": { - const { buildConfigSchema, lookupConfigSchema } = await import("../config/schema.js"); - const response = buildConfigSchema(); - const path = operation.path ?? "."; - const result = lookupConfigSchema(response, path); - if (!result) { - runtime.log(`No config schema at "${path}". Try \`config schema .\` for the root keys.`); - return { applied: false }; - } - const schema = result.schema as { - type?: string | string[]; - description?: string; - enum?: unknown[]; - default?: unknown; - }; - const childLines = result.children.slice(0, CONFIG_SCHEMA_CHILDREN_MAX).map((child) => { - const type = Array.isArray(child.type) ? child.type.join("|") : (child.type ?? "object"); - const bits = [ - type, - child.required ? "required" : undefined, - child.hasChildren ? "…" : undefined, - ] - .filter(Boolean) - .join(", "); - return ` - ${child.path} (${bits})`; - }); - runtime.log( - [ - `Schema for ${result.path === "" ? "." : result.path}:`, - schema.type - ? `type: ${Array.isArray(schema.type) ? schema.type.join("|") : schema.type}` - : undefined, - schema.description ? `description: ${schema.description}` : undefined, - schema.enum - ? `allowed values: ${schema.enum.map((v) => JSON.stringify(v)).join(", ")}` - : undefined, - schema.default !== undefined ? `default: ${JSON.stringify(schema.default)}` : undefined, - ...(childLines.length > 0 ? ["keys:", ...childLines] : []), - result.children.length > CONFIG_SCHEMA_CHILDREN_MAX - ? `… +${result.children.length - CONFIG_SCHEMA_CHILDREN_MAX} more keys` - : undefined, - ] - .filter((line): line is string => line !== undefined) - .join("\n"), - ); - return { applied: false }; - } - case "channel-list": { - // Use the same discovery as channel setup (bundled plugins + trusted - // catalog), so the listing matches what `connect ` can configure - // even before any plugin registry is active. - const { resolved } = await resolveChannelSetupState(opts.deps); - const entries = resolved.entries.toSorted((a, b) => a.id.localeCompare(b.id)); - runtime.log( - [ - "Channels:", - ...entries.map( - (entry) => ` - ${entry.id}${entry.meta.label ? ` (${entry.meta.label})` : ""}`, - ), - "", - "Say `connect ` to walk through setup (for example `connect telegram`).", - ].join("\n"), - ); - return { applied: false }; - } - case "channel-info": { - const { cfg, installedPlugins, resolved, isConfigured } = await resolveChannelSetupState( - opts.deps, - ); - const channel = operation.channel.toLowerCase(); - const entry = resolved.entries.find((candidate) => candidate.id === channel); - if (!entry) { - const knownIds = resolved.entries.map((candidate) => candidate.id).toSorted(); - runtime.log( - [ - `Unknown channel: ${channel}`, - `Known channels: ${knownIds.length > 0 ? knownIds.join(", ") : "none"}`, - ].join("\n"), - ); - return { applied: false }; - } - const installed = - installedPlugins.some((plugin) => plugin.id === entry.id) || - resolved.installedCatalogById.has(entry.id); - runtime.log( - [ - `${entry.meta.label} (${entry.id})`, - entry.meta.blurb, - `Configured: ${isConfigured(cfg, entry.id) ? "yes" : "no"}`, - `Installed: ${installed ? "yes" : "no"}`, - `Docs: ${formatChannelDocsUrl(entry.meta.docsPath)}`, - "", - `Say \`connect ${entry.id}\` to set it up here, or \`open channel wizard for ${entry.id}\` for the masked terminal wizard.`, - ].join("\n"), - ); - return { applied: false }; - } - case "channel-setup": - // Channel setup is a multi-step wizard; only interactive OpenClaw (TUI - // chat bridge or the gateway chat) can host it. One-shot mode points at - // the guided paths. - runtime.log( - [ - `Connecting ${operation.channel} needs an interactive session.`, - "Run `openclaw setup` and say `connect " + operation.channel + "`,", - "or run `openclaw channels add` for the terminal wizard.", - ].join("\n"), - ); - return { applied: false }; - case "model-setup": - runtime.log( - [ - "Changing model providers must happen outside the inference session that powers OpenClaw.", - "Exit OpenClaw and run `openclaw onboard`; it stages credentials, live-tests the candidate route, and saves only a passing setup.", - ].join("\n"), - ); - return { applied: false }; - case "open-setup": { - const command = - operation.target === "guided" - ? "openclaw onboard" - : operation.target === "classic" - ? "openclaw onboard --classic" - : `openclaw channels add${operation.channel ? ` --channel ${operation.channel}` : ""}`; - runtime.log( - `One-shot mode cannot open an interactive wizard. Run \`${command}\` in a terminal.`, - ); - return { applied: false }; - } - case "setup": - return await executeSetup(operation, runtime, opts); - case "config-set": - await assertConfigWriteDoesNotBypassInferenceVerification(operation); - return await applyPersistentOperation({ - auditOperation: "config.set", - operation, - runtime, - opts, - run: async (ctx) => { - await runConfigSetOperation({ operation, ctx }); - return { summary: `Set config ${operation.path}`, details: { path: operation.path } }; - }, - }); - case "config-set-ref": - await assertConfigWriteDoesNotBypassInferenceVerification(operation); - return await applyPersistentOperation({ - auditOperation: "config.setRef", - operation, - runtime, - opts, - run: async (ctx) => { - await runConfigSetOperation({ operation, ctx }); - return { - summary: `Set config ${operation.path} SecretRef`, - details: { - path: operation.path, - source: operation.source, - provider: operation.provider ?? "default", - }, - }; - }, - }); - case "plugin-install": - return await executePluginInstall(operation, runtime, opts); - case "plugin-uninstall": { - const message = [ - "OpenClaw cannot prove that uninstalling a plugin will preserve its own active inference route.", - `Exit OpenClaw and run \`openclaw plugins uninstall ${operation.pluginId}\` from a terminal.`, - ].join("\n"); - runtime.log(message); - return { applied: false, message }; - } - case "create-agent": { - if (isReservedSystemAgentId(operation.agentId)) { - throw new Error( - `Agent id "${normalizeAgentId(operation.agentId)}" is reserved for the system agent. Choose a different agent id.`, - ); - } - if (operation.model?.trim()) { - throw new Error( - "OpenClaw cannot save an explicit per-agent model until that new route can be live-tested. Retry without `model`; the new agent will inherit the already verified default model.", - ); - } - const workspace = resolveUserPath(operation.workspace ?? process.cwd()); - return await applyPersistentOperation({ - auditOperation: "agents.create", - operation, - runtime, - opts, - run: async (ctx) => { - const runAgentsAdd = - ctx.deps?.runAgentsAdd ?? - (await import("../commands/agents.commands.add.js")).agentsAddCommand; - await ctx.commit(async () => { - await runAgentsAdd( - { - name: operation.agentId, - workspace, - nonInteractive: true, - }, - ctx.runtime, - { hasFlags: true }, - ); - }); - return { - summary: `Created agent ${operation.agentId}`, - details: { - agentId: operation.agentId, - workspace, - }, - }; - }, - }); - } - case "doctor": { - const runDoctor = - opts.deps?.runDoctor ?? (await import("../commands/doctor.js")).doctorCommand; - await runDoctor(runtime, { nonInteractive: true }); - return { applied: false }; - } - case "doctor-fix": - runtime.log( - "Doctor repairs can change the inference route that powers this session. Exit OpenClaw and run `openclaw doctor --fix` in a terminal.", - ); - return { applied: false }; - case "status": { - const { statusCommand } = await import("../commands/status.command.js"); - await statusCommand({ timeoutMs: 10_000 }, runtime); - return { applied: false }; - } - case "health": { - const { healthCommand } = await import("../commands/health.js"); - await healthCommand({ timeoutMs: 10_000 }, runtime); - return { applied: false }; - } - case "gateway-status": { - const overview = await loadOverviewForOperation(opts.deps); - runtime.log(formatGatewayStatusLine(overview)); - return { applied: false }; - } - case "gateway-start": - return await applyPersistentOperation({ - auditOperation: "gateway.start", - operation, - runtime, - opts, - run: async (ctx) => { - const runGatewayStart = ctx.deps?.runGatewayStart ?? (() => runGatewayLifecycle("start")); - await ctx.commit(runGatewayStart); - return { summary: "Started Gateway" }; - }, - }); - case "gateway-stop": - return await applyPersistentOperation({ - auditOperation: "gateway.stop", - operation, - runtime, - opts, - run: async (ctx) => { - const runGatewayStop = ctx.deps?.runGatewayStop ?? (() => runGatewayLifecycle("stop")); - await ctx.commit(runGatewayStop); - return { summary: "Stopped Gateway" }; - }, - }); - case "gateway-restart": - return await applyPersistentOperation({ - auditOperation: "gateway.restart", - operation, - runtime, - opts, - run: async (ctx) => { - const runGatewayRestart = - ctx.deps?.runGatewayRestart ?? (() => runGatewayLifecycle("restart")); - const restarted = await ctx.commit(runGatewayRestart); - if (restarted === false) { - throw new Error("Gateway restart did not complete"); - } - return { summary: "Restarted Gateway" }; - }, - }); - case "open-tui": { - const agentId = await resolveTuiAgentId({ - requestedAgentId: operation.agentId, - requestedWorkspace: operation.workspace, - deps: opts.deps, - }); - 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-system-agent") { - runtime.log( - result.systemAgentMessage - ? `[openclaw] returned from agent with request: ${result.systemAgentMessage}` - : "[openclaw] returned from agent", - ); - return { applied: false, returnToShell: true, nextInput: result.systemAgentMessage }; - } - return { applied: false, exitsInteractive: true }; - } - case "set-default-model": - return await executeSetDefaultModel(operation, runtime, opts); - default: - return { applied: false }; - } -} +export * from "./operations-execute.js"; +export * from "./operations-parse.js"; diff --git a/test/scripts/full-release-validation-at-sha.test.ts b/test/scripts/full-release-validation-at-sha.test.ts index d39f9c49ba3a..96e5e5e490a5 100644 --- a/test/scripts/full-release-validation-at-sha.test.ts +++ b/test/scripts/full-release-validation-at-sha.test.ts @@ -55,6 +55,9 @@ describe("full-release-validation-at-sha", () => { const source = readFileSync("scripts/full-release-validation-at-sha.mjs", "utf8"); expect(source).toContain("ref: targetSha"); expect(source).toContain("target_context_ref: targetContextRef"); + expect(source).toContain( + 'args.inputs.allow_unreleased_changelog ??= args.targetRef ? "false" : "true"', + ); }); it("rejects missing option values", () => { @@ -113,6 +116,9 @@ describe("full-release-validation-at-sha", () => { expect(() => parseArgs(["-f", "release_profile=minimum"])).toThrow( "release_profile must be beta, stable, or full", ); + expect(() => parseArgs(["-f", "allow_unreleased_changelog=maybe"])).toThrow( + "allow_unreleased_changelog must be true or false", + ); }); it("reserves the candidate ref for the resolved --sha", () => { diff --git a/test/scripts/install-smoke-no-push-workflow.test.ts b/test/scripts/install-smoke-no-push-workflow.test.ts index c37368456c37..7167d76ab843 100644 --- a/test/scripts/install-smoke-no-push-workflow.test.ts +++ b/test/scripts/install-smoke-no-push-workflow.test.ts @@ -269,10 +269,11 @@ describe("install smoke no-push root image transport", () => { packages: "read", }); expect(caller.with).toMatchObject({ + allow_unreleased_changelog: + "${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }}", ref: "${{ needs.resolve_target.outputs.revision }}", run_bun_global_install_smoke: true, }); - expect(caller.with).not.toHaveProperty("allow_unreleased_changelog"); }); it("passes package changelog intent only to current-tree smoke scripts", () => { diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index e0ff0df2a870..f6cadd60b64d 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -657,6 +657,48 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { ); }); + it("allows Unreleased notes only for current-tree release checks", () => { + const workflow = parse(readFileSync(".github/workflows/openclaw-release-checks.yml", "utf8")); + const fullReleaseSource = readFileSync(".github/workflows/full-release-validation.yml", "utf8"); + const resolveTarget = workflow.jobs.resolve_target; + const captureInputs = resolveTarget.steps.find( + (step: WorkflowStep) => step.name === "Capture selected inputs", + ); + const currentTreeAllowance = + "${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }}"; + + expect(workflow.on.workflow_dispatch.inputs.allow_unreleased_changelog).toEqual({ + default: false, + description: + "Allow current-tree packaging to use Unreleased notes; release branches and tags stay strict", + required: false, + type: "boolean", + }); + expect(resolveTarget.outputs.allow_unreleased_changelog).toBe( + "${{ steps.inputs.outputs.allow_unreleased_changelog }}", + ); + expect(captureInputs?.run).toContain('RELEASE_REF_INPUT" == "main"'); + expect(captureInputs?.run).toContain('RELEASE_REF_INPUT" == "refs/heads/main"'); + expect(captureInputs?.run).toContain("release/[0-9]{4}"); + expect(captureInputs?.run).toContain("extended-stable/[0-9]{4}"); + expect(captureInputs?.run).toContain("tideclaw/alpha/"); + expect(captureInputs?.run).toContain("refs/tags/"); + expect(captureInputs?.run).toContain("RELEASE_ALLOW_UNRELEASED_CHANGELOG_INPUT"); + expect(captureInputs?.run).toContain("allow_unreleased_changelog=false"); + expect(workflow.jobs.install_smoke_release_checks.with.allow_unreleased_changelog).toBe( + currentTreeAllowance, + ); + expect(workflow.jobs.live_repo_e2e_release_checks.with.allow_unreleased_changelog).toBe( + currentTreeAllowance, + ); + expect(workflow.jobs.docker_e2e_release_checks.with.allow_unreleased_changelog).toBe( + currentTreeAllowance, + ); + expect(fullReleaseSource).toContain( + "ALLOW_UNRELEASED_CHANGELOG: ${{ inputs.target_context_ref == '' && (inputs.allow_unreleased_changelog || inputs.ref == 'main' || inputs.ref == 'refs/heads/main') }}", + ); + }); + it("keeps runtime tool coverage blocking in release checks", () => { const releaseChecksSource = readFileSync( ".github/workflows/openclaw-release-checks.yml", diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 05a69ea0606b..9cae3818ed1a 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -372,11 +372,14 @@ describe("release validation no-push transport", () => { 'if [[ "$source_sha" != "$PACKAGE_REF" ]]', ); expect(live.with).toMatchObject({ + allow_unreleased_changelog: + "${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }}", shared_image_artifact_namespace: "release-live", shared_image_policy: "no-push-artifact", }); - expect(live.with).not.toHaveProperty("allow_unreleased_changelog"); expect(docker.with).toMatchObject({ + allow_unreleased_changelog: + "${{ needs.resolve_target.outputs.allow_unreleased_changelog == 'true' }}", package_artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", package_artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}", package_artifact_name: "${{ needs.prepare_release_package.outputs.artifact_name }}", @@ -390,7 +393,6 @@ describe("release validation no-push transport", () => { shared_image_artifact_namespace: "release-docker", shared_image_policy: "no-push-artifact", }); - expect(docker.with).not.toHaveProperty("allow_unreleased_changelog"); expect(acceptance.with).toMatchObject({ artifact_digest: "${{ needs.prepare_release_package.outputs.artifact_digest }}", artifact_id: "${{ needs.prepare_release_package.outputs.artifact_id }}",