diff --git a/extensions/codex/src/app-server/plugin-activation.ts b/extensions/codex/src/app-server/plugin-activation.ts index 97ff4f79d52..c342bfbf3e5 100644 --- a/extensions/codex/src/app-server/plugin-activation.ts +++ b/extensions/codex/src/app-server/plugin-activation.ts @@ -62,6 +62,14 @@ export async function ensureCodexPluginActivation( } satisfies v2.PluginListParams)) as v2.PluginListResponse; const resolved = findOpenAiCuratedPluginSummary(listed, params.identity.pluginName); if (!resolved) { + const hasCuratedMarketplace = listed.marketplaces.some( + (marketplace) => marketplace.name === CODEX_PLUGINS_MARKETPLACE_NAME, + ); + if (!hasCuratedMarketplace) { + return activationFailure(params.identity, "marketplace_missing", { + message: `Codex marketplace ${CODEX_PLUGINS_MARKETPLACE_NAME} was not found.`, + }); + } return activationFailure(params.identity, "plugin_missing", { message: `${params.identity.pluginName} was not found in ${CODEX_PLUGINS_MARKETPLACE_NAME}.`, }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 9d209913e16..353dba5700c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -91,6 +91,7 @@ vi.mock("../config/config.js", () => ({ }, readConfigFileSnapshot: vi.fn(), readSourceConfigBestEffort: vi.fn(), + mutateConfigFileWithRetry: vi.fn(), replaceConfigFile: vi.fn(), resolveGatewayPort: vi.fn(() => 18789), })); @@ -268,7 +269,7 @@ vi.mock("../runtime.js", () => ({ const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); const { - ConfigMutationConflictError, + mutateConfigFileWithRetry, readConfigFileSnapshot, readSourceConfigBestEffort, replaceConfigFile, @@ -426,6 +427,31 @@ describe("update-cli", () => { const replaceConfigCall = (index = 0) => vi.mocked(replaceConfigFile).mock.calls[index]?.[0]; const lastReplaceConfigCall = () => replaceConfigCall(vi.mocked(replaceConfigFile).mock.calls.length - 1); + const setupConfigMutationWithRetryMock = () => { + vi.mocked(mutateConfigFileWithRetry).mockImplementation(async (params) => { + const snapshot = await readConfigFileSnapshot(); + const nextConfig = structuredClone(snapshot.sourceConfig) as OpenClawConfig; + await params.mutate(nextConfig, { + snapshot, + previousHash: snapshot.hash ?? null, + attempt: 0, + }); + await replaceConfigFile({ + nextConfig, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }); + return { + path: snapshot.path, + previousHash: snapshot.hash ?? null, + snapshot, + nextConfig, + result: undefined, + attempts: 1, + afterWrite: { mode: "none", reason: "test" }, + followUp: { mode: "none", reason: "test", requiresRestart: false }, + }; + }); + }; const writeJsonCall = (index = 0) => vi.mocked(defaultRuntime.writeJson).mock.calls[index]?.[0]; const lastWriteJsonCall = () => @@ -562,6 +588,7 @@ describe("update-cli", () => { vi.mocked(resolveOpenClawPackageRoot).mockResolvedValue(process.cwd()); vi.mocked(readConfigFileSnapshot).mockResolvedValue(baseSnapshot); vi.mocked(readSourceConfigBestEffort).mockResolvedValue(baseSnapshot.config); + setupConfigMutationWithRetryMock(); vi.mocked(fetchNpmTagVersion).mockResolvedValue({ tag: "latest", version: "9999.0.0", @@ -1118,47 +1145,57 @@ describe("update-cli", () => { }); it("post-core resume mode retries update channel persistence after config hash drift", async () => { - vi.mocked(readConfigFileSnapshot) - .mockResolvedValueOnce({ - ...baseSnapshot, - parsed: { update: { channel: "stable" } }, - resolved: { update: { channel: "stable" } } as OpenClawConfig, - sourceConfig: { update: { channel: "stable" } } as OpenClawConfig, - runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig, - config: { update: { channel: "stable" } } as OpenClawConfig, - hash: "stable-hash", - }) - .mockResolvedValueOnce({ - ...baseSnapshot, - parsed: { - meta: { lastTouchedVersion: "2026.4.30" }, - update: { channel: "stable" }, - }, - resolved: { - meta: { lastTouchedVersion: "2026.4.30" }, - update: { channel: "stable" }, - } as OpenClawConfig, - sourceConfig: { - meta: { lastTouchedVersion: "2026.4.30" }, - update: { channel: "stable" }, - } as OpenClawConfig, - runtimeConfig: { - meta: { lastTouchedVersion: "2026.4.30" }, - update: { channel: "stable" }, - } as OpenClawConfig, - config: { - meta: { lastTouchedVersion: "2026.4.30" }, - update: { channel: "stable" }, - } as OpenClawConfig, - hash: "newer-hash", + vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({ + ...baseSnapshot, + parsed: { update: { channel: "stable" } }, + resolved: { update: { channel: "stable" } } as OpenClawConfig, + sourceConfig: { update: { channel: "stable" } } as OpenClawConfig, + runtimeConfig: { update: { channel: "stable" } } as OpenClawConfig, + config: { update: { channel: "stable" } } as OpenClawConfig, + hash: "stable-hash", + }); + const newerSnapshot = { + ...baseSnapshot, + parsed: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + }, + resolved: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + sourceConfig: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + runtimeConfig: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + config: { + meta: { lastTouchedVersion: "2026.4.30" }, + update: { channel: "stable" }, + } as OpenClawConfig, + hash: "newer-hash", + }; + vi.mocked(mutateConfigFileWithRetry).mockImplementationOnce(async (params) => { + const nextConfig = structuredClone(newerSnapshot.sourceConfig); + await params.mutate(nextConfig, { + snapshot: newerSnapshot, + previousHash: newerSnapshot.hash, + attempt: 1, }); - vi.mocked(replaceConfigFile) - .mockRejectedValueOnce( - new ConfigMutationConflictError("config changed since last load", { - currentHash: "newer-hash", - }), - ) - .mockResolvedValueOnce({} as Awaited>); + return { + path: newerSnapshot.path, + previousHash: newerSnapshot.hash, + snapshot: newerSnapshot, + nextConfig, + result: undefined, + attempts: 2, + afterWrite: { mode: "none", reason: "test" }, + followUp: { mode: "none", reason: "test", requiresRestart: false }, + }; + }); await withEnvAsync( { @@ -1171,14 +1208,7 @@ describe("update-cli", () => { }, ); - expect(replaceConfigFile).toHaveBeenCalledTimes(2); - expect(replaceConfigFile).toHaveBeenLastCalledWith({ - nextConfig: { - meta: { lastTouchedVersion: "2026.4.30" }, - update: { channel: "dev" }, - }, - baseHash: "newer-hash", - }); + expect(mutateConfigFileWithRetry).toHaveBeenCalledTimes(1); expect(syncPluginCall()?.config?.meta?.lastTouchedVersion).toBe("2026.4.30"); expect(syncPluginCall()?.config?.update?.channel).toBe("dev"); }); @@ -2717,6 +2747,16 @@ describe("update-cli", () => { }, ], }) + .mockResolvedValueOnce({ + ...baseSnapshot, + parsed: migratedConfig, + resolved: migratedConfig, + sourceConfig: migratedConfig, + config: migratedConfig, + runtimeConfig: migratedConfig, + valid: true, + hash: "migrated-hash", + }) .mockResolvedValueOnce({ ...baseSnapshot, parsed: migratedConfig, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index ddf2ab0ea5d..f11ceaea0ed 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -11,10 +11,9 @@ import { import { doctorCommand } from "../../commands/doctor.js"; import { createPreUpdateConfigSnapshot } from "../../config/backup-rotation.js"; import { - ConfigMutationConflictError, assertConfigWriteAllowedInCurrentMode, + mutateConfigFileWithRetry, readConfigFileSnapshot, - replaceConfigFile, resolveGatewayPort, } from "../../config/config.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; @@ -1920,46 +1919,17 @@ async function persistRequestedUpdateChannel(params: { if (params.requestedChannel === storedChannel) { return params.configSnapshot; } + const requestedChannel = params.requestedChannel; - const next = { - ...params.configSnapshot.sourceConfig, - update: { - ...params.configSnapshot.sourceConfig.update, - channel: params.requestedChannel, + const mutation = await mutateConfigFileWithRetry({ + mutate: (draft) => { + draft.update = { + ...draft.update, + channel: requestedChannel, + }; }, - }; - try { - await replaceConfigFile({ - nextConfig: next, - baseHash: params.configSnapshot.hash, - }); - return createUpdatedChannelSnapshot(params.configSnapshot, next); - } catch (error) { - if (!(error instanceof ConfigMutationConflictError)) { - throw error; - } - } - - const refreshed = await readConfigFileSnapshot(); - if (!refreshed.valid) { - return refreshed; - } - const refreshedChannel = normalizeUpdateChannel(refreshed.config.update?.channel); - if (refreshedChannel === params.requestedChannel) { - return refreshed; - } - const refreshedNext = { - ...refreshed.sourceConfig, - update: { - ...refreshed.sourceConfig.update, - channel: params.requestedChannel, - }, - }; - await replaceConfigFile({ - nextConfig: refreshedNext, - baseHash: refreshed.hash, }); - return createUpdatedChannelSnapshot(refreshed, refreshedNext); + return createUpdatedChannelSnapshot(mutation.snapshot, mutation.nextConfig); } function createUpdatedChannelSnapshot( diff --git a/tsdown.config.ts b/tsdown.config.ts index 51a42c48979..fb066a4febe 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -261,6 +261,7 @@ function buildDockerE2eHarnessEntries(): Record { "agents/pi-bundle-mcp-runtime": "src/agents/pi-bundle-mcp-runtime.ts", "agents/pi-embedded-runner/effective-tool-policy": "src/agents/pi-embedded-runner/effective-tool-policy.ts", + "agents/pi-embedded-runner/tool-split": "src/agents/pi-embedded-runner/tool-split.ts", "agents/pi-embedded-runner/run/runtime-context-prompt": "src/agents/pi-embedded-runner/run/runtime-context-prompt.ts", "auto-reply/reply/commands-crestodian": "src/auto-reply/reply/commands-crestodian.ts",