diff --git a/CHANGELOG.md b/CHANGELOG.md index 2943596..e2467ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Reject plugin-controlled entrypoint, source-root, and target-checkout path escapes before filesystem access; preserve explicit operator paths and fixture-suite configuration, and explain how to migrate rejected sibling checkout settings. Thanks @SebTardif. + - Initialize pnpm/Yarn workflows without querying a package manager before Corepack setup, and install Bun without requiring an npm lockfile in Bun workflows. - Capture in-process CPU, memory, event-loop, and active-resource snapshots at workload phase boundaries, preserving signed deltas and process/thread attribution separately from sampled child-process estimates. diff --git a/README.md b/README.md index cd286be..45f07b1 100644 --- a/README.md +++ b/README.md @@ -150,13 +150,21 @@ Use `plugin-inspector.config.json` for a standalone config file: }, "capture": { "mockSdk": true - }, - "openclaw": { - "defaultCheckoutPath": "../openclaw" } } ``` +Plugin-owned entrypoints, `sourceRoot`, and `openclaw.defaultCheckoutPath` must +stay inside the plugin root. Absolute paths, UNC shares, and paths that escape +the root are rejected before filesystem access. An invalid `sourceRoot` stops +inspection; an invalid checkout setting produces `target-openclaw-rejected`. +Compare against a sibling OpenClaw checkout with `--openclaw ../openclaw` or +the API's `openclawPath` option. Operator-owned fixture-suite configurations +retain their existing sibling checkout and source paths. + +These are lexical path checks. They do not follow or reject symlinks or Windows +junctions, and do not sandbox runtime capture. + Inspect the resolved config before wiring CI: ```bash diff --git a/examples/plugin-inspector.config.json b/examples/plugin-inspector.config.json index 55ba50f..99eae52 100644 --- a/examples/plugin-inspector.config.json +++ b/examples/plugin-inspector.config.json @@ -11,8 +11,5 @@ }, "capture": { "mockSdk": true - }, - "openclaw": { - "defaultCheckoutPath": "../openclaw" } } diff --git a/src/config.js b/src/config.js index bd2467e..db9ded4 100644 --- a/src/config.js +++ b/src/config.js @@ -1,10 +1,16 @@ import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import path from "node:path"; +import { resolveJailedPluginPath } from "./path-utils.js"; export const npmPackagePayloadDir = ".crabpot-package"; export const defaultPluginRootConfigFiles = ["plugin-inspector.config.json", ".plugin-inspector.json"]; export const packageJsonConfigKeys = ["pluginInspector", "plugin-inspector"]; +const pluginRootConfig = Symbol("pluginRootConfig"); + +export function isPluginRootConfig(config) { + return config[pluginRootConfig] === true; +} export async function loadInspectorConfig(configPath, options = {}) { if (!configPath) { @@ -123,7 +129,16 @@ export function fixtureCheckoutPath(config, fixture) { export function fixtureSourceRoot(config, fixture) { const checkoutPath = fixtureCheckoutPath(config, fixture); if (fixture.subdir) { - return path.join(checkoutPath, fixture.subdir); + if (!isPluginRootConfig(config)) { + return path.join(checkoutPath, fixture.subdir); + } + const jailed = resolveJailedPluginPath(checkoutPath, fixture.subdir); + if (!jailed) { + throw new Error( + `sourceRoot ${JSON.stringify(fixture.subdir)} is outside the plugin root; refuse to scan a replacement tree`, + ); + } + return jailed; } if (fixture.package) { return path.join(checkoutPath, npmPackagePayloadDir); @@ -153,6 +168,7 @@ export async function normalizePluginRootConfig(config, options = {}) { } return { + [pluginRootConfig]: true, version: 1, submoduleRoot: ".", capture: config.capture, diff --git a/src/fixture-summary.js b/src/fixture-summary.js index c1209b8..fffdcc7 100644 --- a/src/fixture-summary.js +++ b/src/fixture-summary.js @@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises"; import path from "node:path"; import { compatRecordForIssueCode } from "./contract-probes.js"; import { readJsonFile } from "./json-file.js"; +import { resolveJailedPluginPath } from "./path-utils.js"; import { satisfiesOpenClawCompatibilityRange } from "./openclaw-version.js"; const conversationAccessHooks = new Set(["agent_end", "llm_input", "llm_output"]); @@ -1088,7 +1089,15 @@ function collectOpenClawEntrypoints(packageDir, openclaw, options) { ]; return entrypoints.map((entrypoint) => { - const resolvedPath = path.resolve(packageDir, entrypoint.specifier); + const resolvedPath = resolveJailedPluginPath(packageDir, entrypoint.specifier); + if (!resolvedPath) { + return { + ...entrypoint, + relativePath: entrypoint.specifier, + exists: false, + requiresBuild: /(^|\/)dist\//.test(entrypoint.specifier) || /(^|\/)build\//.test(entrypoint.specifier), + }; + } const relativePath = path.relative(options.rootDir, resolvedPath); return { ...entrypoint, @@ -1121,7 +1130,8 @@ function hasUsablePackageRuntimeEntrypoint(entrypoint, packageSummary, entrypoin } const packageDir = path.dirname(packageSummary.path); - return existsSync(path.resolve(packageDir, runtimeBuildSpecifier)); + const resolvedRuntime = resolveJailedPluginPath(packageDir, runtimeBuildSpecifier); + return Boolean(resolvedRuntime && existsSync(resolvedRuntime)); } function isSourceEntrypoint(specifier) { diff --git a/src/inspector.js b/src/inspector.js index 97203d9..232cdbb 100644 --- a/src/inspector.js +++ b/src/inspector.js @@ -4,10 +4,11 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { createCaptureApi } from "./capture-api.js"; import { captureApiOptionsForPlugin } from "./capture-config.js"; -import { fixtureCheckoutPath, fixtureSourceRoot } from "./config.js"; +import { fixtureCheckoutPath, fixtureSourceRoot, isPluginRootConfig } from "./config.js"; import { buildCompatibilityFixtureReport } from "./fixture-summary.js"; import { readOpenClawTargetSurface } from "./openclaw-target.js"; import { prepareOpenClawTarget, resolveOpenClawTargetVersion } from "./openclaw-version.js"; +import { isWithinPluginRoot, resolveJailedPluginPath } from "./path-utils.js"; import { resolveProcessLimits, startOwnedProcess } from "./process-profile.js"; import { buildCompatibilityReport, buildReport } from "./report.js"; import { inspectSdkDeprecations } from "./sdk-deprecation-rules.js"; @@ -39,7 +40,7 @@ export async function inspectCompatibilityFixtureSet(config, options = {}) { (options.openclawVersion ? await prepareOpenClawTarget(await resolveOpenClawTargetVersion(options.openclawVersion, options), options) : await readOpenClawTargetSurface({ - configuredPath: options.openclawPath, + configuredPath: options.openclawPath ?? (isPluginRootConfig(config) ? undefined : config.openclaw?.defaultCheckoutPath), manifest: config, rootDir: config.rootDir, })); @@ -656,7 +657,10 @@ function collectEntrypoint(entrypoints, entrypointFiles, packageDir, value) { } function entrypointCandidates(packageDir, specifier) { - const resolved = path.resolve(packageDir, specifier); + const resolved = resolveJailedPluginPath(packageDir, specifier); + if (!resolved) { + return []; + } if (path.extname(resolved)) { return [resolved]; } @@ -670,7 +674,7 @@ function entrypointCandidates(packageDir, specifier) { path.join(resolved, "index.mjs"), path.join(resolved, "index.cjs"), path.join(resolved, "index.ts"), - ]; + ].filter((candidate) => isWithinPluginRoot(packageDir, candidate)); } function uniquePaths(paths) { diff --git a/src/openclaw-target.js b/src/openclaw-target.js index 54a562c..02a1a46 100644 --- a/src/openclaw-target.js +++ b/src/openclaw-target.js @@ -1,6 +1,7 @@ import { existsSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; +import { resolveJailedPluginPath } from "./path-utils.js"; export const defaultOpenClawCheckoutPaths = ["./openclaw", "../openclaw"]; @@ -12,7 +13,16 @@ export async function readOpenClawTargetSurface(options = {}) { return emptyTargetSurface({ configuredPath: null, status: "disabled" }); } - const requestedPaths = openClawTargetPathCandidates(options.manifest, configuredPath); + const requestedPaths = openClawTargetPathCandidates(options.manifest, configuredPath, { rootDir }); + const rejectedCheckoutPath = rejectedPluginCheckoutPath(options.manifest, configuredPath, { rootDir }); + if (rejectedCheckoutPath) { + return emptyTargetSurface({ + configuredPath: rejectedCheckoutPath, + searchedPaths: [rejectedCheckoutPath], + status: "rejected", + message: rejectedPluginCheckoutMessage(rejectedCheckoutPath), + }); + } if (requestedPaths.length === 0) { return emptyTargetSurface({ configuredPath: null, status: "not-configured" }); } @@ -148,11 +158,26 @@ export async function readOpenClawTargetSurface(options = {}) { }; } -export function openClawTargetPathCandidates(manifest, configuredPath) { +export function openClawTargetPathCandidates(manifest, configuredPath, options = {}) { if (typeof configuredPath === "string") { return [configuredPath]; } - return unique([manifest?.openclaw?.defaultCheckoutPath, ...defaultOpenClawCheckoutPaths].filter(Boolean)); + const pluginPath = manifest?.openclaw?.defaultCheckoutPath; + if (rejectedPluginCheckoutPath(manifest, configuredPath, options)) { + return []; + } + return unique([pluginPath, ...defaultOpenClawCheckoutPaths].filter(Boolean)); +} + +function rejectedPluginCheckoutPath(manifest, configuredPath, options = {}) { + if (typeof configuredPath === "string" || configuredPath === false) { + return null; + } + const pluginPath = manifest?.openclaw?.defaultCheckoutPath; + if (typeof pluginPath !== "string" || !options.rootDir) { + return null; + } + return resolveJailedPluginPath(options.rootDir, pluginPath) ? null : pluginPath; } function importsCompatRecords(source) { @@ -517,12 +542,17 @@ function parseStringUnion(source, typeName) { return match ? unique([...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])).sort() : []; } -function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status }) { +function rejectedPluginCheckoutMessage(configuredPath) { + return `plugin defaultCheckoutPath ${JSON.stringify(configuredPath)} is outside the plugin root; pass --openclaw / openclawPath to compare against a sibling checkout`; +} + +function emptyTargetSurface({ configuredPath, searchedPaths = undefined, status, message }) { return { configuredPath, checkoutPath: null, searchedPaths, status, + message, compatRecords: [], compatRecordStatuses: {}, compatRecordTests: {}, diff --git a/src/path-utils.js b/src/path-utils.js index 2914b9d..37b69f4 100644 --- a/src/path-utils.js +++ b/src/path-utils.js @@ -4,6 +4,41 @@ export function resolveFromRoot(rootDir, value) { return path.isAbsolute(value) ? value : path.join(rootDir, value); } +// Lexical string check only. Does not follow or reject symlinks or Windows junctions. +export function isWithinPluginRoot(rootDir, candidatePath, pathApi = path) { + const root = pathApi.resolve(rootDir); + const candidate = pathApi.resolve(candidatePath); + const relative = pathApi.relative(root, candidate); + return relative === "" || (!isParentDirectoryRelative(relative) && !pathApi.isAbsolute(relative)); +} + +export function isParentDirectoryRelative(relative) { + if (typeof relative !== "string" || relative.length === 0) { + return false; + } + return ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + relative.startsWith("../") || + relative.startsWith("..\\") + ); +} + +export function resolveJailedPluginPath(rootDir, specifier, pathApi = path) { + if (typeof specifier !== "string" || specifier.length === 0) { + return null; + } + if (path.win32.isAbsolute(specifier) || path.posix.isAbsolute(specifier) || /^[A-Za-z]:/u.test(specifier)) { + return null; + } + const root = pathApi.resolve(rootDir); + const resolved = pathApi.resolve(root, specifier); + if (!isWithinPluginRoot(root, resolved, pathApi)) { + return null; + } + return resolved; +} + export function resolveRequiredFromRoot(rootDir, value, label) { if (!value) { throw new Error(`${label} path is required`); diff --git a/src/report.js b/src/report.js index ba1e816..ca2920a 100644 --- a/src/report.js +++ b/src/report.js @@ -140,6 +140,7 @@ export async function buildCompatibilityReport(options = {}) { findings: [...warnings, ...suggestions], suggestions, logs, + warnings, decisions, }); @@ -216,7 +217,21 @@ function filterVisibleFindings(findings, targetOpenClaw, options) { return findings.filter((finding) => isAuthorFacingFinding(finding, targetOpenClaw)); } -export function classifyCompatRecordCoverage({ targetOpenClaw, findings, suggestions, logs, decisions }) { +export function classifyCompatRecordCoverage({ targetOpenClaw, findings, suggestions, logs, warnings, decisions }) { + if (targetOpenClaw.status === "rejected") { + const diagnostic = { + fixture: "openclaw", + code: "target-openclaw-rejected", + level: "warning", + message: + targetOpenClaw.message ?? + `plugin defaultCheckoutPath ${JSON.stringify(targetOpenClaw.configuredPath)} is outside the plugin root; pass --openclaw / openclawPath to compare against a sibling checkout`, + evidence: [targetOpenClaw.configuredPath ?? "not configured", "--openclaw", "openclawPath"], + }; + logs.push(diagnostic); + warnings?.push(diagnostic); + return; + } if (targetOpenClaw.status !== "ok") { logs.push({ fixture: "openclaw", diff --git a/test/openclaw-target.test.js b/test/openclaw-target.test.js index b206d2f..056fd86 100644 --- a/test/openclaw-target.test.js +++ b/test/openclaw-target.test.js @@ -362,6 +362,14 @@ test("OpenClaw target parsing helpers stay deterministic", () => { "./openclaw", "../openclaw", ]); + assert.deepEqual( + openClawTargetPathCandidates({ openclaw: { defaultCheckoutPath: "../target" } }, undefined, { + rootDir: "/tmp/plugin-root", + }), + [], + ); + assert.deepEqual(openClawTargetPathCandidates({}, "../target"), ["../target"]); + assert.deepEqual(openClawTargetPathCandidates({}, "//operator-share/openclaw"), ["//operator-share/openclaw"]); assert.deepEqual(parsePluginSdkExports({ exports: { "./plugin-sdk": "", "./plugin-sdk/tools": "", ".": "" } }), [ "openclaw/plugin-sdk", "openclaw/plugin-sdk/tools", diff --git a/test/path-jail.test.js b/test/path-jail.test.js new file mode 100644 index 0000000..c36bea1 --- /dev/null +++ b/test/path-jail.test.js @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { + fixtureSourceRoot, + inspectPlugin, + inspectCompatibilityFixtureSet, + loadInspectorConfig, + loadPluginRootConfig, + readOpenClawTargetSurface, +} from "../src/advanced.js"; +import { inspectPluginRoot } from "../src/index.js"; + +const uncSpecifier = "//evil.example/share/x.js"; +const windowsAbsoluteSpecifier = path.win32.join("C:", "Windows", "win.ini"); + +test("resolveJailedPluginPath rejects ../, Windows absolute, and UNC specifiers and keeps in-root paths", async () => { + const { resolveJailedPluginPath } = await import("../src/path-utils.js"); + assert.equal(typeof resolveJailedPluginPath, "function"); + + const root = path.win32.join("C:", "plugins", "demo"); + const naiveUnc = path.win32.resolve(root, uncSpecifier); + assert.match(naiveUnc.replaceAll("/", "\\"), /^\\\\evil\.example\\share\\x\.js$/i); + + const naiveAbsolute = path.win32.resolve(root, windowsAbsoluteSpecifier); + assert.equal(naiveAbsolute, windowsAbsoluteSpecifier); + + assert.equal(resolveJailedPluginPath(root, "../secret.js"), null); + assert.equal(resolveJailedPluginPath(root, "..\\secret.js"), null); + assert.equal(resolveJailedPluginPath(root, windowsAbsoluteSpecifier), null); + assert.equal(resolveJailedPluginPath(root, "C:/Windows/win.ini"), null); + assert.equal(resolveJailedPluginPath(root, uncSpecifier), null); + assert.equal(resolveJailedPluginPath(root, "\\\\evil.example\\share\\x.js"), null); + + const accepted = resolveJailedPluginPath(root, "src/index.js"); + assert.ok(accepted); + assert.equal(path.relative(path.resolve(root), accepted).split(path.sep).join("/"), "src/index.js"); + + const dottedName = resolveJailedPluginPath(root, "..generated/index.js"); + assert.ok(dottedName); + assert.equal( + path.relative(path.resolve(root), dottedName).split(path.sep).join("/"), + "..generated/index.js", + ); +}); + +test("Windows path checks preserve operator-selected UNC roots but reject plugin escapes", async () => { + const { isWithinPluginRoot, resolveJailedPluginPath } = await import("../src/path-utils.js"); + const root = String.raw`\\operator-share\plugins\demo`; + const child = path.win32.join(root, "src", "index.js"); + assert.equal(resolveJailedPluginPath(root, "src/index.js", path.win32), child); + assert.equal(isWithinPluginRoot(root, child, path.win32), true); + for (const specifier of ["../secret.js", String.raw`..\secret.js`, String.raw`src\..\..\secret.js`, uncSpecifier, "C:secret.js"]) { + assert.equal(resolveJailedPluginPath(root, specifier, path.win32), null); + } + assert.equal(isWithinPluginRoot(root, String.raw`\\other-share\plugins\demo\index.js`, path.win32), false); +}); + +test("inspect rejects a relative ../ entrypoint escape and still accepts src/index.js", async (t) => { + const workspace = await createWorkspace(t); + const leakedPath = path.join(workspace, "jail-escape-marker.js"); + await writeFile(leakedPath, 'export default function register(api) { api.registerHttpRoute({ path: "/leaked" }); }\n', "utf8"); + + const pluginRoot = await writePlugin(workspace, { + packageJson: { + main: "../jail-escape-marker.js", + openclaw: { + extensions: ["../jail-escape-marker.js"], + entrypoint: "src/index.js", + }, + }, + }); + + const inspection = await inspectPlugin(pluginFixture(), { config: { rootDir: pluginRoot } }); + assert.ok(inspection.sourceFiles.some((file) => normalizeRel(file).endsWith("src/index.js"))); + assert.ok(!inspection.sourceFiles.some((file) => file.includes("jail-escape-marker"))); + assert.ok(!inspection.registrations.includes("registerHttpRoute")); + assert.ok(inspection.registrations.includes("registerTool")); + + const report = await inspectPluginRoot({ pluginRoot, openclawPath: false }); + const escaped = report.fixtures[0].package.openclaw.entrypoints.filter((entrypoint) => + entrypoint.specifier.includes("jail-escape-marker"), + ); + assert.ok(escaped.length > 0); + assert.ok(escaped.every((entrypoint) => entrypoint.exists === false)); + assert.ok(escaped.every((entrypoint) => !isUncLike(entrypoint.relativePath))); +}); + +test("entrypoint extension probing cannot escape through a plugin-root specifier", async (t) => { + const workspace = await createWorkspace(t); + await writeFile(path.join(workspace, "plugin.js"), + 'export default function register(api) { api.registerHttpRoute({ path: "/leaked" }); }\n'); + const pluginRoot = await writePlugin(workspace, { packageJson: { main: "." } }); + const inspection = await inspectPlugin(pluginFixture(), { config: { rootDir: pluginRoot } }); + assert.ok(!inspection.sourceFiles.includes("../plugin.js")); + assert.ok(!inspection.registrations.includes("registerHttpRoute")); + assert.ok(inspection.registrations.includes("registerTool")); +}); + +test("inspect rejects an absolute Windows-style entrypoint even on this host", async (t) => { + const workspace = await createWorkspace(t); + const outsideDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-jail-abs-")); + t.after(() => rm(outsideDir, { recursive: true, force: true })); + const leakedPath = path.join(outsideDir, "jail-escape-marker.js"); + await writeFile(leakedPath, 'export default function register(api) { api.registerHttpRoute({ path: "/leaked" }); }\n', "utf8"); + + const absoluteSpecifier = path.win32.normalize(leakedPath); + assert.equal(path.win32.isAbsolute(absoluteSpecifier), true); + + const pluginRoot = await writePlugin(workspace, { + packageJson: { + main: absoluteSpecifier, + openclaw: { + extensions: [absoluteSpecifier], + entrypoint: "src/index.js", + }, + }, + }); + + const inspection = await inspectPlugin(pluginFixture(), { config: { rootDir: pluginRoot } }); + assert.ok(inspection.sourceFiles.some((file) => normalizeRel(file).endsWith("src/index.js"))); + assert.ok(!inspection.sourceFiles.some((file) => file.includes("jail-escape-marker"))); + assert.ok(!inspection.registrations.includes("registerHttpRoute")); + + const report = await inspectPluginRoot({ pluginRoot, openclawPath: false }); + const escaped = report.fixtures[0].package.openclaw.entrypoints.filter((entrypoint) => + path.win32.isAbsolute(entrypoint.specifier) || entrypoint.specifier.includes("jail-escape-marker"), + ); + assert.ok(escaped.length > 0); + assert.ok(escaped.every((entrypoint) => entrypoint.exists === false)); +}); + +test("inspect does not resolve a UNC entrypoint to a host share", async (t) => { + const { resolveJailedPluginPath } = await import("../src/path-utils.js"); + const workspace = await createWorkspace(t); + const pluginRoot = await writePlugin(workspace, { + packageJson: { + openclaw: { + extensions: [uncSpecifier], + entrypoint: "src/index.js", + }, + }, + }); + + const naive = path.win32.resolve(pluginRoot, uncSpecifier); + assert.match(naive.replaceAll("/", "\\"), /^\\\\evil\.example\\share\\x\.js$/i); + assert.equal(resolveJailedPluginPath(pluginRoot, uncSpecifier), null); + + const report = await inspectPluginRoot({ pluginRoot, openclawPath: false }); + const uncEntrypoints = report.fixtures[0].package.openclaw.entrypoints.filter((entrypoint) => + entrypoint.specifier.includes("evil.example"), + ); + assert.ok(uncEntrypoints.length > 0); + assert.ok(uncEntrypoints.every((entrypoint) => entrypoint.exists === false)); + assert.ok(uncEntrypoints.every((entrypoint) => entrypoint.relativePath !== naive)); +}); + +test("config sourceRoot cannot escape the plugin root via ../ or a Windows absolute path", async (t) => { + const workspace = await createWorkspace(t); + const leakedDir = path.join(workspace, "leaked-src"); + await mkdir(leakedDir, { recursive: true }); + await writeFile( + path.join(leakedDir, "jail-escape-marker.js"), + 'export default function register(api) { api.registerHttpRoute({ path: "/leaked" }); }\n', + "utf8", + ); + + const pluginRoot = await writePlugin(workspace, { + config: { + version: 1, + plugin: { + id: "weather", + priority: "high", + seams: ["plugin"], + sourceRoot: "../leaked-src", + }, + }, + }); + + const config = await loadPluginRootConfig(null, { cwd: pluginRoot }); + assert.throws( + () => fixtureSourceRoot(config, config.fixtures[0]), + /sourceRoot .* is outside the plugin root/, + ); + await assert.rejects( + () => inspectPlugin(config.fixtures[0], { config }), + /sourceRoot .* is outside the plugin root/, + ); + + const absolutePlugin = await writePlugin(workspace, { + dirName: "abs-source-plugin", + config: { + version: 1, + plugin: { + id: "weather", + priority: "high", + seams: ["plugin"], + sourceRoot: path.win32.normalize(leakedDir), + }, + }, + }); + const absoluteConfig = await loadPluginRootConfig(null, { cwd: absolutePlugin }); + assert.throws( + () => fixtureSourceRoot(absoluteConfig, absoluteConfig.fixtures[0]), + /sourceRoot .* is outside the plugin root/, + ); +}); + +test("defaultCheckoutPath cannot escape the plugin root via ../ or a Windows absolute path", async (t) => { + const workspace = await createWorkspace(t); + const checkout = path.join(workspace, "fake-openclaw"); + await writeFakeOpenClawCheckout(checkout); + + const relativePlugin = await writePlugin(workspace, { + dirName: "relative-checkout-plugin", + config: { + version: 1, + openclaw: { defaultCheckoutPath: "../fake-openclaw" }, + }, + }); + const relativeTarget = await readOpenClawTargetSurface({ + rootDir: relativePlugin, + manifest: { openclaw: { defaultCheckoutPath: "../fake-openclaw" } }, + }); + assert.equal(relativeTarget.status, "rejected"); + assert.deepEqual(relativeTarget.searchedPaths, ["../fake-openclaw"]); + assert.equal(relativeTarget.configuredPath, "../fake-openclaw"); + assert.match(relativeTarget.message, /--openclaw \/ openclawPath/); + + const operatorTarget = await readOpenClawTargetSurface({ + rootDir: relativePlugin, + configuredPath: "../fake-openclaw", + }); + assert.equal(operatorTarget.status, "ok"); + assert.equal(operatorTarget.configuredPath, "../fake-openclaw"); + + const pluginRootReport = await inspectPluginRoot({ + pluginRoot: relativePlugin, + generatedAt: "2026-09-18T00:00:00.000Z", + }); + assert.equal(pluginRootReport.targetOpenClaw.status, "rejected"); + assert.equal(pluginRootReport.targetOpenClaw.configuredPath, "../fake-openclaw"); + assert.match(pluginRootReport.targetOpenClaw.message, /--openclaw \/ openclawPath/); + assert.ok(pluginRootReport.warnings.some((warning) => warning.code === "target-openclaw-rejected")); + + const operatorRootReport = await inspectPluginRoot({ + pluginRoot: relativePlugin, + openclawPath: "../fake-openclaw", + generatedAt: "2026-09-18T00:00:00.000Z", + }); + assert.equal(operatorRootReport.targetOpenClaw.status, "ok"); + assert.equal(operatorRootReport.targetOpenClaw.configuredPath, "../fake-openclaw"); + + const absolutePlugin = await writePlugin(workspace, { + dirName: "absolute-checkout-plugin", + config: { + version: 1, + openclaw: { defaultCheckoutPath: path.win32.normalize(checkout) }, + }, + }); + const absoluteTarget = await readOpenClawTargetSurface({ + rootDir: absolutePlugin, + manifest: { openclaw: { defaultCheckoutPath: path.win32.normalize(checkout) } }, + }); + assert.equal(absoluteTarget.status, "rejected"); + assert.ok( + (absoluteTarget.searchedPaths ?? []).some( + (candidate) => path.win32.normalize(candidate) === path.win32.normalize(checkout), + ), + ); +}); + +test("operator fixture-suite config preserves sibling checkout and source paths", async (t) => { + const workspace = await createWorkspace(t); + const pluginRoot = await writePlugin(workspace); + await writeFakeOpenClawCheckout(path.join(workspace, "fake-openclaw")); + const configPath = path.join(pluginRoot, "suite.json"); + await writeFile(configPath, JSON.stringify({ + version: 1, + submoduleRoot: ".", + openclaw: { defaultCheckoutPath: "../fake-openclaw" }, + fixtures: [{ ...pluginFixture(), subdir: "../plugin/src" }], + })); + + const config = await loadInspectorConfig(configPath); + assert.equal(fixtureSourceRoot(config, config.fixtures[0]), path.join(pluginRoot, "src")); + const report = await inspectCompatibilityFixtureSet(config); + assert.equal(report.targetOpenClaw.status, "ok"); + assert.equal(report.targetOpenClaw.configuredPath, "../fake-openclaw"); + assert.ok(!report.warnings.some((warning) => warning.code === "target-openclaw-rejected")); +}); + +function pluginFixture() { + return { + id: "weather", + name: "Weather", + path: ".", + repo: "local", + priority: "high", + seams: ["plugin"], + }; +} + +async function createWorkspace(t) { + const workspace = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-path-jail-")); + t.after(() => rm(workspace, { recursive: true, force: true })); + return workspace; +} + +async function writePlugin(workspace, options = {}) { + const pluginRoot = path.join(workspace, options.dirName ?? "plugin"); + await mkdir(path.join(pluginRoot, "src"), { recursive: true }); + const packageJson = { + name: "@example/openclaw-weather", + version: "1.0.0", + type: "module", + openclaw: { + extensions: ["src/index.js"], + compat: { pluginApi: "^1.0.0" }, + }, + ...(options.packageJson ?? {}), + }; + await writeFile(path.join(pluginRoot, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`, "utf8"); + await writeFile( + path.join(pluginRoot, "openclaw.plugin.json"), + `${JSON.stringify({ id: "weather", name: "Weather", version: "1.0.0", contracts: { tools: {} } }, null, 2)}\n`, + "utf8", + ); + await writeFile( + path.join(pluginRoot, "src", "index.js"), + 'import { definePluginEntry } from "openclaw/plugin-sdk";\nexport default definePluginEntry((api) => api.registerTool({ name: "weather" }));\n', + "utf8", + ); + if (options.config) { + await writeFile( + path.join(pluginRoot, "plugin-inspector.config.json"), + `${JSON.stringify(options.config, null, 2)}\n`, + "utf8", + ); + } + return pluginRoot; +} + +async function writeFakeOpenClawCheckout(checkout) { + await mkdir(path.join(checkout, "src/plugins/compat"), { recursive: true }); + await writeFile(path.join(checkout, "src/plugins/compat/registry.ts"), "export const records = [];\n", "utf8"); + await writeFile(path.join(checkout, "package.json"), `${JSON.stringify({ name: "openclaw", version: "0.0.0" }, null, 2)}\n`, "utf8"); +} + +function normalizeRel(value) { + return String(value).split(path.sep).join("/"); +} + +function isUncLike(value) { + const normalized = String(value).replaceAll("/", "\\"); + return /^\\\\[^\\]+/u.test(normalized); +} diff --git a/test/report.test.js b/test/report.test.js index 8bd80ac..21de2bf 100644 --- a/test/report.test.js +++ b/test/report.test.js @@ -674,6 +674,28 @@ test("compat record coverage logs unavailable targets", () => { }); }); +test("compat record coverage warns when plugin checkout config is rejected", () => { + const logs = []; + const warnings = []; + classifyCompatRecordCoverage({ + targetOpenClaw: { + status: "rejected", + configuredPath: "../openclaw", + message: + 'plugin defaultCheckoutPath "../openclaw" is outside the plugin root; pass --openclaw / openclawPath to compare against a sibling checkout', + }, + findings: [{ fixture: "fixture", compatRecord: "legacy-root-sdk-import" }], + suggestions: [], + logs, + warnings, + decisions: [], + }); + + assert.equal(logs[0].code, "target-openclaw-rejected"); + assert.match(logs[0].message, /--openclaw \/ openclawPath/); + assert.equal(warnings[0].code, "target-openclaw-rejected"); +}); + test("compatibility fixture summary reads manifests and OpenClaw package metadata", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "plugin-inspector-fixture-summary-")); const fixtureDir = path.join(rootDir, "plugin");