perf(gateway): cache stable plugin index fingerprints

This commit is contained in:
Peter Steinberger
2026-05-27 10:01:57 +01:00
parent 1d4537add3
commit 2babe03bf5
8 changed files with 296 additions and 121 deletions

View File

@@ -10,7 +10,6 @@ import {
execServerUrlFromClient,
openSocket,
rpc,
shellQuote,
waitForHttpBodyDeltas,
} from "./sandbox-exec-server.test-helpers.js";
@@ -19,6 +18,12 @@ afterEach(async () => {
await closeCodexSandboxExecServersForTests();
});
function testExecEnv(): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH,
};
}
describe("OpenClaw Codex sandbox exec-server HTTP", () => {
it("routes HTTP requests through the sandbox backend", async () => {
const runShellCommand = vi.fn(async () => ({
@@ -84,13 +89,13 @@ describe("OpenClaw Codex sandbox exec-server HTTP", () => {
});
const buildExecSpec = vi.fn(async () => ({
argv: [
"/bin/sh",
"-lc",
process.execPath,
"-e",
[headerLine, bodyLine, doneLine]
.map((line) => `printf '%s\\n' ${shellQuote(line)}`)
.join("; "),
.map((line) => `process.stdout.write(${JSON.stringify(`${line}\n`)});`)
.join(""),
],
env: process.env,
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const runShellCommand = vi.fn(async () => ({
@@ -167,7 +172,7 @@ describe("OpenClaw Codex sandbox exec-server HTTP", () => {
"setInterval(() => {}, 1000);",
].join(""),
],
env: process.env,
env: testExecEnv(),
finalizeToken: "stream-token",
stdinMode: "pipe-closed",
}),

View File

@@ -40,8 +40,8 @@ export function createSandboxContext(overrides: {
buildExecSpec:
overrides.buildExecSpec ??
(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: process.env,
argv: [process.execPath, "-e", ""],
env: { PATH: process.env.PATH },
stdinMode: "pipe-closed",
})),
finalizeExec: overrides.finalizeExec,

View File

@@ -20,6 +20,26 @@ afterEach(async () => {
await closeCodexSandboxExecServersForTests();
});
function testExecEnv(): NodeJS.ProcessEnv {
return {
PATH: process.env.PATH,
};
}
function echoFirstInputLineScript(prefix: string): string {
return [
"let data = '';",
"process.stdin.setEncoding('utf8');",
"process.stdin.on('data', (chunk) => {",
"data += chunk;",
"if (data.includes('\\n')) {",
`process.stdout.write(${JSON.stringify(prefix)} + data);`,
"process.exit(0);",
"}",
"});",
].join(" ");
}
describe("OpenClaw Codex sandbox exec-server", () => {
it("reports unavailable app-server remote environment support without exposing an environment", async () => {
const sandbox = createSandboxContext({});
@@ -95,8 +115,8 @@ describe("OpenClaw Codex sandbox exec-server", () => {
it("registers a sandbox-backed Codex environment and routes process execution through it", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "printf 'sandbox-process-ok\\n'"],
env: process.env,
argv: [process.execPath, "-e", "process.stdout.write('sandbox-process-ok\\n')"],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
@@ -172,8 +192,8 @@ describe("OpenClaw Codex sandbox exec-server", () => {
it("rejects unsupported arg0 overrides instead of dropping them", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "true"],
env: process.env,
argv: [process.execPath, "-e", ""],
env: testExecEnv(),
stdinMode: "pipe-closed" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
@@ -204,8 +224,8 @@ describe("OpenClaw Codex sandbox exec-server", () => {
it("accepts stdin writes for pipe-backed processes", async () => {
const sandbox = createSandboxContext({
buildExecSpec: async () => ({
argv: ["/bin/sh", "-lc", 'read line; printf "echo:%s\\n" "$line"'],
env: process.env,
argv: [process.execPath, "-e", echoFirstInputLineScript("echo:")],
env: testExecEnv(),
stdinMode: "pipe-open",
}),
});
@@ -242,8 +262,8 @@ describe("OpenClaw Codex sandbox exec-server", () => {
it("keeps tty process starts pipe-backed for sandbox backends", async () => {
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", 'read line; printf "tty:%s\\n" "$line"'],
env: process.env,
argv: [process.execPath, "-e", echoFirstInputLineScript("tty:")],
env: testExecEnv(),
stdinMode: "pipe-open" as const,
}));
const sandbox = createSandboxContext({ buildExecSpec });
@@ -289,7 +309,7 @@ describe("OpenClaw Codex sandbox exec-server", () => {
vi.stubEnv("OPENCLAW_TEST_DATABASE_PASSWORD", "host-password");
vi.stubEnv("OPENCLAW_TEST_PRIVATE_KEY", "host-private-key");
const buildExecSpec = vi.fn(async () => ({
argv: ["/bin/sh", "-lc", "true"],
argv: [process.execPath, "-e", ""],
env: {},
stdinMode: "pipe-closed" as const,
}));
@@ -336,7 +356,7 @@ describe("OpenClaw Codex sandbox exec-server", () => {
"-e",
"process.stdout.write('aaaa'); process.stderr.write('bbbb');",
],
env: process.env,
env: testExecEnv(),
stdinMode: "pipe-closed",
}),
});

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { PluginInstallRecord } from "../config/types.plugins.js";
@@ -239,8 +240,9 @@ const installRecordsCache = new Map<string, InstallRecordsCacheEntry>();
function readFileSignature(filePath: string): string {
try {
const stat = fs.statSync(filePath);
return `${stat.mtimeMs}:${stat.size}`;
const stat = fs.statSync(filePath, { bigint: true });
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("base64url");
return `${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}:${hash}`;
} catch {
return "missing";
}

View File

@@ -6,7 +6,10 @@ import {
writePersistedInstalledPluginIndex,
} from "./installed-plugin-index-store.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import {
loadPluginManifestRegistryForInstalledIndex,
resolveInstalledManifestRegistryIndexFingerprint,
} from "./manifest-registry-installed.js";
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
const tempDirs: string[] = [];
@@ -70,7 +73,106 @@ function createIndex(rootDir: string): InstalledPluginIndex {
};
}
function fileSignature(filePath: string) {
const stat = fs.statSync(filePath);
return {
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
};
}
function createIndexWithFileSignatures(rootDir: string): InstalledPluginIndex {
const index = createIndex(rootDir);
return {
...index,
plugins: index.plugins.map((record) => ({
...record,
manifestFile: fileSignature(record.manifestPath),
})),
};
}
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): T {
if (!value || typeof value !== "object") {
return value;
}
const object = value as object;
if (seen.has(object)) {
return value;
}
seen.add(object);
for (const child of Object.values(value)) {
deepFreeze(child, seen);
}
return Object.freeze(value);
}
describe("loadPluginManifestRegistryForInstalledIndex", () => {
it("reuses frozen installed-index fingerprints when file signatures are persisted", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = deepFreeze(createIndexWithFileSignatures(rootDir));
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
const nextMtime = new Date(Date.now() + 5000);
fs.utimesSync(manifestPath, nextMtime, nextMtime);
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).toBe(first);
});
it("recomputes installed-index fingerprints for mutable index objects", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndexWithFileSignatures(rootDir);
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const record = index.plugins[0];
if (!record) {
throw new Error("expected index record");
}
record.manifestHash = "changed";
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).not.toBe(first);
});
it("does not cache shallow-frozen installed-index fingerprints with mutable nested records", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndexWithFileSignatures(rootDir);
const record = index.plugins[0];
if (!record) {
throw new Error("expected index record");
}
Object.freeze(index.installRecords);
Object.freeze(index.diagnostics);
Object.freeze(record);
Object.freeze(index.plugins);
Object.freeze(index);
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const agentHarnesses = record.startup.agentHarnesses as string[];
agentHarnesses.push("changed");
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).not.toBe(first);
});
it("does not cache frozen installed-index fingerprints that depend on live file state", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = deepFreeze(createIndex(rootDir));
const first = resolveInstalledManifestRegistryIndexFingerprint(index);
const manifestPath = path.join(rootDir, "openclaw.plugin.json");
const nextMtime = new Date(Date.now() + 5000);
fs.utimesSync(manifestPath, nextMtime, nextMtime);
const second = resolveInstalledManifestRegistryIndexFingerprint(index);
expect(second).not.toBe(first);
});
it("reconstructs installed-index manifest registries when manifest files change", () => {
const rootDir = makeTempDir();
const manifestPath = path.join(rootDir, "openclaw.plugin.json");

View File

@@ -7,6 +7,7 @@ import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeOptionalTrimmedStringList } from "../shared/string-normalization.js";
import type { PluginCandidate } from "./discovery.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import type { InstalledPluginFileSignature } from "./installed-plugin-index-hash.js";
import type { InstalledPluginIndex, InstalledPluginIndexRecord } from "./installed-plugin-index.js";
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index.js";
import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js";
@@ -25,6 +26,37 @@ import {
type PluginDependencySpecMap,
} from "./status-dependencies.js";
const installedManifestRegistryIndexFingerprintCache = new WeakMap<InstalledPluginIndex, string>();
function isDeepFrozenJsonLike(value: unknown, seen = new WeakSet<object>()): boolean {
if (!value || typeof value !== "object") {
return true;
}
const object = value as object;
if (seen.has(object)) {
return true;
}
if (!Object.isFrozen(object)) {
return false;
}
seen.add(object);
return Object.values(value).every((entry) => isDeepFrozenJsonLike(entry, seen));
}
function hasPersistedFileSignatures(index: InstalledPluginIndex): boolean {
return index.plugins.every(
(record) =>
record.manifestFile !== undefined &&
(record.packageJson === undefined || record.packageJson.fileSignature !== undefined),
);
}
function isInstalledManifestRegistryIndexFingerprintCacheable(
index: InstalledPluginIndex,
): boolean {
return hasPersistedFileSignatures(index) && isDeepFrozenJsonLike(index);
}
function isRelativePathInsideOrEqual(relativePath: string): boolean {
return (
relativePath === "" ||
@@ -61,12 +93,19 @@ function safeFileSignature(filePath: string | undefined): string | undefined {
}
try {
const stat = fs.statSync(filePath);
return `${filePath}:${stat.size}:${stat.mtimeMs}`;
return formatFileSignature(filePath, stat);
} catch {
return `${filePath}:missing`;
}
}
function formatFileSignature(
filePath: string,
signature: Pick<InstalledPluginFileSignature, "size" | "mtimeMs">,
): string {
return `${filePath}:${signature.size}:${signature.mtimeMs}`;
}
function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
const realpathCache = new Map<string, string>();
return {
@@ -78,9 +117,12 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
installRecords: index.installRecords,
diagnostics: index.diagnostics,
plugins: index.plugins.map((record) => {
const packageJsonFile =
record.packageJson?.fileSignature ??
safeFileSignature(resolvePackageJsonPath(record, realpathCache));
const packageJsonPath = resolvePackageJsonPath(record, realpathCache);
const packageJsonFile = record.packageJson?.fileSignature
? packageJsonPath
? formatFileSignature(packageJsonPath, record.packageJson.fileSignature)
: undefined
: safeFileSignature(packageJsonPath);
return {
pluginId: record.pluginId,
packageName: record.packageName,
@@ -91,7 +133,9 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
packageChannel: record.packageChannel,
manifestPath: record.manifestPath,
manifestHash: record.manifestHash,
manifestFile: safeFileSignature(record.manifestPath),
manifestFile: record.manifestFile
? formatFileSignature(record.manifestPath, record.manifestFile)
: safeFileSignature(record.manifestPath),
format: record.format,
bundleFormat: record.bundleFormat,
source: record.source,
@@ -116,7 +160,15 @@ function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) {
export function resolveInstalledManifestRegistryIndexFingerprint(
index: InstalledPluginIndex,
): string {
return hashJson(buildInstalledManifestRegistryIndexKey(index));
const cached = installedManifestRegistryIndexFingerprintCache.get(index);
if (cached) {
return cached;
}
const fingerprint = hashJson(buildInstalledManifestRegistryIndexKey(index));
if (isInstalledManifestRegistryIndexFingerprintCacheable(index)) {
installedManifestRegistryIndexFingerprintCache.set(index, fingerprint);
}
return fingerprint;
}
function resolveInstalledPluginRootDir(record: InstalledPluginIndexRecord): string {

View File

@@ -81,6 +81,53 @@ function writeNodeShim(binDir: string): void {
}
}
function writeBashExecutable(filePath: string, lines: string[]): void {
fs.writeFileSync(filePath, ["#!/bin/bash", "set -euo pipefail", ...lines, ""].join("\n"));
fs.chmodSync(filePath, 0o755);
}
function writeFakeTimeout(filePath: string, supportsKillAfter: boolean): void {
writeBashExecutable(filePath, [
'if [ "${1:-}" = "--kill-after=1s" ]; then',
` exit ${supportsKillAfter ? 0 : 1}`,
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ]; do',
' case "$1" in',
" --)",
" shift",
" break",
" ;;",
" -k|--kill-after)",
" shift 2",
" ;;",
" --kill-after=*|-*)",
" shift",
" ;;",
" *)",
" shift",
" break",
" ;;",
" esac",
"done",
'exec "$@"',
]);
}
function writeFakeNpm(filePath: string): void {
writeBashExecutable(filePath, ['printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"']);
}
function expectNpmInstallObserved(argsPath: string, expectedArgs: string, prefix: string): void {
if (fs.existsSync(argsPath)) {
expect(fs.readFileSync(argsPath, "utf8").trim()).toBe(expectedArgs);
return;
}
expect(
fs.existsSync(path.join(prefix, "lib/node_modules/openclaw-e2e-fixture/package.json")),
).toBe(true);
}
describe("scripts/lib/openclaw-e2e-instance.sh", () => {
it("sources decoded test-state scripts", () => {
const result = runHelper(base64('export OPENCLAW_E2E_INSTANCE_TEST="ok"\n'));
@@ -112,29 +159,10 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
const npmArgsPath = path.join(tempDir, "npm-args.txt");
const logPath = path.join(tempDir, "install.log");
const packagePath = path.join(tempDir, "openclaw.tgz");
const prefixPath = path.join(tempDir, "prefix");
writePackageFixture(packagePath);
fs.writeFileSync(
path.join(tempDir, "timeout"),
[
"#!/bin/sh",
"set -eu",
'if [ "${1:-}" = "--kill-after=1s" ]; then',
" exit 0",
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_NPM_BIN" "$@"',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(tempDir, "npm"),
["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"),
);
fs.chmodSync(path.join(tempDir, "timeout"), 0o755);
fs.chmodSync(path.join(tempDir, "npm"), 0o755);
writeFakeTimeout(path.join(tempDir, "timeout"), true);
writeFakeNpm(path.join(tempDir, "npm"));
const result = spawnSync(
"/bin/bash",
@@ -143,7 +171,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"set -euo pipefail",
`source ${shellQuote(helperPath)}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")} ${shellQuote(prefixPath)}`,
].join("; "),
],
{
@@ -162,10 +190,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
expectShellSuccess(result);
expect(result.stdout).toContain("Installing fixture package...");
expect(fs.readFileSync(timeoutArgsPath, "utf8").trim()).toBe(
`--kill-after=30s 42s npm install -g ${packagePath} --no-fund --no-audit`,
`--kill-after=30s 42s npm install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
);
expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe(
`install -g ${packagePath} --no-fund --no-audit`,
expectNpmInstallObserved(
npmArgsPath,
`install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
prefixPath,
);
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
@@ -179,29 +209,10 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
const npmArgsPath = path.join(tempDir, "npm-args.txt");
const logPath = path.join(tempDir, "install.log");
const packagePath = path.join(tempDir, "openclaw.tgz");
const prefixPath = path.join(tempDir, "prefix");
writePackageFixture(packagePath);
fs.writeFileSync(
path.join(tempDir, "timeout"),
[
"#!/bin/sh",
"set -eu",
'if [ "${1:-}" = "--kill-after=1s" ]; then',
" exit 1",
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_NPM_BIN" "$@"',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(tempDir, "npm"),
["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"),
);
fs.chmodSync(path.join(tempDir, "timeout"), 0o755);
fs.chmodSync(path.join(tempDir, "npm"), 0o755);
writeFakeTimeout(path.join(tempDir, "timeout"), false);
writeFakeNpm(path.join(tempDir, "npm"));
const result = spawnSync(
"/bin/bash",
@@ -210,7 +221,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"set -euo pipefail",
`source ${shellQuote(helperPath)}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")} ${shellQuote(prefixPath)}`,
].join("; "),
],
{
@@ -228,10 +239,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
expectShellSuccess(result);
expect(fs.readFileSync(timeoutArgsPath, "utf8").trim()).toBe(
`42s npm install -g ${packagePath} --no-fund --no-audit`,
`42s npm install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
);
expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe(
`install -g ${packagePath} --no-fund --no-audit`,
expectNpmInstallObserved(
npmArgsPath,
`install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
prefixPath,
);
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
@@ -245,29 +258,10 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
const npmArgsPath = path.join(tempDir, "npm-args.txt");
const logPath = path.join(tempDir, "install.log");
const packagePath = path.join(tempDir, "openclaw.tgz");
const prefixPath = path.join(tempDir, "prefix");
writePackageFixture(packagePath);
fs.writeFileSync(
path.join(tempDir, "gtimeout"),
[
"#!/bin/bash",
"set -euo pipefail",
'if [ "${1:-}" = "--kill-after=1s" ]; then',
" exit 0",
"fi",
'printf "%s\\n" "$*" >"$OPENCLAW_TEST_TIMEOUT_ARGS"',
'while [ "$#" -gt 0 ] && [ "$1" != "npm" ]; do shift; done',
'[ "$#" -gt 0 ] || exit 127',
"shift",
'exec "$OPENCLAW_TEST_NPM_BIN" "$@"',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(tempDir, "npm"),
["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"),
);
fs.chmodSync(path.join(tempDir, "gtimeout"), 0o755);
fs.chmodSync(path.join(tempDir, "npm"), 0o755);
writeFakeTimeout(path.join(tempDir, "gtimeout"), true);
writeFakeNpm(path.join(tempDir, "npm"));
const result = spawnSync(
"/bin/bash",
@@ -276,7 +270,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"set -euo pipefail",
`source ${shellQuote(helperPath)}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")} ${shellQuote(prefixPath)}`,
].join("; "),
],
{
@@ -294,10 +288,12 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
expectShellSuccess(result);
expect(fs.readFileSync(timeoutArgsPath, "utf8").trim()).toBe(
`--kill-after=30s 42s npm install -g ${packagePath} --no-fund --no-audit`,
`--kill-after=30s 42s npm install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
);
expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe(
`install -g ${packagePath} --no-fund --no-audit`,
expectNpmInstallObserved(
npmArgsPath,
`install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
prefixPath,
);
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });
@@ -310,13 +306,10 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
const npmArgsPath = path.join(tempDir, "npm-args.txt");
const logPath = path.join(tempDir, "install.log");
const packagePath = path.join(tempDir, "openclaw.tgz");
const prefixPath = path.join(tempDir, "prefix");
writePackageFixture(packagePath);
writeNodeShim(tempDir);
fs.writeFileSync(
path.join(tempDir, "npm"),
["#!/bin/sh", "set -eu", 'printf "%s\\n" "$*" >"$OPENCLAW_TEST_NPM_ARGS"', ""].join("\n"),
);
fs.chmodSync(path.join(tempDir, "npm"), 0o755);
writeFakeNpm(path.join(tempDir, "npm"));
const result = spawnSync(
"/bin/bash",
@@ -325,7 +318,7 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
[
"set -euo pipefail",
`source ${shellQuote(helperPath)}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")}`,
`openclaw_e2e_install_package ${shellQuote(logPath)} ${shellQuote("fixture package")} ${shellQuote(prefixPath)}`,
].join("; "),
],
{
@@ -341,8 +334,10 @@ describe("scripts/lib/openclaw-e2e-instance.sh", () => {
expectShellSuccess(result);
expect(fs.readFileSync(logPath, "utf8")).toContain("using Node watchdog");
expect(fs.readFileSync(npmArgsPath, "utf8").trim()).toBe(
`install -g ${packagePath} --no-fund --no-audit`,
expectNpmInstallObserved(
npmArgsPath,
`install -g --prefix ${prefixPath} ${packagePath} --no-fund --no-audit`,
prefixPath,
);
} finally {
fs.rmSync(tempDir, { force: true, recursive: true });

View File

@@ -35,14 +35,13 @@ async function createApp(
queue: ExecApprovalRequest[] = [createExecApproval()],
) {
const { OpenClawApp } = await import("./app.ts");
const app = new OpenClawApp();
Object.defineProperty(app, "client", {
value: { request },
writable: true,
const app = Object.create(OpenClawApp.prototype) as InstanceType<typeof OpenClawApp>;
Object.defineProperties(app, {
client: { value: { request }, writable: true },
execApprovalBusy: { value: false, writable: true },
execApprovalError: { value: null, writable: true },
execApprovalQueue: { value: queue, writable: true },
});
app.execApprovalQueue = queue;
app.execApprovalBusy = false;
app.execApprovalError = null;
return app;
}