Skip to content

Commit 825d9a6

Browse files
authored
fix(os): use sw_vers for macOS version on Darwin instead of os.release() (#95225)
* fix(os): use sw_vers for macOS version on Darwin instead of os.release() * fix: cache sw_vers, keep resolveOsSummary().release as raw kernel version Addresses ClawSweeper P1 findings on #95225: 1. Cache sw_vers output for process lifetime: resolveDarwinProductVersion() is called on the agent prompt hot path (3 callers via resolveOsProductLabel). Without caching, every prompt build spawns a synchronous subprocess. Added module-level cache + _resetCachedDarwinProductVersion() for tests. 2. Keep resolveOsSummary().release as os.release(): changing release from raw kernel to product version breaks status JSON and trajectory metadata consumers that expect the Darwin kernel release. The darwin product version is now used only in the label field. * fix(os): preserve runtime OS labels off Darwin
1 parent 86b9b88 commit 825d9a6

5 files changed

Lines changed: 123 additions & 10 deletions

File tree

src/agents/cli-runner/helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js";
1919
import type { ChatType } from "../../channels/chat-type.js";
2020
import type { CliBackendConfig } from "../../config/types.js";
2121
import type { OpenClawConfig } from "../../config/types.openclaw.js";
22+
import { resolveRuntimeOsLabel } from "../../infra/os-summary.js";
2223
import { privateFileStore } from "../../infra/private-file-store.js";
2324
import { tempWorkspace } from "../../infra/private-temp-workspace.js";
2425
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
@@ -157,7 +158,7 @@ export function buildCliAgentSystemPrompt(params: {
157158
sessionKey: params.sessionKey,
158159
sessionId: params.sessionId,
159160
host: "openclaw",
160-
os: `${os.type()} ${os.release()}`,
161+
os: resolveRuntimeOsLabel(),
161162
arch: os.arch(),
162163
node: process.version,
163164
model: params.modelDisplay,

src/agents/embedded-agent-runner/compact.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
} from "../../infra/diagnostic-trace-context.js";
2323
import { formatErrorMessage } from "../../infra/errors.js";
2424
import { getMachineDisplayName } from "../../infra/machine-name.js";
25+
import { resolveRuntimeOsLabel } from "../../infra/os-summary.js";
2526
import { generateSecureToken } from "../../infra/secure-random.js";
2627
import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js";
2728
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
@@ -1053,7 +1054,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
10531054

10541055
const runtimeInfo = {
10551056
host: machineName,
1056-
os: `${os.type()} ${os.release()}`,
1057+
os: resolveRuntimeOsLabel(),
10571058
arch: os.arch(),
10581059
node: process.version,
10591060
model: `${provider}/${modelId}`,

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { isEmbeddedMode } from "../../../infra/embedded-mode.js";
4545
import { formatErrorMessage, toErrorObject } from "../../../infra/errors.js";
4646
import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js";
4747
import { getMachineDisplayName } from "../../../infra/machine-name.js";
48+
import { resolveRuntimeOsLabel } from "../../../infra/os-summary.js";
4849
import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js";
4950
import type { AssistantMessage } from "../../../llm/types.js";
5051
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
@@ -1925,7 +1926,7 @@ export async function runEmbeddedAttempt(
19251926
sessionKey: params.sessionKey,
19261927
sessionId: params.sessionId,
19271928
host: machineName,
1928-
os: `${os.type()} ${os.release()}`,
1929+
os: resolveRuntimeOsLabel(),
19291930
arch: os.arch(),
19301931
node: process.version,
19311932
model: `${params.provider}/${params.modelId}`,

src/infra/os-summary.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ vi.mock("node:child_process", async () => {
1111
);
1212
});
1313

14-
import { resolveOsSummary } from "./os-summary.js";
14+
import { resolveOsSummary, resolveRuntimeOsLabel } from "./os-summary.js";
1515

1616
type OsSummaryCase = {
1717
name: string;
@@ -25,6 +25,7 @@ type OsSummaryCase = {
2525
describe("resolveOsSummary", () => {
2626
afterEach(() => {
2727
vi.restoreAllMocks();
28+
spawnSyncMock.mockReset();
2829
});
2930

3031
it.each<OsSummaryCase>([
@@ -95,3 +96,81 @@ describe("resolveOsSummary", () => {
9596
expect(resolveOsSummary()).toEqual(expected);
9697
});
9798
});
99+
100+
describe("resolveRuntimeOsLabel", () => {
101+
afterEach(() => {
102+
vi.restoreAllMocks();
103+
spawnSyncMock.mockReset();
104+
});
105+
106+
it("reports the macOS product version without an architecture suffix on tahoe", () => {
107+
vi.spyOn(os, "platform").mockReturnValue("darwin");
108+
vi.spyOn(os, "type").mockReturnValue("Darwin");
109+
vi.spyOn(os, "release").mockReturnValue("25.6.0");
110+
vi.spyOn(os, "arch").mockReturnValue("arm64");
111+
spawnSyncMock.mockReturnValue({
112+
stdout: "26.6.0\n",
113+
stderr: "",
114+
pid: 1,
115+
output: [],
116+
status: 0,
117+
signal: null,
118+
});
119+
120+
expect(resolveRuntimeOsLabel()).toBe("macOS 26.6.0");
121+
});
122+
123+
it("falls back to the Darwin release when sw_vers output is blank", () => {
124+
vi.spyOn(os, "platform").mockReturnValue("darwin");
125+
vi.spyOn(os, "type").mockReturnValue("Darwin");
126+
vi.spyOn(os, "release").mockReturnValue("25.7.0");
127+
vi.spyOn(os, "arch").mockReturnValue("arm64");
128+
spawnSyncMock.mockReturnValue({
129+
stdout: " ",
130+
stderr: "",
131+
pid: 1,
132+
output: [],
133+
status: 0,
134+
signal: null,
135+
});
136+
137+
expect(resolveRuntimeOsLabel()).toBe("macOS 25.7.0");
138+
});
139+
140+
it("preserves the old Windows os.type/os.release shape", () => {
141+
vi.spyOn(os, "platform").mockReturnValue("win32");
142+
vi.spyOn(os, "type").mockReturnValue("Windows_NT");
143+
vi.spyOn(os, "release").mockReturnValue("10.0.26100");
144+
vi.spyOn(os, "arch").mockReturnValue("x64");
145+
146+
expect(resolveRuntimeOsLabel()).toBe("Windows_NT 10.0.26100");
147+
});
148+
149+
it("preserves the old Linux os.type/os.release shape", () => {
150+
vi.spyOn(os, "platform").mockReturnValue("linux");
151+
vi.spyOn(os, "type").mockReturnValue("Linux");
152+
vi.spyOn(os, "release").mockReturnValue("6.8.0-generic");
153+
vi.spyOn(os, "arch").mockReturnValue("x64");
154+
155+
expect(resolveRuntimeOsLabel()).toBe("Linux 6.8.0-generic");
156+
});
157+
158+
it("caches the Darwin product version for repeated runtime prompt lookups", () => {
159+
vi.spyOn(os, "platform").mockReturnValue("darwin");
160+
vi.spyOn(os, "type").mockReturnValue("Darwin");
161+
vi.spyOn(os, "release").mockReturnValue("25.8.0");
162+
vi.spyOn(os, "arch").mockReturnValue("arm64");
163+
spawnSyncMock.mockReturnValue({
164+
stdout: "26.8.0\n",
165+
stderr: "",
166+
pid: 1,
167+
output: [],
168+
status: 0,
169+
signal: null,
170+
});
171+
172+
expect(resolveRuntimeOsLabel()).toBe("macOS 26.8.0");
173+
expect(resolveRuntimeOsLabel()).toBe("macOS 26.8.0");
174+
expect(spawnSyncMock).toHaveBeenCalledTimes(1);
175+
});
176+
});

src/infra/os-summary.ts

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,28 +11,59 @@ type OsSummary = {
1111
};
1212

1313
const cachedOsSummaryByKey = new Map<string, OsSummary>();
14+
const cachedRuntimeOsLabelByKey = new Map<string, string>();
1415

15-
function macosVersion(): string {
16+
/**
17+
* Resolve Darwin product version via sw_vers.
18+
*
19+
* Darwin kernel version and macOS product version are no longer in sync starting
20+
* with macOS 26 (Tahoe), where Darwin 25.x maps to macOS 26.x instead of the
21+
* historical Darwin N → macOS N+9 formula. Prefer sw_vers over os.release() on
22+
* macOS to avoid stale mappings.
23+
*/
24+
function resolveDarwinProductVersion(): string {
1625
const res = spawnSync("sw_vers", ["-productVersion"], { encoding: "utf-8" });
1726
const out = normalizeOptionalString(res.stdout) ?? "";
1827
return out || os.release();
1928
}
2029

21-
/** Resolves a compact OS label for diagnostics, logs, and environment summaries. */
22-
export function resolveOsSummary(): OsSummary {
30+
/**
31+
* Resolves the OS string used in agent runtime prompt metadata, without the
32+
* architecture suffix. The prompt renderer appends `arch` separately. Off
33+
* Darwin this preserves the historical `${os.type()} ${os.release()}` shape.
34+
*/
35+
export function resolveRuntimeOsLabel(): string {
2336
const platform = os.platform();
2437
const release = os.release();
2538
const arch = os.arch();
2639
const cacheKey = `${platform}\0${release}\0${arch}`;
27-
// Cache by stable os.* facts; darwin's sw_vers lookup is comparatively slow
28-
// and only needed once per observed platform/release/arch tuple.
40+
const cached = cachedRuntimeOsLabelByKey.get(cacheKey);
41+
if (cached !== undefined) {
42+
return cached;
43+
}
44+
const label =
45+
platform === "darwin" ? `macOS ${resolveDarwinProductVersion()}` : `${os.type()} ${release}`;
46+
cachedRuntimeOsLabelByKey.set(cacheKey, label);
47+
return label;
48+
}
49+
50+
/** Resolves a compact OS label for diagnostics, logs, and environment summaries. */
51+
export function resolveOsSummary(): OsSummary {
52+
const platform = os.platform();
53+
const rawRelease = os.release();
54+
const arch = os.arch();
55+
// Cache key uses raw os.release() (stable per kernel) so sw_vers drift across
56+
// minor macOS updates does not invalidate the cache.
57+
const cacheKey = `${platform}\0${rawRelease}\0${arch}`;
2958
const cached = cachedOsSummaryByKey.get(cacheKey);
3059
if (cached) {
3160
return cached;
3261
}
62+
const release = rawRelease;
3363
const label = (() => {
3464
if (platform === "darwin") {
35-
return `macos ${macosVersion()} (${arch})`;
65+
const productVersion = resolveDarwinProductVersion();
66+
return `macos ${productVersion} (${arch})`;
3667
}
3768
if (platform === "win32") {
3869
return `windows ${release} (${arch})`;

0 commit comments

Comments
 (0)