Files
openclaw/src/plugins/bundled-dir.ts
Peter Steinberger 538605ff44 [codex] Extract filesystem safety primitives (#77918)
* refactor: extract filesystem safety primitives

* refactor: use fs-safe for file access helpers

* refactor: reuse fs-safe for media reads

* refactor: use fs-safe for image reads

* refactor: reuse fs-safe in qqbot media opener

* refactor: reuse fs-safe for local media checks

* refactor: consume cleaner fs-safe api

* refactor: align fs-safe json option names

* fix: preserve fs-safe migration contracts

* refactor: use fs-safe primitive subpaths

* refactor: use grouped fs-safe subpaths

* refactor: align fs-safe api usage

* refactor: adapt private state store api

* chore: refresh proof gate

* refactor: follow fs-safe json api split

* refactor: follow reduced fs-safe surface

* build: default fs-safe python helper off

* fix: preserve fs-safe plugin sdk aliases

* refactor: consolidate fs-safe usage

* refactor: unify fs-safe store usage

* refactor: trim fs-safe temp workspace usage

* refactor: hide low-level fs-safe primitives

* build: use published fs-safe package

* fix: preserve outbound recovery durability after rebase

* chore: refresh pr checks
2026-05-06 02:15:17 +01:00

319 lines
11 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
import { isPathInside } from "../infra/path-guards.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { resolveUserPath } from "../utils.js";
const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bundled-plugins");
const TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV = "OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR";
let bundledPluginsDirOverrideForTest: string | undefined;
const bundledPluginsDirCache = new Map<string, string | undefined>();
export type SourceCheckoutDependencyDiagnostic = {
source: string;
message: string;
};
export function areBundledPluginsDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = normalizeOptionalLowercaseString(env.OPENCLAW_DISABLE_BUNDLED_PLUGINS);
return raw === "1" || raw === "true";
}
function resolveDisabledBundledPluginsDir(): string {
fs.mkdirSync(DISABLED_BUNDLED_PLUGINS_DIR, { recursive: true });
return DISABLED_BUNDLED_PLUGINS_DIR;
}
function isSourceCheckoutRoot(packageRoot: string): boolean {
return (
fs.existsSync(path.join(packageRoot, ".git")) &&
fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) &&
fs.existsSync(path.join(packageRoot, "src")) &&
fs.existsSync(path.join(packageRoot, "extensions"))
);
}
function isTruthyEnvValue(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase();
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
}
function shouldTrustTestBundledPluginsDirOverride(env: NodeJS.ProcessEnv): boolean {
const isVitestProcess = Boolean(env.VITEST) || Boolean(process.env.VITEST);
return (
isVitestProcess &&
(isTruthyEnvValue(env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV]) ||
isTruthyEnvValue(process.env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV]))
);
}
function hasUsableBundledPluginTree(pluginsDir: string): boolean {
if (!fs.existsSync(pluginsDir)) {
return false;
}
try {
return fs.readdirSync(pluginsDir, { withFileTypes: true }).some((entry) => {
if (!entry.isDirectory()) {
return false;
}
const pluginDir = path.join(pluginsDir, entry.name);
return (
fs.existsSync(path.join(pluginDir, "package.json")) ||
fs.existsSync(path.join(pluginDir, "openclaw.plugin.json"))
);
});
} catch {
return false;
}
}
function safeRealpathSync(targetPath: string): string | null {
try {
return fs.realpathSync.native(targetPath);
} catch {
return null;
}
}
function pathContains(parentDir: string, childPath: string): boolean {
return isPathInside(parentDir, childPath);
}
function trustedBundledPluginRootsForPackageRoot(packageRoot: string): string[] {
const roots = [
path.join(packageRoot, "dist", "extensions"),
path.join(packageRoot, "dist-runtime", "extensions"),
];
if (isSourceCheckoutRoot(packageRoot)) {
roots.push(path.join(packageRoot, "extensions"));
}
return roots;
}
function resolvePackageRootsForBundledPlugins(): string[] {
const argvRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] });
const moduleRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
return [argvRoot, moduleRoot].filter(
(entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index,
);
}
export function resolveSourceCheckoutDependencyDiagnostic(
env: NodeJS.ProcessEnv = process.env,
): SourceCheckoutDependencyDiagnostic | null {
if (areBundledPluginsDisabled(env)) {
return null;
}
for (const packageRoot of resolvePackageRootsForBundledPlugins()) {
if (!isSourceCheckoutRoot(packageRoot)) {
continue;
}
const extensionsDir = path.join(packageRoot, "extensions");
if (!hasUsableBundledPluginTree(extensionsDir)) {
continue;
}
if (fs.existsSync(path.join(packageRoot, "node_modules", ".pnpm"))) {
continue;
}
return {
source: packageRoot,
message:
"OpenClaw source checkout detected without pnpm workspace dependencies; run `pnpm install` from the repo root so bundled plugins can load package-local dependencies.",
};
}
return null;
}
function resolveTrustedExistingOverride(resolvedOverride: string): string | null {
const realOverride = safeRealpathSync(resolvedOverride);
if (!realOverride) {
return null;
}
const modulePackageRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
const packageRoots = modulePackageRoot ? [modulePackageRoot] : [];
const trustedRoots = packageRoots
.flatMap((packageRoot) => trustedBundledPluginRootsForPackageRoot(packageRoot))
.map((trustedRoot) => safeRealpathSync(trustedRoot))
.filter((entry): entry is string => Boolean(entry));
if (!trustedRoots.some((trustedRoot) => pathContains(trustedRoot, realOverride))) {
return null;
}
if (!hasUsableBundledPluginTree(realOverride)) {
return null;
}
return realOverride;
}
function overrideResolvesUnderPackageBundledRoot(params: {
resolvedOverride: string;
packageRoot: string;
}): boolean {
const realOverride = safeRealpathSync(params.resolvedOverride);
if (!realOverride) {
return false;
}
return trustedBundledPluginRootsForPackageRoot(params.packageRoot)
.map((trustedRoot) => safeRealpathSync(trustedRoot))
.filter((entry): entry is string => Boolean(entry))
.some((trustedRoot) => pathContains(trustedRoot, realOverride));
}
function resolveBundledDirFromPackageRoot(packageRoot: string): string | undefined {
const sourceExtensionsDir = path.join(packageRoot, "extensions");
const builtExtensionsDir = path.join(packageRoot, "dist", "extensions");
const sourceCheckout = isSourceCheckoutRoot(packageRoot);
const hasUsableSourceTree = sourceCheckout && hasUsableBundledPluginTree(sourceExtensionsDir);
// In pnpm source checkouts, prefer the built bundled plugin runtime when it
// exists so dist gateway runs avoid loading TS plugin entrypoints through jiti.
// Keep the source tree as the fallback for fresh checkouts before build.
const runtimeExtensionsDir = path.join(packageRoot, "dist-runtime", "extensions");
const hasUsableRuntimeTree = sourceCheckout
? hasUsableBundledPluginTree(runtimeExtensionsDir)
: fs.existsSync(runtimeExtensionsDir);
const hasUsableBuiltTree = sourceCheckout
? hasUsableBundledPluginTree(builtExtensionsDir)
: fs.existsSync(builtExtensionsDir);
if (sourceCheckout && hasUsableBuiltTree) {
return builtExtensionsDir;
}
if (sourceCheckout && hasUsableRuntimeTree) {
return runtimeExtensionsDir;
}
if (hasUsableRuntimeTree && hasUsableBuiltTree) {
return runtimeExtensionsDir;
}
if (hasUsableBuiltTree) {
return builtExtensionsDir;
}
if (hasUsableSourceTree) {
return sourceExtensionsDir;
}
return undefined;
}
function createBundledPluginsDirCacheKey(env: NodeJS.ProcessEnv): string {
return JSON.stringify({
disabled: env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ?? "",
override: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "",
trustOverride: env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV] ?? "",
processTrustOverride: process.env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV] ?? "",
vitest: env.VITEST ?? "",
processVitest: process.env.VITEST ?? "",
nodeEnv: process.env.NODE_ENV ?? "",
argv1: process.argv[1] ?? "",
execPath: process.execPath,
openClawHome: env.OPENCLAW_HOME ?? "",
home: env.HOME ?? "",
userProfile: env.USERPROFILE ?? "",
testOverride: bundledPluginsDirOverrideForTest ?? "",
});
}
function resolveBundledPluginsDirUncached(env: NodeJS.ProcessEnv): string | undefined {
if (areBundledPluginsDisabled(env)) {
return resolveDisabledBundledPluginsDir();
}
if (bundledPluginsDirOverrideForTest) {
return bundledPluginsDirOverrideForTest;
}
const override = env.OPENCLAW_BUNDLED_PLUGINS_DIR?.trim();
let rejectedExistingOverride: string | null = null;
if (override) {
const resolvedOverride = resolveUserPath(override, env);
if (fs.existsSync(resolvedOverride)) {
if (shouldTrustTestBundledPluginsDirOverride(env)) {
return path.resolve(resolvedOverride);
}
const trustedOverride = resolveTrustedExistingOverride(resolvedOverride);
if (trustedOverride) {
return trustedOverride;
}
rejectedExistingOverride = resolvedOverride;
}
}
try {
const argvRoot = resolveOpenClawPackageRootSync({ argv1: process.argv[1] });
const rejectedOverrideUsesArgvRoot = Boolean(
argvRoot &&
rejectedExistingOverride &&
overrideResolvesUnderPackageBundledRoot({
resolvedOverride: rejectedExistingOverride,
packageRoot: argvRoot,
}),
);
const safeArgvRoot = rejectedOverrideUsesArgvRoot ? null : argvRoot;
const moduleRoot = resolveOpenClawPackageRootSync({ moduleUrl: import.meta.url });
const packageRoots = [safeArgvRoot, moduleRoot].filter(
(entry, index, all): entry is string => Boolean(entry) && all.indexOf(entry) === index,
);
for (const packageRoot of packageRoots) {
const bundledDir = resolveBundledDirFromPackageRoot(packageRoot);
if (bundledDir) {
return bundledDir;
}
}
} catch {
// ignore
}
// bun --compile: ship a sibling bundled plugin tree next to the executable.
try {
const execDir = path.dirname(process.execPath);
const siblingBuilt = path.join(execDir, "dist", "extensions");
if (fs.existsSync(siblingBuilt)) {
return siblingBuilt;
}
const sibling = path.join(execDir, "extensions");
if (fs.existsSync(sibling)) {
return sibling;
}
} catch {
// ignore
}
// npm/dev: walk up from this module to find the bundled plugin tree at the package root.
try {
let cursor = path.dirname(fileURLToPath(import.meta.url));
for (let i = 0; i < 6; i += 1) {
const candidate = path.join(cursor, "extensions");
if (fs.existsSync(candidate)) {
return candidate;
}
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
cursor = parent;
}
} catch {
// ignore
}
return undefined;
}
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
const cacheKey = createBundledPluginsDirCacheKey(env);
if (bundledPluginsDirCache.has(cacheKey)) {
return bundledPluginsDirCache.get(cacheKey);
}
const resolved = resolveBundledPluginsDirUncached(env);
bundledPluginsDirCache.set(cacheKey, resolved);
return resolved;
}
export function setBundledPluginsDirOverrideForTest(dir: string | undefined): void {
if (process.env.VITEST !== "true" && process.env.NODE_ENV !== "test") {
throw new Error("setBundledPluginsDirOverrideForTest is only available in tests");
}
bundledPluginsDirOverrideForTest = dir;
bundledPluginsDirCache.clear();
}