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 <steipete@gmail.com>

* test(macos): avoid process-age timing assumption

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-28 13:21:10 -04:00
committed by GitHub
parent 0c36fabc61
commit 5d1311c19e
5 changed files with 95 additions and 57 deletions
@@ -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",
@@ -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<String> = [
"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])
}
}
@@ -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")
@@ -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 {
@@ -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"