Merge upstream/main

This commit is contained in:
MaxxxDong 2026-03-20 17:29:22 +08:00
commit 043a922a7f
11 changed files with 233 additions and 250 deletions

View File

@ -312,11 +312,8 @@ jobs:
- name: Strict TS build smoke
run: pnpm build:strict-smoke
- name: Enforce safe external URL opening policy
run: pnpm lint:ui:no-raw-window-open
plugin-extension-boundary:
name: "plugin-extension-boundary"
check-additional:
name: "check-additional"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
@ -333,68 +330,71 @@ jobs:
use-sticky-disk: "false"
- name: Run plugin extension boundary guard
id: plugin_extension_boundary
continue-on-error: true
run: pnpm run lint:plugins:no-extension-imports
web-search-provider-boundary:
name: "web-search-provider-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run web search provider boundary guard
id: web_search_provider_boundary
continue-on-error: true
run: pnpm run lint:web-search-provider-boundaries
extension-src-outside-plugin-sdk-boundary:
name: "extension-src-outside-plugin-sdk-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension src boundary guard
id: extension_src_outside_plugin_sdk_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-src-outside-plugin-sdk
extension-plugin-sdk-internal-boundary:
name: "extension-plugin-sdk-internal-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension plugin-sdk-internal guard
id: extension_plugin_sdk_internal_boundary
continue-on-error: true
run: pnpm run lint:extensions:no-plugin-sdk-internal
- name: Enforce safe external URL opening policy
id: no_raw_window_open
continue-on-error: true
run: pnpm lint:ui:no-raw-window-open
- name: Run gateway watch regression harness
id: gateway_watch_regression
continue-on-error: true
run: pnpm test:gateway:watch-regression
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
- name: Fail if any additional check failed
if: always()
env:
PLUGIN_EXTENSION_BOUNDARY_OUTCOME: ${{ steps.plugin_extension_boundary.outcome }}
WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME: ${{ steps.web_search_provider_boundary.outcome }}
EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME: ${{ steps.extension_src_outside_plugin_sdk_boundary.outcome }}
EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME: ${{ steps.extension_plugin_sdk_internal_boundary.outcome }}
NO_RAW_WINDOW_OPEN_OUTCOME: ${{ steps.no_raw_window_open.outcome }}
GATEWAY_WATCH_REGRESSION_OUTCOME: ${{ steps.gateway_watch_regression.outcome }}
run: |
failures=0
for result in \
"plugin-extension-boundary|$PLUGIN_EXTENSION_BOUNDARY_OUTCOME" \
"web-search-provider-boundary|$WEB_SEARCH_PROVIDER_BOUNDARY_OUTCOME" \
"extension-src-outside-plugin-sdk-boundary|$EXTENSION_SRC_OUTSIDE_PLUGIN_SDK_BOUNDARY_OUTCOME" \
"extension-plugin-sdk-internal-boundary|$EXTENSION_PLUGIN_SDK_INTERNAL_BOUNDARY_OUTCOME" \
"lint:ui:no-raw-window-open|$NO_RAW_WINDOW_OPEN_OUTCOME" \
"gateway-watch-regression|$GATEWAY_WATCH_REGRESSION_OUTCOME"; do
name="${result%%|*}"
outcome="${result#*|}"
if [ "$outcome" != "success" ]; then
echo "::error title=${name} failed::${name} outcome: ${outcome}"
failures=1
fi
done
exit "$failures"
build-smoke:
name: "build-smoke"
needs: [docs-scope, changed-scope]
@ -427,34 +427,6 @@ jobs:
- name: Check CLI startup memory
run: pnpm test:startup:memory
gateway-watch-regression:
name: "gateway-watch-regression"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run gateway watch regression harness
run: pnpm test:gateway:watch-regression
- name: Upload gateway watch regression artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
needs: [docs-scope]

View File

