From f35fa8c70841dffca8ae9318fc2f0ae94dcb4d37 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 24 Aug 2026 21:35:55 +0900 Subject: [PATCH] Harden managed completion signal boundaries --- .../007-s3a-action.md | 24 +++++ docs/components/agent-detection.md | 5 +- docs/components/agent-profiles.md | 6 +- supacode/CLIService/CLISocketServer.swift | 50 +++++++--- .../CodexEffectiveNotifyResolver.swift | 3 +- .../CodexShellLaunchEnvironment.swift | 25 +++-- .../AgentRuntime/CodexShellProbeProcess.swift | 8 +- .../BusinessLogic/AgentObservationStore.swift | 35 ++++++- supacodeTests/CLISocketServerTests.swift | 99 ++++++++++++++----- .../CodexEffectiveNotifyResolverTests.swift | 16 +++ .../CodexShellLaunchEnvironmentTests.swift | 27 +++++ .../CodexShellProbeProcessTests.swift | 26 +++++ .../ManagedAgentHookObservationTests.swift | 62 ++++++++++++ 13 files changed, 329 insertions(+), 57 deletions(-) diff --git a/docs-ai/064-agent-completion-signals/007-s3a-action.md b/docs-ai/064-agent-completion-signals/007-s3a-action.md index 123ff267..16f028a2 100644 --- a/docs-ai/064-agent-completion-signals/007-s3a-action.md +++ b/docs-ai/064-agent-completion-signals/007-s3a-action.md @@ -222,6 +222,30 @@ source wait. The regression tests coordinate accept, peer close, and MainActor r they prove the peer closed after identity capture but before routing. The owned-descriptor test failed against the previous monitor and passes after duplication; the three disconnect-seam tests also passed ten repeated runs. +A second post-merge review exposed four additional asymmetric boundaries. The login-shell runner now stops waiting +for inherited pipe writers after the tracked shell exits, while still draining all immediately buffered output. The +launch option scanner stops at `--`, matching managed-option insertion. Codex thread rotation retains a per-process +set of retired session IDs, so detector-confirmed and hook-driven rotation accept a new thread without allowing a +late event to rebind an old one. Login-shell facts now end with an explicit marker and must form one ordered, +contiguous record, preserving unrelated profile output while rejecting multiline value truncation. + +The same review hardened descriptor exhaustion and lifecycle handling: peer descriptor duplication happens before +routing and fails the connection closed with a warning, and the monitor activates its owned DispatchSource during +construction so no suspended source can reach deinitialization. Seven focused regressions failed against the prior +implementation and passed ten repeated runs after correction. The review's proposed narrowing of menu split fallback +was rejected because menu/palette compatibility intentionally retries any failed saved split as a tab; the user guide +now documents that behavior. A finite managed first-generation lifetime remains a separate design decision: restoring +the old ten-second cutoff would reintroduce the verified slow-start failure, while indefinite registration remains +constrained by token, runtime, CWD, event, and ancestry validation. + +The descriptor-duplication regression initially used an unbounded client response read, which could stall the full +parallel test host under load. It now coordinates accept, peer close, and explicit rejection with semantic evidence. +The final serialized app suite passed 2549 tests in 40.8 seconds, and the default parallel suite completed without a +stall. Its only failures were the two pre-existing `ShellClientStreamingTests` cancellation timing flakes; both passed +immediately in an isolated two-test run. Static format/lint checks, the CLI build and 97-test integration suite, and +the Debug app build also passed. A final read-only adversarial review found no P0/P1 defects; its sole P2 corrected +user documentation that still described the first managed generation as subject to the old acquisition deadline. + ## Deferred scope S3b owns Copilot/Droid/Qoder adapters. S3c owns Pi/OMP/OpenCode adapters and the Active Agents exact diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 7554c6fe..f8a4f41b 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -163,8 +163,9 @@ native event bridges without writing user, dedicated-home, or project configurat Only an app-issued token plus exact caller-process ancestry and matching pane/runtime/cwd can produce `hook_claude` / `hook_codex` evidence. The channel is not advertised as `verified_live` until a valid native event completes that end-to-end check. Early Claude -`SessionStart` payloads wait for the first timely process generation instead of being lost; -a late or replacement process, pane close, or launched-agent exit revokes coverage. +`SessionStart` payloads wait for the first matching process generation instead of being lost. +That first generation may attach after the detector's acquisition window; once attached, a +replacement process, pane close, or launched-agent exit revokes coverage. Codex exposes only one effective notifier. Before launch, Prowl asks Codex's own bounded `app-server config/read` protocol for the effective notifier, applies selected-profile and diff --git a/docs/components/agent-profiles.md b/docs/components/agent-profiles.md index 980e1f08..f9d7cf34 100644 --- a/docs/components/agent-profiles.md +++ b/docs/components/agent-profiles.md @@ -46,8 +46,10 @@ respawn. Every launch creates a **new** tab or split; Prowl never types the invocation into an existing shell. Toolbar and Command Palette launches use the Profile's saved placement in -the current worktree and start interactively with no initial prompt. CLI launches override -placement from the `create tab|pane` command and may supply the kickoff prompt. A prompted +the current worktree and start interactively with no initial prompt; if a saved split cannot +be created, these interactive launchers fall back to a foreground tab. CLI launches override +placement from the `create tab|pane` command, remain strict about pane placement, and may +supply the kickoff prompt. A prompted CLI launch is also an atomic dispatch: its create response includes a pending opaque receipt, the launched child alone receives `PROWL_DISPATCH_ID`, and the effective prompt tells the agent to finish with `prowl agents dispatch-complete --outcome … --summary …`; the required diff --git a/supacode/CLIService/CLISocketServer.swift b/supacode/CLIService/CLISocketServer.swift index 14807e24..c63f8527 100644 --- a/supacode/CLIService/CLISocketServer.swift +++ b/supacode/CLIService/CLISocketServer.swift @@ -9,6 +9,8 @@ import Foundation import Glibc #endif +private let cliSocketLogger = SupaLogger("CLISocketServer") + @MainActor final class CLISocketServer { private static let maximumFrameLength = 32 * 1_024 * 1_024 @@ -17,6 +19,8 @@ final class CLISocketServer { private let socketPath: String private let lockPath: String private let onClientAccepted: (@Sendable () -> Void)? + private let onPeerMonitorUnavailable: (@Sendable () -> Void)? + private let duplicatePeerDescriptor: @Sendable (Int32) -> Int32 private var serverFD: Int32 = -1 private var lockFD: Int32 = -1 private var ownsSocket = false @@ -28,12 +32,18 @@ final class CLISocketServer { router: CLICommandRouter, socketPath: String = ProwlSocket.defaultPath, lockPath: String? = nil, - onClientAccepted: (@Sendable () -> Void)? = nil + onClientAccepted: (@Sendable () -> Void)? = nil, + onPeerMonitorUnavailable: (@Sendable () -> Void)? = nil, + duplicatePeerDescriptor: @escaping @Sendable (Int32) -> Int32 = { + fcntl($0, F_DUPFD_CLOEXEC, 0) + } ) { self.router = router self.socketPath = socketPath self.lockPath = lockPath ?? "\(socketPath).lock" self.onClientAccepted = onClientAccepted + self.onPeerMonitorUnavailable = onPeerMonitorUnavailable + self.duplicatePeerDescriptor = duplicatePeerDescriptor } /// Start listening for CLI connections. @@ -241,16 +251,21 @@ final class CLISocketServer { } else { preservesRouteAfterDisconnect = false } + let monitorDescriptor = duplicatePeerDescriptor(clientFD) + guard monitorDescriptor >= 0 else { + cliSocketLogger.warning("Failed to duplicate a CLI peer descriptor") + onPeerMonitorUnavailable?() + return + } let routeTask = Task { @MainActor [router] in await router.route(envelope, context: context) } - let monitor = CLIPeerDisconnectMonitor(fileDescriptor: clientFD) { + let monitor = CLIPeerDisconnectMonitor(ownedFileDescriptor: monitorDescriptor) { if !preservesRouteAfterDisconnect { routeTask.cancel() } } - monitor?.start() let response = await routeTask.value - monitor?.cancel() - guard monitor?.didDisconnect != true else { return } + monitor.cancel() + guard !monitor.didDisconnect else { return } // Encode and send response let encoder = JSONEncoder() @@ -416,18 +431,27 @@ nonisolated final class CLIPeerDisconnectMonitor: @unchecked Sendable { private let lock = NSLock() private var disconnected = false - init?(fileDescriptor: Int32, onDisconnect: @escaping @Sendable () -> Void) { - let ownedDescriptor = fcntl(fileDescriptor, F_DUPFD_CLOEXEC, 0) - guard ownedDescriptor >= 0 else { return nil } + convenience init?( + fileDescriptor: Int32, + duplicateDescriptor: @Sendable (Int32) -> Int32 = { fcntl($0, F_DUPFD_CLOEXEC, 0) }, + onDisconnect: @escaping @Sendable () -> Void + ) { + let ownedFileDescriptor = duplicateDescriptor(fileDescriptor) + guard ownedFileDescriptor >= 0 else { return nil } + self.init(ownedFileDescriptor: ownedFileDescriptor, onDisconnect: onDisconnect) + } + + init(ownedFileDescriptor: Int32, onDisconnect: @escaping @Sendable () -> Void) { let source = DispatchSource.makeReadSource( - fileDescriptor: ownedDescriptor, + fileDescriptor: ownedFileDescriptor, queue: DispatchQueue.global(qos: .userInitiated) ) - self.fileDescriptor = ownedDescriptor + fileDescriptor = ownedFileDescriptor self.onDisconnect = onDisconnect self.source = source source.setEventHandler { [weak self] in self?.inspect() } - source.setCancelHandler { Darwin.close(ownedDescriptor) } + source.setCancelHandler { Darwin.close(ownedFileDescriptor) } + source.activate() } deinit { @@ -438,10 +462,6 @@ nonisolated final class CLIPeerDisconnectMonitor: @unchecked Sendable { lock.withLock { disconnected } } - func start() { - source.resume() - } - func cancel() { source.cancel() } diff --git a/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift b/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift index 258b765b..38bad2de 100644 --- a/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift +++ b/supacode/Domain/AgentRuntime/CodexEffectiveNotifyResolver.swift @@ -23,11 +23,12 @@ nonisolated private struct CodexLaunchOptionScanner { mutating func scan() throws { while index < arguments.count { + let argument = arguments[index] + if argument == "--" { break } if index == promptArgumentIndex { index += 1 continue } - let argument = arguments[index] if argument == "--ignore-user-config" { throw CodexLaunchContextError.ignoredUserConfig } diff --git a/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift b/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift index 9ccf6296..f29788be 100644 --- a/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift +++ b/supacode/Domain/AgentRuntime/CodexShellLaunchEnvironment.swift @@ -9,11 +9,13 @@ nonisolated enum CodexShellLaunchEnvironmentProbe { private static let executableMarker = "__PROWL_CODEX_EXECUTABLE__" private static let homeMarker = "__PROWL_CODEX_HOME_BASE__" private static let codexHomeMarker = "__PROWL_CODEX_HOME__" + private static let endMarker = "__PROWL_CODEX_END__" private static let script = """ executable="$(command -v -- codex)" || exit 1 printf '%s%s\n' '\(executableMarker)' "$executable" printf '%s%s\n' '\(homeMarker)' "${HOME-}" printf '%s%s\n' '\(codexHomeMarker)' "${CODEX_HOME-}" + printf '%s\n' '\(endMarker)' """ static func resolve( @@ -54,14 +56,21 @@ nonisolated enum CodexShellLaunchEnvironmentProbe { } private static func parse(_ output: String) -> [String: String]? { + let lines = output.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) let markers = [executableMarker, homeMarker, codexHomeMarker] - var values: [String: String] = [:] - for line in output.split(separator: "\n", omittingEmptySubsequences: false) { - let value = String(line) - guard let marker = markers.first(where: value.hasPrefix) else { continue } - guard values[marker] == nil else { return nil } - values[marker] = String(value.dropFirst(marker.count)) - } - return values.count == markers.count ? values : nil + guard + markers.allSatisfy({ marker in lines.count(where: { $0.hasPrefix(marker) }) == 1 }), + lines.count(where: { $0 == endMarker }) == 1, + let startIndex = lines.firstIndex(where: { $0.hasPrefix(executableMarker) }), + lines.indices.contains(startIndex + 3), + lines[startIndex + 1].hasPrefix(homeMarker), + lines[startIndex + 2].hasPrefix(codexHomeMarker), + lines[startIndex + 3] == endMarker + else { return nil } + return [ + executableMarker: String(lines[startIndex].dropFirst(executableMarker.count)), + homeMarker: String(lines[startIndex + 1].dropFirst(homeMarker.count)), + codexHomeMarker: String(lines[startIndex + 2].dropFirst(codexHomeMarker.count)), + ] } } diff --git a/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift b/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift index 8e61f8c4..b24f151f 100644 --- a/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift +++ b/supacode/Domain/AgentRuntime/CodexShellProbeProcess.swift @@ -138,11 +138,17 @@ nonisolated struct CodexShellProbeProcess: Sendable { var pollDescriptors = descriptors.map { pollfd(fd: $0.fileDescriptor, events: Int16(POLLIN | POLLHUP), revents: 0) } - let status = poll(&pollDescriptors, nfds_t(pollDescriptors.count), 25) + let processIsRunning = process.isRunning + let status = poll( + &pollDescriptors, + nfds_t(pollDescriptors.count), + processIsRunning ? 25 : 0 + ) if status < 0 { if errno == EINTR { continue } throw CodexShellProbeProcessError.processFailed } + if status == 0, !processIsRunning { break } try drainReadyDescriptors( &descriptors, pollDescriptors: pollDescriptors, diff --git a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift index 7e0eeba3..48098758 100644 --- a/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift +++ b/supacode/Features/Terminal/BusinessLogic/AgentObservationStore.swift @@ -18,6 +18,7 @@ private struct ManagedHookRegistrationRecord { var evidenceEpoch: UUID var processGeneration: AgentProcessGeneration? var sessionID: String? + var retiredSessionIDs: Set = [] var verified = false var pendingSignals: [PendingManagedHookSignal] = [] } @@ -290,6 +291,7 @@ final class AgentObservationStore { guard callerAncestry.contains(generation), managed.processGeneration == generation else { return .rejected } + if managed.retiredSessionIDs.contains(input.signal.sessionID) { return .rejected } if let currentSession = managed.sessionID, currentSession != input.signal.sessionID @@ -298,6 +300,9 @@ final class AgentObservationStore { input.runtime == .codex || (input.runtime == .claude && input.signal.event == .sessionStart) guard mayRotateSession else { return .rejected } + if input.runtime == .codex { + managed.retiredSessionIDs.insert(currentSession) + } record.evidenceEpoch = UUID() record.channels.removeAll() record.latestCurrentSignal = nil @@ -368,6 +373,7 @@ final class AgentObservationStore { { return AgentEvidenceEpochUpdate() } + let acceptedSessionID = acceptedManagedSessionID(sessionID, record: record) let firstGenerationIsTimely = processGeneration.map { $0.startedAt <= (record.firstProcessGenerationStartedBefore ?? .distantPast) @@ -389,8 +395,8 @@ final class AgentObservationStore { let sessionChanged = !processChanged && record.sessionID != nil - && sessionID != nil - && record.sessionID != sessionID + && acceptedSessionID != nil + && record.sessionID != acceptedSessionID var update = AgentEvidenceEpochUpdate() if processChanged || sessionChanged { record.evidenceEpoch = UUID() @@ -409,7 +415,14 @@ final class AgentObservationStore { managed.evidenceEpoch = record.evidenceEpoch managed.verified = false managed.pendingSignals.removeAll() - if managed.launch.runtime == .codex { managed.sessionID = nil } + if managed.launch.runtime == .codex { + if let managedSessionID = managed.sessionID, + managedSessionID != acceptedSessionID + { + managed.retiredSessionIDs.insert(managedSessionID) + } + managed.sessionID = nil + } record.managedHook = managed } } @@ -423,7 +436,7 @@ final class AgentObservationStore { managed.pendingSignals.removeAll() record.managedHook = managed record.processGeneration = processGeneration - if let sessionID { record.sessionID = sessionID } + if let acceptedSessionID { record.sessionID = acceptedSessionID } records[surfaceID] = record for pendingSignal in pending { if case .accepted(let signal, _) = recordManagedHook( @@ -437,11 +450,23 @@ final class AgentObservationStore { return update } record.processGeneration = processGeneration - if let sessionID { record.sessionID = sessionID } + if let acceptedSessionID { record.sessionID = acceptedSessionID } records[surfaceID] = record return update } + private func acceptedManagedSessionID( + _ sessionID: String?, + record: SurfaceRecord + ) -> String? { + guard let sessionID, + let managed = record.managedHook, + managed.launch.runtime == .codex, + managed.retiredSessionIDs.contains(sessionID) + else { return sessionID } + return nil + } + func bindingForSignal( surfaceID: UUID, generationMatches: Bool, diff --git a/supacodeTests/CLISocketServerTests.swift b/supacodeTests/CLISocketServerTests.swift index 9c145ae7..adac2b67 100644 --- a/supacodeTests/CLISocketServerTests.swift +++ b/supacodeTests/CLISocketServerTests.swift @@ -88,6 +88,62 @@ struct CLISocketServerTests { #expect(!CLISocketServer.isAllowedPeerUID(502, currentUID: 501)) } + @Test func descriptorDuplicationFailureRejectsConnectionBeforeRouting() async throws { + let socketPath = temporarySocketPath(suffix: "monitor-dup-failure") + let pane = CallerPane(worktreeID: "wt", surfaceID: UUID()) + var recordedSignal: AgentSignal? + let handler = AgentSignalCommandHandler( + resolveCaller: { _ in pane }, + recordSignal: { _, signal in + recordedSignal = signal + return true + } + ) + let accepted = DispatchSemaphore(value: 0) + let closed = DispatchSemaphore(value: 0) + let peerClosed = DisconnectObservation() + let rejected = DisconnectObservation() + let server = CLISocketServer( + router: CLICommandRouter(agentsSignalHandler: handler), + socketPath: socketPath, + onClientAccepted: { + accepted.signal() + if closed.wait(timeout: .now() + 10) == .success { + peerClosed.signal() + } + }, + onPeerMonitorUnavailable: { rejected.signal() }, + duplicatePeerDescriptor: { _ in -1 } + ) + try server.start() + defer { server.stop() } + let requestData = try JSONEncoder().encode( + CommandEnvelope( + output: .json, + command: .agentsSignal(AgentSignalInput(event: .turnEnded, detail: "must not route")) + ) + ) + + try await Task.detached { + try Self.sendAndCloseAfterAcceptance( + requestData: requestData, + socketPath: socketPath, + accepted: accepted, + closed: closed + ) + }.value + let closedBeforeRouting = await Task.detached { + peerClosed.wait(timeout: .now() + 10) + }.value + #expect(closedBeforeRouting) + let connectionRejected = await Task.detached { + rejected.wait(timeout: .now() + 10) + }.value + + #expect(connectionRejected) + #expect(recordedSignal == nil) + } + @Test func socketRoundTripThreadsKernelPeerPIDIntoAgentSignalHandler() async throws { let socketPath = temporarySocketPath(suffix: "signal-context") let pane = CallerPane(worktreeID: "wt", surfaceID: UUID()) @@ -297,6 +353,26 @@ struct CLISocketServerTests { } #if canImport(Darwin) + @Test func disconnectMonitorActivatesDuringCreation() async throws { + var descriptors: [Int32] = [-1, -1] + #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) + defer { close(descriptors[0]) } + let observation = DisconnectObservation() + let monitor = try #require( + CLIPeerDisconnectMonitor(fileDescriptor: descriptors[0]) { + observation.signal() + } + ) + + close(descriptors[1]) + let activatedDuringCreation = await Task.detached { + observation.wait(timeout: .now() + 1) + }.value + + #expect(activatedDuringCreation) + monitor.cancel() + } + @Test func disconnectMonitorOutlivesOriginalDescriptor() async throws { var descriptors: [Int32] = [-1, -1] #expect(socketpair(AF_UNIX, SOCK_STREAM, 0, &descriptors) == 0) @@ -306,7 +382,6 @@ struct CLISocketServerTests { observation.signal() } ) - monitor.start() close(descriptors[0]) close(descriptors[1]) @@ -378,28 +453,6 @@ struct CLISocketServerTests { return try readExact(socketFD, count: Int(responseLength)) } - nonisolated private static func sendAndClose( - requestData: Data, - socketPath: String - ) throws { - let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) - guard socketFD >= 0 else { throw CLIServiceError.socketCreationFailed } - defer { close(socketFD) } - - let connected = withSocketAddress(socketPath) { address in - withUnsafePointer(to: address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketPointer in - connect(socketFD, socketPointer, socklen_t(MemoryLayout.size)) - } - } - } - guard connected == 0 else { throw TestSocketClientError.connectFailed } - - var requestLength = UInt32(requestData.count).bigEndian - try withUnsafeBytes(of: &requestLength) { try writeAll(socketFD, buffer: $0) } - try requestData.withUnsafeBytes { try writeAll(socketFD, buffer: $0) } - } - nonisolated private static func sendAndCloseAfterAcceptance( requestData: Data, socketPath: String, diff --git a/supacodeTests/CodexEffectiveNotifyResolverTests.swift b/supacodeTests/CodexEffectiveNotifyResolverTests.swift index ae5f190d..ed2ca9df 100644 --- a/supacodeTests/CodexEffectiveNotifyResolverTests.swift +++ b/supacodeTests/CodexEffectiveNotifyResolverTests.swift @@ -133,6 +133,22 @@ struct CodexEffectiveNotifyResolverTests { } } + @Test func launchContextStopsScanningOptionsAtSentinel() throws { + let base = temporaryDirectory("codex-sentinel") + let context = try CodexLaunchContext.capture( + invocation: AgentInvocation( + executable: "codex", + arguments: ["exec", "--", "-C", "/must-remain-literal", "Prompt"] + ), + inheritedCWD: base, + environment: [:], + promptArgumentIndex: 4 + ) + + #expect(context.effectiveCWD == base.standardizedFileURL) + #expect(context.configOverrides.isEmpty) + } + @Test func positionalPromptNeverBecomesAConfigOverride() throws { let base = temporaryDirectory("codex-prompt") let invocation = AgentInvocation( diff --git a/supacodeTests/CodexShellLaunchEnvironmentTests.swift b/supacodeTests/CodexShellLaunchEnvironmentTests.swift index cf702941..bd49b88b 100644 --- a/supacodeTests/CodexShellLaunchEnvironmentTests.swift +++ b/supacodeTests/CodexShellLaunchEnvironmentTests.swift @@ -14,6 +14,7 @@ struct CodexShellLaunchEnvironmentTests { __PROWL_CODEX_EXECUTABLE__/opt/custom/bin/codex __PROWL_CODEX_HOME_BASE__/Users/tester __PROWL_CODEX_HOME__/tmp/codex-home + __PROWL_CODEX_END__ """, stderr: "ignored", @@ -44,6 +45,7 @@ struct CodexShellLaunchEnvironmentTests { __PROWL_CODEX_EXECUTABLE__/opt/codex __PROWL_CODEX_HOME_BASE__/Users/tester __PROWL_CODEX_HOME__ + __PROWL_CODEX_END__ Login complete """, stderr: "", @@ -62,6 +64,30 @@ struct CodexShellLaunchEnvironmentTests { #expect(environment.processEnvironment == ["HOME": "/Users/tester"]) } + @Test func multilineMarkerValueDegradesInsteadOfUsingItsFirstLine() async { + let output = ShellOutput( + stdout: """ + Login banner + __PROWL_CODEX_EXECUTABLE__/opt/codex + __PROWL_CODEX_HOME_BASE__/Users/tester + __PROWL_CODEX_HOME__/tmp/first + second + __PROWL_CODEX_END__ + Login complete + """, + stderr: "", + exitCode: 0 + ) + + #expect( + await CodexShellLaunchEnvironmentProbe.resolve( + cwd: URL(filePath: "/tmp", directoryHint: .isDirectory), + run: { _, _ in output }, + isExecutable: { $0 == "/opt/codex" } + ) == nil + ) + } + @Test func malformedNonAbsoluteAndFailedProbeDegrade() async { for output in [ ShellOutput(stdout: "not-json", stderr: "", exitCode: 0), @@ -101,6 +127,7 @@ struct CodexShellLaunchEnvironmentTests { __PROWL_CODEX_EXECUTABLE__/custom/bin/codex __PROWL_CODEX_HOME_BASE__/Users/tester __PROWL_CODEX_HOME__ + __PROWL_CODEX_END__ """ let result = await CodexShellLaunchEnvironmentProbe.resolve( diff --git a/supacodeTests/CodexShellProbeProcessTests.swift b/supacodeTests/CodexShellProbeProcessTests.swift index 98765521..2775a1f8 100644 --- a/supacodeTests/CodexShellProbeProcessTests.swift +++ b/supacodeTests/CodexShellProbeProcessTests.swift @@ -22,6 +22,32 @@ struct CodexShellProbeProcessTests { #expect(output.exitCode == 0) } + @Test func successfulChildDoesNotWaitForBackgroundDescendantEOF() async throws { + let root = temporaryDirectory("shell-background-child") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let shell = try executableScript( + in: root, + name: "background-child.sh", + contents: """ + #!/bin/sh + printf stdout + sleep 2 & + """ + ) + let process = CodexShellProbeProcess( + timeout: 0.5, + maximumOutputBytes: 1_024, + shellOverride: URL(filePath: "/bin/sh"), + shellOverrideArguments: [shell.path(percentEncoded: false)] + ) + + let output = try await process.run(cwd: root, script: "ignored") + + #expect(output.stdout == "stdout") + #expect(output.exitCode == 0) + } + @Test func hardTimeoutKillsLoginShellThatIgnoresTermination() async throws { let root = temporaryDirectory("shell-timeout") try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) diff --git a/supacodeTests/ManagedAgentHookObservationTests.swift b/supacodeTests/ManagedAgentHookObservationTests.swift index e150badf..0ab89b8e 100644 --- a/supacodeTests/ManagedAgentHookObservationTests.swift +++ b/supacodeTests/ManagedAgentHookObservationTests.swift @@ -313,6 +313,68 @@ struct ManagedAgentHookObservationTests { #expect(channel.sessionID == "thread-2") } + @Test func detectorConfirmedThreadRotationRejectsLatePreviousThreadHook() { + let now = Date(timeIntervalSince1970: 100) + let generation = AgentProcessGeneration(pid: 900, startedAt: now) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .codex, cwd: "/tmp/project") + _ = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch(surfaceID: surfaceID, processGeneration: generation, sessionID: nil) + let first = makeInput( + runtime: .codex, + token: registration.token, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project", + sessionID: "thread-1" + ) + #expect(store.recordManagedHook(first, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + + _ = store.updateEvidenceEpoch( + surfaceID: surfaceID, + processGeneration: generation, + sessionID: "thread-2" + ) + + #expect(!store.recordManagedHook(first, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + let second = makeInput( + runtime: .codex, + token: registration.token, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project", + sessionID: "thread-2" + ) + #expect(store.recordManagedHook(second, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + } + + @Test func hookDrivenThreadRotationRejectsLatePreviousThreadHook() { + let now = Date(timeIntervalSince1970: 100) + let generation = AgentProcessGeneration(pid: 900, startedAt: now) + let store = AgentObservationStore(bufferCapacity: 8, now: { now }) + let surfaceID = UUID() + let registration = makeRegistration(runtime: .codex, cwd: "/tmp/project") + _ = store.registerManagedHook(registration, surfaceID: surfaceID) + _ = store.updateEvidenceEpoch(surfaceID: surfaceID, processGeneration: generation, sessionID: nil) + let first = makeInput( + runtime: .codex, + token: registration.token, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project", + sessionID: "thread-1" + ) + let second = makeInput( + runtime: .codex, + token: registration.token, + nativeEvent: "agent-turn-complete", + cwd: "/tmp/project", + sessionID: "thread-2" + ) + #expect(store.recordManagedHook(first, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + #expect(store.recordManagedHook(second, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + + #expect(!store.recordManagedHook(first, callerAncestry: [generation], surfaceID: surfaceID).isAccepted) + } + @Test func processReplacementClearsThePreviousSessionIdentity() { let now = Date(timeIntervalSince1970: 100) let first = AgentProcessGeneration(pid: 900, startedAt: now) -- 2.51.2