diff --git a/extensions/codex/src/app-server/sandbox-exec-server.http.test.ts b/extensions/codex/src/app-server/sandbox-exec-server.http.test.ts index f41c64dc02f..781d5d55e28 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server.http.test.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server.http.test.ts @@ -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", }), diff --git a/extensions/codex/src/app-server/sandbox-exec-server.test-helpers.ts b/extensions/codex/src/app-server/sandbox-exec-server.test-helpers.ts index d8f6546f53e..3f0b3d49405 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server.test-helpers.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server.test-helpers.ts @@ -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, diff --git a/extensions/codex/src/app-server/sandbox-exec-server.test.ts b/extensions/codex/src/app-server/sandbox-exec-server.test.ts index 09ac99daf6e..48f0d7dcb0a 100644 --- a/extensions/codex/src/app-server/sandbox-exec-server.test.ts +++ b/extensions/codex/src/app-server/sandbox-exec-server.test.ts @@ -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", }), }); diff --git a/src/plugins/installed-plugin-index-record-reader.ts b/src/plugins/installed-plugin-index-record-reader.ts index ea55ab350f1..4bf0b6801cf 100644 --- a/src/plugins/installed-plugin-index-record-reader.ts +++ b/src/plugins/installed-plugin-index-record-reader.ts @@ -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(); 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"; } diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index 36333af7eab..747552ddd3f 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -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(value: T, seen = new WeakSet()): 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"); diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 6c4ea24df65..f31dfaa8a08 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -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(); + +function isDeepFrozenJsonLike(value: unknown, seen = new WeakSet()): 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, +): string { + return `${filePath}:${signature.size}:${signature.mtimeMs}`; +} + function buildInstalledManifestRegistryIndexKey(index: InstalledPluginIndex) { const realpathCache = new Map(); 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 { diff --git a/test/scripts/openclaw-e2e-instance.test.ts b/test/scripts/openclaw-e2e-instance.test.ts index 505099599d8..ef1db19b3cd 100644 --- a/test/scripts/openclaw-e2e-instance.test.ts +++ b/test/scripts/openclaw-e2e-instance.test.ts @@ -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 }); diff --git a/ui/src/ui/app.exec-approval.test.ts b/ui/src/ui/app.exec-approval.test.ts index eba9f479109..daebed215ec 100644 --- a/ui/src/ui/app.exec-approval.test.ts +++ b/ui/src/ui/app.exec-approval.test.ts @@ -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; + 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; }