mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-06 05:11:36 +00:00
249 lines
8.9 KiB
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
|
|
}
|
|
}
|