perf: reduce runtime and test startup overhead

This commit is contained in:
Peter Steinberger
2026-03-21 20:17:47 +00:00
parent 80959219ce
commit 4229ffe2b9
51 changed files with 2339 additions and 2212 deletions

View File

@@ -2,7 +2,7 @@ import {
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js";
import { inspectDiscordAccount, type InspectedDiscordAccount } from "./account-inspect.js";
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({

View File

@@ -2,7 +2,7 @@ import {
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js";
import { inspectSlackAccount, type InspectedSlackAccount } from "./account-inspect.js";
import { parseSlackTarget } from "./targets.js";
export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) {

View File

@@ -3,7 +3,7 @@ import {
listInspectedDirectoryEntriesFromSources,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js";
import { inspectTelegramAccount, type InspectedTelegramAccount } from "./account-inspect.js";
export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) {
return listInspectedDirectoryEntriesFromSources({

View File

@@ -0,0 +1 @@
export { hasAnyWhatsAppAuth } from "./src/accounts.js";

View File

@@ -31,8 +31,8 @@ vi.mock("../runtime/registry.js", async (importOriginal) => {
};
});
let AcpSessionManager: typeof import("./manager.js").AcpSessionManager;
let AcpRuntimeError: typeof import("../runtime/errors.js").AcpRuntimeError;
const { AcpSessionManager } = await import("./manager.js");
const { AcpRuntimeError } = await import("../runtime/errors.js");
const baseCfg = {
acp: {
@@ -147,10 +147,7 @@ function extractRuntimeOptionsFromUpserts(): Array<AcpSessionRuntimeOptions | un
}
describe("AcpSessionManager", () => {
beforeEach(async () => {
vi.resetModules();
({ AcpSessionManager } = await import("./manager.js"));
({ AcpRuntimeError } = await import("../runtime/errors.js"));
beforeEach(() => {
hoisted.listAcpSessionEntriesMock.mockReset().mockResolvedValue([]);
hoisted.readAcpSessionEntryMock.mockReset();
hoisted.upsertAcpSessionMetaMock.mockReset().mockResolvedValue(null);

View File

@@ -22,14 +22,10 @@ vi.mock("../../config/sessions.js", async () => {
};
});
type SessionMetaModule = typeof import("./session-meta.js");
let listAcpSessionEntries: SessionMetaModule["listAcpSessionEntries"];
const { listAcpSessionEntries } = await import("./session-meta.js");
describe("listAcpSessionEntries", () => {
beforeEach(async () => {
vi.resetModules();
({ listAcpSessionEntries } = await import("./session-meta.js"));
beforeEach(() => {
vi.clearAllMocks();
});

View File

@@ -44,10 +44,26 @@ async function getMemoryManagerContext(params: { cfg: OpenClawConfig; agentId: s
| {
error: string | undefined;
}
> {
return await getMemoryManagerContextWithPurpose({ ...params, purpose: undefined });
}
async function getMemoryManagerContextWithPurpose(params: {
cfg: OpenClawConfig;
agentId: string;
purpose?: "default" | "status";
}): Promise<
| {
manager: NonNullable<Awaited<ReturnType<typeof getMemorySearchManager>>["manager"]>;
}
| {
error: string | undefined;
}
> {
const { manager, error } = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId,
purpose: params.purpose,
});
return manager ? { manager } : { error };
}
@@ -149,7 +165,11 @@ export function createMemoryGetTool(options: {
const relPath = readStringParam(params, "path", { required: true });
const from = readNumberParam(params, "from", { integer: true });
const lines = readNumberParam(params, "lines", { integer: true });
const memory = await getMemoryManagerContext({ cfg, agentId });
const memory = await getMemoryManagerContextWithPurpose({
cfg,
agentId,
purpose: "status",
});
if ("error" in memory) {
return jsonResult({ path: relPath, text: "", disabled: true, error: memory.error });
}

View File

@@ -45,9 +45,18 @@ export const bundledChannelSetupPlugins = [
lineSetupPlugin,
] as ChannelPlugin[];
const bundledChannelPluginsById = new Map(
bundledChannelPlugins.map((plugin) => [plugin.id, plugin] as const),
);
function buildBundledChannelPluginsById(plugins: readonly ChannelPlugin[]) {
const byId = new Map<ChannelId, ChannelPlugin>();
for (const plugin of plugins) {
if (byId.has(plugin.id)) {
throw new Error(`duplicate bundled channel plugin id: ${plugin.id}`);
}
byId.set(plugin.id, plugin);
}
return byId;
}
const bundledChannelPluginsById = buildBundledChannelPluginsById(bundledChannelPlugins);
export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
return bundledChannelPluginsById.get(id);

View File

@@ -0,0 +1,21 @@
export const channelPluginSurfaceKeys = [
"actions",
"setup",
"status",
"outbound",
"messaging",
"threading",
"directory",
"gateway",
] as const;
export type ChannelPluginSurface = (typeof channelPluginSurfaceKeys)[number];
export const sessionBindingContractChannelIds = [
"discord",
"feishu",
"matrix",
"telegram",
] as const;
export type SessionBindingContractChannelId = (typeof sessionBindingContractChannelIds)[number];

View File

@@ -1,39 +1,19 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
actionContractRegistry,
channelPluginSurfaceKeys,
directoryContractRegistry,
pluginContractRegistry,
sessionBindingContractRegistry,
setupContractRegistry,
statusContractRegistry,
surfaceContractRegistry,
threadingContractRegistry,
} from "./registry.js";
import { sessionBindingContractChannelIds } from "./manifest.js";
function listFilesRecursively(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listFilesRecursively(fullPath));
continue;
}
files.push(fullPath);
}
return files;
}
const sessionBindingAdapterFiles = [
"../../../../extensions/discord/src/monitor/thread-bindings.manager.ts",
"../../../../extensions/feishu/src/thread-bindings.ts",
"../../../../extensions/matrix/src/matrix/thread-bindings.ts",
"../../../../extensions/telegram/src/thread-bindings.ts",
] as const;
function discoverSessionBindingChannels() {
const extensionsDir = path.resolve(import.meta.dirname, "../../../../extensions");
const channels = new Set<string>();
for (const filePath of listFilesRecursively(extensionsDir)) {
if (!filePath.endsWith(".ts") || filePath.endsWith(".test.ts")) {
continue;
}
for (const relativePath of sessionBindingAdapterFiles) {
const filePath = path.resolve(import.meta.dirname, relativePath);
const source = fs.readFileSync(filePath, "utf8");
for (const match of source.matchAll(
/registerSessionBindingAdapter\(\{[\s\S]*?channel:\s*"([^"]+)"/g,
@@ -45,98 +25,7 @@ function discoverSessionBindingChannels() {
}
describe("channel contract registry", () => {
it("does not duplicate channel plugin ids", () => {
const ids = pluginContractRegistry.map((entry) => entry.id);
expect(ids).toEqual([...new Set(ids)]);
});
it("keeps the surface registry aligned with the plugin registry", () => {
expect(surfaceContractRegistry.map((entry) => entry.id).toSorted()).toEqual(
pluginContractRegistry.map((entry) => entry.id).toSorted(),
);
});
it("declares the actual owned channel plugin surfaces explicitly", () => {
for (const entry of surfaceContractRegistry) {
const actual = channelPluginSurfaceKeys.filter((surface) => Boolean(entry.plugin[surface]));
expect([...entry.surfaces].toSorted()).toEqual(actual.toSorted());
}
});
it("only installs deep action coverage for plugins that declare actions", () => {
const actionSurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("actions"))
.map((entry) => entry.id),
);
for (const entry of actionContractRegistry) {
expect(actionSurfaceIds.has(entry.id)).toBe(true);
}
});
it("only installs deep setup coverage for plugins that declare setup", () => {
const setupSurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("setup"))
.map((entry) => entry.id),
);
for (const entry of setupContractRegistry) {
expect(setupSurfaceIds.has(entry.id)).toBe(true);
}
});
it("only installs deep status coverage for plugins that declare status", () => {
const statusSurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("status"))
.map((entry) => entry.id),
);
for (const entry of statusContractRegistry) {
expect(statusSurfaceIds.has(entry.id)).toBe(true);
}
});
it("only installs deep threading coverage for plugins that declare threading", () => {
const threadingSurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("threading"))
.map((entry) => entry.id),
);
for (const entry of threadingContractRegistry) {
expect(threadingSurfaceIds.has(entry.id)).toBe(true);
}
});
it("covers every declared directory surface with an explicit contract level", () => {
const directorySurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("directory"))
.map((entry) => entry.id),
);
for (const entry of directoryContractRegistry) {
expect(directorySurfaceIds.has(entry.id)).toBe(true);
}
expect(directoryContractRegistry.map((entry) => entry.id).toSorted()).toEqual(
[...directorySurfaceIds].toSorted(),
);
});
it("only installs lookup directory coverage for plugins that declare directory", () => {
const directorySurfaceIds = new Set(
surfaceContractRegistry
.filter((entry) => entry.surfaces.includes("directory"))
.map((entry) => entry.id),
);
for (const entry of directoryContractRegistry.filter(
(candidate) => candidate.coverage === "lookups",
)) {
expect(directorySurfaceIds.has(entry.id)).toBe(true);
}
});
it("keeps session binding coverage aligned with registered session binding adapters", () => {
expect(sessionBindingContractRegistry.map((entry) => entry.id).toSorted()).toEqual(
discoverSessionBindingChannels(),
);
expect([...sessionBindingContractChannelIds]).toEqual(discoverSessionBindingChannels());
});
});

View File

@@ -30,6 +30,12 @@ import {
requireBundledChannelPlugin,
} from "../bundled.js";
import type { ChannelPlugin } from "../types.js";
import {
channelPluginSurfaceKeys,
type ChannelPluginSurface,
sessionBindingContractChannelIds,
type SessionBindingContractChannelId,
} from "./manifest.js";
type PluginContractEntry = {
id: string;
@@ -80,27 +86,6 @@ type StatusContractEntry = {
}>;
};
export const channelPluginSurfaceKeys = [
"actions",
"setup",
"status",
"outbound",
"messaging",
"threading",
"directory",
"gateway",
] as const;
export type ChannelPluginSurface =
| "actions"
| "setup"
| "status"
| "outbound"
| "messaging"
| "threading"
| "directory"
| "gateway";
type SurfaceContractEntry = {
id: string;
plugin: Pick<
@@ -647,9 +632,11 @@ const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
{
id: "discord",
const sessionBindingContractEntries: Record<
SessionBindingContractChannelId,
Omit<SessionBindingContractEntry, "id">
> = {
discord: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
@@ -711,8 +698,7 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
{
id: "feishu",
feishu: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
@@ -766,8 +752,7 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
{
id: "matrix",
matrix: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
@@ -817,8 +802,7 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
{
id: "telegram",
telegram: {
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
@@ -879,4 +863,10 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
];
};
export const sessionBindingContractRegistry: SessionBindingContractEntry[] =
sessionBindingContractChannelIds.map((id) => ({
id,
...sessionBindingContractEntries[id],
}));

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
import type { SecretInput } from "../../config/types.secrets.js";
import { resolveSecretInputModeForEnvSelection } from "../../plugins/provider-auth-mode.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import {
@@ -15,11 +16,11 @@ import type {
import type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry } from "./setup-wizard.js";
let providerAuthInputPromise:
| Promise<typeof import("../../plugins/provider-auth-input.js")>
| Promise<Pick<typeof import("../../plugins/provider-auth-ref.js"), "promptSecretRefForSetup">>
| undefined;
function loadProviderAuthInput() {
providerAuthInputPromise ??= import("../../plugins/provider-auth-input.js");
providerAuthInputPromise ??= import("../../plugins/provider-auth-ref.js");
return providerAuthInputPromise;
}
@@ -999,8 +1000,6 @@ export async function promptSingleChannelSecretInput(params: {
inputPrompt: string;
preferredEnvVar?: string;
}): Promise<SingleChannelSecretInputPromptResult> {
const { promptSecretRefForSetup, resolveSecretInputModeForEnvSelection } =
await loadProviderAuthInput();
const selectedMode = await resolveSecretInputModeForEnvSelection({
prompter: params.prompter as WizardPrompter,
explicitMode: params.secretInputMode,
@@ -1042,6 +1041,7 @@ export async function promptSingleChannelSecretInput(params: {
}
}
const { promptSecretRefForSetup } = await loadProviderAuthInput();
const resolved = await promptSecretRefForSetup({
provider: params.providerHint,
config: params.cfg,

View File

@@ -4,11 +4,9 @@ import { isSecureWebSocketUrl } from "../gateway/net.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import { resolveWideAreaDiscoveryDomain } from "../infra/widearea-dns.js";
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "./auth-choice.apply-helpers.js";
import { detectBinary } from "./onboard-helpers.js";
import type { SecretInputMode } from "./onboard-types.js";

View File

@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { FIELD_HELP } from "./schema.help.js";
import type { ConfigSchemaResponse } from "./schema.js";
import { findWildcardHintMatch, schemaHasChildren } from "./schema.shared.js";
import { schemaHasChildren } from "./schema.shared.js";
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
@@ -72,6 +72,14 @@ const GENERATED_BY = "scripts/generate-config-doc-baseline.ts" as const;
const DEFAULT_JSON_OUTPUT = "docs/.generated/config-baseline.json";
const DEFAULT_STATEFILE_OUTPUT = "docs/.generated/config-baseline.jsonl";
let cachedConfigDocBaselinePromise: Promise<ConfigDocBaseline> | null = null;
const uiHintIndexCache = new WeakMap<
ConfigSchemaResponse["uiHints"],
Map<
number,
Array<{ path: string; parts: string[]; hint: ConfigSchemaResponse["uiHints"][string] }>
>
>();
const schemaHasChildrenCache = new WeakMap<JsonSchemaObject, boolean>();
function logConfigDocBaselineDebug(message: string): void {
if (process.env.OPENCLAW_CONFIG_DOC_BASELINE_DEBUG === "1") {
@@ -156,11 +164,77 @@ function resolveUiHintMatch(
uiHints: ConfigSchemaResponse["uiHints"],
path: string,
): ConfigSchemaResponse["uiHints"][string] | undefined {
return findWildcardHintMatch({
uiHints,
path,
splitPath: splitHintLookupPath,
})?.hint;
const targetParts = splitHintLookupPath(path);
if (targetParts.length === 0) {
return undefined;
}
let index = uiHintIndexCache.get(uiHints);
if (!index) {
index = new Map();
for (const [hintPath, hint] of Object.entries(uiHints)) {
const parts = splitHintLookupPath(hintPath);
const bucket = index.get(parts.length);
const entry = { path: hintPath, parts, hint };
if (bucket) {
bucket.push(entry);
} else {
index.set(parts.length, [entry]);
}
}
uiHintIndexCache.set(uiHints, index);
}
const candidates = index.get(targetParts.length);
if (!candidates) {
return undefined;
}
let bestMatch:
| {
hint: ConfigSchemaResponse["uiHints"][string];
wildcardCount: number;
}
| undefined;
for (const candidate of candidates) {
let wildcardCount = 0;
let matches = true;
for (let index = 0; index < candidate.parts.length; index += 1) {
const hintPart = candidate.parts[index];
const targetPart = targetParts[index];
if (hintPart === targetPart) {
continue;
}
if (hintPart === "*") {
wildcardCount += 1;
continue;
}
matches = false;
break;
}
if (!matches) {
continue;
}
if (!bestMatch || wildcardCount < bestMatch.wildcardCount) {
bestMatch = { hint: candidate.hint, wildcardCount };
if (wildcardCount === 0) {
break;
}
}
}
return bestMatch?.hint;
}
function resolveSchemaHasChildren(schema: JsonSchemaObject): boolean {
const cached = schemaHasChildrenCache.get(schema);
if (cached !== undefined) {
return cached;
}
const next = schemaHasChildren(schema);
schemaHasChildrenCache.set(schema, next);
return next;
}
function normalizeTypeValue(value: string | string[] | undefined): string | string[] | undefined {
@@ -453,7 +527,7 @@ export function collectConfigDocBaselineEntries(
tags: [...(hint?.tags ?? [])].toSorted((left, right) => left.localeCompare(right)),
label: hint?.label,
help: hint?.help,
hasChildren: schemaHasChildren(schema),
hasChildren: resolveSchemaHasChildren(schema),
});
}

View File

@@ -7,8 +7,8 @@ import {
clearPluginManifestRegistryCache,
type PluginManifestRegistry,
} from "../plugins/manifest-registry.js";
import { validateConfigObject } from "./config.js";
import { applyPluginAutoEnable } from "./plugin-auto-enable.js";
import { validateConfigObject } from "./validation.js";
const tempDirs: string[] = [];

View File

@@ -1,4 +1,4 @@
import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/api.js";
import { hasAnyWhatsAppAuth } from "../../extensions/whatsapp/auth-presence.js";
import { normalizeProviderId } from "../agents/model-selection.js";
import { hasMeaningfulChannelConfig } from "../channels/config-presence.js";
import {

View File

@@ -4,13 +4,9 @@ import {
redactConfigSnapshot,
restoreRedactedValues as restoreRedactedValues_orig,
} from "./redact-snapshot.js";
import { __test__ } from "./schema.hints.js";
import { redactSnapshotTestHints as mainSchemaHints } from "./redact-snapshot.test-hints.js";
import type { ConfigUiHints } from "./schema.js";
import type { ConfigFileSnapshot } from "./types.openclaw.js";
import { OpenClawSchema } from "./zod-schema.js";
const { mapSensitivePaths } = __test__;
const mainSchemaHints = mapSensitivePaths(OpenClawSchema, "", {});
type TestSnapshot<TConfig extends Record<string, unknown>> = ConfigFileSnapshot & {
parsed: TConfig;

View File

@@ -4,13 +4,9 @@ import {
redactConfigSnapshot,
restoreRedactedValues as restoreRedactedValues_orig,
} from "./redact-snapshot.js";
import { __test__ } from "./schema.hints.js";
import { redactSnapshotTestHints as mainSchemaHints } from "./redact-snapshot.test-hints.js";
import type { ConfigUiHints } from "./schema.js";
import type { ConfigFileSnapshot } from "./types.openclaw.js";
import { OpenClawSchema } from "./zod-schema.js";
const { mapSensitivePaths } = __test__;
const mainSchemaHints = mapSensitivePaths(OpenClawSchema, "", {});
type TestSnapshot<TConfig extends Record<string, unknown>> = ConfigFileSnapshot & {
parsed: TConfig;
@@ -49,13 +45,6 @@ function restoreRedactedValues<TOriginal>(
describe("realredactConfigSnapshot_real", () => {
it("main schema redact works (samples)", () => {
const schema = OpenClawSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
schema.title = "OpenClawConfig";
const hints = mainSchemaHints;
const snapshot = makeSnapshot({
agents: {
defaults: {
@@ -77,11 +66,11 @@ describe("realredactConfigSnapshot_real", () => {
},
});
const result = redactConfigSnapshot(snapshot, hints);
const result = redactConfigSnapshot(snapshot, mainSchemaHints);
const config = result.config as typeof snapshot.config;
expect(config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
expect(config.agents.list[0].memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
const restored = restoreRedactedValues(result.config, snapshot.config, mainSchemaHints);
expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234");
expect(restored.agents.list[0].memorySearch.remote.apiKey).toBe("6789");
});

View File

@@ -0,0 +1,14 @@
import type { ConfigUiHints } from "./schema.js";
// Keep this fixture minimal so redaction tests exercise the hint-matching
// behavior they care about without paying to build the full config schema graph.
export const redactSnapshotTestHints: ConfigUiHints = {
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
"broadcast.apiToken[]": { sensitive: true },
"env.GROQ_API_KEY": { sensitive: true },
"gateway.auth.password": { sensitive: true },
"models.providers.*.apiKey": { sensitive: true },
"models.providers.*.baseUrl": { sensitive: true },
"skills.entries.*.env.GEMINI_API_KEY": { sensitive: true },
};

View File

@@ -5,13 +5,9 @@ import {
redactConfigSnapshot,
restoreRedactedValues as restoreRedactedValues_orig,
} from "./redact-snapshot.js";
import { __test__ } from "./schema.hints.js";
import { redactSnapshotTestHints as mainSchemaHints } from "./redact-snapshot.test-hints.js";
import type { ConfigUiHints } from "./schema.js";
import type { ConfigFileSnapshot } from "./types.openclaw.js";
import { OpenClawSchema } from "./zod-schema.js";
const { mapSensitivePaths } = __test__;
const mainSchemaHints = mapSensitivePaths(OpenClawSchema, "", {});
type TestSnapshot<TConfig extends Record<string, unknown>> = ConfigFileSnapshot & {
parsed: TConfig;

View File

@@ -5,9 +5,9 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
import {
clearSessionStoreCacheForTest,
loadSessionStore,
type SessionEntry,
saveSessionStore,
} from "./sessions.js";
} from "./sessions/store.js";
import type { SessionEntry } from "./sessions/types.js";
function createSessionEntry(overrides: Partial<SessionEntry> = {}): SessionEntry {
return {

View File

@@ -885,56 +885,6 @@ describe("Bundle chunk isolation (#40096)", () => {
expect(engine.info.id).toBe(engineId);
});
it("plugin-sdk export path shares the same global registry", async () => {
// The plugin-sdk re-exports registerContextEngine. Verify the
// re-export writes to the same global symbol as the direct import.
const ts = Date.now().toString(36);
const engineId = `sdk-path-${ts}`;
// Direct registry import
registerContextEngine(engineId, () => new MockContextEngine());
// Plugin-sdk import (different chunk path in the published bundle)
const sdkUrl = new URL("../plugin-sdk/index.ts", import.meta.url).href;
const sdk = await import(/* @vite-ignore */ `${sdkUrl}?sdk-${ts}`);
// The SDK export should see the engine we just registered
const factory = getContextEngineFactory(engineId);
expect(factory).toBeDefined();
// And registering from the SDK path should be visible from the direct path
const sdkEngineId = `sdk-registered-${ts}`;
sdk.registerContextEngine(sdkEngineId, () => new MockContextEngine());
expect(getContextEngineFactory(sdkEngineId)).toBeDefined();
});
it("plugin-sdk registerContextEngine cannot spoof privileged ownership", async () => {
const ts = Date.now().toString(36);
const engineId = `sdk-spoof-guard-${ts}`;
const ownedFactory = () => new MockContextEngine();
expect(
registerContextEngineForOwner(engineId, ownedFactory, "plugin:owner-a", {
allowSameOwnerRefresh: true,
}),
).toEqual({ ok: true });
const sdkUrl = new URL("../plugin-sdk/index.ts", import.meta.url).href;
const sdk = await import(/* @vite-ignore */ `${sdkUrl}?sdk-spoof-${ts}`);
const spoofAttempt = (
sdk.registerContextEngine as unknown as (
id: string,
factory: ContextEngineFactory,
opts?: { owner?: string },
) => ContextEngineRegistrationResult
)(engineId, () => new MockContextEngine(), { owner: "plugin:owner-a" });
expect(spoofAttempt).toEqual({
ok: false,
existingOwner: "plugin:owner-a",
});
expect(getContextEngineFactory(engineId)).toBe(ownedFactory);
});
it("concurrent registration from multiple chunks does not lose entries", async () => {
const ts = Date.now().toString(36);
const registryUrl = new URL("./registry.ts", import.meta.url).href;

View File

@@ -0,0 +1,50 @@
import type { resolveAgentConfig } from "../../agents/agent-scope.js";
import type { AgentDefaultsConfig } from "../../config/types.js";
type ResolvedAgentConfig = NonNullable<ReturnType<typeof resolveAgentConfig>>;
function extractCronAgentDefaultsOverride(agentConfigOverride?: ResolvedAgentConfig) {
const {
model: overrideModel,
sandbox: _agentSandboxOverride,
...agentOverrideRest
} = agentConfigOverride ?? {};
return {
overrideModel,
definedOverrides: Object.fromEntries(
Object.entries(agentOverrideRest).filter(([, value]) => value !== undefined),
) as Partial<AgentDefaultsConfig>,
};
}
function mergeCronAgentModelOverride(params: {
defaults: AgentDefaultsConfig;
overrideModel: ResolvedAgentConfig["model"] | undefined;
}) {
const nextDefaults: AgentDefaultsConfig = { ...params.defaults };
const existingModel =
nextDefaults.model && typeof nextDefaults.model === "object" ? nextDefaults.model : {};
if (typeof params.overrideModel === "string") {
nextDefaults.model = { ...existingModel, primary: params.overrideModel };
} else if (params.overrideModel) {
nextDefaults.model = { ...existingModel, ...params.overrideModel };
}
return nextDefaults;
}
export function buildCronAgentDefaultsConfig(params: {
defaults?: AgentDefaultsConfig;
agentConfigOverride?: ResolvedAgentConfig;
}) {
const { overrideModel, definedOverrides } = extractCronAgentDefaultsOverride(
params.agentConfigOverride,
);
// Keep sandbox overrides out of `agents.defaults` here. Sandbox resolution
// already merges global defaults with per-agent overrides using `agentId`;
// copying the agent sandbox into defaults clobbers global defaults and can
// double-apply nested agent overrides during isolated cron runs.
return mergeCronAgentModelOverride({
defaults: Object.assign({}, params.defaults, definedOverrides),
overrideModel,
});
}

View File

@@ -1,55 +1,45 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearFastTestEnv,
loadRunCronIsolatedAgentTurn,
resolveAgentConfigMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runWithModelFallbackMock,
} from "./run.test-harness.js";
import { describe, expect, it } from "vitest";
import { resolveSandboxConfigForAgent } from "../../agents/sandbox/config.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn();
function makeJob(overrides?: Record<string, unknown>) {
function makeCfg() {
return {
id: "sandbox-test-job",
name: "Sandbox Test",
schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" },
sessionTarget: "isolated",
payload: { kind: "agentTurn", message: "test" },
...overrides,
} as never;
}
function makeParams(overrides?: Record<string, unknown>) {
return {
cfg: {
agents: {
defaults: {
sandbox: {
mode: "all" as const,
workspaceAccess: "rw" as const,
docker: {
network: "none",
dangerouslyAllowContainerNamespaceJoin: true,
dangerouslyAllowExternalBindSources: true,
},
browser: {
enabled: true,
autoStart: false,
},
prune: {
maxAgeDays: 7,
},
agents: {
defaults: {
sandbox: {
mode: "all" as const,
workspaceAccess: "rw" as const,
docker: {
network: "none",
dangerouslyAllowContainerNamespaceJoin: true,
dangerouslyAllowExternalBindSources: true,
},
browser: {
enabled: true,
autoStart: false,
},
prune: {
maxAgeDays: 7,
},
},
},
},
deps: {} as never,
job: makeJob(),
message: "test",
sessionKey: "cron:sandbox-test",
...overrides,
};
}
function buildRunCfg(agentId: string, agentConfigOverride?: Record<string, unknown>) {
const cfg = makeCfg();
const agentDefaults = buildCronAgentDefaultsConfig({
defaults: cfg.agents.defaults,
agentConfigOverride: agentConfigOverride as never,
});
return {
...cfg,
agents: {
...cfg.agents,
defaults: agentDefaults,
list: [{ id: agentId, ...agentConfigOverride }],
},
};
}
@@ -79,35 +69,23 @@ function expectDefaultSandboxPreserved(
}
describe("runCronIsolatedAgentTurn sandbox config preserved", () => {
let previousFastTestEnv: string | undefined;
beforeEach(() => {
previousFastTestEnv = clearFastTestEnv();
resetRunCronIsolatedAgentTurnHarness();
});
afterEach(() => {
restoreFastTestEnv(previousFastTestEnv);
});
it("preserves default sandbox config when agent entry omits sandbox", async () => {
resolveAgentConfigMock.mockReturnValue({
const runCfg = buildRunCfg("worker", {
name: "worker",
workspace: "/tmp/custom-workspace",
sandbox: undefined,
heartbeat: undefined,
tools: undefined,
});
await runCronIsolatedAgentTurn(makeParams({ agentId: "worker" }));
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1);
const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg;
expectDefaultSandboxPreserved(runCfg);
expect(resolveSandboxConfigForAgent(runCfg, "worker")).toMatchObject({
mode: "all",
workspaceAccess: "rw",
});
});
it("keeps global sandbox defaults when agent override is partial", async () => {
resolveAgentConfigMock.mockReturnValue({
const runCfg = buildRunCfg("specialist", {
sandbox: {
docker: {
image: "ghcr.io/openclaw/sandbox:custom",
@@ -120,12 +98,6 @@ describe("runCronIsolatedAgentTurn sandbox config preserved", () => {
},
},
});
await runCronIsolatedAgentTurn(makeParams({ agentId: "specialist" }));
expect(runWithModelFallbackMock).toHaveBeenCalledTimes(1);
const runCfg = runWithModelFallbackMock.mock.calls[0]?.[0]?.cfg;
const { resolveSandboxConfigForAgent } = await import("../../agents/sandbox/config.js");
const resolvedSandbox = resolveSandboxConfigForAgent(runCfg, "specialist");
expectDefaultSandboxPreserved(runCfg);

View File

@@ -46,7 +46,6 @@ import {
setSessionRuntimeModel,
updateSessionStore,
} from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { logWarn } from "../../logger.js";
import { normalizeAgentId } from "../../routing/session-key.js";
@@ -73,6 +72,7 @@ import {
pickSummaryFromPayloads,
resolveHeartbeatAckMaxChars,
} from "./helpers.js";
import { buildCronAgentDefaultsConfig } from "./run-config.js";
import { resolveCronAgentSessionKey } from "./session-key.js";
import { resolveCronSession } from "./session.js";
import { resolveCronSkillsSnapshot } from "./skills-snapshot.js";
@@ -101,54 +101,6 @@ export type RunCronAgentTurnResult = {
} & CronRunOutcome &
CronRunTelemetry;
type ResolvedAgentConfig = NonNullable<ReturnType<typeof resolveAgentConfig>>;
function extractCronAgentDefaultsOverride(agentConfigOverride?: ResolvedAgentConfig) {
const {
model: overrideModel,
sandbox: _agentSandboxOverride,
...agentOverrideRest
} = agentConfigOverride ?? {};
return {
overrideModel,
definedOverrides: Object.fromEntries(
Object.entries(agentOverrideRest).filter(([, value]) => value !== undefined),
) as Partial<AgentDefaultsConfig>,
};
}
function mergeCronAgentModelOverride(params: {
defaults: AgentDefaultsConfig;
overrideModel: ResolvedAgentConfig["model"] | undefined;
}) {
const nextDefaults: AgentDefaultsConfig = { ...params.defaults };
const existingModel =
nextDefaults.model && typeof nextDefaults.model === "object" ? nextDefaults.model : {};
if (typeof params.overrideModel === "string") {
nextDefaults.model = { ...existingModel, primary: params.overrideModel };
} else if (params.overrideModel) {
nextDefaults.model = { ...existingModel, ...params.overrideModel };
}
return nextDefaults;
}
function buildCronAgentDefaultsConfig(params: {
defaults?: AgentDefaultsConfig;
agentConfigOverride?: ResolvedAgentConfig;
}) {
const { overrideModel, definedOverrides } = extractCronAgentDefaultsOverride(
params.agentConfigOverride,
);
// Keep sandbox overrides out of `agents.defaults` here. Sandbox resolution
// already merges global defaults with per-agent overrides using `agentId`;
// copying the agent sandbox into defaults clobbers global defaults and can
// double-apply nested agent overrides during isolated cron runs.
return mergeCronAgentModelOverride({
defaults: Object.assign({}, params.defaults, definedOverrides),
overrideModel,
});
}
type ResolvedCronDeliveryTarget = Awaited<ReturnType<typeof resolveDeliveryTarget>>;
type IsolatedDeliveryContract = "cron-owned" | "shared";

View File

@@ -1,11 +1,8 @@
import fs from "node:fs";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { applyTemplate, runLegacyCliEntry } from "./index.js";
describe("legacy root entry", () => {
afterEach(() => {
vi.resetModules();
});
it("routes the package root export to the pure library entry", () => {
const packageJson = JSON.parse(
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
@@ -18,10 +15,8 @@ describe("legacy root entry", () => {
expect(packageJson.exports?.["."]).toBe("./dist/index.js");
});
it("does not run CLI bootstrap when imported as a library dependency", async () => {
const mod = await import("./index.js");
expect(typeof mod.applyTemplate).toBe("function");
expect(typeof mod.runLegacyCliEntry).toBe("function");
it("does not run CLI bootstrap when imported as a library dependency", () => {
expect(typeof applyTemplate).toBe("function");
expect(typeof runLegacyCliEntry).toBe("function");
});
});

View File

@@ -1,4 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
resolveTelegramTransport,
shouldRetryTelegramTransportFallback,
} from "../../extensions/telegram/src/fetch.js";
import { makeProxyFetch } from "../../extensions/telegram/src/proxy.js";
import { fetchRemoteMedia } from "./fetch.js";
const undiciMocks = vi.hoisted(() => {
const createDispatcherCtor = <T extends Record<string, unknown> | string>() =>
@@ -21,18 +27,14 @@ vi.mock("undici", () => ({
fetch: undiciMocks.fetch,
}));
let resolveTelegramTransport: typeof import("../../extensions/telegram/src/fetch.js").resolveTelegramTransport;
let shouldRetryTelegramTransportFallback: typeof import("../../extensions/telegram/src/fetch.js").shouldRetryTelegramTransportFallback;
let fetchRemoteMedia: typeof import("./fetch.js").fetchRemoteMedia;
describe("fetchRemoteMedia telegram network policy", () => {
type LookupFn = NonNullable<Parameters<typeof fetchRemoteMedia>[0]["lookupFn"]>;
beforeEach(async () => {
vi.resetModules();
({ resolveTelegramTransport, shouldRetryTelegramTransportFallback } =
await import("../../extensions/telegram/src/fetch.js"));
({ fetchRemoteMedia } = await import("./fetch.js"));
beforeEach(() => {
undiciMocks.fetch.mockReset();
undiciMocks.agentCtor.mockClear();
undiciMocks.envHttpProxyAgentCtor.mockClear();
undiciMocks.proxyAgentCtor.mockClear();
});
function createTelegramFetchFailedError(code: string): Error {
@@ -42,10 +44,6 @@ describe("fetchRemoteMedia telegram network policy", () => {
}
afterEach(() => {
undiciMocks.fetch.mockReset();
undiciMocks.agentCtor.mockClear();
undiciMocks.envHttpProxyAgentCtor.mockClear();
undiciMocks.proxyAgentCtor.mockClear();
vi.unstubAllEnvs();
});
@@ -99,7 +97,6 @@ describe("fetchRemoteMedia telegram network policy", () => {
});
it("keeps explicit proxy routing for file downloads", async () => {
const { makeProxyFetch } = await import("../../extensions/telegram/src/proxy.js");
const lookupFn = vi.fn(async () => [
{ address: "149.154.167.220", family: 4 },
]) as unknown as LookupFn;

View File

@@ -179,10 +179,21 @@ describe("memory index", () => {
function resetManagerForTest(manager: MemoryIndexManager) {
// These tests reuse managers for performance. Clear the index + embedding
// cache to keep each test fully isolated.
const db = (
manager as unknown as {
db: {
exec: (sql: string) => void;
prepare: (sql: string) => { get: (name: string) => { name?: string } | undefined };
};
}
).db;
(manager as unknown as { resetIndex: () => void }).resetIndex();
(manager as unknown as { db: { exec: (sql: string) => void } }).db.exec(
"DELETE FROM embedding_cache",
);
const embeddingCacheTable = db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("embedding_cache");
if (embeddingCacheTable?.name === "embedding_cache") {
db.exec("DELETE FROM embedding_cache");
}
(manager as unknown as { dirty: boolean }).dirty = true;
(manager as unknown as { sessionsDirty: boolean }).sessionsDirty = false;
}
@@ -406,6 +417,7 @@ describe("memory index", () => {
const firstManager = requireManager(first);
await firstManager.sync?.({ reason: "test" });
await firstManager.close?.();
const providerCallsBeforeStatus = providerCalls.length;
const statusOnly = await getMemorySearchManager({
cfg,
@@ -415,6 +427,8 @@ describe("memory index", () => {
const statusManager = requireManager(statusOnly, "status manager missing");
const status = statusManager.status();
expect(status.dirty).toBe(false);
expect(status.provider).toBe("openai");
expect(providerCalls).toHaveLength(providerCallsBeforeStatus);
await statusManager.close?.();
});

View File

@@ -361,6 +361,7 @@ export abstract class MemoryManagerSyncOps {
const result = ensureMemoryIndexSchema({
db: this.db,
embeddingCacheTable: EMBEDDING_CACHE_TABLE,
cacheEnabled: this.cache.enabled,
ftsTable: FTS_TABLE,
ftsEnabled: this.fts.enabled,
});

View File

@@ -118,4 +118,20 @@ describe("memory manager cache hydration", () => {
await secondManager?.close?.();
});
it("does not cache status-only managers when no full manager exists", async () => {
const indexPath = path.join(workspaceDir, "index.sqlite");
const cfg = createMemoryConcurrencyConfig(indexPath);
const first = await RawMemoryIndexManager.get({ cfg, agentId: "main", purpose: "status" });
const second = await RawMemoryIndexManager.get({ cfg, agentId: "main", purpose: "status" });
expect(first).toBeTruthy();
expect(second).toBeTruthy();
expect(Object.is(second, first)).toBe(false);
expect(hoisted.providerCreateCalls).toBe(0);
await first?.close?.();
await second?.close?.();
});
});

View File

@@ -53,6 +53,7 @@ describe("MemoryIndexManager.readFile", () => {
manager = await getRequiredMemoryIndexManager({
cfg: createMemorySearchCfg({ workspaceDir, indexPath }),
agentId: "main",
purpose: "status",
});
const relPath = "memory/2099-01-01.md";
@@ -69,6 +70,7 @@ describe("MemoryIndexManager.readFile", () => {
manager = await getRequiredMemoryIndexManager({
cfg: createMemorySearchCfg({ workspaceDir, indexPath }),
agentId: "main",
purpose: "status",
});
const result = await manager.readFile({ relPath, from: 2, lines: 1 });
@@ -84,6 +86,7 @@ describe("MemoryIndexManager.readFile", () => {
manager = await getRequiredMemoryIndexManager({
cfg: createMemorySearchCfg({ workspaceDir, indexPath }),
agentId: "main",
purpose: "status",
});
const result = await manager.readFile({ relPath, from: 10, lines: 5 });
@@ -99,6 +102,7 @@ describe("MemoryIndexManager.readFile", () => {
manager = await getRequiredMemoryIndexManager({
cfg: createMemorySearchCfg({ workspaceDir, indexPath }),
agentId: "main",
purpose: "status",
});
const realReadFile = fs.readFile;

View File

@@ -21,7 +21,9 @@ describe("memory manager readonly recovery", () => {
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath },
store: { path: indexPath, vector: { enabled: false } },
cache: { enabled: false },
query: { minScore: 0, hybrid: { enabled: false } },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},

View File

@@ -48,7 +48,9 @@ describe("memory manager sync failures", () => {
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: indexPath },
store: { path: indexPath, vector: { enabled: false } },
cache: { enabled: false },
query: { minScore: 0, hybrid: { enabled: false } },
sync: { watch: true, watchDebounceMs: 1, onSessionStart: false, onSearch: false },
},
},

View File

@@ -10,6 +10,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import {
createEmbeddingProvider,
type EmbeddingProvider,
type EmbeddingProviderRequest,
type EmbeddingProviderResult,
type GeminiEmbeddingClient,
type MistralEmbeddingClient,
@@ -83,17 +84,12 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
protected readonly workspaceDir: string;
protected readonly settings: ResolvedMemorySearchConfig;
protected provider: EmbeddingProvider | null;
private readonly requestedProvider:
| "openai"
| "local"
| "gemini"
| "voyage"
| "mistral"
| "ollama"
| "auto";
private readonly requestedProvider: EmbeddingProviderRequest;
private providerInitPromise: Promise<void> | null = null;
private providerInitialized = false;
protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama";
protected fallbackReason?: string;
private readonly providerUnavailableReason?: string;
private providerUnavailableReason?: string;
protected openAi?: OpenAiEmbeddingClient;
protected gemini?: GeminiEmbeddingClient;
protected voyage?: VoyageEmbeddingClient;
@@ -150,6 +146,23 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
private readonlyRecoveryFailures = 0;
private readonlyRecoveryLastError?: string;
private static async loadProviderResult(params: {
cfg: OpenClawConfig;
agentId: string;
settings: ResolvedMemorySearchConfig;
}): Promise<EmbeddingProviderResult> {
return await createEmbeddingProvider({
config: params.cfg,
agentDir: resolveAgentDir(params.cfg, params.agentId),
provider: params.settings.provider,
remote: params.settings.remote,
model: params.settings.model,
outputDimensionality: params.settings.outputDimensionality,
fallback: params.settings.fallback,
local: params.settings.local,
});
}
static async get(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -162,6 +175,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const key = `${agentId}:${workspaceDir}:${JSON.stringify(settings)}`;
const statusOnly = params.purpose === "status";
const existing = INDEX_CACHE.get(key);
if (existing) {
return existing;
@@ -170,16 +184,21 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (pending) {
return pending;
}
if (statusOnly) {
return new MemoryIndexManager({
cacheKey: key,
cfg,
agentId,
workspaceDir,
settings,
purpose: params.purpose,
});
}
const createPromise = (async () => {
const providerResult = await createEmbeddingProvider({
config: cfg,
agentDir: resolveAgentDir(cfg, agentId),
provider: settings.provider,
remote: settings.remote,
model: settings.model,
outputDimensionality: settings.outputDimensionality,
fallback: settings.fallback,
local: settings.local,
const providerResult = await MemoryIndexManager.loadProviderResult({
cfg,
agentId,
settings,
});
const refreshed = INDEX_CACHE.get(key);
if (refreshed) {
@@ -213,7 +232,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
agentId: string;
workspaceDir: string;
settings: ResolvedMemorySearchConfig;
providerResult: EmbeddingProviderResult;
providerResult?: EmbeddingProviderResult;
purpose?: "default" | "status";
}) {
super();
@@ -222,16 +241,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
this.agentId = params.agentId;
this.workspaceDir = params.workspaceDir;
this.settings = params.settings;
this.provider = params.providerResult.provider;
this.requestedProvider = params.providerResult.requestedProvider;
this.fallbackFrom = params.providerResult.fallbackFrom;
this.fallbackReason = params.providerResult.fallbackReason;
this.providerUnavailableReason = params.providerResult.providerUnavailableReason;
this.openAi = params.providerResult.openAi;
this.gemini = params.providerResult.gemini;
this.voyage = params.providerResult.voyage;
this.mistral = params.providerResult.mistral;
this.ollama = params.providerResult.ollama;
this.provider = null;
this.requestedProvider = params.settings.provider;
if (params.providerResult) {
this.applyProviderResult(params.providerResult);
}
this.sources = new Set(params.settings.sources);
this.db = this.openDatabase();
this.providerKey = this.computeProviderKey();
@@ -250,14 +264,54 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (meta?.vectorDims) {
this.vector.dims = meta.vectorDims;
}
this.ensureWatcher();
this.ensureSessionListener();
this.ensureIntervalSync();
const statusOnly = params.purpose === "status";
if (!statusOnly) {
this.ensureWatcher();
this.ensureSessionListener();
this.ensureIntervalSync();
}
this.dirty = this.sources.has("memory") && (statusOnly ? !meta : true);
this.batch = this.resolveBatchConfig();
}
private applyProviderResult(providerResult: EmbeddingProviderResult): void {
this.provider = providerResult.provider;
this.fallbackFrom = providerResult.fallbackFrom;
this.fallbackReason = providerResult.fallbackReason;
this.providerUnavailableReason = providerResult.providerUnavailableReason;
this.openAi = providerResult.openAi;
this.gemini = providerResult.gemini;
this.voyage = providerResult.voyage;
this.mistral = providerResult.mistral;
this.ollama = providerResult.ollama;
this.providerInitialized = true;
}
private async ensureProviderInitialized(): Promise<void> {
if (this.providerInitialized) {
return;
}
if (!this.providerInitPromise) {
this.providerInitPromise = (async () => {
const providerResult = await MemoryIndexManager.loadProviderResult({
cfg: this.cfg,
agentId: this.agentId,
settings: this.settings,
});
this.applyProviderResult(providerResult);
this.providerKey = this.computeProviderKey();
this.batch = this.resolveBatchConfig();
})();
}
try {
await this.providerInitPromise;
} finally {
if (this.providerInitialized) {
this.providerInitPromise = null;
}
}
}
async warmSession(sessionKey?: string): Promise<void> {
if (!this.settings.sync.onSessionStart) {
return;
@@ -282,6 +336,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
sessionKey?: string;
},
): Promise<MemorySearchResult[]> {
await this.ensureProviderInitialized();
void this.warmSession(opts?.sessionKey);
if (this.settings.sync.onSearch && (this.dirty || this.sessionsDirty)) {
void this.sync({ reason: "search" }).catch((err) => {
@@ -478,6 +533,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
if (this.closed) {
return;
}
await this.ensureProviderInitialized();
if (this.syncing) {
if (params?.sessionFiles?.some((sessionFile) => sessionFile.trim().length > 0)) {
return this.enqueueTargetedSessionSync(params.sessionFiles);
@@ -723,11 +779,12 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return sources.map((source) => Object.assign({ source }, bySource.get(source)!));
})();
// Determine search mode: "fts-only" if no provider, "hybrid" otherwise
const searchMode = this.provider ? "hybrid" : "fts-only";
const searchMode = this.provider || !this.providerInitialized ? "hybrid" : "fts-only";
const providerInfo = this.provider
? { provider: this.provider.id, model: this.provider.model }
: { provider: "none", model: undefined };
: this.providerInitialized
? { provider: "none", model: undefined }
: { provider: this.requestedProvider, model: this.settings.model || undefined };
return {
backend: "builtin",
@@ -794,17 +851,19 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
async probeVectorAvailability(): Promise<boolean> {
// FTS-only mode: vector search not available
if (!this.provider) {
if (!this.vector.enabled) {
return false;
}
if (!this.vector.enabled) {
await this.ensureProviderInitialized();
// FTS-only mode: vector search not available
if (!this.provider) {
return false;
}
return this.ensureVectorReady();
}
async probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult> {
await this.ensureProviderInitialized();
// FTS-only mode: embeddings not available but search still works
if (!this.provider) {
return {
@@ -827,6 +886,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
}
this.closed = true;
const pendingSync = this.syncing;
const pendingProviderInit = this.providerInitPromise;
if (this.watchTimer) {
clearTimeout(this.watchTimer);
this.watchTimer = null;
@@ -852,6 +912,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
await pendingSync;
} catch {}
}
if (pendingProviderInit) {
try {
await pendingProviderInit;
} catch {}
}
this.db.close();
INDEX_CACHE.delete(this.cacheKey);
}

View File

@@ -3,6 +3,7 @@ import type { DatabaseSync } from "node:sqlite";
export function ensureMemoryIndexSchema(params: {
db: DatabaseSync;
embeddingCacheTable: string;
cacheEnabled: boolean;
ftsTable: string;
ftsEnabled: boolean;
}): { ftsAvailable: boolean; ftsError?: string } {
@@ -35,21 +36,23 @@ export function ensureMemoryIndexSchema(params: {
updated_at INTEGER NOT NULL
);
`);
params.db.exec(`
CREATE TABLE IF NOT EXISTS ${params.embeddingCacheTable} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
if (params.cacheEnabled) {
params.db.exec(`
CREATE TABLE IF NOT EXISTS ${params.embeddingCacheTable} (
provider TEXT NOT NULL,
model TEXT NOT NULL,
provider_key TEXT NOT NULL,
hash TEXT NOT NULL,
embedding TEXT NOT NULL,
dims INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (provider, model, provider_key, hash)
);
`);
params.db.exec(
`CREATE INDEX IF NOT EXISTS idx_embedding_cache_updated_at ON ${params.embeddingCacheTable}(updated_at);`,
);
`);
params.db.exec(
`CREATE INDEX IF NOT EXISTS idx_embedding_cache_updated_at ON ${params.embeddingCacheTable}(updated_at);`,
);
}
let ftsAvailable = false;
let ftsError: string | undefined;

View File

@@ -4,12 +4,14 @@ import type { MemoryIndexManager } from "./index.js";
export async function getRequiredMemoryIndexManager(params: {
cfg: OpenClawConfig;
agentId?: string;
purpose?: "default" | "status";
}): Promise<MemoryIndexManager> {
await import("./embedding.test-mocks.js");
const { getMemorySearchManager } = await import("./index.js");
const result = await getMemorySearchManager({
cfg: params.cfg,
agentId: params.agentId ?? "main",
purpose: params.purpose,
});
if (!result.manager) {
throw new Error("manager missing");

View File

@@ -8,6 +8,7 @@ const ALLOWED_EXTENSION_PUBLIC_SURFACES = new Set([
"action-runtime.runtime.js",
"action-runtime-api.js",
"api.js",
"auth-presence.js",
"index.js",
"light-runtime-api.js",
"login-qr-api.js",

View File

@@ -1,10 +1,8 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import {
buildPluginSdkEntrySources,
@@ -14,7 +12,6 @@ import {
} from "./entrypoints.js";
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
const execFileAsync = promisify(execFile);
const require = createRequire(import.meta.url);
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
@@ -24,25 +21,17 @@ describe("plugin-sdk bundled exports", () => {
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
try {
const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs");
await fs.writeFile(
buildScriptPath,
`import { build } from ${JSON.stringify(tsdownModuleUrl)};
await build(${JSON.stringify({
clean: true,
config: false,
dts: false,
entry: buildPluginSdkEntrySources(),
env: { NODE_ENV: "production" },
fixedExtension: false,
logLevel: "error",
outDir,
platform: "node",
})});
`,
);
await execFileAsync(process.execPath, [buildScriptPath], {
cwd: process.cwd(),
const { build } = await import(tsdownModuleUrl);
await build({
clean: true,
config: false,
dts: false,
entry: buildPluginSdkEntrySources(),
env: { NODE_ENV: "production" },
fixedExtension: false,
logLevel: "error",
outDir,
platform: "node",
});
await fs.symlink(
path.join(process.cwd(), "node_modules"),
@@ -50,10 +39,11 @@ await build(${JSON.stringify({
"dir",
);
for (const entry of pluginSdkEntrypoints) {
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
expect(module).toBeTypeOf("object");
}
await Promise.all(
pluginSdkEntrypoints.map(async (entry) => {
await expect(fs.stat(path.join(outDir, `${entry}.js`))).resolves.toBeTruthy();
}),
);
const packageDir = path.join(fixtureDir, "openclaw");
const consumerDir = path.join(fixtureDir, "consumer");

View File

@@ -2,10 +2,49 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildPluginSdkPackageExports } from "./entrypoints.js";
import * as sdk from "./index.js";
async function collectRuntimeExports(filePath: string, seen = new Set<string>()) {
const normalizedPath = path.resolve(filePath);
if (seen.has(normalizedPath)) {
return new Set<string>();
}
seen.add(normalizedPath);
const source = await fs.readFile(normalizedPath, "utf8");
const exportNames = new Set<string>();
for (const match of source.matchAll(/export\s+(?!type\b)\{([\s\S]*?)\}\s+from\s+"([^"]+)";/g)) {
const names = match[1]
.split(",")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => part.split(/\s+as\s+/).at(-1) ?? part);
for (const name of names) {
exportNames.add(name);
}
}
for (const match of source.matchAll(/export\s+\*\s+from\s+"([^"]+)";/g)) {
const specifier = match[1];
if (!specifier.startsWith(".")) {
continue;
}
const nestedPath = path.resolve(
path.dirname(normalizedPath),
specifier.replace(/\.js$/, ".ts"),
);
const nestedExports = await collectRuntimeExports(nestedPath, seen);
for (const name of nestedExports) {
exportNames.add(name);
}
}
return exportNames;
}
describe("plugin-sdk exports", () => {
it("does not expose runtime modules", () => {
it("does not expose runtime modules", async () => {
const runtimeExports = await collectRuntimeExports(path.join(import.meta.dirname, "index.ts"));
const forbidden = [
"chunkMarkdownText",
"chunkText",
@@ -43,18 +82,21 @@ describe("plugin-sdk exports", () => {
];
for (const key of forbidden) {
expect(Object.prototype.hasOwnProperty.call(sdk, key)).toBe(false);
expect(runtimeExports.has(key)).toBe(false);
}
});
it("keeps the root runtime surface intentionally small", () => {
expect(typeof sdk.emptyPluginConfigSchema).toBe("function");
expect(typeof sdk.delegateCompactionToRuntime).toBe("function");
expect(typeof sdk.onDiagnosticEvent).toBe("function");
expect(Object.prototype.hasOwnProperty.call(sdk, "resolveControlCommandGate")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(sdk, "buildAgentSessionKey")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(sdk, "isDangerousNameMatchingEnabled")).toBe(false);
expect(Object.prototype.hasOwnProperty.call(sdk, "emitDiagnosticEvent")).toBe(false);
it("keeps the root runtime surface intentionally small", async () => {
const runtimeExports = await collectRuntimeExports(path.join(import.meta.dirname, "index.ts"));
expect([...runtimeExports].toSorted()).toEqual([
"buildFalImageGenerationProvider",
"buildGoogleImageGenerationProvider",
"buildOpenAIImageGenerationProvider",
"delegateCompactionToRuntime",
"emptyPluginConfigSchema",
"onDiagnosticEvent",
"registerContextEngine",
]);
});
it("keeps package.json plugin-sdk exports synced with the manifest", async () => {

View File

@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { registerPluginHttpRoute } from "./http-registry.js";
import { createEmptyPluginRegistry } from "./registry.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import {
pinActivePluginHttpRouteRegistry,
releasePinnedPluginHttpRouteRegistry,

View File

@@ -1,18 +1,9 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js";
import { withEnv } from "../test-utils/env.js";
type CreateJiti = typeof import("jiti").createJiti;
let createJitiPromise: Promise<CreateJiti> | undefined;
async function getCreateJiti() {
createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti);
return createJitiPromise;
}
async function importFreshPluginTestModules() {
vi.resetModules();
@@ -113,16 +104,6 @@ function makeTempDir() {
return dir;
}
function withCwd<T>(cwd: string, run: () => T): T {
const previousCwd = process.cwd();
process.chdir(cwd);
try {
return run();
} finally {
process.chdir(previousCwd);
}
}
function writePlugin(params: {
id: string;
body: string;
@@ -316,94 +297,6 @@ function createEscapingEntryFixture(params: { id: string; sourceBody: string })
return { pluginDir, outsideEntry, linkedEntry };
}
function createPluginSdkAliasFixture(params?: {
srcFile?: string;
distFile?: string;
srcBody?: string;
distBody?: string;
packageName?: string;
packageExports?: Record<string, unknown>;
trustedRootIndicators?: boolean;
trustedRootIndicatorMode?: "bin+marker" | "cli-entry-only" | "none";
}) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts");
const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
const trustedRootIndicatorMode =
params?.trustedRootIndicatorMode ??
(params?.trustedRootIndicators === false ? "none" : "bin+marker");
const packageJson: Record<string, unknown> = {
name: params?.packageName ?? "openclaw",
type: "module",
};
if (trustedRootIndicatorMode === "bin+marker") {
packageJson.bin = {
openclaw: "openclaw.mjs",
};
}
if (params?.packageExports || trustedRootIndicatorMode === "cli-entry-only") {
const trustedExports: Record<string, unknown> =
trustedRootIndicatorMode === "cli-entry-only"
? { "./cli-entry": { default: "./dist/cli-entry.js" } }
: {};
packageJson.exports = {
"./plugin-sdk": { default: "./dist/plugin-sdk/index.js" },
...trustedExports,
...params?.packageExports,
};
}
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(packageJson, null, 2), "utf-8");
if (trustedRootIndicatorMode === "bin+marker") {
fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8");
}
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
return { root, srcFile, distFile };
}
function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "extensionAPI.ts");
const distFile = path.join(root, "dist", "extensionAPI.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf-8",
);
fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8");
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
return { root, srcFile, distFile };
}
function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: string }) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts");
const distFile = path.join(root, "dist", "plugins", "runtime", "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf-8",
);
fs.writeFileSync(
srcFile,
params?.srcBody ?? "export const createPluginRuntime = () => ({});\n",
"utf-8",
);
fs.writeFileSync(
distFile,
params?.distBody ?? "export const createPluginRuntime = () => ({});\n",
"utf-8",
);
return { root, srcFile, distFile };
}
function loadBundleFixture(params: {
pluginId: string;
build: (bundleRoot: string) => void;
@@ -658,53 +551,6 @@ function expectEscapingEntryRejected(params: {
return registry;
}
function resolvePluginSdkAlias(params: {
root: string;
srcFile: string;
distFile: string;
modulePath: string;
argv1?: string;
env?: NodeJS.ProcessEnv;
}) {
const run = () =>
__testing.resolvePluginSdkAliasFile({
srcFile: params.srcFile,
distFile: params.distFile,
modulePath: params.modulePath,
argv1: params.argv1,
});
return params.env ? withEnv(params.env, run) : run();
}
function listPluginSdkAliasCandidates(params: {
root: string;
srcFile: string;
distFile: string;
modulePath: string;
env?: NodeJS.ProcessEnv;
}) {
const run = () =>
__testing.listPluginSdkAliasCandidates({
srcFile: params.srcFile,
distFile: params.distFile,
modulePath: params.modulePath,
});
return params.env ? withEnv(params.env, run) : run();
}
function resolvePluginRuntimeModule(params: {
modulePath: string;
argv1?: string;
env?: NodeJS.ProcessEnv;
}) {
const run = () =>
__testing.resolvePluginRuntimeModulePath({
modulePath: params.modulePath,
argv1: params.argv1,
});
return params.env ? withEnv(params.env, run) : run();
}
afterEach(() => {
clearPluginLoaderCache();
resetDiagnosticEventsForTest();
@@ -3425,377 +3271,6 @@ module.exports = {
}
});
it.each([
{
name: "prefers dist plugin-sdk alias when loader runs from dist",
buildFixture: () => createPluginSdkAliasFixture(),
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
srcFile: "index.ts",
distFile: "index.js",
expected: "dist" as const,
},
{
name: "prefers src plugin-sdk alias when loader runs from src in non-production",
buildFixture: () => createPluginSdkAliasFixture(),
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
srcFile: "index.ts",
distFile: "index.js",
env: { NODE_ENV: undefined },
expected: "src" as const,
},
{
name: "falls back to src plugin-sdk alias when dist is missing in production",
buildFixture: () => {
const fixture = createPluginSdkAliasFixture();
fs.rmSync(fixture.distFile);
return fixture;
},
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
srcFile: "index.ts",
distFile: "index.js",
env: { NODE_ENV: "production", VITEST: undefined },
expected: "src" as const,
},
{
name: "prefers dist root-alias shim when loader runs from dist",
buildFixture: () =>
createPluginSdkAliasFixture({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
srcBody: "module.exports = {};\n",
distBody: "module.exports = {};\n",
}),
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
expected: "dist" as const,
},
{
name: "prefers src root-alias shim when loader runs from src in non-production",
buildFixture: () =>
createPluginSdkAliasFixture({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
srcBody: "module.exports = {};\n",
distBody: "module.exports = {};\n",
}),
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
env: { NODE_ENV: undefined },
expected: "src" as const,
},
{
name: "resolves plugin-sdk alias from package root when loader runs from transpiler cache path",
buildFixture: () => createPluginSdkAliasFixture(),
modulePath: () => "/tmp/tsx-cache/openclaw-loader.js",
argv1: (root: string) => path.join(root, "openclaw.mjs"),
srcFile: "index.ts",
distFile: "index.js",
env: { NODE_ENV: undefined },
expected: "src" as const,
},
])("$name", ({ buildFixture, modulePath, argv1, srcFile, distFile, env, expected }) => {
const fixture = buildFixture();
const resolved = resolvePluginSdkAlias({
root: fixture.root,
srcFile,
distFile,
modulePath: modulePath(fixture.root),
argv1: argv1?.(fixture.root),
env,
});
expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile);
});
it.each([
{
name: "prefers dist extension-api alias when loader runs from dist",
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
expected: "dist" as const,
},
{
name: "prefers src extension-api alias when loader runs from src in non-production",
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
env: { NODE_ENV: undefined },
expected: "src" as const,
},
{
name: "resolves extension-api alias from package root when loader runs from transpiler cache path",
modulePath: () => "/tmp/tsx-cache/openclaw-loader.js",
argv1: (root: string) => path.join(root, "openclaw.mjs"),
env: { NODE_ENV: undefined },
expected: "src" as const,
},
])("$name", ({ modulePath, argv1, env, expected }) => {
const fixture = createExtensionApiAliasFixture();
const resolved = withEnv(env ?? {}, () =>
__testing.resolveExtensionApiAlias({
modulePath: modulePath(fixture.root),
argv1: argv1?.(fixture.root),
}),
);
expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile);
});
it.each([
{
name: "prefers dist candidates first for production src runtime",
env: { NODE_ENV: "production", VITEST: undefined },
expectedFirst: "dist" as const,
},
{
name: "prefers src candidates first for non-production src runtime",
env: { NODE_ENV: undefined },
expectedFirst: "src" as const,
},
])("$name", ({ env, expectedFirst }) => {
const fixture = createPluginSdkAliasFixture();
const candidates = listPluginSdkAliasCandidates({
root: fixture.root,
srcFile: "index.ts",
distFile: "index.js",
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
env,
});
const first = expectedFirst === "dist" ? fixture.distFile : fixture.srcFile;
const second = expectedFirst === "dist" ? fixture.srcFile : fixture.distFile;
expect(candidates.indexOf(first)).toBeLessThan(candidates.indexOf(second));
});
it("derives plugin-sdk subpaths from package exports", () => {
const fixture = createPluginSdkAliasFixture({
packageExports: {
"./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" },
"./plugin-sdk/telegram": { default: "./dist/plugin-sdk/telegram.js" },
"./plugin-sdk/nested/value": { default: "./dist/plugin-sdk/nested/value.js" },
},
});
const subpaths = __testing.listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
});
expect(subpaths).toEqual(["compat", "telegram"]);
});
it("derives plugin-sdk subpaths from nearest package exports even when package name is renamed", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
"./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" },
},
});
const subpaths = __testing.listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
});
expect(subpaths).toEqual(["channel-runtime", "compat", "core"]);
});
it("derives plugin-sdk subpaths via cwd fallback when module path is a transpiler cache and package is renamed", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
__testing.listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual(["channel-runtime", "core"]);
});
it("resolves plugin-sdk alias files via cwd fallback when module path is a transpiler cache and package is renamed", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageName: "moltbot",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const resolved = withCwd(fixture.root, () =>
resolvePluginSdkAlias({
root: fixture.root,
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
env: { NODE_ENV: undefined },
}),
);
expect(resolved).not.toBeNull();
expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(fixture.srcFile));
});
it("does not derive plugin-sdk subpaths from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
trustedRootIndicators: false,
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
__testing.listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual([]);
});
it("derives plugin-sdk subpaths via cwd fallback when trusted root indicator is cli-entry export", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
trustedRootIndicatorMode: "cli-entry-only",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
__testing.listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual(["channel-runtime", "core"]);
});
it("builds plugin-sdk aliases from the module being loaded, not the loader location", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
const distRootAlias = path.join(fixture.root, "dist", "plugin-sdk", "root-alias.cjs");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
fs.writeFileSync(distRootAlias, "module.exports = {};\n", "utf-8");
const sourcePluginEntry = path.join(fixture.root, "extensions", "demo", "src", "index.ts");
fs.mkdirSync(path.dirname(sourcePluginEntry), { recursive: true });
fs.writeFileSync(sourcePluginEntry, 'export const plugin = "demo";\n', "utf-8");
const sourceAliases = withEnv({ NODE_ENV: undefined }, () =>
__testing.buildPluginLoaderAliasMap(sourcePluginEntry),
);
expect(fs.realpathSync(sourceAliases["openclaw/plugin-sdk"] ?? "")).toBe(
fs.realpathSync(sourceRootAlias),
);
expect(fs.realpathSync(sourceAliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe(
fs.realpathSync(path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.ts")),
);
const distPluginEntry = path.join(fixture.root, "dist", "extensions", "demo", "index.js");
fs.mkdirSync(path.dirname(distPluginEntry), { recursive: true });
fs.writeFileSync(distPluginEntry, 'export const plugin = "demo";\n', "utf-8");
const distAliases = withEnv({ NODE_ENV: undefined }, () =>
__testing.buildPluginLoaderAliasMap(distPluginEntry),
);
expect(fs.realpathSync(distAliases["openclaw/plugin-sdk"] ?? "")).toBe(
fs.realpathSync(distRootAlias),
);
expect(fs.realpathSync(distAliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe(
fs.realpathSync(path.join(fixture.root, "dist", "plugin-sdk", "channel-runtime.js")),
);
});
it("does not resolve plugin-sdk alias files from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageName: "moltbot",
trustedRootIndicators: false,
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const resolved = withCwd(fixture.root, () =>
resolvePluginSdkAlias({
root: fixture.root,
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
env: { NODE_ENV: undefined },
}),
);
expect(resolved).toBeNull();
});
it("configures the plugin loader jiti boundary to prefer native dist modules", () => {
const options = __testing.buildPluginLoaderJitiOptions({});
expect(options.tryNative).toBe(true);
expect(options.interopDefault).toBe(true);
expect(options.extensions).toContain(".js");
expect(options.extensions).toContain(".ts");
expect("alias" in options).toBe(false);
});
it("uses transpiled Jiti loads for source TypeScript plugin entries", () => {
expect(__testing.shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(
__testing.shouldPreferNativeJiti("/repo/extensions/discord/src/channel.runtime.ts"),
).toBe(false);
});
it("loads source runtime shims through the non-native Jiti boundary", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord");
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/infra-runtime";
export const syntheticRuntimeMarker = {
resolveOutboundSendDep,
};
`,
"utf-8",
);
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "infra-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,
});
// 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({
"openclaw/plugin-sdk/infra-runtime": copiedChannelRuntimeShim,
}),
tryNative: false,
});
expect(withAlias(copiedChannelRuntime)).toMatchObject({
syntheticRuntimeMarker: {
resolveOutboundSendDep: expect.any(Function),
},
});
}, 240_000);
it("loads source TypeScript plugins that route through local runtime shims", () => {
const plugin = writePlugin({
id: "source-runtime-shim",
@@ -3834,29 +3309,6 @@ export const runtimeValue = helperValue;`,
const record = registry.plugins.find((entry) => entry.id === "source-runtime-shim");
expect(record?.status).toBe("loaded");
});
it.each([
{
name: "prefers dist plugin runtime module when loader runs from dist",
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
expected: "dist" as const,
},
{
name: "resolves plugin runtime module from package root when loader runs from transpiler cache path",
modulePath: () => "/tmp/tsx-cache/openclaw-loader.js",
argv1: (root: string) => path.join(root, "openclaw.mjs"),
env: { NODE_ENV: undefined },
expected: "src" as const,
},
])("$name", ({ modulePath, argv1, env, expected }) => {
const fixture = createPluginRuntimeAliasFixture();
const resolved = resolvePluginRuntimeModule({
modulePath: modulePath(fixture.root),
argv1: argv1?.(fixture.root),
env,
});
expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile);
});
});
describe("clearPluginLoaderCache", () => {

View File

@@ -1,6 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createJiti } from "jiti";
import type { ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
@@ -36,15 +35,16 @@ import type { CreatePluginRuntimeOptions } from "./runtime/index.js";
import type { PluginRuntime } from "./runtime/types.js";
import { validateJsonSchemaValue } from "./schema-validator.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
resolveLoaderPackageRoot,
resolveExtensionApiAlias,
resolvePluginSdkAliasCandidateOrder,
resolvePluginSdkAliasFile,
resolvePluginRuntimeModulePath,
resolvePluginSdkScopedAliasMap,
shouldPreferNativeJiti,
type LoaderModuleResolveParams,
} from "./sdk-alias.js";
import type {
OpenClawPluginDefinition,
@@ -128,84 +128,6 @@ export function clearPluginLoaderCache(): void {
const defaultLogger = () => createSubsystemLogger("plugins");
function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string {
return params.modulePath ?? fileURLToPath(params.moduleUrl ?? import.meta.url);
}
const resolvePluginSdkAlias = (params: LoaderModuleResolveParams = {}): string | null =>
resolvePluginSdkAliasFile({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
...params,
});
function buildPluginLoaderAliasMap(modulePath: string): Record<string, string> {
const pluginSdkAlias = resolvePluginSdkAlias({ modulePath });
const extensionApiAlias = resolveExtensionApiAlias({ modulePath });
return {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap({ modulePath }),
};
}
const resolveExtensionApiAlias = (params: LoaderModuleResolveParams = {}): string | null => {
try {
const modulePath = resolveLoaderModulePath(params);
const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath });
if (!packageRoot) {
return null;
}
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const candidateMap = {
src: path.join(packageRoot, "src", "extensionAPI.ts"),
dist: path.join(packageRoot, "dist", "extensionAPI.js"),
} as const;
for (const kind of orderedKinds) {
const candidate = candidateMap[kind];
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
};
function resolvePluginRuntimeModulePath(params: LoaderModuleResolveParams = {}): string | null {
try {
const modulePath = resolveLoaderModulePath(params);
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath });
const candidates = packageRoot
? orderedKinds.map((kind) =>
kind === "src"
? path.join(packageRoot, "src", "plugins", "runtime", "index.ts")
: path.join(packageRoot, "dist", "plugins", "runtime", "index.js"),
)
: [
path.join(path.dirname(modulePath), "runtime", "index.ts"),
path.join(path.dirname(modulePath), "runtime", "index.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
export const __testing = {
buildPluginLoaderJitiOptions,
buildPluginLoaderAliasMap,

View File

@@ -1,45 +1,31 @@
import { resolveEnvApiKey } from "../agents/model-auth.js";
import type { OpenClawConfig } from "../config/types.js";
import {
isValidEnvSecretRefId,
type SecretInput,
type SecretRef,
} from "../config/types.secrets.js";
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
isValidFileSecretRefId,
resolveDefaultSecretProviderAlias,
} from "../secrets/ref-contract.js";
import { resolveSecretRefString } from "../secrets/resolve.js";
import type { SecretInput } from "../config/types.secrets.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import {
resolveSecretInputModeForEnvSelection,
type SecretInputModePromptCopy,
} from "./provider-auth-mode.js";
import {
extractEnvVarFromSourceLabel,
promptSecretRefForSetup,
resolveRefFallbackInput,
type SecretRefSetupPromptCopy,
} from "./provider-auth-ref.js";
import type { SecretInputMode } from "./provider-auth-types.js";
export {
extractEnvVarFromSourceLabel,
promptSecretRefForSetup,
resolveRefFallbackInput,
type SecretRefSetupPromptCopy,
} from "./provider-auth-ref.js";
export {
resolveSecretInputModeForEnvSelection,
type SecretInputModePromptCopy,
} from "./provider-auth-mode.js";
const DEFAULT_KEY_PREVIEW = { head: 4, tail: 4 };
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret
export type SecretInputModePromptCopy = {
modeMessage?: string;
plaintextLabel?: string;
plaintextHint?: string;
refLabel?: string;
refHint?: string;
};
export type SecretRefSetupPromptCopy = {
sourceMessage?: string;
envVarMessage?: string;
envVarPlaceholder?: string;
envVarFormatError?: string;
envVarMissingError?: (envVar: string) => string;
noProvidersMessage?: string;
envValidatedMessage?: (envVar: string) => string;
providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string;
};
export function normalizeApiKeyInput(raw: string): string {
const trimmed = String(raw ?? "").trim();
@@ -87,239 +73,6 @@ export function formatApiKeyPreview(
return `${trimmed.slice(0, head)}${trimmed.slice(-tail)}`;
}
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === "string" && error.message.trim()) {
return error.message;
}
return String(error);
}
function extractEnvVarFromSourceLabel(source: string): string | undefined {
const match = ENV_SOURCE_LABEL_RE.exec(source.trim());
return match?.[1];
}
function resolveDefaultProviderEnvVar(provider: string): string | undefined {
const envVars = PROVIDER_ENV_VARS[provider];
return envVars?.find((candidate) => candidate.trim().length > 0);
}
function resolveDefaultFilePointerId(provider: string): string {
return `/providers/${encodeJsonPointerToken(provider)}/apiKey`;
}
function resolveRefFallbackInput(params: {
config: OpenClawConfig;
provider: string;
preferredEnvVar?: string;
}): { ref: SecretRef; resolvedValue: string } {
const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider);
if (!fallbackEnvVar) {
throw new Error(
`No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`,
);
}
const value = process.env[fallbackEnvVar]?.trim();
if (!value) {
throw new Error(
`Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`,
);
}
return {
ref: {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: fallbackEnvVar,
},
resolvedValue: value,
};
}
export async function promptSecretRefForSetup(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
preferredEnvVar?: string;
copy?: SecretRefSetupPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const defaultEnvVar =
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
const defaultFilePointer = resolveDefaultFilePointerId(params.provider);
let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret
while (true) {
const sourceRaw: SecretRefChoice = await params.prompter.select<SecretRefChoice>({
message: params.copy?.sourceMessage ?? "Where is this API key stored?",
initialValue: sourceChoice,
options: [
{
value: "env",
label: "Environment variable",
hint: "Reference a variable from your runtime environment",
},
{
value: "provider",
label: "Configured secret provider",
hint: "Use a configured file or exec secret provider",
},
],
});
const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env";
sourceChoice = source;
if (source === "env") {
const envVarRaw = await params.prompter.text({
message: params.copy?.envVarMessage ?? "Environment variable name",
initialValue: defaultEnvVar || undefined,
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!isValidEnvSecretRefId(candidate)) {
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
);
}
if (!process.env[candidate]?.trim()) {
return (
params.copy?.envVarMissingError?.(candidate) ??
`Environment variable "${candidate}" is missing or empty in this session.`
);
}
return undefined;
},
});
const envCandidate = String(envVarRaw ?? "").trim();
const envVar =
envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : defaultEnvVar;
if (!envVar) {
throw new Error(
`No valid environment variable name provided for provider "${params.provider}".`,
);
}
const ref: SecretRef = {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: envVar,
};
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.envValidatedMessage?.(envVar) ??
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
}
const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter(
([, provider]) => provider?.source === "file" || provider?.source === "exec",
);
if (externalProviders.length === 0) {
await params.prompter.note(
params.copy?.noProvidersMessage ??
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.",
"No providers configured",
);
continue;
}
const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", {
preferFirstProviderForSource: true,
});
const selectedProvider = await params.prompter.select<string>({
message: "Select secret provider",
initialValue:
externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ??
externalProviders[0]?.[0],
options: externalProviders.map(([providerName, provider]) => ({
value: providerName,
label: providerName,
hint: provider?.source === "exec" ? "Exec provider" : "File provider",
})),
});
const providerEntry = params.config.secrets?.providers?.[selectedProvider];
if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) {
await params.prompter.note(
`Provider "${selectedProvider}" is not a file/exec provider.`,
"Invalid provider",
);
continue;
}
const idPrompt =
providerEntry.source === "file"
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
: "Secret id for the exec provider";
const idDefault =
providerEntry.source === "file"
? providerEntry.mode === "singleValue"
? "value"
: defaultFilePointer
: `${params.provider}/apiKey`;
const idRaw = await params.prompter.text({
message: idPrompt,
initialValue: idDefault,
placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key",
validate: (value) => {
const candidate = value.trim();
if (!candidate) {
return "Secret id cannot be empty.";
}
if (
providerEntry.source === "file" &&
providerEntry.mode !== "singleValue" &&
!isValidFileSecretRefId(candidate)
) {
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
}
if (
providerEntry.source === "file" &&
providerEntry.mode === "singleValue" &&
candidate !== "value"
) {
return 'singleValue mode expects id "value".';
}
if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) {
return formatExecSecretRefIdValidationMessage();
}
return undefined;
},
});
const id = String(idRaw ?? "").trim() || idDefault;
const ref: SecretRef = {
source: providerEntry.source,
provider: selectedProvider,
id,
};
try {
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ??
`Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
} catch (error) {
await params.prompter.note(
[
`Could not validate provider reference ${selectedProvider}:${id}.`,
formatErrorMessage(error),
"Check your provider configuration and try again.",
].join("\n"),
"Reference check failed",
);
}
}
}
export function normalizeTokenProviderInput(
tokenProvider: string | null | undefined,
): string | undefined {
@@ -341,38 +94,6 @@ export function normalizeSecretInputModeInput(
return undefined;
}
export async function resolveSecretInputModeForEnvSelection(params: {
prompter: WizardPrompter;
explicitMode?: SecretInputMode;
copy?: SecretInputModePromptCopy;
}): Promise<SecretInputMode> {
if (params.explicitMode) {
return params.explicitMode;
}
if (typeof params.prompter.select !== "function") {
return "plaintext";
}
const selected = await params.prompter.select<SecretInputMode>({
message: params.copy?.modeMessage ?? "How do you want to provide this API key?",
initialValue: "plaintext",
options: [
{
value: "plaintext",
label: params.copy?.plaintextLabel ?? "Paste API key now",
hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config",
},
{
value: "ref",
label: params.copy?.refLabel ?? "Use external secret provider",
hint:
params.copy?.refHint ??
"Stores a reference to env or configured external secret providers",
},
],
});
return selected === "ref" ? "ref" : "plaintext";
}
export async function maybeApplyApiKeyFromOption(params: {
token: string | undefined;
tokenProvider: string | undefined;

View File

@@ -0,0 +1,42 @@
import type { WizardPrompter } from "../wizard/prompts.js";
import type { SecretInputMode } from "./provider-auth-types.js";
export type SecretInputModePromptCopy = {
modeMessage?: string;
plaintextLabel?: string;
plaintextHint?: string;
refLabel?: string;
refHint?: string;
};
export async function resolveSecretInputModeForEnvSelection(params: {
prompter: Pick<WizardPrompter, "select">;
explicitMode?: SecretInputMode;
copy?: SecretInputModePromptCopy;
}): Promise<SecretInputMode> {
if (params.explicitMode) {
return params.explicitMode;
}
if (typeof params.prompter.select !== "function") {
return "plaintext";
}
const selected = await params.prompter.select<SecretInputMode>({
message: params.copy?.modeMessage ?? "How do you want to provide this API key?",
initialValue: "plaintext",
options: [
{
value: "plaintext",
label: params.copy?.plaintextLabel ?? "Paste API key now",
hint: params.copy?.plaintextHint ?? "Stores the key directly in OpenClaw config",
},
{
value: "ref",
label: params.copy?.refLabel ?? "Use external secret provider",
hint:
params.copy?.refHint ??
"Stores a reference to env or configured external secret providers",
},
],
});
return selected === "ref" ? "ref" : "plaintext";
}

View File

@@ -0,0 +1,310 @@
import type { OpenClawConfig } from "../config/types.js";
import { isValidEnvSecretRefId, type SecretRef } from "../config/types.secrets.js";
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
import {
formatExecSecretRefIdValidationMessage,
isValidExecSecretRefId,
isValidFileSecretRefId,
resolveDefaultSecretProviderAlias,
} from "../secrets/ref-contract.js";
import type { WizardPrompter } from "../wizard/prompts.js";
let secretResolvePromise: Promise<typeof import("../secrets/resolve.js")> | undefined;
function loadSecretResolve() {
secretResolvePromise ??= import("../secrets/resolve.js");
return secretResolvePromise;
}
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
type SecretRefChoice = "env" | "provider"; // pragma: allowlist secret
export type SecretRefSetupPromptCopy = {
sourceMessage?: string;
envVarMessage?: string;
envVarPlaceholder?: string;
envVarFormatError?: string;
envVarMissingError?: (envVar: string) => string;
noProvidersMessage?: string;
envValidatedMessage?: (envVar: string) => string;
providerValidatedMessage?: (provider: string, id: string, source: "file" | "exec") => string;
};
function formatErrorMessage(error: unknown): string {
if (error instanceof Error && typeof error.message === "string" && error.message.trim()) {
return error.message;
}
return String(error);
}
export function extractEnvVarFromSourceLabel(source: string): string | undefined {
const match = ENV_SOURCE_LABEL_RE.exec(source.trim());
return match?.[1];
}
function resolveDefaultProviderEnvVar(provider: string): string | undefined {
const envVars = PROVIDER_ENV_VARS[provider];
return envVars?.find((candidate) => candidate.trim().length > 0);
}
function resolveDefaultFilePointerId(provider: string): string {
return `/providers/${encodeJsonPointerToken(provider)}/apiKey`;
}
export function resolveRefFallbackInput(params: {
config: OpenClawConfig;
provider: string;
preferredEnvVar?: string;
}): { ref: SecretRef; resolvedValue: string } {
const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider);
if (!fallbackEnvVar) {
throw new Error(
`No default environment variable mapping found for provider "${params.provider}". Set a provider-specific env var, or re-run setup in an interactive terminal to configure a ref.`,
);
}
const value = process.env[fallbackEnvVar]?.trim();
if (!value) {
throw new Error(
`Environment variable "${fallbackEnvVar}" is required for --secret-input-mode ref in non-interactive setup.`,
);
}
return {
ref: {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: fallbackEnvVar,
},
resolvedValue: value,
};
}
async function promptEnvSecretRefForSetup(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
defaultEnvVar: string;
copy?: SecretRefSetupPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const envVarRaw = await params.prompter.text({
message: params.copy?.envVarMessage ?? "Environment variable name",
initialValue: params.defaultEnvVar || undefined,
placeholder: params.copy?.envVarPlaceholder ?? "OPENAI_API_KEY",
validate: (value) => {
const candidate = value.trim();
if (!isValidEnvSecretRefId(candidate)) {
return (
params.copy?.envVarFormatError ??
'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).'
);
}
if (!process.env[candidate]?.trim()) {
return (
params.copy?.envVarMissingError?.(candidate) ??
`Environment variable "${candidate}" is missing or empty in this session.`
);
}
return undefined;
},
});
const envCandidate = String(envVarRaw ?? "").trim();
const envVar =
envCandidate && isValidEnvSecretRefId(envCandidate) ? envCandidate : params.defaultEnvVar;
if (!envVar) {
throw new Error(
`No valid environment variable name provided for provider "${params.provider}".`,
);
}
const resolvedValue = process.env[envVar]?.trim();
if (!resolvedValue) {
throw new Error(`Environment variable "${envVar}" is missing or empty in this session.`);
}
const ref: SecretRef = {
source: "env",
provider: resolveDefaultSecretProviderAlias(params.config, "env", {
preferFirstProviderForSource: true,
}),
id: envVar,
};
await params.prompter.note(
params.copy?.envValidatedMessage?.(envVar) ??
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
}
async function promptProviderSecretRefForSetup(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
defaultFilePointer: string;
copy?: SecretRefSetupPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const externalProviders = Object.entries(params.config.secrets?.providers ?? {}).filter(
([, provider]) => provider?.source === "file" || provider?.source === "exec",
);
if (externalProviders.length === 0) {
await params.prompter.note(
params.copy?.noProvidersMessage ??
"No file/exec secret providers are configured yet. Add one under secrets.providers, or select Environment variable.",
"No providers configured",
);
throw new Error("retry");
}
const defaultProvider = resolveDefaultSecretProviderAlias(params.config, "file", {
preferFirstProviderForSource: true,
});
const selectedProvider = await params.prompter.select<string>({
message: "Select secret provider",
initialValue:
externalProviders.find(([providerName]) => providerName === defaultProvider)?.[0] ??
externalProviders[0]?.[0],
options: externalProviders.map(([providerName, provider]) => ({
value: providerName,
label: providerName,
hint: provider?.source === "exec" ? "Exec provider" : "File provider",
})),
});
const providerEntry = params.config.secrets?.providers?.[selectedProvider];
if (!providerEntry || (providerEntry.source !== "file" && providerEntry.source !== "exec")) {
await params.prompter.note(
`Provider "${selectedProvider}" is not a file/exec provider.`,
"Invalid provider",
);
throw new Error("retry");
}
const idPrompt =
providerEntry.source === "file"
? "Secret id (JSON pointer for json mode, or 'value' for singleValue mode)"
: "Secret id for the exec provider";
const idDefault =
providerEntry.source === "file"
? providerEntry.mode === "singleValue"
? "value"
: params.defaultFilePointer
: `${params.provider}/apiKey`;
const idRaw = await params.prompter.text({
message: idPrompt,
initialValue: idDefault,
placeholder: providerEntry.source === "file" ? "/providers/openai/apiKey" : "openai/api-key",
validate: (value) => {
const candidate = value.trim();
if (!candidate) {
return "Secret id cannot be empty.";
}
if (
providerEntry.source === "file" &&
providerEntry.mode !== "singleValue" &&
!isValidFileSecretRefId(candidate)
) {
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
}
if (
providerEntry.source === "file" &&
providerEntry.mode === "singleValue" &&
candidate !== "value"
) {
return 'singleValue mode expects id "value".';
}
if (providerEntry.source === "exec" && !isValidExecSecretRefId(candidate)) {
return formatExecSecretRefIdValidationMessage();
}
return undefined;
},
});
const id = String(idRaw ?? "").trim() || idDefault;
const ref: SecretRef = {
source: providerEntry.source,
provider: selectedProvider,
id,
};
try {
const { resolveSecretRefString } = await loadSecretResolve();
const resolvedValue = await resolveSecretRefString(ref, {
config: params.config,
env: process.env,
});
await params.prompter.note(
params.copy?.providerValidatedMessage?.(selectedProvider, id, providerEntry.source) ??
`Validated ${providerEntry.source} reference ${selectedProvider}:${id}. OpenClaw will store a reference, not the key value.`,
"Reference validated",
);
return { ref, resolvedValue };
} catch (error) {
await params.prompter.note(
[
`Could not validate provider reference ${selectedProvider}:${id}.`,
formatErrorMessage(error),
"Check your provider configuration and try again.",
].join("\n"),
"Reference check failed",
);
throw new Error("retry", { cause: error });
}
}
export async function promptSecretRefForSetup(params: {
provider: string;
config: OpenClawConfig;
prompter: WizardPrompter;
preferredEnvVar?: string;
copy?: SecretRefSetupPromptCopy;
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
const defaultEnvVar =
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
const defaultFilePointer = resolveDefaultFilePointerId(params.provider);
let sourceChoice: SecretRefChoice = "env"; // pragma: allowlist secret
while (true) {
const sourceRaw: SecretRefChoice = await params.prompter.select<SecretRefChoice>({
message: params.copy?.sourceMessage ?? "Where is this API key stored?",
initialValue: sourceChoice,
options: [
{
value: "env",
label: "Environment variable",
hint: "Reference a variable from your runtime environment",
},
{
value: "provider",
label: "Configured secret provider",
hint: "Use a configured file or exec secret provider",
},
],
});
const source: SecretRefChoice = sourceRaw === "provider" ? "provider" : "env";
sourceChoice = source;
if (source === "env") {
return await promptEnvSecretRefForSetup({
provider: params.provider,
config: params.config,
prompter: params.prompter,
defaultEnvVar,
copy: params.copy,
});
}
try {
return await promptProviderSecretRefForSetup({
provider: params.provider,
config: params.config,
prompter: params.prompter,
defaultFilePointer,
copy: params.copy,
});
} catch (error) {
if (error instanceof Error && error.message === "retry") {
continue;
}
throw error;
}
}
}

View File

@@ -0,0 +1,573 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import {
buildPluginLoaderAliasMap,
buildPluginLoaderJitiOptions,
listPluginSdkAliasCandidates,
listPluginSdkExportedSubpaths,
resolveExtensionApiAlias,
resolvePluginRuntimeModulePath,
resolvePluginSdkAliasFile,
shouldPreferNativeJiti,
} from "./sdk-alias.js";
type CreateJiti = typeof import("jiti").createJiti;
let createJitiPromise: Promise<CreateJiti> | undefined;
async function getCreateJiti() {
createJitiPromise ??= import("jiti").then(({ createJiti }) => createJiti);
return createJitiPromise;
}
function chmodSafeDir(dir: string) {
if (process.platform === "win32") {
return;
}
fs.chmodSync(dir, 0o755);
}
function mkdtempSafe(prefix: string) {
const dir = fs.mkdtempSync(prefix);
chmodSafeDir(dir);
return dir;
}
function mkdirSafe(dir: string) {
fs.mkdirSync(dir, { recursive: true });
chmodSafeDir(dir);
}
const fixtureRoot = mkdtempSafe(path.join(os.tmpdir(), "openclaw-sdk-alias-"));
let tempDirIndex = 0;
function makeTempDir() {
const dir = path.join(fixtureRoot, `case-${tempDirIndex++}`);
mkdirSafe(dir);
return dir;
}
function withCwd<T>(cwd: string, run: () => T): T {
const previousCwd = process.cwd();
process.chdir(cwd);
try {
return run();
} finally {
process.chdir(previousCwd);
}
}
function createPluginSdkAliasFixture(params?: {
srcFile?: string;
distFile?: string;
srcBody?: string;
distBody?: string;
packageName?: string;
packageExports?: Record<string, unknown>;
trustedRootIndicators?: boolean;
trustedRootIndicatorMode?: "bin+marker" | "cli-entry-only" | "none";
}) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugin-sdk", params?.srcFile ?? "index.ts");
const distFile = path.join(root, "dist", "plugin-sdk", params?.distFile ?? "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
const trustedRootIndicatorMode =
params?.trustedRootIndicatorMode ??
(params?.trustedRootIndicators === false ? "none" : "bin+marker");
const packageJson: Record<string, unknown> = {
name: params?.packageName ?? "openclaw",
type: "module",
};
if (trustedRootIndicatorMode === "bin+marker") {
packageJson.bin = {
openclaw: "openclaw.mjs",
};
}
if (params?.packageExports || trustedRootIndicatorMode === "cli-entry-only") {
const trustedExports: Record<string, unknown> =
trustedRootIndicatorMode === "cli-entry-only"
? { "./cli-entry": { default: "./dist/cli-entry.js" } }
: {};
packageJson.exports = {
"./plugin-sdk": { default: "./dist/plugin-sdk/index.js" },
...trustedExports,
...params?.packageExports,
};
}
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(packageJson, null, 2), "utf-8");
if (trustedRootIndicatorMode === "bin+marker") {
fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8");
}
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
return { root, srcFile, distFile };
}
function createExtensionApiAliasFixture(params?: { srcBody?: string; distBody?: string }) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "extensionAPI.ts");
const distFile = path.join(root, "dist", "extensionAPI.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf-8",
);
fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8");
fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8");
fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8");
return { root, srcFile, distFile };
}
function createPluginRuntimeAliasFixture(params?: { srcBody?: string; distBody?: string }) {
const root = makeTempDir();
const srcFile = path.join(root, "src", "plugins", "runtime", "index.ts");
const distFile = path.join(root, "dist", "plugins", "runtime", "index.js");
mkdirSafe(path.dirname(srcFile));
mkdirSafe(path.dirname(distFile));
fs.writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf-8",
);
fs.writeFileSync(
srcFile,
params?.srcBody ?? "export const createPluginRuntime = () => ({});\n",
"utf-8",
);
fs.writeFileSync(
distFile,
params?.distBody ?? "export const createPluginRuntime = () => ({});\n",
"utf-8",
);
return { root, srcFile, distFile };
}
function resolvePluginSdkAlias(params: {
srcFile: string;
distFile: string;
modulePath: string;
argv1?: string;
env?: NodeJS.ProcessEnv;
}) {
const run = () =>
resolvePluginSdkAliasFile({
srcFile: params.srcFile,
distFile: params.distFile,
modulePath: params.modulePath,
argv1: params.argv1,
});
return params.env ? withEnv(params.env, run) : run();
}
function resolvePluginRuntimeModule(params: {
modulePath: string;
argv1?: string;
env?: NodeJS.ProcessEnv;
}) {
const run = () =>
resolvePluginRuntimeModulePath({
modulePath: params.modulePath,
argv1: params.argv1,
});
return params.env ? withEnv(params.env, run) : run();
}
afterAll(() => {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
describe("plugin sdk alias helpers", () => {
it.each([
{
name: "prefers dist plugin-sdk alias when loader runs from dist",
buildFixture: () => createPluginSdkAliasFixture(),
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
srcFile: "index.ts",
distFile: "index.js",
expected: "dist" as const,
},
{
name: "prefers src plugin-sdk alias when loader runs from src in non-production",
buildFixture: () => createPluginSdkAliasFixture(),
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
srcFile: "index.ts",
distFile: "index.js",
env: { NODE_ENV: undefined },
expected: "src" as const,
},
{
name: "falls back to src plugin-sdk alias when dist is missing in production",
buildFixture: () => {
const fixture = createPluginSdkAliasFixture();
fs.rmSync(fixture.distFile);
return fixture;
},
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
srcFile: "index.ts",
distFile: "index.js",
env: { NODE_ENV: "production", VITEST: undefined },
expected: "src" as const,
},
{
name: "prefers dist root-alias shim when loader runs from dist",
buildFixture: () =>
createPluginSdkAliasFixture({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
srcBody: "module.exports = {};\n",
distBody: "module.exports = {};\n",
}),
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
expected: "dist" as const,
},
{
name: "prefers src root-alias shim when loader runs from src in non-production",
buildFixture: () =>
createPluginSdkAliasFixture({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
srcBody: "module.exports = {};\n",
distBody: "module.exports = {};\n",
}),
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
env: { NODE_ENV: undefined },
expected: "src" as const,
},
{
name: "resolves plugin-sdk alias from package root when loader runs from transpiler cache path",
buildFixture: () => createPluginSdkAliasFixture(),
modulePath: () => "/tmp/tsx-cache/openclaw-loader.js",
argv1: (root: string) => path.join(root, "openclaw.mjs"),
srcFile: "index.ts",
distFile: "index.js",
env: { NODE_ENV: undefined },
expected: "src" as const,
},
])("$name", ({ buildFixture, modulePath, argv1, srcFile, distFile, env, expected }) => {
const fixture = buildFixture();
const resolved = resolvePluginSdkAlias({
srcFile,
distFile,
modulePath: modulePath(fixture.root),
argv1: argv1?.(fixture.root),
env,
});
expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile);
});
it.each([
{
name: "prefers dist extension-api alias when loader runs from dist",
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
expected: "dist" as const,
},
{
name: "prefers src extension-api alias when loader runs from src in non-production",
modulePath: (root: string) => path.join(root, "src", "plugins", "loader.ts"),
env: { NODE_ENV: undefined },
expected: "src" as const,
},
{
name: "resolves extension-api alias from package root when loader runs from transpiler cache path",
modulePath: () => "/tmp/tsx-cache/openclaw-loader.js",
argv1: (root: string) => path.join(root, "openclaw.mjs"),
env: { NODE_ENV: undefined },
expected: "src" as const,
},
])("$name", ({ modulePath, argv1, env, expected }) => {
const fixture = createExtensionApiAliasFixture();
const resolved = withEnv(env ?? {}, () =>
resolveExtensionApiAlias({
modulePath: modulePath(fixture.root),
argv1: argv1?.(fixture.root),
}),
);
expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile);
});
it.each([
{
name: "prefers dist candidates first for production src runtime",
env: { NODE_ENV: "production", VITEST: undefined },
expectedFirst: "dist" as const,
},
{
name: "prefers src candidates first for non-production src runtime",
env: { NODE_ENV: undefined },
expectedFirst: "src" as const,
},
])("$name", ({ env, expectedFirst }) => {
const fixture = createPluginSdkAliasFixture();
const candidates = withEnv(env ?? {}, () =>
listPluginSdkAliasCandidates({
srcFile: "index.ts",
distFile: "index.js",
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
}),
);
const first = expectedFirst === "dist" ? fixture.distFile : fixture.srcFile;
const second = expectedFirst === "dist" ? fixture.srcFile : fixture.distFile;
expect(candidates.indexOf(first)).toBeLessThan(candidates.indexOf(second));
});
it("derives plugin-sdk subpaths from package exports", () => {
const fixture = createPluginSdkAliasFixture({
packageExports: {
"./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" },
"./plugin-sdk/telegram": { default: "./dist/plugin-sdk/telegram.js" },
"./plugin-sdk/nested/value": { default: "./dist/plugin-sdk/nested/value.js" },
},
});
const subpaths = listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
});
expect(subpaths).toEqual(["compat", "telegram"]);
});
it("derives plugin-sdk subpaths from nearest package exports even when package name is renamed", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
"./plugin-sdk/compat": { default: "./dist/plugin-sdk/compat.js" },
},
});
const subpaths = listPluginSdkExportedSubpaths({
modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"),
});
expect(subpaths).toEqual(["channel-runtime", "compat", "core"]);
});
it("derives plugin-sdk subpaths via cwd fallback when module path is a transpiler cache and package is renamed", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual(["channel-runtime", "core"]);
});
it("resolves plugin-sdk alias files via cwd fallback when module path is a transpiler cache and package is renamed", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageName: "moltbot",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const resolved = withCwd(fixture.root, () =>
resolvePluginSdkAlias({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
env: { NODE_ENV: undefined },
}),
);
expect(resolved).not.toBeNull();
expect(fs.realpathSync(resolved ?? "")).toBe(fs.realpathSync(fixture.srcFile));
});
it("does not derive plugin-sdk subpaths from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
trustedRootIndicators: false,
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual([]);
});
it("derives plugin-sdk subpaths via cwd fallback when trusted root indicator is cli-entry export", () => {
const fixture = createPluginSdkAliasFixture({
packageName: "moltbot",
trustedRootIndicatorMode: "cli-entry-only",
packageExports: {
"./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" },
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const subpaths = withCwd(fixture.root, () =>
listPluginSdkExportedSubpaths({
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
}),
);
expect(subpaths).toEqual(["channel-runtime", "core"]);
});
it("builds plugin-sdk aliases from the module being loaded, not the loader location", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs");
const distRootAlias = path.join(fixture.root, "dist", "plugin-sdk", "root-alias.cjs");
fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8");
fs.writeFileSync(distRootAlias, "module.exports = {};\n", "utf-8");
const sourcePluginEntry = path.join(fixture.root, "extensions", "demo", "src", "index.ts");
fs.mkdirSync(path.dirname(sourcePluginEntry), { recursive: true });
fs.writeFileSync(sourcePluginEntry, 'export const plugin = "demo";\n', "utf-8");
const sourceAliases = withEnv({ NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(sourcePluginEntry),
);
expect(fs.realpathSync(sourceAliases["openclaw/plugin-sdk"] ?? "")).toBe(
fs.realpathSync(sourceRootAlias),
);
expect(fs.realpathSync(sourceAliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe(
fs.realpathSync(path.join(fixture.root, "src", "plugin-sdk", "channel-runtime.ts")),
);
const distPluginEntry = path.join(fixture.root, "dist", "extensions", "demo", "index.js");
fs.mkdirSync(path.dirname(distPluginEntry), { recursive: true });
fs.writeFileSync(distPluginEntry, 'export const plugin = "demo";\n', "utf-8");
const distAliases = withEnv({ NODE_ENV: undefined }, () =>
buildPluginLoaderAliasMap(distPluginEntry),
);
expect(fs.realpathSync(distAliases["openclaw/plugin-sdk"] ?? "")).toBe(
fs.realpathSync(distRootAlias),
);
expect(fs.realpathSync(distAliases["openclaw/plugin-sdk/channel-runtime"] ?? "")).toBe(
fs.realpathSync(path.join(fixture.root, "dist", "plugin-sdk", "channel-runtime.js")),
);
});
it("does not resolve plugin-sdk alias files from cwd fallback when package root is not an OpenClaw root", () => {
const fixture = createPluginSdkAliasFixture({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
packageName: "moltbot",
trustedRootIndicators: false,
packageExports: {
"./plugin-sdk/channel-runtime": { default: "./dist/plugin-sdk/channel-runtime.js" },
},
});
const resolved = withCwd(fixture.root, () =>
resolvePluginSdkAlias({
srcFile: "channel-runtime.ts",
distFile: "channel-runtime.js",
modulePath: "/tmp/tsx-cache/openclaw-loader.js",
env: { NODE_ENV: undefined },
}),
);
expect(resolved).toBeNull();
});
it("configures the plugin loader jiti boundary to prefer native dist modules", () => {
const options = buildPluginLoaderJitiOptions({});
expect(options.tryNative).toBe(true);
expect(options.interopDefault).toBe(true);
expect(options.extensions).toContain(".js");
expect(options.extensions).toContain(".ts");
expect("alias" in options).toBe(false);
});
it("uses transpiled Jiti loads for source TypeScript plugin entries", () => {
expect(shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true);
expect(shouldPreferNativeJiti("/repo/extensions/discord/src/channel.runtime.ts")).toBe(false);
});
it("loads source runtime shims through the non-native Jiti boundary", async () => {
const copiedExtensionRoot = path.join(makeTempDir(), "extensions", "discord");
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/infra-runtime";
export const syntheticRuntimeMarker = {
resolveOutboundSendDep,
};
`,
"utf-8",
);
const copiedChannelRuntimeShim = path.join(copiedPluginSdkDir, "infra-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, {
...buildPluginLoaderJitiOptions({}),
tryNative: false,
});
expect(() => withoutAlias(copiedChannelRuntime)).toThrow();
const withAlias = createJiti(jitiBaseUrl, {
...buildPluginLoaderJitiOptions({
"openclaw/plugin-sdk/infra-runtime": copiedChannelRuntimeShim,
}),
tryNative: false,
});
expect(withAlias(copiedChannelRuntime)).toMatchObject({
syntheticRuntimeMarker: {
resolveOutboundSendDep: expect.any(Function),
},
});
}, 240_000);
it.each([
{
name: "prefers dist plugin runtime module when loader runs from dist",
modulePath: (root: string) => path.join(root, "dist", "plugins", "loader.js"),
expected: "dist" as const,
},
{
name: "resolves plugin runtime module from package root when loader runs from transpiler cache path",
modulePath: () => "/tmp/tsx-cache/openclaw-loader.js",
argv1: (root: string) => path.join(root, "openclaw.mjs"),
env: { NODE_ENV: undefined },
expected: "src" as const,
},
])("$name", ({ modulePath, argv1, env, expected }) => {
const fixture = createPluginRuntimeAliasFixture();
const resolved = resolvePluginRuntimeModule({
modulePath: modulePath(fixture.root),
argv1: argv1?.(fixture.root),
env,
});
expect(resolved).toBe(expected === "dist" ? fixture.distFile : fixture.srcFile);
});
});

View File

@@ -238,6 +238,79 @@ export function resolvePluginSdkScopedAliasMap(
return aliasMap;
}
export function resolveExtensionApiAlias(params: LoaderModuleResolveParams = {}): string | null {
try {
const modulePath = resolveLoaderModulePath(params);
const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath });
if (!packageRoot) {
return null;
}
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const candidateMap = {
src: path.join(packageRoot, "src", "extensionAPI.ts"),
dist: path.join(packageRoot, "dist", "extensionAPI.js"),
} as const;
for (const kind of orderedKinds) {
const candidate = candidateMap[kind];
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
export function buildPluginLoaderAliasMap(modulePath: string): Record<string, string> {
const pluginSdkAlias = resolvePluginSdkAliasFile({
srcFile: "root-alias.cjs",
distFile: "root-alias.cjs",
modulePath,
});
const extensionApiAlias = resolveExtensionApiAlias({ modulePath });
return {
...(extensionApiAlias ? { "openclaw/extension-api": extensionApiAlias } : {}),
...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}),
...resolvePluginSdkScopedAliasMap({ modulePath }),
};
}
export function resolvePluginRuntimeModulePath(
params: LoaderModuleResolveParams = {},
): string | null {
try {
const modulePath = resolveLoaderModulePath(params);
const orderedKinds = resolvePluginSdkAliasCandidateOrder({
modulePath,
isProduction: process.env.NODE_ENV === "production",
});
const packageRoot = resolveLoaderPackageRoot({ ...params, modulePath });
const candidates = packageRoot
? orderedKinds.map((kind) =>
kind === "src"
? path.join(packageRoot, "src", "plugins", "runtime", "index.ts")
: path.join(packageRoot, "dist", "plugins", "runtime", "index.js"),
)
: [
path.join(path.dirname(modulePath), "runtime", "index.ts"),
path.join(path.dirname(modulePath), "runtime", "index.js"),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
} catch {
// ignore
}
return null;
}
export function buildPluginLoaderJitiOptions(aliasMap: Record<string, string>) {
return {
interopDefault: true,

View File

@@ -1,7 +1,8 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { edgeTTS } from "./tts-core.js";
let mockTtsPromise = vi.fn<(text: string, filePath: string) => Promise<void>>();
@@ -13,10 +14,6 @@ vi.mock("node-edge-tts", () => ({
},
}));
type TtsCoreModule = typeof import("./tts-core.js");
let edgeTTS: TtsCoreModule["edgeTTS"];
const baseEdgeConfig = {
enabled: true,
voice: "en-US-MichelleNeural",
@@ -29,11 +26,6 @@ const baseEdgeConfig = {
describe("edgeTTS empty audio validation", () => {
let tempDir: string;
beforeEach(async () => {
vi.resetModules();
({ edgeTTS } = await import("./tts-core.js"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});

View File

@@ -19,10 +19,8 @@ import {
} from "../gateway/gateway-config-prompts.shared.js";
import { DEFAULT_DANGEROUS_NODE_COMMANDS } from "../gateway/node-command-policy.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import {
promptSecretRefForSetup,
resolveSecretInputModeForEnvSelection,
} from "../plugins/provider-auth-input.js";
import { resolveSecretInputModeForEnvSelection } from "../plugins/provider-auth-mode.js";
import { promptSecretRefForSetup } from "../plugins/provider-auth-ref.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import type { WizardPrompter } from "./prompts.js";

View File

@@ -139,10 +139,6 @@
"file": "src/cron/service.runs-one-shot-main-job-disables-it.test.ts",
"reason": "One-shot cron service coverage retained a top shared unit-fast heap spike in the March 19, 2026 Linux Node 22 OOM lane."
},
{
"file": "src/cron/isolated-agent/run.sandbox-config-preserved.test.ts",
"reason": "Isolated-agent sandbox config coverage retained a large shared unit-fast heap spike on Linux CI."
},
{
"file": "src/cron/isolated-agent.direct-delivery-core-channels.test.ts",
"reason": "Direct-delivery isolated-agent coverage retained a top shared unit-fast heap spike in the March 20, 2026 Linux Node 24 OOM lane."

File diff suppressed because it is too large Load Diff