Skip to content

Commit 7eb118f

Browse files
committed
fix(os): preserve runtime OS labels off Darwin
1 parent 885f45b commit 7eb118f

5 files changed

Lines changed: 103 additions & 35 deletions

File tree

src/agents/cli-runner/helpers.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +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 { resolveOsProductLabel } from "../../infra/os-summary.js";
22+
import { resolveRuntimeOsLabel } from "../../infra/os-summary.js";
2323
import { privateFileStore } from "../../infra/private-file-store.js";
2424
import { tempWorkspace } from "../../infra/private-temp-workspace.js";
2525
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
@@ -158,7 +158,7 @@ export function buildCliAgentSystemPrompt(params: {
158158
sessionKey: params.sessionKey,
159159
sessionId: params.sessionId,
160160
host: "openclaw",
161-
os: resolveOsProductLabel(),
161+
os: resolveRuntimeOsLabel(),
162162
arch: os.arch(),
163163
node: process.version,
164164
model: params.modelDisplay,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
} from "../../infra/diagnostic-trace-context.js";
2525
import { formatErrorMessage } from "../../infra/errors.js";
2626
import { getMachineDisplayName } from "../../infra/machine-name.js";
27-
import { resolveOsProductLabel } from "../../infra/os-summary.js";
27+
import { resolveRuntimeOsLabel } from "../../infra/os-summary.js";
2828
import { generateSecureToken } from "../../infra/secure-random.js";
2929
import { listRegisteredPluginAgentPromptGuidance } from "../../plugins/command-registry-state.js";
3030
import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js";
@@ -1036,7 +1036,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
10361036

10371037
const runtimeInfo = {
10381038
host: machineName,
1039-
os: resolveOsProductLabel(),
1039+
os: resolveRuntimeOsLabel(),
10401040
arch: os.arch(),
10411041
node: process.version,
10421042
model: `${provider}/${modelId}`,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ import { isEmbeddedMode } from "../../../infra/embedded-mode.js";
4444
import { formatErrorMessage } from "../../../infra/errors.js";
4545
import { resolveHeartbeatSummaryForAgent } from "../../../infra/heartbeat-summary.js";
4646
import { getMachineDisplayName } from "../../../infra/machine-name.js";
47-
import { resolveOsProductLabel } from "../../../infra/os-summary.js";
47+
import { resolveRuntimeOsLabel } from "../../../infra/os-summary.js";
4848
import { createCodexNativeWebSearchWrapper } from "../../../llm/providers/stream-wrappers/openai.js";
4949
import type { AssistantMessage } from "../../../llm/types.js";
5050
import { listRegisteredPluginAgentPromptGuidance } from "../../../plugins/command-registry-state.js";
@@ -1934,7 +1934,7 @@ export async function runEmbeddedAttempt(
19341934
sessionKey: params.sessionKey,
19351935
sessionId: params.sessionId,
19361936
host: machineName,
1937-
os: resolveOsProductLabel(),
1937+
os: resolveRuntimeOsLabel(),
19381938
arch: os.arch(),
19391939
node: process.version,
19401940
model: `${params.provider}/${params.modelId}`,

src/infra/os-summary.test.ts

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

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

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

3131
it.each<OsSummaryCase>([
@@ -96,3 +96,81 @@ describe("resolveOsSummary", () => {
9696
expect(resolveOsSummary()).toEqual(expected);
9797
});
9898
});
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: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,7 @@ type OsSummary = {
1111
};
1212

1313
const cachedOsSummaryByKey = new Map<string, OsSummary>();
14-
15-
let _cachedDarwinProductVersion: string | undefined;
16-
17-
/** Reset module-level cache (for tests). */
18-
export function _resetCachedDarwinProductVersion(): void {
19-
_cachedDarwinProductVersion = undefined;
20-
}
14+
const cachedRuntimeOsLabelByKey = new Map<string, string>();
2115

2216
/**
2317
* Resolve Darwin product version via sw_vers.
@@ -26,35 +20,31 @@ export function _resetCachedDarwinProductVersion(): void {
2620
* with macOS 26 (Tahoe), where Darwin 25.x maps to macOS 26.x instead of the
2721
* historical Darwin N → macOS N+9 formula. Prefer sw_vers over os.release() on
2822
* macOS to avoid stale mappings.
29-
*
30-
* Result is cached for the process lifetime — sw_vers output is stable within
31-
* a single OS boot. Called on the agent prompt hot path, so caching avoids a
32-
* synchronous subprocess spawn on every prompt build.
3323
*/
34-
export function resolveDarwinProductVersion(): string {
35-
if (_cachedDarwinProductVersion !== undefined) {
36-
return _cachedDarwinProductVersion;
37-
}
24+
function resolveDarwinProductVersion(): string {
3825
const res = spawnSync("sw_vers", ["-productVersion"], { encoding: "utf-8" });
3926
const out = normalizeOptionalString(res.stdout) ?? "";
40-
_cachedDarwinProductVersion = out || os.release();
41-
return _cachedDarwinProductVersion;
27+
return out || os.release();
4228
}
4329

4430
/**
45-
* Canonical OS product label for system prompts, diagnostics, and user-facing
46-
* display. Uses sw_vers on macOS to get the real product version instead of the
47-
* Darwin kernel version that diverged in Tahoe.
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.
4834
*/
49-
export function resolveOsProductLabel(): string {
35+
export function resolveRuntimeOsLabel(): string {
5036
const platform = os.platform();
51-
if (platform === "darwin") {
52-
return `macOS ${resolveDarwinProductVersion()}`;
53-
}
54-
if (platform === "win32") {
55-
return `Windows ${os.release()}`;
37+
const release = os.release();
38+
const arch = os.arch();
39+
const cacheKey = `${platform}\0${release}\0${arch}`;
40+
const cached = cachedRuntimeOsLabelByKey.get(cacheKey);
41+
if (cached !== undefined) {
42+
return cached;
5643
}
57-
return `${os.type()} ${os.release()}`;
44+
const label =
45+
platform === "darwin" ? `macOS ${resolveDarwinProductVersion()}` : `${os.type()} ${release}`;
46+
cachedRuntimeOsLabelByKey.set(cacheKey, label);
47+
return label;
5848
}
5949

6050
/** Resolves a compact OS label for diagnostics, logs, and environment summaries. */

0 commit comments

Comments
 (0)