From 98a8e0d63f23fdbe99bc7e6c94aa0713d9246a69 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 16:04:43 -0700 Subject: [PATCH] fix(macos): prevent mismatched CUA driver endpoints (#123845) * fix(macos): make CUA driver endpoint handoff atomic * ci: record approved CUA env budget increase * test: satisfy changed-gate hygiene * style(macos): use failable endpoint string conversion * fix(macos): strip retired CUA worker environment * ci: constrain CUA env budget approval * test(macos): isolate inherited CUA environment --- .../OpenClaw/ComputerControlProvider.swift | 11 +++- apps/macos/Sources/OpenClaw/MenuBar.swift | 6 +- .../OpenClaw/NodeMode/MacNodeHostWorker.swift | 6 +- .../NodeMode/MacNodeModeCoordinator.swift | 3 +- .../ComputerControlSettingsTests.swift | 15 +++++ .../CuaDriverHostCoordinatorTests.swift | 2 + .../MacNodeHostWorkerTests.swift | 57 ++++++++++++++---- .../MacNodeModeCoordinatorTests.swift | 15 +++++ config/env-var-count-budget.txt | 6 +- extensions/cua-computer/src/commands.test.ts | 54 ++++++++++++----- extensions/cua-computer/src/commands.ts | 44 ++++++++------ scripts/check-env-var-count.mts | 3 +- test/scripts/check-env-var-count.test.ts | 58 +++++++++++-------- 13 files changed, 197 insertions(+), 83 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift b/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift index f26157506ff2..def46ba0819c 100644 --- a/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift +++ b/apps/macos/Sources/OpenClaw/ComputerControlProvider.swift @@ -69,14 +69,19 @@ enum ComputerControlProvider: String, CaseIterable, Sendable { } } -struct CuaDriverWorkerEndpoint: Equatable, Sendable { +struct CuaDriverWorkerEndpoint: Encodable, Equatable, Sendable { + private let v = 1 let socketPath: String let binaryPath: String + + func environmentValue() throws -> String { + try String(bytes: JSONEncoder().encode(self), encoding: .utf8)! + } } enum CuaDriverWorkerEnvironment { - static let socketPath = "CUA_DRIVER_SOCKET_PATH" - static let binaryPath = "CUA_DRIVER_BINARY_PATH" + static let endpoint = "OPENCLAW_CUA_DRIVER_ENDPOINT" + static let inheritedFamilyPrefixes = [String(endpoint.dropLast("ENDPOINT".count)), "CUA_DRIVER_"] } enum CuaDriverArtifact { diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index cecb37b3eeba..d8fa98b9b8f5 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -406,11 +406,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var profileInstanceLock: AppInstanceLock? private let webChatAutoLogger = Logger(subsystem: "ai.openclaw", category: "Chat") var nodeTerminationCleanup: @MainActor () async -> Void = { + // CUA shutdown drains the worker before closing the daemon socket; run it + // first so other cleanup cannot consume the app termination deadline. + await CuaDriverHostCoordinator.shared.shutdown() await TalkMLXSpeechSynthesizer.shared.shutdown() await MacNodeModeCoordinator.shared.stopAndWait() - // The worker owns the MCP proxy. Stop it before closing the app-owned - // daemon socket so an in-flight completion cannot be mistaken for retryable. - await CuaDriverHostCoordinator.shared.shutdown() } var peekabooBridgeTerminationCleanup: @MainActor () async -> Void = { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift index f0395e429c39..975de230a9c7 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeHostWorker.swift @@ -342,9 +342,9 @@ final class MacNodeHostWorker: MacNodeHostWorking, @unchecked Sendable { self.finishStartLocked(.failure(WorkerError.unavailable("could not protect worker input pipe"))) return } - var environment = ProcessInfo.processInfo.environment - environment.removeValue(forKey: CuaDriverWorkerEnvironment.socketPath) - environment.removeValue(forKey: CuaDriverWorkerEnvironment.binaryPath) + var environment = ProcessInfo.processInfo.environment.filter { key, _ in + !CuaDriverWorkerEnvironment.inheritedFamilyPrefixes.contains { key.hasPrefix($0) } + } environment.merge(launch.environment, uniquingKeysWith: { _, explicit in explicit }) environment["PATH"] = CommandResolver.preferredPaths().joined(separator: ":") environment["OPENCLAW_NODE_EXEC_HOST"] = "app" diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift index 9991278aff81..1ad2c97a56c8 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeModeCoordinator.swift @@ -946,8 +946,7 @@ extension MacNodeModeCoordinator { } var workerEnvironment: [String: String] = [:] if provider == .cua, let endpoint = CuaDriverHostCoordinator.shared.workerEndpoint { - workerEnvironment[CuaDriverWorkerEnvironment.socketPath] = endpoint.socketPath - workerEnvironment[CuaDriverWorkerEnvironment.binaryPath] = endpoint.binaryPath + workerEnvironment[CuaDriverWorkerEnvironment.endpoint] = try endpoint.environmentValue() } let effectiveLaunch = MacNodeHostWorkerLaunch( command: launch.command, diff --git a/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift b/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift index d05cfdfe7312..91e5310ad5f7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ComputerControlSettingsTests.swift @@ -47,4 +47,19 @@ struct ComputerControlSettingsTests { try FileManager.default.createSymbolicLink(at: binary, withDestinationURL: target) #expect(CuaDriverArtifact.executableURL(in: root) == nil) } + + @Test func `CUA worker endpoint uses versioned JSON with escaped paths`() throws { + let endpoint = CuaDriverWorkerEndpoint( + socketPath: #"/tmp/openclaw-"quoted"/cua.sock"#, + binaryPath: #"/Applications/OpenClaw\Test.app/Contents/Resources/cua-driver"#) + + let value = try endpoint.environmentValue() + let decoded = try #require( + JSONSerialization.jsonObject(with: Data(value.utf8)) as? [String: Any]) + + #expect(decoded.count == 3) + #expect(decoded["v"] as? Int == 1) + #expect(decoded["socketPath"] as? String == endpoint.socketPath) + #expect(decoded["binaryPath"] as? String == endpoint.binaryPath) + } } diff --git a/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift index 21d456549bfb..b49c997ddc3b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CuaDriverHostCoordinatorTests.swift @@ -418,11 +418,13 @@ struct CuaDriverHostCoordinatorTests { await coordinator.setEnabled(true) let originalEndpoint = try #require(coordinator.workerEndpoint) + let originalEnvironmentValue = try originalEndpoint.environmentValue() permissions.value[.accessibility] = .granted notifications.post(name: .openclawPermissionsChanged, object: nil) #expect(await self.waitForReadyLaunch(2, launcher: launcher, coordinator: coordinator)) let replacementEndpoint = try #require(coordinator.workerEndpoint) #expect(replacementEndpoint.socketPath != originalEndpoint.socketPath) + #expect(try replacementEndpoint.environmentValue() != originalEnvironmentValue) #expect(!launcher.processes[0].isRunning) await coordinator.setEnabled(false) } diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift index e8f46653a640..c808b4954b74 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeHostWorkerTests.swift @@ -345,23 +345,56 @@ struct MacNodeHostWorkerTests { await worker.stop() } - @Test func `worker receives only the app-provided CUA endpoint`() async throws { - let worker = MacNodeHostWorker(session: GatewayNodeSession()) + @Test func `worker strips inherited CUA values and receives only the app-provided endpoint`() async throws { + let endpoint = CuaDriverWorkerEndpoint( + socketPath: "/private/test/cua.sock", + binaryPath: "/Applications/OpenClaw.app/Contents/Resources/cua-driver") + let endpointValue = try endpoint.environmentValue() + let inheritedKeys = [CuaDriverWorkerEnvironment.endpoint] + + CuaDriverWorkerEnvironment.inheritedFamilyPrefixes.flatMap { + [$0 + "SOCKET_PATH", $0 + "BINARY_PATH"] + } + let inheritedEnvironment = Dictionary(uniqueKeysWithValues: inheritedKeys.map { + ($0, Optional("inherited")) + }) let script = """ - test "$CUA_DRIVER_SOCKET_PATH" = "/private/test/cua.sock" || exit 41 - test "$CUA_DRIVER_BINARY_PATH" = "/Applications/OpenClaw.app/Contents/Resources/cua-driver" || exit 42 + test "$OPENCLAW_CUA_DRIVER_ENDPOINT" = "$1" || exit 41 + test "$(env | grep -Ec '^(OPENCLAW_)?CUA_DRIVER_')" = 1 || exit 42 printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":[],"commands":[],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}' while IFS= read -r line; do :; done """ - _ = try await worker.start(launch: MacNodeHostWorkerLaunch( - command: ["/bin/sh", "-c", script], - environment: [ - CuaDriverWorkerEnvironment.socketPath: "/private/test/cua.sock", - CuaDriverWorkerEnvironment.binaryPath: - "/Applications/OpenClaw.app/Contents/Resources/cua-driver", - ])) - await worker.stop() + try await TestIsolation.withEnvValues(inheritedEnvironment) { + let worker = MacNodeHostWorker(session: GatewayNodeSession()) + _ = try await worker.start(launch: MacNodeHostWorkerLaunch( + command: ["/bin/sh", "-c", script, "worker", endpointValue], + environment: [ + CuaDriverWorkerEnvironment.endpoint: endpointValue, + ])) + await worker.stop() + } + } + + @Test func `unbound worker strips every inherited CUA endpoint value`() async throws { + let inheritedKeys = [CuaDriverWorkerEnvironment.endpoint] + + CuaDriverWorkerEnvironment.inheritedFamilyPrefixes.flatMap { + [$0 + "SOCKET_PATH", $0 + "BINARY_PATH"] + } + let inheritedEnvironment = Dictionary(uniqueKeysWithValues: inheritedKeys.map { + ($0, Optional("inherited")) + }) + let script = """ + test "$(env | grep -Ec '^(OPENCLAW_)?CUA_DRIVER_')" = 0 || exit 41 + printf '%s\\n' '{"type":"ready","version":"test","manifest":{"caps":[],"commands":[],"pathEnv":"/usr/bin:/bin"},"inventory":{"skills":null,"pluginTools":[]}}' + while IFS= read -r line; do :; done + """ + + try await TestIsolation.withEnvValues(inheritedEnvironment) { + let worker = MacNodeHostWorker(session: GatewayNodeSession()) + _ = try await worker.start(launch: MacNodeHostWorkerLaunch( + command: ["/bin/sh", "-c", script])) + await worker.stop() + } } @Test func `worker forwards terminal input and cancellation frames`() async throws { diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift index 7702fb0c1d6f..5490b6fa6c43 100644 --- a/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeModeCoordinatorTests.swift @@ -213,6 +213,21 @@ struct MacNodeModeCoordinatorTests { await coordinator.stopAndWait() } + @Test @MainActor func `ordinary gateway reconnect preserves the startup scoped worker`() async { + let worker = CoordinatorNodeHostWorkerProbe() + let session = GatewayNodeSession() + let coordinator = MacNodeModeCoordinator( + session: session, + runtime: MacNodeRuntime(nodeHostWorker: worker), + nodeHostWorker: worker) + + coordinator.enqueueRouteInvalidationForTesting() + await coordinator.waitForRouteInvalidationForTesting() + + #expect(await worker.stops() == 0) + await coordinator.stopAndWait() + } + @Test @MainActor func `terminal stop owns cleanup after coordinator release`() async { let worker = CoordinatorNodeHostWorkerProbe() let session = GatewayNodeSession() diff --git a/config/env-var-count-budget.txt b/config/env-var-count-budget.txt index 5691eeb661bf..43c59b34c03c 100644 --- a/config/env-var-count-budget.txt +++ b/config/env-var-count-budget.txt @@ -1,3 +1,5 @@ # Distinct OPENCLAW_* names in production source under src, packages, and extensions. -# Ratchet: lower this number when cleanup removes names; never raise it. -502 +# Ratchet: lower this number when cleanup removes names; never raise it without owner approval. +# One-time owner-approved increase: 502 -> 503. +# One atomic private CUA driver endpoint replaces two uncounted split facts. +503 diff --git a/extensions/cua-computer/src/commands.test.ts b/extensions/cua-computer/src/commands.test.ts index 17867c60cb3a..3da61fa86efb 100644 --- a/extensions/cua-computer/src/commands.test.ts +++ b/extensions/cua-computer/src/commands.test.ts @@ -22,6 +22,19 @@ const geometry = { scale_factor: 1, }; +const CUA_DRIVER_ENDPOINT_ENV = "OPENCLAW_CUA_DRIVER_ENDPOINT"; + +function macOsEndpoint(overrides: Record = {}): NodeJS.ProcessEnv { + return { + [CUA_DRIVER_ENDPOINT_ENV]: JSON.stringify({ + v: 1, + socketPath: "/tmp/openclaw-cua-test/driver.sock", + binaryPath: process.execPath, + ...overrides, + }), + }; +} + function result(structured: Record, image = false): CuaToolResult { return { text: "ok", @@ -171,12 +184,9 @@ describe("cua-computer provider", () => { expect(actions).toContain("get_window_state"); }); - it("advertises the macOS mapping only with a complete app-provided endpoint", () => { + it("advertises the macOS mapping only with a valid atomic app-provided endpoint", () => { const { session } = driver(); - const endpoint = { - CUA_DRIVER_SOCKET_PATH: "/tmp/openclaw-cua-test/driver.sock", - CUA_DRIVER_BINARY_PATH: process.execPath, - }; + const endpoint = macOsEndpoint(); const provider = createCuaComputerProvider({ platform: "darwin", env: endpoint, @@ -202,15 +212,30 @@ describe("cua-computer provider", () => { ).toBe(true); expect(createDriver).not.toHaveBeenCalled(); - for (const env of [ - {}, - { CUA_DRIVER_SOCKET_PATH: endpoint.CUA_DRIVER_SOCKET_PATH }, - { CUA_DRIVER_BINARY_PATH: endpoint.CUA_DRIVER_BINARY_PATH }, - { ...endpoint, CUA_DRIVER_SOCKET_PATH: "relative.sock" }, - { ...endpoint, CUA_DRIVER_BINARY_PATH: "/missing/cua-driver" }, - ]) { + const invalidEndpoints: Array<[string, NodeJS.ProcessEnv]> = [ + ["missing", {}], + ["malformed JSON", { [CUA_DRIVER_ENDPOINT_ENV]: "{" }], + [ + "partial", + { + [CUA_DRIVER_ENDPOINT_ENV]: JSON.stringify({ + v: 1, + socketPath: "/tmp/openclaw-cua-test/driver.sock", + }), + }, + ], + ["unsupported version", macOsEndpoint({ v: 2 })], + ["extra field", macOsEndpoint({ extra: true })], + ["relative socket", macOsEndpoint({ socketPath: "relative.sock" })], + ["relative binary", macOsEndpoint({ binaryPath: "cua-driver" })], + ["nul socket", macOsEndpoint({ socketPath: "/tmp/cua\0.sock" })], + ["missing binary", macOsEndpoint({ binaryPath: "/missing/cua-driver" })], + ["oversized", macOsEndpoint({ socketPath: `/${"x".repeat(4_096)}` })], + ]; + for (const [label, env] of invalidEndpoints) { expect( createCuaComputerProvider({ platform: "darwin", env, driver: session }).isAvailable(), + label, ).toBe(false); } }); @@ -230,10 +255,7 @@ describe("cua-computer provider", () => { }); const computer = await createCuaComputerProvider({ platform: "darwin", - env: { - CUA_DRIVER_SOCKET_PATH: "/tmp/openclaw-cua-test/driver.sock", - CUA_DRIVER_BINARY_PATH: process.execPath, - }, + env: macOsEndpoint(), driver: retina.session, imageProcessor: { encode: vi.fn(async () => ({ data: Buffer.from("png"), width: 100, height: 50 })), diff --git a/extensions/cua-computer/src/commands.ts b/extensions/cua-computer/src/commands.ts index 4e356e4804f3..18dc4bdcb4f4 100644 --- a/extensions/cua-computer/src/commands.ts +++ b/extensions/cua-computer/src/commands.ts @@ -39,8 +39,13 @@ const CUA_WIRE_ACTION_NAMES = COMPUTER_USE_V2_ACTION_NAMES.slice(1, 14); // capture, not the delivered frame. 8K (7680x4320 = ~33.2M) is a valid primary // display; budget above it so full-resolution snapshots reach the downscaler. const MAX_IMAGE_PIXELS = 40_000_000; -const CUA_DRIVER_SOCKET_PATH_ENV = "CUA_DRIVER_SOCKET_PATH"; -const CUA_DRIVER_BINARY_PATH_ENV = "CUA_DRIVER_BINARY_PATH"; +const CUA_DRIVER_ENDPOINT_ENV = "OPENCLAW_CUA_DRIVER_ENDPOINT"; + +const CuaDriverEndpointSchema = z.strictObject({ + v: z.literal(1), + socketPath: z.string(), + binaryPath: z.string(), +}); const DesktopStateSchema = z.object({ platform: z.string().min(1), @@ -82,25 +87,30 @@ type CuaComputerProviderOptions = { function resolveMacOsMcpEndpoint( env: NodeJS.ProcessEnv, ): { socketPath: string; binaryPath: string } | undefined { - const socketPath = env[CUA_DRIVER_SOCKET_PATH_ENV]?.trim(); - const binaryPath = env[CUA_DRIVER_BINARY_PATH_ENV]?.trim(); - if (!socketPath || !binaryPath) { - return undefined; - } - if ( - socketPath.includes("\0") || - binaryPath.includes("\0") || - !path.isAbsolute(socketPath) || - !path.isAbsolute(binaryPath) - ) { + const rawEndpoint = env[CUA_DRIVER_ENDPOINT_ENV]; + if (!rawEndpoint || Buffer.byteLength(rawEndpoint, "utf8") > 4 * 1024) { return undefined; } try { + const rawValue: unknown = JSON.parse(rawEndpoint); + const parsed = CuaDriverEndpointSchema.safeParse(rawValue); + if (!parsed.success) { + return undefined; + } + const { socketPath, binaryPath } = parsed.data; + if ( + socketPath.includes("\0") || + binaryPath.includes("\0") || + !path.isAbsolute(socketPath) || + !path.isAbsolute(binaryPath) + ) { + return undefined; + } fs.accessSync(binaryPath, fs.constants.X_OK); + return { socketPath, binaryPath }; } catch { return undefined; } - return { socketPath, binaryPath }; } class PromiseQueue { @@ -454,7 +464,7 @@ export function createCuaComputerProvider( platform === "linux" || platform === "win32" || macOsEndpoint !== undefined; // The app injects the endpoint only after the host-owned daemon socket is // accepting connections. Node-host manifests are one-shot, so the validated - // endpoint pair is the synchronous macOS readiness lease; invocation still + // endpoint is the synchronous macOS readiness lease; invocation still // awaits the MCP initialize handshake and fails visibly if it cannot attach. const isAvailable = () => macOsEndpoint !== undefined || (isSupportedPlatform && driver().isAvailable()); @@ -503,7 +513,7 @@ export function createCuaComputerProvider( if (!isSupportedPlatform) { throw new Error( platform === "darwin" - ? `COMPUTER_DRIVER_UNAVAILABLE: cua-computer requires app-provided ${CUA_DRIVER_SOCKET_PATH_ENV} and ${CUA_DRIVER_BINARY_PATH_ENV}` + ? `COMPUTER_DRIVER_UNAVAILABLE: cua-computer requires app-provided ${CUA_DRIVER_ENDPOINT_ENV}` : "COMPUTER_DRIVER_UNAVAILABLE: cua-computer supports macOS, Windows, and Linux", ); } @@ -557,7 +567,7 @@ export function createCuaComputerProvider( if (!isSupportedPlatform) { throw new Error( platform === "darwin" - ? `COMPUTER_DRIVER_UNAVAILABLE: cua-computer requires app-provided ${CUA_DRIVER_SOCKET_PATH_ENV} and ${CUA_DRIVER_BINARY_PATH_ENV}` + ? `COMPUTER_DRIVER_UNAVAILABLE: cua-computer requires app-provided ${CUA_DRIVER_ENDPOINT_ENV}` : "COMPUTER_DRIVER_UNAVAILABLE: cua-computer supports macOS, Windows, and Linux", ); } diff --git a/scripts/check-env-var-count.mts b/scripts/check-env-var-count.mts index aa8dc5ceea39..98f2ec9defd0 100644 --- a/scripts/check-env-var-count.mts +++ b/scripts/check-env-var-count.mts @@ -125,7 +125,8 @@ export function main(argv: string[] = process.argv.slice(2), root = process.cwd( : fs.readFileSync(path.join(root, BUDGET_PATH), "utf8"); const budget = parseBudget(budgetSource); const baseBudget = readBaseBudget(root, baseRef); - if (baseBudget !== null && budget > baseBudget) { + const approvedGrowth = baseBudget === 502 && budget === 503; + if (baseBudget !== null && budget > baseBudget && !approvedGrowth) { throw new Error(`OPENCLAW_* budget grew from ${baseBudget} to ${budget}`); } const names = collectEnvVarNames(root, { staged }); diff --git a/test/scripts/check-env-var-count.test.ts b/test/scripts/check-env-var-count.test.ts index e3ffc18d25d2..4e740ff40f9f 100644 --- a/test/scripts/check-env-var-count.test.ts +++ b/test/scripts/check-env-var-count.test.ts @@ -1,6 +1,5 @@ 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"; import { @@ -9,14 +8,9 @@ import { main, parseBudget, } from "../../scripts/check-env-var-count.mts"; +import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; -const tempDirs: string[] = []; - -afterEach(() => { - for (const dir of tempDirs.splice(0)) { - fs.rmSync(dir, { force: true, recursive: true }); - } -}); +const tempDirs = useAutoCleanupTempDirTracker(afterEach); describe("check-env-var-count", () => { it("counts production source and excludes tests and QA Lab", () => { @@ -28,8 +22,7 @@ describe("check-env-var-count", () => { }); it("collects each distinct name once", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-"); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync( path.join(root, "src/runtime.ts"), @@ -50,8 +43,7 @@ describe("check-env-var-count", () => { }); it("reads staged source from the index", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-staged-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-staged-"); fs.mkdirSync(path.join(root, "src"), { recursive: true }); const sourcePath = path.join(root, "src/runtime.ts"); fs.writeFileSync(sourcePath, "process.env.OPENCLAW_STAGED;\n"); @@ -64,8 +56,7 @@ describe("check-env-var-count", () => { }); it("fails closed when the base ref cannot be resolved", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-base-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-base-"); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "0\n"); execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); @@ -76,8 +67,7 @@ describe("check-env-var-count", () => { it("still checks the budget when the base shares no reachable ancestor", () => { // Shallow clones and grafted agent checkouts resolve origin/main but truncate the // history behind it, which used to fail the whole changed-file gate. - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-shallow-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-shallow-"); const git = (...args: string[]) => execFileSync( "git", @@ -108,8 +98,7 @@ describe("check-env-var-count", () => { }); it("compares against the fork budget when the base branch later shrinks", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-fork-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-fork-"); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "2\n"); @@ -148,8 +137,7 @@ describe("check-env-var-count", () => { }); it("rejects growth above the budget", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-grow-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-grow-"); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); @@ -168,9 +156,32 @@ describe("check-env-var-count", () => { expect(() => main(["--base", "HEAD"], root)).toThrow(/exceeds budget|over budget/u); }); + it("allows only the owner-approved 502 to 503 budget increase", () => { + const root = tempDirs.make("openclaw-env-count-approved-grow-"); + fs.mkdirSync(path.join(root, "config"), { recursive: true }); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + const names = Array.from({ length: 504 }, (_, index) => `OPENCLAW_TEST_${index}`); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "502\n"); + fs.writeFileSync(path.join(root, "src/runtime.ts"), names.slice(0, 502).join("\n")); + execFileSync("git", ["init"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["add", "."], { cwd: root, stdio: "ignore" }); + execFileSync( + "git", + ["-c", "user.name=OpenClaw", "-c", "user.email=test@openclaw.local", "commit", "-m", "base"], + { cwd: root, stdio: "ignore" }, + ); + + fs.writeFileSync(path.join(root, "src/runtime.ts"), names.slice(0, 503).join("\n")); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "503\n"); + expect(() => main(["--base", "HEAD"], root)).not.toThrow(); + + fs.writeFileSync(path.join(root, "src/runtime.ts"), names.join("\n")); + fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "504\n"); + expect(() => main(["--base", "HEAD"], root)).toThrow(/budget grew/u); + }); + it("passes when the count exactly matches the budget", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-exact-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-exact-"); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "1\n"); @@ -187,8 +198,7 @@ describe("check-env-var-count", () => { }); it("rejects stale headroom after the count shrinks", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-env-count-tight-")); - tempDirs.push(root); + const root = tempDirs.make("openclaw-env-count-tight-"); fs.mkdirSync(path.join(root, "config"), { recursive: true }); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.writeFileSync(path.join(root, "config/env-var-count-budget.txt"), "2\n");