diff --git a/CHANGELOG.md b/CHANGELOG.md index eb2f281d857..8ff65d82fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/loader: do not retry native-loaded JavaScript plugin modules through the source transformer after native evaluation has already reached a missing dependency, avoiding duplicate top-level side effects. Thanks @vincentkoc. +- Plugins/packages: reject blank `openclaw.runtimeExtensions` entries instead of silently ignoring them and falling back to inferred TypeScript runtime entries. Thanks @vincentkoc. - Doctor/plugins: remove stale managed npm plugin shadow entries from the managed package lock as well as `package.json` and `node_modules`, so future npm operations do not keep referencing repaired bundled-plugin shadows. Thanks @vincentkoc. - Plugins/runtime state: keep the key being registered when namespace eviction runs in the same millisecond as existing entries, so `register` and `registerIfAbsent` do not report success while evicting their own fresh value. Thanks @vincentkoc. - Control UI/Talk: make failed Talk startup errors dismissable and clear the stale Talk error state when dismissed, so missing realtime voice provider configuration does not leave a permanent chat banner. Fixes #77071. Thanks @ijoshdavis. @@ -86,6 +88,7 @@ Docs: https://docs.openclaw.ai - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. - iOS/mobile pairing: reject non-loopback `ws://` setup URLs before QR/setup-code issuance and let the iOS Gateway settings screen scan QR codes or paste full setup-code messages. Thanks @BunsDev. - Control UI: keep Gateway Access inputs and locale picker contained inside the card at narrow and tablet widths. +- Agents/trajectory: bound runtime trajectory capture and yield queued sidecar writes so oversized traces stop recording instead of monopolizing Gateway cleanup. Fixes #77124. Thanks @loyur. - Telegram/streaming: sanitize tool-progress draft preview backticks before shared compaction, so long backtick-heavy progress text still renders inside the safe code-formatted preview instead of collapsing to an ellipsis. - UI/chat: remove the unsupported `line-clamp` declaration from the chat queue text rule to eliminate Firefox console noise without changing visible truncation behavior. Thanks @ZanderH-code. - Agents/Pi: suppress persistence for synthetic mid-turn overflow continuation prompts, so transcript-retry recovery does not write the "continue from transcript" prompt as a new user turn. Thanks @vincentkoc. @@ -111,6 +114,7 @@ Docs: https://docs.openclaw.ai - Web search: keep first-class assistant `web_search` auto-detect and configured runtime providers visible when active runtime metadata or the active plugin registry is incomplete. Fixes #77073. Thanks @joeykrug. - Plugins/tools: mark manifest-optional sibling tools as optional even when they come from a shared non-optional factory, so cached/status/MCP metadata keeps opt-in tool policy accurate. Thanks @vincentkoc. - Matrix: keep `streaming.progress.toolProgress` scoped to progress draft mode, so partial and quiet Matrix previews do not lose tool progress unless `streaming.preview.toolProgress` is disabled. Thanks @vincentkoc. +- Gateway/validation: isolate gateway server validation files, ignore unrelated startup logs in request-trace coverage, and fail fast on stuck shared-auth sockets, reducing false main-branch CI failures for contributors. Thanks @amknight. - Channels/streaming: keep `streaming.progress.toolProgress` scoped to progress draft mode, so disabling compact progress lines does not silence partial/block preview tool updates. Thanks @vincentkoc. - Plugins/update: treat OpenClaw stable correction versions like `2026.5.3-1` as stable releases for npm installs, plugin updates, and bundled-version comparisons, so `latest` can advance official plugins without prerelease opt-in. Thanks @vincentkoc. - Control UI: point the Appearance tweakcn browse action and docs at the live tweakcn editor route instead of the removed `/themes` page. Fixes #77048. diff --git a/docs/tools/trajectory.md b/docs/tools/trajectory.md index 27699d9ee04..b6f8eb0598a 100644 --- a/docs/tools/trajectory.md +++ b/docs/tools/trajectory.md @@ -181,7 +181,7 @@ OpenClaw redacts sensitive values before writing export files: The exporter also bounds input size: -- runtime sidecar files: 50 MiB +- runtime sidecar files: live capture stops at 10 MiB and records a truncation event when space remains; export accepts existing runtime sidecars up to 50 MiB - session files: 50 MiB - runtime events: 200,000 - total exported events: 250,000 diff --git a/scripts/e2e/crestodian-planner-docker-client.ts b/scripts/e2e/crestodian-planner-docker-client.mjs similarity index 82% rename from scripts/e2e/crestodian-planner-docker-client.ts rename to scripts/e2e/crestodian-planner-docker-client.mjs index 5c604b24678..8acb6800ef0 100644 --- a/scripts/e2e/crestodian-planner-docker-client.ts +++ b/scripts/e2e/crestodian-planner-docker-client.mjs @@ -5,18 +5,20 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { clearConfigCache } from "../../dist/config/config.js"; -import type { OpenClawConfig } from "../../dist/config/types.openclaw.js"; import { runCrestodian } from "../../dist/crestodian/crestodian.js"; -import type { RuntimeEnv } from "../../dist/runtime.js"; -function assert(condition: unknown, message: string): asserts condition { +function assert(condition, message) { if (!condition) { throw new Error(message); } } -function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { - const lines: string[] = []; +function assertOutputIncludes(output, expected, message) { + assert(output.includes(expected), `${message}\n\nCaptured Crestodian output:\n${output}`); +} + +function createRuntime() { + const lines = []; return { lines, runtime: { @@ -29,7 +31,7 @@ function createRuntime(): { runtime: RuntimeEnv; lines: string[] } { }; } -async function installFakeClaudeCli(fakeBinDir: string, promptLogPath: string): Promise { +async function installFakeClaudeCli(fakeBinDir, promptLogPath) { await fs.mkdir(fakeBinDir, { recursive: true }); const scriptPath = path.join(fakeBinDir, "claude"); await fs.writeFile( @@ -75,20 +77,24 @@ async function main() { runtime.runtime, ); const output = runtime.lines.join("\n"); - assert( - output.includes("[crestodian] planner: claude-cli/claude-opus-4-7"), + assertOutputIncludes( + output, + "[crestodian] planner: claude-cli/claude-opus-4-7", "configless planner did not use Claude CLI fallback", ); - assert( - output.includes("Fake Claude planner selected a typed model update."), + assertOutputIncludes( + output, + "Fake Claude planner selected a typed model update.", "planner reply was not surfaced", ); - assert( - output.includes("[crestodian] interpreted: set default model openai/gpt-5.2"), + assertOutputIncludes( + output, + "[crestodian] interpreted: set default model openai/gpt-5.2", "planner command was not interpreted", ); - assert( - output.includes("[crestodian] done: config.setDefaultModel"), + assertOutputIncludes( + output, + "[crestodian] done: config.setDefaultModel", "planned model update did not apply", ); @@ -99,7 +105,7 @@ async function main() { "planner prompt did not include docs reference context", ); - const config = JSON.parse(await fs.readFile(configPath, "utf8")) as OpenClawConfig; + const config = JSON.parse(await fs.readFile(configPath, "utf8")); assert( config.agents?.defaults?.model && typeof config.agents.defaults.model === "object" && diff --git a/scripts/e2e/crestodian-planner-docker.sh b/scripts/e2e/crestodian-planner-docker.sh index c4093a0b2a4..d21f9a51c95 100755 --- a/scripts/e2e/crestodian-planner-docker.sh +++ b/scripts/e2e/crestodian-planner-docker.sh @@ -28,7 +28,7 @@ docker_e2e_run_with_harness \ bash -lc "set -euo pipefail source scripts/lib/openclaw-e2e-instance.sh openclaw_e2e_eval_test_state_from_b64 \"\${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing OPENCLAW_TEST_STATE_SCRIPT_B64}\" - tsx scripts/e2e/crestodian-planner-docker-client.ts + node scripts/e2e/crestodian-planner-docker-client.mjs " >"$RUN_LOG" 2>&1 status=${PIPESTATUS[0]} set -e diff --git a/src/agents/pi-embedded-subscribe.tools.extract.test.ts b/src/agents/pi-embedded-subscribe.tools.extract.test.ts index b0c22b91f47..86ec98e417c 100644 --- a/src/agents/pi-embedded-subscribe.tools.extract.test.ts +++ b/src/agents/pi-embedded-subscribe.tools.extract.test.ts @@ -46,7 +46,7 @@ describe("extractMessagingToolSend", () => { expect(result?.tool).toBe("message"); expect(result?.provider).toBe("slack"); - expect(result?.to).toBe("channel:C1"); + expect(result?.to).toBe("channel:c1"); }); it("accepts target alias when to is omitted", () => { diff --git a/src/agents/queued-file-writer.test.ts b/src/agents/queued-file-writer.test.ts index 6486d23234c..8a23f8ec1f0 100644 --- a/src/agents/queued-file-writer.test.ts +++ b/src/agents/queued-file-writer.test.ts @@ -80,4 +80,16 @@ describe("getQueuedFileWriter", () => { expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n"); }); + + it("drops writes that would exceed the pending queue cap", async () => { + const tmpDir = makeTempDir(); + const filePath = path.join(tmpDir, "trace.jsonl"); + const writer = getQueuedFileWriter(new Map(), filePath, { maxQueuedBytes: 6 }); + + expect(writer.write("12345\n")).toBe("queued"); + expect(writer.write("after\n")).toBe("dropped"); + await writer.flush(); + + expect(fs.readFileSync(filePath, "utf8")).toBe("12345\n"); + }); }); diff --git a/src/agents/queued-file-writer.ts b/src/agents/queued-file-writer.ts index f6e8616c077..f02e5d9ef96 100644 --- a/src/agents/queued-file-writer.ts +++ b/src/agents/queued-file-writer.ts @@ -2,14 +2,18 @@ import nodeFs from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; +export type QueuedFileWriteResult = "queued" | "dropped"; + export type QueuedFileWriter = { filePath: string; - write: (line: string) => void; + write: (line: string) => unknown; flush: () => Promise; }; type QueuedFileWriterOptions = { maxFileBytes?: number; + maxQueuedBytes?: number; + yieldBeforeWrite?: boolean; }; type QueuedFileAppendFlagConstants = Pick< @@ -111,6 +115,12 @@ async function safeAppendFile( } } +function waitForImmediate(): Promise { + return new Promise((resolve) => { + setImmediate(resolve); + }); +} + export function getQueuedFileWriter( writers: Map, filePath: string, @@ -123,15 +133,29 @@ export function getQueuedFileWriter( const dir = path.dirname(filePath); const ready = fs.mkdir(dir, { recursive: true, mode: 0o700 }).catch(() => undefined); - let queue = Promise.resolve(); + let queue: Promise = Promise.resolve(); + let queuedBytes = 0; const writer: QueuedFileWriter = { filePath, write: (line: string) => { + const lineBytes = Buffer.byteLength(line, "utf8"); + if ( + options.maxQueuedBytes !== undefined && + queuedBytes + lineBytes > options.maxQueuedBytes + ) { + return "dropped"; + } + queuedBytes += lineBytes; queue = queue .then(() => ready) + .then(() => (options.yieldBeforeWrite ? waitForImmediate() : undefined)) .then(() => safeAppendFile(filePath, line, options)) - .catch(() => undefined); + .catch(() => undefined) + .finally(() => { + queuedBytes = Math.max(0, queuedBytes - lineBytes); + }); + return "queued"; }, flush: async () => { await queue; diff --git a/src/auto-reply/reply/followup-delivery.test.ts b/src/auto-reply/reply/followup-delivery.test.ts index 59cf07f42a9..64cdbb8133c 100644 --- a/src/auto-reply/reply/followup-delivery.test.ts +++ b/src/auto-reply/reply/followup-delivery.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { resolveFollowupDeliveryPayloads } from "./followup-delivery.js"; +vi.mock("../../channels/plugins/index.js", () => ({ + getChannelPlugin: () => undefined, +})); + const baseConfig = {} as OpenClawConfig; describe("resolveFollowupDeliveryPayloads", () => { diff --git a/src/channels/plugins/module-loader.test.ts b/src/channels/plugins/module-loader.test.ts index e9a9b0cca90..67876b8848d 100644 --- a/src/channels/plugins/module-loader.test.ts +++ b/src/channels/plugins/module-loader.test.ts @@ -82,7 +82,7 @@ describe("channel plugin module loader helpers", () => { expect(createJiti).not.toHaveBeenCalled(); }); - it("loads TypeScript channel plugin modules through Jiti when no native hook exists", async () => { + it("loads TypeScript channel plugin modules through Jiti when native loading is unavailable", async () => { const loadWithJiti = vi.fn((target: string) => ({ loadedBy: "jiti", target, @@ -103,7 +103,7 @@ describe("channel plugin module loader helpers", () => { const rootDir = createTempDir(); const modulePath = path.join(rootDir, "extensions", "demo", "index.ts"); fs.mkdirSync(path.dirname(modulePath), { recursive: true }); - fs.writeFileSync(modulePath, "export const ok = true;\n", "utf8"); + fs.writeFileSync(modulePath, 'throw new Error("native source load failed");\n', "utf8"); try { expect( diff --git a/src/gateway/server-http.request-trace.test.ts b/src/gateway/server-http.request-trace.test.ts index e45e6898bde..8ccd5039c07 100644 --- a/src/gateway/server-http.request-trace.test.ts +++ b/src/gateway/server-http.request-trace.test.ts @@ -88,9 +88,13 @@ describe("gateway HTTP request trace scope", () => { expect(activeTraceInHandler?.spanId).toMatch(/^[0-9a-f]{16}$/); expect(events).toEqual([{ trace: activeTraceInHandler, type: "message.queued" }]); - const [line] = fs.readFileSync(logPath, "utf8").trim().split("\n"); - const record = JSON.parse(line ?? "{}") as Record; - expect(record).toMatchObject({ + const traceRecord = fs + .readFileSync(logPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record) + .find((record) => record.message === "handled request trace"); + expect(traceRecord).toMatchObject({ traceId: activeTraceInHandler?.traceId, spanId: activeTraceInHandler?.spanId, }); diff --git a/src/gateway/server-network-runtime.e2e.test.ts b/src/gateway/server-network-runtime.e2e.test.ts index 03bd74ea8a6..e97ff7c55d1 100644 --- a/src/gateway/server-network-runtime.e2e.test.ts +++ b/src/gateway/server-network-runtime.e2e.test.ts @@ -38,6 +38,14 @@ function isEnvHttpProxyDispatcher(dispatcher: unknown): boolean { ); } +async function closeTestDispatcher(dispatcher: unknown): Promise { + const close = (dispatcher as { close?: () => Promise | void } | undefined)?.close; + if (typeof close !== "function") { + return; + } + await close.call(dispatcher); +} + describe("gateway network runtime", () => { beforeEach(() => { clearRuntimeConfigSnapshot(); @@ -64,7 +72,8 @@ describe("gateway network runtime", () => { let server: Awaited> | undefined; try { - setGlobalDispatcher(new Agent()); + const testDispatcher = new Agent(); + setGlobalDispatcher(testDispatcher); for (const key of NETWORK_GATEWAY_ENV_KEYS) { delete process.env[key]; } @@ -101,7 +110,11 @@ describe("gateway network runtime", () => { expect(isEnvHttpProxyDispatcher(getGlobalDispatcher())).toBe(true); } finally { await server?.close({ reason: "gateway proxy bootstrap test complete" }); + const dispatcherToClose = getGlobalDispatcher(); setGlobalDispatcher(originalDispatcher); + if (dispatcherToClose !== originalDispatcher) { + await closeTestDispatcher(dispatcherToClose); + } await fs.rm(tempHome, { recursive: true, force: true }); envSnapshot.restore(); } diff --git a/src/gateway/shared-auth.test-helpers.ts b/src/gateway/shared-auth.test-helpers.ts index b589087e28d..8fe2462c741 100644 --- a/src/gateway/shared-auth.test-helpers.ts +++ b/src/gateway/shared-auth.test-helpers.ts @@ -2,10 +2,42 @@ import { expect } from "vitest"; import { WebSocket } from "ws"; import { connectOk, rpcReq, trackConnectChallengeNonce } from "./test-helpers.js"; -export async function openAuthenticatedGatewayWs(port: number, token: string): Promise { +export async function openAuthenticatedGatewayWs( + port: number, + token: string, + timeoutMs = 10_000, +): Promise { const ws = new WebSocket(`ws://127.0.0.1:${port}`); trackConnectChallengeNonce(ws); - await new Promise((resolve) => ws.once("open", resolve)); + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + ws.off("open", onOpen); + ws.off("error", onError); + ws.off("close", onClose); + }; + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (error: unknown) => { + cleanup(); + reject(error instanceof Error ? error : new Error(String(error))); + }; + const onClose = (code: number, reason: Buffer) => { + cleanup(); + reject(new Error(`gateway websocket closed before open (${code}: ${reason.toString()})`)); + }; + const timer = setTimeout(() => { + cleanup(); + ws.close(); + reject(new Error(`gateway websocket did not open within ${timeoutMs}ms`)); + }, timeoutMs); + timer.unref?.(); + ws.once("open", onOpen); + ws.once("error", onError); + ws.once("close", onClose); + }); await connectOk(ws, { token }); return ws; } @@ -17,8 +49,11 @@ export async function waitForGatewayWsClose( return await new Promise((resolve, reject) => { const timer = setTimeout(() => { ws.off("close", onClose); - reject(new Error(`gateway websocket did not close within ${timeoutMs}ms`)); + reject( + new Error(`gateway websocket did not close within ${timeoutMs}ms (state=${ws.readyState})`), + ); }, timeoutMs); + timer.unref?.(); const onClose = (code: number, reason: Buffer) => { clearTimeout(timer); resolve({ code, reason: reason.toString() }); diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index ecea63e15b6..3521011cb2b 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -285,7 +285,7 @@ describe("loadBundledEntryExportSync", () => { }); it("keeps Windows dist sidecar loads off source-transform loading", async () => { - const createJiti = vi.fn(() => vi.fn(() => ({ load: 42 }))); + const createJiti = vi.fn(() => vi.fn(() => ({ load: 0 }))); stubPluginModuleLoaderJitiFactory(createJiti as unknown as PluginModuleLoaderFactory); const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); @@ -300,22 +300,17 @@ describe("loadBundledEntryExportSync", () => { fs.mkdirSync(pluginRoot, { recursive: true }); const importerPath = path.join(pluginRoot, "index.js"); - const helperPath = path.join(pluginRoot, "helper.ts"); + const helperPath = path.join(pluginRoot, "helper.cjs"); fs.writeFileSync(importerPath, "export default {};\n", "utf8"); - fs.writeFileSync(helperPath, "export const load = 42;\n", "utf8"); + fs.writeFileSync(helperPath, "module.exports = { load: 42 };\n", "utf8"); expect( channelEntryContract.loadBundledEntryExportSync(pathToFileURL(importerPath).href, { - specifier: "./helper.ts", + specifier: "./helper.cjs", exportName: "load", }), ).toBe(42); - expect(createJiti).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - tryNative: false, - }), - ); + expect(createJiti).not.toHaveBeenCalled(); } finally { platformSpy.mockRestore(); } diff --git a/src/plugins/discovery.test.ts b/src/plugins/discovery.test.ts index a5f8a483c34..7536b0d8c31 100644 --- a/src/plugins/discovery.test.ts +++ b/src/plugins/discovery.test.ts @@ -928,6 +928,34 @@ describe("discoverOpenClawPlugins", () => { ).toBe(true); }); + it("rejects blank package runtimeExtensions before falling back to inferred entries", async () => { + const stateDir = makeTempDir(); + const pluginDir = path.join(stateDir, "extensions", "runtime-blank-pack"); + mkdirSafe(path.join(pluginDir, "src")); + mkdirSafe(path.join(pluginDir, "dist")); + + writePluginPackageManifest({ + packageDir: pluginDir, + packageName: "@openclaw/runtime-blank-pack", + extensions: ["./src/index.ts"], + runtimeExtensions: [" "], + }); + writePluginEntry(path.join(pluginDir, "src", "index.ts")); + writePluginEntry(path.join(pluginDir, "dist", "index.js")); + + const result = await discoverWithStateDir(stateDir, {}); + + expectCandidatePresence(result, { absent: ["runtime-blank-pack"] }); + expect( + result.diagnostics.some( + (entry) => + entry.level === "error" && + entry.message.includes("openclaw.runtimeExtensions[0]") && + entry.message.includes("non-empty string"), + ), + ).toBe(true); + }); + it("infers built dist entries for installed TypeScript package plugins", async () => { const stateDir = makeTempDir(); const pluginDir = path.join(stateDir, "extensions", "built-peer-pack"); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 2416408024a..a74f63ef93b 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -985,6 +985,37 @@ describe("installPluginFromArchive", () => { } }); + it("rejects package installs when runtimeExtensions contains a blank entry", async () => { + const { pluginDir, extensionsDir } = setupPluginInstallDirs(); + fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true }); + fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify({ + name: "runtime-blank-plugin", + version: "1.0.0", + openclaw: { + extensions: ["./src/index.ts"], + runtimeExtensions: [" "], + }, + }), + ); + fs.writeFileSync(path.join(pluginDir, "src", "index.ts"), "export {};\n"); + fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};\n"); + + const result = await installPluginFromDir({ + dirPath: pluginDir, + extensionsDir, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.INVALID_OPENCLAW_EXTENSIONS); + expect(result.error).toContain("openclaw.runtimeExtensions[0]"); + expect(result.error).toContain("non-empty string"); + } + }); + it("rejects package installs when runtimeSetupEntry is missing", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 4bae4812fdf..13b063810b9 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1037,7 +1037,8 @@ describe("loadOpenClawPlugins", () => { }, }); - expect(registry.plugins.find((entry) => entry.id === "discord")?.status).toBe("loaded"); + const record = registry.plugins.find((entry) => entry.id === "discord"); + expect(record?.status, record?.error).toBe("loaded"); }); it("registers standalone text transforms", () => { useNoBundledPlugins(); @@ -6591,7 +6592,7 @@ module.exports = { }), ); const record = registry.plugins.find((entry) => entry.id === "legacy-root-import"); - expect(record?.status).toBe("loaded"); + expect(record?.status, record?.error).toBe("loaded"); }); it("supports legacy plugins subscribing to diagnostic events from the root sdk", async () => { @@ -6639,7 +6640,7 @@ module.exports = { const record = registry.plugins.find( (entry) => entry.id === "legacy-root-diagnostic-listener", ); - expect(record?.status).toBe("loaded"); + expect(record?.status, record?.error).toBe("loaded"); emitDiagnosticEvent({ type: "model.usage", diff --git a/src/plugins/native-module-require.test.ts b/src/plugins/native-module-require.test.ts index 6566a930aa0..67e97a1c7e3 100644 --- a/src/plugins/native-module-require.test.ts +++ b/src/plugins/native-module-require.test.ts @@ -64,6 +64,19 @@ describe("tryNativeRequireJavaScriptModule", () => { ); }); + it("declines missing dependency errors when source-transform fallback is available", () => { + const dir = makeTempDir(); + const modulePath = path.join(dir, "plugin.cjs"); + fs.writeFileSync(modulePath, 'require("openclaw/plugin-sdk");\n', "utf8"); + + expect( + tryNativeRequireJavaScriptModule(modulePath, { + allowWindows: true, + fallbackOnMissingDependency: true, + }), + ).toEqual({ ok: false }); + }); + it("propagates real module evaluation errors instead of falling back", () => { const dir = makeTempDir(); const modulePath = path.join(dir, "plugin.cjs"); diff --git a/src/plugins/native-module-require.ts b/src/plugins/native-module-require.ts index a89d7beb723..e70a1079722 100644 --- a/src/plugins/native-module-require.ts +++ b/src/plugins/native-module-require.ts @@ -33,7 +33,7 @@ function isSourceTransformFallbackError(error: unknown, modulePath: string): boo export function tryNativeRequireJavaScriptModule( modulePath: string, - options: { allowWindows?: boolean } = {}, + options: { allowWindows?: boolean; fallbackOnMissingDependency?: boolean } = {}, ): { ok: true; moduleExport: unknown } | { ok: false } { if (process.platform === "win32" && options.allowWindows !== true) { return { ok: false }; @@ -44,7 +44,15 @@ export function tryNativeRequireJavaScriptModule( try { return { ok: true, moduleExport: nodeRequire(modulePath) }; } catch (error) { - if (!isSourceTransformFallbackError(error, modulePath)) { + const code = + error && typeof error === "object" ? (error as { code?: unknown }).code : undefined; + if ( + !isSourceTransformFallbackError(error, modulePath) && + !( + options.fallbackOnMissingDependency === true && + (code === "MODULE_NOT_FOUND" || code === "ERR_MODULE_NOT_FOUND") + ) + ) { throw error; } return { ok: false }; diff --git a/src/plugins/package-entry-resolution.ts b/src/plugins/package-entry-resolution.ts index f117562abd6..b92f55ea393 100644 --- a/src/plugins/package-entry-resolution.ts +++ b/src/plugins/package-entry-resolution.ts @@ -21,6 +21,8 @@ type RuntimeExtensionsResolution = | { ok: true; runtimeExtensions: string[] } | { ok: false; error: string }; +type PackageManifestStringList = { ok: true; entries: string[] } | { ok: false; error: string }; + function runtimeExtensionsLengthMismatchMessage(params: { runtimeExtensionsLength: number; extensionsLength: number; @@ -31,11 +33,25 @@ function runtimeExtensionsLengthMismatchMessage(params: { ); } -function normalizePackageManifestStringList(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; +function readPackageManifestStringList(params: { + fieldName: string; + value: unknown; +}): PackageManifestStringList { + if (!Array.isArray(params.value)) { + return { ok: true, entries: [] }; } - return value.map((entry) => normalizeOptionalString(entry) ?? "").filter(Boolean); + const entries: string[] = []; + for (const [index, entry] of params.value.entries()) { + const normalized = normalizeOptionalString(entry); + if (!normalized) { + return { + ok: false, + error: `package.json ${params.fieldName}[${index}] must be a non-empty string`, + }; + } + entries.push(normalized); + } + return { ok: true, entries }; } function resolvePackageRuntimeExtensionEntries(params: { @@ -43,7 +59,14 @@ function resolvePackageRuntimeExtensionEntries(params: { extensions: readonly string[]; }): RuntimeExtensionsResolution { const packageManifest = getPackageManifestMetadata(params.manifest ?? undefined); - const runtimeExtensions = normalizePackageManifestStringList(packageManifest?.runtimeExtensions); + const runtimeExtensionsResult = readPackageManifestStringList({ + fieldName: "openclaw.runtimeExtensions", + value: packageManifest?.runtimeExtensions, + }); + if (!runtimeExtensionsResult.ok) { + return runtimeExtensionsResult; + } + const runtimeExtensions = runtimeExtensionsResult.entries; if (runtimeExtensions.length === 0) { return { ok: true, runtimeExtensions: [] }; } diff --git a/src/plugins/plugin-module-loader-cache.test.ts b/src/plugins/plugin-module-loader-cache.test.ts index 4b29754583a..2fd80e1a34a 100644 --- a/src/plugins/plugin-module-loader-cache.test.ts +++ b/src/plugins/plugin-module-loader-cache.test.ts @@ -416,6 +416,48 @@ describe("getCachedPluginModuleLoader", () => { }); }); + it("does not source-transform fallback after native loading reaches a missing dependency", async () => { + const fromSourceTransformer = vi.fn(); + const createJiti = vi.fn(() => fromSourceTransformer); + vi.doMock("jiti", () => ({ createJiti })); + const missingDependency = Object.assign(new Error("Cannot find module 'missing-dep'"), { + code: "MODULE_NOT_FOUND", + }); + const nativeStub = vi.fn(() => { + throw missingDependency; + }); + vi.doMock("./native-module-require.js", () => ({ + isJavaScriptModulePath: () => true, + tryNativeRequireJavaScriptModule: nativeStub, + })); + const { getCachedPluginModuleLoader, getPluginModuleLoaderStats } = await importFreshModule< + typeof import("./plugin-module-loader-cache.js") + >(import.meta.url, "./plugin-module-loader-cache.js?scope=native-missing-dependency"); + + const cache = new Map(); + const loader = getCachedPluginModuleLoader({ + cache, + modulePath: "/repo/dist/extensions/demo/api.js", + importerUrl: "file:///repo/src/plugins/public-surface-loader.ts", + loaderFilename: "file:///repo/src/plugins/public-surface-loader.ts", + createLoader: asPluginModuleLoaderFactory(createJiti), + }); + + expect(() => loader("/repo/dist/extensions/demo/api.js")).toThrow("missing-dep"); + expect(createJiti).not.toHaveBeenCalled(); + expect(fromSourceTransformer).not.toHaveBeenCalled(); + expect(nativeStub).toHaveBeenCalledWith("/repo/dist/extensions/demo/api.js", { + allowWindows: true, + }); + expect(getPluginModuleLoaderStats()).toMatchObject({ + calls: 1, + nativeHits: 0, + nativeMisses: 0, + sourceTransformFallbacks: 0, + sourceTransformForced: 0, + }); + }); + it("falls back to source transform when the native-require helper declines", async () => { const fromSourceTransformer = vi.fn(() => ({ fromSourceTransform: true })); const createJiti = vi.fn(() => fromSourceTransformer); diff --git a/src/plugins/plugin-module-loader-cache.ts b/src/plugins/plugin-module-loader-cache.ts index 076dda986bc..f73850b2ba0 100644 --- a/src/plugins/plugin-module-loader-cache.ts +++ b/src/plugins/plugin-module-loader-cache.ts @@ -282,7 +282,9 @@ function createPluginModuleLoader(params: { ...rest, ); } - const native = tryNativeRequireJavaScriptModule(target, { allowWindows: true }); + const native = tryNativeRequireJavaScriptModule(target, { + allowWindows: true, + }); if (native.ok) { pluginModuleLoaderStats.nativeHits += 1; return native.moduleExport; diff --git a/src/plugins/plugin-sdk-dist-alias.ts b/src/plugins/plugin-sdk-dist-alias.ts index e1f98c8aa74..58cddb1b31e 100644 --- a/src/plugins/plugin-sdk-dist-alias.ts +++ b/src/plugins/plugin-sdk-dist-alias.ts @@ -8,12 +8,7 @@ function writeRuntimeJsonFile(targetPath: string, value: unknown): void { function writeRuntimeModuleWrapper(sourcePath: string, targetPath: string): void { const relative = `./${path.relative(path.dirname(targetPath), sourcePath).split(path.sep).join("/")}`; - const content = [ - `export * from ${JSON.stringify(relative)};`, - `import * as moduleExports from ${JSON.stringify(relative)};`, - `export default moduleExports.default ?? moduleExports;`, - "", - ].join("\n"); + const content = [`export * from ${JSON.stringify(relative)};`, ""].join("\n"); try { if (fs.readFileSync(targetPath, "utf8") === content) { return; diff --git a/src/trajectory/export.test.ts b/src/trajectory/export.test.ts index b3b23c6ec25..c6e786788b4 100644 --- a/src/trajectory/export.test.ts +++ b/src/trajectory/export.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import type { Message, Usage } from "@mariozechner/pi-ai"; import { afterAll, describe, expect, it } from "vitest"; import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js"; -import { resolveTrajectoryPointerFilePath } from "./paths.js"; +import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryPointerFilePath } from "./paths.js"; import type { TrajectoryEvent } from "./types.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-trajectory-")); @@ -272,7 +272,7 @@ describe("exportTrajectoryBundle", () => { const outputDir = path.join(tmpDir, "bundle"); writeSimpleSessionFile(sessionFile); fs.closeSync(fs.openSync(runtimeFile, "w")); - fs.truncateSync(runtimeFile, 50 * 1024 * 1024 + 1); + fs.truncateSync(runtimeFile, TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1); await expect( exportTrajectoryBundle({ diff --git a/src/trajectory/paths.ts b/src/trajectory/paths.ts index 76915989671..d2b3033830a 100644 --- a/src/trajectory/paths.ts +++ b/src/trajectory/paths.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; +export const TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES = 10 * 1024 * 1024; export const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024; export const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024; diff --git a/src/trajectory/runtime.test.ts b/src/trajectory/runtime.test.ts index c364b30dfb8..63723c76d22 100644 --- a/src/trajectory/runtime.test.ts +++ b/src/trajectory/runtime.test.ts @@ -88,7 +88,7 @@ describe("trajectory runtime", () => { expect(JSON.stringify(parsed.data)).not.toContain("sk-other-secret-token"); }); - it("truncates events that exceed the runtime event byte limit", () => { + it("bounds large runtime event fields before serialization", () => { const writes: string[] = []; const recorder = createTrajectoryRuntimeRecorder({ sessionId: "session-1", @@ -108,15 +108,53 @@ describe("trajectory runtime", () => { expect(writes).toHaveLength(1); const parsed = JSON.parse(writes[0]); - expect(parsed.data).toMatchObject({ + expect(parsed.data.prompt).toMatchObject({ truncated: true, - reason: "trajectory-event-size-limit", + reason: "trajectory-field-size-limit", }); expect(Buffer.byteLength(writes[0], "utf8")).toBeLessThanOrEqual( TRAJECTORY_RUNTIME_EVENT_MAX_BYTES + 1, ); }); + it("stops runtime capture at the file budget and records a truncation event", async () => { + const writes: string[] = []; + const recorder = createTrajectoryRuntimeRecorder({ + sessionId: "session-1", + sessionFile: "/tmp/session.jsonl", + maxRuntimeFileBytes: 900, + writer: { + filePath: "/tmp/session.trajectory.jsonl", + write: (line) => { + writes.push(line); + }, + flush: async () => undefined, + }, + }); + + recorder?.recordEvent("context.compiled", { + prompt: "x".repeat(180), + }); + recorder?.recordEvent("prompt.submitted", { + prompt: "y".repeat(180), + }); + recorder?.recordEvent("model.completed", { + get prompt() { + throw new Error("stopped recorder should not read dropped payloads"); + }, + }); + await recorder?.flush(); + + const parsed = writes.map((line) => JSON.parse(line)); + expect(parsed.map((event) => event.type)).toContain("trace.truncated"); + const truncated = parsed.find((event) => event.type === "trace.truncated"); + expect(truncated?.data).toMatchObject({ + reason: "trajectory-runtime-file-size-limit", + limitBytes: 900, + }); + expect(truncated?.data.droppedEvents).toBeGreaterThan(0); + }); + it("writes a session-adjacent pointer when using an override directory", () => { const tmpDir = makeTempDir(); const sessionFile = path.join(tmpDir, "session.jsonl"); diff --git a/src/trajectory/runtime.ts b/src/trajectory/runtime.ts index af2b5e1f93f..e714cd30ac2 100644 --- a/src/trajectory/runtime.ts +++ b/src/trajectory/runtime.ts @@ -6,6 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { parseBooleanValue } from "../utils/boolean.js"; import { safeJsonStringify } from "../utils/safe-json.js"; import { + TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES, TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryFilePath, @@ -15,6 +16,7 @@ import { import type { TrajectoryEvent, TrajectoryToolDefinition } from "./types.js"; export { + TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES, TRAJECTORY_RUNTIME_EVENT_MAX_BYTES, TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryFilePath, @@ -26,6 +28,7 @@ export { type TrajectoryRuntimeInit = { cfg?: OpenClawConfig; env?: NodeJS.ProcessEnv; + maxRuntimeFileBytes?: number; runId?: string; sessionId: string; sessionKey?: string; @@ -46,6 +49,11 @@ type TrajectoryRuntimeRecorder = { const writers = new Map(); const MAX_TRAJECTORY_WRITERS = 100; +const TRAJECTORY_RUNTIME_TRUNCATION_SENTINEL_RESERVE_BYTES = 2048; +const TRAJECTORY_RUNTIME_DATA_STRING_MAX_CHARS = 32_768; +const TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS = 64; +const TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS = 64; +const TRAJECTORY_RUNTIME_DATA_MAX_DEPTH = 6; function writeTrajectoryPointerBestEffort(params: { filePath: string; @@ -128,6 +136,75 @@ function truncateOversizedTrajectoryEvent( return undefined; } +function truncatedTrajectoryValue(reason: string, details: Record = {}): unknown { + return { + truncated: true, + reason, + ...details, + }; +} + +function limitTrajectoryPayloadValue( + value: unknown, + depth = 0, + seen: WeakSet = new WeakSet(), +): unknown { + if (typeof value === "string") { + if (value.length > TRAJECTORY_RUNTIME_DATA_STRING_MAX_CHARS) { + return truncatedTrajectoryValue("trajectory-field-size-limit", { + originalChars: value.length, + limitChars: TRAJECTORY_RUNTIME_DATA_STRING_MAX_CHARS, + }); + } + return value; + } + if (typeof value !== "object" || value === null) { + return value; + } + if (seen.has(value)) { + return truncatedTrajectoryValue("trajectory-circular-reference"); + } + if (depth >= TRAJECTORY_RUNTIME_DATA_MAX_DEPTH) { + return truncatedTrajectoryValue("trajectory-depth-limit", { + limitDepth: TRAJECTORY_RUNTIME_DATA_MAX_DEPTH, + }); + } + seen.add(value); + if (Array.isArray(value)) { + const limited = value + .slice(0, TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS) + .map((item) => limitTrajectoryPayloadValue(item, depth + 1, seen)); + if (value.length > TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS) { + limited.push( + truncatedTrajectoryValue("trajectory-array-size-limit", { + originalLength: value.length, + limitItems: TRAJECTORY_RUNTIME_DATA_ARRAY_MAX_ITEMS, + }), + ); + } + seen.delete(value); + return limited; + } + const record = value as Record; + const keys = Object.keys(record); + const limited: Record = {}; + for (const key of keys.slice(0, TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS)) { + limited[key] = limitTrajectoryPayloadValue(record[key], depth + 1, seen); + } + if (keys.length > TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS) { + limited._truncated = truncatedTrajectoryValue("trajectory-object-size-limit", { + originalKeys: keys.length, + limitKeys: TRAJECTORY_RUNTIME_DATA_OBJECT_MAX_KEYS, + }); + } + seen.delete(value); + return limited; +} + +function sanitizeTrajectoryPayload(data: Record): Record { + return sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data)) as Record; +} + export function toTrajectoryToolDefinitions( tools: ReadonlyArray<{ name?: string; description?: string; parameters?: unknown }>, ): TrajectoryToolDefinition[] { @@ -141,7 +218,7 @@ export function toTrajectoryToolDefinitions( { name, description: tool.description, - parameters: sanitizeDiagnosticPayload(tool.parameters), + parameters: sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(tool.parameters)), }, ]; }) @@ -167,10 +244,16 @@ export function createTrajectoryRuntimeRecorder( if (!params.writer) { trimTrajectoryWriterCache(); } + const maxRuntimeFileBytes = Math.max( + 1, + Math.floor(params.maxRuntimeFileBytes ?? TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES), + ); const writer = params.writer ?? getQueuedFileWriter(writers, filePath, { - maxFileBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES, + maxFileBytes: maxRuntimeFileBytes, + maxQueuedBytes: maxRuntimeFileBytes, + yieldBeforeWrite: true, }); writeTrajectoryPointerBestEffort({ filePath, @@ -179,40 +262,97 @@ export function createTrajectoryRuntimeRecorder( }); let seq = 0; const traceId = params.sessionId; + const sentinelReserveBytes = Math.min( + TRAJECTORY_RUNTIME_TRUNCATION_SENTINEL_RESERVE_BYTES, + Math.floor(maxRuntimeFileBytes / 2), + ); + const normalEventLimitBytes = Math.max(1, maxRuntimeFileBytes - sentinelReserveBytes); + let acceptedRuntimeBytes = 0; + let droppedEvents = 0; + let droppedEventBytes = 0; + let captureStopped = false; + + const writeBoundedLine = (line: string, options: { reserveSentinel: boolean }): boolean => { + const jsonlLine = `${line}\n`; + const lineBytes = Buffer.byteLength(jsonlLine, "utf8"); + const limitBytes = options.reserveSentinel ? normalEventLimitBytes : maxRuntimeFileBytes; + if (acceptedRuntimeBytes + lineBytes > limitBytes) { + captureStopped = true; + droppedEvents += 1; + droppedEventBytes += lineBytes; + return false; + } + const result = writer.write(jsonlLine); + if (result === "dropped") { + captureStopped = true; + droppedEvents += 1; + droppedEventBytes += lineBytes; + return false; + } + acceptedRuntimeBytes += lineBytes; + return true; + }; + + const buildEventLine = (type: string, data?: Record): string | undefined => { + const nextSeq = seq + 1; + const event: TrajectoryEvent = { + traceSchema: "openclaw-trajectory", + schemaVersion: 1, + traceId, + source: "runtime", + type, + ts: new Date().toISOString(), + seq: nextSeq, + sourceSeq: nextSeq, + sessionId: params.sessionId, + sessionKey: params.sessionKey, + runId: params.runId, + workspaceDir: params.workspaceDir, + provider: params.provider, + modelId: params.modelId, + modelApi: params.modelApi, + data: data ? sanitizeTrajectoryPayload(data) : undefined, + }; + const line = safeJsonStringify(event); + if (!line) { + return undefined; + } + const boundedLine = truncateOversizedTrajectoryEvent(event, line); + if (!boundedLine) { + return undefined; + } + seq = nextSeq; + return boundedLine; + }; return { enabled: true, filePath, recordEvent: (type, data) => { - const event: TrajectoryEvent = { - traceSchema: "openclaw-trajectory", - schemaVersion: 1, - traceId, - source: "runtime", - type, - ts: new Date().toISOString(), - seq: (seq += 1), - sourceSeq: seq, - sessionId: params.sessionId, - sessionKey: params.sessionKey, - runId: params.runId, - workspaceDir: params.workspaceDir, - provider: params.provider, - modelId: params.modelId, - modelApi: params.modelApi, - data: data ? (sanitizeDiagnosticPayload(data) as Record) : undefined, - }; - const line = safeJsonStringify(event); + if (captureStopped) { + droppedEvents += 1; + return; + } + const line = buildEventLine(type, data); if (!line) { return; } - const boundedLine = truncateOversizedTrajectoryEvent(event, line); - if (!boundedLine) { - return; - } - writer.write(`${boundedLine}\n`); + writeBoundedLine(line, { reserveSentinel: true }); }, flush: async () => { + if (droppedEvents > 0) { + const line = buildEventLine("trace.truncated", { + reason: "trajectory-runtime-file-size-limit", + droppedEvents, + droppedEventBytes, + limitBytes: maxRuntimeFileBytes, + }); + if (line) { + writeBoundedLine(line, { reserveSentinel: false }); + } + droppedEvents = 0; + droppedEventBytes = 0; + } await writer.flush(); if (!params.writer) { writers.delete(filePath); diff --git a/test/vitest/vitest.gateway-server.config.ts b/test/vitest/vitest.gateway-server.config.ts index a1bdcb00068..b26a8098d54 100644 --- a/test/vitest/vitest.gateway-server.config.ts +++ b/test/vitest/vitest.gateway-server.config.ts @@ -20,6 +20,9 @@ export function createGatewayServerVitestConfig(env?: Record