mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-06 15:55:34 +08:00
* fix(cron): reject sub-millisecond durations * fix(skill-workshop): preserve proposal terminal newline Preserve proposal_content exactly at the agent tool boundary and make renderProposalMarkdown defensively emit a terminal newline. Add focused regressions for the tool write path and markdown renderer. * fix(skill-workshop): reject blank raw proposal content * fix: treat empty-string optional integer tool params as unset Optional positive-integer tool params (e.g. Telegram replyTo/threadId) threw ToolInputError when a tool-calling model populated them with an empty-string or whitespace-only default. Those defaults carry no value, so readPositiveIntegerParam/readNonNegativeIntegerParam now treat a blank string as unset (undefined) instead of throwing, while still rejecting genuinely invalid present values (0, "42.5", "-3"). This prevents silent message-delivery failures when models emit empty routing-param defaults. Adds unit tests covering blank vs invalid. * fix(gateway): log start session persistence failures The gateway session-lifecycle "start" event persistence (persistGatewaySessionLifecycleEvent, which surfaces write failures via requireWriteSuccess) was fired as void ...catch(() => undefined), swallowing the rejection with zero logging. A failed start-marker write silently dropped the run's start record from restart-recovery accounting, with no operator-visible trace. The sibling terminal-phase catch already logs this since #97839; the start path was the unfixed sibling. Mirror that fix: log the swallowed start-phase persistence failure with the same redacted message shape via formatForLog, keeping the fire-and-forget semantics unchanged. Adds a focused regression test asserting the log fires on start-persist rejection. * fix(memory): report close-time pending work failures * fix(shared): return "" from sliceUtf16Safe when end <= start, matching native .slice sliceUtf16Safe silently swapped reversed bounds (to < from) instead of returning "" like String.prototype.slice, creating a subtle footgun for callers with dynamic start/end pairs. Caller scan across src/, extensions/, and packages/ confirmed no production code relies on the old swap behavior — all callers use (text, 0, N) or (text, -N) forms only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(plugins): require plugin manifest in npm verifier * fix(android): propagate CancellationException in CameraHandler catch blocks Catch (err: Throwable) swallows kotlinx.coroutines.CancellationException, breaking structured concurrency when the coroutine scope is cancelled during camera operations (handleList/handleSnap/handleClip). Add CancellationException rethrow before each Throwable catch to match the existing pattern used in GatewaySession and TalkModeManager. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(android): propagate CancellationException past GatewaySession invoke boundary * chore: sync native i18n inventory after gateway session line shift * fix(agents): prevent native hook relay bridge race condition on renew and registration Remove synchronous bridge record write after server.listen() that races before the TCP server binds, and guard renew handler with server.listening check to prevent stale relay registrations. Closes #98650 * fix: harden small reliability fixes Co-authored-by: cxbAsDev <cxbAsDev@users.noreply.github.com> * docs(agents): explain buffered LSP spawn failures * docs(agents): clarify LSP spawn timeout invariant --------- Co-authored-by: qingminlong <qing.minlong@xydigit.com> Co-authored-by: anyech <anyech@gmail.com> Co-authored-by: snotty <snotty@users.noreply.github.com> Co-authored-by: masatohoshino <g515hoshino@gmail.com> Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com> Co-authored-by: simon-w <weng.qimeng@xydigit.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: 宇宙熊Yzx <53250620+849261680@users.noreply.github.com> Co-authored-by: xialonglee <li.xialong@xydigit.com> Co-authored-by: nankingjing <1079826437@qq.com> Co-authored-by: cxbAsDev <cxbAsDev@users.noreply.github.com>
383 lines
13 KiB
TypeScript
383 lines
13 KiB
TypeScript
// Verify Plugin Npm Published Runtime tests cover verify plugin npm published runtime script behavior.
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
collectPluginNpmPublishedRuntimeErrors,
|
|
findPackedPackageReadmePath,
|
|
parseVerifyPublishedPluginRuntimeArgs,
|
|
parseNpmReadmeMetadata,
|
|
readPluginNpmCommandOptions,
|
|
readPositiveIntEnv,
|
|
resolveNpmPackFilename,
|
|
runPluginNpmCommand,
|
|
usage,
|
|
} from "../../scripts/verify-plugin-npm-published-runtime.mjs";
|
|
|
|
describe("plugin npm publish verifier args", () => {
|
|
it("parses help and package specs before npm calls", () => {
|
|
expect(parseVerifyPublishedPluginRuntimeArgs(["--help"])).toEqual({ help: true, spec: "" });
|
|
expect(parseVerifyPublishedPluginRuntimeArgs(["--", "@openclaw/discord@2026.5.2"])).toEqual({
|
|
help: false,
|
|
spec: "@openclaw/discord@2026.5.2",
|
|
});
|
|
});
|
|
|
|
it("rejects unknown and extra args before npm calls", () => {
|
|
expect(() => parseVerifyPublishedPluginRuntimeArgs([])).toThrow(usage());
|
|
expect(() => parseVerifyPublishedPluginRuntimeArgs(["--wat"])).toThrow(
|
|
"Unknown plugin npm verifier option: --wat",
|
|
);
|
|
expect(() =>
|
|
parseVerifyPublishedPluginRuntimeArgs(["@openclaw/discord@2026.5.2", "extra"]),
|
|
).toThrow("Unexpected plugin npm verifier argument: extra");
|
|
});
|
|
});
|
|
|
|
describe("plugin npm publish verifier retry limits", () => {
|
|
it("rejects loose numeric retry env values instead of parsing prefixes", () => {
|
|
expect(() =>
|
|
readPositiveIntEnv("OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS", 90, {
|
|
OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS: "2tries",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS: 2tries");
|
|
expect(() =>
|
|
readPositiveIntEnv("OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS", 10000, {
|
|
OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS: "1e3",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_PLUGIN_NPM_VERIFY_DELAY_MS: 1e3");
|
|
expect(() =>
|
|
readPositiveIntEnv("OPENCLAW_PLUGIN_NPM_README_VERIFY_ATTEMPTS", 6, {
|
|
OPENCLAW_PLUGIN_NPM_README_VERIFY_ATTEMPTS: "0",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_PLUGIN_NPM_README_VERIFY_ATTEMPTS: 0");
|
|
});
|
|
|
|
it("accepts strict positive retry env values and defaults", () => {
|
|
expect(readPositiveIntEnv("OPENCLAW_PLUGIN_NPM_VERIFY_ATTEMPTS", 90, {})).toBe(90);
|
|
expect(
|
|
readPositiveIntEnv("OPENCLAW_PLUGIN_NPM_README_VERIFY_DELAY_MS", 10000, {
|
|
OPENCLAW_PLUGIN_NPM_README_VERIFY_DELAY_MS: "2500",
|
|
}),
|
|
).toBe(2500);
|
|
});
|
|
});
|
|
|
|
describe("plugin npm publish verifier command limits", () => {
|
|
it("bounds npm command runtime and captured output by default", () => {
|
|
expect(readPluginNpmCommandOptions({})).toStrictEqual({
|
|
encoding: "utf8",
|
|
killSignal: "SIGKILL",
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
timeout: 5 * 60 * 1000,
|
|
});
|
|
});
|
|
|
|
it("accepts strict npm command timeout and buffer overrides", () => {
|
|
expect(
|
|
readPluginNpmCommandOptions({
|
|
OPENCLAW_PLUGIN_NPM_COMMAND_MAX_BUFFER_BYTES: "33554432",
|
|
OPENCLAW_PLUGIN_NPM_COMMAND_TIMEOUT_MS: "120000",
|
|
}),
|
|
).toMatchObject({
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
timeout: 120000,
|
|
});
|
|
});
|
|
|
|
it("rejects loose npm command timeout and buffer overrides", () => {
|
|
expect(() =>
|
|
readPluginNpmCommandOptions({
|
|
OPENCLAW_PLUGIN_NPM_COMMAND_TIMEOUT_MS: "60s",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_PLUGIN_NPM_COMMAND_TIMEOUT_MS: 60s");
|
|
expect(() =>
|
|
readPluginNpmCommandOptions({
|
|
OPENCLAW_PLUGIN_NPM_COMMAND_MAX_BUFFER_BYTES: "16mb",
|
|
}),
|
|
).toThrow("invalid OPENCLAW_PLUGIN_NPM_COMMAND_MAX_BUFFER_BYTES: 16mb");
|
|
});
|
|
|
|
it("runs npm metadata commands with bounded exec options", () => {
|
|
const calls: unknown[] = [];
|
|
const output = runPluginNpmCommand(["view", "@openclaw/discord", "readme"], {
|
|
env: {
|
|
OPENCLAW_PLUGIN_NPM_COMMAND_MAX_BUFFER_BYTES: "1024",
|
|
OPENCLAW_PLUGIN_NPM_COMMAND_TIMEOUT_MS: "2500",
|
|
},
|
|
execFileSyncImpl(command: string, args: string[], options: unknown) {
|
|
calls.push({ args, command, options });
|
|
return JSON.stringify("# Discord");
|
|
},
|
|
});
|
|
|
|
expect(output).toBe(JSON.stringify("# Discord"));
|
|
expect(calls).toStrictEqual([
|
|
{
|
|
args: ["view", "@openclaw/discord", "readme"],
|
|
command: "npm",
|
|
options: {
|
|
encoding: "utf8",
|
|
killSignal: "SIGKILL",
|
|
maxBuffer: 1024,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
timeout: 2500,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("collectPluginNpmPublishedRuntimeErrors", () => {
|
|
it("flags published plugin packages with TypeScript entries and no compiled runtime output", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
spec: "@openclaw/discord@2026.5.2",
|
|
packageJson: {
|
|
name: "@openclaw/discord",
|
|
version: "2026.5.2",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "index.ts"],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/discord@2026.5.2 requires compiled runtime output for TypeScript entry ./index.ts: expected ./dist/index.js, ./dist/index.mjs, ./dist/index.cjs, ./index.js, ./index.mjs, ./index.cjs",
|
|
]);
|
|
});
|
|
|
|
it("accepts published plugin packages with explicit runtimeExtensions", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/zalo",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "index.ts", "dist/index.js"],
|
|
}),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it("flags plugin npm packages without an OpenClaw plugin manifest", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/searxng-plugin",
|
|
version: "2026.6.11",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
},
|
|
},
|
|
files: ["package.json", "dist/index.js"],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/searxng-plugin@2026.6.11 plugin npm package must include openclaw.plugin.json",
|
|
]);
|
|
});
|
|
|
|
it("flags reservation packages before they can pass plugin runtime verification", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/tavily-plugin",
|
|
version: "0.0.0",
|
|
description: "Bootstrap reservation",
|
|
},
|
|
files: ["package.json", "README.md"],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/tavily-plugin@0.0.0 plugin npm package must include openclaw.plugin.json",
|
|
]);
|
|
});
|
|
|
|
it("flags missing explicit runtimeExtensions outputs", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/line",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./src/index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "src/index.ts"],
|
|
}),
|
|
).toEqual(["@openclaw/line@2026.5.3 runtime extension entry not found: ./dist/index.js"]);
|
|
});
|
|
|
|
it("flags runtimeExtensions length mismatches", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/acpx",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./index.ts", "./tools.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "dist/index.js"],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/acpx@2026.5.3 package.json openclaw.runtimeExtensions length (1) must match openclaw.extensions length (2)",
|
|
]);
|
|
});
|
|
|
|
it("flags blank runtimeExtensions entries instead of falling back to inferred outputs", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/whatsapp",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./src/index.ts"],
|
|
runtimeExtensions: [" "],
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "src/index.ts", "dist/index.js"],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/whatsapp@2026.5.3 package.json openclaw.runtimeExtensions[0] must be a non-empty string",
|
|
]);
|
|
});
|
|
|
|
it("flags published plugin packages with TypeScript setup entries and no compiled setup runtime", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/line",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
setupEntry: "./setup-entry.ts",
|
|
},
|
|
},
|
|
files: [
|
|
"package.json",
|
|
"openclaw.plugin.json",
|
|
"index.ts",
|
|
"dist/index.js",
|
|
"setup-entry.ts",
|
|
],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/line@2026.5.3 requires compiled runtime output for TypeScript entry ./setup-entry.ts: expected ./dist/setup-entry.js, ./dist/setup-entry.mjs, ./dist/setup-entry.cjs, ./setup-entry.js, ./setup-entry.mjs, ./setup-entry.cjs",
|
|
]);
|
|
});
|
|
|
|
it("accepts published plugin packages with explicit runtimeSetupEntry", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/qqbot",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
setupEntry: "./setup-entry.ts",
|
|
runtimeSetupEntry: "./dist/setup-entry.js",
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "dist/index.js", "dist/setup-entry.js"],
|
|
}),
|
|
).toStrictEqual([]);
|
|
});
|
|
|
|
it("flags missing explicit runtimeSetupEntry outputs", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/matrix",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
setupEntry: "./setup-entry.ts",
|
|
runtimeSetupEntry: "./dist/setup-entry.js",
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "dist/index.js"],
|
|
}),
|
|
).toEqual(["@openclaw/matrix@2026.5.3 runtime setup entry not found: ./dist/setup-entry.js"]);
|
|
});
|
|
|
|
it("flags runtimeSetupEntry without setupEntry", () => {
|
|
expect(
|
|
collectPluginNpmPublishedRuntimeErrors({
|
|
packageJson: {
|
|
name: "@openclaw/twitch",
|
|
version: "2026.5.3",
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
runtimeExtensions: ["./dist/index.js"],
|
|
runtimeSetupEntry: "./dist/setup-entry.js",
|
|
},
|
|
},
|
|
files: ["package.json", "openclaw.plugin.json", "dist/index.js", "dist/setup-entry.js"],
|
|
}),
|
|
).toEqual([
|
|
"@openclaw/twitch@2026.5.3 package.json openclaw.runtimeSetupEntry requires openclaw.setupEntry",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("resolveNpmPackFilename", () => {
|
|
it("uses the final tarball filename from plain npm pack output", () => {
|
|
const noisyOutput = [
|
|
"npm notice",
|
|
"npm notice package: @openclaw/msteams@2026.5.24-beta.1",
|
|
"openclaw-msteams-2026.5.24-beta.1.tgz",
|
|
"",
|
|
].join("\n");
|
|
|
|
expect(resolveNpmPackFilename(noisyOutput)).toBe("openclaw-msteams-2026.5.24-beta.1.tgz");
|
|
});
|
|
|
|
it("rejects path-like tarball output instead of reading outside the pack directory", () => {
|
|
const unsafeOutputs = [
|
|
"../openclaw-msteams.tgz",
|
|
"nested/openclaw-msteams.tgz",
|
|
"nested\\openclaw-msteams.tgz",
|
|
"/tmp/openclaw-msteams.tgz",
|
|
"C:\\temp\\openclaw-msteams.tgz",
|
|
"openclaw-msteams\u0000.tgz",
|
|
];
|
|
|
|
for (const output of unsafeOutputs) {
|
|
expect(() => resolveNpmPackFilename(output)).toThrow(
|
|
"npm pack did not report a tarball filename",
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("findPackedPackageReadmePath", () => {
|
|
it("finds a root package README without accepting nested documentation files", () => {
|
|
expect(
|
|
findPackedPackageReadmePath(["package.json", "docs/README.md", "README.md", "dist/index.js"]),
|
|
).toBe("README.md");
|
|
expect(findPackedPackageReadmePath(["package.json", "docs/README.md"])).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("parseNpmReadmeMetadata", () => {
|
|
it("accepts non-empty npm readme metadata", () => {
|
|
expect(parseNpmReadmeMetadata(JSON.stringify("# Plugin\n\nInstall it."))).toBe(
|
|
"# Plugin\n\nInstall it.",
|
|
);
|
|
});
|
|
|
|
it("rejects empty or unsupported npm readme metadata", () => {
|
|
expect(parseNpmReadmeMetadata(JSON.stringify(""))).toBe("");
|
|
expect(parseNpmReadmeMetadata(JSON.stringify(null))).toBe("");
|
|
expect(parseNpmReadmeMetadata("{")).toBe("");
|
|
});
|
|
});
|