Compare commits

...

17 Commits

Author SHA1 Message Date
Peter Steinberger
1c927a553c chore(release): prepare 2026.4.24 beta 5 2026-04-25 15:53:10 +01:00
Peter Steinberger
99de2cfcc5 fix(plugins): alias wildcard runtime dependency exports 2026-04-25 15:46:01 +01:00
Peter Steinberger
f2b5e5bc69 chore(release): prepare 2026.4.24 beta 4 2026-04-25 15:11:29 +01:00
Peter Steinberger
9d612c1b5d fix(plugins): alias runtime dependency export subpaths 2026-04-25 15:01:08 +01:00
Peter Steinberger
da6530be0f chore(release): prepare 2026.4.24 beta 3 2026-04-25 14:24:22 +01:00
Peter Steinberger
9119ee6d75 fix(plugins): load mirrored runtime deps through ESM-safe aliases 2026-04-25 14:15:34 +01:00
Peter Steinberger
6dbce80a43 docs(changelog): finalize 2026.4.24 notes 2026-04-25 13:37:40 +01:00
Peter Steinberger
85839e5a13 chore(release): prepare 2026.4.24 beta 2 2026-04-25 13:04:46 +01:00
Peter Steinberger
1c98de9e66 fix(plugins): preserve package deps for runtime mirrors 2026-04-25 12:50:49 +01:00
Vincent Koc
3ee268cb4d fix(test): cap native worker pools for serial Vitest
(cherry picked from commit 734748d4f4)
2026-04-25 11:10:21 +01:00
hcl
9f6cda120d fix(heartbeat): clamp scheduler delay to Node setTimeout cap (#71414) (#71478)
* fix(heartbeat): clamp scheduler delay to Node setTimeout cap (#71414)

When `agents.defaults.heartbeat.every` resolves to >2_147_483_647 ms
(~24.85d), the previous scheduleNext() called setTimeout with the raw
delay. Node clamps any delay > 2^31-1 to 1 ms, fires the callback, and
the heartbeat re-arms with the same oversized value - a tight loop that
floods the log with TimeoutOverflowWarning and crashes the gateway with
exit code 1.

Clamp the computed delay to HEARTBEAT_MAX_TIMEOUT_MS (2_147_483_647)
before calling setTimeout. The worst case is now one heartbeat every
~24.85d instead of crash-loop. Warn once per process when clamping
fires, so a misconfigured "365d" remains visible without flooding.

This is a defense-in-depth fix at the scheduler layer; loadConfig-level
rejection is a broader change with more blast radius and a separate
question (some users may legitimately want "every: 365d" to mean
"effectively never"). The clamped behaviour is closer to that intent
than the crash is.

Test: new scheduler test sets heartbeat.every="365d" with fake timers,
advances 60s, and asserts runSpy was never called (with the bug, it
would be called ~60_000 times).

* style: format heartbeat scheduler clamp

* fix: share safe timeout delay clamp (#71478) (thanks @hclsys)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
(cherry picked from commit fd74fc5a4f)
2026-04-25 11:10:16 +01:00
Peter Steinberger
bd13188f79 chore(release): prepare 2026.4.24 beta 1 2026-04-25 10:31:38 +01:00
Peter Steinberger
c2f6ad3876 fix: keep control ui bundle browser-safe 2026-04-25 10:01:44 +01:00
Peter Steinberger
bca479cefb fix: align browser profile facade exports 2026-04-25 09:32:50 +01:00
Peter Steinberger
6d082070a2 fix(plugins): preserve bundled cli metadata skip 2026-04-25 09:27:46 +01:00
Peter Steinberger
86f65ef1e3 docs(release): require changelog rewrite from commits 2026-04-25 09:16:12 +01:00
Peter Steinberger
cdd91edd5e fix(plugins): load packaged runtime mirrors from canonical sources 2026-04-25 09:16:05 +01:00
44 changed files with 2384 additions and 1740 deletions

View File

@@ -97,6 +97,11 @@ Use this skill for release and publish-time workflow. Keep ordinary development
## Build changelog-backed release notes
- Before release branching or tagging, rewrite the target `CHANGELOG.md`
section from commit history, not just from existing notes: scan commits since
the last reachable release tag, add missed user-facing changes, dedupe
overlapping entries, and sort each section from most to least interesting for
users.
- Changelog entries should be user-facing, not internal release-process notes.
- GitHub release and prerelease bodies must use the full matching
`CHANGELOG.md` version section, not highlights or an excerpt. When creating

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
56ccee3ef8ff3b0ba7e2e765ae631b59254464585d5fef9db7e905f2c4c34ded plugin-sdk-api-baseline.json
39184cf8afaec691f0352d1a113e30a7099b87c0748237a3c7307e903ba24eee plugin-sdk-api-baseline.jsonl
93391fdd7342a45a90e5d34293e88ad15eb23274cb524ac002fa14c4ce12f02d plugin-sdk-api-baseline.json
98837bb6ab3198ea7b57def40a8e78ffd247db210cab770a1ca8cd946d794dc4 plugin-sdk-api-baseline.jsonl

View File

@@ -1,5 +1,6 @@
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,

View File

@@ -35,6 +35,7 @@ import { DEFAULT_UPLOAD_DIR } from "./paths.js";
export {
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
DEFAULT_BROWSER_ACTION_TIMEOUT_MS,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_OPENCLAW_BROWSER_COLOR,

View File

@@ -53,6 +53,7 @@ import {
beforeEach(async () => {
const page = {
on: vi.fn(),
goto,
title: pageTitle,
url: pageUrl,

View File

@@ -5,6 +5,12 @@ type QaWebSession = {
browser: Browser;
context: BrowserContext;
page: Page;
diagnostics: QaWebDiagnosticEntry[];
};
type QaWebDiagnosticEntry = {
kind: "console" | "pageerror" | "requestfailed";
text: string;
};
type QaWebOpenPageParams = {
@@ -44,6 +50,18 @@ type QaWebEvaluateParams = {
const sessions = new Map<string, QaWebSession>();
const DEFAULT_WEB_TIMEOUT_MS = 20_000;
const MAX_DIAGNOSTIC_ENTRIES = 50;
const MAX_DIAGNOSTIC_TEXT_CHARS = 2_000;
function appendDiagnostic(diagnostics: QaWebDiagnosticEntry[], entry: QaWebDiagnosticEntry): void {
diagnostics.push({
kind: entry.kind,
text: entry.text.slice(0, MAX_DIAGNOSTIC_TEXT_CHARS),
});
if (diagnostics.length > MAX_DIAGNOSTIC_ENTRIES) {
diagnostics.splice(0, diagnostics.length - MAX_DIAGNOSTIC_ENTRIES);
}
}
function resolveTimeoutMs(timeoutMs: number | undefined, fallbackMs = DEFAULT_WEB_TIMEOUT_MS) {
if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs)) {
@@ -71,12 +89,31 @@ export async function qaWebOpenPage(params: QaWebOpenPageParams) {
viewport: params.viewport ?? { width: 1440, height: 1080 },
});
const page = await context.newPage();
const diagnostics: QaWebDiagnosticEntry[] = [];
page.on("console", (message) => {
appendDiagnostic(diagnostics, {
kind: "console",
text: `[${message.type()}] ${message.text()}`,
});
});
page.on("pageerror", (error) => {
appendDiagnostic(diagnostics, {
kind: "pageerror",
text: error instanceof Error ? (error.stack ?? error.message) : String(error),
});
});
page.on("requestfailed", (request) => {
appendDiagnostic(diagnostics, {
kind: "requestfailed",
text: `${request.method()} ${request.url()} ${request.failure()?.errorText ?? "failed"}`,
});
});
await page.goto(params.url, {
waitUntil: "domcontentloaded",
timeout: timeoutMs,
});
const pageId = randomUUID();
sessions.set(pageId, { browser, context, page });
sessions.set(pageId, { browser, context, page, diagnostics });
return {
pageId,
url: page.url(),
@@ -128,6 +165,7 @@ export async function qaWebSnapshot(params: QaWebSnapshotParams) {
url: session.page.url(),
title: await session.page.title().catch(() => ""),
text: maxChars ? text.slice(0, maxChars) : text,
diagnostics: [...session.diagnostics],
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.4.24",
"version": "2026.4.24-beta.5",
"description": "Multi-channel AI gateway with extensible messaging integrations",
"keywords": [],
"homepage": "https://github.com/openclaw/openclaw#readme",
@@ -1129,14 +1129,14 @@
"types": "./dist/plugin-sdk/provider-usage.d.ts",
"default": "./dist/plugin-sdk/provider-usage.js"
},
"./plugin-sdk/web-content-extractor": {
"types": "./dist/plugin-sdk/web-content-extractor.d.ts",
"default": "./dist/plugin-sdk/web-content-extractor.js"
},
"./plugin-sdk/document-extractor": {
"types": "./dist/plugin-sdk/document-extractor.d.ts",
"default": "./dist/plugin-sdk/document-extractor.js"
},
"./plugin-sdk/web-content-extractor": {
"types": "./dist/plugin-sdk/web-content-extractor.d.ts",
"default": "./dist/plugin-sdk/web-content-extractor.js"
},
"./plugin-sdk/provider-web-fetch-contract": {
"types": "./dist/plugin-sdk/provider-web-fetch-contract.d.ts",
"default": "./dist/plugin-sdk/provider-web-fetch-contract.js"

View File

@@ -65,7 +65,7 @@ steps:
expr: "buildAgentSessionKey({ agentId: env.cfg.agents?.list?.find((agent) => agent.default)?.id ?? env.cfg.agents?.list?.[0]?.id ?? 'main', channel: 'qa-channel', accountId: 'default', peer: { kind: 'direct', id: config.conversationId }, dmScope: env.cfg.session?.dmScope, identityLinks: env.cfg.session?.identityLinks })"
- set: controlUiChatUrl
value:
expr: "(() => { const url = new URL(String(bootstrap.controlUiEmbeddedUrl)); url.pathname = `${url.pathname.replace(/\\/$/, '')}/chat`; url.searchParams.set('session', uiSessionKey); return url.toString(); })()"
expr: "(() => { const url = new URL(`${env.gateway.baseUrl}/`); url.searchParams.set('session', uiSessionKey); url.hash = `token=${encodeURIComponent(env.gateway.token ?? '')}`; return url.toString(); })()"
- call: webOpenPage
saveAs: uiTab
args:
@@ -80,17 +80,38 @@ steps:
args:
- pageId:
ref: uiPageId
selector: textarea
selector: openclaw-app
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- call: waitForCondition
saveAs: uiReadySnapshot
args:
- lambda:
async: true
expr: "await (async () => { const snapshot = await webSnapshot({ pageId: uiPageId, maxChars: 12000, timeoutMs: liveTurnTimeoutMs(env, 30000) }); const text = normalizeLowercaseStringOrEmpty(snapshot.text); return text.includes('ready to chat') ? snapshot : undefined; })()"
- expr: liveTurnTimeoutMs(env, 45000)
- 500
- try:
actions:
- call: waitForCondition
saveAs: uiReadySnapshot
args:
- lambda:
async: true
expr: "await (async () => { const snapshot = await webSnapshot({ pageId: uiPageId, maxChars: 12000, timeoutMs: liveTurnTimeoutMs(env, 30000) }); const text = normalizeLowercaseStringOrEmpty(snapshot.text); return text.includes('ready to chat') ? snapshot : undefined; })()"
- expr: liveTurnTimeoutMs(env, 45000)
- 500
catch:
- call: webSnapshot
saveAs: uiReadyFailureSnapshot
args:
- pageId:
ref: uiPageId
maxChars: 12000
timeoutMs:
expr: liveTurnTimeoutMs(env, 15000)
- call: webEvaluate
saveAs: uiReadyFailureState
args:
- pageId:
ref: uiPageId
expression: "(() => { const app = document.querySelector('openclaw-app'); const resources = performance.getEntriesByType('resource').map((entry) => ({ name: entry.name, type: entry.initiatorType, duration: Math.round(entry.duration), transferSize: entry.transferSize, decodedBodySize: entry.decodedBodySize })); return { url: location.href, readyState: document.readyState, appDefined: Boolean(customElements.get('openclaw-app')), appState: app ? { sessionKey: app.sessionKey, settingsSessionKey: app.settings?.sessionKey, lastActiveSessionKey: app.settings?.lastActiveSessionKey, chatMessages: Array.isArray(app.chatMessages) ? app.chatMessages.length : null, chatLoading: app.chatLoading, lastError: app.lastError, connected: app.connected, tab: app.tab } : null, scripts: Array.from(document.scripts).map((script) => script.src || script.textContent?.slice(0, 80)), links: Array.from(document.querySelectorAll('link')).map((link) => link.href), resources, bodyHtml: document.body.innerHTML.slice(0, 400) }; })()"
timeoutMs:
expr: liveTurnTimeoutMs(env, 15000)
- throw:
expr: "`control ui did not become ready. state=${JSON.stringify(uiReadyFailureState)} diagnostics=${JSON.stringify(uiReadyFailureSnapshot.diagnostics ?? [])} snapshot: ${uiReadyFailureSnapshot.text}`"
- assert:
expr: "Boolean(uiPageId)"
message: control ui page was not available
@@ -149,7 +170,7 @@ steps:
args:
- pageId:
ref: uiAckPageId
selector: textarea
selector: openclaw-app
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- try:
@@ -240,7 +261,7 @@ steps:
args:
- pageId:
ref: uiImagePageId
selector: textarea
selector: openclaw-app
timeoutMs:
expr: liveTurnTimeoutMs(env, 45000)
- try:

View File

@@ -718,7 +718,17 @@ function Invoke-OpenClawUpdateWithTimeout {
$updateJob = Start-Job -ScriptBlock {
param([string]$Path, [string]$Target)
$output = & $Path update --tag $Target --yes --json *>&1
$previousDisableBundledPlugins = $env:OPENCLAW_DISABLE_BUNDLED_PLUGINS
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'
try {
$output = & $Path update --tag $Target --yes --json *>&1
} finally {
if ($null -eq $previousDisableBundledPlugins) {
Remove-Item Env:OPENCLAW_DISABLE_BUNDLED_PLUGINS -ErrorAction SilentlyContinue
} else {
$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = $previousDisableBundledPlugins
}
}
[pscustomobject]@{
ExitCode = $LASTEXITCODE
Output = ($output | Out-String).Trim()
@@ -1649,7 +1659,7 @@ stop_openclaw_gateway_processes() {
# host can observe new plugin metadata mid-update and abort config validation.
scrub_future_plugin_entries
stop_openclaw_gateway_processes
/opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update --tag "$update_target" --yes --json
# Same-guest npm upgrades can leave the old gateway process holding the old
# bundled plugin host version. Stop it before post-update config commands.
stop_openclaw_gateway_processes
@@ -1782,7 +1792,7 @@ stop_openclaw_gateway_processes() {
# the old host can observe new plugin metadata mid-update and abort validation.
scrub_future_plugin_entries
stop_openclaw_gateway_processes
openclaw update --tag "$update_target" --yes --json
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag "$update_target" --yes --json
# The fresh Linux lane starts a manual gateway; stop the old process before
# post-update config validation sees mixed old-host/new-plugin metadata.
stop_openclaw_gateway_processes

View File

@@ -5,6 +5,7 @@ import {
existsSync,
mkdtempSync,
mkdirSync,
realpathSync,
readdirSync,
readFileSync,
rmSync,
@@ -17,6 +18,10 @@ import {
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
writePackageDistInventory,
} from "../src/infra/package-dist-inventory.ts";
import {
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDependencyPackageInstallRoot,
} from "../src/plugins/bundled-runtime-deps.ts";
import {
collectBundledExtensionManifestErrors,
type BundledExtension,
@@ -317,28 +322,48 @@ function bundledRuntimeDependencySentinelPath(
);
}
function bundledRuntimeDependencySentinelCandidates(
export function bundledRuntimeDependencySentinelCandidates(
packageRoot: string,
pluginId: string,
dependencyName: string,
env: NodeJS.ProcessEnv = process.env,
): string[] {
const dependencyParts = dependencyName.split("/");
const packageRoots = [
packageRoot,
(() => {
try {
return realpathSync(packageRoot);
} catch {
return packageRoot;
}
})(),
];
const runtimeRoots = packageRoots.flatMap((root) => [
resolveBundledRuntimeDependencyPackageInstallRoot(root, { env }),
resolveBundledRuntimeDependencyInstallRoot(join(root, "dist", "extensions", pluginId), {
env,
}),
]);
return [
bundledRuntimeDependencySentinelPath(packageRoot, pluginId, dependencyName),
join(packageRoot, "dist", "extensions", "node_modules", ...dependencyParts, "package.json"),
join(packageRoot, "node_modules", ...dependencyParts, "package.json"),
];
...runtimeRoots.map((root) => join(root, "node_modules", ...dependencyParts, "package.json")),
].filter((candidate, index, candidates) => candidates.indexOf(candidate) === index);
}
function assertBundledRuntimeDependencyAbsent(params: {
packageRoot: string;
pluginId: string;
dependencyName: string;
env?: NodeJS.ProcessEnv;
}): void {
const sentinelPath = bundledRuntimeDependencySentinelCandidates(
params.packageRoot,
params.pluginId,
params.dependencyName,
params.env,
).find((candidate) => existsSync(candidate));
if (sentinelPath) {
throw new Error(
@@ -351,11 +376,13 @@ function assertBundledRuntimeDependencyPresent(params: {
packageRoot: string;
pluginId: string;
dependencyName: string;
env?: NodeJS.ProcessEnv;
}): void {
const sentinelPath = bundledRuntimeDependencySentinelCandidates(
params.packageRoot,
params.pluginId,
params.dependencyName,
params.env,
).find((candidate) => existsSync(candidate));
if (sentinelPath) {
return;
@@ -413,24 +440,25 @@ function runPackedBundledPluginActivationSmoke(packageRoot: string, tmpRoot: str
{ pluginId: "feishu", dependencyName: "@larksuiteoapi/node-sdk" },
] as const;
for (const dep of lazyDeps) {
assertBundledRuntimeDependencyAbsent({ packageRoot, ...dep });
}
const homeDir = join(tmpRoot, "activation-home");
mkdirSync(homeDir, { recursive: true });
const env = createPackedCliSmokeEnv(process.env, {
HOME: homeDir,
OPENAI_API_KEY: "sk-openclaw-release-check",
});
for (const dep of lazyDeps) {
assertBundledRuntimeDependencyAbsent({ packageRoot, env, ...dep });
}
writePackedBundledPluginActivationConfig(homeDir);
execFileSync(process.execPath, [join(packageRoot, "openclaw.mjs"), "plugins", "doctor"], {
cwd: packageRoot,
stdio: "inherit",
env: createPackedCliSmokeEnv(process.env, {
HOME: homeDir,
OPENAI_API_KEY: "sk-openclaw-release-check",
}),
env,
});
for (const dep of lazyDeps) {
assertBundledRuntimeDependencyPresent({ packageRoot, ...dep });
assertBundledRuntimeDependencyPresent({ packageRoot, env, ...dep });
}
}

View File

@@ -40,12 +40,42 @@ export function resolveVitestNoOutputTimeoutMs(env = process.env) {
export function resolveVitestSpawnParams(env = process.env, platform = process.platform) {
return {
env,
env: resolveVitestSpawnEnv(env),
detached: shouldUseDetachedVitestProcessGroup(platform),
stdio: ["inherit", "pipe", "pipe"],
};
}
export function resolveVitestSpawnEnv(env = process.env) {
if (!shouldApplyNativeWorkerBudget(env)) {
return env;
}
const nativeWorkerCount = String(resolveNativeWorkerCount(env));
return {
...env,
RAYON_NUM_THREADS: env.RAYON_NUM_THREADS?.trim() || nativeWorkerCount,
TOKIO_WORKER_THREADS: env.TOKIO_WORKER_THREADS?.trim() || nativeWorkerCount,
};
}
function shouldApplyNativeWorkerBudget(env) {
if (env.RAYON_NUM_THREADS?.trim() && env.TOKIO_WORKER_THREADS?.trim()) {
return false;
}
return (
env.OPENCLAW_TEST_PROJECTS_SERIAL === "1" || resolveExplicitVitestWorkerBudget(env) !== null
);
}
function resolveNativeWorkerCount(env) {
return Math.min(resolveExplicitVitestWorkerBudget(env) ?? 1, 4);
}
function resolveExplicitVitestWorkerBudget(env) {
return parsePositiveInt(env.OPENCLAW_VITEST_MAX_WORKERS ?? env.OPENCLAW_TEST_WORKERS);
}
export function shouldSuppressVitestStderrLine(line) {
return SUPPRESSED_VITEST_STDERR_PATTERNS.some((pattern) => line.includes(pattern));
}

View File

@@ -230,7 +230,11 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]],
[
"scripts/run-vitest.mjs",
["test/scripts/test-projects.test.ts", "test/scripts/vitest-local-scheduling.test.ts"],
[
"test/scripts/run-vitest.test.ts",
"test/scripts/test-projects.test.ts",
"test/scripts/vitest-local-scheduling.test.ts",
],
],
["scripts/test-extension-batch.mjs", ["test/scripts/test-extension.test.ts"]],
["scripts/lib/extension-test-plan.mjs", ["test/scripts/test-extension.test.ts"]],

View File

@@ -1,9 +1,9 @@
const MAX_SAFE_TIMEOUT_MS = 2_147_483_647;
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
export function clampRuntimeAuthRefreshDelayMs(params: {
refreshAt: number;
now: number;
minDelayMs: number;
}): number {
return Math.min(MAX_SAFE_TIMEOUT_MS, Math.max(params.minDelayMs, params.refreshAt - params.now));
return resolveSafeTimeoutDelayMs(params.refreshAt - params.now, { minMs: params.minDelayMs });
}

View File

@@ -92,7 +92,7 @@ describe("channel plugin module loader helpers", () => {
expect(createJiti).not.toHaveBeenCalled();
});
it("keeps Windows dist loads off Jiti native import", async () => {
it("uses native Jiti import for Windows dist loads", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ ok: true })));
vi.doMock("jiti", () => ({
createJiti,
@@ -119,7 +119,7 @@ describe("channel plugin module loader helpers", () => {
expect(createJiti).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
} finally {

View File

@@ -27959,6 +27959,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
tags: ["advanced", "url-secret"],
},
},
version: "2026.4.24",
version: "2026.4.24-beta.5",
generatedAt: "2026-03-22T21:17:33.302Z",
};

View File

@@ -15,6 +15,7 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../utils/message-channel.js";
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { VERSION } from "../version.js";
import { GatewayClient, type GatewayClientOptions } from "./client.js";
import {
@@ -289,7 +290,7 @@ function resolveGatewayCallTimeout(timeoutValue: unknown): {
} {
const timeoutMs =
typeof timeoutValue === "number" && Number.isFinite(timeoutValue) ? timeoutValue : 10_000;
const safeTimerTimeoutMs = Math.max(1, Math.min(Math.floor(timeoutMs), 2_147_483_647));
const safeTimerTimeoutMs = resolveSafeTimeoutDelayMs(timeoutMs);
return { timeoutMs, safeTimerTimeoutMs };
}

View File

@@ -24,6 +24,7 @@ import {
type GatewayClientMode,
type GatewayClientName,
} from "../utils/message-channel.js";
import { resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { VERSION } from "../version.js";
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { resolveConnectChallengeTimeoutMs } from "./handshake-timeouts.js";
@@ -210,7 +211,7 @@ export class GatewayClient {
};
this.requestTimeoutMs =
typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs)
? Math.max(1, Math.min(Math.floor(opts.requestTimeoutMs), 2_147_483_647))
? resolveSafeTimeoutDelayMs(opts.requestTimeoutMs)
: 30_000;
}
@@ -931,7 +932,7 @@ export class GatewayClient {
opts?.timeoutMs === null
? null
: typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.min(Math.floor(opts.timeoutMs), 2_147_483_647))
? resolveSafeTimeoutDelayMs(opts.timeoutMs)
: expectFinal
? null
: this.requestTimeoutMs;

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import { formatErrorMessage } from "../infra/errors.js";
import type { SystemPresence } from "../infra/system-presence.js";
import { MAX_SAFE_TIMEOUT_DELAY_MS, resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { GatewayClient, GatewayClientRequestError } from "./client.js";
import { READ_SCOPE } from "./method-scopes.js";
import { isLoopbackHost } from "./net.js";
@@ -46,14 +47,14 @@ export type GatewayProbeResult = {
};
export const MIN_PROBE_TIMEOUT_MS = 250;
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
export const MAX_TIMER_DELAY_MS = MAX_SAFE_TIMEOUT_DELAY_MS;
const PAIRING_REQUIRED_PATTERN = /\bpairing required\b/i;
const OPERATOR_READ_SCOPE = "operator.read";
const OPERATOR_WRITE_SCOPE = "operator.write";
const OPERATOR_ADMIN_SCOPE = "operator.admin";
export function clampProbeTimeoutMs(timeoutMs: number): number {
return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs));
return resolveSafeTimeoutDelayMs(timeoutMs, { minMs: MIN_PROBE_TIMEOUT_MS });
}
function formatProbeCloseError(close: GatewayProbeClose): string {

View File

@@ -10,6 +10,7 @@ import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-event
import { detectErrorKind, type ErrorKind } from "../infra/errors.js";
import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js";
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
import { setSafeTimeout } from "../utils/timer-delay.js";
import {
isSuppressedControlReplyLeadFragment,
isSuppressedControlReplyText,
@@ -671,11 +672,10 @@ export function createAgentEventHandler({
opts?: { skipChatErrorFinal?: boolean },
) => {
clearPendingTerminalLifecycleError(evt.runId);
const delayMs = Math.max(1, Math.min(Math.floor(lifecycleErrorRetryGraceMs), 2_147_483_647));
const timer = setTimeout(() => {
const timer = setSafeTimeout(() => {
pendingTerminalLifecycleErrors.delete(evt.runId);
finalizeLifecycleEvent(evt, opts);
}, delayMs);
}, lifecycleErrorRetryGraceMs);
timer.unref?.();
pendingTerminalLifecycleErrors.set(evt.runId, timer);
};

View File

@@ -1,4 +1,5 @@
import { onAgentEvent } from "../../infra/agent-events.js";
import { setSafeTimeout } from "../../utils/timer-delay.js";
const AGENT_RUN_CACHE_TTL_MS = 10 * 60_000;
/**
@@ -254,8 +255,7 @@ export async function waitForAgentJob(params: {
) => {
clearPendingErrorTimer();
clearPendingTimeoutTimer();
const effectiveDelay = Math.max(1, Math.min(Math.floor(delayMs), 2_147_483_647));
const timerRef = setTimeout(() => {
const timerRef = setSafeTimeout(() => {
const latest = ignoreCachedSnapshot ? undefined : getCachedAgentRun(runId);
if (latest) {
finish(latest);
@@ -263,7 +263,7 @@ export async function waitForAgentJob(params: {
}
recordAgentRunSnapshot(snapshot);
finish(snapshot);
}, effectiveDelay);
}, delayMs);
timerRef.unref?.();
if (kind === "error") {
pendingErrorTimer = timerRef;
@@ -335,8 +335,7 @@ export async function waitForAgentJob(params: {
finish(snapshot);
});
const timerDelayMs = Math.max(1, Math.min(Math.floor(timeoutMs), 2_147_483_647));
const timer = setTimeout(() => finish(null), timerDelayMs);
const timer = setSafeTimeout(() => finish(null), timeoutMs);
onAbort = () => finish(null);
signal?.addEventListener("abort", onAbort, { once: true });
});

View File

@@ -1,3 +1,4 @@
import { setSafeTimeout } from "../../utils/timer-delay.js";
import type { DedupeEntry } from "../server-shared.js";
export type AgentWaitTerminalSnapshot = {
@@ -194,8 +195,7 @@ export async function waitForTerminalGatewayDedupe(params: {
return;
}
const timeoutDelayMs = Math.max(1, Math.min(Math.floor(params.timeoutMs), 2_147_483_647));
timeoutHandle = setTimeout(() => finish(null), timeoutDelayMs);
timeoutHandle = setSafeTimeout(() => finish(null), params.timeoutMs);
timeoutHandle.unref?.();
onAbort = () => finish(null);

View File

@@ -346,6 +346,24 @@ describe("startHeartbeatRunner", () => {
runner.stop();
});
it("clamps oversized scheduler delays so heartbeats do not fire in a tight loop (#71414)", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });
// 365d resolves to ~31_536_000_000 ms, well past Node setTimeout's
// 2_147_483_647 ms cap. Without clamping, setTimeout would fire after
// 1ms and re-arm in a tight loop, exhausting the runner.
const runner = startHeartbeatRunner({
cfg: heartbeatConfig([{ id: "main", heartbeat: { every: "365d" } }]),
runOnce: runSpy,
stableSchedulerSeed: TEST_SCHEDULER_SEED,
});
// Advance well past the broken 1ms re-arm but well under the clamped cap
// (~24.85d). If the bug is present, runSpy gets called many times.
await vi.advanceTimersByTimeAsync(60_000);
expect(runSpy).not.toHaveBeenCalled();
runner.stop();
});
it("does not fan out to unrelated agents for session-scoped exec wakes", async () => {
useFakeHeartbeatTime();
const runSpy = vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 });

View File

@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
function createHeartbeatConfig(every: string): OpenClawConfig {
return {
agents: {
defaults: { heartbeat: { every } },
list: [{ id: "main", heartbeat: { every } }],
},
} as OpenClawConfig;
}
describe("startHeartbeatRunner timeout overflow warnings", () => {
afterEach(() => {
vi.useRealTimers();
vi.resetModules();
vi.restoreAllMocks();
});
it("warns once per runner lifetime when clamping an oversized scheduler delay", async () => {
const warn = vi.fn();
const noop = vi.fn();
const logger = {
subsystem: "gateway/heartbeat",
isEnabled: vi.fn(() => true),
trace: noop,
debug: noop,
info: noop,
warn,
error: noop,
fatal: noop,
raw: noop,
child: vi.fn(() => logger),
};
vi.doMock("../logging/subsystem.js", async () => {
const actual =
await vi.importActual<typeof import("../logging/subsystem.js")>("../logging/subsystem.js");
return {
...actual,
createSubsystemLogger: vi.fn(() => logger),
};
});
const [{ startHeartbeatRunner }, { resetHeartbeatWakeStateForTests }] = await Promise.all([
import("./heartbeat-runner.js"),
import("./heartbeat-wake.js"),
]);
vi.useFakeTimers();
vi.setSystemTime(new Date(0));
const cfg = createHeartbeatConfig("365d");
const runnerA = startHeartbeatRunner({
cfg,
runOnce: vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }),
stableSchedulerSeed: "seed-0",
});
const runnerB = startHeartbeatRunner({
cfg,
runOnce: vi.fn().mockResolvedValue({ status: "ran", durationMs: 1 }),
stableSchedulerSeed: "seed-0",
});
expect(warn).toHaveBeenCalledTimes(2);
runnerA.stop();
runnerB.stop();
resetHeartbeatWakeStateForTests();
});
});

View File

@@ -64,6 +64,7 @@ import {
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { escapeRegExp } from "../utils.js";
import { MAX_SAFE_TIMEOUT_DELAY_MS, resolveSafeTimeoutDelayMs } from "../utils/timer-delay.js";
import { loadOrCreateDeviceIdentity } from "./device-identity.js";
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
@@ -1330,6 +1331,7 @@ export function startHeartbeatRunner(opts: {
stopped: false,
};
let initialized = false;
let heartbeatTimeoutOverflowWarned = false;
const resolveNextDue = (
now: number,
@@ -1384,7 +1386,15 @@ export function startHeartbeatRunner(opts: {
if (!Number.isFinite(nextDue)) {
return;
}
const delay = Math.max(0, nextDue - now);
const rawDelay = Math.max(0, nextDue - now);
if (rawDelay > MAX_SAFE_TIMEOUT_DELAY_MS && !heartbeatTimeoutOverflowWarned) {
heartbeatTimeoutOverflowWarned = true;
log.warn("heartbeat: scheduled delay exceeds Node setTimeout cap; clamping to ~24.85d", {
rawDelayMs: rawDelay,
clampedMs: MAX_SAFE_TIMEOUT_DELAY_MS,
});
}
const delay = resolveSafeTimeoutDelayMs(rawDelay, { minMs: 0 });
state.timer = setTimeout(() => {
state.timer = null;
requestHeartbeatNow({ reason: "interval", coalesceMs: 0 });

View File

@@ -108,7 +108,7 @@ describe("plugin-sdk facade loader", () => {
expect(listImportedFacadeRuntimeIds()).toEqual(["demo"]);
});
it("keeps Windows dist facade loads off Jiti native import", () => {
it("uses native Jiti import for Windows dist facade loads", () => {
const dir = createTempDirSync("openclaw-facade-loader-windows-dist-");
const bundledPluginsDir = path.join(dir, "dist");
fs.mkdirSync(path.join(bundledPluginsDir, "demo"), { recursive: true });
@@ -139,7 +139,7 @@ describe("plugin-sdk facade loader", () => {
expect(createJitiCalls[0]?.[0]).toEqual(expect.any(String));
expect(createJitiCalls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
} finally {

View File

@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { Module } from "node:module";
import os from "node:os";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
@@ -55,6 +56,8 @@ const BUNDLED_RUNTIME_DEPS_LOCK_WAIT_MS = 100;
const BUNDLED_RUNTIME_DEPS_LOCK_TIMEOUT_MS = 5 * 60_000;
const BUNDLED_RUNTIME_DEPS_LOCK_STALE_MS = 10 * 60_000;
const registeredBundledRuntimeDepNodePaths = new Set<string>();
export type BundledRuntimeDepsNpmRunner = {
command: string;
args: string[];
@@ -324,6 +327,43 @@ function resolveBundledPluginPackageRoot(pluginRoot: string): string | null {
return path.dirname(buildDir);
}
export function resolveBundledRuntimeDependencyPackageRoot(pluginRoot: string): string | null {
return resolveBundledPluginPackageRoot(pluginRoot);
}
export function registerBundledRuntimeDependencyNodePath(rootDir: string): void {
const nodeModulesDir = path.join(rootDir, "node_modules");
if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) {
return;
}
const currentPaths = (process.env.NODE_PATH ?? "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
process.env.NODE_PATH = [
nodeModulesDir,
...currentPaths.filter((entry) => entry !== nodeModulesDir),
].join(path.delimiter);
(Module as unknown as { _initPaths?: () => void })._initPaths?.();
registeredBundledRuntimeDepNodePaths.add(nodeModulesDir);
}
export function clearBundledRuntimeDependencyNodePaths(): void {
if (registeredBundledRuntimeDepNodePaths.size === 0) {
return;
}
const retainedPaths = (process.env.NODE_PATH ?? "")
.split(path.delimiter)
.filter((entry) => entry.length > 0 && !registeredBundledRuntimeDepNodePaths.has(entry));
if (retainedPaths.length > 0) {
process.env.NODE_PATH = retainedPaths.join(path.delimiter);
} else {
delete process.env.NODE_PATH;
}
registeredBundledRuntimeDepNodePaths.clear();
(Module as unknown as { _initPaths?: () => void })._initPaths?.();
}
function isPackagedBundledPluginRoot(pluginRoot: string): boolean {
const packageRoot = resolveBundledPluginPackageRoot(pluginRoot);
return Boolean(packageRoot && !isSourceCheckoutRoot(packageRoot));

View File

@@ -3,6 +3,8 @@ import path from "node:path";
import {
ensureBundledPluginRuntimeDeps,
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDependencyPackageRoot,
registerBundledRuntimeDependencyNodePath,
} from "./bundled-runtime-deps.js";
const bundledRuntimeDepsRetainSpecsByInstallRoot = new Map<string, readonly string[]>();
@@ -44,6 +46,11 @@ export function prepareBundledPluginRuntimeRoot(params: {
if (path.resolve(installRoot) === path.resolve(params.pluginRoot)) {
return { pluginRoot: params.pluginRoot, modulePath: params.modulePath };
}
const packageRoot = resolveBundledRuntimeDependencyPackageRoot(params.pluginRoot);
if (packageRoot) {
registerBundledRuntimeDependencyNodePath(packageRoot);
}
registerBundledRuntimeDependencyNodePath(installRoot);
const mirrorRoot = mirrorBundledPluginRuntimeRoot({
pluginId: params.pluginId,
pluginRoot: params.pluginRoot,
@@ -115,6 +122,7 @@ function prepareBundledPluginRuntimeDistMirror(params: {
const mirrorDistRoot = path.join(params.installRoot, "dist");
const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions");
fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 });
ensureBundledRuntimeDistPackageJson(mirrorDistRoot);
for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) {
if (entry.name === "extensions") {
continue;
@@ -138,6 +146,14 @@ function prepareBundledPluginRuntimeDistMirror(params: {
return mirrorExtensionsRoot;
}
function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void {
const packageJsonPath = path.join(mirrorDistRoot, "package.json");
if (fs.existsSync(packageJsonPath)) {
return;
}
writeRuntimeJsonFile(packageJsonPath, { type: "module" });
}
function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void {
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 });
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {

View File

@@ -128,6 +128,7 @@ const BROWSER_HELPER_EXPORT_PARITY_CONTRACTS: readonly BrowserHelperExportParity
extensionPath: "extensions/browser/browser-profiles.ts",
expectedExports: [
"DEFAULT_AI_SNAPSHOT_MAX_CHARS",
"DEFAULT_BROWSER_ACTION_TIMEOUT_MS",
"DEFAULT_BROWSER_DEFAULT_PROFILE_NAME",
"DEFAULT_BROWSER_EVALUATE_ENABLED",
"DEFAULT_OPENCLAW_BROWSER_COLOR",

View File

@@ -34,7 +34,7 @@ describe("doctor-contract-registry getJiti", () => {
clearPluginDoctorContractRegistryCache();
});
it("disables native jiti loading on Windows for contract-api modules", () => {
it("uses native jiti loading on Windows for contract-api modules", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "contract-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
@@ -56,7 +56,7 @@ describe("doctor-contract-registry getJiti", () => {
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "contract-api.js"));
expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
});

View File

@@ -1504,6 +1504,212 @@ module.exports = {
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads bundled plugins from symlinked package roots with an external stage dir", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
const aliasRoot = path.join(makeTempDir(), "openclaw-alias");
const bundledDir = path.join(packageRoot, "dist", "extensions");
const plugin = writePlugin({
id: "alpha",
dir: path.join(bundledDir, "alpha"),
filename: "index.cjs",
body: `module.exports = { id: "alpha", register(api) { api.registerCommand({ name: "alpha", handler: () => "ok" }); } };`,
});
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.24", type: "module" }),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "package.json"),
JSON.stringify(
{
name: "@openclaw/alpha",
version: "1.0.0",
openclaw: { extensions: ["./index.cjs"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(plugin.dir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "alpha",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
fs.symlinkSync(packageRoot, aliasRoot, "dir");
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(aliasRoot, "dist", "extensions");
process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir;
const registry = loadOpenClawPlugins({
cache: false,
config: { plugins: { enabled: true } },
});
expect(registry.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads copied external runtime mirrors with package-root runtime deps", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();
const bundledDir = path.join(packageRoot, "dist", "extensions");
const pluginRoot = path.join(bundledDir, "alpha");
const packageDepRoot = path.join(packageRoot, "node_modules", "root-support");
fs.mkdirSync(pluginRoot, { recursive: true });
fs.mkdirSync(packageDepRoot, { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: "openclaw",
version: "2026.4.24",
type: "module",
dependencies: { "root-support": "1.0.0" },
}),
"utf-8",
);
fs.writeFileSync(
path.join(packageDepRoot, "package.json"),
JSON.stringify({
name: "root-support",
version: "1.0.0",
type: "module",
exports: {
".": {
import: "./index.js",
},
"./oauth": {
import: "./oauth.js",
},
"./*": {
import: "./dist/*",
},
},
}),
"utf-8",
);
fs.mkdirSync(path.join(packageDepRoot, "dist", "client"), { recursive: true });
fs.writeFileSync(
path.join(packageDepRoot, "index.js"),
"export default { marker: 'root-ok' };\n",
"utf-8",
);
fs.writeFileSync(
path.join(packageDepRoot, "oauth.js"),
"export const oauthMarker = 'oauth-ok';\n",
"utf-8",
);
fs.writeFileSync(
path.join(packageDepRoot, "dist", "client", "index.js"),
"export const clientMarker = 'client-ok';\n",
"utf-8",
);
fs.writeFileSync(
path.join(packageRoot, "dist", "manifest-support.js"),
[
`import support from "root-support";`,
`import { oauthMarker } from "root-support/oauth";`,
`import { clientMarker } from "root-support/client/index.js";`,
`export const marker = [support.marker, oauthMarker, clientMarker].join(":");`,
"",
].join("\n"),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "index.js"),
[
`import { marker } from "../../manifest-support.js";`,
`import externalRuntime from "external-runtime";`,
`export default {`,
` id: "alpha",`,
` register(api) {`,
` api.registerCommand({ name: "root-support", handler: () => [marker, externalRuntime.marker].join(":") });`,
` },`,
`};`,
"",
].join("\n"),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "package.json"),
JSON.stringify(
{
name: "@openclaw/alpha",
version: "1.0.0",
type: "module",
dependencies: {
"external-runtime": "1.0.0",
},
openclaw: { extensions: ["./index.js"] },
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: "alpha",
enabledByDefault: true,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledDir;
process.env.OPENCLAW_PLUGIN_STAGE_DIR = stageDir;
const symlinkSync = vi.spyOn(fs, "symlinkSync").mockImplementation(() => {
throw Object.assign(new Error("symlinks unavailable"), { code: "EPERM" });
});
let registry: PluginRegistry | null = null;
try {
registry = loadOpenClawPlugins({
cache: false,
config: { plugins: { enabled: true } },
bundledRuntimeDepsInstaller: ({ installRoot }) => {
const depRoot = path.join(installRoot, "node_modules", "external-runtime");
fs.mkdirSync(depRoot, { recursive: true });
fs.writeFileSync(
path.join(depRoot, "package.json"),
JSON.stringify({
name: "external-runtime",
version: "1.0.0",
type: "module",
exports: {
".": {
import: "./index.js",
},
},
}),
"utf-8",
);
fs.writeFileSync(
path.join(depRoot, "index.js"),
"export default { marker: 'external-ok' };\n",
"utf-8",
);
},
});
} finally {
symlinkSync.mockRestore();
}
expect(registry?.plugins.find((entry) => entry.id === "alpha")?.status).toBe("loaded");
});
it("loads bundled plugins with plugin-sdk imports from an external stage dir", () => {
const packageRoot = makeTempDir();
const stageDir = makeTempDir();

View File

@@ -1,6 +1,5 @@
import { createHash } from "node:crypto";
import fs from "node:fs";
import { Module } from "node:module";
import path from "node:path";
import {
clearAgentHarnesses,
@@ -33,9 +32,12 @@ import { resolvePluginActivationSourceConfig } from "./activation-source-config.
import { buildPluginApi } from "./api-builder.js";
import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js";
import {
clearBundledRuntimeDependencyNodePaths,
ensureBundledPluginRuntimeDeps,
installBundledRuntimeDeps,
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDependencyPackageRoot,
registerBundledRuntimeDependencyNodePath,
type BundledRuntimeDepsInstallParams,
} from "./bundled-runtime-deps.js";
import {
@@ -117,6 +119,7 @@ import {
resolvePluginSdkAliasFile,
resolvePluginRuntimeModulePath,
resolvePluginSdkScopedAliasMap,
normalizeJitiAliasTargetPath,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
import { hasKind, kindsEqual } from "./slots.js";
@@ -254,6 +257,7 @@ export function clearPluginLoaderCache(): void {
inFlightPluginRegistryLoads.clear();
openAllowlistWarningCache.clear();
clearBundledRuntimeDependencyNodePaths();
bundledRuntimeDependencyJitiAliases.clear();
clearAgentHarnesses();
clearPluginCommands();
clearCompactionProviders();
@@ -453,16 +457,229 @@ function toSafeImportPath(specifier: string): string {
return specifier;
}
type RuntimeDependencyPackageJson = {
dependencies?: Record<string, unknown>;
optionalDependencies?: Record<string, unknown>;
peerDependencies?: Record<string, unknown>;
exports?: unknown;
module?: string;
main?: string;
};
const bundledRuntimeDependencyJitiAliases = new Map<string, string>();
function readRuntimeDependencyPackageJson(
packageJsonPath: string,
): RuntimeDependencyPackageJson | null {
try {
return JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as RuntimeDependencyPackageJson;
} catch {
return null;
}
}
function collectRuntimeDependencyNames(pkg: RuntimeDependencyPackageJson): string[] {
return [
...Object.keys(pkg.dependencies ?? {}),
...Object.keys(pkg.optionalDependencies ?? {}),
...Object.keys(pkg.peerDependencies ?? {}),
].toSorted((left, right) => left.localeCompare(right));
}
function resolveRuntimePackageImportTarget(exportsField: unknown): string | null {
if (typeof exportsField === "string") {
return exportsField;
}
if (Array.isArray(exportsField)) {
for (const entry of exportsField) {
const resolved = resolveRuntimePackageImportTarget(entry);
if (resolved) {
return resolved;
}
}
return null;
}
if (!exportsField || typeof exportsField !== "object" || Array.isArray(exportsField)) {
return null;
}
const record = exportsField as Record<string, unknown>;
if (Object.prototype.hasOwnProperty.call(record, ".")) {
return resolveRuntimePackageImportTarget(record["."]);
}
for (const condition of ["import", "node", "default"] as const) {
const resolved = resolveRuntimePackageImportTarget(record[condition]);
if (resolved) {
return resolved;
}
}
return null;
}
function collectRuntimePackageWildcardImportTargets(
dependencyRoot: string,
exportKey: string,
targetPattern: string,
): Map<string, string> {
const targets = new Map<string, string>();
const wildcardIndex = exportKey.indexOf("*");
const targetWildcardIndex = targetPattern.indexOf("*");
if (wildcardIndex === -1 || targetWildcardIndex === -1) {
return targets;
}
const exportPrefix = exportKey.slice(0, wildcardIndex);
const exportSuffix = exportKey.slice(wildcardIndex + 1);
const targetPrefix = targetPattern.slice(0, targetWildcardIndex);
const targetSuffix = targetPattern.slice(targetWildcardIndex + 1);
const targetBase = path.resolve(dependencyRoot, targetPrefix);
if (!isPathInside(dependencyRoot, targetBase) || !safeStatSync(targetBase)?.isDirectory()) {
return targets;
}
const stack = [targetBase];
while (stack.length > 0) {
const currentDir = stack.pop();
if (!currentDir) {
continue;
}
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const entryPath = path.join(currentDir, entry.name);
if (!isPathInside(dependencyRoot, entryPath)) {
continue;
}
if (entry.isDirectory()) {
stack.push(entryPath);
continue;
}
if (!entry.isFile()) {
continue;
}
const relativeTarget = path.relative(targetBase, entryPath).split(path.sep).join("/");
if (targetSuffix && !relativeTarget.endsWith(targetSuffix)) {
continue;
}
const wildcardValue = targetSuffix
? relativeTarget.slice(0, -targetSuffix.length)
: relativeTarget;
targets.set(`${exportPrefix}${wildcardValue}${exportSuffix}`, entryPath);
}
}
return targets;
}
function collectRuntimePackageImportTargets(
dependencyRoot: string,
pkg: RuntimeDependencyPackageJson,
): Map<string, string> {
const targets = new Map<string, string>();
const exportsField = pkg.exports;
if (
exportsField &&
typeof exportsField === "object" &&
!Array.isArray(exportsField) &&
Object.keys(exportsField).some((key) => key.startsWith("."))
) {
for (const [exportKey, exportValue] of Object.entries(exportsField)) {
if (!exportKey.startsWith(".")) {
continue;
}
const resolved = resolveRuntimePackageImportTarget(exportValue);
if (resolved) {
if (exportKey.includes("*")) {
for (const [wildcardExportKey, targetPath] of collectRuntimePackageWildcardImportTargets(
dependencyRoot,
exportKey,
resolved,
)) {
targets.set(wildcardExportKey, targetPath);
}
} else {
targets.set(exportKey, resolved);
}
}
}
return targets;
}
const rootEntry = resolveRuntimePackageImportTarget(exportsField) ?? pkg.module ?? pkg.main;
if (rootEntry) {
targets.set(".", rootEntry);
}
return targets;
}
function registerBundledRuntimeDependencyJitiAliases(rootDir: string): void {
const rootPackageJson = readRuntimeDependencyPackageJson(path.join(rootDir, "package.json"));
if (!rootPackageJson) {
return;
}
for (const dependencyName of collectRuntimeDependencyNames(rootPackageJson)) {
const dependencyPackageJsonPath = path.join(
rootDir,
"node_modules",
...dependencyName.split("/"),
"package.json",
);
const dependencyPackageJson = readRuntimeDependencyPackageJson(dependencyPackageJsonPath);
if (!dependencyPackageJson) {
continue;
}
const dependencyRoot = path.dirname(dependencyPackageJsonPath);
for (const [exportKey, entry] of collectRuntimePackageImportTargets(
dependencyRoot,
dependencyPackageJson,
)) {
if (!entry || entry.startsWith("#")) {
continue;
}
const targetPath = path.resolve(dependencyRoot, entry);
if (!isPathInside(dependencyRoot, targetPath) || !fs.existsSync(targetPath)) {
continue;
}
const aliasKey =
exportKey === "." ? dependencyName : `${dependencyName}${exportKey.slice(1)}`;
bundledRuntimeDependencyJitiAliases.set(aliasKey, normalizeJitiAliasTargetPath(targetPath));
}
}
}
function resolveBundledRuntimeDependencyJitiAliasMap(): Record<string, string> | undefined {
if (bundledRuntimeDependencyJitiAliases.size === 0) {
return undefined;
}
return Object.fromEntries(
[...bundledRuntimeDependencyJitiAliases.entries()].toSorted(
([left], [right]) => right.length - left.length || left.localeCompare(right),
),
);
}
function createPluginJitiLoader(options: Pick<PluginLoadOptions, "pluginSdkResolution">) {
const jitiLoaders: PluginJitiLoaderCache = new Map();
return (modulePath: string) => {
const tryNative =
shouldPreferNativeJiti(modulePath) && !isBundledRuntimeDependencyMirrorPath(modulePath);
const tryNative = shouldPreferNativeJiti(modulePath);
const runtimeAliasMap = resolveBundledRuntimeDependencyJitiAliasMap();
return getCachedPluginJitiLoader({
cache: jitiLoaders,
modulePath,
importerUrl: import.meta.url,
jitiFilename: modulePath,
...(runtimeAliasMap
? {
aliasMap: {
...buildPluginLoaderAliasMap(
modulePath,
process.argv[1],
import.meta.url,
options.pluginSdkResolution,
),
...runtimeAliasMap,
},
}
: {}),
pluginSdkResolution: options.pluginSdkResolution,
// Source .ts runtime shims import sibling ".js" specifiers that only exist
// after build. Disable native loading for source entries so Jiti rewrites
@@ -483,55 +700,6 @@ function resolveCanonicalDistRuntimeSource(source: string): string {
return fs.existsSync(candidate) ? candidate : source;
}
const registeredBundledRuntimeDepNodePaths = new Set<string>();
function isBundledRuntimeDependencyMirrorPath(modulePath: string): boolean {
const resolvedModulePath = path.resolve(modulePath);
for (const nodeModulesDir of registeredBundledRuntimeDepNodePaths) {
const installRoot = path.dirname(nodeModulesDir);
if (
resolvedModulePath === installRoot ||
resolvedModulePath.startsWith(`${installRoot}${path.sep}`)
) {
return true;
}
}
return false;
}
function registerBundledRuntimeDependencyNodePath(installRoot: string): void {
const nodeModulesDir = path.join(installRoot, "node_modules");
if (registeredBundledRuntimeDepNodePaths.has(nodeModulesDir) || !fs.existsSync(nodeModulesDir)) {
return;
}
const currentPaths = (process.env.NODE_PATH ?? "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
process.env.NODE_PATH = [
nodeModulesDir,
...currentPaths.filter((entry) => entry !== nodeModulesDir),
].join(path.delimiter);
(Module as unknown as { _initPaths?: () => void })._initPaths?.();
registeredBundledRuntimeDepNodePaths.add(nodeModulesDir);
}
function clearBundledRuntimeDependencyNodePaths(): void {
if (registeredBundledRuntimeDepNodePaths.size === 0) {
return;
}
const retainedPaths = (process.env.NODE_PATH ?? "")
.split(path.delimiter)
.filter((entry) => entry.length > 0 && !registeredBundledRuntimeDepNodePaths.has(entry));
if (retainedPaths.length > 0) {
process.env.NODE_PATH = retainedPaths.join(path.delimiter);
} else {
delete process.env.NODE_PATH;
}
registeredBundledRuntimeDepNodePaths.clear();
(Module as unknown as { _initPaths?: () => void })._initPaths?.();
}
function mirrorBundledPluginRuntimeRoot(params: {
pluginId: string;
pluginRoot: string;
@@ -577,6 +745,7 @@ function prepareBundledPluginRuntimeDistMirror(params: {
const mirrorDistRoot = path.join(params.installRoot, sourceDistRootName);
const mirrorExtensionsRoot = path.join(mirrorDistRoot, "extensions");
fs.mkdirSync(mirrorExtensionsRoot, { recursive: true, mode: 0o755 });
ensureBundledRuntimeDistPackageJson(mirrorDistRoot);
for (const entry of fs.readdirSync(sourceDistRoot, { withFileTypes: true })) {
if (entry.name === "extensions") {
continue;
@@ -618,6 +787,14 @@ function prepareBundledPluginRuntimeDistMirror(params: {
return mirrorExtensionsRoot;
}
function ensureBundledRuntimeDistPackageJson(mirrorDistRoot: string): void {
const packageJsonPath = path.join(mirrorDistRoot, "package.json");
if (fs.existsSync(packageJsonPath)) {
return;
}
writeRuntimeJsonFile(packageJsonPath, { type: "module" });
}
function copyBundledPluginRuntimeRoot(sourceRoot: string, targetRoot: string): void {
fs.mkdirSync(targetRoot, { recursive: true, mode: 0o755 });
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
@@ -2276,8 +2453,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
};
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
let runtimePluginRoot = pluginRoot;
let runtimeCandidateSource = candidate.source;
let runtimeSetupSource = manifestRecord.setupSource;
let runtimeCandidateSource =
candidate.origin === "bundled" ? safeRealpathOrResolve(candidate.source) : candidate.source;
let runtimeSetupSource =
candidate.origin === "bundled" && manifestRecord.setupSource
? safeRealpathOrResolve(manifestRecord.setupSource)
: manifestRecord.setupSource;
const scopedSetupOnlyChannelPluginRequested =
includeSetupOnlyChannelPlugins &&
@@ -2373,7 +2554,13 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
}
}
if (path.resolve(installRoot) !== path.resolve(pluginRoot)) {
const packageRoot = resolveBundledRuntimeDependencyPackageRoot(pluginRoot);
if (packageRoot) {
registerBundledRuntimeDependencyNodePath(packageRoot);
registerBundledRuntimeDependencyJitiAliases(packageRoot);
}
registerBundledRuntimeDependencyNodePath(installRoot);
registerBundledRuntimeDependencyJitiAliases(installRoot);
runtimePluginRoot = mirrorBundledPluginRuntimeRoot({
pluginId: record.id,
pluginRoot,
@@ -2381,12 +2568,12 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
});
runtimeCandidateSource =
remapBundledPluginRuntimePath({
source: candidate.source,
source: runtimeCandidateSource,
pluginRoot,
mirroredRoot: runtimePluginRoot,
}) ?? candidate.source;
}) ?? runtimeCandidateSource;
runtimeSetupSource = remapBundledPluginRuntimePath({
source: manifestRecord.setupSource,
source: runtimeSetupSource,
pluginRoot,
mirroredRoot: runtimePluginRoot,
});
@@ -3186,7 +3373,11 @@ export async function loadOpenClawPluginCliRegistry(
const pluginRoot = safeRealpathOrResolve(candidate.rootDir);
const cliMetadataSource = resolveCliMetadataEntrySource(candidate.rootDir);
const sourceForCliMetadata =
candidate.origin === "bundled" ? cliMetadataSource : (cliMetadataSource ?? candidate.source);
candidate.origin === "bundled"
? cliMetadataSource
? safeRealpathOrResolve(cliMetadataSource)
: null
: (cliMetadataSource ?? candidate.source);
if (!sourceForCliMetadata) {
record.status = "loaded";
registry.plugins.push(record);

View File

@@ -28,7 +28,7 @@ afterEach(() => {
});
describe("bundled plugin public surface loader", () => {
it("keeps Windows dist public artifact loads off Jiti native import", async () => {
it("uses native Jiti import for Windows dist public artifact loads", async () => {
const createJiti = vi.fn(() => vi.fn(() => ({ marker: "windows-dist-ok" })));
vi.doMock("jiti", () => ({
createJiti,
@@ -56,7 +56,7 @@ describe("bundled plugin public surface loader", () => {
expect(createJiti).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
} finally {

View File

@@ -870,7 +870,7 @@ describe("plugin sdk alias helpers", () => {
}
});
it("disables native Jiti loads on Windows even for built JavaScript entries", () => {
it("prefers native Jiti loads on Windows for built JavaScript entries", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -878,9 +878,9 @@ describe("plugin sdk alias helpers", () => {
});
try {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(false);
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(shouldPreferNativeJiti(`/repo/${bundledDistPluginFile("browser", "index.js")}`)).toBe(
false,
true,
);
} finally {
Object.defineProperty(process, "platform", {
@@ -890,7 +890,7 @@ describe("plugin sdk alias helpers", () => {
}
});
it("keeps plugin loader dist shortcuts off on Windows", () => {
it("keeps plugin loader dist shortcuts native on Windows", () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
configurable: true,
@@ -902,7 +902,7 @@ describe("plugin sdk alias helpers", () => {
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {
preferBuiltDist: true,
}),
).toBe(false);
).toBe(true);
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "helper.ts")}`, {
preferBuiltDist: true,
@@ -918,7 +918,7 @@ describe("plugin sdk alias helpers", () => {
it("prefers native jiti for bundled plugin dist .js modules, keeps .ts on aliased path", () => {
// Built .js/.mjs/.cjs files under dist/extensions/ should now delegate
// to shouldPreferNativeJiti() — which returns true on Linux/macOS for
// to shouldPreferNativeJiti() — which returns true on Node for
// compiled artifacts, avoiding the slow jiti transform path.
expect(
resolvePluginLoaderJitiTryNative(`/repo/${bundledDistPluginFile("browser", "index.js")}`, {

View File

@@ -695,7 +695,7 @@ export function buildPluginLoaderJitiOptions(aliasMap: Record<string, string>) {
function supportsNativeJitiRuntime(): boolean {
const versions = process.versions as { bun?: string };
return typeof versions.bun !== "string" && process.platform !== "win32";
return typeof versions.bun !== "string";
}
function isBundledPluginDistModulePath(modulePath: string): boolean {

View File

@@ -158,7 +158,7 @@ describe("setup-registry getJiti", () => {
clearPluginSetupRegistryCache();
});
it("disables native jiti loading on Windows for setup-api modules", () => {
it("uses native jiti loading on Windows for setup-api modules", () => {
const pluginRoot = makeTempDir();
fs.writeFileSync(path.join(pluginRoot, "setup-api.js"), "export default {};\n", "utf-8");
mocks.loadPluginManifestRegistry.mockReturnValue({
@@ -180,7 +180,7 @@ describe("setup-registry getJiti", () => {
expect(mocks.createJiti.mock.calls[0]?.[0]).toBe(path.join(pluginRoot, "setup-api.js"));
expect(mocks.createJiti.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
tryNative: false,
tryNative: true,
}),
);
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import {
MAX_SAFE_TIMEOUT_DELAY_MS,
resolveSafeTimeoutDelayMs,
setSafeTimeout,
} from "./timer-delay.js";
describe("resolveSafeTimeoutDelayMs", () => {
it("clamps to Node's signed-32-bit timer ceiling", () => {
expect(resolveSafeTimeoutDelayMs(3_000_000_000)).toBe(MAX_SAFE_TIMEOUT_DELAY_MS);
});
it("respects custom minimums", () => {
expect(resolveSafeTimeoutDelayMs(10, { minMs: 250 })).toBe(250);
expect(resolveSafeTimeoutDelayMs(10, { minMs: 0 })).toBe(10);
});
it("falls back to the minimum for non-finite input", () => {
expect(resolveSafeTimeoutDelayMs(Number.POSITIVE_INFINITY, { minMs: 250 })).toBe(250);
expect(resolveSafeTimeoutDelayMs(Number.NaN)).toBe(1);
});
});
describe("setSafeTimeout", () => {
it("arms setTimeout with the clamped delay", () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const timer = setSafeTimeout(() => undefined, 3_000_000_000);
clearTimeout(timer);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_SAFE_TIMEOUT_DELAY_MS);
timeoutSpy.mockRestore();
});
});

19
src/utils/timer-delay.ts Normal file
View File

@@ -0,0 +1,19 @@
export const MAX_SAFE_TIMEOUT_DELAY_MS = 2_147_483_647;
export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number {
const rawMinMs = opts?.minMs ?? 1;
const minMs = Math.min(
MAX_SAFE_TIMEOUT_DELAY_MS,
Math.max(0, Number.isFinite(rawMinMs) ? Math.floor(rawMinMs) : 1),
);
const candidateMs = Number.isFinite(delayMs) ? Math.floor(delayMs) : minMs;
return Math.min(MAX_SAFE_TIMEOUT_DELAY_MS, Math.max(minMs, candidateMs));
}
export function setSafeTimeout(
callback: () => void,
delayMs: number,
opts?: { minMs?: number },
): NodeJS.Timeout {
return setTimeout(callback, resolveSafeTimeoutDelayMs(delayMs, opts));
}

View File

@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
@@ -12,6 +12,7 @@ import {
collectBundledPluginRootRuntimeMirrorErrors,
collectForbiddenPackContentPaths,
collectInstalledBundledPluginRuntimeDepErrors,
bundledRuntimeDependencySentinelCandidates,
collectRootDistBundledRuntimeMirrors,
collectForbiddenPackPaths,
collectMissingPackPaths,
@@ -673,3 +674,36 @@ describe("collectInstalledBundledPluginRuntimeDepErrors", () => {
}
});
});
describe("bundledRuntimeDependencySentinelCandidates", () => {
it("checks canonical external runtime-deps roots for packed installs", () => {
const root = mkdtempSync(join(tmpdir(), "release-check-runtime-candidates-"));
const packageRoot = join(root, "package");
const aliasRoot = join(root, "package-alias");
const homeRoot = join(root, "home");
try {
mkdirSync(join(packageRoot, "dist", "extensions", "browser"), { recursive: true });
writeFileSync(
join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw", version: "2026.4.24-beta.1" }, null, 2),
);
symlinkSync(packageRoot, aliasRoot, "dir");
const candidates = bundledRuntimeDependencySentinelCandidates(
aliasRoot,
"browser",
"playwright-core",
{ HOME: homeRoot } as NodeJS.ProcessEnv,
);
const externalCandidates = candidates.filter(
(candidate) =>
candidate.startsWith(join(homeRoot, ".openclaw", "plugin-runtime-deps")) &&
candidate.endsWith(join("node_modules", "playwright-core", "package.json")),
);
expect(externalCandidates.length).toBeGreaterThanOrEqual(2);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});

View File

@@ -21,11 +21,11 @@ describe("parallels npm update smoke", () => {
expect(script).toContain("delete entries.whatsapp");
expect(script).toContain("Remove-FuturePluginEntries\n Stop-OpenClawGatewayProcesses");
expect(script).toContain("scrub_future_plugin_entries\nstop_openclaw_gateway_processes");
expect(script).not.toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'\n & $Path update");
expect(script).not.toContain(
expect(script).toContain("$env:OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'");
expect(script).toContain(
"OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw update",
);
expect(script).not.toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update");
expect(script).toContain("OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update");
expect(script).toContain(
"OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 /opt/homebrew/bin/openclaw gateway stop",
);

View File

@@ -58,6 +58,40 @@ describe("scripts/run-vitest", () => {
});
});
it("caps native Rust worker pools for serial Vitest runs", () => {
expect(
resolveVitestSpawnParams(
{
OPENCLAW_TEST_PROJECTS_SERIAL: "1",
PATH: "/usr/bin",
},
"darwin",
).env,
).toMatchObject({
OPENCLAW_TEST_PROJECTS_SERIAL: "1",
RAYON_NUM_THREADS: "1",
TOKIO_WORKER_THREADS: "1",
});
});
it("keeps explicit native Rust worker pool settings", () => {
expect(
resolveVitestSpawnParams(
{
OPENCLAW_VITEST_MAX_WORKERS: "2",
PATH: "/usr/bin",
RAYON_NUM_THREADS: "8",
TOKIO_WORKER_THREADS: "6",
},
"darwin",
).env,
).toMatchObject({
OPENCLAW_VITEST_MAX_WORKERS: "2",
RAYON_NUM_THREADS: "8",
TOKIO_WORKER_THREADS: "6",
});
});
it("suppresses rolldown plugin timing noise while keeping other stderr intact", () => {
expect(
shouldSuppressVitestStderrLine(

View File

@@ -1,10 +1,5 @@
import { html, nothing } from "lit";
import { applyMergePatch } from "../../../src/config/merge-patch.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../../src/routing/session-key.js";
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChat } from "./app-chat.ts";
@@ -120,9 +115,14 @@ import {
} from "./controllers/skills.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
import { icons } from "./icons.ts";
import "./components/dashboard-header.ts";
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
import "./components/dashboard-header.ts";
import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "./session-key.ts";
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
import { agentLogoUrl } from "./views/agents-utils.ts";
import {

View File

@@ -1,6 +1,5 @@
import { LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { resolveAgentIdFromSessionKey } from "../../../src/routing/session-key.js";
import { i18n, I18nController, isSupportedLocale } from "../i18n/index.ts";
import {
handleChannelConfigReload as handleChannelConfigReloadInternal,
@@ -80,6 +79,7 @@ import type {
import { importCustomThemeFromUrl } from "./custom-theme.ts";
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts";
import type { Tab } from "./navigation.ts";
import { resolveAgentIdFromSessionKey } from "./session-key.ts";
import type { SidebarContent } from "./sidebar-content.ts";
import { loadLocalUserIdentity, loadSettings, type UiSettings } from "./storage.ts";
import { VALID_THEME_NAMES, type ResolvedTheme, type ThemeMode, type ThemeName } from "./theme.ts";

View File

@@ -1,4 +1,3 @@
import { isHeartbeatOkResponse } from "../../../../src/auto-reply/heartbeat-filter.js";
import { resetToolStream } from "../app-tool-stream.ts";
import { extractText } from "../chat/message-extract.ts";
import { formatConnectError } from "../connect-error.ts";
@@ -12,6 +11,8 @@ import {
} from "./scope-errors.ts";
const SILENT_REPLY_PATTERN = /^\s*NO_REPLY\s*$/;
const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
const SYNTHETIC_TRANSCRIPT_REPAIR_RESULT =
"[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair.";
const STARTUP_CHAT_HISTORY_RETRY_TIMEOUT_MS = 60_000;
@@ -41,6 +42,97 @@ function shouldApplyChatHistoryResult(
function isSilentReplyStream(text: string): boolean {
return SILENT_REPLY_PATTERN.test(text);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function stripHeartbeatTokenForDisplay(
raw: string,
maxAckChars = DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
): { shouldSkip: boolean } {
let text = raw.trim();
if (!text) {
return { shouldSkip: true };
}
const strippedMarkup = text
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/^[*`~_]+/, "")
.replace(/[*`~_]+$/, "");
if (!text.includes(HEARTBEAT_TOKEN) && !strippedMarkup.includes(HEARTBEAT_TOKEN)) {
return { shouldSkip: false };
}
const tokenAtEnd = new RegExp(`${escapeRegExp(HEARTBEAT_TOKEN)}[^\\w]{0,4}$`);
let changed = true;
let didStrip = false;
text = strippedMarkup.trim();
while (changed) {
changed = false;
const next = text.trim();
if (next.startsWith(HEARTBEAT_TOKEN)) {
text = next.slice(HEARTBEAT_TOKEN.length).trimStart();
didStrip = true;
changed = true;
continue;
}
if (tokenAtEnd.test(next)) {
const index = next.lastIndexOf(HEARTBEAT_TOKEN);
const before = next.slice(0, index).trimEnd();
const after = next.slice(index + HEARTBEAT_TOKEN.length).trimStart();
text = before ? `${before}${after}`.trimEnd() : "";
didStrip = true;
changed = true;
}
}
if (!didStrip) {
return { shouldSkip: false };
}
return { shouldSkip: !text || text.length <= maxAckChars };
}
function isHeartbeatOkResponse(message: { role: string; content?: unknown }): boolean {
if (message.role !== "assistant") {
return false;
}
const { text, hasNonTextContent } = resolveMessageText(message.content);
if (hasNonTextContent) {
return false;
}
return stripHeartbeatTokenForDisplay(text).shouldSkip;
}
function resolveMessageText(content: unknown): { text: string; hasNonTextContent: boolean } {
if (typeof content === "string") {
return { text: content, hasNonTextContent: false };
}
if (!Array.isArray(content)) {
return { text: "", hasNonTextContent: content != null };
}
let hasNonTextContent = false;
const text = content
.filter((block): block is { type: "text"; text: string } => {
if (!block || typeof block !== "object" || !("type" in block)) {
hasNonTextContent = true;
return false;
}
if ((block as { type?: unknown }).type !== "text") {
hasNonTextContent = true;
return false;
}
if (typeof (block as { text?: unknown }).text !== "string") {
hasNonTextContent = true;
return false;
}
return true;
})
.map((block) => block.text)
.join("");
return { text, hasNonTextContent };
}
/** Client-side defense-in-depth: detect assistant messages whose text is purely NO_REPLY. */
function isAssistantSilentReply(message: unknown): boolean {
if (!message || typeof message !== "object") {