diff --git a/CHANGELOG.md b/CHANGELOG.md index d4110aa124c..ead00c6eaeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -244,6 +244,7 @@ Docs: https://docs.openclaw.ai - Agents/failover: scope assistant-side fallback classification and surfaced provider errors to the current attempt instead of stale session history, so cross-provider fallback runs stop inheriting the previous provider's failure. (#62907) Thanks @stainlu. - MiniMax/OAuth: write `api: "anthropic-messages"` and `authHeader: true` into the `minimax-portal` config patch during `openclaw configure`, so re-authenticated portal setups keep Bearer auth routing working. (#64964) Thanks @ryanlee666. - Agents/tools: stop repeated unavailable-tool retries from escaping loop detection when the model changes arguments, and rewrite over-threshold unknown tool calls into plain assistant text before dispatch. (#65922) Thanks @dutifulbob. +- Cron/announce delivery: tell isolated cron jobs to return the full response exactly instead of a summary, so structured `--announce` deliveries stop dropping fields nondeterministically. (#65638) Thanks @srinivaspavan9 and @vincentkoc. ## 2026.4.10 diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index acfbbf1ccfd..091b2154c4e 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -119,13 +119,22 @@ function readInstalledDistInventory(params = {}) { if (!pathExists(inventoryPath)) { throw new Error(`missing dist inventory: ${DIST_INVENTORY_PATH}`); } - const parsed = JSON.parse(readFile(inventoryPath, "utf8")); + let parsed; + try { + parsed = JSON.parse(readFile(inventoryPath, "utf8")); + } catch { + throw new Error(`invalid dist inventory: ${DIST_INVENTORY_PATH}`); + } if (!Array.isArray(parsed) || parsed.some((entry) => typeof entry !== "string")) { throw new Error(`invalid dist inventory: ${DIST_INVENTORY_PATH}`); } return new Set(parsed.map(normalizeRelativePath)); } +function isRecoverableInstalledDistInventoryError(error) { + return error instanceof Error && /^(missing|invalid) dist inventory: /u.test(error.message); +} + function resolveInstalledDistRoot(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; @@ -250,7 +259,18 @@ export function pruneInstalledPackageDist(params = {}) { if (distRoot === null) { return []; } - const expectedFiles = params.expectedFiles ?? readInstalledDistInventory(params); + let expectedFiles = params.expectedFiles ?? null; + if (expectedFiles === null) { + try { + expectedFiles = readInstalledDistInventory(params); + } catch (error) { + if (!isRecoverableInstalledDistInventoryError(error)) { + throw error; + } + log.warn?.(`[postinstall] skipping dist prune: ${error.message}`); + return []; + } + } const installedFiles = listInstalledDistFiles(params); const removed = []; diff --git a/src/cron/isolated-agent/run.message-tool-policy.test.ts b/src/cron/isolated-agent/run.message-tool-policy.test.ts index 73cc2dc4db8..3bff3561411 100644 --- a/src/cron/isolated-agent/run.message-tool-policy.test.ts +++ b/src/cron/isolated-agent/run.message-tool-policy.test.ts @@ -165,3 +165,71 @@ describe("runCronIsolatedAgentTurn message tool policy", () => { ); }); }); + +describe("runCronIsolatedAgentTurn delivery instruction", () => { + let previousFastTestEnv: string | undefined; + + beforeEach(() => { + previousFastTestEnv = clearFastTestEnv(); + resetRunCronIsolatedAgentTurnHarness(); + resolveDeliveryTargetMock.mockResolvedValue({ + ok: true, + channel: "telegram", + to: "123", + accountId: undefined, + error: undefined, + }); + }); + + afterEach(() => { + restoreFastTestEnv(previousFastTestEnv); + }); + + it("appends a plain-text delivery instruction to the prompt when delivery is requested", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "telegram", + to: "123", + }); + + await runCronIsolatedAgentTurn(makeParams()); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const prompt: string = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).toContain("Return your response as plain text"); + expect(prompt).toContain("it will be delivered automatically"); + }); + + it("does not append a delivery instruction when delivery is not requested", async () => { + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ requested: false, mode: "none" }); + + await runCronIsolatedAgentTurn(makeParams()); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const prompt: string = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).not.toContain("Return your response as plain text"); + expect(prompt).not.toContain("it will be delivered automatically"); + }); + + it("does not instruct the agent to summarize when delivery is requested", async () => { + // Regression for https://github.com/openclaw/openclaw/issues/58535: + // "summary" caused LLMs to condense structured output and drop fields + // non-deterministically on every run. + mockRunCronFallbackPassthrough(); + resolveCronDeliveryPlanMock.mockReturnValue({ + requested: true, + mode: "announce", + channel: "telegram", + to: "123", + }); + + await runCronIsolatedAgentTurn(makeParams()); + + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(1); + const prompt: string = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; + expect(prompt).not.toMatch(/\bsummary\b/i); + }); +}); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4e7af581dde..6b25464c3c8 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -193,7 +193,7 @@ function appendCronDeliveryInstruction(params: { if (!params.deliveryRequested) { return params.commandBody; } - return `${params.commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); + return `${params.commandBody}\n\nReturn your response as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); } function resolvePositiveContextTokens(value: unknown): number | undefined { diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index d468a1f1fc3..4ab18b5d248 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -6,7 +6,10 @@ import { BUNDLED_RUNTIME_SIDECAR_PATHS } from "../plugins/runtime-sidecar-paths. import { withTempDir } from "../test-helpers/temp-dir.js"; import { captureEnv } from "../test-utils/env.js"; import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js"; -import { writePackageDistInventory } from "./package-dist-inventory.js"; +import { + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, + writePackageDistInventory, +} from "./package-dist-inventory.js"; import { canResolveRegistryVersionForPackageTarget, collectInstalledGlobalPackageErrors, @@ -375,6 +378,11 @@ describe("update global helpers", () => { JSON.stringify({ name: "openclaw", version: "1.0.0" }), "utf-8", ); + for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) { + const absolutePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } for (const relativePath of BUNDLED_RUNTIME_SIDECAR_PATHS) { const absolutePath = path.join(packageRoot, relativePath); await fs.mkdir(path.dirname(absolutePath), { recursive: true }); @@ -422,6 +430,51 @@ describe("update global helpers", () => { }); }); + it("fails closed on newer installs when the inventory is missing", async () => { + await withTempDir( + { prefix: "openclaw-update-global-missing-inventory-new-" }, + async (packageRoot) => { + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.15" }), + "utf-8", + ); + for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) { + const absolutePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + `missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`, + ); + }, + ); + }); + + it("rejects invalid inventory files during global verify", async () => { + await withTempDir( + { prefix: "openclaw-update-global-invalid-inventory-" }, + async (packageRoot) => { + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.15" }), + "utf-8", + ); + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, PACKAGE_DIST_INVENTORY_RELATIVE_PATH), + "{not-json}\n", + "utf8", + ); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + `invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`, + ); + }, + ); + }); + it("verifies legacy sidecars for installed bundled plugins without inventory", async () => { await withTempDir({ prefix: "openclaw-update-global-legacy-plugin-" }, async (packageRoot) => { await fs.writeFile( @@ -444,4 +497,40 @@ describe("update global helpers", () => { ); }); }); + + it("still enforces critical sidecars when the inventory omits them", async () => { + await withTempDir( + { prefix: "openclaw-update-global-critical-sidecars-" }, + async (packageRoot) => { + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.4.15" }), + "utf-8", + ); + for (const relativePath of NPM_UPDATE_COMPAT_SIDECAR_PATHS) { + const absolutePath = path.join(packageRoot, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, "export {};\n", "utf-8"); + } + const matrixPackageJson = path.join( + packageRoot, + "dist", + "extensions", + "matrix", + "package.json", + ); + await fs.mkdir(path.dirname(matrixPackageJson), { recursive: true }); + await fs.writeFile( + matrixPackageJson, + JSON.stringify({ name: "@openclaw/matrix" }), + "utf-8", + ); + await writePackageDistInventory(packageRoot); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + `missing bundled runtime sidecar ${MATRIX_HELPER_API}`, + ); + }, + ); + }); }); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index fd3a342a3f0..dedff0b9d53 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -8,10 +8,12 @@ import { pathExists } from "../utils.js"; import { NPM_UPDATE_COMPAT_SIDECAR_PATHS } from "./npm-update-compat-sidecars.js"; import { collectPackageDistInventory, + PACKAGE_DIST_INVENTORY_RELATIVE_PATH, readPackageDistInventoryIfPresent, } from "./package-dist-inventory.js"; import { readPackageVersion } from "./package-json.js"; import { applyPathPrepend } from "./path-prepend.js"; +import { parseSemver } from "./runtime-guard.js"; export type GlobalInstallManager = "npm" | "pnpm" | "bun"; @@ -40,6 +42,7 @@ const NPM_GLOBAL_INSTALL_OMIT_OPTIONAL_FLAGS = [ "--omit=optional", ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, ] as const; +const FIRST_PACKAGED_DIST_INVENTORY_VERSION = { major: 2026, minor: 4, patch: 15 }; function normalizePackageTarget(value: string): string { return value.trim(); @@ -94,33 +97,107 @@ export async function collectInstalledGlobalPackageErrors(params: { `expected installed version ${params.expectedVersion}, found ${installedVersion ?? ""}`, ); } - errors.push(...(await collectInstalledPackageDistErrors(params.packageRoot))); + errors.push( + ...(await collectInstalledPackageDistErrors({ + packageRoot: params.packageRoot, + installedVersion, + expectedVersion: params.expectedVersion, + })), + ); return errors; } -async function collectInstalledPackageDistErrors(packageRoot: string): Promise { - const inventoryFiles = await readPackageDistInventoryIfPresent(packageRoot); +function shouldRequirePackagedDistInventory(version: string | null | undefined): boolean { + const parsed = parseSemver(version ?? null); + if (!parsed) { + return false; + } + if (parsed.major !== FIRST_PACKAGED_DIST_INVENTORY_VERSION.major) { + return parsed.major > FIRST_PACKAGED_DIST_INVENTORY_VERSION.major; + } + if (parsed.minor !== FIRST_PACKAGED_DIST_INVENTORY_VERSION.minor) { + return parsed.minor > FIRST_PACKAGED_DIST_INVENTORY_VERSION.minor; + } + return parsed.patch >= FIRST_PACKAGED_DIST_INVENTORY_VERSION.patch; +} + +async function collectInstalledPackageDistErrors(params: { + packageRoot: string; + installedVersion: string | null; + expectedVersion?: string | null; +}): Promise { + const criticalPaths = await collectCriticalInstalledPackageDistPaths(params.packageRoot); + let inventoryFiles: string[] | null = null; + let inventoryError: string | null = null; + try { + inventoryFiles = await readPackageDistInventoryIfPresent(params.packageRoot); + } catch { + inventoryError = `invalid package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`; + } + if (inventoryFiles !== null) { - return await collectInstalledPathErrors({ - packageRoot, + const actualFiles = await collectPackageDistInventory(params.packageRoot); + const inventoryErrors = await collectInstalledPathErrors({ + packageRoot: params.packageRoot, expectedFiles: inventoryFiles, - actualFiles: await collectPackageDistInventory(packageRoot), + actualFiles, missingMessage: (relativePath) => `missing packaged dist file ${relativePath}`, unexpectedMessage: (relativePath) => `unexpected packaged dist file ${relativePath}`, }); + const inventorySet = new Set(inventoryFiles); + const supplementalCriticalPaths = criticalPaths.filter( + (relativePath) => !inventorySet.has(relativePath), + ); + if (supplementalCriticalPaths.length === 0) { + return inventoryErrors; + } + return [ + ...inventoryErrors, + ...(await collectInstalledPathErrors({ + packageRoot: params.packageRoot, + expectedFiles: supplementalCriticalPaths, + actualFiles, + missingMessage: (relativePath) => `missing bundled runtime sidecar ${relativePath}`, + })), + ]; } - return await collectInstalledPathErrors({ - packageRoot, - expectedFiles: await collectLegacyInstalledPackageDistPaths(packageRoot), + + const criticalErrors = await collectInstalledPathErrors({ + packageRoot: params.packageRoot, + expectedFiles: await collectLegacyInstalledPackageDistPaths(params.packageRoot), actualFiles: null, missingMessage: (relativePath) => `missing bundled runtime sidecar ${relativePath}`, }); + if (inventoryError) { + return [inventoryError, ...criticalErrors]; + } + if ( + shouldRequirePackagedDistInventory(params.installedVersion) || + shouldRequirePackagedDistInventory(params.expectedVersion) + ) { + return [ + `missing package dist inventory ${PACKAGE_DIST_INVENTORY_RELATIVE_PATH}`, + ...criticalErrors, + ]; + } + return criticalErrors; } async function collectLegacyInstalledPackageDistPaths(packageRoot: string): Promise { const expectedFiles = new Set(NPM_UPDATE_COMPAT_SIDECAR_PATHS); + for (const relativePath of await collectCriticalInstalledPackageDistPaths(packageRoot)) { + expectedFiles.add(relativePath); + } + return [...expectedFiles].toSorted((left, right) => left.localeCompare(right)); +} + +async function collectCriticalInstalledPackageDistPaths(packageRoot: string): Promise { + const expectedFiles = new Set(); await Promise.all( BUNDLED_RUNTIME_SIDECAR_PATHS.map(async (relativePath) => { + if (NPM_UPDATE_COMPAT_SIDECAR_PATHS.has(relativePath)) { + return; + } const pluginRoot = resolveBundledPluginRoot(relativePath); if (pluginRoot === null) { return; diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index b4b50b2beb8..3f02e24fd3f 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -214,6 +214,48 @@ describe("bundled plugin postinstall", () => { await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("keeps packaged postinstall non-fatal when the dist inventory is missing", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-install-missing-inventory-"); + const staleFile = path.join(packageRoot, "dist", "channel-CJUAgRQR.js"); + await fs.mkdir(path.dirname(staleFile), { recursive: true }); + await fs.writeFile(staleFile, "export {};\n"); + const warn = vi.fn(); + + expect(() => + runBundledPluginPostinstall({ + packageRoot, + log: { log: vi.fn(), warn }, + }), + ).not.toThrow(); + + await expect(fs.stat(staleFile)).resolves.toBeTruthy(); + expect(warn).toHaveBeenCalledWith( + "[postinstall] skipping dist prune: missing dist inventory: dist/postinstall-inventory.json", + ); + }); + + it("keeps packaged postinstall non-fatal when the dist inventory is invalid", async () => { + const packageRoot = await createTempDirAsync("openclaw-packaged-install-invalid-inventory-"); + const currentFile = path.join(packageRoot, "dist", "channel-BOa4MfoC.js"); + const inventoryPath = path.join(packageRoot, "dist", "postinstall-inventory.json"); + await fs.mkdir(path.dirname(currentFile), { recursive: true }); + await fs.writeFile(currentFile, "export {};\n"); + await fs.writeFile(inventoryPath, "{not-json}\n"); + const warn = vi.fn(); + + expect(() => + runBundledPluginPostinstall({ + packageRoot, + log: { log: vi.fn(), warn }, + }), + ).not.toThrow(); + + await expect(fs.stat(currentFile)).resolves.toBeTruthy(); + expect(warn).toHaveBeenCalledWith( + "[postinstall] skipping dist prune: invalid dist inventory: dist/postinstall-inventory.json", + ); + }); + it("rejects symlinked dist roots in packaged installs", () => { expect(() => pruneInstalledPackageDist({