mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
Merge branch 'main' into feat/plugins-route-runtime-planning
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? "<missing>"}`,
|
||||
);
|
||||
}
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
const expectedFiles = new Set<string>();
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user