From ca5e352c53e8794c05339ba6a0b0b8f5ba6bf629 Mon Sep 17 00:00:00 2001 From: Altay Date: Sun, 8 Mar 2026 19:10:48 +0300 Subject: [PATCH] CLI: include commit hash in --version output (#39712) * CLI: include commit hash in --version output * fix(version): harden commit SHA resolution and keep output consistent * CLI: keep install checks compatible with commit-tagged version output * fix(cli): include commit hash in root version fast path * test(cli): allow null commit-hash mocks * Installer: share version parser across install scripts * Installer: avoid sourcing helpers from stdin cwd * CLI: note commit-tagged version output * CLI: anchor commit hash resolution to module root * CLI: harden commit hash resolution * CLI: fix commit hash lookup edge cases * CLI: prefer live git metadata in dev builds * CLI: keep git lookup inside package root * Infra: tolerate invalid moduleUrl hints * CLI: cache baked commit metadata fallbacks * CLI: align changelog attribution with prep gate * CLI: restore changelog contributor credit --------- Co-authored-by: echoVic Co-authored-by: echoVic --- CHANGELOG.md | 1 + .../docker/install-sh-common/cli-verify.sh | 6 + .../docker/install-sh-common/version-parse.sh | 14 + scripts/docker/install-sh-e2e/Dockerfile | 1 + scripts/docker/install-sh-e2e/run.sh | 9 + scripts/docker/install-sh-nonroot/Dockerfile | 1 + scripts/docker/install-sh-smoke/Dockerfile | 1 + scripts/install.sh | 40 +- src/auto-reply/status.ts | 2 +- src/cli/banner.ts | 3 +- src/cli/program/help.test.ts | 26 +- src/cli/program/help.ts | 6 +- src/entry.ts | 8 +- src/entry.version-fast-path.test.ts | 104 +++++ src/infra/git-commit.test.ts | 402 ++++++++++++++++++ src/infra/git-commit.ts | 182 ++++++-- src/infra/openclaw-root.test.ts | 12 + src/infra/openclaw-root.ts | 6 +- src/install-sh-version.test.ts | 121 ++++++ 19 files changed, 903 insertions(+), 42 deletions(-) create mode 100644 scripts/docker/install-sh-common/version-parse.sh create mode 100644 src/entry.version-fast-path.test.ts create mode 100644 src/infra/git-commit.test.ts create mode 100644 src/install-sh-version.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d76ab2c1f4b..64a0571cb7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Docs: https://docs.openclaw.ai - TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7. - Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp. - Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147. +- CLI/install: include the short git commit hash in `openclaw --version` output when metadata is available, and keep installer version checks compatible with the decorated format. (#39712) thanks @sourman. ### Fixes diff --git a/scripts/docker/install-sh-common/cli-verify.sh b/scripts/docker/install-sh-common/cli-verify.sh index 98d08cfe4bf..2781b18cca1 100644 --- a/scripts/docker/install-sh-common/cli-verify.sh +++ b/scripts/docker/install-sh-common/cli-verify.sh @@ -1,5 +1,9 @@ #!/usr/bin/env bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=./version-parse.sh +source "$SCRIPT_DIR/version-parse.sh" + verify_installed_cli() { local package_name="$1" local expected_version="$2" @@ -32,6 +36,8 @@ verify_installed_cli() { installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')" fi + installed_version="$(extract_openclaw_semver "$installed_version")" + echo "cli=$cli_name installed=$installed_version expected=$expected_version" if [[ "$installed_version" != "$expected_version" ]]; then echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2 diff --git a/scripts/docker/install-sh-common/version-parse.sh b/scripts/docker/install-sh-common/version-parse.sh new file mode 100644 index 00000000000..b56c200f47c --- /dev/null +++ b/scripts/docker/install-sh-common/version-parse.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +extract_openclaw_semver() { + local raw="${1:-}" + local parsed="" + parsed="$( + printf '%s\n' "$raw" \ + | tr -d '\r' \ + | grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \ + | head -n 1 \ + || true + )" + printf '%s' "${parsed#v}" +} diff --git a/scripts/docker/install-sh-e2e/Dockerfile b/scripts/docker/install-sh-e2e/Dockerfile index ae7049bd310..26b69b0b7ef 100644 --- a/scripts/docker/install-sh-e2e/Dockerfile +++ b/scripts/docker/install-sh-e2e/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update \ git \ && rm -rf /var/lib/apt/lists/* +COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY run.sh /usr/local/bin/openclaw-install-e2e RUN chmod +x /usr/local/bin/openclaw-install-e2e diff --git a/scripts/docker/install-sh-e2e/run.sh b/scripts/docker/install-sh-e2e/run.sh index 4873436b057..6475fe9a914 100755 --- a/scripts/docker/install-sh-e2e/run.sh +++ b/scripts/docker/install-sh-e2e/run.sh @@ -1,6 +1,14 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VERIFY_HELPER_PATH="/usr/local/install-sh-common/version-parse.sh" +if [[ ! -f "$VERIFY_HELPER_PATH" ]]; then + VERIFY_HELPER_PATH="${SCRIPT_DIR}/../install-sh-common/version-parse.sh" +fi +# shellcheck source=../install-sh-common/version-parse.sh +source "$VERIFY_HELPER_PATH" + INSTALL_URL="${OPENCLAW_INSTALL_URL:-${CLAWDBOT_INSTALL_URL:-https://openclaw.bot/install.sh}}" MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}" @@ -69,6 +77,7 @@ fi echo "==> Verify installed version" INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')" +INSTALLED_VERSION="$(extract_openclaw_semver "$INSTALLED_VERSION")" echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION" if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2 diff --git a/scripts/docker/install-sh-nonroot/Dockerfile b/scripts/docker/install-sh-nonroot/Dockerfile index 2e9c604d3a1..5543ef84882 100644 --- a/scripts/docker/install-sh-nonroot/Dockerfile +++ b/scripts/docker/install-sh-nonroot/Dockerfile @@ -27,6 +27,7 @@ ENV NPM_CONFIG_FUND=false ENV NPM_CONFIG_AUDIT=false COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh +COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot diff --git a/scripts/docker/install-sh-smoke/Dockerfile b/scripts/docker/install-sh-smoke/Dockerfile index be6b3b0f6ee..ee3221607fb 100644 --- a/scripts/docker/install-sh-smoke/Dockerfile +++ b/scripts/docker/install-sh-smoke/Dockerfile @@ -19,6 +19,7 @@ RUN set -eux; \ && rm -rf /var/lib/apt/lists/* COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh +COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke RUN chmod +x /usr/local/bin/openclaw-install-smoke diff --git a/scripts/install.sh b/scripts/install.sh index 70d794b97e3..f7f13490796 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2085,14 +2085,52 @@ run_bootstrap_onboarding_if_needed() { } } +load_install_version_helpers() { + local source_path="${BASH_SOURCE[0]-}" + local script_dir="" + local helper_path="" + if [[ -z "$source_path" || ! -f "$source_path" ]]; then + return 0 + fi + script_dir="$(cd "$(dirname "$source_path")" && pwd 2>/dev/null || true)" + helper_path="${script_dir}/docker/install-sh-common/version-parse.sh" + if [[ -n "$script_dir" && -r "$helper_path" ]]; then + # shellcheck source=docker/install-sh-common/version-parse.sh + source "$helper_path" + fi +} + +load_install_version_helpers + +if ! declare -F extract_openclaw_semver >/dev/null 2>&1; then +# Inline fallback when version-parse.sh could not be sourced (for example, stdin install). +extract_openclaw_semver() { + local raw="${1:-}" + local parsed="" + parsed="$( + printf '%s\n' "$raw" \ + | tr -d '\r' \ + | grep -Eo 'v?[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?(\+[0-9A-Za-z.-]+)?' \ + | head -n 1 \ + || true + )" + printf '%s' "${parsed#v}" +} +fi + resolve_openclaw_version() { local version="" + local raw_version_output="" local claw="${OPENCLAW_BIN:-}" if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then claw="$(command -v openclaw)" fi if [[ -n "$claw" ]]; then - version=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r') + raw_version_output=$("$claw" --version 2>/dev/null | head -n 1 | tr -d '\r') + version="$(extract_openclaw_semver "$raw_version_output")" + if [[ -z "$version" ]]; then + version="$raw_version_output" + fi fi if [[ -z "$version" ]]; then local npm_root="" diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index a08931b1c1c..d4c5e0c18bb 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -655,7 +655,7 @@ export function buildStatusMessage(args: StatusArgs): string { showFallbackAuth ? ` ยท ๐Ÿ”‘ ${activeAuthLabelValue}` : "" } (${fallbackState.reason ?? "selected model unavailable"})` : null; - const commit = resolveCommitHash(); + const commit = resolveCommitHash({ moduleUrl: import.meta.url }); const versionLine = `๐Ÿฆž OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`; const usagePair = formatUsagePair(inputTokens, outputTokens); const cacheLine = formatCacheLine(inputTokens, cacheRead, cacheWrite); diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 4c9e4b7e488..07bc16abfa0 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -57,7 +57,8 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined { } export function formatCliBannerLine(version: string, options: BannerOptions = {}): string { - const commit = options.commit ?? resolveCommitHash({ env: options.env }); + const commit = + options.commit ?? resolveCommitHash({ env: options.env, moduleUrl: import.meta.url }); const commitLabel = commit ?? "unknown"; const tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) }); const rich = options.richTty ?? isRich(); diff --git a/src/cli/program/help.test.ts b/src/cli/program/help.test.ts index 0a68fae5ef6..6acceb5cc41 100644 --- a/src/cli/program/help.test.ts +++ b/src/cli/program/help.test.ts @@ -5,6 +5,7 @@ import type { ProgramContext } from "./context.js"; const hasEmittedCliBannerMock = vi.fn(() => false); const formatCliBannerLineMock = vi.fn(() => "BANNER-LINE"); const formatDocsLinkMock = vi.fn((_path: string, full: string) => `https://${full}`); +const resolveCommitHashMock = vi.fn<() => string | null>(() => "abc1234"); vi.mock("../../terminal/links.js", () => ({ formatDocsLink: formatDocsLinkMock, @@ -26,6 +27,10 @@ vi.mock("../banner.js", () => ({ hasEmittedCliBanner: hasEmittedCliBannerMock, })); +vi.mock("../../infra/git-commit.js", () => ({ + resolveCommitHash: resolveCommitHashMock, +})); + vi.mock("../cli-name.js", () => ({ resolveCliName: () => "openclaw", replaceCliName: (cmd: string) => cmd, @@ -55,6 +60,7 @@ describe("configureProgramHelp", () => { vi.clearAllMocks(); originalArgv = [...process.argv]; hasEmittedCliBannerMock.mockReturnValue(false); + resolveCommitHashMock.mockReturnValue("abc1234"); }); afterEach(() => { @@ -116,7 +122,25 @@ describe("configureProgramHelp", () => { const program = makeProgramWithCommands(); expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0"); - expect(logSpy).toHaveBeenCalledWith("9.9.9-test"); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exitSpy).toHaveBeenCalledWith(0); + + logSpy.mockRestore(); + exitSpy.mockRestore(); + }); + + it("prints version and exits immediately without commit metadata", () => { + process.argv = ["node", "openclaw", "--version"]; + resolveCommitHashMock.mockReturnValue(null); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`exit:${code ?? ""}`); + }) as typeof process.exit); + + const program = makeProgramWithCommands(); + expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0"); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); expect(exitSpy).toHaveBeenCalledWith(0); logSpy.mockRestore(); diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 87ef63d8d2e..c22ea7c8322 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { resolveCommitHash } from "../../infra/git-commit.js"; import { formatDocsLink } from "../../terminal/links.js"; import { isRich, theme } from "../../terminal/theme.js"; import { escapeRegExp } from "../../utils.js"; @@ -109,7 +110,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { hasFlag(process.argv, "--version") || hasRootVersionAlias(process.argv) ) { - console.log(ctx.programVersion); + const commit = resolveCommitHash({ moduleUrl: import.meta.url }); + console.log( + commit ? `OpenClaw ${ctx.programVersion} (${commit})` : `OpenClaw ${ctx.programVersion}`, + ); process.exit(0); } diff --git a/src/entry.ts b/src/entry.ts index 25f91d62921..50b08029d05 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -127,9 +127,11 @@ if ( if (!isRootVersionInvocation(argv)) { return false; } - import("./version.js") - .then(({ VERSION }) => { - console.log(VERSION); + Promise.all([import("./version.js"), import("./infra/git-commit.js")]) + .then(([{ VERSION }, { resolveCommitHash }]) => { + const commit = resolveCommitHash({ moduleUrl: import.meta.url }); + console.log(commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`); + process.exit(0); }) .catch((error) => { console.error( diff --git a/src/entry.version-fast-path.test.ts b/src/entry.version-fast-path.test.ts new file mode 100644 index 00000000000..a7aa0bad672 --- /dev/null +++ b/src/entry.version-fast-path.test.ts @@ -0,0 +1,104 @@ +import process from "node:process"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const applyCliProfileEnvMock = vi.hoisted(() => vi.fn()); +const attachChildProcessBridgeMock = vi.hoisted(() => vi.fn()); +const installProcessWarningFilterMock = vi.hoisted(() => vi.fn()); +const isMainModuleMock = vi.hoisted(() => vi.fn(() => true)); +const isRootHelpInvocationMock = vi.hoisted(() => vi.fn(() => false)); +const isRootVersionInvocationMock = vi.hoisted(() => vi.fn(() => true)); +const normalizeEnvMock = vi.hoisted(() => vi.fn()); +const normalizeWindowsArgvMock = vi.hoisted(() => vi.fn((argv: string[]) => argv)); +const parseCliProfileArgsMock = vi.hoisted(() => vi.fn((argv: string[]) => ({ ok: true, argv }))); +const resolveCommitHashMock = vi.hoisted(() => vi.fn<() => string | null>(() => "abc1234")); +const shouldSkipRespawnForArgvMock = vi.hoisted(() => vi.fn(() => true)); + +vi.mock("./cli/argv.js", () => ({ + isRootHelpInvocation: isRootHelpInvocationMock, + isRootVersionInvocation: isRootVersionInvocationMock, +})); + +vi.mock("./cli/profile.js", () => ({ + applyCliProfileEnv: applyCliProfileEnvMock, + parseCliProfileArgs: parseCliProfileArgsMock, +})); + +vi.mock("./cli/respawn-policy.js", () => ({ + shouldSkipRespawnForArgv: shouldSkipRespawnForArgvMock, +})); + +vi.mock("./cli/windows-argv.js", () => ({ + normalizeWindowsArgv: normalizeWindowsArgvMock, +})); + +vi.mock("./infra/env.js", () => ({ + isTruthyEnvValue: () => false, + normalizeEnv: normalizeEnvMock, +})); + +vi.mock("./infra/git-commit.js", () => ({ + resolveCommitHash: resolveCommitHashMock, +})); + +vi.mock("./infra/is-main.js", () => ({ + isMainModule: isMainModuleMock, +})); + +vi.mock("./infra/warning-filter.js", () => ({ + installProcessWarningFilter: installProcessWarningFilterMock, +})); + +vi.mock("./process/child-process-bridge.js", () => ({ + attachChildProcessBridge: attachChildProcessBridgeMock, +})); + +vi.mock("./version.js", () => ({ + VERSION: "9.9.9-test", +})); + +describe("entry root version fast path", () => { + let originalArgv: string[]; + let exitSpy: ReturnType; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + originalArgv = [...process.argv]; + process.argv = ["node", "openclaw", "--version"]; + exitSpy = vi + .spyOn(process, "exit") + .mockImplementation(((_code?: number) => undefined) as typeof process.exit); + }); + + afterEach(() => { + process.argv = originalArgv; + exitSpy.mockRestore(); + }); + + it("prints commit-tagged version output when commit metadata is available", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await import("./entry.js"); + + await vi.waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + logSpy.mockRestore(); + }); + + it("falls back to plain version output when commit metadata is unavailable", async () => { + resolveCommitHashMock.mockReturnValueOnce(null); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await import("./entry.js"); + + await vi.waitFor(() => { + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + logSpy.mockRestore(); + }); +}); diff --git a/src/infra/git-commit.test.ts b/src/infra/git-commit.test.ts new file mode 100644 index 00000000000..d0905e8fb84 --- /dev/null +++ b/src/infra/git-commit.test.ts @@ -0,0 +1,402 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function makeTempDir(label: string): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`)); +} + +async function makeFakeGitRepo( + root: string, + options: { + head: string; + refs?: Record; + gitdir?: string; + commondir?: string; + }, +) { + await fs.mkdir(root, { recursive: true }); + const gitdir = options.gitdir ?? path.join(root, ".git"); + if (options.gitdir) { + await fs.writeFile(path.join(root, ".git"), `gitdir: ${options.gitdir}\n`, "utf-8"); + } else { + await fs.mkdir(gitdir, { recursive: true }); + } + await fs.mkdir(gitdir, { recursive: true }); + await fs.writeFile(path.join(gitdir, "HEAD"), options.head, "utf-8"); + if (options.commondir) { + await fs.writeFile(path.join(gitdir, "commondir"), options.commondir, "utf-8"); + } + for (const [refPath, commit] of Object.entries(options.refs ?? {})) { + const targetPath = path.join(gitdir, refPath); + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await fs.writeFile(targetPath, `${commit}\n`, "utf-8"); + } +} + +describe("git commit resolution", () => { + const originalCwd = process.cwd(); + + afterEach(() => { + process.chdir(originalCwd); + vi.resetModules(); + }); + + it("resolves commit metadata from the caller module root instead of the caller cwd", async () => { + const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: originalCwd, + encoding: "utf-8", + }).trim(); + + const temp = await makeTempDir("git-commit-cwd"); + const otherRepo = path.join(temp, "other"); + await fs.mkdir(otherRepo, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: otherRepo }); + await fs.writeFile(path.join(otherRepo, "note.txt"), "x\n", "utf-8"); + execFileSync("git", ["add", "note.txt"], { cwd: otherRepo }); + execFileSync( + "git", + ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"], + { cwd: otherRepo }, + ); + const otherHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: otherRepo, + encoding: "utf-8", + }).trim(); + + process.chdir(otherRepo); + const { resolveCommitHash } = await import("./git-commit.js"); + const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; + + expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).toBe(repoHead); + expect(resolveCommitHash({ moduleUrl: entryModuleUrl })).not.toBe(otherHead); + }); + + it("prefers live git metadata over stale build info in a real checkout", async () => { + const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: originalCwd, + encoding: "utf-8", + }).trim(); + + vi.doMock("node:module", () => ({ + createRequire: () => { + return (specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }; + }, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + const entryModuleUrl = pathToFileURL(path.join(originalCwd, "src", "entry.ts")).href; + + expect(resolveCommitHash({ moduleUrl: entryModuleUrl, env: {} })).toBe(repoHead); + + vi.doUnmock("node:module"); + }); + + it("caches build-info fallback results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-build-info-cache"); + const moduleRequire = vi.fn((specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + return { commit: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }); + + vi.doMock("node:module", () => ({ + createRequire: () => moduleRequire, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); + const firstCallRequires = moduleRequire.mock.calls.length; + expect(firstCallRequires).toBeGreaterThan(0); + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("deadbee"); + expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); + + vi.doUnmock("node:module"); + }); + + it("caches package.json fallback results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-package-json-cache"); + const moduleRequire = vi.fn((specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + } + if (specifier === "../../package.json") { + return { gitHead: "badc0ffee0ddf00d" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }); + + vi.doMock("node:module", () => ({ + createRequire: () => moduleRequire, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); + const firstCallRequires = moduleRequire.mock.calls.length; + expect(firstCallRequires).toBeGreaterThan(0); + expect(resolveCommitHash({ cwd: temp, env: {} })).toBe("badc0ff"); + expect(moduleRequire.mock.calls.length).toBe(firstCallRequires); + + vi.doUnmock("node:module"); + }); + + it("treats invalid moduleUrl inputs as a fallback hint instead of throwing", async () => { + const repoHead = execFileSync("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: originalCwd, + encoding: "utf-8", + }).trim(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(() => + resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} }), + ).not.toThrow(); + expect(resolveCommitHash({ moduleUrl: "not-a-file-url", cwd: originalCwd, env: {} })).toBe( + repoHead, + ); + }); + + it("does not walk out of the openclaw package into a host repo", async () => { + const temp = await makeTempDir("git-commit-package-boundary"); + const hostRepo = path.join(temp, "host"); + await fs.mkdir(hostRepo, { recursive: true }); + execFileSync("git", ["init", "-q"], { cwd: hostRepo }); + await fs.writeFile(path.join(hostRepo, "host.txt"), "x\n", "utf-8"); + execFileSync("git", ["add", "host.txt"], { cwd: hostRepo }); + execFileSync( + "git", + ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-q", "-m", "init"], + { cwd: hostRepo }, + ); + + const packageRoot = path.join(hostRepo, "node_modules", "openclaw"); + await fs.mkdir(path.join(packageRoot, "dist"), { recursive: true }); + await fs.writeFile( + path.join(packageRoot, "package.json"), + JSON.stringify({ name: "openclaw", version: "2026.3.8" }), + "utf-8", + ); + const moduleUrl = pathToFileURL(path.join(packageRoot, "dist", "entry.js")).href; + + vi.doMock("node:module", () => ({ + createRequire: () => { + return (specifier: string) => { + if (specifier === "../build-info.json" || specifier === "./build-info.json") { + return { commit: "feedfacefeedfacefeedfacefeedfacefeedface" }; + } + if (specifier === "../../package.json") { + return { name: "openclaw", version: "2026.3.8", gitHead: "badc0ffee0ddf00d" }; + } + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }; + }, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ moduleUrl, cwd: packageRoot, env: {} })).toBe("feedfac"); + + vi.doUnmock("node:module"); + }); + + it("caches git lookups per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-cache"); + const repoA = path.join(temp, "repo-a"); + const repoB = path.join(temp, "repo-b"); + await makeFakeGitRepo(repoA, { + head: "0123456789abcdef0123456789abcdef01234567\n", + }); + await makeFakeGitRepo(repoB, { + head: "89abcdef0123456789abcdef0123456789abcdef\n", + }); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); + expect(resolveCommitHash({ cwd: repoB, env: {} })).toBe("89abcde"); + expect(resolveCommitHash({ cwd: repoA, env: {} })).toBe("0123456"); + }); + + it("caches deterministic null results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-null-cache"); + const repoRoot = path.join(temp, "repo"); + await makeFakeGitRepo(repoRoot, { + head: "not-a-commit\n", + }); + + const actualFs = await vi.importActual("node:fs"); + const readFileSyncSpy = vi.fn(actualFs.readFileSync); + vi.doMock("node:fs", () => ({ + ...actualFs, + default: { + ...actualFs, + readFileSync: readFileSyncSpy, + }, + readFileSync: readFileSyncSpy, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + const firstCallReads = readFileSyncSpy.mock.calls.length; + expect(firstCallReads).toBeGreaterThan(0); + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + expect(readFileSyncSpy.mock.calls.length).toBe(firstCallReads); + + vi.doUnmock("node:fs"); + }); + + it("caches caught null fallback results per resolved search directory", async () => { + const temp = await makeTempDir("git-commit-caught-null-cache"); + const repoRoot = path.join(temp, "repo"); + await makeFakeGitRepo(repoRoot, { + head: "0123456789abcdef0123456789abcdef01234567\n", + }); + const headPath = path.join(repoRoot, ".git", "HEAD"); + + const actualFs = await vi.importActual("node:fs"); + const readFileSyncSpy = vi.fn((filePath: string, ...args: unknown[]) => { + if (path.resolve(filePath) === path.resolve(headPath)) { + const error = Object.assign(new Error(`EACCES: permission denied, open '${filePath}'`), { + code: "EACCES", + }); + throw error; + } + return Reflect.apply(actualFs.readFileSync, actualFs, [filePath, ...args]); + }); + vi.doMock("node:fs", () => ({ + ...actualFs, + default: { + ...actualFs, + readFileSync: readFileSyncSpy, + }, + readFileSync: readFileSyncSpy, + })); + vi.doMock("node:module", () => ({ + createRequire: () => { + return (specifier: string) => { + throw Object.assign(new Error(`Cannot find module ${specifier}`), { + code: "MODULE_NOT_FOUND", + }); + }; + }, + })); + vi.resetModules(); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + const headReadCount = () => + readFileSyncSpy.mock.calls.filter(([filePath]) => path.resolve(filePath) === headPath).length; + const firstCallReads = headReadCount(); + expect(firstCallReads).toBe(2); + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBeNull(); + expect(headReadCount()).toBe(firstCallReads); + + vi.doUnmock("node:fs"); + vi.doUnmock("node:module"); + }); + + it("formats env-provided commit strings consistently", async () => { + const temp = await makeTempDir("git-commit-env"); + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "ABCDEF0123456789" } })).toBe( + "abcdef0", + ); + expect( + resolveCommitHash({ cwd: temp, env: { GIT_SHA: "commit abcdef0123456789 dirty" } }), + ).toBe("abcdef0"); + expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "not-a-sha" } })).toBeNull(); + expect(resolveCommitHash({ cwd: temp, env: { GIT_COMMIT: "" } })).toBeNull(); + }); + + it("rejects unsafe HEAD refs and accepts valid refs", async () => { + const temp = await makeTempDir("git-commit-refs"); + const { resolveCommitHash } = await import("./git-commit.js"); + + const absoluteRepo = path.join(temp, "absolute"); + await makeFakeGitRepo(absoluteRepo, { head: "ref: /tmp/evil\n" }); + expect(resolveCommitHash({ cwd: absoluteRepo, env: {} })).toBeNull(); + + const traversalRepo = path.join(temp, "traversal"); + await makeFakeGitRepo(traversalRepo, { head: "ref: refs/heads/../evil\n" }); + expect(resolveCommitHash({ cwd: traversalRepo, env: {} })).toBeNull(); + + const invalidPrefixRepo = path.join(temp, "invalid-prefix"); + await makeFakeGitRepo(invalidPrefixRepo, { head: "ref: heads/main\n" }); + expect(resolveCommitHash({ cwd: invalidPrefixRepo, env: {} })).toBeNull(); + + const validRepo = path.join(temp, "valid"); + await makeFakeGitRepo(validRepo, { + head: "ref: refs/heads/main\n", + refs: { + "refs/heads/main": "fedcba9876543210fedcba9876543210fedcba98", + }, + }); + expect(resolveCommitHash({ cwd: validRepo, env: {} })).toBe("fedcba9"); + }); + + it("resolves refs from the git commondir in worktree layouts", async () => { + const temp = await makeTempDir("git-commit-worktree"); + const repoRoot = path.join(temp, "repo"); + const worktreeGitDir = path.join(temp, "worktree-git"); + const commonGitDir = path.join(temp, "common-git"); + await fs.mkdir(commonGitDir, { recursive: true }); + const refPath = path.join(commonGitDir, "refs", "heads", "main"); + await fs.mkdir(path.dirname(refPath), { recursive: true }); + await fs.writeFile(refPath, "76543210fedcba9876543210fedcba9876543210\n", "utf-8"); + await makeFakeGitRepo(repoRoot, { + gitdir: worktreeGitDir, + head: "ref: refs/heads/main\n", + commondir: "../common-git", + }); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("7654321"); + }); + + it("reads full HEAD refs before parsing long branch names", async () => { + const temp = await makeTempDir("git-commit-long-head"); + const repoRoot = path.join(temp, "repo"); + const longRefName = `refs/heads/${"segment/".repeat(40)}main`; + await makeFakeGitRepo(repoRoot, { + head: `ref: ${longRefName}\n`, + refs: { + [longRefName]: "0123456789abcdef0123456789abcdef01234567", + }, + }); + + const { resolveCommitHash } = await import("./git-commit.js"); + + expect(resolveCommitHash({ cwd: repoRoot, env: {} })).toBe("0123456"); + }); +}); diff --git a/src/infra/git-commit.ts b/src/infra/git-commit.ts index 44778ce5a05..74e22156e1b 100644 --- a/src/infra/git-commit.ts +++ b/src/infra/git-commit.ts @@ -1,7 +1,9 @@ import fs from "node:fs"; import { createRequire } from "node:module"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { resolveGitHeadPath } from "./git-root.js"; +import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; const formatCommit = (value?: string | null) => { if (!value) { @@ -11,10 +13,127 @@ const formatCommit = (value?: string | null) => { if (!trimmed) { return null; } - return trimmed.length > 7 ? trimmed.slice(0, 7) : trimmed; + const match = trimmed.match(/[0-9a-fA-F]{7,40}/); + if (!match) { + return null; + } + return match[0].slice(0, 7).toLowerCase(); }; -let cachedCommit: string | null | undefined; +const cachedGitCommitBySearchDir = new Map(); + +function isMissingPathError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR"; +} + +const resolveCommitSearchDir = (options: { cwd?: string; moduleUrl?: string }) => { + if (options.cwd) { + return path.resolve(options.cwd); + } + if (options.moduleUrl) { + try { + return path.dirname(fileURLToPath(options.moduleUrl)); + } catch { + // moduleUrl is not a valid file:// URL; fall back to process.cwd(). + } + } + return process.cwd(); +}; + +/** Read at most `limit` bytes from a file to avoid unbounded reads. */ +const safeReadFilePrefix = (filePath: string, limit = 256) => { + const fd = fs.openSync(filePath, "r"); + try { + const buf = Buffer.alloc(limit); + const bytesRead = fs.readSync(fd, buf, 0, limit, 0); + return buf.subarray(0, bytesRead).toString("utf-8"); + } finally { + fs.closeSync(fd); + } +}; + +const cacheGitCommit = (searchDir: string, commit: string | null) => { + cachedGitCommitBySearchDir.set(searchDir, commit); + return commit; +}; + +const resolveGitLookupDepth = (searchDir: string, packageRoot: string | null) => { + if (!packageRoot) { + return undefined; + } + const relative = path.relative(packageRoot, searchDir); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return undefined; + } + const depth = relative ? relative.split(path.sep).filter(Boolean).length : 0; + return depth + 1; +}; + +const readCommitFromGit = ( + searchDir: string, + packageRoot: string | null, +): string | null | undefined => { + const headPath = resolveGitHeadPath(searchDir, { + maxDepth: resolveGitLookupDepth(searchDir, packageRoot), + }); + if (!headPath) { + return undefined; + } + const head = fs.readFileSync(headPath, "utf-8").trim(); + if (!head) { + return null; + } + if (head.startsWith("ref:")) { + const ref = head.replace(/^ref:\s*/i, "").trim(); + const refPath = resolveRefPath(headPath, ref); + if (!refPath) { + return null; + } + const refHash = safeReadFilePrefix(refPath).trim(); + return formatCommit(refHash); + } + return formatCommit(head); +}; + +const resolveGitRefsBase = (headPath: string) => { + const gitDir = path.dirname(headPath); + try { + const commonDir = safeReadFilePrefix(path.join(gitDir, "commondir")).trim(); + if (commonDir) { + return path.resolve(gitDir, commonDir); + } + } catch (error) { + if (!isMissingPathError(error)) { + throw error; + } + // Plain repo git dirs do not have commondir. + } + return gitDir; +}; + +/** Safely resolve a git ref path, rejecting traversal attacks from a crafted HEAD file. */ +const resolveRefPath = (headPath: string, ref: string) => { + if (!ref.startsWith("refs/")) { + return null; + } + if (path.isAbsolute(ref)) { + return null; + } + if (ref.split(/[/]/).includes("..")) { + return null; + } + const refsBase = resolveGitRefsBase(headPath); + const resolved = path.resolve(refsBase, ref); + const rel = path.relative(refsBase, resolved); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { + return null; + } + return resolved; +}; const readCommitFromPackageJson = () => { try { @@ -52,49 +171,46 @@ const readCommitFromBuildInfo = () => { } }; -export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => { - if (cachedCommit !== undefined) { - return cachedCommit; - } +export const resolveCommitHash = ( + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + moduleUrl?: string; + } = {}, +) => { const env = options.env ?? process.env; const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); const normalized = formatCommit(envCommit); if (normalized) { - cachedCommit = normalized; - return cachedCommit; + return normalized; + } + const searchDir = resolveCommitSearchDir(options); + if (cachedGitCommitBySearchDir.has(searchDir)) { + return cachedGitCommitBySearchDir.get(searchDir) ?? null; + } + const packageRoot = resolveOpenClawPackageRootSync({ + cwd: options.cwd, + moduleUrl: options.moduleUrl, + }); + try { + const gitCommit = readCommitFromGit(searchDir, packageRoot); + if (gitCommit !== undefined) { + return cacheGitCommit(searchDir, gitCommit); + } + } catch { + // Fall through to baked metadata for packaged installs that are not in a live checkout. } const buildInfoCommit = readCommitFromBuildInfo(); if (buildInfoCommit) { - cachedCommit = buildInfoCommit; - return cachedCommit; + return cacheGitCommit(searchDir, buildInfoCommit); } const pkgCommit = readCommitFromPackageJson(); if (pkgCommit) { - cachedCommit = pkgCommit; - return cachedCommit; + return cacheGitCommit(searchDir, pkgCommit); } try { - const headPath = resolveGitHeadPath(options.cwd ?? process.cwd()); - if (!headPath) { - cachedCommit = null; - return cachedCommit; - } - const head = fs.readFileSync(headPath, "utf-8").trim(); - if (!head) { - cachedCommit = null; - return cachedCommit; - } - if (head.startsWith("ref:")) { - const ref = head.replace(/^ref:\s*/i, "").trim(); - const refPath = path.resolve(path.dirname(headPath), ref); - const refHash = fs.readFileSync(refPath, "utf-8").trim(); - cachedCommit = formatCommit(refHash); - return cachedCommit; - } - cachedCommit = formatCommit(head); - return cachedCommit; + return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null); } catch { - cachedCommit = null; - return cachedCommit; + return cacheGitCommit(searchDir, null); } }; diff --git a/src/infra/openclaw-root.test.ts b/src/infra/openclaw-root.test.ts index 9caf5cf5d22..85d24512468 100644 --- a/src/infra/openclaw-root.test.ts +++ b/src/infra/openclaw-root.test.ts @@ -141,6 +141,18 @@ describe("resolveOpenClawPackageRoot", () => { expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot); }); + it("ignores invalid moduleUrl values and falls back to cwd", async () => { + const pkgRoot = fx("invalid-moduleurl"); + setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" })); + + expect(resolveOpenClawPackageRootSync({ moduleUrl: "not-a-file-url", cwd: pkgRoot })).toBe( + pkgRoot, + ); + await expect( + resolveOpenClawPackageRoot({ moduleUrl: "not-a-file-url", cwd: pkgRoot }), + ).resolves.toBe(pkgRoot); + }); + it("returns null for non-openclaw package roots", async () => { const pkgRoot = fx("not-openclaw"); setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" })); diff --git a/src/infra/openclaw-root.ts b/src/infra/openclaw-root.ts index 5d48c6cb017..55b6bf7b91a 100644 --- a/src/infra/openclaw-root.ts +++ b/src/infra/openclaw-root.ts @@ -116,7 +116,11 @@ function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: strin const candidates: string[] = []; if (opts.moduleUrl) { - candidates.push(path.dirname(fileURLToPath(opts.moduleUrl))); + try { + candidates.push(path.dirname(fileURLToPath(opts.moduleUrl))); + } catch { + // Ignore invalid file:// URLs and keep other package-root hints. + } } if (opts.argv1) { candidates.push(...candidateDirsFromArgv1(opts.argv1)); diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts new file mode 100644 index 00000000000..4a7135925b8 --- /dev/null +++ b/src/install-sh-version.test.ts @@ -0,0 +1,121 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +function withFakeCli(versionOutput: string): { root: string; cliPath: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-sh-")); + const cliPath = path.join(root, "openclaw"); + const escapedOutput = versionOutput.replace(/'/g, "'\\''"); + fs.writeFileSync( + cliPath, + `#!/usr/bin/env bash +printf '%s\n' '${escapedOutput}' +`, + "utf-8", + ); + fs.chmodSync(cliPath, 0o755); + return { root, cliPath }; +} + +function resolveVersionFromInstaller(cliPath: string): string { + const installerPath = path.join(process.cwd(), "scripts", "install.sh"); + const output = execFileSync( + "bash", + [ + "-lc", + `source "${installerPath}" >/dev/null 2>&1 +OPENCLAW_BIN="$FAKE_OPENCLAW_BIN" +resolve_openclaw_version`, + ], + { + cwd: process.cwd(), + encoding: "utf-8", + env: { + ...process.env, + FAKE_OPENCLAW_BIN: cliPath, + OPENCLAW_INSTALL_SH_NO_RUN: "1", + }, + }, + ); + return output.trim(); +} + +function resolveVersionFromInstallerViaStdin(cliPath: string, cwd: string): string { + const installerPath = path.join(process.cwd(), "scripts", "install.sh"); + const installerSource = fs.readFileSync(installerPath, "utf-8"); + const output = execFileSync("bash", [], { + cwd, + encoding: "utf-8", + input: `${installerSource} +OPENCLAW_BIN="$FAKE_OPENCLAW_BIN" +resolve_openclaw_version +`, + env: { + ...process.env, + FAKE_OPENCLAW_BIN: cliPath, + OPENCLAW_INSTALL_SH_NO_RUN: "1", + }, + }); + return output.trim(); +} + +describe("install.sh version resolution", () => { + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.runIf(process.platform !== "win32")( + "extracts the semantic version from decorated CLI output", + () => { + const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)"); + tempRoots.push(fixture.root); + + expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("2026.3.8"); + }, + ); + + it.runIf(process.platform !== "win32")( + "falls back to raw output when no semantic version is present", + () => { + const fixture = withFakeCli("OpenClaw dev's build"); + tempRoots.push(fixture.root); + + expect(resolveVersionFromInstaller(fixture.cliPath)).toBe("OpenClaw dev's build"); + }, + ); + + it.runIf(process.platform !== "win32")( + "does not source version helpers from cwd when installer runs via stdin", + () => { + const fixture = withFakeCli("OpenClaw 2026.3.8 (abcdef0)"); + tempRoots.push(fixture.root); + + const hostileCwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-install-stdin-")); + tempRoots.push(hostileCwd); + const hostileHelper = path.join( + hostileCwd, + "docker", + "install-sh-common", + "version-parse.sh", + ); + fs.mkdirSync(path.dirname(hostileHelper), { recursive: true }); + fs.writeFileSync( + hostileHelper, + `#!/usr/bin/env bash +extract_openclaw_semver() { + printf '%s' 'poisoned' +} +`, + "utf-8", + ); + + expect(resolveVersionFromInstallerViaStdin(fixture.cliPath, hostileCwd)).toBe("2026.3.8"); + }, + ); +});