Files
openclaw/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsPromptServerTests.swift

249 lines
8.9 KiB
Swift

import Darwin
import Foundation
import Testing
@testable import OpenClaw
@Suite(.serialized)
@MainActor
struct ExecApprovalsPromptServerTests {
private func makeShortSocketRoot() throws -> URL {
let root = URL(fileURLWithPath: "/tmp", isDirectory: true)
.appendingPathComponent("ocps-\(UUID().uuidString.prefix(12))", isDirectory: true)
try FileManager().createDirectory(
at: root,
withIntermediateDirectories: false,
attributes: [.posixPermissions: 0o700])
let socketPath = root.appendingPathComponent("exec-approvals.sock").path
guard socketPath.utf8.count < MemoryLayout.size(ofValue: sockaddr_un().sun_path) else {
throw POSIXError(.ENAMETOOLONG)
}
return root
}
private final class SequenceCredentialsProbe: @unchecked Sendable {
private let lock = NSLock()
private let socketPath: String
private let tokens: [String]
private var count = 0
private var resolvedOnMain = false
init(socketPath: String, tokens: [String]) {
self.socketPath = socketPath
self.tokens = tokens
}
func resolve() -> (socketPath: String, token: String) {
self.lock.withLock {
self.resolvedOnMain = self.resolvedOnMain || Thread.isMainThread
let token = self.tokens[min(self.count, self.tokens.count - 1)]
self.count += 1
return (self.socketPath, token)
}
}
func snapshot() -> (count: Int, resolvedOnMain: Bool) {
self.lock.withLock { (self.count, self.resolvedOnMain) }
}
}
private final class BlockingCredentialsProbe: @unchecked Sendable {
private let lock = NSLock()
private let release = DispatchSemaphore(value: 0)
private let socketPath: String
private var started = false
private var resolvedOnMain = false
private var completed = false
init(socketPath: String) {
self.socketPath = socketPath
}
func resolve() -> (socketPath: String, token: String) {
self.lock.withLock {
self.started = true
self.resolvedOnMain = Thread.isMainThread
}
self.release.wait()
self.lock.withLock {
self.completed = true
}
return (self.socketPath, "current-token")
}
func releaseResolution() {
self.release.signal()
}
func snapshot() -> (started: Bool, resolvedOnMain: Bool, completed: Bool) {
self.lock.withLock { (self.started, self.resolvedOnMain, self.completed) }
}
}
@Test
func `prompt server retry backoff grows exponentially and caps`() {
var backoff = ExecApprovalsPromptRetryBackoff(
initialDelay: .milliseconds(10),
maximumDelay: .milliseconds(40))
#expect(backoff.nextDelay() == .milliseconds(10))
#expect(backoff.nextDelay() == .milliseconds(20))
#expect(backoff.nextDelay() == .milliseconds(40))
#expect(backoff.nextDelay() == .milliseconds(40))
}
@Test
func `prompt server reloads credentials after an unavailable approvals read`() async throws {
let root = try self.makeShortSocketRoot()
let socketPath = root.appendingPathComponent("exec-approvals.sock").path
defer { try? FileManager().removeItem(at: root) }
let probe = SequenceCredentialsProbe(
socketPath: socketPath,
tokens: ["", "current-token"])
let server = ExecApprovalsPromptServer(
retryDelay: .milliseconds(10),
resolveSocketCredentials: { probe.resolve() },
onPrompt: { _ in .allowOnce })
defer { server.stop() }
server.start()
#expect(!FileManager().fileExists(atPath: socketPath))
let decision = await self.waitForDecision(socketPath: socketPath, token: "current-token")
let snapshot = probe.snapshot()
#expect(snapshot.count >= 2)
#expect(!snapshot.resolvedOnMain)
#expect(decision == .allowOnce)
}
@Test
func `stopping prompt server prevents a blocked resolver from installing a socket`() async throws {
let root = try self.makeShortSocketRoot()
let socketPath = root.appendingPathComponent("exec-approvals.sock").path
defer { try? FileManager().removeItem(at: root) }
let probe = BlockingCredentialsProbe(socketPath: socketPath)
let server = ExecApprovalsPromptServer(
retryDelay: .milliseconds(10),
resolveSocketCredentials: { probe.resolve() })
server.start()
let started = await self.waitUntil { probe.snapshot().started }
#expect(started)
#expect(!probe.snapshot().resolvedOnMain)
let pendingStartup = server.stop()
probe.releaseResolution()
if let pendingStartup {
await pendingStartup.value
}
#expect(probe.snapshot().completed)
#expect(!FileManager().fileExists(atPath: socketPath))
let decision = await ExecApprovalsSocketClient.requestDecision(
socketPath: socketPath,
token: "current-token",
request: ExecApprovalPromptRequest(command: "echo stopped"),
timeoutMs: 100)
#expect(decision == nil)
}
@Test
func `pre-cancelled socket startup does not listen`() async throws {
let root = try self.makeShortSocketRoot()
let socketPath = root.appendingPathComponent("exec-approvals.sock").path
defer { try? FileManager().removeItem(at: root) }
let result = await ExecApprovalsPromptServer._testPrecancelledSocketStart(
socketPath: socketPath)
#expect(!result.ready)
#expect(result.preservedExistingListener)
#expect(!FileManager().fileExists(atPath: socketPath))
}
@Test
func `prompt server retries after a transient socket startup failure`() async throws {
let root = try self.makeShortSocketRoot()
let socketURL = root.appendingPathComponent("exec-approvals.sock")
_ = FileManager().createFile(atPath: socketURL.path, contents: Data("occupied".utf8))
defer { try? FileManager().removeItem(at: root) }
let probe = SequenceCredentialsProbe(
socketPath: socketURL.path,
tokens: ["current-token"])
let server = ExecApprovalsPromptServer(
retryDelay: .milliseconds(10),
resolveSocketCredentials: { probe.resolve() },
onPrompt: { _ in .allowOnce })
defer { server.stop() }
server.start()
let observedRetry = await self.waitUntil { probe.snapshot().count >= 2 }
#expect(observedRetry)
try FileManager().removeItem(at: socketURL)
let decision = await self.waitForDecision(
socketPath: socketURL.path,
token: "current-token")
#expect(probe.snapshot().count >= 2)
#expect(decision == .allowOnce)
}
@Test
func `prompt server restarts after its active listener stops unexpectedly`() async throws {
let root = try self.makeShortSocketRoot()
let socketPath = root.appendingPathComponent("exec-approvals.sock").path
defer { try? FileManager().removeItem(at: root) }
let probe = SequenceCredentialsProbe(
socketPath: socketPath,
tokens: ["current-token"])
let server = ExecApprovalsPromptServer(
retryDelay: .milliseconds(10),
resolveSocketCredentials: { probe.resolve() },
onPrompt: { _ in .allowOnce })
defer { server.stop() }
server.start()
let initialDecision = await self.waitForDecision(
socketPath: socketPath,
token: "current-token")
let initialResolveCount = probe.snapshot().count
#expect(initialDecision == .allowOnce)
server._testFailActiveSocket()
let recoveredDecision = await self.waitForDecision(
socketPath: socketPath,
token: "current-token")
#expect(probe.snapshot().count > initialResolveCount)
#expect(recoveredDecision == .allowOnce)
}
private func waitForDecision(socketPath: String, token: String) async -> ExecApprovalDecision? {
for _ in 0..<100 {
try? await Task.sleep(for: .milliseconds(10))
if let decision = await ExecApprovalsSocketClient.requestDecision(
socketPath: socketPath,
token: token,
request: ExecApprovalPromptRequest(command: "echo ready"),
timeoutMs: 100)
{
return decision
}
}
return nil
}
private func waitUntil(_ condition: () -> Bool) async -> Bool {
for _ in 0..<100 {
if condition() {
return true
}
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
}