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
+147
View File
@@ -0,0 +1,147 @@
import { Option, type Command } from "commander";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
type ChannelSetupCliOptionsModule = typeof import("../channels/plugins/cli-add-options.js");
type ChannelSetupFlagArity = "boolean" | "value" | "conflict";
export type ChannelSetupCliOption = {
flags: string;
negatedFlags?: string;
description: string;
defaultValue?: boolean | string;
};
const CHANNEL_ADD_SHARED_BOOLEAN_OPTIONS = new Set(["--help", "-h"]);
const CHANNEL_ADD_SHARED_VALUE_OPTIONS = new Set(["--channel", "--account", "--name"]);
const CHANNEL_ADD_SHARED_VALUE_OPTION_PREFIXES = ["--channel=", "--account=", "--name="];
const channelSetupCliOptionsLoader = createLazyImportLoader<ChannelSetupCliOptionsModule>(
() => import("../channels/plugins/cli-add-options.js"),
);
export function loadChannelSetupCliOptions(): Promise<ChannelSetupCliOptionsModule> {
return channelSetupCliOptionsLoader.load();
}
export function getChannelSetupOptionSwitches(flags: string): string[] {
const option = new Option(flags);
return [option.short, option.long].filter((flag): flag is string => Boolean(flag));
}
function resolveChannelSetupFlagArity(flags: string): Exclude<ChannelSetupFlagArity, "conflict"> {
return /<[^>]+>|\[[^\]]+\]/u.test(flags) ? "value" : "boolean";
}
function buildChannelSetupFlagArityMap(
options: readonly ChannelSetupCliOption[],
): Map<string, ChannelSetupFlagArity> {
const arityBySwitch = new Map<string, ChannelSetupFlagArity>();
const addSwitch = (flag: string, arity: Exclude<ChannelSetupFlagArity, "conflict">) => {
const existing = arityBySwitch.get(flag);
arityBySwitch.set(flag, existing === undefined || existing === arity ? arity : "conflict");
};
for (const option of options) {
const arity = resolveChannelSetupFlagArity(option.flags);
for (const flag of getChannelSetupOptionSwitches(option.flags)) {
addSwitch(flag, arity);
}
if (option.negatedFlags) {
for (const flag of getChannelSetupOptionSwitches(option.negatedFlags)) {
addSwitch(flag, "boolean");
}
}
}
return arityBySwitch;
}
export async function resolveChannelsAddChannelFromArgv(
argv: string[],
): Promise<string | undefined> {
const normalizedArgv = normalizeWindowsArgv(argv);
const addIndex = normalizedArgv.findIndex(
(arg, index) => arg === "add" && normalizedArgv[index - 1] === "channels",
);
if (addIndex === -1) {
return undefined;
}
const args = normalizedArgv.slice(addIndex + 1);
let explicitChannel: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
break;
}
if (arg === "--channel") {
const value = args[index + 1]?.trim();
explicitChannel = value || explicitChannel;
index += 1;
continue;
}
if (arg.startsWith("--channel=")) {
const value = arg.slice("--channel=".length).trim();
explicitChannel = value || explicitChannel;
}
}
if (explicitChannel) {
return explicitChannel;
}
let channelFlagArities: Map<string, ChannelSetupFlagArity> | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
break;
}
if (CHANNEL_ADD_SHARED_VALUE_OPTIONS.has(arg)) {
index += 1;
continue;
}
if (CHANNEL_ADD_SHARED_VALUE_OPTION_PREFIXES.some((prefix) => arg.startsWith(prefix))) {
continue;
}
if (CHANNEL_ADD_SHARED_BOOLEAN_OPTIONS.has(arg)) {
continue;
}
if (arg.startsWith("-")) {
// Shipped `channels add` accepted channel flags before a positional id by registering every
// channel option. Lazily inspect serialized all-channel metadata for arity only; actual
// option registration remains scoped to the selected channel.
if (!channelFlagArities) {
const { resolveChannelSetupCliOptionMetadata } = await loadChannelSetupCliOptions();
const { optionCandidates } = resolveChannelSetupCliOptionMetadata(undefined, {
includeAll: true,
});
channelFlagArities = buildChannelSetupFlagArityMap(optionCandidates);
}
const equalsIndex = arg.indexOf("=");
const optionSwitch = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
const arity = channelFlagArities.get(optionSwitch);
if (!arity || arity === "conflict") {
return undefined;
}
if (equalsIndex === -1 && arity === "value") {
index += 1;
}
continue;
}
return arg;
}
return undefined;
}
export function resolveChannelsAddOptions(
channelArg: string | undefined,
opts: Record<string, unknown>,
command?: Pick<Command, "getOptionValueSource">,
): Record<string, unknown> {
const forwardedOpts = command
? Object.fromEntries(
Object.entries(opts).filter(([key]) => command.getOptionValueSource(key) === "cli"),
)
: opts;
return {
...forwardedOpts,
channel: forwardedOpts.channel ?? channelArg,
};
}
+449 -6
View File
@@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
import {
resolveChannelsAddChannelFromArgv,
resolveChannelsAddOptions,
} from "./channels-cli-add-args.js";
import { registerChannelsCli } from "./channels-cli.js";
const listBundledPackageChannelMetadataMock = vi.hoisted(() =>
@@ -51,6 +55,7 @@ async function runChannelsAddCli(args: string[]) {
const program = new Command().name("openclaw");
await registerChannelsCli(program, ["node", "openclaw", ...args]);
await program.parseAsync(args, { from: "user" });
return program;
}
describe("registerChannelsCli", () => {
@@ -69,7 +74,7 @@ describe("registerChannelsCli", () => {
expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled();
expect(listRawChannelPluginCatalogEntriesMock).not.toHaveBeenCalled();
process.argv = ["node", "openclaw", "channels", "add", "--help"];
process.argv = ["node", "openclaw", "channels", "add", "clickclack", "--help"];
await registerChannelsCli(new Command().name("openclaw"));
expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1);
@@ -100,7 +105,7 @@ describe("registerChannelsCli", () => {
],
},
]);
process.argv = ["node", "openclaw", "channels", "add", "--help"];
process.argv = ["node", "openclaw", "channels", "add", "clickclack", "--help"];
const program = new Command().name("openclaw");
await registerChannelsCli(program);
@@ -132,7 +137,15 @@ describe("registerChannelsCli", () => {
]);
const program = new Command().name("openclaw");
await registerChannelsCli(program, ["node", "openclaw", "channels", "add", "--help"]);
await registerChannelsCli(program, [
"node",
"openclaw",
"channels",
"add",
"--channel",
"installed-chat",
"--help",
]);
expect(getChannelAddOptionFlags(program)).toContain("--installed-key <key>");
});
@@ -184,13 +197,21 @@ describe("registerChannelsCli", () => {
// Commander throws on conflicting switches; registration must survive a
// plugin redeclaring `--url` with a different placeholder or the static
// `--token` with a different value name.
await registerChannelsCli(program, ["node", "openclaw", "channels", "add", "--help"]);
await registerChannelsCli(program, [
"node",
"openclaw",
"channels",
"add",
"--channel",
"chat-a",
"--help",
]);
const flags = getChannelAddOptionFlags(program);
expect(flags).toContain("--url <url>");
expect(flags).not.toContain("--url <server>");
expect(flags).toContain("--token <token>");
expect(flags).not.toContain("--token <payload>");
expect(flags).toContain("--token <payload>");
expect(flags).not.toContain("--token <token>");
});
it("prefers the selected channel's declaration for a shared switch", async () => {
@@ -248,6 +269,190 @@ describe("registerChannelsCli", () => {
expect(flags).not.toContain("--url <url>");
});
it("projects channel-owned setup fields into Commander options", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "signal",
setup: {
fields: [
{
key: "signalTransport",
kind: "choice",
choices: ["external-native", "container"],
cli: {
flags: "--signal-transport <kind>",
description: "Signal transport kind",
},
},
{
key: "autoDiscover",
kind: "boolean",
cli: {
flags: "--auto-discover",
negatedFlags: "--no-auto-discover",
description: "Discover channels automatically",
},
},
],
},
},
]);
process.argv = ["node", "openclaw", "channels", "add", "--channel", "signal", "--help"];
const program = new Command().name("openclaw");
await registerChannelsCli(program);
expect(getChannelAddOptionFlags(program)).toContain("--signal-transport <kind>");
expect(getChannelAddOptionFlags(program)).toContain("--no-auto-discover");
});
it("registers only the positional channel setup options", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "telegram",
setup: {
fields: [
{
key: "token",
kind: "string",
cli: { flags: "--telegram-token <token>", description: "Telegram bot token" },
},
],
},
},
{
id: "signal",
setup: {
fields: [
{
key: "signalNumber",
kind: "string",
cli: { flags: "--signal-number <e164>", description: "Signal account number" },
},
],
},
},
]);
const program = new Command().name("openclaw");
await registerChannelsCli(program, [
"node",
"openclaw",
"channels",
"add",
"telegram",
"--help",
]);
expect(getChannelAddOptionFlags(program)).toEqual(
expect.arrayContaining(["--telegram-token <token>"]),
);
expect(getChannelAddOptionFlags(program)).not.toEqual(
expect.arrayContaining(["--signal-number <e164>"]),
);
});
it.each(["--help", "-h"])(
"keeps generic add help via %s limited to the shared control envelope",
async (helpFlag) => {
const program = new Command().name("openclaw");
await registerChannelsCli(program, ["node", "openclaw", "channels", "add", helpFlag]);
expect(getChannelAddOptionFlags(program)).toEqual([
"--channel <name>",
"--account <id>",
"--name <name>",
]);
expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled();
},
);
it("registers add help when channels reuse a long flag with different placeholders", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "example",
setup: {
fields: [
{
key: "apiKey",
kind: "string",
cli: { flags: "--api-key <token>", description: "Example API key" },
},
{
key: "apiKeyJson",
kind: "string",
cli: { flags: "--api-key <json>", description: "Example API key JSON" },
},
],
},
},
]);
process.argv = ["node", "openclaw", "channels", "add", "example", "--help"];
const program = new Command().name("openclaw");
await registerChannelsCli(program);
expect(
getChannelAddOptionFlags(program).filter((flags) => flags.startsWith("--api-key ")),
).toHaveLength(1);
});
it("forwards only explicitly supplied setup options", () => {
const sources = new Map<string, "cli" | "default">([
["channel", "cli"],
["signalTransport", "cli"],
["useEnv", "default"],
]);
expect(
resolveChannelsAddOptions(
undefined,
{ channel: "signal", signalTransport: "container", useEnv: false },
{
getOptionValueSource: (key) => sources.get(key),
} as Pick<Command, "getOptionValueSource">,
),
).toEqual({ channel: "signal", signalTransport: "container" });
});
it("preserves selected legacy channel defaults", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "legacy-chat",
cliAddOptions: [
{
flags: "--legacy-mode <mode>",
description: "Legacy transport mode",
defaultValue: "socket",
},
],
},
]);
const program = await runChannelsAddCli([
"channels",
"add",
"--channel",
"legacy-chat",
"--token",
"test-token",
]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({
channel: "legacy-chat",
legacyMode: "socket",
token: "test-token",
useEnv: false,
}),
runtimeMock,
{ hasFlags: true },
);
expect(getChannelAddOptionFlags(program)).not.toContain("--secret-file <path>");
expect(getChannelAddOptionFlags(program)).not.toContain("--workspace <workspace>");
});
it("uses caller argv instead of raw process argv for channel-specific add options", async () => {
process.argv = ["node", "openclaw", "channels"];
@@ -256,6 +461,7 @@ describe("registerChannelsCli", () => {
"openclaw",
"channels",
"add",
"telegram",
"--help",
]);
@@ -333,6 +539,243 @@ describe("registerChannelsCli", () => {
);
});
it("registers selected-channel options before Commander parses option-first argv", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "telegram",
setup: {
fields: [
{
key: "token",
kind: "string",
cli: { flags: "--token <token>", description: "Telegram bot token" },
},
],
},
},
]);
await runChannelsAddCli(["channels", "add", "--token", "test-token", "--channel", "telegram"]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram", token: "test-token" }),
runtimeMock,
{ hasFlags: true },
);
});
it("prefers modern contract options when a channel also publishes cliAddOptions", async () => {
listBundledPackageChannelMetadataMock.mockReturnValue([
{
id: "telegram",
setup: {
fields: [
{
key: "token",
kind: "string",
cli: { flags: "--token <token>", description: "Telegram bot token" },
},
],
},
cliAddOptions: [{ flags: "--legacy-token <token>", description: "Retained legacy switch" }],
},
]);
const program = new Command().name("openclaw");
const argv = ["channels", "add", "telegram", "--token", "test-token"];
await registerChannelsCli(program, ["node", "openclaw", ...argv]);
const flags = getChannelAddOptionFlags(program);
expect(flags).toContain("--token <token>");
expect(flags).not.toContain("--legacy-token <token>");
await program.parseAsync(argv, { from: "user" });
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram", token: "test-token" }),
runtimeMock,
{ hasFlags: true },
);
});
it("resolves a positional channel after a value-taking channel option", async () => {
const metadata: PluginPackageChannel[] = [
{
id: "telegram",
setup: {
fields: [
{
key: "token",
kind: "string",
cli: { flags: "--token <token>", description: "Telegram bot token" },
},
],
},
},
];
listBundledPackageChannelMetadataMock
.mockReturnValueOnce(metadata)
.mockReturnValueOnce(metadata);
await runChannelsAddCli(["channels", "add", "--token", "tok", "telegram"]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram", token: "tok" }),
runtimeMock,
{ hasFlags: true },
);
});
it("resolves a positional channel after a boolean channel option", async () => {
const metadata: PluginPackageChannel[] = [
{
id: "telegram",
setup: {
fields: [
{
key: "useEnv",
kind: "boolean",
cli: { flags: "--use-env", description: "Use Telegram environment credentials" },
},
],
},
},
];
listBundledPackageChannelMetadataMock
.mockReturnValueOnce(metadata)
.mockReturnValueOnce(metadata);
await runChannelsAddCli(["channels", "add", "--use-env", "telegram"]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram", useEnv: true }),
runtimeMock,
{ hasFlags: true },
);
});
it("keeps an all-channel-unknown flag before a positional channel ambiguous", async () => {
await expect(
resolveChannelsAddChannelFromArgv([
"node",
"openclaw",
"channels",
"add",
"--unknown-option",
"value",
"telegram",
]),
).resolves.toBeUndefined();
});
it("keeps conflicting all-channel flag arities before a positional channel ambiguous", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "chat-a",
setup: {
fields: [
{
key: "mode",
kind: "string",
cli: { flags: "--mode <mode>", description: "Chat A mode" },
},
],
},
},
{
id: "chat-b",
setup: {
fields: [
{
key: "mode",
kind: "boolean",
cli: { flags: "--mode", description: "Enable Chat B mode" },
},
],
},
},
]);
await expect(
resolveChannelsAddChannelFromArgv([
"node",
"openclaw",
"channels",
"add",
"--mode",
"telegram",
]),
).resolves.toBeUndefined();
});
it("finds a positional channel after shared option-value pairs", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "telegram",
setup: {
fields: [
{
key: "token",
kind: "string",
cli: { flags: "--token <token>", description: "Telegram bot token" },
},
],
},
},
]);
await runChannelsAddCli(["channels", "add", "--account", "work", "telegram", "--token", "tok"]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "telegram", account: "work", token: "tok" }),
runtimeMock,
{ hasFlags: true },
);
});
it("lets an explicit channel override the positional channel during option registration", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "telegram",
setup: {
fields: [
{
key: "token",
kind: "string",
cli: { flags: "--token <token>", description: "Telegram bot token" },
},
],
},
},
{
id: "signal",
setup: {
fields: [
{
key: "signalNumber",
kind: "string",
cli: { flags: "--signal-number <e164>", description: "Signal account number" },
},
],
},
},
]);
await runChannelsAddCli([
"channels",
"add",
"telegram",
"--channel",
"signal",
"--signal-number",
"+15555550123",
]);
expect(channelsAddCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ channel: "signal", signalNumber: "+15555550123" }),
runtimeMock,
{ hasFlags: true },
);
});
it("treats plugin-provided config flags as direct automation inputs", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
+104 -65
View File
@@ -8,6 +8,13 @@ import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
import { formatCliChannelOptions } from "./channel-options.js";
import {
getChannelSetupOptionSwitches,
loadChannelSetupCliOptions,
resolveChannelsAddChannelFromArgv,
resolveChannelsAddOptions,
type ChannelSetupCliOption,
} from "./channels-cli-add-args.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { hasExplicitOptions } from "./command-options.js";
import { formatHelpExamples } from "./help-format.js";
@@ -15,8 +22,6 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
type ChannelsCommandsModule = typeof import("../commands/channels.js");
type ChannelSetupCliOptionsModule = typeof import("../channels/plugins/cli-add-options.js");
const optionNamesRemove = ["channel", "account", "delete"] as const;
const CHANNEL_ADD_SELECTION_OPTION_NAMES = new Set(["channel"]);
@@ -24,13 +29,37 @@ type RegisterChannelsCliOptions = {
includeSetupOptions?: boolean;
};
type AddChannelSetupOptionsParams = {
channelId?: string;
includeAll?: boolean;
};
type ChannelSetupOptionMode = "none" | "modern" | "legacy";
const LEGACY_CHANNEL_SETUP_OPTIONS: readonly ChannelSetupCliOption[] = [
{ flags: "--token <token>", description: "Channel token or credential payload" },
{
flags: "--token-file <path>",
description: "Read channel token or credential payload from file",
},
{ flags: "--secret <secret>", description: "Channel shared secret" },
{ flags: "--bot-token <token>", description: "Bot token" },
{ flags: "--app-token <token>", description: "App token" },
{ flags: "--password <password>", description: "Channel password or login secret" },
{ flags: "--cli-path <path>", description: "Channel CLI path" },
{ flags: "--url <url>", description: "Channel setup URL" },
{ flags: "--base-url <url>", description: "Channel base URL" },
{ flags: "--http-url <url>", description: "Channel HTTP service URL" },
{ flags: "--auth-dir <path>", description: "Channel auth directory override" },
{
flags: "--use-env",
description: "Use env-backed credentials when supported",
defaultValue: false,
},
];
const channelsCommandsLoader = createLazyImportLoader<ChannelsCommandsModule>(
() => import("../commands/channels.js"),
);
const channelSetupCliOptionsLoader = createLazyImportLoader<ChannelSetupCliOptionsModule>(
() => import("../channels/plugins/cli-add-options.js"),
);
function loadChannelsCommands(): Promise<ChannelsCommandsModule> {
return channelsCommandsLoader.load();
}
@@ -50,14 +79,28 @@ function getOptionNames(command: Command): string[] {
return command.options.map((option) => option.attributeName());
}
function resolveChannelsAddOptions(
channelArg: string | undefined,
opts: Record<string, unknown>,
): Record<string, unknown> {
return {
...opts,
channel: opts.channel ?? channelArg,
};
function addChannelSetupOption(
command: Command,
option: ChannelSetupCliOption,
seenFlags: Set<string>,
): void {
const optionSwitches = getChannelSetupOptionSwitches(option.flags);
if (optionSwitches.some((flag) => seenFlags.has(flag))) {
return;
}
optionSwitches.forEach((flag) => seenFlags.add(flag));
if (option.defaultValue !== undefined) {
command.option(option.flags, option.description, option.defaultValue);
} else {
command.option(option.flags, option.description);
}
if (option.negatedFlags) {
const negatedSwitches = getChannelSetupOptionSwitches(option.negatedFlags);
if (!negatedSwitches.some((flag) => seenFlags.has(flag))) {
negatedSwitches.forEach((flag) => seenFlags.add(flag));
command.option(option.negatedFlags, option.description);
}
}
}
function shouldRegisterChannelSetupOptions(
@@ -72,52 +115,35 @@ function shouldRegisterChannelSetupOptions(
return commandPath[0] === "channels" && commandPath[1] === "add";
}
// Best-effort pre-parse sniff of the selected channel so its option
// declarations win registration. Misses only the unusual `add --flag value
// <channel>` shape, which falls back to first-declaration ordering.
function resolveChannelsAddArgvChannel(argv: string[]): string | undefined {
const tokens = normalizeWindowsArgv(argv);
const addIndex = tokens.indexOf("add");
if (addIndex === -1) {
return undefined;
}
const rest = tokens.slice(addIndex + 1);
const channelFlagIndex = rest.indexOf("--channel");
if (channelFlagIndex !== -1) {
const value = rest[channelFlagIndex + 1];
return value && !value.startsWith("-") ? value : undefined;
}
const inline = rest.find((token) => token.startsWith("--channel="));
if (inline) {
return inline.slice("--channel=".length) || undefined;
}
const positional = rest[0];
return positional && !positional.startsWith("-") ? positional : undefined;
}
async function addChannelSetupOptions(command: Command, channelId?: string): Promise<Command> {
const { channelCliOptionSwitchKey, resolveChannelSetupCliOptionMetadata } =
await channelSetupCliOptionsLoader.load();
// Seed with switch identities, not raw flags strings: Commander throws on a
// matching switch with a different placeholder (e.g. plugin `--token <payload>`
// vs the static `--token <token>`).
const seenSwitches = new Set(
command.options.map((option) => option.long ?? option.short ?? option.flags),
async function addChannelSetupOptions(
command: Command,
params: AddChannelSetupOptionsParams = {},
): Promise<ChannelSetupOptionMode> {
const { resolveChannelSetupCliOptionMetadata } = await loadChannelSetupCliOptions();
const selected = params.channelId?.trim().toLowerCase();
const { options, selectedChannel } = resolveChannelSetupCliOptionMetadata(selected, {
includeAll: params.includeAll,
});
const mode: ChannelSetupOptionMode = selected
? selectedChannel?.setup
? "modern"
: "legacy"
: "none";
const seenFlags = new Set(
command.options.flatMap((option) => getChannelSetupOptionSwitches(option.flags)),
);
const { options } = resolveChannelSetupCliOptionMetadata(channelId);
for (const option of options) {
const key = channelCliOptionSwitchKey(option.flags);
if (seenSwitches.has(key)) {
continue;
}
seenSwitches.add(key);
if (option.defaultValue !== undefined) {
command.option(option.flags, option.description, option.defaultValue);
} else {
command.option(option.flags, option.description);
addChannelSetupOption(command, option, seenFlags);
}
if (
params.includeAll ||
(mode === "legacy" && (selectedChannel === undefined || selectedChannel.setup === undefined))
) {
for (const option of LEGACY_CHANNEL_SETUP_OPTIONS) {
addChannelSetupOption(command, option, seenFlags);
}
}
return command;
return mode;
}
export async function registerChannelsCli(
@@ -280,13 +306,18 @@ export async function registerChannelsCli(
)
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (default when omitted)")
.option("--name <name>", "Display name for this account")
.option("--token <token>", "Channel token or credential payload")
.option("--token-file <path>", "Read channel token or credential payload from file")
.option("--use-env", "Use env-backed credentials when supported", false);
.option("--name <name>", "Display name for this account");
if (shouldRegisterChannelSetupOptions(argv, options)) {
await addChannelSetupOptions(addCommand, resolveChannelsAddArgvChannel(argv));
let channelSetupOptionMode: ChannelSetupOptionMode = "none";
const selectedChannelId = await resolveChannelsAddChannelFromArgv(argv);
if (
shouldRegisterChannelSetupOptions(argv, options) &&
(selectedChannelId !== undefined || options.includeSetupOptions)
) {
channelSetupOptionMode = await addChannelSetupOptions(addCommand, {
channelId: selectedChannelId,
includeAll: options.includeSetupOptions,
});
}
addCommand.action(async (channelArg: string | undefined, opts, command) => {
@@ -296,9 +327,17 @@ export async function registerChannelsCli(
command,
getOptionNames(command).filter((name) => !CHANNEL_ADD_SELECTION_OPTION_NAMES.has(name)),
);
await channelsAddCommand(resolveChannelsAddOptions(channelArg, opts), defaultRuntime, {
hasFlags,
});
await channelsAddCommand(
resolveChannelsAddOptions(
channelArg,
opts,
channelSetupOptionMode === "modern" ? command : undefined,
),
defaultRuntime,
{
hasFlags,
},
);
});
});
+340
View File
@@ -117,6 +117,14 @@ vi.mock("../infra/openclaw-root.js", () => ({
resolveOpenClawPackageRootSync: vi.fn(() => process.cwd()),
}));
vi.mock("../daemon/gateway-entrypoint.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../daemon/gateway-entrypoint.js")>();
return {
...actual,
resolveGatewayInstallEntrypoint: vi.fn(actual.resolveGatewayInstallEntrypoint),
};
});
vi.mock("../config/config.js", () => ({
assertConfigWriteAllowedInCurrentMode: () => {
if (process.env.OPENCLAW_NIX_MODE === "1") {
@@ -419,6 +427,7 @@ vi.mock("../runtime.js", () => ({
const { runGatewayUpdate } = await import("../infra/update-runner.js");
const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js");
const { resolveGatewayInstallEntrypoint } = await import("../daemon/gateway-entrypoint.js");
const {
mutateConfigFileWithRetry,
readConfigFileSnapshot,
@@ -439,6 +448,8 @@ const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js");
const { doctorCommand } = await import("../commands/doctor.js");
const { defaultRuntime } = await import("../runtime.js");
const postCorePluginConvergence = await import("./update-cli/post-core-plugin-convergence.js");
const { completePostCorePluginUpdate } =
await import("./update-cli/update-command-fresh-doctor.js");
const runPostCorePluginConvergenceSpy = vi.spyOn(
postCorePluginConvergence,
"runPostCorePluginConvergence",
@@ -884,6 +895,32 @@ describe("update-cli", () => {
return { root, entrypoints };
};
const FRESH_POST_UPDATE_ENTRYPOINT = "/tmp/openclaw-updated-entry.mjs";
const mockCurrentProcessFreshDoctor = (params: { postCoreResumeAttempt?: boolean } = {}) => {
if (params.postCoreResumeAttempt !== false) {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(undefined);
}
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(FRESH_POST_UPDATE_ENTRYPOINT);
};
const expectFreshPostUpdateDoctor = (params: { yes: boolean }) => {
const calls = vi
.mocked(runExec)
.mock.calls.filter(
([, args]) => args[0] === FRESH_POST_UPDATE_ENTRYPOINT && args[1] === "doctor",
);
expect(calls).toHaveLength(1);
expect(calls[0]?.[1]).toEqual([
FRESH_POST_UPDATE_ENTRYPOINT,
"doctor",
"--repair",
"--non-interactive",
"--no-workspace-suggestions",
...(params.yes ? ["--yes"] : []),
]);
};
beforeEach(() => {
delete process.env.OPENCLAW_SERVICE_MARKER;
delete process.env.OPENCLAW_SERVICE_KIND;
@@ -1486,6 +1523,7 @@ describe("update-cli", () => {
tag: "latest",
version: "2026.4.10",
});
mockCurrentProcessFreshDoctor();
probeGateway.mockResolvedValue({
ok: true,
close: null,
@@ -1508,10 +1546,35 @@ describe("update-cli", () => {
expect(spawn).not.toHaveBeenCalled();
expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1);
expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1);
expectFreshPostUpdateDoctor({ yes: true });
expectNoSideEffects(runDaemonInstall, probeGateway);
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it("runs the fresh doctor for a core-changing downgrade without plugin changes", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-fresh-doctor-root");
setupUpdatedRootRefresh({
gatewayUpdateImpl: async () =>
makeOkUpdateResult({
mode: "npm",
root: downgradedRoot,
before: { version: "2026.4.14" },
after: { version: "2026.4.10" },
}),
});
readPackageVersion.mockResolvedValue("2026.4.14");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" });
mockCurrentProcessFreshDoctor();
await updateCommand({ yes: true, tag: "2026.4.10", restart: false });
expect(spawn).not.toHaveBeenCalled();
expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1);
expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1);
expectFreshPostUpdateDoctor({ yes: true });
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it("pins the compatibility host version to the downgraded target during current-process post-core plugin convergence (#87914)", async () => {
const downgradedRoot = createCaseDir("openclaw-downgraded-compat-root");
setupUpdatedRootRefresh({
@@ -1553,6 +1616,215 @@ describe("update-cli", () => {
}
});
it("runs updated plugin migrations for a plugin-only current-process update", async () => {
mockGitUpdateAfterMutation();
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: true,
config: baseConfig,
outcomes: [],
});
let strictValidationEnv: string | undefined;
vi.mocked(readConfigFileSnapshot).mockImplementation(async (options) => {
if (!options) {
strictValidationEnv = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
}
return baseSnapshot;
});
vi.mocked(runExec).mockImplementationOnce(async (_file, args) => {
expect(args).toEqual([
"/tmp/openclaw-updated-entry.mjs",
"doctor",
"--repair",
"--non-interactive",
"--no-workspace-suggestions",
"--yes",
]);
return { stdout: "", stderr: "" };
});
await updateCommand({ yes: true, restart: false });
expect(spawn).not.toHaveBeenCalled();
expect(resolveGatewayInstallEntrypoint).toHaveBeenCalledTimes(1);
expect(runExec).toHaveBeenCalledTimes(2);
expect(strictValidationEnv).toBe("0");
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it("runs the fresh plugin doctor with the selected Node runner", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
await completePostCorePluginUpdate({
root: "/tmp/openclaw-updated-root",
pluginUpdate: {
status: "ok",
changed: true,
warnings: [],
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: true, outcomes: [] },
integrityDrifts: [],
},
freshDoctorRequired: true,
yes: true,
json: true,
timeoutMs: 30_000,
nodeRunner: "/opt/openclaw-service/bin/node",
});
expect(vi.mocked(runExec).mock.calls[0]?.[0]).toBe("/opt/openclaw-service/bin/node");
});
it("runs the fresh plugin doctor when the migration owner changed even if config is valid", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
const result = await completePostCorePluginUpdate({
root: "/tmp/openclaw-updated-root",
pluginUpdate: {
status: "ok",
changed: true,
warnings: [],
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: true, outcomes: [] },
integrityDrifts: [],
},
freshDoctorRequired: true,
yes: true,
json: true,
timeoutMs: 30_000,
});
expect(result.pluginUpdate.status).toBe("ok");
expect(runExec).toHaveBeenCalledTimes(2);
expect(resolveGatewayInstallEntrypoint).toHaveBeenCalledTimes(1);
});
it("returns a structured error when the fresh plugin doctor cannot run", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
vi.mocked(runExec).mockRejectedValueOnce(new Error("doctor process failed"));
const result = await completePostCorePluginUpdate({
root: "/tmp/openclaw-updated-root",
pluginUpdate: {
status: "ok",
changed: true,
warnings: [],
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: true, outcomes: [] },
integrityDrifts: [],
},
freshDoctorRequired: true,
yes: true,
json: true,
timeoutMs: 30_000,
});
expect(result.pluginUpdate).toMatchObject({
status: "error",
reason: "post-plugin-doctor-execution-failed",
});
expect(result.pluginUpdate.warnings?.at(-1)?.reason).toContain("doctor process failed");
});
it("keeps an invalid config authoritative after a fresh plugin doctor failure", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
vi.mocked(runExec)
.mockRejectedValueOnce(new Error("doctor process failed"))
.mockRejectedValueOnce(new Error("config invalid"));
vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
...baseSnapshot,
valid: false,
issues: [{ path: "channels.signal.httpUrl", message: "legacy Signal transport field" }],
} as ConfigFileSnapshot);
const result = await completePostCorePluginUpdate({
root: "/tmp/openclaw-updated-root",
pluginUpdate: {
status: "ok",
changed: true,
warnings: [],
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: true, outcomes: [] },
integrityDrifts: [],
},
freshDoctorRequired: true,
yes: true,
json: true,
timeoutMs: 30_000,
});
expect(result.pluginUpdate).toMatchObject({
status: "error",
reason: "post-plugin-doctor-invalid-config",
});
});
it("keeps entrypoint resolution failures structured and fail-closed", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockRejectedValueOnce(
new Error("entrypoint lookup failed"),
);
const result = await completePostCorePluginUpdate({
root: "/tmp/openclaw-updated-root",
pluginUpdate: {
status: "ok",
changed: true,
warnings: [],
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: { changed: true, outcomes: [] },
integrityDrifts: [],
},
freshDoctorRequired: true,
yes: true,
json: true,
timeoutMs: 30_000,
});
expect(result.pluginUpdate).toMatchObject({
status: "error",
reason: "post-plugin-doctor-invalid-config",
});
expect(result.pluginUpdate.warnings?.[0]?.reason).toContain("entrypoint lookup failed");
expect(runExec).not.toHaveBeenCalled();
});
it("fails the update when the fresh process exits non-zero", async () => {
setupUpdatedRootRefresh();
spawn.mockImplementationOnce(() => {
@@ -2023,6 +2295,9 @@ describe("update-cli", () => {
});
it("includes colored ClawHub trust warnings in json post-core plugin output", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
const trustWarning =
"╭─ WARNING - ClawHub found security risks in this release ─╮\n" +
"│ • Security scan: suspicious │\n" +
@@ -2236,6 +2511,9 @@ describe("update-cli", () => {
});
it("marks disabled-after-failure plugin skips as post-update warnings", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: true,
config: baseConfig,
@@ -3118,12 +3396,14 @@ describe("update-cli", () => {
tag: "latest",
version: null,
});
mockCurrentProcessFreshDoctor();
await updateCommand({});
expect(getErrorOutput()).not.toContain("Downgrade confirmation required.");
expect(defaultRuntime.exit).not.toHaveBeenCalled();
expectPackageInstallSpec("openclaw@latest");
expectFreshPostUpdateDoctor({ yes: false });
});
it("blocks the package update when a non-latest dist-tag lookup is unresolved", async () => {
@@ -3147,6 +3427,7 @@ describe("update-cli", () => {
it("warns but still runs package updates when disk space looks low", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
mockCurrentProcessFreshDoctor();
vi.spyOn(fsSync, "statfsSync").mockReturnValue(
statfsFixture({
bavail: 256,
@@ -4574,6 +4855,38 @@ describe("update-cli", () => {
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("restarts a stopped git service when the fresh plugin doctor cannot run", async () => {
const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js");
serviceReadCommand.mockResolvedValue({
programArguments: ["node", serviceEntrypoint, "gateway", "run"],
environment: {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
},
});
serviceLoaded.mockResolvedValue(true);
serviceReadRuntime.mockResolvedValue({
status: "running",
pid: 4242,
state: "running",
});
mockGitUpdateAfterMutation();
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: true,
config: baseConfig,
outcomes: [],
});
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(
"/tmp/openclaw-updated-entry.mjs",
);
vi.mocked(runExec).mockRejectedValueOnce(new Error("doctor process failed"));
await updateCommand({ yes: true });
expect(serviceStop).toHaveBeenCalledTimes(1);
expect(serviceRestart).toHaveBeenCalledTimes(1);
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("keeps managed service stop output off stdout during json package updates", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-json-stop-service-");
const nodeModules = path.join(tempDir, "node_modules");
@@ -4787,6 +5100,7 @@ describe("update-cli", () => {
const nodeModules = path.join(tempDir, "node_modules");
const pkgRoot = path.join(nodeModules, "openclaw");
mockPackageInstallStatus(pkgRoot);
mockCurrentProcessFreshDoctor();
await fs.mkdir(pkgRoot, { recursive: true });
await fs.writeFile(
path.join(pkgRoot, "package.json"),
@@ -5522,6 +5836,7 @@ describe("update-cli", () => {
it("repairs legacy config before persisting a requested update channel", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
mockCurrentProcessFreshDoctor();
const legacyConfig = {
channels: {
slack: {
@@ -6953,6 +7268,7 @@ describe("update-cli", () => {
it("restores an unknown package service without rewriting its missing updated entrypoint", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
mockCurrentProcessFreshDoctor();
serviceLoaded.mockResolvedValue(true);
vi.mocked(runDaemonInstall).mockRejectedValueOnce(new Error("refresh failed"));
@@ -7713,6 +8029,7 @@ describe("update-cli", () => {
});
it("updateFinalizeCommand repairs doctor by default and refreshes plugin state after doctor", async () => {
vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce("/tmp/openclaw-entry.mjs");
const preDoctorConfig = {
update: { channel: "stable" },
plugins: { entries: { pre: { enabled: true } } },
@@ -7745,6 +8062,7 @@ describe("update-cli", () => {
} satisfies Record<string, PluginInstallRecord>;
vi.mocked(readConfigFileSnapshot)
.mockResolvedValueOnce(preDoctorSnapshot)
.mockResolvedValueOnce(postDoctorSnapshot)
.mockResolvedValueOnce(postDoctorSnapshot);
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords);
syncPluginsForUpdateChannel.mockImplementationOnce(
@@ -7768,6 +8086,25 @@ describe("update-cli", () => {
repair: true,
yes: false,
});
expect(doctorCommand).toHaveBeenCalledTimes(1);
const freshDoctorCall = vi
.mocked(runExec)
.mock.calls.find(([, args]) => args.includes("doctor"));
expect(freshDoctorCall?.[1]).toEqual([
"/tmp/openclaw-entry.mjs",
"doctor",
"--repair",
"--non-interactive",
"--no-workspace-suggestions",
]);
expect(freshDoctorCall?.[2]).toMatchObject({
cwd: process.cwd(),
env: {
OPENCLAW_UPDATE_IN_PROGRESS: "1",
OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1",
OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1",
},
});
expect(syncPluginCall()?.channel).toBe("beta");
expect(syncPluginCall()?.config).toEqual({
...postDoctorConfig,
@@ -7957,6 +8294,9 @@ describe("update-cli", () => {
},
])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunPackageUpdate }) => {
await setupNonInteractiveDowngrade();
if (shouldRunPackageUpdate) {
mockCurrentProcessFreshDoctor({ postCoreResumeAttempt: false });
}
await updateCommand(options);
const downgradeMessageSeen = vi
@@ -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(