mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-23 22:55:24 +00:00
perf: reduce runtime and test startup overhead
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
1
extensions/whatsapp/auth-presence.ts
Normal file
1
extensions/whatsapp/auth-presence.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { hasAnyWhatsAppAuth } from "./src/accounts.js";
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
21
src/channels/plugins/contracts/manifest.ts
Normal file
21
src/channels/plugins/contracts/manifest.ts
Normal 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];
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
14
src/config/redact-snapshot.test-hints.ts
Normal file
14
src/config/redact-snapshot.test-hints.ts
Normal 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 },
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
50
src/cron/isolated-agent/run-config.ts
Normal file
50
src/cron/isolated-agent/run-config.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
42
src/plugins/provider-auth-mode.ts
Normal file
42
src/plugins/provider-auth-mode.ts
Normal 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";
|
||||
}
|
||||
310
src/plugins/provider-auth-ref.ts
Normal file
310
src/plugins/provider-auth-ref.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
573
src/plugins/sdk-alias.test.ts
Normal file
573
src/plugins/sdk-alias.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
4
test/fixtures/test-parallel.behavior.json
vendored
4
test/fixtures/test-parallel.behavior.json
vendored
@@ -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."
|
||||
|
||||
1524
test/fixtures/test-timings.unit.json
vendored
1524
test/fixtures/test-timings.unit.json
vendored
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user