From 5635d74eccd7f4fccf4f78d88d341b2d10c6efd1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 10 Jul 2026 16:27:24 +0100 Subject: [PATCH] fix(release): contain installer artifact restores (#103608) --- scripts/lib/docker-e2e-package.sh | 56 ++++++++++++----- test/scripts/test-install-sh-docker.test.ts | 69 +++++++++++++++++---- 2 files changed, 97 insertions(+), 28 deletions(-) diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index 5fc92daf5ceb..ae58ec4a1132 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -159,11 +159,14 @@ docker_e2e_abs_path() { docker_e2e_restore_package_dist_from_image() ( local image="$1" local ai_backup_dir="" + local ai_dist_dir="" local ai_dist_installed=0 + local ai_package_dir="" local backup_dir="" local container_id="" local dist_installed=0 local requires_ai_dist=0 + local restore_root="" local restore_complete=0 local temp_dir="" @@ -175,19 +178,20 @@ docker_e2e_restore_package_dist_from_image() ( # so the package step cannot combine outputs from different builds. if [ "$restore_complete" != "1" ]; then if [ "$dist_installed" = "1" ]; then - rm -rf "$ROOT_DIR/dist" >/dev/null 2>&1 || true + rm -rf "$restore_root/dist" >/dev/null 2>&1 || true fi if [ -n "$backup_dir" ] && [ -d "$backup_dir" ]; then - if [ ! -e "$ROOT_DIR/dist" ] && mv "$backup_dir" "$ROOT_DIR/dist" >/dev/null 2>&1; then + if [ ! -e "$restore_root/dist" ] && \ + mv "$backup_dir" "$restore_root/dist" >/dev/null 2>&1; then backup_dir="" fi fi if [ "$ai_dist_installed" = "1" ]; then - rm -rf "$ROOT_DIR/packages/ai/dist" >/dev/null 2>&1 || true + rm -rf "$ai_dist_dir" >/dev/null 2>&1 || true fi if [ -n "$ai_backup_dir" ] && [ -d "$ai_backup_dir" ]; then - if [ ! -e "$ROOT_DIR/packages/ai/dist" ] && \ - mv "$ai_backup_dir" "$ROOT_DIR/packages/ai/dist" >/dev/null 2>&1; then + if [ ! -e "$ai_dist_dir" ] && \ + mv "$ai_backup_dir" "$ai_dist_dir" >/dev/null 2>&1; then ai_backup_dir="" fi fi @@ -203,7 +207,29 @@ docker_e2e_restore_package_dist_from_image() ( fi } - if [ -f "$ROOT_DIR/packages/ai/package.json" ]; then + if ! restore_root="$(cd "$ROOT_DIR" && pwd -P)"; then + echo "unable to resolve package restore root: $ROOT_DIR" >&2 + return 1 + fi + # The trusted workflow owns this static checkout and runs no candidate process + # concurrently. Resolve owner paths once and reuse them through every swap. + if [ -L "$restore_root/packages" ] || [ -L "$restore_root/packages/ai" ]; then + echo "refusing package artifact restore through a symlinked packages path" >&2 + return 1 + fi + if [ -f "$restore_root/packages/ai/package.json" ]; then + if ! ai_package_dir="$(cd "$restore_root/packages/ai" && pwd -P)"; then + echo "unable to resolve bundled AI package path" >&2 + return 1 + fi + case "$ai_package_dir/" in + "$restore_root"/*) ;; + *) + echo "refusing bundled AI artifact restore outside the package root" >&2 + return 1 + ;; + esac + ai_dist_dir="$ai_package_dir/dist" requires_ai_dist=1 fi @@ -212,7 +238,7 @@ docker_e2e_restore_package_dist_from_image() ( cleanup_restore_package_dist return 1 fi - if ! temp_dir="$(mktemp -d "$ROOT_DIR/.package-dist.XXXXXX")"; then + if ! temp_dir="$(mktemp -d "$restore_root/.package-dist.XXXXXX")"; then cleanup_restore_package_dist return 1 fi @@ -227,8 +253,8 @@ docker_e2e_restore_package_dist_from_image() ( cleanup_restore_package_dist return 1 fi - if [ -e "$ROOT_DIR/dist" ]; then - if ! backup_dir="$(mktemp -d "$ROOT_DIR/.dist-backup.XXXXXX")"; then + if [ -e "$restore_root/dist" ]; then + if ! backup_dir="$(mktemp -d "$restore_root/.dist-backup.XXXXXX")"; then cleanup_restore_package_dist return 1 fi @@ -236,19 +262,19 @@ docker_e2e_restore_package_dist_from_image() ( cleanup_restore_package_dist return 1 fi - if ! mv "$ROOT_DIR/dist" "$backup_dir"; then + if ! mv "$restore_root/dist" "$backup_dir"; then cleanup_restore_package_dist return 1 fi fi - if ! mv "$temp_dir/dist" "$ROOT_DIR/dist"; then + if ! mv "$temp_dir/dist" "$restore_root/dist"; then cleanup_restore_package_dist return 1 fi dist_installed=1 if [ "$requires_ai_dist" = "1" ]; then - if [ -e "$ROOT_DIR/packages/ai/dist" ]; then - if ! ai_backup_dir="$(mktemp -d "$ROOT_DIR/packages/ai/.dist-backup.XXXXXX")"; then + if [ -e "$ai_dist_dir" ]; then + if ! ai_backup_dir="$(mktemp -d "$ai_package_dir/.dist-backup.XXXXXX")"; then cleanup_restore_package_dist return 1 fi @@ -256,12 +282,12 @@ docker_e2e_restore_package_dist_from_image() ( cleanup_restore_package_dist return 1 fi - if ! mv "$ROOT_DIR/packages/ai/dist" "$ai_backup_dir"; then + if ! mv "$ai_dist_dir" "$ai_backup_dir"; then cleanup_restore_package_dist return 1 fi fi - if ! mv "$temp_dir/ai-dist" "$ROOT_DIR/packages/ai/dist"; then + if ! mv "$temp_dir/ai-dist" "$ai_dist_dir"; then cleanup_restore_package_dist return 1 fi diff --git a/test/scripts/test-install-sh-docker.test.ts b/test/scripts/test-install-sh-docker.test.ts index 2076cf577e13..5c5790f1c5fb 100644 --- a/test/scripts/test-install-sh-docker.test.ts +++ b/test/scripts/test-install-sh-docker.test.ts @@ -7,6 +7,7 @@ import { mkdtempSync, readFileSync, rmSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -237,9 +238,14 @@ function extractEnsureLocalUpdateDistImportClosure(): string { return match[1]; } -function runRestoreLocalDistFixture(options: { failAiSwap?: boolean } = {}) { +type RestorePathEscape = "packages" | "ai"; + +function runRestoreLocalDistFixture( + options: { failAiSwap?: boolean; symlinkEscape?: RestorePathEscape } = {}, +) { const fixtureRoot = tempDirs.make("openclaw-install-restore-root-"); const imageRoot = tempDirs.make("openclaw-install-restore-image-"); + let externalSentinel = ""; for (const [relativePath, contents] of [ ["dist/root.txt", "old-root"], ["packages/ai/dist/ai.txt", "old-ai"], @@ -258,6 +264,23 @@ function runRestoreLocalDistFixture(options: { failAiSwap?: boolean } = {}) { writeFileSync(target, contents); } + if (options.symlinkEscape) { + const escapeRoot = tempDirs.make("openclaw-install-restore-escape-"); + const externalAiRoot = + options.symlinkEscape === "packages" ? join(escapeRoot, "packages", "ai") : escapeRoot; + externalSentinel = join(externalAiRoot, "dist", "ai.txt"); + mkdirSync(path.dirname(externalSentinel), { recursive: true }); + writeFileSync(join(externalAiRoot, "package.json"), "{}"); + writeFileSync(externalSentinel, "external-ai"); + if (options.symlinkEscape === "packages") { + rmSync(join(fixtureRoot, "packages"), { force: true, recursive: true }); + symlinkSync(join(escapeRoot, "packages"), join(fixtureRoot, "packages"), "dir"); + } else { + rmSync(join(fixtureRoot, "packages", "ai"), { force: true, recursive: true }); + symlinkSync(externalAiRoot, join(fixtureRoot, "packages", "ai"), "dir"); + } + } + return spawnSync( "bash", [ @@ -269,6 +292,7 @@ REPO_ROOT="$FIXTURE_REPO" ROOT_DIR="$FIXTURE_ROOT" IMAGE_ROOT="$FIXTURE_IMAGE" docker_e2e_docker_cmd() { + printf 'docker-call=%s\\n' "$1" >&2 case "$1" in create) printf "fixture" @@ -285,7 +309,7 @@ docker_e2e_docker_cmd() { esac } mv() { - if [[ "$FAIL_AI_SWAP" == "1" && "$1" == */ai-dist && "$2" == "$ROOT_DIR/packages/ai/dist" ]]; then + if [[ "$FAIL_AI_SWAP" == "1" && "$1" == */ai-dist && "$2" == */packages/ai/dist ]]; then return 1 fi command mv "$@" @@ -296,6 +320,9 @@ docker_e2e_restore_package_dist_from_image fixture-image || status=$? printf 'status=%s\\n' "$status" printf 'root=%s\\n' "$(cat "$ROOT_DIR/dist/root.txt")" printf 'ai=%s\\n' "$(cat "$ROOT_DIR/packages/ai/dist/ai.txt")" +if [[ -n "$EXTERNAL_SENTINEL" ]]; then + printf 'external=%s\\n' "$(cat "$EXTERNAL_SENTINEL")" +fi `, ], { @@ -303,6 +330,7 @@ printf 'ai=%s\\n' "$(cat "$ROOT_DIR/packages/ai/dist/ai.txt")" env: { ...process.env, FAIL_AI_SWAP: options.failAiSwap ? "1" : "0", + EXTERNAL_SENTINEL: externalSentinel, FIXTURE_IMAGE: imageRoot, FIXTURE_REPO: process.cwd(), FIXTURE_ROOT: fixtureRoot, @@ -457,12 +485,12 @@ describe("test-install-sh-docker", () => { ); expect(packageHelper).toContain('"${container_id}:/app/node_modules/@openclaw/ai/dist"'); expect(packageHelper).toContain('"$temp_dir/ai-dist"'); - expect(packageHelper).toContain('mv "$temp_dir/ai-dist" "$ROOT_DIR/packages/ai/dist"'); + expect(packageHelper).toContain('mv "$temp_dir/ai-dist" "$ai_dist_dir"'); expect(packageHelper).toContain("cleanup_restore_package_dist() {"); - expect(packageHelper).toContain('mv "$ROOT_DIR/dist" "$backup_dir"'); - expect(packageHelper).toContain('mv "$temp_dir/dist" "$ROOT_DIR/dist"'); - expect(packageHelper).toContain('rm -rf "$ROOT_DIR/dist" >/dev/null 2>&1 || true'); - expect(packageHelper).toContain('mv "$backup_dir" "$ROOT_DIR/dist"'); + expect(packageHelper).toContain('mv "$restore_root/dist" "$backup_dir"'); + expect(packageHelper).toContain('mv "$temp_dir/dist" "$restore_root/dist"'); + expect(packageHelper).toContain('rm -rf "$restore_root/dist" >/dev/null 2>&1 || true'); + expect(packageHelper).toContain('mv "$backup_dir" "$restore_root/dist"'); expect(packageHelper).toContain('docker_e2e_docker_cmd rm -f "$container_id"'); expect(script).not.toContain('container_id="$(docker create "$image")"'); expect(script).not.toContain('docker cp "${container_id}:/app/dist" "$ROOT_DIR/dist"'); @@ -499,6 +527,21 @@ describe("test-install-sh-docker", () => { expect(result.stdout).toContain("ai=old-ai"); }); + it.each(["packages", "ai"] as const)( + "rejects a symlinked %s path before restoring artifacts", + (symlinkEscape) => { + const result = runRestoreLocalDistFixture({ symlinkEscape }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("status=1"); + expect(result.stdout).toContain("root=old-root"); + expect(result.stdout).toContain("ai=external-ai"); + expect(result.stdout).toContain("external=external-ai"); + expect(result.stderr).not.toContain("docker-call="); + expect(result.stderr).toContain("refusing package artifact restore through a symlinked"); + }, + ); + it("fails closed when exact image artifacts fail import closure", () => { const result = spawnSync( "bash", @@ -1069,13 +1112,13 @@ describe("bun global install smoke", () => { ); expect(packageHelper).toContain('"${container_id}:/app/node_modules/@openclaw/ai/dist"'); expect(packageHelper).toContain('"$temp_dir/ai-dist"'); - expect(packageHelper).toContain('mv "$temp_dir/ai-dist" "$ROOT_DIR/packages/ai/dist"'); + expect(packageHelper).toContain('mv "$temp_dir/ai-dist" "$ai_dist_dir"'); expect(packageHelper).toContain("cleanup_restore_package_dist() {"); - expect(packageHelper).toContain('mv "$ROOT_DIR/dist" "$backup_dir"'); - expect(packageHelper).toContain('mv "$temp_dir/dist" "$ROOT_DIR/dist"'); - expect(packageHelper).toContain('mktemp -d "$ROOT_DIR/.package-dist.XXXXXX"'); - expect(packageHelper).toContain('rm -rf "$ROOT_DIR/dist" >/dev/null 2>&1 || true'); - expect(packageHelper).toContain('mv "$backup_dir" "$ROOT_DIR/dist"'); + expect(packageHelper).toContain('mv "$restore_root/dist" "$backup_dir"'); + expect(packageHelper).toContain('mv "$temp_dir/dist" "$restore_root/dist"'); + expect(packageHelper).toContain('mktemp -d "$restore_root/.package-dist.XXXXXX"'); + expect(packageHelper).toContain('rm -rf "$restore_root/dist" >/dev/null 2>&1 || true'); + expect(packageHelper).toContain('mv "$backup_dir" "$restore_root/dist"'); expect(packageHelper).toContain('docker_e2e_docker_cmd rm -f "$container_id"'); expect(script).not.toContain('container_id="$(docker create "$image")"'); expect(script).not.toContain('docker cp "${container_id}:/app/dist" "$ROOT_DIR/dist"');