From 5d1311c19e651833799f35395d82321722143162 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 13:21:10 -0400 Subject: [PATCH] test(macos): block real process fallthrough (#113337) * test(macos): block real process fallthrough * test(macos): cover SwiftPM helper executable paths Co-authored-by: Peter Steinberger * test(macos): avoid process-age timing assumption Co-authored-by: Peter Steinberger --- .../OpenClaw/GatewayLaunchAgentManager.swift | 6 ++ .../OpenClaw/ProcessInfo+OpenClaw.swift | 39 ++++++++++--- .../GatewayLaunchAgentManagerTests.swift | 17 ++++++ .../LowCoverageHelperTests.swift | 55 +++---------------- .../NixModeStableSuiteTests.swift | 35 ++++++++++++ 5 files changed, 95 insertions(+), 57 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift index 4f1622bb78ac..4f54dd243bca 100644 --- a/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift +++ b/apps/macos/Sources/OpenClaw/GatewayLaunchAgentManager.swift @@ -267,6 +267,12 @@ extension GatewayLaunchAgentManager { payload: Data(payload.utf8), message: nil) } + if ProcessInfo.processInfo.isRunningTests { + return CommandResult( + success: false, + payload: nil, + message: "Gateway daemon commands require explicit interception during tests") + } #endif let command = CommandResolver.openclawCommand( subcommand: "gateway", diff --git a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift index a219f4953366..e123caff3538 100644 --- a/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift +++ b/apps/macos/Sources/OpenClaw/ProcessInfo+OpenClaw.swift @@ -1,6 +1,13 @@ import Foundation extension ProcessInfo { + /// SwiftPM loads test bundles into these helpers, so bundle inspection alone + /// cannot identify every test process. Keep current and legacy runner names. + private static let swiftPMTestHelperNames: Set = [ + "swiftpm-testing-helper", + "swiftpm-xctest-helper", + ] + var isPreview: Bool { guard let raw = getenv("XCODE_RUNNING_FOR_PREVIEWS") else { return false } return String(cString: raw) == "1" @@ -34,15 +41,29 @@ extension ProcessInfo { isAppBundle: isAppBundle) } - var isRunningTests: Bool { - // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not - // guaranteed to be the `.xctest` bundle, so check all loaded bundles. - if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true } - if Bundle.main.bundleURL.pathExtension == "xctest" { return true } + static func resolveIsRunningTests( + environment: [String: String], + processName: String, + arguments: [String], + bundleURLs: [URL]) -> Bool + { + if bundleURLs.contains(where: { $0.pathExtension == "xctest" }) { return true } + if self.swiftPMTestHelperNames.contains(processName) { return true } + if let executable = arguments.first.map({ URL(fileURLWithPath: $0).lastPathComponent }), + self.swiftPMTestHelperNames.contains(executable) + { + return true + } + return environment["XCTestConfigurationFilePath"] != nil + || environment["XCTestBundlePath"] != nil + || environment["XCTestSessionIdentifier"] != nil + } - // Backwards-compatible fallbacks for runners that still set XCTest env vars. - return self.environment["XCTestConfigurationFilePath"] != nil - || self.environment["XCTestBundlePath"] != nil - || self.environment["XCTestSessionIdentifier"] != nil + var isRunningTests: Bool { + Self.resolveIsRunningTests( + environment: self.environment, + processName: self.processName, + arguments: self.arguments, + bundleURLs: Bundle.allBundles.map(\.bundleURL) + [Bundle.main.bundleURL]) } } diff --git a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift index 93879cc26a38..acd7f2507fd2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/GatewayLaunchAgentManagerTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import OpenClaw +@Suite(.serialized) struct GatewayLaunchAgentManagerTests { @Test func `reads Gateway service ownership command directly from launchd`() throws { let url = FileManager.default.temporaryDirectory @@ -75,6 +76,22 @@ struct GatewayLaunchAgentManagerTests { #expect(GatewayLaunchAgentManager.testingDaemonCommandCallsSnapshot().isEmpty) } + @Test func `unintercepted daemon commands fail closed during tests`() async { + let marker = FileManager.default.temporaryDirectory + .appendingPathComponent("openclaw-no-disable-marker-\(UUID().uuidString)") + defer { + GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(nil) + GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false) + } + + GatewayLaunchAgentManager.setTestingDisableLaunchAgentMarkerURL(marker) + GatewayLaunchAgentManager.setTestingInterceptDaemonCommands(false) + + let error = await GatewayLaunchAgentManager.kickstart() + + #expect(error == "Gateway daemon commands require explicit interception during tests") + } + @Test func `launch agent plist snapshot parses args and env`() throws { let url = FileManager().temporaryDirectory .appendingPathComponent("openclaw-launchd-\(UUID().uuidString).plist") diff --git a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift index 59ab990f7415..1366f8014d03 100644 --- a/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/LowCoverageHelperTests.swift @@ -337,55 +337,14 @@ struct LowCoverageHelperTests { #expect(siblingPlan.reap.isEmpty) } - @Test func `port guardian classifies a real orphaned tunnel process for reaping`() async throws { - // Real ssh that hangs safely: ProxyCommand replaces the TCP transport, so no - // network traffic happens and the -L port is never bound (forwards only bind - // after auth). Spawned through sh so the parent exits and ssh reparents to - // launchd — the exact orphan shape the reaper must detect. - let port = 45871 - // Detach the child's stdio: the pipe must reach EOF when sh exits, not when ssh dies. - let script = "/usr/bin/ssh -o BatchMode=yes -o ProxyCommand='sleep 60' " + - "-N -L \(port):127.0.0.1:\(port) orphan-reap-test-host >/dev/null 2>&1 & echo $!" - let spawn = Process() - spawn.executableURL = URL(fileURLWithPath: "/bin/sh") - spawn.arguments = ["-c", script] - let out = Pipe() - spawn.standardOutput = out - try spawn.run() - spawn.waitUntilExit() - let pidText = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let pid = try #require(Int32(pidText.trimmingCharacters(in: .whitespacesAndNewlines))) - defer { kill(pid, SIGKILL) } + @Test func `port guardian reads current process metadata without spawning children`() throws { + let info = try #require(PortGuardian._testTunnelProcessInfo(pid: getpid())) + let now = Date().timeIntervalSince1970 - // Reparenting to launchd is immediate once sh exits, but give ps/sysctl a beat. - var info: PortGuardian.TunnelProcessInfo? - for _ in 0..<40 { - info = PortGuardian._testTunnelProcessInfo(pid: pid) - if info?.parentPid == 1, info?.fullCommand?.isEmpty == false { break } - try await Task.sleep(nanoseconds: 50_000_000) - } - let orphan = try #require(info) - #expect(orphan.parentPid == 1) - // Kernel start time must be sane so the pid-reuse gate can rely on it. - #expect(abs(orphan.startedAt - Date().timeIntervalSince1970) < 60) - let recordedAt = Date().timeIntervalSince1970 - let record = PortGuardian.Record( - port: port, pid: pid, command: "/usr/bin/ssh", mode: "remote", timestamp: recordedAt) - #expect(PortGuardian.classifyTunnelRecord(record, process: orphan) == .reap) - - // Same process under a different recorded port must never be reap-eligible. - let mismatched = PortGuardian.Record( - port: port + 1, pid: pid, command: "/usr/bin/ssh", mode: "remote", timestamp: recordedAt) - #expect(PortGuardian.classifyTunnelRecord(mismatched, process: orphan) == .drop) - - // A record predating this process (reused pid) must drop, not reap. - let predates = PortGuardian.Record( - port: port, - pid: pid, - command: "/usr/bin/ssh", - mode: "remote", - timestamp: orphan.startedAt - 3600) - #expect(PortGuardian.classifyTunnelRecord(predates, process: orphan) == .drop) + #expect(info.parentPid > 0) + #expect(info.startedAt > now - ProcessInfo.processInfo.systemUptime - 1) + #expect(info.startedAt <= now + 1) + #expect(info.fullCommand?.isEmpty == false) } @Test @MainActor func `canvas scheme handler resolves files and errors`() throws { diff --git a/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift b/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift index ad3a67ebd1c7..ba65f2b890ff 100644 --- a/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/NixModeStableSuiteTests.swift @@ -25,6 +25,41 @@ struct NixModeStableSuiteTests { #expect(resolved) } + @Test func `detects SwiftPM and XCTest runners`() { + #expect(ProcessInfo.resolveIsRunningTests( + environment: [:], + processName: "swiftpm-testing-helper", + arguments: [], + bundleURLs: [])) + #expect(ProcessInfo.resolveIsRunningTests( + environment: [:], + processName: "swiftpm-xctest-helper", + arguments: [], + bundleURLs: [])) + for helper in ["swiftpm-testing-helper", "swiftpm-xctest-helper"] { + #expect(ProcessInfo.resolveIsRunningTests( + environment: [:], + processName: "OpenClawTests", + arguments: ["/Library/Developer/Toolchains/usr/libexec/swift/pm/\(helper)"], + bundleURLs: [])) + } + #expect(ProcessInfo.resolveIsRunningTests( + environment: ["XCTestSessionIdentifier": "session"], + processName: "OpenClawTests", + arguments: [], + bundleURLs: [])) + #expect(ProcessInfo.resolveIsRunningTests( + environment: [:], + processName: "OpenClawTests", + arguments: [], + bundleURLs: [URL(fileURLWithPath: "/tmp/OpenClawTests.xctest")])) + #expect(!ProcessInfo.resolveIsRunningTests( + environment: [:], + processName: "OpenClaw", + arguments: [], + bundleURLs: [])) + } + @Test func `ignores stable suite outside app bundles`() throws { let suite = try #require(UserDefaults(suiteName: launchdLabel)) let key = "openclaw.nixMode"