feat(channels): add channel-owned setup contracts (#112176)

* feat(channels): add channel-owned setup contracts

* test(channels): align legacy setup fixtures

* chore(channels): regenerate config and SDK baselines after rebase

* fix(update): run fresh doctor after current-process core changes

* fix(channels): align add pre-scan with execution precedence

* style(cli): format channels-cli test additions

* fix(channels): restore option-before-positional channel resolution via metadata arity scan

* fix(channels): keep help flags out of metadata arity escalation

* test(update): mock fresh post-update doctor in current-process suites

* style: format review fixes and correct entrypoint mock type

* fix(channels): register only modern contract options for dual-publishing plugins

* test(update): align downgrade suites with fresh-doctor child invocation

* docs(channels): record empty-contract and input-forwarding invariants

* fix(line): keep the shipped --token switch as a channel access token alias

* fix(signal): stop treating exact cross-family loopback endpoints as bind-aligned

* chore(config): regenerate docs config baselines after second rebase

* style: format rebased channels add tests

* fix(channels): enforce field-key and flag-name agreement in setup contracts

* fix(signal): detect container endpoints for bare --http-url setup

* fix(signal): ignore unconfigured accounts in transport collision checks

* fix(channels): validate negated setup flags in contract and normalizer

* fix(signal): preserve existing transport kind when setup detection is unreachable

* style(signal): use direct boolean check in collision guard

* style(signal): type test config literals

* docs(update): record two-read design of fresh-doctor validation gate

* fix(channels): satisfy post-rebase architecture gates

* docs: refresh channel setup map

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Jesse Merhi
2026-07-23 09:57:42 +10:00
committed by GitHub
parent b375776acf
commit 4a2a600809
181 changed files with 9189 additions and 1721 deletions
@@ -0,0 +1,228 @@
// Runs the post-plugin migration pass without retaining pre-update plugin modules.
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
} from "../../commands/doctor/shared/update-phase.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import type { ConfigFileSnapshot } from "../../config/types.openclaw.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { runExec } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveNodeRunner } from "./shared.js";
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
import {
applyPostPluginConfigValidation,
POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON,
} from "./update-command-post-plugin-validation.js";
import {
disableUpdatedPackageCompileCacheEnv,
stripGatewayServiceMarkerEnv,
} from "./update-command-service.js";
export function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T> {
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
const previousDeferConfiguredPluginInstallRepair =
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
const previousParentSupportsDoctorConfigWrite =
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
return run().finally(() => {
if (previousUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
}
if (previousDeferConfiguredPluginInstallRepair === undefined) {
delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
} else {
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] =
previousDeferConfiguredPluginInstallRepair;
}
if (previousParentSupportsDoctorConfigWrite === undefined) {
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
} else {
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] =
previousParentSupportsDoctorConfigWrite;
}
});
}
async function withNormalConfigValidation<T>(run: () => Promise<T>): Promise<T> {
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "0";
try {
return await run();
} finally {
if (previousUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
}
}
}
function createPostPluginDoctorExecutionFailure(
pluginUpdate: PostCorePluginUpdateResult,
reason: string,
): PostCorePluginUpdateResult {
return {
...pluginUpdate,
status: "error",
reason: POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON,
warnings: [
...(pluginUpdate.warnings ?? []),
{
reason,
message: "Updated plugin migrations could not be run in a fresh process.",
guidance: ["Run `openclaw update repair` to retry post-update plugin repair."],
},
],
};
}
async function runPostPluginDoctorInFreshProcess(params: {
root: string;
yes: boolean;
json: boolean;
timeoutMs: number;
nodeRunner?: string;
entryPath?: string;
}): Promise<void> {
const entryPath = params.entryPath ?? (await resolveGatewayInstallEntrypoint(params.root));
if (!entryPath) {
throw new Error("Updated OpenClaw entrypoint not found for post-plugin doctor");
}
const args = [
entryPath,
"doctor",
"--repair",
"--non-interactive",
"--no-workspace-suggestions",
...(params.yes ? ["--yes"] : []),
];
const result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, {
cwd: params.root,
timeoutMs: params.timeoutMs,
maxBuffer: 4 * 1024 * 1024,
logOutput: false,
baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
},
});
if (!params.json) {
if (result.stdout.trim()) {
defaultRuntime.log(result.stdout.trimEnd());
}
if (result.stderr.trim()) {
defaultRuntime.error(result.stderr.trimEnd());
}
}
}
async function validatePostPluginConfigInFreshProcess(params: {
root: string;
timeoutMs: number;
entryPath: string;
nodeRunner?: string;
}): Promise<boolean> {
try {
await runExec(
params.nodeRunner ?? resolveNodeRunner(),
[params.entryPath, "config", "validate", "--json"],
{
cwd: params.root,
timeoutMs: params.timeoutMs,
maxBuffer: 4 * 1024 * 1024,
logOutput: false,
baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" },
},
);
return true;
} catch {
return false;
}
}
async function applyFreshPostPluginDoctor(params: {
root: string;
pluginUpdate: PostCorePluginUpdateResult;
yes: boolean;
json: boolean;
timeoutMs: number;
nodeRunner?: string;
}): Promise<{ pluginUpdate: PostCorePluginUpdateResult; configValid: boolean }> {
let entryPath: string | undefined;
try {
entryPath = await resolveGatewayInstallEntrypoint(params.root);
} catch (err) {
return {
pluginUpdate: createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err)),
configValid: false,
};
}
if (!entryPath) {
return {
pluginUpdate: createPostPluginDoctorExecutionFailure(
params.pluginUpdate,
"Updated OpenClaw entrypoint not found for post-plugin doctor",
),
configValid: false,
};
}
let pluginUpdate = params.pluginUpdate;
try {
await runPostPluginDoctorInFreshProcess({ ...params, entryPath });
} catch (err) {
pluginUpdate = createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err));
}
const configValid = await validatePostPluginConfigInFreshProcess({ ...params, entryPath });
return { pluginUpdate, configValid };
}
export async function completePostCorePluginUpdate(params: {
root: string;
pluginUpdate: PostCorePluginUpdateResult;
freshDoctorRequired: boolean;
yes: boolean;
json: boolean;
timeoutMs: number;
nodeRunner?: string;
}): Promise<{
pluginUpdate: PostCorePluginUpdateResult;
configSnapshot: ConfigFileSnapshot;
}> {
let pluginUpdate = params.pluginUpdate;
let freshConfigValid: boolean | undefined;
if (pluginUpdate.status !== "error" && params.freshDoctorRequired) {
// The current process can still hold the pre-update plugin and schema. Reload the updated
// migration owner before trusting strict validation or restarting the gateway.
const freshResult = await applyFreshPostPluginDoctor({
root: params.root,
pluginUpdate,
yes: params.yes,
json: params.json,
timeoutMs: params.timeoutMs,
...(params.nodeRunner ? { nodeRunner: params.nodeRunner } : {}),
});
pluginUpdate = freshResult.pluginUpdate;
freshConfigValid = freshResult.configValid;
}
const configSnapshot = await withNormalConfigValidation(() => readConfigFileSnapshot());
// A plugin migration that did not converge must fail finalization instead of letting legacy
// config reach the restarted gateway.
// Two reads by design: the fresh child is the only process able to validate under the
// UPDATED schema, so its verdict gates the restart; this parent snapshot is best-effort
// state under the stale in-memory schema and the restarted gateway re-reads config anyway.
pluginUpdate = applyPostPluginConfigValidation(
pluginUpdate,
freshConfigValid ?? configSnapshot.valid,
);
return { pluginUpdate, configSnapshot };
}
+33 -52
View File
@@ -7,10 +7,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { doctorCommand } from "../../commands/doctor.js";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
} from "../../commands/doctor/shared/update-phase.js";
import {
assertConfigWriteAllowedInCurrentMode,
readConfigFileSnapshot,
@@ -70,6 +66,10 @@ import {
restoreDroppedPreUpdateChannels,
writePostCoreSourceConfigFile,
} from "./update-command-config.js";
import {
completePostCorePluginUpdate,
withUpdateFinalizationEnv,
} from "./update-command-fresh-doctor.js";
import {
updatePluginsAfterCoreUpdate,
type PostCorePluginUpdateResult,
@@ -128,36 +128,6 @@ type UpdateFinalizeResult = {
};
};
function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T> {
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
const previousDeferConfiguredPluginInstallRepair =
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
const previousParentSupportsDoctorConfigWrite =
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
return run().finally(() => {
if (previousUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
}
if (previousDeferConfiguredPluginInstallRepair === undefined) {
delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
} else {
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] =
previousDeferConfiguredPluginInstallRepair;
}
if (previousParentSupportsDoctorConfigWrite === undefined) {
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
} else {
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] =
previousParentSupportsDoctorConfigWrite;
}
});
}
export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promise<void> {
suppressDeprecations();
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
@@ -225,7 +195,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
});
}
const pluginUpdate = await withUpdateFinalizationEnv(async () => {
const initialPluginUpdate = await withUpdateFinalizationEnv(async () => {
await createUpdateConfigSnapshot();
await doctorCommand(defaultRuntime, {
nonInteractive: true,
@@ -268,6 +238,16 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
pluginInstallRecords,
});
});
const completedPluginUpdate = await completePostCorePluginUpdate({
root,
pluginUpdate: initialPluginUpdate,
freshDoctorRequired: initialPluginUpdate.changed,
yes: opts.yes === true,
json: opts.json === true,
timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS,
});
const pluginUpdate = completedPluginUpdate.pluginUpdate;
configSnapshot = completedPluginUpdate.configSnapshot;
const result: UpdateFinalizeResult = {
status:
@@ -627,27 +607,28 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
}
}
export function didCoreUpdateChangeInstall(result: UpdateRunResult): boolean {
if (isPackageManagerUpdateMode(result.mode)) {
return true;
}
if (result.mode !== "git") {
return false;
}
const beforeSha = normalizeOptionalString(result.before?.sha);
const afterSha = normalizeOptionalString(result.after?.sha);
if (beforeSha && afterSha && beforeSha !== afterSha) {
return true;
}
const beforeVersion = normalizeOptionalString(result.before?.version);
const afterVersion = normalizeOptionalString(result.after?.version);
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
}
export function shouldResumePostCoreUpdateInFreshProcess(params: {
result: UpdateRunResult;
downgradeRisk: boolean;
}): boolean {
if (params.downgradeRisk) {
return false;
}
if (isPackageManagerUpdateMode(params.result.mode)) {
return true;
}
if (params.result.mode !== "git") {
return false;
}
const beforeSha = normalizeOptionalString(params.result.before?.sha);
const afterSha = normalizeOptionalString(params.result.after?.sha);
if (beforeSha && afterSha && beforeSha !== afterSha) {
return true;
}
const beforeVersion = normalizeOptionalString(params.result.before?.version);
const afterVersion = normalizeOptionalString(params.result.after?.version);
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
return !params.downgradeRisk && didCoreUpdateChangeInstall(params.result);
}
export async function writeControlPlaneUpdateRestartSentinelBestEffort(params: {
@@ -0,0 +1,30 @@
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
export const POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON = "post-plugin-doctor-execution-failed";
export function applyPostPluginConfigValidation(
pluginUpdate: PostCorePluginUpdateResult,
configValid: boolean,
): PostCorePluginUpdateResult {
if (
configValid ||
(pluginUpdate.status === "error" &&
pluginUpdate.reason !== POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON)
) {
return pluginUpdate;
}
return {
...pluginUpdate,
status: "error",
reason: "post-plugin-doctor-invalid-config",
warnings: [
...(pluginUpdate.warnings ?? []),
{
reason: "Config remained invalid after updated plugin migrations.",
message:
"Post-update plugin migration did not produce a valid config; refusing to restart.",
guidance: ["Run `openclaw doctor --fix`, then rerun `openclaw update repair`."],
},
],
};
}
@@ -26,13 +26,16 @@ import {
persistRequestedUpdateChannel,
restoreDroppedPreUpdateChannels,
} from "./update-command-config.js";
import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
continuePostCoreUpdateInFreshProcess,
didCoreUpdateChangeInstall,
markControlPlaneUpdateRestartSentinelFailureBestEffort,
shouldResumePostCoreUpdateInFreshProcess,
writeControlPlaneUpdateRestartSentinelBestEffort,
} from "./update-command-post-core.js";
import { POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON } from "./update-command-post-plugin-validation.js";
import {
gatewayServiceCommandUsesRoot,
maybeRestartService,
@@ -254,7 +257,7 @@ export async function finishUpdate(params: {
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget;
}
try {
postCorePluginUpdate = await updatePluginsAfterCoreUpdate({
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
root: postUpdateRoot,
channel: params.channel,
configSnapshot: postUpdateConfigSnapshot,
@@ -264,6 +267,22 @@ export async function finishUpdate(params: {
timeoutMs: params.updateStepTimeoutMs,
pluginInstallRecords: params.preUpdatePluginInstallRecords,
});
const completedPluginUpdate = await completePostCorePluginUpdate({
root: postUpdateRoot,
pluginUpdate: initialPluginUpdate,
// A plugin-only update can replace its migration owner without replacing core.
// Downgrades and resume fallbacks can also leave an updated core on disk in this process.
freshDoctorRequired:
didCoreUpdateChangeInstall(params.result) ||
initialPluginUpdate.sync.changed ||
initialPluginUpdate.npm.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.updateStepTimeoutMs,
...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}),
});
postCorePluginUpdate = completedPluginUpdate.pluginUpdate;
postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot;
} finally {
if (compatibilityDowngradeTarget) {
if (previousCompatibilityHostVersion === undefined) {
@@ -296,6 +315,14 @@ export async function finishUpdate(params: {
result: resultWithPostUpdate,
jsonMode: Boolean(params.opts.json),
});
// If strict config became valid despite a fresh-doctor process failure, restore the service
// stopped by this update. Invalid post-migration config intentionally remains stopped.
if (postCorePluginUpdate.reason === POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON) {
await maybeRestartServiceAfterFailedMutableUpdate({
preManagedServiceStop: params.preManagedServiceStop,
jsonMode: Boolean(params.opts.json),
});
}
if (params.opts.json) {
defaultRuntime.writeJson(resultWithPostUpdate);
} else {
+11 -1
View File
@@ -11,6 +11,7 @@ import {
readPostCorePreUpdateSourceConfig,
restoreDroppedPreUpdateChannels,
} from "./update-command-config.js";
import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV,
@@ -75,7 +76,7 @@ export async function resumePostCoreUpdate(params: {
? currentPluginInstallRecords
: parentPluginInstallRecords;
const pluginUpdate = await updatePluginsAfterCoreUpdate({
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
root: params.root,
channel: params.channel,
configSnapshot: restoredConfig.snapshot,
@@ -85,6 +86,15 @@ export async function resumePostCoreUpdate(params: {
timeoutMs: params.timeoutMs,
pluginInstallRecords,
});
const { pluginUpdate } = await completePostCorePluginUpdate({
root: params.root,
pluginUpdate: initialPluginUpdate,
// Only package/channel sync can replace the migration owner loaded by this process.
freshDoctorRequired: initialPluginUpdate.sync.changed || initialPluginUpdate.npm.changed,
yes: params.opts.yes === true,
json: params.opts.json === true,
timeoutMs: params.timeoutMs,
});
if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
await writePostCorePluginUpdateResultFile(
process.env[POST_CORE_UPDATE_RESULT_PATH_ENV],
+44 -1
View File
@@ -6,13 +6,17 @@ import { describe, expect, it, vi } from "vitest";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
import {
updatePluginsAfterCoreUpdate,
type PostCorePluginUpdateResult,
} from "./update-command-plugins.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
} from "./update-command-plugins.test-support.js";
import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js";
import { applyPostPluginConfigValidation } from "./update-command-post-plugin-validation.js";
import {
resolvePostInstallDoctorEnv,
resolvePostUpdateServiceStateReadEnv,
@@ -51,6 +55,45 @@ describe("resolveGatewayInstallEntrypoint", () => {
});
});
describe("applyPostPluginConfigValidation", () => {
const pluginUpdate = {
status: "ok",
changed: true,
sync: {
changed: true,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: true, outcomes: [] },
integrityDrifts: [],
warnings: [],
} satisfies PostCorePluginUpdateResult;
it("fails closed when updated plugin migrations leave config invalid", () => {
expect(applyPostPluginConfigValidation(pluginUpdate, false)).toMatchObject({
status: "error",
reason: "post-plugin-doctor-invalid-config",
warnings: [
{
guidance: ["Run `openclaw doctor --fix`, then rerun `openclaw update repair`."],
},
],
});
});
it("preserves an earlier plugin update error", () => {
const failed = {
...pluginUpdate,
status: "error" as const,
reason: "plugin-sync-failed",
};
expect(applyPostPluginConfigValidation(failed, false)).toBe(failed);
});
});
describe("shouldPrepareUpdatedInstallRestart", () => {
it("prepares package update restarts when the service is installed but stopped", () => {
expect(