fix: install codex runtime plugin during doctor

This commit is contained in:
Peter Steinberger
2026-05-02 21:46:23 +01:00
parent 2dfa4b082a
commit 47375fd6dc
4 changed files with 141 additions and 8 deletions

View File

@@ -381,6 +381,60 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
});
it("installs the missing configured Codex runtime plugin from the beta npm tag", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "codex",
targetDir: "/tmp/openclaw-plugins/codex",
version: "2026.5.2-beta.1",
npmResolution: {
name: "@openclaw/codex",
version: "2026.5.2-beta.1",
resolvedSpec: "@openclaw/codex@2026.5.2-beta.1",
integrity: "sha512-codex-beta",
resolvedAt: "2026-05-01T00:00:00.000Z",
},
});
const { repairMissingPluginInstallsForIds } =
await import("./missing-configured-plugin-install.js");
const result = await repairMissingPluginInstallsForIds({
cfg: {
agents: {
defaults: {
model: "openai/gpt-5.4",
agentRuntime: { id: "codex" },
},
},
},
pluginIds: ["codex"],
env: {},
});
expect(mocks.resolveProviderInstallCatalogEntries).toHaveBeenCalled();
expect(mocks.installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/codex@beta",
expectedPluginId: "codex",
}),
);
expect(mocks.writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith(
expect.objectContaining({
codex: expect.objectContaining({
source: "npm",
spec: "@openclaw/codex@beta",
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2-beta.1",
}),
}),
{ env: {} },
);
expect(result.changes).toEqual([
'Installed missing configured plugin "codex" from @openclaw/codex@beta.',
]);
expect(result.warnings).toEqual([]);
});
it("does not install a blocked downloadable plugin from explicit channel ids", async () => {
mocks.listChannelPluginCatalogEntries.mockReturnValue([
{

View File

@@ -30,6 +30,15 @@ type DownloadableInstallCandidate = {
defaultChoice?: PluginPackageInstall["defaultChoice"];
};
const RUNTIME_PLUGIN_INSTALL_CANDIDATES: readonly DownloadableInstallCandidate[] = [
// Runtime-only configs do not have a provider/channel integration catalog entry.
{
pluginId: "codex",
label: "Codex",
npmSpec: "@openclaw/codex@beta",
},
];
function buildOpenClawClawHubSpec(npmSpec: string): string | undefined {
const parsed = parseRegistryNpmSpec(npmSpec);
if (!parsed?.name.startsWith("@openclaw/")) {
@@ -192,6 +201,16 @@ function collectDownloadableInstallCandidates(params: {
});
}
for (const entry of RUNTIME_PLUGIN_INSTALL_CANDIDATES) {
if (!configuredPluginIds.has(entry.pluginId) && !params.missingPluginIds.has(entry.pluginId)) {
continue;
}
if (params.blockedPluginIds?.has(entry.pluginId)) {
continue;
}
candidates.set(entry.pluginId, entry);
}
return [...candidates.values()].toSorted((left, right) =>
left.pluginId.localeCompare(right.pluginId),
);

View File

@@ -39,6 +39,12 @@ describe("configured plugin install release step", () => {
touchedVersion: "2026.4.30",
}),
).toBe(false);
expect(
shouldRunConfiguredPluginInstallReleaseStep({
currentVersion: "2026.5.2-beta.1",
touchedVersion: "2026.5.1",
}),
).toBe(true);
expect(
shouldRunConfiguredPluginInstallReleaseStep({
currentVersion: "2026.5.2",
@@ -123,6 +129,37 @@ describe("configured plugin install release step", () => {
expect(result.channelIds).toEqual(["wecom"]);
});
it("collects Codex from the configured agent runtime even without integration discovery", async () => {
const { collectReleaseConfiguredPluginIds } =
await import("./release-configured-plugin-installs.js");
const result = collectReleaseConfiguredPluginIds({
cfg: {
agents: {
defaults: {
model: "openai/gpt-5.4",
agentRuntime: { id: "codex" },
},
},
},
env: {},
});
expect(mocks.detectPluginAutoEnableCandidates).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
agents: expect.objectContaining({
defaults: expect.objectContaining({
model: "openai/gpt-5.4",
agentRuntime: { id: "codex" },
}),
}),
}),
}),
);
expect(result.pluginIds).toEqual(["codex"]);
expect(result.channelIds).toEqual([]);
});
it("does not collect channel ids when the matching plugin id is blocked", async () => {
const { collectReleaseConfiguredPluginIds } =
await import("./release-configured-plugin-installs.js");
@@ -178,26 +215,30 @@ describe("configured plugin install release step", () => {
});
it("repairs used plugin installs and touches config only on success", async () => {
mocks.detectPluginAutoEnableCandidates.mockReturnValue([
{ pluginId: "matrix", kind: "channel-configured", channelId: "matrix" },
]);
mocks.repairMissingPluginInstallsForIds.mockResolvedValue({
changes: ['Installed missing configured plugin "matrix".'],
changes: ['Installed missing configured plugin "codex".'],
warnings: [],
});
const { maybeRunConfiguredPluginInstallReleaseStep } =
await import("./release-configured-plugin-installs.js");
const result = await maybeRunConfiguredPluginInstallReleaseStep({
cfg: {},
currentVersion: "2026.5.2",
cfg: {
agents: {
defaults: {
model: "openai/gpt-5.4",
agentRuntime: { id: "codex" },
},
},
},
currentVersion: "2026.5.2-beta.1",
touchedVersion: "2026.5.1",
env: {},
});
expect(mocks.repairMissingPluginInstallsForIds).toHaveBeenCalledWith(
expect.objectContaining({
pluginIds: ["matrix"],
pluginIds: ["codex"],
channelIds: [],
env: {},
}),

View File

@@ -1,3 +1,4 @@
import { collectConfiguredAgentHarnessRuntimes } from "../../../agents/harness-runtimes.js";
import { listPotentialConfiguredChannelPresenceSignals } from "../../../channels/config-presence.js";
import { normalizeChatChannelId } from "../../../channels/registry.js";
import { isChannelConfigured } from "../../../config/channel-configured.js";
@@ -9,7 +10,12 @@ import { VERSION } from "../../../version.js";
import { repairMissingPluginInstallsForIds } from "./missing-configured-plugin-install.js";
import { asObjectRecord } from "./object.js";
export const CONFIGURED_PLUGIN_INSTALL_RELEASE_VERSION = "2026.5.2";
export const CONFIGURED_PLUGIN_INSTALL_RELEASE_VERSION = "2026.5.2-beta.1";
const AGENT_HARNESS_RUNTIME_PLUGIN_IDS: Readonly<Record<string, string>> = {
// Codex can be selected as a harness for OpenAI models without a plugin entry.
codex: "codex",
};
type ReleaseConfiguredPluginIds = {
pluginIds: string[];
@@ -207,6 +213,16 @@ function collectProviderPluginIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv):
return [...ids].toSorted((left, right) => left.localeCompare(right));
}
function collectAgentHarnessRuntimePluginIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
): string[] {
return collectConfiguredAgentHarnessRuntimes(cfg, env)
.map((runtime) => AGENT_HARNESS_RUNTIME_PLUGIN_IDS[runtime])
.filter((pluginId): pluginId is string => Boolean(pluginId))
.toSorted((left, right) => left.localeCompare(right));
}
function addEligiblePluginId(cfg: OpenClawConfig, pluginIds: Set<string>, pluginId: string): void {
const normalized = pluginId.trim();
if (!normalized || isDenied(cfg, normalized) || isDisabled(cfg, normalized)) {
@@ -258,6 +274,9 @@ export function collectReleaseConfiguredPluginIds(params: {
for (const pluginId of collectProviderPluginIds(params.cfg, env)) {
addEligiblePluginId(params.cfg, pluginIds, pluginId);
}
for (const pluginId of collectAgentHarnessRuntimePluginIds(params.cfg, env)) {
addEligiblePluginId(params.cfg, pluginIds, pluginId);
}
for (const channelId of collectConfiguredChannelIds(params.cfg, env)) {
if (
!isChannelDisabled(params.cfg, channelId) &&