Merge branch 'main' into meow/control-chat-responsive

This commit is contained in:
Val Alexander
2026-05-04 03:58:29 -05:00
committed by GitHub
27 changed files with 511 additions and 89 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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<void> {
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" &&

View File

@@ -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

View File

@@ -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", () => {

View File

@@ -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");
});
});

View File

@@ -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<void>;
};
type QueuedFileWriterOptions = {
maxFileBytes?: number;
maxQueuedBytes?: number;
yieldBeforeWrite?: boolean;
};
type QueuedFileAppendFlagConstants = Pick<
@@ -111,6 +115,12 @@ async function safeAppendFile(
}
}
function waitForImmediate(): Promise<void> {
return new Promise((resolve) => {
setImmediate(resolve);
});
}
export function getQueuedFileWriter(
writers: Map<string, QueuedFileWriter>,
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<unknown> = 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;

View File

@@ -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", () => {

View File

@@ -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(

View File

@@ -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<string, unknown>;
expect(record).toMatchObject({
const traceRecord = fs
.readFileSync(logPath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as Record<string, unknown>)
.find((record) => record.message === "handled request trace");
expect(traceRecord).toMatchObject({
traceId: activeTraceInHandler?.traceId,
spanId: activeTraceInHandler?.spanId,
});

View File

@@ -38,6 +38,14 @@ function isEnvHttpProxyDispatcher(dispatcher: unknown): boolean {
);
}
async function closeTestDispatcher(dispatcher: unknown): Promise<void> {
const close = (dispatcher as { close?: () => Promise<void> | 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<ReturnType<typeof startGatewayServer>> | 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();
}

View File

@@ -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<WebSocket> {
export async function openAuthenticatedGatewayWs(
port: number,
token: string,
timeoutMs = 10_000,
): Promise<WebSocket> {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve) => ws.once("open", resolve));
await new Promise<void>((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() });

View File

@@ -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<number>(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();
}

View File

@@ -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");

View File

@@ -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 });

View File

@@ -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",

View File

@@ -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");

View File

@@ -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 };

View File

@@ -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: [] };
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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({

View File

@@ -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;

View File

@@ -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");

View File

@@ -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<string, QueuedFileWriter>();
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<string, unknown> = {}): unknown {
return {
truncated: true,
reason,
...details,
};
}
function limitTrajectoryPayloadValue(
value: unknown,
depth = 0,
seen: WeakSet<object> = 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<string, unknown>;
const keys = Object.keys(record);
const limited: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
return sanitizeDiagnosticPayload(limitTrajectoryPayloadValue(data)) as Record<string, unknown>;
}
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, unknown>): 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<string, unknown>) : 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);

View File

@@ -20,6 +20,9 @@ export function createGatewayServerVitestConfig(env?: Record<string, string | un
"src/gateway/server.startup-matrix-migration.integration.test.ts",
"src/gateway/sessions-history-http.test.ts",
],
// Gateway server suites share process-level env, logger, and server helper state.
// Isolate files so parallel shards cannot cross-wire suite-scoped servers.
isolate: true,
name: "gateway-server",
},
);