mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
13 Commits
codex/pr-8
...
v2026.4.24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6530be0f | ||
|
|
9119ee6d75 | ||
|
|
6dbce80a43 | ||
|
|
85839e5a13 | ||
|
|
1c98de9e66 | ||
|
|
3ee268cb4d | ||
|
|
9f6cda120d | ||
|
|
bd13188f79 | ||
|
|
c2f6ad3876 | ||
|
|
bca479cefb | ||
|
|
6d082070a2 | ||
|
|
86f65ef1e3 | ||
|
|
cdd91edd5e |
@@ -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
|
||||
|
||||
2934
CHANGELOG.md
2934
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
|
||||
beforeEach(async () => {
|
||||
const page = {
|
||||
on: vi.fn(),
|
||||
goto,
|
||||
title: pageTitle,
|
||||
url: pageUrl,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.4.24",
|
||||
"version": "2026.4.24-beta.3",
|
||||
"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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -27959,6 +27959,6 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
tags: ["advanced", "url-secret"],
|
||||
},
|
||||
},
|
||||
version: "2026.4.24",
|
||||
version: "2026.4.24-beta.3",
|
||||
generatedAt: "2026-03-22T21:17:33.302Z",
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
70
src/infra/heartbeat-runner.timeout-warning.test.ts
Normal file
70
src/infra/heartbeat-runner.timeout-warning.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 })) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1504,6 +1504,191 @@ 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",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageDepRoot, "index.js"),
|
||||
"export default { marker: 'root-ok' };\n",
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(packageRoot, "dist", "manifest-support.js"),
|
||||
[`import support from "root-support";`, `export const marker = support.marker;`, ""].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();
|
||||
|
||||
@@ -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,133 @@ 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 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 entry =
|
||||
resolveRuntimePackageImportTarget(dependencyPackageJson.exports) ??
|
||||
dependencyPackageJson.module ??
|
||||
dependencyPackageJson.main;
|
||||
if (!entry || entry.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
const dependencyRoot = path.dirname(dependencyPackageJsonPath);
|
||||
const targetPath = path.resolve(dependencyRoot, entry);
|
||||
if (!isPathInside(dependencyRoot, targetPath) || !fs.existsSync(targetPath)) {
|
||||
continue;
|
||||
}
|
||||
bundledRuntimeDependencyJitiAliases.set(
|
||||
dependencyName,
|
||||
normalizeJitiAliasTargetPath(targetPath),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBundledRuntimeDependencyJitiAliasMap(): Record<string, string> | undefined {
|
||||
if (bundledRuntimeDependencyJitiAliases.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
[...bundledRuntimeDependencyJitiAliases.entries()].toSorted(([left], [right]) =>
|
||||
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 +604,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 +649,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 +691,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 +2357,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 +2458,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 +2472,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 +3277,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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")}`, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
34
src/utils/timer-delay.test.ts
Normal file
34
src/utils/timer-delay.test.ts
Normal 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
19
src/utils/timer-delay.ts
Normal 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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(/ /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") {
|
||||
|
||||
Reference in New Issue
Block a user