fix(release): preserve 2026.8.33 update and validation safety (#153362)

* fix: fence claimless ackDelivery against a live platform-send claim (#153083)

The claimless branch of ackDeliveryInDatabase read the row and settled
it without checking for a live producer/platform-send claim, so a
caller with no owner of its own could delete another worker's
in-flight delivery. Route it through the same fenced transition the
owned path already uses, with the absent owner treated as null and a
missing row still a no-op.

(cherry picked from commit 165099e7e9)

* fix(discord): stop /new and /reset from skipping guild guards (#152991)

Discord /new and /reset skipped every configured guild guard in every
ordinary guild channel. A channel configured enabled:false still answered
both commands, while /status in the same channel was correctly refused.

canBypassConfiguredAcpGuildGuards ended in a four-way OR whose last clause
was routeState.configuredRoute != null. resolveConfiguredBindingRoute has
three return sites and all three are object literals, so configuredRoute is
never null for a non-thread guild channel and the bypass fired always. That
also made the correct check one line above, configuredBinding != null, dead
code.

Drop the vacuous clause. configuredBinding is non-null exactly when a
configured binding matched the conversation, so the intended ACP recovery
bypass still fires for ACP-bound channels while ordinary channels get the
enabled, allowed, group-policy and guild-command-authorization guards back.

Adds the first coverage for the native-command kill switch path.

(cherry picked from commit 0be25b288c)

* test(ui): accept equivalent round corner serialization

Accept round and superellipse(1) as equivalent circular CSSOM values.
Keep radius, non-circular shape, excluded-surface, and root-token
assertions unchanged. This is a release verification repair only.

(cherry picked from commit 46dc9d8dad)

* fix(release): keep frozen plugin harness pins advisory

* test(release): stabilize frozen validation gates

* fix(release): preserve strict ClawHub dependency checks

* chore(release): keep freshness collector private

---------

Co-authored-by: Jialong(Bruce) Li <chelsealong@126.com>
Co-authored-by: Yuval Dinodia <102706514+yetval@users.noreply.github.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Dallin Romney 2026-09-19 20:01:44 -07:00 • committed by GitHub
parent aea01a2522
commit ea06b14d64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 366 additions and 58 deletions

View file

@ -379,6 +379,7 @@ jobs:
fi
cat .local/plugin-npm-release-plan.json
jq -r '.warnings[]? | "- Warning: \(.)"' .local/plugin-npm-release-plan.json >> "$GITHUB_STEP_SUMMARY"
candidate_count="$(jq -r '.candidates | length' .local/plugin-npm-release-plan.json)"
selection_count="$(jq -r '.all | length' .local/plugin-npm-release-plan.json)"

View file

@ -3148,7 +3148,7 @@ src/infra/outbound/deliver-queue-state.ts 1
src/infra/outbound/delivery-queue-media-spool.ts 2
src/infra/outbound/delivery-queue-media-staging.ts 2
src/infra/outbound/delivery-queue-preparation.ts 1
src/infra/outbound/delivery-queue-storage.ts 8
src/infra/outbound/delivery-queue-storage.ts 7
src/infra/outbound/envelope.ts 2
src/infra/outbound/format.ts 1
src/infra/outbound/message-account-selection.ts 2

View file

@ -348,6 +348,7 @@ A legacy fallback correction tag may reuse base-package evidence only when the c
- That workflow runs `OPENCLAW_LIVE_TEST=1 OPENCLAW_LIVE_CACHE_TEST=1 pnpm test:live:cache` using both `OPENAI_API_KEY` and `ANTHROPIC_API_KEY` workflow secrets.
- npm release preflight no longer waits on the separate release checks lane.
- Before tagging a release candidate locally, run `RELEASE_TAG=vYYYY.M.PATCH-beta.N pnpm release:fast-pretag-check`. The helper runs the fast release guardrails, plugin npm/ClawHub release checks, build, UI build, and `release:openclaw:npm:check` in the order that catches common approval-blocking mistakes before the GitHub publish workflow starts.
- Plugin `openclaw.release.requireLatestDependencies` declarations remain release metadata, but npm `latest` drift is advisory. Checks warn with the plugin, dependency, pinned version, and current latest version; a failed latest lookup also warns and does not establish that the pin is unusable. Full Release Validation's Codex lanes validate the `@openclaw/codex` harness pin. Keep that frozen, tested pin when upstream publishes a newer version. Missing or malformed required runtime dependency metadata, package/install failures, and failed required validation lanes still block release.
- Run `RELEASE_TAG=vYYYY.M.PATCH node --import tsx scripts/openclaw-npm-release-check.ts` (or the matching prerelease/correction tag) before approval.
- After npm publish, run `node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.PATCH` (or the matching beta/correction version) to verify the published registry install path in a fresh temp prefix.
- After a beta publish, run `OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC=openclaw@YYYY.M.PATCH-beta.N OPENCLAW_NPM_TELEGRAM_CREDENTIAL_ROLE=maintainer pnpm test:docker:npm-telegram-live` with `OPENCLAW_QA_CONVEX_SITE_URL` and `OPENCLAW_QA_CONVEX_SECRET_MAINTAINER` set. This verifies installed-package onboarding, Telegram setup, and real Telegram E2E against the published npm package using the shared Test Server userbot pool. CI uses the `ci` role and `OPENCLAW_QA_CONVEX_SECRET_CI` instead.

View file

@ -0,0 +1,176 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ChannelType } from "discord-api-types/v10";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
createTestRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { afterEach, describe, expect, it } from "vitest";
import { discordPlugin } from "../channel.js";
import type { CommandInteraction } from "../internal/discord.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createMockCommandInteraction } from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.manager.js";
const directories: string[] = [];
const userId = "100000000000000003";
const channelId = "100000000000000001";
const guildId = "100000000000000002";
const otherChannelId = "100000000000000005";
const sessionId = "existing-channel-session";
afterEach(async () => {
clearRuntimeConfigSnapshot();
setActivePluginRegistry(createTestRegistry());
await Promise.all(
directories.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
);
});
async function runNativeCommand(params: {
commandName: string;
guildChannels: Record<string, { enabled?: boolean }>;
configuredBinding?: boolean;
label: string;
}) {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "discord-guild-guards-"));
directories.push(home);
const storePath = path.join(home, "sessions.json");
const sessionKey = `agent:main:discord:channel:${channelId}`;
const scope = { agentId: "main", storePath, sessionKey };
const cfg: OpenClawConfig = {
agents: { defaults: { workspace: home } },
session: { store: storePath },
commands: { allowFrom: { discord: [`user:${userId}`] } },
...(params.configuredBinding
? {
bindings: [
{
type: "acp",
agentId: "main",
match: {
channel: "discord",
accountId: "default",
peer: { kind: "channel", id: channelId },
},
acp: { backend: "acpx" },
},
],
}
: {}),
channels: {
discord: {
commands: { native: true },
guilds: { [guildId]: { channels: params.guildChannels } },
},
},
} as OpenClawConfig;
setActivePluginRegistry(
createTestRegistry([{ pluginId: "discord", plugin: discordPlugin, source: "test" }]),
);
await upsertSessionEntry({
...scope,
entry: {
sessionId,
lifecycleRevision: "before-reset",
updatedAt: Date.now(),
totalTokens: 100,
},
});
setRuntimeConfigSnapshot(cfg);
const interaction = createMockCommandInteraction({
channelType: ChannelType.GuildText,
channelId,
guildId,
userId,
interactionId: params.label,
});
const command = createDiscordNativeCommand({
command: { name: params.commandName, description: "Guild guard probe.", acceptsArgs: true },
cfg,
discordConfig: cfg.channels!.discord!,
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
await command.run(interaction as unknown as CommandInteraction);
return {
entry: getSessionEntry(scope),
replies: [...interaction.reply.mock.calls, ...interaction.followUp.mock.calls].map(
([payload]) => payload?.content,
),
};
}
describe("discord native command guild guards", () => {
it.each(["reset", "new"] as const)(
"refuses /%s in a disabled guild channel with no configured binding",
async (commandName) => {
const result = await runNativeCommand({
commandName,
guildChannels: { [channelId]: { enabled: false } },
label: `disabled-${commandName}`,
});
expect(result.replies).toEqual(["This channel is disabled."]);
expect(result.entry?.lifecycleRevision).toBe("before-reset");
},
);
it.each(["reset", "new"] as const)(
"refuses /%s in a not-allowed guild channel with no configured binding",
async (commandName) => {
const result = await runNativeCommand({
commandName,
guildChannels: { [otherChannelId]: { enabled: true } },
label: `notallowed-${commandName}`,
});
expect(result.replies).toEqual(["This channel is not allowed."]);
expect(result.entry?.lifecycleRevision).toBe("before-reset");
},
);
it("refuses /status in a disabled guild channel", async () => {
const result = await runNativeCommand({
commandName: "status",
guildChannels: { [channelId]: { enabled: false } },
label: "disabled-status",
});
expect(result.replies).toEqual(["This channel is disabled."]);
});
it.each(["reset", "new"] as const)(
"still bypasses the guards for /%s when a configured binding owns the channel",
async (commandName) => {
const result = await runNativeCommand({
commandName,
guildChannels: { [channelId]: { enabled: false } },
configuredBinding: true,
label: `bound-${commandName}`,
});
expect(result.replies).not.toContain("This channel is disabled.");
expect(result.replies).not.toContain("This channel is not allowed.");
expect(result.replies.length).toBeGreaterThan(0);
},
);
it.each(["reset", "new"] as const)(
"passes guild guards for /%s in an enabled channel with no configured binding",
async (commandName) => {
const result = await runNativeCommand({
commandName,
guildChannels: { [channelId]: { enabled: true } },
label: `enabled-${commandName}`,
});
expect(result.replies).not.toContain("This channel is disabled.");
expect(result.replies).not.toContain("This channel is not allowed.");
},
);
});

View file

@ -368,8 +368,7 @@ async function dispatchDiscordCommandInteraction(params: {
return (
routeState.effectiveRoute.matchedBy === "binding.channel" ||
routeState.boundSessionKey != null ||
routeState.configuredBinding != null ||
routeState.configuredRoute != null
routeState.configuredBinding != null
);
};
if (channelConfig?.enabled === false && !(await canBypassConfiguredAcpGuildGuards())) {

View file

@ -30,6 +30,7 @@ type PluginReleasePlanItem = PublishablePluginPackage & {
type PluginReleasePlan = {
all: PluginReleasePlanItem[];
warnings: string[];
candidates: PluginReleasePlanItem[];
skippedPublished: PluginReleasePlanItem[];
};
@ -420,14 +421,13 @@ function resolveNpmLatestVersion(packageName: string): string {
return parsed.trim();
}
export function collectPluginReleaseDependencyFreshnessErrors(
function collectPluginReleaseDependencyFreshnessMessages(
plugins: readonly PublishablePluginPackage[],
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
resolveLatestVersion: NpmLatestVersionResolver,
policy: "advisory" | "strict",
): string[] {
// Only plugin-owned opt-ins use this strict gate. It prevents release branches
// from silently carrying old executable pins while leaving normal dependencies alone.
const latestVersions = new Map<string, string>();
const errors: string[] = [];
const messages: string[] = [];
for (const plugin of plugins) {
for (const dependency of plugin.requiredLatestDependencies ?? []) {
@ -437,21 +437,41 @@ export function collectPluginReleaseDependencyFreshnessErrors(
latestVersion = resolveLatestVersion(dependency.packageName);
latestVersions.set(dependency.packageName, latestVersion);
} catch (error) {
errors.push(
`${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`,
messages.push(
policy === "advisory"
? `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName} (pinned "${dependency.version}"); freshness is advisory: ${error instanceof Error ? error.message : String(error)}`
: `${plugin.packageName}@${plugin.version}: could not resolve npm latest for ${dependency.packageName}: ${error instanceof Error ? error.message : String(error)}`,
);
continue;
}
}
if (dependency.version !== latestVersion) {
errors.push(
`${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`,
messages.push(
policy === "advisory"
? `${plugin.packageName}@${plugin.version}: ${dependency.packageName} pinned "${dependency.version}", npm latest is "${latestVersion}". Freshness is advisory; retain the release-validated pin.`
: `${plugin.packageName}@${plugin.version}: ${dependency.packageName} must match npm latest for release; found "${dependency.version}", latest is "${latestVersion}".`,
);
}
}
}
return errors;
return messages;
}
export function collectPluginReleaseDependencyFreshnessWarnings(
plugins: readonly PublishablePluginPackage[],
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
): string[] {
// Release validation owns pin compatibility. A moving npm dist-tag must not
// invalidate a frozen, tested candidate, including when the lookup is unavailable.
return collectPluginReleaseDependencyFreshnessMessages(plugins, resolveLatestVersion, "advisory");
}
function collectPluginReleaseDependencyFreshnessErrors(
plugins: readonly PublishablePluginPackage[],
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
): string[] {
return collectPluginReleaseDependencyFreshnessMessages(plugins, resolveLatestVersion, "strict");
}
export function assertPluginReleaseDependencyFreshness(
@ -470,6 +490,18 @@ export function assertPluginReleaseDependencyFreshness(
);
}
export function warnPluginReleaseDependencyFreshness(
plugins: readonly PublishablePluginPackage[],
label: string,
resolveLatestVersion: NpmLatestVersionResolver = resolveNpmLatestVersion,
): string[] {
const warnings = collectPluginReleaseDependencyFreshnessWarnings(plugins, resolveLatestVersion);
for (const warning of warnings) {
console.warn(`${label}: warning: ${warning}`);
}
return warnings;
}
function isPluginVersionPublished(packageName: string, version: string): boolean {
try {
runNpmView([`${packageName}@${version}`, "version"]);
@ -527,7 +559,10 @@ export function collectPluginReleasePlan(params?: {
if (explicitPublishSelection) {
assertPluginReleaseVersionFloors(selectedPublishable, "Plugin NPM release plan");
}
assertPluginReleaseDependencyFreshness(selectedPublishable, "Plugin NPM release plan");
const warnings = warnPluginReleaseDependencyFreshness(
selectedPublishable,
"Plugin NPM release plan",
);
const all = selectedPublishable.map((plugin) =>
Object.assign({}, plugin, {
@ -537,6 +572,7 @@ export function collectPluginReleasePlan(params?: {
return {
all,
warnings,
candidates: all.filter((plugin) => !plugin.alreadyPublished),
skippedPublished: all.filter((plugin) => plugin.alreadyPublished),
};

View file

@ -38,6 +38,8 @@ export function transitionOwnedDeliveryQueueEntry(
id: string;
stateDir?: string;
platformSendAttemptId: string | null;
/** A caller with no owner of its own treats an already-settled row as a no-op. */
allowMissingEntry?: boolean;
},
transition: (entry: DeliveryQueueEntryState) => void,
): boolean {
@ -49,7 +51,7 @@ export function transitionOwnedDeliveryQueueEntry(
() => {
const entry = loadDeliveryQueueEntry(params.queueName, params.id, params.stateDir);
if (!entry) {
return false;
return params.allowMissingEntry === true;
}
if (
params.platformSendAttemptId === null

View file

@ -298,23 +298,23 @@ export async function ackDelivery(
deleteDeliveryQueueEntry(OUTBOUND_DELIVERY_QUEUE_NAME, id, stateDir);
}
};
if (options && "expectedPlatformSendAttemptId" in options) {
const settled = transitionOwnedDeliveryQueueEntry(
{
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
id,
stateDir,
platformSendAttemptId: options.expectedPlatformSendAttemptId ?? null,
},
(entry) => settle(entry as QueuedDelivery),
);
if (!settled) {
throw lostPlatformClaim(id);
}
} else {
settle(
loadDeliveryQueueEntry(OUTBOUND_DELIVERY_QUEUE_NAME, id, stateDir) as QueuedDelivery | null,
);
// A claimless caller has no owner to assert, so an unclaimed row settles and an
// already-missing row is a no-op; either way it must never touch a live claim.
const ownsPlatformAttempt = options && "expectedPlatformSendAttemptId" in options;
const settled = transitionOwnedDeliveryQueueEntry(
{
queueName: OUTBOUND_DELIVERY_QUEUE_NAME,
id,
stateDir,
platformSendAttemptId: ownsPlatformAttempt
? (options.expectedPlatformSendAttemptId ?? null)
: null,
allowMissingEntry: !ownsPlatformAttempt,
},
(entry) => settle(entry as QueuedDelivery),
);
if (!settled) {
throw lostPlatformClaim(id);
}
if (!options?.retainSpoolArtifacts) {
await releaseSpoolArtifacts(spoolPaths, stateDir);

View file

@ -420,6 +420,27 @@ describe("delivery-queue storage", () => {
await expect(ackDelivery("nonexistent-id", tmpDir())).resolves.toBeUndefined();
});
it("claimless ack rejects a live-claimed row instead of deleting it", async () => {
const stateDir = tmpDir();
const id = await enqueueTextDelivery({
channel: "directchat",
to: "+1",
payloads: [{ text: "claimless-ack-guard" }],
});
const attemptId = await claimDeliveryPlatformSendAttempt(id, stateDir);
if (!attemptId) {
throw new Error("test invariant: the unclaimed row must accept a platform claim");
}
await expect(ackDelivery(id, stateDir)).rejects.toThrow(
`Delivery platform claim was lost: ${id}`,
);
const pending = await loadPendingDelivery(id, stateDir);
expect(pending).toMatchObject({ id, producerClaimId: attemptId });
expect(readStatus(id)).toBe("pending");
});
it("removes acked entries from pending recovery", async () => {
const id = await enqueueTextDelivery({
channel: "directchat",

View file

@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { collectClawHubPublishablePluginPackages } from "../scripts/lib/plugin-clawhub-release.ts";
import {
collectChangedExtensionIdsFromPaths,
collectPluginReleaseDependencyFreshnessErrors,
collectPluginReleaseDependencyFreshnessWarnings,
collectPluginNpmGitRangeSelection,
collectPluginReleasePlan,
collectPluginReleaseVersionFloorErrors,
@ -20,6 +20,7 @@ import {
parsePluginReleaseSelectionMode,
resolveChangedPublishablePluginPackages,
resolveSelectedPublishablePluginPackages,
warnPluginReleaseDependencyFreshness,
type PublishablePluginPackage,
} from "../scripts/lib/plugin-npm-release.ts";
import { writePublishablePluginFixture } from "./helpers/publishable-plugin-fixture.js";
@ -47,6 +48,7 @@ const tempDirs: string[] = [];
afterEach(() => {
childProcessMock.execFileSyncOverride = undefined;
vi.restoreAllMocks();
cleanupTempDirs(tempDirs);
});
@ -416,7 +418,7 @@ describe("collectPluginReleaseVersionFloorErrors", () => {
});
});
describe("collectPluginReleaseDependencyFreshnessErrors", () => {
describe("collectPluginReleaseDependencyFreshnessWarnings", () => {
const plugin: PublishablePluginPackage = {
extensionId: "codex",
packageDir: "extensions/codex",
@ -432,15 +434,15 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => {
],
};
it("rejects release dependencies older than the npm latest dist-tag", () => {
expect(collectPluginReleaseDependencyFreshnessErrors([plugin], () => "0.142.5")).toEqual([
'@openclaw/codex@2026.6.11: @openai/codex must match npm latest for release; found "0.139.0", latest is "0.142.5".',
it("reports release dependencies older than the npm latest dist-tag", () => {
expect(collectPluginReleaseDependencyFreshnessWarnings([plugin], () => "0.142.5")).toEqual([
'@openclaw/codex@2026.6.11: @openai/codex pinned "0.139.0", npm latest is "0.142.5". Freshness is advisory; retain the release-validated pin.',
]);
});
it("accepts release dependencies matching the npm latest dist-tag", () => {
expect(
collectPluginReleaseDependencyFreshnessErrors(
collectPluginReleaseDependencyFreshnessWarnings(
[
{
...plugin,
@ -457,17 +459,17 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => {
).toEqual([]);
});
it("fails closed when npm latest cannot be resolved", () => {
it("reports unavailable npm latest as advisory", () => {
expect(
collectPluginReleaseDependencyFreshnessErrors([plugin], () => {
collectPluginReleaseDependencyFreshnessWarnings([plugin], () => {
throw new Error("registry unavailable");
}),
).toEqual([
"@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: registry unavailable",
'@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex (pinned "0.139.0"); freshness is advisory: registry unavailable',
]);
});
it("fails closed when the npm latest lookup times out", () => {
it("reports npm latest lookup timeouts as advisory", () => {
childProcessMock.execFileSyncOverride = ((
command: string,
args?: readonly string[],
@ -489,13 +491,54 @@ describe("collectPluginReleaseDependencyFreshnessErrors", () => {
throw Object.assign(new Error("spawnSync npm ETIMEDOUT"), { code: "ETIMEDOUT" });
}) as unknown as ExecFileSync;
expect(collectPluginReleaseDependencyFreshnessErrors([plugin])).toEqual([
"@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex: npm view timed out after 60000ms.",
expect(collectPluginReleaseDependencyFreshnessWarnings([plugin])).toEqual([
'@openclaw/codex@2026.6.11: could not resolve npm latest for @openai/codex (pinned "0.139.0"); freshness is advisory: npm view timed out after 60000ms.',
]);
});
it("logs advisory warnings without blocking the caller", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const warnings = warnPluginReleaseDependencyFreshness(
[plugin],
"release check",
() => "0.142.5",
);
expect(warnings).toHaveLength(1);
expect(warn).toHaveBeenCalledExactlyOnceWith(`release check: warning: ${warnings[0]}`);
});
});
describe("collectPluginReleasePlan", () => {
it("keeps a publish candidate when npm latest moved after validation", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
writePublishablePluginFixture(repoDir, {
version: "2026.8.33",
publishTo: "npm",
dependency: { packageName: "demo-runtime", version: "1.2.3", requireLatest: true },
});
childProcessMock.execFileSyncOverride = ((command: string, args?: readonly string[]) => {
expect(command).toBe("npm");
if (args?.[2] === "dist-tags.latest") {
return JSON.stringify("1.2.4");
}
throw new Error("version not published");
}) as unknown as ExecFileSync;
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const plan = collectPluginReleasePlan({ rootDir: repoDir });
expect(plan.candidates.map((candidate) => candidate.packageName)).toEqual([
"@openclaw/demo-plugin",
]);
expect(plan.warnings).toEqual([
'@openclaw/demo-plugin@2026.8.33: demo-runtime pinned "1.2.3", npm latest is "1.2.4". Freshness is advisory; retain the release-validated pin.',
]);
expect(warn).toHaveBeenCalledExactlyOnceWith(
`Plugin NPM release plan: warning: ${plan.warnings[0]}`,
);
});
it("fails closed when the published-version lookup times out", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
writePublishablePluginFixture(repoDir, {

View file

@ -243,7 +243,7 @@ process.exitCode = await new Promise((resolve, reject) => {
);
it.each(["wrapper", "root package preloads"])(
"keeps %s and raw tsx children off disk caches without changing other cache settings",
"keeps %s and descendants off disk caches without changing other cache settings",
async (entrypoint) => {
await withShimFixture(TSX_SHIM_WRAPPERS[0], async (fixture) => {
const { fixtureRoot, implementationPath, wrapperPath, runNode } = fixture;
@ -287,22 +287,36 @@ fs.readdirSync = function (directory, ...args) {
key === "TMPDIR" || key === "TEMP" ? tempRoot : path.join(fixtureRoot, key),
]),
);
const childPath = path.join(fixtureRoot, "child.mts");
const snapshotSource = `
const childPath = path.join(fixtureRoot, "child.mjs");
const environmentSnapshot = `
env: Object.fromEntries(${JSON.stringify(Object.keys(preservedEnv))}.map(key => [key, process.env[key]])),
tsxDisableCache: process.env.TSX_DISABLE_CACHE,
`;
const transformedSnapshotSource = `
enum Transformed { Value = "transformed" }
console.log(JSON.stringify({
transformed: Transformed.Value,
args: process.argv.slice(2),
cwd: process.cwd(),
env: Object.fromEntries(${JSON.stringify(Object.keys(preservedEnv))}.map(key => [key, process.env[key]])),
${environmentSnapshot}
}));
`;
writeFileSync(childPath, `${snapshotSource}\nprocess.exitCode = 17;\n`);
writeFileSync(
childPath,
`console.log(JSON.stringify({
transformed: null,
args: process.argv.slice(2),
cwd: process.cwd(),
${environmentSnapshot}
}));
process.exitCode = 17;
`,
);
writeFileSync(
implementationPath,
`${snapshotSource}
`${transformedSnapshotSource}
import { spawnSync } from "node:child_process";
const child = spawnSync(process.execPath, ["--import", "tsx", ${JSON.stringify(childPath)}, ...process.argv.slice(2)], { stdio: "inherit" });
const child = spawnSync(process.execPath, [${JSON.stringify(childPath)}, ...process.argv.slice(2)], { stdio: "inherit" });
if (child.error) throw child.error;
process.exitCode = child.status ?? 1;
`,
@ -346,14 +360,22 @@ process.exitCode = child.status ?? 1;
.trim()
.split("\n")
.map((line) => JSON.parse(line)),
).toEqual(
Array.from({ length: 2 }, () => ({
).toEqual([
{
transformed: "transformed",
args: ["argument with spaces", "--proof"],
cwd: process.cwd(),
env: preservedEnv,
})),
);
tsxDisableCache: "1",
},
{
transformed: null,
args: ["argument with spaces", "--proof"],
cwd: process.cwd(),
env: preservedEnv,
tsxDisableCache: "1",
},
]);
if (entrypoint === "wrapper") {
expect(result.stderr.trim().split("\n").at(-1)).toBe("[test] FAILED (exit 17)");
}

View file

@ -209,6 +209,10 @@ const EXCLUDED_CASES: readonly CornerCase[] = [
const ALL_CASES = [...CORNER_CASES, ...ROUND_CASES, ...EXCLUDED_CASES];
// CSSOM can serialize the same circular shape as round or superellipse(1).
// https://drafts.csswg.org/css-borders-4/#valdef-corner-shape-value-round
const CIRCULAR_SHAPE = expect.stringMatching(/^(?:round|superellipse\(1\))$/);
// The radius tokens themselves, read at :root exactly like
// collectMcpAppStyleVariables() in mcp-app-theme.ts reads them for embedded
// MCP apps. They must stay canonical/unscaled even under the superelliptical
@ -334,11 +338,11 @@ describeCornerShape("Control UI corner curvature", () => {
]),
...ROUND_CASES.map((corner) => [
corner.selector,
{ radius: corner.superelliptical, shape: "round" },
{ radius: corner.superelliptical, shape: CIRCULAR_SHAPE },
]),
...EXCLUDED_CASES.map((corner) => [
corner.selector,
{ radius: corner.superelliptical, shape: "round" },
{ radius: corner.superelliptical, shape: CIRCULAR_SHAPE },
]),
]),
);
@ -349,7 +353,10 @@ describeCornerShape("Control UI corner curvature", () => {
expect(probe).toEqual(
Object.fromEntries(
ALL_CASES.map((corner) => [corner.selector, { radius: corner.circular, shape: "round" }]),
ALL_CASES.map((corner) => [
corner.selector,
{ radius: corner.circular, shape: CIRCULAR_SHAPE },
]),
),
);
});