mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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
This commit is contained in:
committed by
GitHub
parent
f726393812
commit
98a8e0d63f
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,19 @@ const geometry = {
|
||||
scale_factor: 1,
|
||||
};
|
||||
|
||||
const CUA_DRIVER_ENDPOINT_ENV = "OPENCLAW_CUA_DRIVER_ENDPOINT";
|
||||
|
||||
function macOsEndpoint(overrides: Record<string, unknown> = {}): 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<string, unknown>, 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 })),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user