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 <echovic@163.com>
Co-authored-by: echoVic <echoVic@users.noreply.github.com>
This commit is contained in:
Altay
2026-03-08 19:10:48 +03:00
committed by GitHub
parent c942655451
commit ca5e352c53
19 changed files with 903 additions and 42 deletions

View File

@@ -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. - 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. - 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. - 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 ### Fixes

View File

@@ -1,5 +1,9 @@
#!/usr/bin/env bash #!/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() { verify_installed_cli() {
local package_name="$1" local package_name="$1"
local expected_version="$2" 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')" installed_version="$(node "$entry_path" --version 2>/dev/null | head -n 1 | tr -d '\r')"
fi fi
installed_version="$(extract_openclaw_semver "$installed_version")"
echo "cli=$cli_name installed=$installed_version expected=$expected_version" echo "cli=$cli_name installed=$installed_version expected=$expected_version"
if [[ "$installed_version" != "$expected_version" ]]; then if [[ "$installed_version" != "$expected_version" ]]; then
echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2 echo "ERROR: expected ${cli_name}@${expected_version}, got ${cli_name}@${installed_version}" >&2

View File

@@ -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}"
}

View File

@@ -8,6 +8,7 @@ RUN apt-get update \
git \ git \
&& rm -rf /var/lib/apt/lists/* && 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 COPY run.sh /usr/local/bin/openclaw-install-e2e
RUN chmod +x /usr/local/bin/openclaw-install-e2e RUN chmod +x /usr/local/bin/openclaw-install-e2e

View File

@@ -1,6 +1,14 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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}}" 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 MODELS_MODE="${OPENCLAW_E2E_MODELS:-${CLAWDBOT_E2E_MODELS:-both}}" # both|openai|anthropic
INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}" INSTALL_TAG="${OPENCLAW_INSTALL_TAG:-${CLAWDBOT_INSTALL_TAG:-latest}}"
@@ -69,6 +77,7 @@ fi
echo "==> Verify installed version" echo "==> Verify installed version"
INSTALLED_VERSION="$(openclaw --version 2>/dev/null | head -n 1 | tr -d '\r')" 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" echo "installed=$INSTALLED_VERSION expected=$EXPECTED_VERSION"
if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then if [[ "$INSTALLED_VERSION" != "$EXPECTED_VERSION" ]]; then
echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2 echo "ERROR: expected openclaw@$EXPECTED_VERSION, got openclaw@$INSTALLED_VERSION" >&2

View File

@@ -27,6 +27,7 @@ ENV NPM_CONFIG_FUND=false
ENV NPM_CONFIG_AUDIT=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/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 COPY install-sh-nonroot/run.sh /usr/local/bin/openclaw-install-nonroot
RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot RUN sudo chmod +x /usr/local/bin/openclaw-install-nonroot

View File

@@ -19,6 +19,7 @@ RUN set -eux; \
&& rm -rf /var/lib/apt/lists/* && 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/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 COPY install-sh-smoke/run.sh /usr/local/bin/openclaw-install-smoke
RUN chmod +x /usr/local/bin/openclaw-install-smoke RUN chmod +x /usr/local/bin/openclaw-install-smoke

View File

@@ -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() { resolve_openclaw_version() {
local version="" local version=""
local raw_version_output=""
local claw="${OPENCLAW_BIN:-}" local claw="${OPENCLAW_BIN:-}"
if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then if [[ -z "$claw" ]] && command -v openclaw &> /dev/null; then
claw="$(command -v openclaw)" claw="$(command -v openclaw)"
fi fi
if [[ -n "$claw" ]]; then 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 fi
if [[ -z "$version" ]]; then if [[ -z "$version" ]]; then
local npm_root="" local npm_root=""

View File

@@ -655,7 +655,7 @@ export function buildStatusMessage(args: StatusArgs): string {
showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : "" showFallbackAuth ? ` · 🔑 ${activeAuthLabelValue}` : ""
} (${fallbackState.reason ?? "selected model unavailable"})` } (${fallbackState.reason ?? "selected model unavailable"})`
: null; : null;
const commit = resolveCommitHash(); const commit = resolveCommitHash({ moduleUrl: import.meta.url });
const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`; const versionLine = `🦞 OpenClaw ${VERSION}${commit ? ` (${commit})` : ""}`;
const usagePair = formatUsagePair(inputTokens, outputTokens); const usagePair = formatUsagePair(inputTokens, outputTokens);
const cacheLine = formatCacheLine(inputTokens, cacheRead, cacheWrite); const cacheLine = formatCacheLine(inputTokens, cacheRead, cacheWrite);

View File

@@ -57,7 +57,8 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined {
} }
export function formatCliBannerLine(version: string, options: BannerOptions = {}): string { 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 commitLabel = commit ?? "unknown";
const tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) }); const tagline = pickTagline({ ...options, mode: resolveTaglineMode(options) });
const rich = options.richTty ?? isRich(); const rich = options.richTty ?? isRich();

View File

@@ -5,6 +5,7 @@ import type { ProgramContext } from "./context.js";
const hasEmittedCliBannerMock = vi.fn(() => false); const hasEmittedCliBannerMock = vi.fn(() => false);
const formatCliBannerLineMock = vi.fn(() => "BANNER-LINE"); const formatCliBannerLineMock = vi.fn(() => "BANNER-LINE");
const formatDocsLinkMock = vi.fn((_path: string, full: string) => `https://${full}`); const formatDocsLinkMock = vi.fn((_path: string, full: string) => `https://${full}`);
const resolveCommitHashMock = vi.fn<() => string | null>(() => "abc1234");
vi.mock("../../terminal/links.js", () => ({ vi.mock("../../terminal/links.js", () => ({
formatDocsLink: formatDocsLinkMock, formatDocsLink: formatDocsLinkMock,
@@ -26,6 +27,10 @@ vi.mock("../banner.js", () => ({
hasEmittedCliBanner: hasEmittedCliBannerMock, hasEmittedCliBanner: hasEmittedCliBannerMock,
})); }));
vi.mock("../../infra/git-commit.js", () => ({
resolveCommitHash: resolveCommitHashMock,
}));
vi.mock("../cli-name.js", () => ({ vi.mock("../cli-name.js", () => ({
resolveCliName: () => "openclaw", resolveCliName: () => "openclaw",
replaceCliName: (cmd: string) => cmd, replaceCliName: (cmd: string) => cmd,
@@ -55,6 +60,7 @@ describe("configureProgramHelp", () => {
vi.clearAllMocks(); vi.clearAllMocks();
originalArgv = [...process.argv]; originalArgv = [...process.argv];
hasEmittedCliBannerMock.mockReturnValue(false); hasEmittedCliBannerMock.mockReturnValue(false);
resolveCommitHashMock.mockReturnValue("abc1234");
}); });
afterEach(() => { afterEach(() => {
@@ -116,7 +122,25 @@ describe("configureProgramHelp", () => {
const program = makeProgramWithCommands(); const program = makeProgramWithCommands();
expect(() => configureProgramHelp(program, testProgramContext)).toThrow("exit:0"); 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); expect(exitSpy).toHaveBeenCalledWith(0);
logSpy.mockRestore(); logSpy.mockRestore();

View File

@@ -1,4 +1,5 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { resolveCommitHash } from "../../infra/git-commit.js";
import { formatDocsLink } from "../../terminal/links.js"; import { formatDocsLink } from "../../terminal/links.js";
import { isRich, theme } from "../../terminal/theme.js"; import { isRich, theme } from "../../terminal/theme.js";
import { escapeRegExp } from "../../utils.js"; import { escapeRegExp } from "../../utils.js";
@@ -109,7 +110,10 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
hasFlag(process.argv, "--version") || hasFlag(process.argv, "--version") ||
hasRootVersionAlias(process.argv) 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); process.exit(0);
} }

View File

@@ -127,9 +127,11 @@ if (
if (!isRootVersionInvocation(argv)) { if (!isRootVersionInvocation(argv)) {
return false; return false;
} }
import("./version.js") Promise.all([import("./version.js"), import("./infra/git-commit.js")])
.then(({ VERSION }) => { .then(([{ VERSION }, { resolveCommitHash }]) => {
console.log(VERSION); const commit = resolveCommitHash({ moduleUrl: import.meta.url });
console.log(commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`);
process.exit(0);
}) })
.catch((error) => { .catch((error) => {
console.error( console.error(

View File

@@ -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<typeof vi.spyOn>;
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();
});
});

View File

@@ -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<string> {
return fs.mkdtemp(path.join(os.tmpdir(), `openclaw-${label}-`));
}
async function makeFakeGitRepo(
root: string,
options: {
head: string;
refs?: Record<string, string>;
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<typeof import("node:fs")>("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<typeof import("node:fs")>("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");
});
});

View File

@@ -1,7 +1,9 @@
import fs from "node:fs"; import fs from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url";
import { resolveGitHeadPath } from "./git-root.js"; import { resolveGitHeadPath } from "./git-root.js";
import { resolveOpenClawPackageRootSync } from "./openclaw-root.js";
const formatCommit = (value?: string | null) => { const formatCommit = (value?: string | null) => {
if (!value) { if (!value) {
@@ -11,10 +13,127 @@ const formatCommit = (value?: string | null) => {
if (!trimmed) { if (!trimmed) {
return null; 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<string, string | null>();
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 = () => { const readCommitFromPackageJson = () => {
try { try {
@@ -52,49 +171,46 @@ const readCommitFromBuildInfo = () => {
} }
}; };
export const resolveCommitHash = (options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) => { export const resolveCommitHash = (
if (cachedCommit !== undefined) { options: {
return cachedCommit; cwd?: string;
} env?: NodeJS.ProcessEnv;
moduleUrl?: string;
} = {},
) => {
const env = options.env ?? process.env; const env = options.env ?? process.env;
const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim(); const envCommit = env.GIT_COMMIT?.trim() || env.GIT_SHA?.trim();
const normalized = formatCommit(envCommit); const normalized = formatCommit(envCommit);
if (normalized) { if (normalized) {
cachedCommit = normalized; return normalized;
return cachedCommit; }
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(); const buildInfoCommit = readCommitFromBuildInfo();
if (buildInfoCommit) { if (buildInfoCommit) {
cachedCommit = buildInfoCommit; return cacheGitCommit(searchDir, buildInfoCommit);
return cachedCommit;
} }
const pkgCommit = readCommitFromPackageJson(); const pkgCommit = readCommitFromPackageJson();
if (pkgCommit) { if (pkgCommit) {
cachedCommit = pkgCommit; return cacheGitCommit(searchDir, pkgCommit);
return cachedCommit;
} }
try { try {
const headPath = resolveGitHeadPath(options.cwd ?? process.cwd()); return cacheGitCommit(searchDir, readCommitFromGit(searchDir, packageRoot) ?? null);
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;
} catch { } catch {
cachedCommit = null; return cacheGitCommit(searchDir, null);
return cachedCommit;
} }
}; };

View File

@@ -141,6 +141,18 @@ describe("resolveOpenClawPackageRoot", () => {
expect(resolveOpenClawPackageRootSync({ moduleUrl })).toBe(pkgRoot); 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 () => { it("returns null for non-openclaw package roots", async () => {
const pkgRoot = fx("not-openclaw"); const pkgRoot = fx("not-openclaw");
setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" })); setFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "not-openclaw" }));

View File

@@ -116,7 +116,11 @@ function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: strin
const candidates: string[] = []; const candidates: string[] = [];
if (opts.moduleUrl) { 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) { if (opts.argv1) {
candidates.push(...candidateDirsFromArgv1(opts.argv1)); candidates.push(...candidateDirsFromArgv1(opts.argv1));

View File

@@ -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");
},
);
});