@ -49,6 +49,8 @@ Docs: https://docs.openclaw.ai
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily.
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
### Fixes
@ -116,6 +118,7 @@ Docs: https://docs.openclaw.ai
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
- Gateway/agent events: stop broadcasting false end-of-run `seq gap` errors to clients, and isolate node-driven ingress turns with per-turn run IDs so stale tail events cannot leak into later session runs. (#43751) Thanks @caesargattuso.
- Docs/security audit: spell out that `gateway.controlUi.allowedOrigins: ["*"]` is an explicit allow-all browser-origin policy and should be avoided outside tightly controlled local testing.
- Gateway/auth: clear self-declared scopes for device-less trusted-proxy Control UI sessions so proxy-authenticated connects cannot claim admin or secrets scopes without a bound device identity.
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.

View File

@ -45,6 +45,15 @@ Use this format when adding entries:
## Listed plugins
- **openclaw-dingtalk** — The OpenClaw DingTalk channel plugin enables the integration of enterprise robots using the Stream mode. It supports text, images and file messages via any DingTalk client.
npm: `@largezhou/ddingtalk`
repo: `https://github.com/largezhou/openclaw-dingtalk`
install: `openclaw plugins install @largezhou/ddingtalk`
- **QQbot** — Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files.
npm: `@sliverp/qqbot`
repo: `https://github.com/sliverp/qqbot`
install: `openclaw plugins install @sliverp/qqbot`
- **WeChat** — Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations.
npm: `@icesword760/openclaw-wechat`
repo: `https://github.com/icesword0760/openclaw-wechat`

View File

@ -66,8 +66,12 @@ export function createMockSignalDaemonHandle(
};
}
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
// Use importActual so shared-worker mocks from earlier test files do not leak
// into this harness's partial overrides.
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
return {
...actual,
loadConfig: () => config,
@ -78,8 +82,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
"openclaw/plugin-sdk/reply-runtime",
);
return {
...actual,
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
@ -104,8 +110,8 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
};
});
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
return {
...actual,
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
@ -114,8 +120,10 @@ vi.mock("./send.js", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
);
return {
...actual,
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
@ -123,8 +131,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
vi.mock("openclaw/plugin-sdk/security-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/security-runtime")>(
"openclaw/plugin-sdk/security-runtime",
);
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
@ -137,16 +147,18 @@ vi.mock("./client.js", () => ({
signalRpcRequest: (...args: unknown[]) => signalRpcRequestMock(...args),
}));
vi.mock("./daemon.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./daemon.js")>();
vi.mock("./daemon.js", async () => {
const actual = await vi.importActual<typeof import("./daemon.js")>("./daemon.js");
return {
...actual,
spawnSignalDaemon: (...args: unknown[]) => spawnSignalDaemonMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
"openclaw/plugin-sdk/infra-runtime",
);
return {
...actual,
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),

View File

@ -487,6 +487,46 @@ describe("agent event handler", () => {
nowSpy?.mockRestore();
});
it("drops stale events that arrive after lifecycle completion", () => {
const { broadcast, nodeSendToSession, chatRunState, handler, nowSpy } = createHarness({
now: 2_500,
});
chatRunState.registry.add("run-stale-tail", {
sessionKey: "session-stale-tail",
clientRunId: "client-stale-tail",
});
handler({
runId: "run-stale-tail",
seq: 1,
stream: "assistant",
ts: Date.now(),
data: { text: "done" },
});
emitLifecycleEnd(handler, "run-stale-tail");
const errorCallsBeforeStaleEvent = broadcast.mock.calls.filter(
([event, payload]) =>
event === "agent" && (payload as { stream?: string }).stream === "error",
).length;
const sessionChatCallsBeforeStaleEvent = sessionChatCalls(nodeSendToSession).length;
handler({
runId: "run-stale-tail",
seq: 3,
stream: "assistant",
ts: Date.now(),
data: { text: "late tail" },
});
const errorCalls = broadcast.mock.calls.filter(
([event, payload]) =>
event === "agent" && (payload as { stream?: string }).stream === "error",
);
expect(errorCalls).toHaveLength(errorCallsBeforeStaleEvent);
expect(sessionChatCalls(nodeSendToSession)).toHaveLength(sessionChatCallsBeforeStaleEvent);
nowSpy?.mockRestore();
});
it("flushes buffered chat delta before tool start events", () => {
let now = 12_000;
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);

View File

@ -710,7 +710,7 @@ export function createAgentEventHandler({
: { ...eventForClients, data };
})()
: agentPayload;
if (evt.seq !== last + 1) {
if (last > 0 && evt.seq !== last + 1) {
broadcast("agent", {
runId: eventRunId,
stream: "error",

View File

@ -410,7 +410,9 @@ describe("voice transcript events", () => {
});
it("forwards transcript with voice provenance", async () => {
const addChatRun = vi.fn();
const ctx = buildCtx();
ctx.addChatRun = addChatRun;
await handleNodeEvent(ctx, "node-v2", {
event: "voice.transcript",
@ -432,6 +434,12 @@ describe("voice transcript events", () => {
sourceTool: "gateway.voice.transcript",
},
});
expect(typeof opts.runId).toBe("string");
expect(opts.runId).not.toBe(opts.sessionId);
expect(addChatRun).toHaveBeenCalledWith(
opts.runId,
expect.objectContaining({ clientRunId: expect.stringMatching(/^voice-/) }),
);
});
it("does not block agent dispatch when session-store touch fails", async () => {
@ -674,5 +682,6 @@ describe("agent request events", () => {
channel: "telegram",
to: "123",
});
expect(opts.runId).toBe(opts.sessionId);
});
});

View File

@ -288,16 +288,18 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
sessionId,
now,
});
const runId = randomUUID();
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
ctx.addChatRun(sessionId, {
// This maps agent bus events (keyed by per-turn runId) to chat events (keyed by clientRunId).
ctx.addChatRun(runId, {
sessionKey: canonicalKey,
clientRunId: `voice-${randomUUID()}`,
});
void agentCommandFromIngress(
{
runId,
message: text,
sessionId,
sessionKey: canonicalKey,
@ -404,7 +406,6 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
const deliver = deliverRequested && Boolean(channel && to);
const deliveryChannel = deliver ? channel : undefined;
const deliveryTo = deliver ? to : undefined;
if (deliverRequested && !deliver) {
ctx.logGateway.warn(
`agent delivery disabled node=${nodeId}: missing session delivery route (channel=${channel ?? "-"} to=${to ?? "-"})`,
@ -430,6 +431,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
void agentCommandFromIngress(
{
runId: sessionId,
message,
images,
sessionId,

View File

@ -1,15 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { __testing } from "./loader.js";
const EMPTY_PLUGIN_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {},
} as const;
type CreateJiti = typeof import("jiti").createJiti;
let createJitiPromise: Promise<CreateJiti> | undefined;
async function getCreateJiti() {
createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti);
return createJitiPromise;
}
const tempRoots: string[] = [];
@ -29,92 +32,66 @@ afterEach(() => {
}
});
describe("loadOpenClawPlugins", () => {
it("builds plugin-loader aliases for git-style package extension entries that import plugin-sdk channel-runtime (#49806)", () => {
const pluginId = "imessage-loader-regression";
const gitExtensionRoot = path.join(
makeTempDir(),
"git-source-checkout",
"extensions",
pluginId,
);
const gitSourceDir = path.join(gitExtensionRoot, "src");
mkdirSafe(gitSourceDir);
describe("plugin loader git path regression", () => {
it("loads git-style package extension entries when they import plugin-sdk channel-runtime (#49806)", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
mkdirSafe(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(
path.join(gitExtensionRoot, "package.json"),
JSON.stringify(
{
name: `@openclaw/${pluginId}`,
version: "0.0.1",
type: "module",
openclaw: {
extensions: ["./src/index.ts"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(gitExtensionRoot, "openclaw.plugin.json"),
JSON.stringify(
{
id: pluginId,
configSchema: EMPTY_PLUGIN_SCHEMA,
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(gitSourceDir, "channel.runtime.ts"),
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
export function runtimeProbeType() {
return typeof resolveOutboundSendDep;
}
`,
"utf-8",
);
const entryFile = path.join(gitSourceDir, "index.ts");
fs.writeFileSync(
entryFile,
`import { runtimeProbeType } from "./channel.runtime.ts";
export default {
id: ${JSON.stringify(pluginId)},
register() {
if (runtimeProbeType() !== "function") {
throw new Error("channel-runtime import did not resolve");
}
},
export const copiedRuntimeMarker = {
resolveOutboundSendDep,
PAIRING_APPROVED_MESSAGE,
};
`,
"utf-8",
);
fs.writeFileSync(
path.join(copiedExtensionRoot, "runtime-api.ts"),
`export const PAIRING_APPROVED_MESSAGE = "paired";
`,
"utf-8",
);
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts");
fs.writeFileSync(
copiedChannelRuntimeShim,
`export function resolveOutboundSendDep() {
return "shimmed";
}
`,
"utf-8",
);
const { aliasMap, runtimeModulePath, tryNative } = withEnv(
{
NODE_ENV: "production",
VITEST: undefined,
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
},
() => ({
aliasMap: __testing.buildPluginLoaderAliasMap(entryFile),
runtimeModulePath: __testing.resolvePluginRuntimeModulePath({ modulePath: entryFile }),
tryNative: __testing.shouldPreferNativeJiti(entryFile),
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
const createJiti = await getCreateJiti();
const withoutAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({}),
tryNative: false,
});
// The production loader uses sync Jiti evaluation, so this regression test
// should exercise the same seam instead of Jiti's async import helper.
expect(() => withoutAlias(copiedChannelRuntime)).toThrow();
const withAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({
"openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim,
}),
);
expect(tryNative).toBe(false);
expect(aliasMap).toHaveProperty("openclaw/plugin-sdk");
expect(aliasMap["openclaw/plugin-sdk"]).toMatch(/plugin-sdk[\\/]root-alias\.cjs$/);
expect(aliasMap["openclaw/plugin-sdk/channel-runtime"]).toMatch(
/plugin-sdk[\\/](channel-runtime\.ts|channel-runtime\.js)$/,
);
expect(runtimeModulePath).toMatch(/plugins[\\/]runtime[\\/]index\.(ts|js)$/);
tryNative: false,
});
expect(withAlias(copiedChannelRuntime)).toMatchObject({
copiedRuntimeMarker: {
PAIRING_APPROVED_MESSAGE: "paired",
resolveOutboundSendDep: expect.any(Function),
},
});
});
});

View File

@ -3595,9 +3595,9 @@ export const syntheticRuntimeMarker = {
...__testing.buildPluginLoaderJitiOptions({}),
tryNative: false,
});
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(
/plugin-sdk\/channel-runtime/,
);
// The production loader uses sync Jiti evaluation, so this boundary should
// follow the same path instead of the async import helper.
expect(() => withoutAlias(copiedChannelRuntime)).toThrow();
const withAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({
@ -3605,74 +3605,13 @@ export const syntheticRuntimeMarker = {
}),
tryNative: false,
});
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
expect(withAlias(copiedChannelRuntime)).toMatchObject({
syntheticRuntimeMarker: {
resolveOutboundSendDep: expect.any(Function),
},
});
}, 240_000);
it("loads copied imessage runtime sources from git-style paths with plugin-sdk aliases (#49806)", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "imessage");
const copiedSourceDir = path.join(copiedExtensionRoot, "src");
const copiedPluginSdkDir = path.join(copiedExtensionRoot, "plugin-sdk");
mkdirSafe(copiedSourceDir);
mkdirSafe(copiedPluginSdkDir);
const jitiBaseFile = path.join(copiedSourceDir, "__jiti-base__.mjs");
fs.writeFileSync(jitiBaseFile, "export {};\n", "utf-8");
fs.writeFileSync(
path.join(copiedSourceDir, "channel.runtime.ts"),
`import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { PAIRING_APPROVED_MESSAGE } from "../runtime-api.js";
export const copiedRuntimeMarker = {
resolveOutboundSendDep,
PAIRING_APPROVED_MESSAGE,
};
`,
"utf-8",
);
fs.writeFileSync(
path.join(copiedExtensionRoot, "runtime-api.ts"),
`export const PAIRING_APPROVED_MESSAGE = "paired";
`,
"utf-8",
);
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "channel-runtime.ts");
fs.writeFileSync(
copiedChannelRuntimeShim,
`export function resolveOutboundSendDep() {
return "shimmed";
}
`,
"utf-8",
);
const copiedChannelRuntime = path.join(copiedExtensionRoot, "src", "channel.runtime.ts");
const jitiBaseUrl = pathToFileURL(jitiBaseFile).href;
const createJiti = await getCreateJiti();
const withoutAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({}),
tryNative: false,
});
await expect(withoutAlias.import(copiedChannelRuntime)).rejects.toThrow(
/plugin-sdk\/channel-runtime/,
);
const withAlias = createJiti(jitiBaseUrl, {
...__testing.buildPluginLoaderJitiOptions({
"openclaw/plugin-sdk/channel-runtime": copiedChannelRuntimeShim,
}),
tryNative: false,
});
await expect(withAlias.import(copiedChannelRuntime)).resolves.toMatchObject({
copiedRuntimeMarker: {
PAIRING_APPROVED_MESSAGE: "paired",
resolveOutboundSendDep: expect.any(Function),
},
});
});
it("loads source TypeScript plugins that route through local runtime shims", () => {
const plugin = writePlugin({
id: "source-runtime-shim",

View File

@ -183,6 +183,26 @@
"file": "src/infra/heartbeat-runner.returns-default-unset.test.ts",
"reason": "Heartbeat default-unset coverage retained a large shared unit-fast heap spike on Linux Node 22 CI."
},
{
"file": "src/infra/heartbeat-runner.ghost-reminder.test.ts",
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
},
{
"file": "src/infra/heartbeat-runner.transcript-prune.test.ts",
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
},
{
"file": "src/infra/heartbeat-runner.sender-prefers-delivery-target.test.ts",
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
},
{
"file": "src/infra/heartbeat-runner.model-override.test.ts",
"reason": "Mocks jiti at file scope, so it is safer outside shared Vitest workers."
},
{
"file": "src/plugins/loader.git-path-regression.test.ts",
"reason": "Constructs a real Jiti boundary and is safer outside shared workers that may have mocked jiti earlier."
},
{
"file": "src/infra/outbound/outbound-session.test.ts",
"reason": "Outbound session coverage retained a large shared unit-fast heap spike on Linux Node 22 CI."