mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user