refactor: remove remaining channel and gateway boundary leaks

This commit is contained in:
Peter Steinberger
2026-04-05 20:48:00 +01:00
parent 5cadf069e9
commit 8806ef804e
12 changed files with 344 additions and 343 deletions

View File

@@ -3,21 +3,24 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { classifyBundledExtensionSourcePath } from "../../../../scripts/lib/extension-source-classifier.mjs";
import {
BUNDLED_PLUGIN_PATH_PREFIX,
BUNDLED_PLUGIN_ROOT_DIR,
bundledPluginFile,
} from "../../../../test/helpers/bundled-plugin-paths.js";
import { loadPluginManifestRegistry } from "../../../plugins/manifest-registry.js";
import { GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES } from "../../../plugins/public-artifacts.js";
const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
const REPO_ROOT = resolve(ROOT_DIR, "..");
const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set(GUARDED_EXTENSION_PUBLIC_SURFACE_BASENAMES);
ALLOWED_EXTENSION_PUBLIC_SURFACES.add("test-api.js");
const BUNDLED_EXTENSION_IDS = readdirSync(resolve(REPO_ROOT, "extensions"), { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name !== "shared")
.map((entry) => entry.name)
.toSorted((left, right) => right.length - left.length);
const BUNDLED_PLUGIN_ROOT_DIR = "extensions";
const bundledPluginRecords = loadPluginManifestRegistry({
cache: true,
config: {},
}).plugins.filter((plugin) => plugin.origin === "bundled");
const bundledPluginRoots = new Map(
bundledPluginRecords.map((plugin) => [plugin.id, plugin.rootDir] as const),
);
const BUNDLED_EXTENSION_IDS = [...bundledPluginRoots.keys()].toSorted(
(left, right) => right.length - left.length,
);
const GUARDED_CHANNEL_EXTENSIONS = new Set([
"bluebubbles",
"discord",
@@ -44,6 +47,14 @@ const GUARDED_CHANNEL_EXTENSIONS = new Set([
// Shared config validation intentionally consumes this curated Telegram contract.
const ALLOWED_CORE_CHANNEL_SDK_SUBPATHS = new Set(["telegram-command-config"]);
function bundledPluginFile(pluginId: string, relativePath: string): string {
const rootDir = bundledPluginRoots.get(pluginId);
if (!rootDir) {
throw new Error(`missing bundled plugin root for ${pluginId}`);
}
return normalizePath(resolve(rootDir, relativePath));
}
type GuardedSource = {
path: string;
forbiddenPatterns: RegExp[];
@@ -320,18 +331,18 @@ function readSetupBarrelImportBlock(path: string): string {
}
function collectExtensionSourceFiles(): string[] {
const extensionsDir = normalizePath(resolve(ROOT_DIR, "..", "extensions"));
const sharedExtensionsDir = normalizePath(resolve(extensionsDir, "shared"));
extensionSourceFilesCache = collectSourceFiles(extensionSourceFilesCache, {
rootDir: resolve(ROOT_DIR, "..", "extensions"),
shouldSkipPath: (normalizedFullPath) =>
normalizedFullPath.includes(sharedExtensionsDir) ||
normalizedFullPath.includes(`${extensionsDir}/shared/`),
shouldSkipEntry: ({ entryName, normalizedFullPath }) =>
classifyBundledExtensionSourcePath(normalizedFullPath).isTestLike ||
entryName === "api.ts" ||
entryName === "runtime-api.ts",
});
if (extensionSourceFilesCache) {
return extensionSourceFilesCache;
}
extensionSourceFilesCache = bundledPluginRecords.flatMap((plugin) =>
collectSourceFiles(undefined, {
rootDir: plugin.rootDir,
shouldSkipEntry: ({ entryName, normalizedFullPath }) =>
classifyBundledExtensionSourcePath(normalizedFullPath).isTestLike ||
entryName === "api.ts" ||
entryName === "runtime-api.ts",
}),
);
return extensionSourceFilesCache;
}
@@ -361,8 +372,12 @@ function collectCoreSourceFiles(): string[] {
function collectExtensionFiles(extensionId: string): string[] {
const cached = extensionFilesCache.get(extensionId);
const rootDir = bundledPluginRoots.get(extensionId);
if (!rootDir) {
return [];
}
const files = collectSourceFiles(cached, {
rootDir: resolve(ROOT_DIR, "..", "extensions", extensionId),
rootDir,
shouldSkipEntry: ({ entryName, normalizedFullPath }) =>
classifyBundledExtensionSourcePath(normalizedFullPath).isTestLike ||
entryName === "runtime-api.ts",
@@ -406,7 +421,7 @@ function getSourceAnalysis(path: string): SourceAnalysis {
text,
importSpecifiers,
extensionImports: importSpecifiers.filter((specifier) =>
specifier.includes(BUNDLED_PLUGIN_PATH_PREFIX),
specifier.includes(`/${BUNDLED_PLUGIN_ROOT_DIR}/`),
),
} satisfies SourceAnalysis;
sourceAnalysisCache.set(fullPath, analysis);

View File

@@ -27,17 +27,6 @@ vi.mock("../../plugin-sdk/facade-runtime.js", async () => {
};
});
vi.mock("../../plugins/bundled-plugin-metadata.js", async () => {
const actual = await vi.importActual<typeof import("../../plugins/bundled-plugin-metadata.js")>(
"../../plugins/bundled-plugin-metadata.js",
);
return {
...actual,
resolveBundledPluginPublicSurfacePath: ({ dirName }: { dirName: string }) =>
dirName === fallbackState.activeDirName ? `/tmp/${dirName}/session-key-api.js` : null,
};
});
import { resolveSessionConversationRef } from "./session-conversation.js";
describe("session conversation bundled fallback", () => {

View File

@@ -1,7 +1,4 @@
import { fileURLToPath } from "node:url";
import { tryLoadActivatedBundledPluginPublicSurfaceModuleSync } from "../../plugin-sdk/facade-runtime.js";
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
import { resolveBundledPluginPublicSurfacePath } from "../../plugins/bundled-plugin-metadata.js";
import {
parseRawSessionConversationRef,
parseThreadSessionSuffix,
@@ -47,7 +44,6 @@ type BundledSessionKeyModule = {
) => SessionConversationHookResult | null;
};
const OPENCLAW_PACKAGE_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
const SESSION_KEY_API_ARTIFACT_BASENAME = "session-key-api.js";
type NormalizedSessionConversationResolution = ResolvedSessionConversation & {
@@ -140,16 +136,6 @@ function resolveBundledSessionConversationFallback(params: {
rawId: string;
}): NormalizedSessionConversationResolution | null {
const dirName = normalizeResolvedChannel(params.channel);
if (
!resolveBundledPluginPublicSurfacePath({
rootDir: OPENCLAW_PACKAGE_ROOT,
bundledPluginsDir: resolveBundledPluginsDir(),
dirName,
artifactBasename: SESSION_KEY_API_ARTIFACT_BASENAME,
})
) {
return null;
}
let resolveSessionConversation: BundledSessionKeyModule["resolveSessionConversation"];
try {
resolveSessionConversation =

View File

@@ -1,7 +1,7 @@
import { vi } from "vitest";
import { createEmptyPluginRegistry, type PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { setTestPluginRegistry } from "./test-helpers.mocks.js";
import { setTestPluginRegistry } from "./test-helpers.plugin-registry.js";
export const registryState: { registry: PluginRegistry } = {
registry: createEmptyPluginRegistry(),

View File

@@ -18,7 +18,7 @@ import {
withGatewayServer,
writeSessionStore,
} from "./test-helpers.js";
import { agentCommand } from "./test-helpers.mocks.js";
import { agentCommand } from "./test-helpers.runtime-state.js";
import { installConnectedControlUiServerSuite } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" });

View File

@@ -6,7 +6,7 @@ import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import { createPreauthConnectionBudget } from "./server/preauth-connection-budget.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { testState } from "./test-helpers.mocks.js";
import { testState } from "./test-helpers.runtime-state.js";
import {
createGatewaySuiteHarness,
installGatewayTestHooks,

View File

@@ -5,7 +5,7 @@ import { afterEach, describe, expect, test, vi } from "vitest";
import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
import { emitSessionLifecycleEvent } from "../sessions/session-lifecycle-events.js";
import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js";
import { testState } from "./test-helpers.mocks.js";
import { testState } from "./test-helpers.runtime-state.js";
import {
connectOk,
createGatewaySuiteHarness,

View File

@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js";
import { testState } from "./test-helpers.mocks.js";
import { testState } from "./test-helpers.runtime-state.js";
import {
connectReq,
createGatewaySuiteHarness,

View File

@@ -0,0 +1,295 @@
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { AgentBinding } from "../config/types.agents.js";
import type { OpenClawConfig } from "../config/types.js";
import { testConfigRoot, testIsNixMode, testState } from "./test-helpers.runtime-state.js";
type GatewayConfigModule = typeof import("../config/config.js");
export function createGatewayConfigModuleMock(actual: GatewayConfigModule): GatewayConfigModule {
const resolveConfigPath = () => path.join(testConfigRoot.value, "openclaw.json");
const hashConfigRaw = (raw: string | null) =>
crypto
.createHash("sha256")
.update(raw ?? "")
.digest("hex");
const composeTestConfig = (baseConfig: Record<string, unknown>) => {
const fileAgents =
baseConfig.agents &&
typeof baseConfig.agents === "object" &&
!Array.isArray(baseConfig.agents)
? (baseConfig.agents as Record<string, unknown>)
: {};
const fileDefaults =
fileAgents.defaults &&
typeof fileAgents.defaults === "object" &&
!Array.isArray(fileAgents.defaults)
? (fileAgents.defaults as Record<string, unknown>)
: {};
const defaults = {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: path.join(os.tmpdir(), "openclaw-gateway-test"),
...fileDefaults,
...testState.agentConfig,
};
const agents = testState.agentsConfig
? { ...fileAgents, ...testState.agentsConfig, defaults }
: { ...fileAgents, defaults };
const fileBindings = Array.isArray(baseConfig.bindings)
? (baseConfig.bindings as AgentBinding[])
: undefined;
const fileChannels =
baseConfig.channels &&
typeof baseConfig.channels === "object" &&
!Array.isArray(baseConfig.channels)
? ({ ...(baseConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
: {};
const overrideChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...testState.channelsConfig }
: {};
const mergedChannels = { ...fileChannels, ...overrideChannels };
if (testState.allowFrom !== undefined) {
const existing =
mergedChannels.whatsapp &&
typeof mergedChannels.whatsapp === "object" &&
!Array.isArray(mergedChannels.whatsapp)
? (mergedChannels.whatsapp as Record<string, unknown>)
: {};
mergedChannels.whatsapp = {
...existing,
allowFrom: testState.allowFrom,
};
}
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
const fileSession =
baseConfig.session &&
typeof baseConfig.session === "object" &&
!Array.isArray(baseConfig.session)
? (baseConfig.session as Record<string, unknown>)
: {};
const session: Record<string, unknown> = {
...fileSession,
mainKey: fileSession.mainKey ?? "main",
};
if (typeof testState.sessionStorePath === "string") {
session.store = testState.sessionStorePath;
}
if (testState.sessionConfig) {
Object.assign(session, testState.sessionConfig);
}
const fileGateway =
baseConfig.gateway &&
typeof baseConfig.gateway === "object" &&
!Array.isArray(baseConfig.gateway)
? ({ ...(baseConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (testState.gatewayBind) {
fileGateway.bind = testState.gatewayBind;
}
if (testState.gatewayAuth) {
fileGateway.auth = testState.gatewayAuth;
}
if (testState.gatewayControlUi) {
const fileControlUi =
fileGateway.controlUi &&
typeof fileGateway.controlUi === "object" &&
!Array.isArray(fileGateway.controlUi)
? (fileGateway.controlUi as Record<string, unknown>)
: {};
fileGateway.controlUi = {
...fileControlUi,
...testState.gatewayControlUi,
};
}
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost =
baseConfig.canvasHost &&
typeof baseConfig.canvasHost === "object" &&
!Array.isArray(baseConfig.canvasHost)
? ({ ...(baseConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.canvasHostPort === "number") {
fileCanvasHost.port = testState.canvasHostPort;
}
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
const hooks = testState.hooksConfig ?? baseConfig.hooks;
const fileCron =
baseConfig.cron && typeof baseConfig.cron === "object" && !Array.isArray(baseConfig.cron)
? ({ ...(baseConfig.cron as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.cronEnabled === "boolean") {
fileCron.enabled = testState.cronEnabled;
}
if (typeof testState.cronStorePath === "string") {
fileCron.store = testState.cronStorePath;
}
const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined;
return {
...baseConfig,
agents,
bindings: testState.bindingsConfig ?? fileBindings,
channels,
session,
gateway,
canvasHost,
hooks,
cron,
} as OpenClawConfig;
};
const readConfigFileSnapshot = async () => {
if (testState.legacyIssues.length > 0) {
const raw = JSON.stringify(testState.legacyParsed ?? {});
return {
path: resolveConfigPath(),
exists: true,
raw,
parsed: testState.legacyParsed ?? {},
valid: false,
config: {},
hash: hashConfigRaw(raw),
issues: testState.legacyIssues.map((issue) => ({
path: issue.path,
message: issue.message,
})),
legacyIssues: testState.legacyIssues,
};
}
const configPath = resolveConfigPath();
try {
await fs.access(configPath);
} catch {
return {
path: configPath,
exists: false,
raw: null,
parsed: {},
valid: true,
config: composeTestConfig({}),
hash: hashConfigRaw(null),
issues: [],
legacyIssues: [],
};
}
try {
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
path: configPath,
exists: true,
raw,
parsed,
valid: true,
config: composeTestConfig(parsed),
hash: hashConfigRaw(raw),
issues: [],
legacyIssues: [],
};
} catch (err) {
return {
path: configPath,
exists: true,
raw: null,
parsed: {},
valid: false,
config: {},
hash: hashConfigRaw(null),
issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
};
}
};
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
const configPath = resolveConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
await fs.writeFile(configPath, raw, "utf-8");
actual.resetConfigRuntimeState();
});
const readConfigFileSnapshotForWrite = async () => ({
snapshot: await readConfigFileSnapshot(),
writeOptions: {
expectedConfigPath: resolveConfigPath(),
},
});
const loadTestConfig = () => {
const configPath = resolveConfigPath();
let fileConfig: Record<string, unknown> = {};
try {
if (fsSync.existsSync(configPath)) {
const raw = fsSync.readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(raw) as Record<string, unknown>;
}
} catch {
fileConfig = {};
}
return applyPluginAutoEnable({
config: composeTestConfig(fileConfig),
env: process.env,
}).config;
};
const loadRuntimeAwareTestConfig = () => {
const runtimeSnapshot = actual.getRuntimeConfigSnapshot();
if (runtimeSnapshot) {
return runtimeSnapshot;
}
const config = loadTestConfig();
actual.setRuntimeConfigSnapshot(config);
return config;
};
return {
...actual,
get CONFIG_PATH() {
return resolveConfigPath();
},
get STATE_DIR() {
return path.dirname(resolveConfigPath());
},
get isNixMode() {
return testIsNixMode.value;
},
migrateLegacyConfig: (raw: unknown) => ({
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
changes: testState.migrationChanges,
}),
applyConfigOverrides: (cfg: OpenClawConfig) =>
composeTestConfig(cfg as Record<string, unknown>),
loadConfig: loadRuntimeAwareTestConfig,
getRuntimeConfig: loadRuntimeAwareTestConfig,
parseConfigJson5: (raw: string) => {
try {
return { ok: true, parsed: JSON.parse(raw) as unknown };
} catch (err) {
return { ok: false, error: String(err) };
}
},
validateConfigObject: (parsed: unknown) => ({
ok: true,
config: parsed as Record<string, unknown>,
issues: [],
}),
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
writeConfigFile,
};
}

View File

@@ -1,12 +1,6 @@
import crypto from "node:crypto";
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import type { AgentBinding } from "../config/types.agents.js";
import { createGatewayConfigModuleMock } from "./test-helpers.config-runtime.js";
import {
getTestPluginRegistry,
resetTestPluginRegistry,
@@ -26,7 +20,6 @@ import {
sendWhatsAppMock,
sessionStoreSaveDelayMs,
setTestConfigRoot,
testConfigRoot,
testIsNixMode,
testState,
testTailnetIPv4,
@@ -194,286 +187,7 @@ vi.mock("../config/sessions.js", async () => {
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
const resolveConfigPath = () => path.join(testConfigRoot.value, "openclaw.json");
const hashConfigRaw = (raw: string | null) =>
crypto
.createHash("sha256")
.update(raw ?? "")
.digest("hex");
const composeTestConfig = (baseConfig: Record<string, unknown>) => {
const fileAgents =
baseConfig.agents &&
typeof baseConfig.agents === "object" &&
!Array.isArray(baseConfig.agents)
? (baseConfig.agents as Record<string, unknown>)
: {};
const fileDefaults =
fileAgents.defaults &&
typeof fileAgents.defaults === "object" &&
!Array.isArray(fileAgents.defaults)
? (fileAgents.defaults as Record<string, unknown>)
: {};
const defaults = {
model: { primary: "anthropic/claude-opus-4-6" },
workspace: path.join(os.tmpdir(), "openclaw-gateway-test"),
...fileDefaults,
...testState.agentConfig,
};
const agents = testState.agentsConfig
? { ...fileAgents, ...testState.agentsConfig, defaults }
: { ...fileAgents, defaults };
const fileBindings = Array.isArray(baseConfig.bindings)
? (baseConfig.bindings as AgentBinding[])
: undefined;
const fileChannels =
baseConfig.channels &&
typeof baseConfig.channels === "object" &&
!Array.isArray(baseConfig.channels)
? ({ ...(baseConfig.channels as Record<string, unknown>) } as Record<string, unknown>)
: {};
const overrideChannels =
testState.channelsConfig && typeof testState.channelsConfig === "object"
? { ...testState.channelsConfig }
: {};
const mergedChannels = { ...fileChannels, ...overrideChannels };
if (testState.allowFrom !== undefined) {
const existing =
mergedChannels.whatsapp &&
typeof mergedChannels.whatsapp === "object" &&
!Array.isArray(mergedChannels.whatsapp)
? (mergedChannels.whatsapp as Record<string, unknown>)
: {};
mergedChannels.whatsapp = {
...existing,
allowFrom: testState.allowFrom,
};
}
const channels = Object.keys(mergedChannels).length > 0 ? mergedChannels : undefined;
const fileSession =
baseConfig.session &&
typeof baseConfig.session === "object" &&
!Array.isArray(baseConfig.session)
? (baseConfig.session as Record<string, unknown>)
: {};
const session: Record<string, unknown> = {
...fileSession,
mainKey: fileSession.mainKey ?? "main",
};
if (typeof testState.sessionStorePath === "string") {
session.store = testState.sessionStorePath;
}
if (testState.sessionConfig) {
Object.assign(session, testState.sessionConfig);
}
const fileGateway =
baseConfig.gateway &&
typeof baseConfig.gateway === "object" &&
!Array.isArray(baseConfig.gateway)
? ({ ...(baseConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (testState.gatewayBind) {
fileGateway.bind = testState.gatewayBind;
}
if (testState.gatewayAuth) {
fileGateway.auth = testState.gatewayAuth;
}
if (testState.gatewayControlUi) {
const fileControlUi =
fileGateway.controlUi &&
typeof fileGateway.controlUi === "object" &&
!Array.isArray(fileGateway.controlUi)
? (fileGateway.controlUi as Record<string, unknown>)
: {};
fileGateway.controlUi = {
...fileControlUi,
...testState.gatewayControlUi,
};
}
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
const fileCanvasHost =
baseConfig.canvasHost &&
typeof baseConfig.canvasHost === "object" &&
!Array.isArray(baseConfig.canvasHost)
? ({ ...(baseConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.canvasHostPort === "number") {
fileCanvasHost.port = testState.canvasHostPort;
}
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
const hooks = testState.hooksConfig ?? baseConfig.hooks;
const fileCron =
baseConfig.cron && typeof baseConfig.cron === "object" && !Array.isArray(baseConfig.cron)
? ({ ...(baseConfig.cron as Record<string, unknown>) } as Record<string, unknown>)
: {};
if (typeof testState.cronEnabled === "boolean") {
fileCron.enabled = testState.cronEnabled;
}
if (typeof testState.cronStorePath === "string") {
fileCron.store = testState.cronStorePath;
}
const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined;
return {
...baseConfig,
agents,
bindings: testState.bindingsConfig ?? fileBindings,
channels,
session,
gateway,
canvasHost,
hooks,
cron,
} as OpenClawConfig;
};
const readConfigFileSnapshot = async () => {
if (testState.legacyIssues.length > 0) {
const raw = JSON.stringify(testState.legacyParsed ?? {});
return {
path: resolveConfigPath(),
exists: true,
raw,
parsed: testState.legacyParsed ?? {},
valid: false,
config: {},
hash: hashConfigRaw(raw),
issues: testState.legacyIssues.map((issue) => ({
path: issue.path,
message: issue.message,
})),
legacyIssues: testState.legacyIssues,
};
}
const configPath = resolveConfigPath();
try {
await fs.access(configPath);
} catch {
return {
path: configPath,
exists: false,
raw: null,
parsed: {},
valid: true,
config: composeTestConfig({}),
hash: hashConfigRaw(null),
issues: [],
legacyIssues: [],
};
}
try {
const raw = await fs.readFile(configPath, "utf-8");
const parsed = JSON.parse(raw) as Record<string, unknown>;
return {
path: configPath,
exists: true,
raw,
parsed,
valid: true,
config: composeTestConfig(parsed),
hash: hashConfigRaw(raw),
issues: [],
legacyIssues: [],
};
} catch (err) {
return {
path: configPath,
exists: true,
raw: null,
parsed: {},
valid: false,
config: {},
hash: hashConfigRaw(null),
issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
};
}
};
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
const configPath = resolveConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
await fs.writeFile(configPath, raw, "utf-8");
actual.resetConfigRuntimeState();
});
const readConfigFileSnapshotForWrite = async () => ({
snapshot: await readConfigFileSnapshot(),
writeOptions: {
expectedConfigPath: resolveConfigPath(),
},
});
const loadTestConfig = () => {
const configPath = resolveConfigPath();
let fileConfig: Record<string, unknown> = {};
try {
if (fsSync.existsSync(configPath)) {
const raw = fsSync.readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(raw) as Record<string, unknown>;
}
} catch {
fileConfig = {};
}
return applyPluginAutoEnable({
config: composeTestConfig(fileConfig),
env: process.env,
}).config;
};
const loadRuntimeAwareTestConfig = () => {
const runtimeSnapshot = actual.getRuntimeConfigSnapshot();
if (runtimeSnapshot) {
return runtimeSnapshot;
}
const config = loadTestConfig();
actual.setRuntimeConfigSnapshot(config);
return config;
};
return {
...actual,
get CONFIG_PATH() {
return resolveConfigPath();
},
get STATE_DIR() {
return path.dirname(resolveConfigPath());
},
get isNixMode() {
return testIsNixMode.value;
},
migrateLegacyConfig: (raw: unknown) => ({
config: testState.migrationConfig ?? (raw as Record<string, unknown>),
changes: testState.migrationChanges,
}),
applyConfigOverrides: (cfg: OpenClawConfig) =>
composeTestConfig(cfg as Record<string, unknown>),
loadConfig: loadRuntimeAwareTestConfig,
getRuntimeConfig: loadRuntimeAwareTestConfig,
parseConfigJson5: (raw: string) => {
try {
return { ok: true, parsed: JSON.parse(raw) as unknown };
} catch (err) {
return { ok: false, error: String(err) };
}
},
validateConfigObject: (parsed: unknown) => ({
ok: true,
config: parsed as Record<string, unknown>,
issues: [],
}),
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
writeConfigFile,
};
return createGatewayConfigModuleMock(actual);
});
vi.mock("../agents/pi-embedded.js", async () => {

View File

@@ -32,13 +32,13 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-cha
import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import type { GatewayServerOptions } from "./server.js";
import { resetTestPluginRegistry } from "./test-helpers.plugin-registry.js";
import {
agentCommand,
cronIsolatedRun,
embeddedRunMock,
getReplyFromConfig,
piSdkMock,
resetTestPluginRegistry,
sendWhatsAppMock,
sessionStoreSaveDelayMs,
setTestConfigRoot,
@@ -46,7 +46,7 @@ import {
testTailscaleWhois,
testState,
testTailnetIPv4,
} from "./test-helpers.mocks.js";
} from "./test-helpers.runtime-state.js";
// Import lazily after test env/home setup so config/session paths resolve to test dirs.
// Keep one cached module per worker for speed.

View File

@@ -1,2 +1,4 @@
export * from "./test-helpers.runtime-state.js";
export * from "./test-helpers.plugin-registry.js";
export * from "./test-helpers.mocks.js";
export * from "./test-helpers.server.js";