fix: cover Windows pnpm and Lobster install regressions

This commit is contained in:
Peter Steinberger
2026-04-26 08:13:58 +01:00
parent 5b9be2cdb1
commit 1de4aff06d
6 changed files with 232 additions and 23 deletions

View File

@@ -2,7 +2,11 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createEmbeddedLobsterRunner, resolveLobsterCwd } from "./lobster-runner.js";
import {
createEmbeddedLobsterRunner,
loadEmbeddedToolRuntimeFromPackage,
resolveLobsterCwd,
} from "./lobster-runner.js";
describe("resolveLobsterCwd", () => {
it("defaults to the current working directory", () => {
@@ -352,6 +356,60 @@ describe("createEmbeddedLobsterRunner", () => {
expect(loadRuntime).toHaveBeenCalledTimes(1);
});
it("falls back to the installed package core file when the core export is unavailable", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js");
const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js");
try {
await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });
await fs.mkdir(path.dirname(packageCorePath), { recursive: true });
await fs.writeFile(
path.join(packageRoot, "package.json"),
JSON.stringify({
name: "@clawdbot/lobster",
type: "module",
main: "./dist/src/sdk/index.js",
}),
"utf8",
);
await fs.writeFile(packageEntryPath, "export {};\n", "utf8");
await fs.writeFile(
packageCorePath,
[
"export async function runToolRequest() {",
" return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };",
"}",
"export async function resumeToolRequest() {",
" return { ok: true, status: 'cancelled', output: [], requiresApproval: null };",
"}",
"",
].join("\n"),
"utf8",
);
const runtime = await loadEmbeddedToolRuntimeFromPackage({
importModule: async (specifier) => {
if (specifier === "@clawdbot/lobster/core") {
throw new Error("package export missing");
}
return (await import(`${specifier}?t=${Date.now()}`)) as object;
},
resolvePackageEntry: () => packageEntryPath,
});
await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({
ok: true,
status: "ok",
output: [{ source: "fallback" }],
requiresApproval: null,
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("requires a pipeline for run", async () => {
const runner = createEmbeddedLobsterRunner({
loadRuntime: vi.fn().mockResolvedValue({

View File

@@ -1,10 +1,9 @@
import { readFileSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { Readable, Writable } from "node:stream";
import {
resumeToolRequest as embeddedResumeToolRequest,
runToolRequest as embeddedRunToolRequest,
} from "@clawdbot/lobster/core";
import { pathToFileURL } from "node:url";
export type LobsterEnvelope =
| {
@@ -97,6 +96,45 @@ type EmbeddedToolRuntime = {
type LoadEmbeddedToolRuntime = () => Promise<EmbeddedToolRuntime>;
type LoadEmbeddedToolRuntimeFromPackageOptions = {
importModule?: (specifier: string) => Promise<Partial<EmbeddedToolRuntime>>;
resolvePackageEntry?: (specifier: string) => string;
};
const lobsterRequire = createRequire(import.meta.url);
function toEmbeddedToolRuntime(
moduleExports: Partial<EmbeddedToolRuntime>,
source: string,
): EmbeddedToolRuntime {
const { runToolRequest, resumeToolRequest } = moduleExports;
if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") {
return { runToolRequest, resumeToolRequest };
}
throw new Error(`${source} does not export Lobster embedded runtime functions`);
}
function findLobsterPackageRoot(resolvedEntryPath: string): string {
let dir = path.dirname(resolvedEntryPath);
while (true) {
const packageJsonPath = path.join(dir, "package.json");
try {
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string };
if (parsed.name === "@clawdbot/lobster") {
return dir;
}
} catch {
// Keep walking until the installed package root is found.
}
const parent = path.dirname(dir);
if (parent === dir) {
throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);
}
dir = parent;
}
}
function normalizeForCwdSandbox(p: string): string {
const normalized = path.normalize(p);
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
@@ -255,11 +293,39 @@ async function withTimeout<T>(
});
}
async function loadEmbeddedToolRuntimeFromPackage(): Promise<EmbeddedToolRuntime> {
return {
runToolRequest: embeddedRunToolRequest,
resumeToolRequest: embeddedResumeToolRequest,
};
export async function loadEmbeddedToolRuntimeFromPackage(
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
): Promise<EmbeddedToolRuntime> {
const importModule =
options.importModule ??
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
const resolvePackageEntry =
options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
let coreLoadError: unknown;
try {
const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/");
return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core");
} catch (error) {
coreLoadError = error;
}
let fallbackLoadError: unknown;
try {
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
const packageRoot = findLobsterPackageRoot(packageEntryPath);
const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);
} catch (error) {
fallbackLoadError = error;
}
throw new Error("Failed to load the Lobster embedded runtime", {
cause: new AggregateError(
[coreLoadError, fallbackLoadError],
"Both Lobster embedded runtime load paths failed",
),
});
}
export function createEmbeddedLobsterRunner(options?: {