diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index cd5ea1dbcc9..2a8ed0c2e3b 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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([ { diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 196b762b7e6..cab94214c65 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -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), ); diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts index d2c56ff8a30..82477179f7e 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.test.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.test.ts @@ -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: {}, }), diff --git a/src/commands/doctor/shared/release-configured-plugin-installs.ts b/src/commands/doctor/shared/release-configured-plugin-installs.ts index 4c47afd06e4..cf1465ba451 100644 --- a/src/commands/doctor/shared/release-configured-plugin-installs.ts +++ b/src/commands/doctor/shared/release-configured-plugin-installs.ts @@ -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> = { + // 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, 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) &&