refactor(config): simplify channel schema loading (#106462)

This commit is contained in:
Peter Steinberger
2026-07-13 07:57:23 -07:00
committed by GitHub
parent 69554995c1
commit 2ab8cbc66b
3 changed files with 70 additions and 309 deletions
@@ -253,7 +253,7 @@ async function collectBundledChannelConfigMetadata(params?: { repoRoot?: string
if (!modulePath) {
continue;
}
const surface = await loadChannelConfigSurfaceModule(modulePath, { repoRoot });
const surface = await loadChannelConfigSurfaceModule(modulePath);
if (!surface?.schema) {
continue;
}
+14 -153
View File
@@ -1,41 +1,13 @@
// Load Channel Config Surface script supports OpenClaw repository automation.
import { spawnSync } from "node:child_process";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import type { createJiti } from "jiti";
import { pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import { buildChannelConfigSchema } from "../src/channels/plugins/config-schema.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
} from "../src/plugins/sdk-alias.js";
type CreateJiti = typeof createJiti;
const jitiFactoryOverrideKey = Symbol.for("openclaw.channelConfigSurfaceJitiFactoryOverride");
const requireForJiti = createRequire(import.meta.url);
let createJitiLoaderFactory: CreateJiti | undefined;
function loadCreateJitiLoaderFactory(): CreateJiti {
const override = (
globalThis as typeof globalThis & {
[jitiFactoryOverrideKey]?: CreateJiti;
}
)[jitiFactoryOverrideKey];
if (override) {
return override;
}
if (createJitiLoaderFactory) {
return createJitiLoaderFactory;
}
const loaded = requireForJiti("jiti") as { createJiti?: CreateJiti };
if (typeof loaded.createJiti !== "function") {
throw new Error("jiti module did not export createJiti");
}
createJitiLoaderFactory = loaded.createJiti;
return createJitiLoaderFactory;
}
function isBuiltChannelConfigSchema(
value: unknown,
): value is { schema: Record<string, unknown>; uiHints?: Record<string, unknown> } {
@@ -76,132 +48,21 @@ function resolveConfigSchemaExport(
return null;
}
function resolveRepoRoot(): string {
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
}
function isMissingExecutableError(error: unknown): boolean {
if (!error || typeof error !== "object") {
return false;
}
return "code" in error && error.code === "ENOENT";
}
export async function loadChannelConfigSurfaceModule(
modulePath: string,
options?: { repoRoot?: string },
): Promise<{ schema: Record<string, unknown>; uiHints?: Record<string, unknown> } | null> {
const repoRoot = options?.repoRoot ?? resolveRepoRoot();
const loaderRepoRoot = resolveRepoRoot();
const bunBuildChannelConfigSchemaUrl = pathToFileURL(
path.join(loaderRepoRoot, "src/channels/plugins/config-schema.ts"),
).href;
const loadViaBun = (candidatePath: string) => {
const script = `
import { pathToFileURL } from "node:url";
const { buildChannelConfigSchema } = await import(${JSON.stringify(bunBuildChannelConfigSchemaUrl)});
const modulePath = process.env.OPENCLAW_CONFIG_SURFACE_MODULE;
if (!modulePath) {
throw new Error("missing OPENCLAW_CONFIG_SURFACE_MODULE");
}
const imported = await import(pathToFileURL(modulePath).href);
const isBuilt = (value) => Boolean(
value &&
typeof value === "object" &&
value.schema &&
typeof value.schema === "object"
);
const resolve = (mod) => {
for (const [name, value] of Object.entries(mod)) {
if (name.endsWith("ChannelConfigSchema") && isBuilt(value)) return value;
}
for (const [name, value] of Object.entries(mod)) {
if (!name.endsWith("ConfigSchema") || name.endsWith("AccountConfigSchema")) continue;
if (isBuilt(value)) return value;
if (value && typeof value === "object") return buildChannelConfigSchema(value);
}
for (const value of Object.values(mod)) {
if (isBuilt(value)) return value;
}
return null;
};
process.stdout.write(JSON.stringify(resolve(imported)));
`;
const result = spawnSync("bun", ["-e", script], {
cwd: repoRoot,
encoding: "utf8",
env: {
...process.env,
OPENCLAW_CONFIG_SURFACE_MODULE: path.resolve(candidatePath),
},
});
if (result.error) {
if (isMissingExecutableError(result.error)) {
return null;
}
throw result.error;
}
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || `bun loader failed for ${candidatePath}`);
}
return JSON.parse(result.stdout || "null") as {
schema: Record<string, unknown>;
uiHints?: Record<string, unknown>;
} | null;
};
const loadViaJiti = (candidatePath: string) => {
const resolvedPath = path.resolve(candidatePath);
const aliasMap = buildPluginLoaderAliasMap(resolvedPath, "", undefined, "src");
const jiti = loadCreateJitiLoaderFactory()(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
interopDefault: true,
tryNative: false,
moduleCache: false,
fsCache: false,
});
return jiti(resolvedPath) as Record<string, unknown>;
};
const loadViaNativeImport = async (candidatePath: string) => {
const imported = (await import(pathToFileURL(path.resolve(candidatePath)).href)) as Record<
string,
unknown
>;
return resolveConfigSchemaExport(imported);
};
const loadFromPath = async (
candidatePath: string,
): Promise<{ schema: Record<string, unknown>; uiHints?: Record<string, unknown> } | null> => {
try {
const resolved = await loadViaNativeImport(candidatePath);
if (resolved) {
return resolved;
}
} catch {
// Fall through to the compatibility loaders when the module needs custom
// plugin SDK aliasing or cannot be imported by the current Node loader.
}
try {
// Prefer the source-aware Jiti path so generated config metadata stays
// stable before and after build output exists in the repo.
const imported = loadViaJiti(candidatePath);
const resolved = resolveConfigSchemaExport(imported);
if (resolved) {
return resolved;
}
} catch {
// Fall back to Bun below when the source-aware loader cannot resolve the
// module graph in the current environment.
}
const bunLoaded = loadViaBun(candidatePath);
if (bunLoaded && isBuiltChannelConfigSchema(bunLoaded)) {
return bunLoaded;
}
return null;
};
return loadFromPath(modulePath);
const resolvedPath = path.resolve(modulePath);
const aliasMap = buildPluginLoaderAliasMap(resolvedPath, "", undefined, "src");
// Jiti 2.7 passes Windows drive paths raw to import(); use its source loader there.
// Disabled caches keep generation source-current.
const jiti = createJiti(import.meta.url, {
...buildPluginLoaderJitiOptions(aliasMap),
tryNative: process.platform !== "win32",
moduleCache: false,
fsCache: false,
});
const imported = await jiti.import<Record<string, unknown>>(resolvedPath);
return resolveConfigSchemaExport(imported);
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+55 -155
View File
@@ -1,186 +1,86 @@
// Verifies channel config loading surfaces visible plugin settings.
import fs from "node:fs";
import path from "node:path";
import type { createJiti as createJitiType } from "jiti";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { loadChannelConfigSurfaceModule } from "../../scripts/load-channel-config-surface.ts";
import { withTempDir } from "../test-helpers/temp-dir.js";
const jitiFactoryOverrideKey = Symbol.for("openclaw.channelConfigSurfaceJitiFactoryOverride");
function stubChannelConfigSurfaceJitiFactory(createJiti: typeof createJitiType): void {
(
globalThis as typeof globalThis & {
[jitiFactoryOverrideKey]?: typeof createJitiType;
}
)[jitiFactoryOverrideKey] = createJiti;
}
afterEach(() => {
delete (
globalThis as typeof globalThis & {
[jitiFactoryOverrideKey]?: typeof createJitiType;
}
)[jitiFactoryOverrideKey];
});
async function importLoaderWithMissingBun() {
const spawnSync = vi.fn(() => ({
error: Object.assign(new Error("bun not found"), { code: "ENOENT" }),
status: null,
stdout: "",
stderr: "",
}));
vi.doMock("node:child_process", () => ({ spawnSync }));
try {
const imported = await importFreshModule<
typeof import("../../scripts/load-channel-config-surface.ts")
>(import.meta.url, "../../scripts/load-channel-config-surface.ts?scope=missing-bun");
return { loadChannelConfigSurfaceModule: imported.loadChannelConfigSurfaceModule, spawnSync };
} finally {
vi.doUnmock("node:child_process");
}
}
async function importLoaderWithFailingJitiAndWorkingBun() {
const spawnSync = vi.fn(() => ({
error: undefined,
status: 0,
stdout: JSON.stringify({
schema: {
type: "object",
properties: {
ok: { type: "number" },
},
},
}),
stderr: "",
}));
const createJiti = vi.fn(() => () => {
throw new Error("jiti failed");
});
vi.doMock("node:child_process", () => ({ spawnSync }));
stubChannelConfigSurfaceJitiFactory(createJiti as unknown as typeof createJitiType);
try {
const imported = await importFreshModule<
typeof import("../../scripts/load-channel-config-surface.ts")
>(import.meta.url, "../../scripts/load-channel-config-surface.ts?scope=failing-jiti");
return {
loadChannelConfigSurfaceModule: imported.loadChannelConfigSurfaceModule,
spawnSync,
createJiti,
};
} finally {
vi.doUnmock("node:child_process");
}
}
function expectedOkSchema(type: string) {
return {
schema: {
type: "object",
properties: {
ok: { type },
},
},
};
}
function createDemoConfigSchemaModule(repoRoot: string, sourceLines?: string[]) {
function createDemoModule(repoRoot: string, filename: string, source: string): string {
const packageRoot = path.join(repoRoot, "extensions", "demo");
const modulePath = path.join(packageRoot, "src", "config-schema.js");
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
const modulePath = path.join(packageRoot, "src", filename);
fs.mkdirSync(path.dirname(modulePath), { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "@openclaw/demo", type: "module" }, null, 2),
JSON.stringify({ name: "@openclaw/demo", type: "module" }),
"utf8",
);
fs.writeFileSync(
modulePath,
[
...(sourceLines ?? [
"export const DemoChannelConfigSchema = {",
" schema: {",
" type: 'object',",
" properties: { ok: { type: 'string' } },",
" },",
"};",
]),
"",
].join("\n"),
"utf8",
);
return { packageRoot, modulePath };
fs.writeFileSync(modulePath, source, "utf8");
return modulePath;
}
describe("loadChannelConfigSurfaceModule", () => {
it("prefers the source-aware loader over bun when both succeed", async () => {
it("loads TypeScript through plugin SDK aliases", async () => {
await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => {
const { modulePath } = createDemoConfigSchemaModule(repoRoot);
const modulePath = createDemoModule(
repoRoot,
"config-schema.ts",
`
import { buildJsonChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
const spawnSync = vi.fn(() => ({
error: undefined,
status: 0,
stdout: JSON.stringify({
schema: {
type: "object",
properties: {
ok: { type: "number" },
},
},
}),
stderr: "",
}));
vi.doMock("node:child_process", () => ({ spawnSync }));
const label: string = "OK";
export const DemoChannelConfigSchema = buildJsonChannelConfigSchema(
{ type: "object", additionalProperties: false, properties: {} },
{ uiHints: { ok: { label } } },
);
`,
);
try {
const imported = await importFreshModule<
typeof import("../../scripts/load-channel-config-surface.ts")
>(import.meta.url, "../../scripts/load-channel-config-surface.ts?scope=prefer-jiti");
const surface = await imported.loadChannelConfigSurfaceModule(modulePath, { repoRoot });
expect(surface).toStrictEqual(expectedOkSchema("string"));
expect(spawnSync).not.toHaveBeenCalled();
} finally {
vi.doUnmock("node:child_process");
}
await expect(loadChannelConfigSurfaceModule(modulePath)).resolves.toMatchObject({
schema: { type: "object", additionalProperties: false, properties: {} },
uiHints: { ok: { label: "OK" } },
});
});
});
it("does not require bun when the source-aware loader succeeds", async () => {
it("wraps a raw config schema loaded natively", async () => {
await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => {
const { modulePath } = createDemoConfigSchemaModule(repoRoot);
const modulePath = createDemoModule(
repoRoot,
"config-schema.mjs",
`
export const DemoConfigSchema = {
toJSONSchema: () => ({
type: "object",
properties: { count: { type: "number" } },
}),
safeParse: (value) => ({ success: true, data: value }),
};
`,
);
const { loadChannelConfigSurfaceModule: loadWithMissingBun, spawnSync } =
await importLoaderWithMissingBun();
const surface = await loadWithMissingBun(modulePath, { repoRoot });
expect(surface).toStrictEqual(expectedOkSchema("string"));
expect(spawnSync).not.toHaveBeenCalled();
await expect(loadChannelConfigSurfaceModule(modulePath)).resolves.toMatchObject({
schema: { type: "object", properties: { count: { type: "number" } } },
});
});
});
it("falls back to bun when the source-aware loader fails", async () => {
it("returns null without a config schema export", async () => {
await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => {
const { modulePath } = createDemoConfigSchemaModule(repoRoot, ["export const = ;"]);
const modulePath = createDemoModule(
repoRoot,
"config-schema.mjs",
"export const unrelated = true;",
);
const { loadChannelConfigSurfaceModule: loadWithFailingJiti, spawnSync } =
await importLoaderWithFailingJitiAndWorkingBun();
await expect(loadChannelConfigSurfaceModule(modulePath)).resolves.toBeNull();
});
});
const surface = await loadWithFailingJiti(modulePath, { repoRoot });
expect(surface).toStrictEqual(expectedOkSchema("number"));
it("rejects invalid module source", async () => {
await withTempDir({ prefix: "openclaw-config-surface-" }, async (repoRoot) => {
const modulePath = createDemoModule(repoRoot, "config-schema.ts", "export const = ;");
const spawnCalls = spawnSync.mock.calls as unknown as Array<
[string, string[], Record<string, unknown>]
>;
const spawnCall = spawnCalls[0];
expect(spawnCall?.[0]).toBe("bun");
expect(Array.isArray(spawnCall?.[1])).toBe(true);
expect(typeof spawnCall?.[2]).toBe("object");
await expect(loadChannelConfigSurfaceModule(modulePath)).rejects.toThrow();
});
});
});