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
+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