diff --git a/scripts/canvas-a2ui-copy.ts b/scripts/canvas-a2ui-copy.ts index 3d20df3dd31a5..318b185fac91a 100644 --- a/scripts/canvas-a2ui-copy.ts +++ b/scripts/canvas-a2ui-copy.ts @@ -14,23 +14,78 @@ export function shouldSkipMissingA2uiAssets(env = process.env): boolean { return env.OPENCLAW_A2UI_SKIP_MISSING === "1" || Boolean(env.OPENCLAW_SPARSE_PROFILE); } -export async function copyA2uiAssets({ srcDir, outDir }: { srcDir: string; outDir: string }) { +function normalizeRelativePath(value: string): string { + return value.replace(/\\/g, "/"); +} + +async function assertSafeRequiredAsset(srcDir: string, entryName: string) { + const entryPath = path.join(srcDir, entryName); + const stats = await fs.lstat(entryPath); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error(`Invalid A2UI asset path: ${normalizeRelativePath(entryPath)}`); + } +} + +async function copyDirectoryContentsSafe(params: { + srcDir: string; + outDir: string; + rootDir: string; + warn: (message: string) => void; +}) { + const entries = await fs.readdir(params.srcDir, { withFileTypes: true }); + for (const entry of entries.toSorted((left, right) => left.name.localeCompare(right.name))) { + const sourcePath = path.join(params.srcDir, entry.name); + const destinationPath = path.join(params.outDir, entry.name); + + if (entry.isSymbolicLink()) { + const relativePath = normalizeRelativePath(path.relative(params.rootDir, sourcePath)); + params.warn(`[canvas-a2ui-copy] skipping symlinked asset: ${relativePath}`); + continue; + } + if (entry.isDirectory()) { + await fs.mkdir(destinationPath, { recursive: true }); + await copyDirectoryContentsSafe({ + srcDir: sourcePath, + outDir: destinationPath, + rootDir: params.rootDir, + warn: params.warn, + }); + continue; + } + if (entry.isFile()) { + await fs.copyFile(sourcePath, destinationPath); + } + } +} + +export async function copyA2uiAssets( + { + srcDir, + outDir, + }: { + srcDir: string; + outDir: string; + }, + params: { warn?: (message: string) => void } = {}, +) { const skipMissing = shouldSkipMissingA2uiAssets(process.env); + const warn = params.warn ?? console.warn; try { - await fs.stat(path.join(srcDir, "index.html")); - await fs.stat(path.join(srcDir, "a2ui.bundle.js")); + await assertSafeRequiredAsset(srcDir, "index.html"); + await assertSafeRequiredAsset(srcDir, "a2ui.bundle.js"); } catch (err) { const message = 'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.'; if (skipMissing) { - console.warn( + warn( `${message} Skipping copy because OPENCLAW_A2UI_SKIP_MISSING=1 or OPENCLAW_SPARSE_PROFILE is set.`, ); return; } throw new Error(message, { cause: err }); } - await fs.mkdir(path.dirname(outDir), { recursive: true }); - await fs.cp(srcDir, outDir, { recursive: true }); + await fs.rm(outDir, { recursive: true, force: true }); + await fs.mkdir(outDir, { recursive: true }); + await copyDirectoryContentsSafe({ srcDir, outDir, rootDir: srcDir, warn }); } async function main() { diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 8b37f93ffaf92..0029117355de7 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -355,6 +355,21 @@ function collectRuntimeDeps(packageJson) { }; } +function collectRootRuntimeDeps(params = {}) { + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const packageJsonPath = join(packageRoot, "package.json"); + const pathExists = params.existsSync ?? existsSync; + const readJsonFile = params.readJson ?? readJson; + if (!pathExists(packageJsonPath)) { + return new Map(); + } + try { + return new Map(Object.entries(collectRuntimeDeps(readJsonFile(packageJsonPath)))); + } catch { + return new Map(); + } +} + export function discoverBundledPluginRuntimeDeps(params = {}) { const extensionsDir = params.extensionsDir ?? DEFAULT_EXTENSIONS_DIR; const pathExists = params.existsSync ?? existsSync; @@ -671,7 +686,13 @@ export function runBundledPluginPostinstall(params = {}) { const runtimeDeps = params.runtimeDeps ?? discoverBundledPluginRuntimeDeps({ extensionsDir, existsSync: pathExists }); + const rootRuntimeDeps = collectRootRuntimeDeps({ + packageRoot, + existsSync: pathExists, + readJson: params.readJson ?? readJson, + }); const missingSpecs = runtimeDeps + .filter((dep) => rootRuntimeDeps.has(dep.name)) .filter((dep) => runtimeDepNeedsInstall({ dep, diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index b26b2c9051901..805d89ef11c45 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -325,10 +325,8 @@ process.stdout.write(last.filename); } prepare_update_host_access() { - local host_os - host_os="$(uname -s)" UPDATE_DOCKER_HOST_ARGS=() - if [[ "$host_os" == "Linux" ]]; then + if [[ -n "$UPDATE_HOST_ALIAS" && "$UPDATE_HOST_ALIAS" != "127.0.0.1" && "$UPDATE_HOST_ALIAS" != "localhost" ]]; then UPDATE_DOCKER_HOST_ARGS=(--add-host "${UPDATE_HOST_ALIAS}:host-gateway") fi } diff --git a/src/scripts/canvas-a2ui-copy.test.ts b/src/scripts/canvas-a2ui-copy.test.ts index 4b3ef9f4cf6db..42003c65e936e 100644 --- a/src/scripts/canvas-a2ui-copy.test.ts +++ b/src/scripts/canvas-a2ui-copy.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { copyA2uiAssets } from "../../scripts/canvas-a2ui-copy.js"; import { withTempDir } from "../test-utils/temp-dir.js"; @@ -66,4 +66,33 @@ describe("canvas a2ui copy", () => { await expect(fs.stat(path.join(outDir, "a2ui.bundle.js"))).resolves.toBeTruthy(); }); }); + + it("cleans stale output and skips symlinked source entries", async () => { + await withA2uiFixture(async (dir) => { + const srcDir = path.join(dir, "src"); + const outDir = path.join(dir, "dist"); + const warn = vi.fn(); + await fs.mkdir(srcDir, { recursive: true }); + await fs.mkdir(outDir, { recursive: true }); + await fs.writeFile(path.join(srcDir, "index.html"), "", "utf8"); + await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8"); + await fs.writeFile(path.join(dir, "package.json"), "{}", "utf8"); + await fs.writeFile(path.join(outDir, "stale.txt"), "stale", "utf8"); + await fs.symlink(path.join(dir, "package.json"), path.join(srcDir, "test-link.txt")); + + await copyA2uiAssets({ srcDir, outDir }, { warn }); + + await expect(fs.stat(path.join(outDir, "index.html"))).resolves.toBeTruthy(); + await expect(fs.stat(path.join(outDir, "a2ui.bundle.js"))).resolves.toBeTruthy(); + await expect(fs.stat(path.join(outDir, "stale.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.stat(path.join(outDir, "test-link.txt"))).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(warn).toHaveBeenCalledWith( + "[canvas-a2ui-copy] skipping symlinked asset: test-link.txt", + ); + }); + }); }); diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 3f02e24fd3f22..378e0b34e430c 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -44,6 +44,16 @@ async function writePluginPackage( } } +async function writeRootPackageJson( + packageRoot: string, + packageJson: Record = { name: "@qverisai/qverisbot" }, +) { + await fs.writeFile( + path.join(packageRoot, "package.json"), + `${JSON.stringify(packageJson, null, 2)}\n`, + ); +} + describe("bundled plugin postinstall", () => { function createNpmInstallArgs(...packages: string[]) { return [ @@ -99,9 +109,15 @@ describe("bundled plugin postinstall", () => { }); }); - it("installs bundled plugin deps outside of source checkouts", async () => { + it("installs only root-mirrored bundled plugin deps outside of source checkouts", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { + name: "@qverisai/qverisbot", + dependencies: { + acpx: "0.4.1", + }, + }); await writePluginPackage(extensionsDir, "acpx", { dependencies: { acpx: "0.4.1", @@ -341,6 +357,12 @@ describe("bundled plugin postinstall", () => { it("runs nested local installs with sanitized env when the sentinel package is missing", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { + name: "@qverisai/qverisbot", + dependencies: { + acpx: "0.4.1", + }, + }); await writePluginPackage(extensionsDir, "acpx", { dependencies: { acpx: "0.4.1", @@ -394,6 +416,12 @@ describe("bundled plugin postinstall", () => { it("reinstalls bundled runtime deps when optional native children are missing", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { + name: "@qverisai/qverisbot", + dependencies: { + "@snazzah/davey": "0.1.11", + }, + }); await writePluginPackage(extensionsDir, "discord", { dependencies: { "@snazzah/davey": "0.1.11", @@ -519,6 +547,12 @@ describe("bundled plugin postinstall", () => { it("installs missing bundled plugin runtime deps during global installs", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { + name: "@qverisai/qverisbot", + dependencies: { + "@slack/web-api": "7.11.0", + }, + }); await writePluginPackage(extensionsDir, "slack", { dependencies: { "@slack/web-api": "7.11.0", @@ -540,17 +574,23 @@ describe("bundled plugin postinstall", () => { }, extensionsDir, packageRoot, - npmRunner: createBareNpmRunner(["@slack/web-api@7.11.0", "grammy@1.38.4"]), + npmRunner: createBareNpmRunner(["@slack/web-api@7.11.0"]), spawnSync, log: { log: vi.fn(), warn: vi.fn() }, }); - expectNpmInstallSpawn(spawnSync, packageRoot, ["@slack/web-api@7.11.0", "grammy@1.38.4"]); + expectNpmInstallSpawn(spawnSync, packageRoot, ["@slack/web-api@7.11.0"]); }); - it("installs only missing bundled plugin runtime deps", async () => { + it("installs only missing root-mirrored bundled plugin runtime deps", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { + name: "@qverisai/qverisbot", + dependencies: { + "@slack/web-api": "7.11.0", + }, + }); await writePluginPackage(extensionsDir, "slack", { dependencies: { "@slack/web-api": "7.11.0", @@ -576,17 +616,23 @@ describe("bundled plugin postinstall", () => { }, extensionsDir, packageRoot, - npmRunner: createBareNpmRunner(["grammy@1.38.4"]), + npmRunner: createBareNpmRunner([]), spawnSync, log: { log: vi.fn(), warn: vi.fn() }, }); - expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]); + expect(spawnSync).not.toHaveBeenCalled(); }); - it("installs bundled plugin deps when npm location is global", async () => { + it("installs mirrored bundled plugin deps when npm location is global", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { + name: "@qverisai/qverisbot", + dependencies: { + grammy: "1.38.4", + }, + }); await writePluginPackage(extensionsDir, "telegram", { dependencies: { grammy: "1.38.4", @@ -610,6 +656,32 @@ describe("bundled plugin postinstall", () => { expectNpmInstallSpawn(spawnSync, packageRoot, ["grammy@1.38.4"]); }); + it("skips non-mirrored bundled plugin deps during installed-package postinstall", async () => { + const extensionsDir = await createExtensionsDir(); + const packageRoot = path.dirname(path.dirname(extensionsDir)); + await writeRootPackageJson(packageRoot, { name: "@qverisai/qverisbot" }); + await writePluginPackage(extensionsDir, "telegram", { + dependencies: { + grammy: "1.38.4", + }, + }); + const spawnSync = vi.fn(); + + runBundledPluginPostinstall({ + env: { + npm_config_location: "global", + npm_config_prefix: "/opt/homebrew", + HOME: "/tmp/home", + }, + extensionsDir, + packageRoot, + spawnSync, + log: { log: vi.fn(), warn: vi.fn() }, + }); + + expect(spawnSync).not.toHaveBeenCalled(); + }); + it("prunes only bundled plugin package node_modules in source checkouts", async () => { const packageRoot = await createTempDirAsync("openclaw-source-prune-"); const extensionsDir = path.join(packageRoot, "extensions"); diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index c3a611abf5c1e..d72df12f5f4bd 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -77,6 +77,17 @@ describe("test-install-sh-docker", () => { expect(script).toContain('echo "==> Skip update smoke (${UPDATE_SKIP_REASON})"'); expect(script).toContain('if [[ -n "$BASELINE_TAG_URL" ]]; then'); }); + + it("always injects a Docker host mapping for non-local update host aliases", () => { + const script = readFileSync(SCRIPT_PATH, "utf8"); + + expect(script).toContain( + 'if [[ -n "$UPDATE_HOST_ALIAS" && "$UPDATE_HOST_ALIAS" != "127.0.0.1" && "$UPDATE_HOST_ALIAS" != "localhost" ]]; then', + ); + expect(script).toContain( + 'UPDATE_DOCKER_HOST_ARGS=(--add-host "${UPDATE_HOST_ALIAS}:host-gateway")', + ); + }); }); describe("install-sh smoke runner", () => {