Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,12 @@
"release:metadata": "node ./scripts/release-metadata.mjs",
"release:apply-version": "node ./scripts/apply-release-version.mjs"
},
"peerDependencies": {
"openclaw": ">=2026.3.22"
},
"dependencies": {
"ws": "^8.18.3"
},
"devDependencies": {
"@types/node": "^24.6.0",
"openclaw": "2026.4.1",
"typescript": "^5.9.2",
"vitest": "^3.2.4",
"yaml": "^2.8.2"
Expand Down
17 changes: 5 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 92 additions & 1 deletion src/openclaw-sdk-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ describe("openclaw sdk compat", () => {
});

it("falls back to the dist facade when the public subpath is gone", async () => {
const files = new Map<string, string>([
[
"/tmp/node_modules/openclaw/package.json",
JSON.stringify({
name: "openclaw",
exports: {
"./plugin-sdk": { default: "./dist/plugin-sdk/index.js" },
"./cli-entry": { default: "./dist/cli-entry.js" },
},
}),
],
["/tmp/node_modules/openclaw/dist/plugin-sdk/discord.js", ""],
]);
const importer = vi.fn(async (specifier: string) => {
if (specifier === "openclaw/plugin-sdk/discord") {
throw Object.assign(
Expand All @@ -58,7 +71,15 @@ describe("openclaw sdk compat", () => {
label: "discord",
importer,
resolver: () => "/tmp/node_modules/openclaw/dist/index.js",
pathExists: () => true,
pathExists: (targetPath) =>
targetPath === "/tmp/node_modules/openclaw/dist/index.js" || files.has(targetPath),
readFile: (targetPath) => {
const content = files.get(targetPath);
if (!content) {
throw new Error(`missing ${targetPath}`);
}
return content;
},
cache: new Map(),
});

Expand Down Expand Up @@ -103,6 +124,76 @@ describe("openclaw sdk compat", () => {
expect(result).toBe("/host/openclaw/dist/index.js");
});

it("falls back to the host OpenClaw entrypoint from require.main when argv/cwd do not identify it", () => {
const files = new Map<string, string>([
[
"/opt/homebrew/lib/node_modules/openclaw/package.json",
JSON.stringify({
name: "openclaw",
exports: {
"./plugin-sdk": { default: "./dist/plugin-sdk/index.js" },
"./cli-entry": { default: "./dist/cli-entry.js" },
},
}),
],
]);

const result = resolveOpenClawEntrypointPath({
argv1: "/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server/index.ts",
cwd: "/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server",
mainFilename: "/opt/homebrew/lib/node_modules/openclaw/dist/index.js",
pathExists: (targetPath) =>
targetPath === "/opt/homebrew/lib/node_modules/openclaw/dist/index.js" ||
files.has(targetPath),
readFile: (targetPath) => {
const content = files.get(targetPath);
if (!content) {
throw new Error(`missing ${targetPath}`);
}
return content;
},
resolver: () =>
"/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server/node_modules/openclaw/dist/index.js",
});

expect(result).toBe("/opt/homebrew/lib/node_modules/openclaw/dist/index.js");
});

it("rejects extension-local vendored openclaw fallbacks", () => {
const files = new Map<string, string>([
[
"/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server/node_modules/openclaw/package.json",
JSON.stringify({
name: "openclaw",
exports: {
"./plugin-sdk": { default: "./dist/plugin-sdk/index.js" },
"./cli-entry": { default: "./dist/cli-entry.js" },
},
}),
],
]);

expect(() =>
resolveOpenClawEntrypointPath({
argv1: "/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server/index.ts",
cwd: "/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server",
pathExists: (targetPath) =>
targetPath ===
"/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server/node_modules/openclaw/dist/index.js" ||
files.has(targetPath),
readFile: (targetPath) => {
const content = files.get(targetPath);
if (!content) {
throw new Error(`missing ${targetPath}`);
}
return content;
},
resolver: () =>
"/Users/huntharo/.openclaw/extensions/openclaw-codex-app-server/node_modules/openclaw/dist/index.js",
}),
).toThrow("Unable to resolve a trusted host OpenClaw installation");
});

it("rethrows non-resolution failures from the public import", async () => {
const importer = vi.fn(async (_specifier: string) => {
throw new Error("boom");
Expand Down
43 changes: 40 additions & 3 deletions src/openclaw-sdk-compat.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

const require = createRequire(import.meta.url);
const THIS_PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");

export type PluginSdkCompatLogger = {
debug?: (message: string) => void;
Expand Down Expand Up @@ -113,27 +114,55 @@ function resolveTrustedOpenClawRootFromStart(
return null;
}

function isDisallowedResolvedOpenClawRoot(packageRoot: string): boolean {
const normalizedRoot = path.resolve(packageRoot);
if (
normalizedRoot === THIS_PACKAGE_ROOT ||
normalizedRoot.startsWith(`${THIS_PACKAGE_ROOT}${path.sep}`)
) {
return true;
}
return normalizedRoot.includes(`${path.sep}.openclaw${path.sep}extensions${path.sep}`);
}

export function resolveOpenClawEntrypointPath(params?: {
resolver?: CompatResolver;
pathExists?: CompatPathExists;
readFile?: CompatReadFile;
argv1?: string;
cwd?: string;
mainFilename?: string;
}): string {
const resolver = params?.resolver ?? ((specifier: string) => require.resolve(specifier));
const pathExists = params?.pathExists ?? existsSync;
const readFile = params?.readFile ?? ((targetPath: string) => readFileSync(targetPath, "utf-8"));
const hostRoot =
resolveTrustedOpenClawRootFromStart(params?.argv1 ?? process.argv[1], pathExists, readFile) ??
resolveTrustedOpenClawRootFromStart(params?.cwd ?? process.cwd(), pathExists, readFile);
resolveTrustedOpenClawRootFromStart(params?.cwd ?? process.cwd(), pathExists, readFile) ??
resolveTrustedOpenClawRootFromStart(
params?.mainFilename ?? require.main?.filename,
pathExists,
readFile,
);
if (hostRoot) {
const distEntrypoint = path.join(hostRoot, "dist", "index.js");
if (pathExists(distEntrypoint)) {
return distEntrypoint;
}
return path.join(hostRoot, "src", "index.ts");
}
return resolver("openclaw");
const resolvedEntrypoint = resolver("openclaw");
const resolvedRoot = resolveTrustedOpenClawRootFromStart(resolvedEntrypoint, pathExists, readFile);
if (!resolvedRoot || isDisallowedResolvedOpenClawRoot(resolvedRoot)) {
throw new Error(
`Unable to resolve a trusted host OpenClaw installation from ${resolvedEntrypoint}`,
);
}
const distEntrypoint = path.join(resolvedRoot, "dist", "index.js");
if (pathExists(distEntrypoint)) {
return distEntrypoint;
}
return path.join(resolvedRoot, "src", "index.ts");
}

export async function loadOpenClawCompatModule<T>(params: {
Expand All @@ -144,6 +173,10 @@ export async function loadOpenClawCompatModule<T>(params: {
importer?: CompatImporter;
resolver?: CompatResolver;
pathExists?: CompatPathExists;
readFile?: CompatReadFile;
argv1?: string;
cwd?: string;
mainFilename?: string;
cache?: Map<string, Promise<unknown>>;
}): Promise<T> {
const cache = params.cache ?? compatModuleCache;
Expand All @@ -167,6 +200,10 @@ export async function loadOpenClawCompatModule<T>(params: {
const openClawEntrypointPath = resolveOpenClawEntrypointPath({
resolver: params.resolver,
pathExists,
readFile: params.readFile,
argv1: params.argv1,
cwd: params.cwd,
mainFilename: params.mainFilename,
});
const fallbackPath = resolveCompatFallbackPath(
openClawEntrypointPath,
Expand Down