fix(plugins): support linked source checkouts on Windows

This commit is contained in:
Vincent Koc
2026-05-25 03:14:58 +02:00
parent 42bdc949f2
commit 793e300cc5
10 changed files with 252 additions and 26 deletions
+2
View File
@@ -18,6 +18,8 @@ Docs: https://docs.openclaw.ai
- Installer: let the local-prefix CLI installer use Alpine's `apk` Node.js, npm, and Git packages on musl Linux instead of downloading glibc Node tarballs that fail `node:sqlite`.
- Scripts: use `git grep` to prefilter tracked conflict-marker scans so changed checks avoid reading every repository file on clean runs.
- Plugins: allow linked local plugin paths to probe TypeScript source entries without requiring compiled package output, restoring source-checkout plugin development on native Windows.
- CLI: route source-checkout build output to stderr before launching OpenClaw commands so stale local builds do not corrupt `--json` stdout.
- Installer: install Node.js through `apk` on Alpine Linux instead of falling through to the NodeSource package-manager path.
- Agents/perf: cache manifest-backed CLI provider descriptors and fallback provider resolution so model fallback retries avoid repeated bundled provider runtime scans while still invalidating across plugin reloads.
- Installer: detect musl Linux shells such as Alpine as Linux instead of rejecting them before npm install.
+31 -7
View File
@@ -647,6 +647,28 @@ const createRunNodeOutputTee = (deps) => {
if (!outputLogPath) {
return null;
}
try {
const existing = deps.fs.statSync(outputLogPath);
if (existing.isDirectory()) {
return {
outputLogPath,
write() {},
async close() {
throw new Error(`output log path is a directory: ${outputLogPath}`);
},
};
}
} catch (error) {
if (error?.code && error.code !== "ENOENT") {
return {
outputLogPath,
write() {},
async close() {
throw error;
},
};
}
}
deps.fs.mkdirSync(path.dirname(outputLogPath), { recursive: true });
const stream = deps.fs.createWriteStream(outputLogPath, {
flags: "a",
@@ -936,8 +958,9 @@ const runOpenClaw = async (deps) => {
return res.exitCode ?? 1;
};
const pipeSpawnedOutput = (childProcess, deps) => {
if (!shouldPipeSpawnedOutput(deps)) {
const pipeSpawnedOutput = (childProcess, deps, options = {}) => {
const stdoutTarget = options.stdoutTarget ?? "stdout";
if (!shouldPipeSpawnedOutput(deps) && stdoutTarget !== "stderr") {
return;
}
const stderrFilter =
@@ -945,7 +968,8 @@ const pipeSpawnedOutput = (childProcess, deps) => {
? createSyncIoTraceStderrFilter(deps)
: null;
childProcess.stdout?.on("data", (chunk) => {
writeRunnerStream(deps, deps.stdout, chunk);
const target = stdoutTarget === "stderr" ? deps.stderr : deps.stdout;
writeRunnerStream(deps, target, chunk);
deps.outputTee?.write(chunk);
});
childProcess.stderr?.on("data", (chunk) => {
@@ -1389,9 +1413,9 @@ export async function runNodeMain(params = {}) {
const assetBuild = deps.spawn(buildCmd, bundledPluginAssetBuildArgs, {
cwd: deps.cwd,
env: deps.env,
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
stdio: ["inherit", "pipe", "pipe"],
});
pipeSpawnedOutput(assetBuild, deps);
pipeSpawnedOutput(assetBuild, deps, { stdoutTarget: "stderr" });
const assetBuildRes = await waitForSpawnedProcess(assetBuild, deps);
const assetBuildInterruptedExitCode = getInterruptedSpawnExitCode(assetBuildRes);
if (assetBuildInterruptedExitCode !== null) {
@@ -1407,9 +1431,9 @@ export async function runNodeMain(params = {}) {
...deps.env,
[RUN_NODE_SKIP_DTS_BUILD_ENV]: deps.env[RUN_NODE_SKIP_DTS_BUILD_ENV] ?? "1",
},
stdio: shouldPipeSpawnedOutput(deps) ? ["inherit", "pipe", "pipe"] : "inherit",
stdio: ["inherit", "pipe", "pipe"],
});
pipeSpawnedOutput(build, deps);
pipeSpawnedOutput(build, deps, { stdoutTarget: "stderr" });
const buildRes = await waitForSpawnedProcess(build, deps);
const interruptedExitCode = getInterruptedSpawnExitCode(buildRes);
+3
View File
@@ -302,6 +302,7 @@ type MockWithCalls = {
};
type PluginInstallCall = {
allowSourceTypeScriptEntries?: boolean;
archivePath?: string;
dangerouslyForceUnsafeInstall?: boolean;
dryRun?: boolean;
@@ -1321,6 +1322,7 @@ describe("plugins cli install", () => {
expect(pathInstallCall().path).toBe(tmpRoot);
expect(pathInstallCall().dryRun).toBe(true);
expect(pathInstallCall().allowSourceTypeScriptEntries).toBe(true);
expect(pathInstallCall().dangerouslyForceUnsafeInstall).toBe(true);
});
@@ -1536,6 +1538,7 @@ describe("plugins cli install", () => {
expect(pathInstallCall().path).toBe(localPluginDir);
expect(pathInstallCall().dryRun).toBe(true);
expect(pathInstallCall().allowSourceTypeScriptEntries).toBe(true);
expect(pathInstallCall().dangerouslyForceUnsafeInstall).toBe(true);
expect(typeof pathInstallCall().logger?.info).toBe("function");
expect(typeof pathInstallCall().logger?.warn).toBe("function");
+1
View File
@@ -693,6 +693,7 @@ export async function runPluginInstallCommand(params: {
mode: installMode,
path: resolved,
dryRun: true,
allowSourceTypeScriptEntries: true,
extensionsDir,
logger: createPluginInstallLogger(runtime),
});
+62
View File
@@ -559,6 +559,68 @@ describe("run-node script", () => {
});
});
it("routes local build stdout to stderr before JSON command output", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await writeRuntimePostBuildScaffold(tmp);
const outputPath = path.join(tmp, ".artifacts", "run-node", "output.log");
const spawn = (_cmd: string, args: string[]) => {
if (args[0] === "scripts/bundled-plugin-assets.mjs") {
return createPipedExitedProcess({
stdout: "asset stdout\n",
stderr: "asset stderr\n",
});
}
if (args[0] === "scripts/tsdown-build.mjs") {
return createPipedExitedProcess({
stdout: "build stdout\n",
stderr: "build stderr\n",
});
}
return createPipedExitedProcess({ stdout: '{"plugins":[]}\n' });
};
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const stdout = {
write: (chunk: string | Buffer) => {
stdoutChunks.push(String(chunk));
return true;
},
} as unknown as NodeJS.WriteStream;
const stderr = {
write: (chunk: string | Buffer) => {
stderrChunks.push(String(chunk));
return true;
},
} as unknown as NodeJS.WriteStream;
const exitCode = await runNodeMain({
cwd: tmp,
args: ["plugins", "list", "--json"],
env: {
...process.env,
OPENCLAW_FORCE_BUILD: "1",
OPENCLAW_RUNNER_LOG: "0",
OPENCLAW_RUN_NODE_OUTPUT_LOG: outputPath,
},
spawn,
stdout,
stderr,
execPath: process.execPath,
platform: process.platform,
} as Parameters<typeof runNodeMain>[0] & {
stdout: NodeJS.WriteStream;
stderr: NodeJS.WriteStream;
});
expect(exitCode).toBe(0);
expect(stdoutChunks.join("")).toBe('{"plugins":[]}\n');
expect(stderrChunks.join("")).toContain("asset stdout\n");
expect(stderrChunks.join("")).toContain("asset stderr\n");
expect(stderrChunks.join("")).toContain("build stdout\n");
expect(stderrChunks.join("")).toContain("build stderr\n");
});
});
it("routes sync I/O trace stderr blocks to the output log without flooding stderr", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp);
+39
View File
@@ -879,6 +879,45 @@ describe("discoverOpenClawPlugins", () => {
});
});
it("allows linked local install records to point at TypeScript source entries", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "linked-source-pack");
mkdirSafe(path.join(pluginDir, "src"));
writePluginPackageManifest({
packageDir: pluginDir,
packageName: "@openclaw/linked-source-pack",
extensions: ["./src/index.ts"],
setupEntry: "./src/setup-entry.ts",
});
writePluginManifest({ pluginDir, id: "linked-source-pack" });
writePluginEntry(path.join(pluginDir, "src", "index.ts"));
writePluginEntry(path.join(pluginDir, "src", "setup-entry.ts"));
const installRecords = {
"linked-source-pack": {
source: "path",
installPath: pluginDir,
sourcePath: pluginDir,
},
} satisfies Record<string, PluginInstallRecord>;
const result = await discoverWithStateDir(stateDir, { installRecords });
expectCandidateSource(
result.candidates,
"linked-source-pack",
fs.realpathSync(path.join(pluginDir, "src", "index.ts")),
);
expectCandidateFields(requireCandidateById(result.candidates, "linked-source-pack"), {
setupSource: fs.realpathSync(path.join(pluginDir, "src", "setup-entry.ts")),
});
expectNoDiagnostic({
diagnostics: result.diagnostics,
pluginId: "linked-source-pack",
messageIncludes: "requires compiled runtime output",
});
});
it("still requires compiled runtime output for tracked installed package plugins", async () => {
const stateDir = makeTempDir();
const pluginDir = path.join(stateDir, "extensions", "source-only-pack");
+46 -7
View File
@@ -389,11 +389,40 @@ function mergeDiscoveryResult(
}
}
type InstalledPluginRecordPath = {
path: string;
requireBuiltRuntimeEntry: boolean;
};
function isLinkedLocalPluginRecord(params: {
record: PluginInstallRecord;
env: NodeJS.ProcessEnv;
realpathCache: Map<string, string>;
}): boolean {
if (params.record.source !== "path") {
return false;
}
if (
typeof params.record.sourcePath !== "string" ||
!params.record.sourcePath.trim() ||
typeof params.record.installPath !== "string" ||
!params.record.installPath.trim()
) {
return false;
}
return resolvesToSameDirectory(
resolveUserPath(params.record.sourcePath, params.env),
resolveUserPath(params.record.installPath, params.env),
params.realpathCache,
);
}
function collectInstalledPluginRecordPaths(
installRecords: Record<string, PluginInstallRecord> | undefined,
env: NodeJS.ProcessEnv,
): string[] {
const paths: string[] = [];
realpathCache: Map<string, string>,
): InstalledPluginRecordPath[] {
const paths: InstalledPluginRecordPath[] = [];
const seen = new Set<string>();
for (const record of Object.values(installRecords ?? {})) {
const rawPath =
@@ -410,7 +439,10 @@ function collectInstalledPluginRecordPaths(
continue;
}
seen.add(resolved);
paths.push(resolved);
paths.push({
path: resolved,
requireBuiltRuntimeEntry: !isLinkedLocalPluginRecord({ record, env, realpathCache }),
});
}
return paths;
}
@@ -1380,19 +1412,26 @@ export function discoverOpenClawPlugins(params: {
skipDirectories: readChildDirectoryNames(roots.stock),
});
}
const installedPaths = collectInstalledPluginRecordPaths(params.installRecords, env);
const installedPluginDirKeys = collectManagedPluginDirKeys(installedPaths, realpathCache);
const installedPaths = collectInstalledPluginRecordPaths(
params.installRecords,
env,
realpathCache,
);
const installedPluginDirKeys = collectManagedPluginDirKeys(
installedPaths.map((installedPath) => installedPath.path),
realpathCache,
);
const managedPluginDirs = collectManagedPluginDirKeys(
collectManagedPluginRecordPaths(params.installRecords, env),
realpathCache,
);
for (const installedPath of installedPaths) {
discoverFromPath({
rawPath: installedPath,
rawPath: installedPath.path,
origin: "global",
ownershipUid: params.ownershipUid,
workspaceDir,
requireBuiltRuntimeEntry: true,
requireBuiltRuntimeEntry: installedPath.requireBuiltRuntimeEntry,
managedPluginDirs,
env,
candidates: result.candidates,
+45 -12
View File
@@ -293,6 +293,10 @@ function expectWarningExcludes(warnings: readonly string[], fragment: string) {
expect(warnings.join("\n")).not.toContain(fragment);
}
function expectMessageIncludesPath(message: string, fragment: string) {
expect(message.replaceAll("\\", "/")).toContain(fragment);
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be an object`);
@@ -1187,6 +1191,33 @@ describe("installPluginFromArchive", () => {
}
});
it("allows linked source probes when TypeScript extension entries have no compiled runtime output", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "src"), { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify({
name: "source-link-runtime-plugin",
version: "1.0.0",
openclaw: { extensions: ["./src/index.ts"] },
}),
);
fs.writeFileSync(path.join(pluginDir, "src", "index.ts"), "export {};\n");
const result = await installPluginFromDir({
dirPath: pluginDir,
extensionsDir,
dryRun: true,
allowSourceTypeScriptEntries: true,
});
if (!result.ok) {
throw new Error(result.error);
}
expect(result.pluginId).toBe("source-link-runtime-plugin");
expect(result.targetDir).toBe(resolvePluginInstallDir(result.pluginId, extensionsDir));
});
it("rejects package installs when runtimeExtensions length does not match extensions", async () => {
const { pluginDir, extensionsDir } = setupPluginInstallDirs();
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
@@ -1450,7 +1481,7 @@ describe("installPluginFromArchive", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain("dist/payload.js");
expectMessageIncludesPath(result.error, "dist/payload.js");
}
expectWarningIncludes(warnings, "dangerous code pattern");
});
@@ -1601,7 +1632,7 @@ describe("installPluginFromArchive", () => {
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies');
expect(result.error).toContain("declared in axios (vendor/axios/package.json)");
expectMessageIncludesPath(result.error, "declared in axios (vendor/axios/package.json)");
}
});
@@ -1628,7 +1659,7 @@ describe("installPluginFromArchive", () => {
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/node_modules/plain-crypto-js");
expectMessageIncludesPath(result.error, "vendor/node_modules/plain-crypto-js");
}
});
@@ -1655,7 +1686,7 @@ describe("installPluginFromArchive", () => {
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js.Js");
expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js.Js");
}
});
@@ -1682,7 +1713,7 @@ describe("installPluginFromArchive", () => {
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js");
expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js");
}
});
@@ -2085,7 +2116,8 @@ describe("installPluginFromArchive", () => {
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name');
expect(result.error).toContain(
expectMessageIncludesPath(
result.error,
"vendor/pkg-127/node_modules/nested-safe/node_modules/plain-crypto-js/package.json",
);
}
@@ -2410,7 +2442,7 @@ describe("installPluginFromArchive", () => {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('Bundle "blocked-dependency-bundle" installation blocked');
expect(result.error).toContain('blocked dependencies "plain-crypto-js" in dependencies');
expect(result.error).toContain("declared in axios (vendor/axios/package.json)");
expectMessageIncludesPath(result.error, "declared in axios (vendor/axios/package.json)");
}
expect(
warnings.some((warning) =>
@@ -2442,7 +2474,8 @@ describe("installPluginFromArchive", () => {
'Bundle "blocked-vendored-package-name-bundle" installation blocked',
);
expect(result.error).toContain('"plain-crypto-js" as package name');
expect(result.error).toContain(
expectMessageIncludesPath(
result.error,
"declared in plain-crypto-js (vendor/plain-crypto-js/package.json)",
);
}
@@ -2464,7 +2497,7 @@ describe("installPluginFromArchive", () => {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('Bundle "blocked-package-dir-bundle" installation blocked');
expect(result.error).toContain('blocked dependency directory "plain-crypto-js"');
expect(result.error).toContain("vendor/node_modules/plain-crypto-js");
expectMessageIncludesPath(result.error, "vendor/node_modules/plain-crypto-js");
}
});
@@ -2484,7 +2517,7 @@ describe("installPluginFromArchive", () => {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('Bundle "blocked-package-file-bundle" installation blocked');
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js.Js");
expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js.Js");
}
});
@@ -2506,7 +2539,7 @@ describe("installPluginFromArchive", () => {
'Bundle "blocked-package-extensionless-file-bundle" installation blocked',
);
expect(result.error).toContain('blocked dependency file alias "Plain-Crypto-Js"');
expect(result.error).toContain("vendor/Node_Modules/Plain-Crypto-Js");
expectMessageIncludesPath(result.error, "vendor/Node_Modules/Plain-Crypto-Js");
}
});
@@ -3111,7 +3144,7 @@ describe("installPluginFromDir", () => {
if (!result.ok) {
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
expect(result.error).toContain('blocked dependencies "plain-crypto-js" as package name');
expect(result.error).toContain("node_modules/plain-crypto-js/package.json");
expectMessageIncludesPath(result.error, "node_modules/plain-crypto-js/package.json");
}
expect(vi.mocked(runCommandWithTimeout)).not.toHaveBeenCalled();
});
+6
View File
@@ -945,6 +945,7 @@ type PackageInstallCommonParams = InstallSafetyOverrides & {
dryRun?: boolean;
expectedPluginId?: string;
requirePluginManifest?: boolean;
allowSourceTypeScriptEntries?: boolean;
installPolicyRequest?: PluginInstallPolicyRequest;
};
@@ -973,6 +974,7 @@ function pickPackageInstallCommonParams(
dryRun: params.dryRun,
expectedPluginId: params.expectedPluginId,
requirePluginManifest: params.requirePluginManifest,
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
installPolicyRequest: params.installPolicyRequest,
};
}
@@ -1313,6 +1315,7 @@ async function validatePackagePluginInstallSource(params: {
packageDir: string;
expectedPluginId?: string;
requirePluginManifest?: boolean;
allowSourceTypeScriptEntries?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
trustedSourceLinkedOfficialInstall?: boolean;
installPolicyRequest?: PluginInstallPolicyRequest;
@@ -1422,6 +1425,7 @@ async function validatePackagePluginInstallSource(params: {
packageDir: params.packageDir,
extensions,
manifest,
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
});
if (!extensionValidation.ok) {
return {
@@ -1531,6 +1535,7 @@ export async function installPluginFromInstalledPackageDir(
packageDir: params.packageDir,
expectedPluginId: params.expectedPluginId,
requirePluginManifest: params.requirePluginManifest,
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
installPolicyRequest: params.installPolicyRequest,
@@ -1598,6 +1603,7 @@ async function installPluginFromPackageDir(
packageDir: params.packageDir,
expectedPluginId: params.expectedPluginId,
requirePluginManifest: params.requirePluginManifest,
allowSourceTypeScriptEntries: params.allowSourceTypeScriptEntries,
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
trustedSourceLinkedOfficialInstall: params.trustedSourceLinkedOfficialInstall,
installPolicyRequest: params.installPolicyRequest,
+17
View File
@@ -142,6 +142,7 @@ export async function validatePackageExtensionEntriesForInstall(params: {
packageDir: string;
extensions: string[];
manifest: PackageManifest;
allowSourceTypeScriptEntries?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const runtimeResolution = resolvePackageRuntimeExtensionEntries({
manifest: params.manifest,
@@ -198,6 +199,14 @@ export async function validatePackageExtensionEntriesForInstall(params: {
continue;
}
if (
sourceEntry.exists &&
isTypeScriptPackageEntry(entry) &&
params.allowSourceTypeScriptEntries
) {
continue;
}
if (sourceEntry.exists && isTypeScriptPackageEntry(entry)) {
return {
ok: false,
@@ -282,6 +291,14 @@ export async function validatePackageExtensionEntriesForInstall(params: {
return { ok: true };
}
if (
sourceEntry.exists &&
isTypeScriptPackageEntry(setupEntry) &&
params.allowSourceTypeScriptEntries
) {
return { ok: true };
}
if (sourceEntry.exists && isTypeScriptPackageEntry(setupEntry)) {
return {
ok: false,