diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 5c760328d2a..12439887c47 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -82,7 +82,7 @@ permissions: concurrency: group: full-release-validation-${{ inputs.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ inputs.ref == 'main' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml index aee56480b68..72066ee2fe1 100644 --- a/.github/workflows/openclaw-cross-os-release-checks-reusable.yml +++ b/.github/workflows/openclaw-cross-os-release-checks-reusable.yml @@ -158,7 +158,7 @@ permissions: read-all concurrency: group: openclaw-cross-os-release-checks-${{ inputs.ref }}-${{ inputs.provider }}-${{ inputs.mode }} - cancel-in-progress: false + cancel-in-progress: ${{ inputs.ref == 'main' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -333,6 +333,9 @@ jobs: cache: pnpm cache-dependency-path: ${{ inputs.candidate_artifact_name == '' && 'source/pnpm-lock.yaml' || 'workflow/pnpm-lock.yaml' }} + - name: Ensure pnpm store cache directory exists + run: mkdir -p "$(pnpm store path --silent)" + - name: Build candidate artifact once if: inputs.candidate_artifact_name == '' env: diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 257dc4f6e6b..04a4ff65d61 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -56,7 +56,7 @@ on: concurrency: group: openclaw-release-checks-${{ inputs.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ inputs.ref == 'main' }} env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/extensions/browser/src/browser/trash.test.ts b/extensions/browser/src/browser/trash.test.ts index 43884767b65..52d2345e69a 100644 --- a/extensions/browser/src/browser/trash.test.ts +++ b/extensions/browser/src/browser/trash.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const runExec = vi.hoisted(() => vi.fn()); const resolvePreferredOpenClawTmpDirMock = vi.hoisted(() => vi.fn(() => "/tmp/openclaw")); +const OPENCLAW_TMP_ROOT = "/tmp/openclaw"; +const TRASH_SOURCE = `${OPENCLAW_TMP_ROOT}/demo`; vi.mock("../process/exec.js", () => ({ runExec, @@ -46,7 +48,7 @@ describe("browser trash", () => { const cpSync = vi.spyOn(fs, "cpSync"); const rmSync = vi.spyOn(fs, "rmSync"); - await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe( + await expect(movePathToTrash(TRASH_SOURCE)).resolves.toBe( "/home/test/.Trash/demo-123-secure/demo", ); expect(runExec).not.toHaveBeenCalled(); @@ -55,10 +57,7 @@ describe("browser trash", () => { mode: 0o700, }); expect(mkdtempSync).toHaveBeenCalledWith("/home/test/.Trash/demo-123-"); - expect(renameSync).toHaveBeenCalledWith( - "/tmp/openclaw/demo", - "/home/test/.Trash/demo-123-secure/demo", - ); + expect(renameSync).toHaveBeenCalledWith(TRASH_SOURCE, "/home/test/.Trash/demo-123-secure/demo"); expect(cpSync).not.toHaveBeenCalled(); expect(rmSync).not.toHaveBeenCalled(); }); @@ -79,12 +78,12 @@ describe("browser trash", () => { const mkdtempSync = mockTrashContainer("secure"); const renameSync = vi.spyOn(fs, "renameSync").mockImplementation(() => undefined); - await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe( + await expect(movePathToTrash(TRASH_SOURCE)).resolves.toBe( "/real/home/test/.Trash/demo-123-secure/demo", ); expect(mkdtempSync).toHaveBeenCalledWith("/real/home/test/.Trash/demo-123-"); expect(renameSync).toHaveBeenCalledWith( - "/tmp/openclaw/demo", + TRASH_SOURCE, "/real/home/test/.Trash/demo-123-secure/demo", ); }); @@ -106,12 +105,15 @@ describe("browser trash", () => { it("refuses to use a symlinked trash directory", async () => { const { movePathToTrash } = await import("./trash.js"); vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined); - vi.spyOn(fs, "lstatSync").mockReturnValue({ - isDirectory: () => true, - isSymbolicLink: () => true, - } as fs.Stats); + vi.spyOn(fs, "lstatSync").mockImplementation( + (candidate) => + ({ + isDirectory: () => true, + isSymbolicLink: () => String(candidate) === "/home/test/.Trash", + }) as fs.Stats, + ); - await expect(movePathToTrash("/tmp/openclaw/demo")).rejects.toThrow( + await expect(movePathToTrash(TRASH_SOURCE)).rejects.toThrow( "Refusing to use non-directory/symlink trash directory", ); }); @@ -127,22 +129,15 @@ describe("browser trash", () => { const cpSync = vi.spyOn(fs, "cpSync").mockImplementation(() => undefined); const rmSync = vi.spyOn(fs, "rmSync").mockImplementation(() => undefined); - await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe( + await expect(movePathToTrash(TRASH_SOURCE)).resolves.toBe( "/home/test/.Trash/demo-123-secure/demo", ); - expect(cpSync).toHaveBeenCalledWith( - "/tmp/openclaw/demo", - "/home/test/.Trash/demo-123-secure/demo", - { - recursive: true, - force: false, - errorOnExist: true, - }, - ); - expect(rmSync).toHaveBeenCalledWith("/tmp/openclaw/demo", { + expect(cpSync).toHaveBeenCalledWith(TRASH_SOURCE, "/home/test/.Trash/demo-123-secure/demo", { recursive: true, force: false, + errorOnExist: true, }); + expect(rmSync).toHaveBeenCalledWith(TRASH_SOURCE, { recursive: true, force: false }); }); it("retries copy fallback when the copy destination is created concurrently", async () => { @@ -164,12 +159,12 @@ describe("browser trash", () => { .mockImplementation(() => undefined); const rmSync = vi.spyOn(fs, "rmSync").mockImplementation(() => undefined); - await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe( + await expect(movePathToTrash(TRASH_SOURCE)).resolves.toBe( "/home/test/.Trash/demo-123-second/demo", ); expect(cpSync).toHaveBeenNthCalledWith( 1, - "/tmp/openclaw/demo", + TRASH_SOURCE, "/home/test/.Trash/demo-123-first/demo", { recursive: true, @@ -179,7 +174,7 @@ describe("browser trash", () => { ); expect(cpSync).toHaveBeenNthCalledWith( 2, - "/tmp/openclaw/demo", + TRASH_SOURCE, "/home/test/.Trash/demo-123-second/demo", { recursive: true, @@ -203,17 +198,17 @@ describe("browser trash", () => { }) .mockImplementation(() => undefined); - await expect(movePathToTrash("/tmp/openclaw/demo")).resolves.toBe( + await expect(movePathToTrash(TRASH_SOURCE)).resolves.toBe( "/home/test/.Trash/demo-123-second/demo", ); expect(renameSync).toHaveBeenNthCalledWith( 1, - "/tmp/openclaw/demo", + TRASH_SOURCE, "/home/test/.Trash/demo-123-first/demo", ); expect(renameSync).toHaveBeenNthCalledWith( 2, - "/tmp/openclaw/demo", + TRASH_SOURCE, "/home/test/.Trash/demo-123-second/demo", ); expect(Date.now).toHaveBeenCalledTimes(1); diff --git a/extensions/qa-channel/package.json b/extensions/qa-channel/package.json index c2b17c6084f..929987552ba 100644 --- a/extensions/qa-channel/package.json +++ b/extensions/qa-channel/package.json @@ -4,6 +4,12 @@ "private": true, "description": "OpenClaw QA synthetic channel plugin", "type": "module", + "exports": { + ".": "./index.ts", + "./api.js": "./api.ts", + "./runtime-api.js": "./runtime-api.ts", + "./test-api.js": "./test-api.ts" + }, "dependencies": { "typebox": "1.1.33" }, diff --git a/extensions/qa-lab/src/gateway-rpc-client.test.ts b/extensions/qa-lab/src/gateway-rpc-client.test.ts index a7320bd2ac9..583b47b0ed1 100644 --- a/extensions/qa-lab/src/gateway-rpc-client.test.ts +++ b/extensions/qa-lab/src/gateway-rpc-client.test.ts @@ -51,8 +51,12 @@ describe("startQaGatewayRpcClient", () => { }, { prompt: "hi" }, { + clientName: "gateway-client", + deviceIdentity: null, expectFinal: true, + mode: "backend", progress: false, + scopes: ["operator.admin"], }, ); @@ -124,8 +128,12 @@ describe("startQaGatewayRpcClient", () => { }, {}, { + clientName: "gateway-client", + deviceIdentity: null, expectFinal: undefined, + mode: "backend", progress: false, + scopes: ["operator.admin"], }, ); diff --git a/extensions/qa-lab/src/gateway-rpc-client.ts b/extensions/qa-lab/src/gateway-rpc-client.ts index 0a2583b4395..9a38d9a63ec 100644 --- a/extensions/qa-lab/src/gateway-rpc-client.ts +++ b/extensions/qa-lab/src/gateway-rpc-client.ts @@ -55,8 +55,12 @@ export async function startQaGatewayRpcClient(params: { }, rpcParams ?? {}, { + clientName: "gateway-client", + deviceIdentity: null, expectFinal: opts?.expectFinal, + mode: "backend", progress: false, + scopes: ["operator.admin"], }, ), ); diff --git a/src/cli/gateway-rpc.runtime.ts b/src/cli/gateway-rpc.runtime.ts index 9cd6dc0eb2f..2d095b11665 100644 --- a/src/cli/gateway-rpc.runtime.ts +++ b/src/cli/gateway-rpc.runtime.ts @@ -3,11 +3,20 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/ import type { GatewayRpcOpts } from "./gateway-rpc.types.js"; import { withProgress } from "./progress.js"; +type CallGatewayFromCliRuntimeExtra = { + clientName?: Parameters[0]["clientName"]; + mode?: Parameters[0]["mode"]; + deviceIdentity?: Parameters[0]["deviceIdentity"]; + expectFinal?: boolean; + progress?: boolean; + scopes?: Parameters[0]["scopes"]; +}; + export async function callGatewayFromCliRuntime( method: string, opts: GatewayRpcOpts, params?: unknown, - extra?: { expectFinal?: boolean; progress?: boolean }, + extra?: CallGatewayFromCliRuntimeExtra, ) { const showProgress = extra?.progress ?? opts.json !== true; return await withProgress( @@ -22,10 +31,12 @@ export async function callGatewayFromCliRuntime( token: opts.token, method, params, + deviceIdentity: extra?.deviceIdentity, expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal), + scopes: extra?.scopes, timeoutMs: Number(opts.timeout ?? 10_000), - clientName: GATEWAY_CLIENT_NAMES.CLI, - mode: GATEWAY_CLIENT_MODES.CLI, + clientName: extra?.clientName ?? GATEWAY_CLIENT_NAMES.CLI, + mode: extra?.mode ?? GATEWAY_CLIENT_MODES.CLI, }), ); } diff --git a/src/cli/gateway-rpc.ts b/src/cli/gateway-rpc.ts index 97462e0cf42..c2ba77b05b1 100644 --- a/src/cli/gateway-rpc.ts +++ b/src/cli/gateway-rpc.ts @@ -1,6 +1,9 @@ import type { Command } from "commander"; -export type { GatewayRpcOpts } from "./gateway-rpc.types.js"; +import type { OperatorScope } from "../gateway/operator-scopes.js"; +import type { GatewayClientMode, GatewayClientName } from "../gateway/protocol/client-info.js"; +import type { DeviceIdentity } from "../infra/device-identity.js"; import type { GatewayRpcOpts } from "./gateway-rpc.types.js"; +export type { GatewayRpcOpts } from "./gateway-rpc.types.js"; type GatewayRpcRuntimeModule = typeof import("./gateway-rpc.runtime.js"); @@ -23,7 +26,14 @@ export async function callGatewayFromCli( method: string, opts: GatewayRpcOpts, params?: unknown, - extra?: { expectFinal?: boolean; progress?: boolean }, + extra?: { + clientName?: GatewayClientName; + mode?: GatewayClientMode; + deviceIdentity?: DeviceIdentity | null; + expectFinal?: boolean; + progress?: boolean; + scopes?: OperatorScope[]; + }, ) { const runtime = await loadGatewayRpcRuntime(); return await runtime.callGatewayFromCliRuntime(method, opts, params, extra); diff --git a/src/config/plugin-auto-enable.prefer-over.test.ts b/src/config/plugin-auto-enable.prefer-over.test.ts index cbb282c83b1..c67ae431292 100644 --- a/src/config/plugin-auto-enable.prefer-over.test.ts +++ b/src/config/plugin-auto-enable.prefer-over.test.ts @@ -38,7 +38,13 @@ const EMPTY_MANIFEST_REGISTRY: PluginManifestRegistry = { diagnostics: [], }; -afterEach(() => { +async function setBundledPluginsDirFixture(dir: string | undefined): Promise { + const { setBundledPluginsDirOverrideForTest } = await import("../plugins/bundled-dir.js"); + setBundledPluginsDirOverrideForTest(dir); +} + +afterEach(async () => { + await setBundledPluginsDirFixture(undefined); vi.unstubAllEnvs(); vi.resetModules(); cleanupTrackedTempDirs(tempDirs); @@ -52,10 +58,12 @@ describe("plugin auto-enable preferOver", () => { writeBundledChannelPackage(rootDir, channelId); vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", rootDir); + await setBundledPluginsDirFixture(rootDir); const { normalizeChatChannelId } = await import("../channels/ids.js"); expect(normalizeChatChannelId(channelId)).toBe(channelId); vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(rootDir, "missing")); + await setBundledPluginsDirFixture(undefined); const { materializePluginAutoEnableCandidates } = await import("./plugin-auto-enable.js"); const result = materializePluginAutoEnableCandidates({ diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts index 0cd73f16f1f..d288bbbda08 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.test.ts @@ -359,6 +359,15 @@ describe("handshake auth helpers", () => { authMethod: "token", }), ).toBe(true); + expect( + shouldSkipLocalBackendSelfPairing({ + connectParams, + locality: "shared_secret_loopback_local", + hasBrowserOriginHeader: false, + sharedAuthOk: true, + authMethod: "token", + }), + ).toBe(true); expect( shouldSkipLocalBackendSelfPairing({ connectParams, diff --git a/src/gateway/server/ws-connection/handshake-auth-helpers.ts b/src/gateway/server/ws-connection/handshake-auth-helpers.ts index 8f51193af46..3903ede1147 100644 --- a/src/gateway/server/ws-connection/handshake-auth-helpers.ts +++ b/src/gateway/server/ws-connection/handshake-auth-helpers.ts @@ -265,7 +265,7 @@ export function shouldSkipLocalBackendSelfPairing(params: { const usesSharedSecretAuth = params.authMethod === "token" || params.authMethod === "password"; const usesDeviceTokenAuth = params.authMethod === "device-token"; return ( - params.locality === "direct_local" && + (params.locality === "direct_local" || params.locality === "shared_secret_loopback_local") && !params.hasBrowserOriginHeader && ((params.sharedAuthOk && usesSharedSecretAuth) || usesDeviceTokenAuth) ); diff --git a/src/plugin-sdk/facade-loader.test.ts b/src/plugin-sdk/facade-loader.test.ts index e5a0217a1eb..8bf66272567 100644 --- a/src/plugin-sdk/facade-loader.test.ts +++ b/src/plugin-sdk/facade-loader.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js"; import { clearBundledRuntimeDependencyNodePaths, resolveBundledRuntimeDependencyInstallRoot, @@ -73,6 +74,11 @@ function createBundledPluginDir(prefix: string, marker: string): string { return rootDir; } +function useBundledPluginDirOverrideForTest(dir: string): void { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + setBundledPluginsDirOverrideForTest(dir); +} + function createThrowingPluginDir(prefix: string): string { const rootDir = createTrustedBundledFixtureRoot(prefix); const pluginDir = path.join(rootDir, "bad"); @@ -206,6 +212,7 @@ afterEach(() => { } resetFacadeLoaderStateForTest(); setFacadeLoaderJitiFactoryForTest(undefined); + setBundledPluginsDirOverrideForTest(undefined); clearBundledRuntimeDependencyNodePaths(); delete (globalThis as typeof globalThis & Record)[FACADE_LOADER_GLOBAL]; if (originalBundledPluginsDir === undefined) { @@ -230,14 +237,14 @@ describe("plugin-sdk facade loader", () => { const overrideA = createBundledPluginDir("openclaw-facade-loader-a-", "override-a"); const overrideB = createBundledPluginDir("openclaw-facade-loader-b-", "override-b"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = overrideA; + useBundledPluginDirOverrideForTest(overrideA); const fromA = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ dirName: "demo", artifactBasename: "api.js", }); expect(fromA.marker).toBe("override-a"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = overrideB; + useBundledPluginDirOverrideForTest(overrideB); const fromB = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ dirName: "demo", artifactBasename: "api.js", @@ -246,7 +253,8 @@ describe("plugin-sdk facade loader", () => { }); it("falls back to package source surfaces when an override dir lacks a bundled plugin", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = createTempDirSync("openclaw-facade-loader-empty-"); + const overrideDir = createTrustedBundledFixtureRoot("openclaw-facade-loader-empty-"); + useBundledPluginDirOverrideForTest(overrideDir); const loaded = loadBundledPluginPublicSurfaceModuleSync<{ closeTrackedBrowserTabsForSessions: unknown; @@ -272,7 +280,7 @@ describe("plugin-sdk facade loader", () => { it("shares loaded facade ids with facade-runtime", () => { const dir = createBundledPluginDir("openclaw-facade-loader-ids-", "identity-check"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + useBundledPluginDirOverrideForTest(dir); const first = loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ dirName: "demo", @@ -301,7 +309,7 @@ describe("plugin-sdk facade loader", () => { 'export const marker = "windows-dist-ok";\n', "utf8", ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = bundledPluginsDir; + useBundledPluginDirOverrideForTest(bundledPluginsDir); const createJitiCalls: Parameters[] = []; setFacadeLoaderJitiFactoryForTest(((...args) => { @@ -338,7 +346,7 @@ describe("plugin-sdk facade loader", () => { marker: "staged", prefix: "openclaw-facade-loader-runtime-deps-", }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = fixture.bundledPluginsDir; + useBundledPluginDirOverrideForTest(fixture.bundledPluginsDir); process.env.OPENCLAW_PLUGIN_STAGE_DIR = fixture.stageRoot; await expect(import(pathToFileURL(fixture.modulePath).href)).rejects.toMatchObject({ @@ -367,6 +375,7 @@ describe("plugin-sdk facade loader", () => { marker: "staged", prefix: "openclaw-facade-loader-built-async-", }); + setBundledPluginsDirOverrideForTest(fixture.bundledPluginsDir); const loaded = await loadBundledPluginPublicSurfaceModule<{ marker: string; @@ -387,7 +396,7 @@ describe("plugin-sdk facade loader", () => { it("breaks circular facade re-entry during module evaluation", () => { const dir = createCircularPluginDir("openclaw-facade-loader-circular-"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + useBundledPluginDirOverrideForTest(dir); (globalThis as typeof globalThis & Record)[FACADE_LOADER_GLOBAL] = loadBundledPluginPublicSurfaceModuleSync; @@ -401,7 +410,7 @@ describe("plugin-sdk facade loader", () => { it("clears the cache on load failure so retries re-execute", () => { const dir = createThrowingPluginDir("openclaw-facade-loader-throw-"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + useBundledPluginDirOverrideForTest(dir); expect(() => loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ diff --git a/src/plugin-sdk/facade-runtime.test.ts b/src/plugin-sdk/facade-runtime.test.ts index 13a62d83de7..5a80d6f1410 100644 --- a/src/plugin-sdk/facade-runtime.test.ts +++ b/src/plugin-sdk/facade-runtime.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js"; +import { setBundledPluginsDirOverrideForTest } from "../plugins/bundled-dir.js"; import { createPluginActivationSource, normalizePluginsConfig } from "../plugins/config-state.js"; import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; @@ -59,6 +60,11 @@ function createBundledPluginDir(prefix: string, marker: string): string { return rootDir; } +function useBundledPluginDirOverrideForTest(dir: string): void { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + setBundledPluginsDirOverrideForTest(dir); +} + function createThrowingPluginDir(prefix: string): string { const rootDir = createTrustedBundledFixtureRoot(prefix); const pluginDir = path.join(rootDir, "bad"); @@ -86,6 +92,7 @@ afterEach(() => { clearRuntimeConfigSnapshot(); resetFacadeRuntimeStateForTest(); resetFacadeActivationCheckRuntimeStateForTest(); + setBundledPluginsDirOverrideForTest(undefined); clearPluginDiscoveryCache(); clearPluginManifestRegistryCache(); vi.doUnmock("../plugins/manifest-registry.js"); @@ -111,7 +118,7 @@ describe("plugin-sdk facade runtime", () => { const overrideA = createBundledPluginDir("openclaw-facade-runtime-a-", "override-a"); const overrideB = createBundledPluginDir("openclaw-facade-runtime-b-", "override-b"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = overrideA; + useBundledPluginDirOverrideForTest(overrideA); const fromA = __testing.resolveFacadeModuleLocation({ dirName: "demo", artifactBasename: "api.js", @@ -121,7 +128,7 @@ describe("plugin-sdk facade runtime", () => { boundaryRoot: overrideA, }); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = overrideB; + useBundledPluginDirOverrideForTest(overrideB); const fromB = __testing.resolveFacadeModuleLocation({ dirName: "demo", artifactBasename: "api.js", @@ -133,20 +140,18 @@ describe("plugin-sdk facade runtime", () => { }); it("falls back to package source surfaces when an override dir is partial", () => { - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = createBundledPluginDir( - "openclaw-facade-runtime-partial-", - "partial", - ); + const overrideDir = createTrustedBundledFixtureRoot("openclaw-facade-runtime-empty-"); + useBundledPluginDirOverrideForTest(overrideDir); - expect( - __testing.resolveFacadeModuleLocation({ - dirName: "browser", - artifactBasename: "browser-maintenance.js", - }), - ).toEqual({ - modulePath: path.resolve("extensions/browser/browser-maintenance.ts"), - boundaryRoot: path.resolve("."), + const resolved = __testing.resolveFacadeModuleLocation({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", }); + + expect(resolved?.boundaryRoot).not.toBe(overrideDir); + expect(resolved?.modulePath).toMatch( + /(?:^|\/)(?:extensions|dist-runtime\/extensions)\/browser\/browser-maintenance\.(?:ts|js)$/u, + ); }); it("does not fall back to package source surfaces when bundled plugins are disabled", () => { @@ -163,7 +168,7 @@ describe("plugin-sdk facade runtime", () => { it("returns the same object identity on repeated calls (sentinel consistency)", () => { const dir = createBundledPluginDir("openclaw-facade-identity-", "identity-check"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + useBundledPluginDirOverrideForTest(dir); const location = { modulePath: path.join(dir, "demo", "api.js"), boundaryRoot: dir, @@ -245,7 +250,7 @@ describe("plugin-sdk facade runtime", () => { }); it("clears the cache on load failure so retries re-execute", () => { const dir = createThrowingPluginDir("openclaw-facade-throw-"); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + useBundledPluginDirOverrideForTest(dir); expect(() => loadBundledPluginPublicSurfaceModuleSync<{ marker: string }>({ @@ -479,7 +484,7 @@ describe("plugin-sdk facade runtime", () => { }), "utf8", ); - process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = dir; + useBundledPluginDirOverrideForTest(dir); setRuntimeConfigSnapshot( { plugins: {}, diff --git a/src/plugins/bundled-dir.ts b/src/plugins/bundled-dir.ts index 6f3108e123e..76cf6070d5d 100644 --- a/src/plugins/bundled-dir.ts +++ b/src/plugins/bundled-dir.ts @@ -7,6 +7,7 @@ import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { resolveUserPath } from "../utils.js"; const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bundled-plugins"); +let bundledPluginsDirOverrideForTest: string | undefined; export function areBundledPluginsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_PLUGINS); @@ -173,6 +174,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): return resolveDisabledBundledPluginsDir(); } + if (bundledPluginsDirOverrideForTest) { + return bundledPluginsDirOverrideForTest; + } + const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim(); let rejectedExistingOverride: string | null = null; if (override) { @@ -248,3 +253,10 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): return undefined; } + +export function setBundledPluginsDirOverrideForTest(dir: string | undefined): void { + if (process.env.VITEST !== "true" && process.env.NODE_ENV !== "test") { + throw new Error("setBundledPluginsDirOverrideForTest is only available in tests"); + } + bundledPluginsDirOverrideForTest = dir; +}