diff --git a/Sources/AgentIsolation/AgentSession.swift b/Sources/AgentIsolation/AgentSession.swift index 7e7e25d..e18a3d7 100644 --- a/Sources/AgentIsolation/AgentSession.swift +++ b/Sources/AgentIsolation/AgentSession.swift @@ -1,3 +1,5 @@ +import Synchronization + #if canImport(FoundationEssentials) import FoundationEssentials #else @@ -9,6 +11,18 @@ private struct AgentConfigurationSettings: Decodable { var additionalMounts: [String]? } +/// Errors surfaced by ``AgentSession``. +public enum AgentSessionError: Error, Sendable { + /// ``AgentSession/write(_:)`` or ``AgentSession/resize(cols:rows:)`` was called + /// on a session whose ``IsolationConfig/customPTY`` is `false`. + case customPTYNotEnabled + /// ``AgentSession/wait()``, ``AgentSession/resize(cols:rows:)``, or + /// ``AgentSession/write(_:)`` was called before ``AgentSession/start(entrypoint:timeout:)``. + case notStarted + /// ``AgentSession/start(entrypoint:timeout:)`` was called more than once. + case alreadyStarted +} + /// Orchestrates running an isolated agent container session using a ``ContainerRuntime``. /// /// `AgentSession` is responsible for: @@ -17,21 +31,84 @@ private struct AgentConfigurationSettings: Decodable { /// - Building container mounts (profile home, workspace, exclude overlays, configurations, additional mounts) /// - Configuring and running the container /// - Performing necessary cleanups (temp dirs) -public struct AgentSession: Sendable { +/// +/// The session is object-oriented: construct once with ``init(config:runtime:)``, +/// launch with ``start(entrypoint:timeout:)``, then drive I/O via ``rawOut``, +/// ``write(_:)``, ``resize(cols:rows:)``, and ``wait()``. +/// +/// When ``IsolationConfig/customPTY`` is `false` (the default), the container +/// attaches to the current terminal (or standard streams) just like before; +/// ``rawOut`` finishes immediately on ``start(entrypoint:timeout:)`` and +/// ``write(_:)``/``resize(cols:rows:)`` throw +/// ``AgentSessionError/customPTYNotEnabled``. +public final class AgentSession: Sendable { public let config: IsolationConfig public let runtime: Runtime + private let stdinStream: AsyncStream + private let stdinContinuation: AsyncStream.Continuation + private let rawOutStream: AsyncStream<[UInt8]> + private let rawOutContinuation: AsyncStream<[UInt8]>.Continuation + + private struct State: ~Copyable { + var container: Runtime.Container? = nil + var tempDirs: [URL] = [] + var timeoutInSeconds: Int64? = nil + var hasStarted: Bool = false + var waited: Bool = false + } + private let state = Mutex(State()) + public init(config: IsolationConfig, runtime: Runtime) { self.config = config self.runtime = runtime + (self.stdinStream, self.stdinContinuation) = AsyncStream.makeStream( + bufferingPolicy: .unbounded) + (self.rawOutStream, self.rawOutContinuation) = AsyncStream<[UInt8]>.makeStream( + bufferingPolicy: .unbounded) + } + + /// A sequence of raw bytes produced by the container's PTY. + /// + /// When ``IsolationConfig/customPTY`` is `false`, iteration ends as soon as + /// ``start(entrypoint:timeout:)`` completes. Otherwise, bytes stream in as + /// the container writes to its terminal and the sequence finishes when the + /// container's output closes. + public var rawOut: some AsyncSequence<[UInt8], Never> { + rawOutStream } - /// Run the agent session and return the container process exit code. + /// Start the agent session. /// - /// - Parameter entrypoint: Optional entrypoint override. When non-nil, the bootstrap - /// executes this instead of the last configuration's entrypoint (e.g. `["/bin/bash"]` - /// for an interactive shell, or `["/bin/bash", "-c", "ls -la"]` for a command). - public func run(entrypoint entrypointOverride: [String]? = nil) async throws -> Int32 { + /// Prepares the runtime, resolves mounts, creates the container, and starts + /// it. I/O routing depends on ``IsolationConfig/customPTY``: + /// - `false`: attaches to the current terminal (when ``IsolationConfig/allocateTTY`` + /// is `true`) or to the parent process's stdio. + /// - `true`: allocates a custom PTY wired up to ``rawOut`` / + /// ``write(_:)`` / ``resize(cols:rows:)``. + /// + /// - Parameters: + /// - entrypointOverride: Optional entrypoint override. When non-nil, the + /// bootstrap executes this instead of the last configuration's entrypoint + /// (e.g. `["/bin/bash"]` for an interactive shell). + /// - timeout: Optional timeout (seconds) forwarded to ``wait()``. + public func start( + entrypoint entrypointOverride: [String]? = nil, + timeout: Int64? = nil + ) async throws { + try state.withLock { state in + guard !state.hasStarted else { throw AgentSessionError.alreadyStarted } + state.hasStarted = true + state.timeoutInSeconds = timeout + } + + if !config.customPTY { + // In non-custom mode, nothing will ever be fed through the rawOut/stdin + // streams — close them up front so consumers see an immediate EOF. + rawOutContinuation.finish() + stdinContinuation.finish() + } + try await runtime.prepare() let canonicalWorkspace = AgentIsolationPathUtils.resolveSymlinksWithPlatformConsiderations( @@ -45,6 +122,7 @@ public struct AgentSession: Sendable { // Build mounts list var mounts: [ContainerConfiguration.Mount] = [] + var tempDirs: [URL] = [] // Profile home → /home/agent mounts.append( @@ -61,13 +139,6 @@ public struct AgentSession: Sendable { )) // Excluded folders: each gets an empty temp dir mounted as a read-only overlay - var tempDirs: [URL] = [] - defer { - for dir in tempDirs { - try? FileManager.default.removeItem(at: dir) - } - } - for rawFolder in config.excludeFolders { let folder = rawFolder.trimmingCharacters(in: .init(charactersIn: "/")) guard !folder.isEmpty else { continue } @@ -115,7 +186,8 @@ public struct AgentSession: Sendable { // Additional host mounts (from CLI --additional-mount flags) for hostMount in config.additionalHostMounts { let canonical = AgentIsolationPathUtils.resolveSymlinksWithPlatformConsiderations(hostMount) - let containerPath = "/workspace/\(AgentIsolationPathUtils.pathIdentifier(for: canonical.path))" + let containerPath = + "/workspace/\(AgentIsolationPathUtils.pathIdentifier(for: canonical.path))" mounts.append( .init( hostPath: canonical.path, @@ -170,8 +242,17 @@ public struct AgentSession: Sendable { entrypoint = containerArgs } - let io: ContainerConfiguration.IO = - config.allocateTTY ? .currentTerminal : .standardIO + let io: ContainerConfiguration.IO + if config.customPTY { + io = .custom( + stdin: AgentSessionStdinReader(inner: stdinStream), + stdout: AgentSessionRawOutWriter(continuation: rawOutContinuation), + stderr: AgentSessionNullWriter(), + isTerminal: true + ) + } else { + io = config.allocateTTY ? .currentTerminal : .standardIO + } let containerConfig = ContainerConfiguration( entrypoint: entrypoint, @@ -184,29 +265,135 @@ public struct AgentSession: Sendable { memoryLimitMiB: config.memoryLimitMiB ) - let container = try await runtime.runContainer( - imageRef: config.image, - configuration: containerConfig - ) - defer { _runBlocking { try await runtime.removeContainer(container) } } + do { + let container = try await runtime.runContainer( + imageRef: config.image, + configuration: containerConfig + ) + state.withLock { state in + state.container = container + state.tempDirs = tempDirs + } + } catch { + // Container never came up — purge temp dirs eagerly and finish streams. + for dir in tempDirs { + try? FileManager.default.removeItem(at: dir) + } + rawOutContinuation.finish() + stdinContinuation.finish() + throw error + } + } + + /// Push bytes into the container's PTY input. + /// + /// Throws ``AgentSessionError/customPTYNotEnabled`` when ``IsolationConfig/customPTY`` + /// is `false`, or ``AgentSessionError/notStarted`` if called before + /// ``start(entrypoint:timeout:)``. + public func write(_ data: Data) throws { + guard config.customPTY else { throw AgentSessionError.customPTYNotEnabled } + let started = state.withLock { $0.hasStarted } + guard started else { throw AgentSessionError.notStarted } + stdinContinuation.yield(data) + } + + /// Resize the container's PTY. + /// + /// Throws ``AgentSessionError/customPTYNotEnabled`` when ``IsolationConfig/customPTY`` + /// is `false`, or ``AgentSessionError/notStarted`` if called before + /// ``start(entrypoint:timeout:)``. + public func resize(cols: Int, rows: Int) async throws { + guard config.customPTY else { throw AgentSessionError.customPTYNotEnabled } + let container = state.withLock { $0.container } + guard let container else { throw AgentSessionError.notStarted } + try await container.resize(cols: cols, rows: rows) + } + + /// Wait for the container to exit, then clean up temporary resources and + /// return the exit code. + public func wait() async throws -> Int32 { + let (container, timeout, alreadyWaited) = state.withLock { + state -> (Runtime.Container?, Int64?, Bool) in + let result = (state.container, state.timeoutInSeconds, state.waited) + state.waited = true + return result + } + guard !alreadyWaited else { + // Idempotent: a second wait just throws `notStarted` if nothing is live. + throw AgentSessionError.notStarted + } + guard let container else { + throw AgentSessionError.notStarted + } - let exitCode = try await container.wait(timeoutInSeconds: nil) + let exitCode: Int32 + do { + exitCode = try await container.wait(timeoutInSeconds: timeout) + } catch { + await cleanup(container: container) + throw error + } try await container.stop() + await cleanup(container: container) return exitCode } // MARK: - Helpers + private func cleanup(container: Runtime.Container) async { + // Signal consumers that no further IO will arrive. + rawOutContinuation.finish() + stdinContinuation.finish() + + try? await runtime.removeContainer(container) + + let dirs = state.withLock { state -> [URL] in + let d = state.tempDirs + state.tempDirs = [] + state.container = nil + return d + } + for dir in dirs { + try? FileManager.default.removeItem(at: dir) + } + } + private func makeTempDir() throws -> URL { let dir = URL(fileURLWithPath: "/tmp/agentc-\(UUID().uuidString.lowercased())") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } +} + +// MARK: - Custom IO plumbing + +/// Adapts ``AgentSession``'s internal stdin stream to the runtime's +/// ``ReaderStream`` protocol. `stream()` must only be called once. +private struct AgentSessionStdinReader: ReaderStream { + let inner: AsyncStream + func stream() -> AsyncStream { + inner + } +} + +/// A ``Writer`` that pushes bytes into an ``AsyncStream`` continuation so +/// they surface via ``AgentSession/rawOut``. +private struct AgentSessionRawOutWriter: Writer { + let continuation: AsyncStream<[UInt8]>.Continuation + + func write(_ data: Data) throws { + continuation.yield(Array(data)) + } + + func close() throws { + continuation.finish() + } } -/// Fire-and-forget helper for calling async cleanup in a defer block. -private func _runBlocking(_ body: @escaping @Sendable () async throws -> Void) { - // Best-effort cleanup — runs on a detached task since defer can't be async. - Task { try? await body() } +/// A ``Writer`` that discards everything. Used for the stderr slot in raw-PTY +/// mode, where a terminal merges stderr into stdout anyway. +private struct AgentSessionNullWriter: Writer { + func write(_ data: Data) throws {} + func close() throws {} } diff --git a/Sources/AgentIsolation/ContainerRuntime.swift b/Sources/AgentIsolation/ContainerRuntime.swift index 7996b04..8dbe7b9 100644 --- a/Sources/AgentIsolation/ContainerRuntime.swift +++ b/Sources/AgentIsolation/ContainerRuntime.swift @@ -145,4 +145,26 @@ public protocol ContainerRuntimeContainer: Identifiable, Sendable, AnyObject { /// /// Will be called even after ``wait(timeoutInSeconds:)``. func stop() async throws + + /// Resize the container's PTY, in character cells. + /// + /// Only meaningful when the container was started with a terminal + /// (e.g. ``ContainerConfiguration/IO/currentTerminal`` or + /// ``ContainerConfiguration/IO/custom(stdin:stdout:stderr:isTerminal:)`` with + /// `isTerminal: true`). The default implementation throws + /// ``ContainerRuntimeError/resizeNotSupported``; runtimes that support + /// resizing should override it. + func resize(cols: Int, rows: Int) async throws +} + +extension ContainerRuntimeContainer { + public func resize(cols: Int, rows: Int) async throws { + throw ContainerRuntimeError.resizeNotSupported + } +} + +/// Errors reported by conforming ``ContainerRuntime`` implementations. +public enum ContainerRuntimeError: Error, Sendable { + /// The runtime does not support resizing this container's PTY. + case resizeNotSupported } diff --git a/Sources/AgentIsolation/IsolationConfig.swift b/Sources/AgentIsolation/IsolationConfig.swift index 26d11af..f1862fa 100644 --- a/Sources/AgentIsolation/IsolationConfig.swift +++ b/Sources/AgentIsolation/IsolationConfig.swift @@ -61,6 +61,16 @@ public struct IsolationConfig: Sendable { /// prints extra information (e.g. prepare.sh progress). public var verbose: Bool + /// When true, the session allocates a raw PTY whose bytes flow through + /// ``AgentSession/rawOut`` and accept input via ``AgentSession/write(_:)``, + /// with ``AgentSession/resize(cols:rows:)`` to adjust the terminal size. + /// + /// When false (the default), the session attaches to the current terminal + /// or standard streams per ``allocateTTY``; in that mode ``AgentSession/rawOut`` + /// finishes immediately on ``AgentSession/start(entrypoint:timeout:)`` and + /// ``AgentSession/write(_:)`` / ``AgentSession/resize(cols:rows:)`` throw. + public var customPTY: Bool + public init( image: String, profileHomeDir: URL, @@ -74,7 +84,8 @@ public struct IsolationConfig: Sendable { cpuCount: Int = 1, memoryLimitMiB: Int = 1536, additionalHostMounts: [URL] = [], - verbose: Bool = false + verbose: Bool = false, + customPTY: Bool = false ) { self.image = image self.profileHomeDir = profileHomeDir @@ -89,5 +100,6 @@ public struct IsolationConfig: Sendable { self.memoryLimitMiB = memoryLimitMiB self.additionalHostMounts = additionalHostMounts self.verbose = verbose + self.customPTY = customPTY } } diff --git a/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift b/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift index 70713cd..c61e91a 100644 --- a/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift +++ b/Sources/AgentIsolationAppleContainerRuntime/AppleContainerRuntime.swift @@ -224,7 +224,8 @@ let firstComponent = name[.. (Int, Int) async throws -> Void = { c in + { cols, rows in try await c.resize(cols: cols, rows: rows) } + } + _ = resizeFunc + } } #endif diff --git a/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift b/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift index 342006d..8c814f1 100644 --- a/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift +++ b/Tests/AgentIsolationDockerRuntimeTests/DockerRuntimeTests.swift @@ -434,6 +434,43 @@ try await runtime.removeImage(ref: "alpine:3.18") } + @Test("resize forwards to Docker API for a TTY container") + func resizeOnTTYContainer() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + print("DIAG resizeOnTTYContainer: pulled alpine:latest") + + // Use a sleep so the container stays alive long enough to resize. + // Attach with custom IO + isTerminal so useTTY=true. + let config = ContainerConfiguration( + entrypoint: ["/bin/sh", "-c", "sleep 2"], + io: .custom( + stdin: EmptyReaderStream(), + stdout: MockWriter(), + stderr: MockWriter(), + isTerminal: true) + ) + + let container = try await runtime.runContainer( + imageRef: "alpine:latest", configuration: config) + print("DIAG resizeOnTTYContainer: container=\(container.id) started") + + do { + try await container.resize(cols: 100, rows: 30) + print("DIAG resizeOnTTYContainer: resize call succeeded") + } catch { + print("DIAG resizeOnTTYContainer: resize failed with error=\(error)") + try? await runtime.removeContainer(container) + throw error + } + + let exitCode = try await container.wait(timeoutInSeconds: 10) + print("DIAG resizeOnTTYContainer: exitCode=\(exitCode)") + try await runtime.removeContainer(container) + } + @Test("runContainer executes a command and returns exit code") func runContainer() async throws { let runtime = makeRuntime() @@ -519,6 +556,113 @@ #expect(!stderr.string.contains("to-stdout")) } + @Test("AgentSession customPTY streams output via rawOut and accepts resize") + func agentSessionCustomPTY() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + let tmpBase = URL(fileURLWithPath: "/tmp/claudec-test-custompty-docker-\(UUID().uuidString)") + let profileDir = tmpBase.appendingPathComponent("home") + let configsDir = tmpBase.appendingPathComponent("configurations") + try FileManager.default.createDirectory( + at: configsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpBase) } + + let config = IsolationConfig( + image: "alpine:latest", + profileHomeDir: profileDir, + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: configsDir, + configurations: [], + bootstrapMode: .imageDefault, + arguments: ["/bin/sh", "-c", "echo pty-ok; sleep 0.2"], + customPTY: true + ) + + let session = AgentSession(config: config, runtime: runtime) + + // Collect rawOut off the task so it runs concurrently with start/wait. + let collector = Task { () -> Data in + var buf = Data() + for await chunk in session.rawOut { + buf.append(contentsOf: chunk) + } + return buf + } + + try await session.start() + print("DIAG agentSessionCustomPTY: session started") + try await session.resize(cols: 100, rows: 30) + print("DIAG agentSessionCustomPTY: resize invoked") + + let exitCode = try await session.wait() + let output = await collector.value + print( + "DIAG agentSessionCustomPTY: exitCode=\(exitCode) bytes=\(output.count) text=\(String(data: output, encoding: .utf8) ?? "")" + ) + + #expect(exitCode == 0) + #expect(String(data: output, encoding: .utf8)?.contains("pty-ok") == true) + } + + @Test("AgentSession customPTY write delivers stdin to container") + func agentSessionCustomPTYWrite() async throws { + let runtime = makeRuntime() + defer { Task { try? await runtime.shutdown() } } + try await runtime.prepare() + _ = try await runtime.pullImage(ref: "alpine:latest") + + let tmpBase = URL( + fileURLWithPath: "/tmp/claudec-test-custompty-docker-write-\(UUID().uuidString)") + let profileDir = tmpBase.appendingPathComponent("home") + let configsDir = tmpBase.appendingPathComponent("configurations") + try FileManager.default.createDirectory( + at: configsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpBase) } + + // `head -c 5` echoes the first 5 stdin bytes then exits. + let config = IsolationConfig( + image: "alpine:latest", + profileHomeDir: profileDir, + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: configsDir, + configurations: [], + bootstrapMode: .imageDefault, + arguments: ["/bin/sh", "-c", "head -c 5"], + customPTY: true + ) + + let session = AgentSession(config: config, runtime: runtime) + let collector = Task { () -> Data in + var buf = Data() + for await chunk in session.rawOut { + buf.append(contentsOf: chunk) + if buf.count >= 5 { break } + } + return buf + } + + try await session.start() + print("DIAG agentSessionCustomPTYWrite: session started") + // Include a newline so the PTY's cooked line discipline commits the + // bytes to the child (`head -c 5` otherwise blocks waiting for EOL). + try session.write(Data("hello\n".utf8)) + print("DIAG agentSessionCustomPTYWrite: wrote 6 bytes") + + let exitCode = try await session.wait() + let output = await collector.value + print( + "DIAG agentSessionCustomPTYWrite: exitCode=\(exitCode) bytes=\(output.count) text=\(String(data: output, encoding: .utf8) ?? "")" + ) + + #expect(exitCode == 0) + // A TTY echoes input, so the output contains our bytes (possibly with + // an echoed prefix). Verify the payload appears somewhere. + #expect(String(data: output, encoding: .utf8)?.contains("hello") == true) + } + @Test("custom IO sends stdin to container") func sendsStdin() async throws { let runtime = makeRuntime() diff --git a/Tests/AgentIsolationTests/AgentSessionTests.swift b/Tests/AgentIsolationTests/AgentSessionTests.swift index 0ad65ae..ea3a2f7 100644 --- a/Tests/AgentIsolationTests/AgentSessionTests.swift +++ b/Tests/AgentIsolationTests/AgentSessionTests.swift @@ -20,7 +20,8 @@ struct AgentSessionTests { arguments: ["echo", "hello"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() #expect(runtime.prepareCallCount == 1) } @@ -39,7 +40,8 @@ struct AgentSessionTests { arguments: ["echo", "test"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() #expect(runtime.lastImageRef == "ghcr.io/test/image:v1") } @@ -64,7 +66,8 @@ struct AgentSessionTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let mounts = runtime.lastContainerConfiguration!.mounts let homeMount = mounts.first { $0.containerPath == "/home/agent" } @@ -91,7 +94,8 @@ struct AgentSessionTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let canonicalPath = wsDir.resolvingSymlinksInPath().path let expectedPath: String @@ -135,7 +139,8 @@ struct AgentSessionTests { arguments: ["pwd"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let workDir = runtime.lastContainerConfiguration!.workingDirectory #expect(workDir != nil) @@ -165,7 +170,8 @@ struct AgentSessionTests { arguments: ["ls"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let mounts = runtime.lastContainerConfiguration!.mounts let excludeMounts = mounts.filter { @@ -195,7 +201,8 @@ struct AgentSessionTests { arguments: ["sh", "echo", "ok"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let entrypoint = runtime.lastContainerConfiguration!.entrypoint #expect(entrypoint.first == "/entrypoint-bootstrap/bootstrap") @@ -224,7 +231,8 @@ struct AgentSessionTests { arguments: ["echo", "hello"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let entrypoint = runtime.lastContainerConfiguration!.entrypoint #expect(entrypoint == ["echo", "hello"]) @@ -251,7 +259,8 @@ struct AgentSessionTests { arguments: ["exit", "42"] ) let session = AgentSession(config: config, runtime: runtime) - let exitCode = try await session.run() + try await session.start() + let exitCode = try await session.wait() #expect(exitCode == 42) } @@ -271,7 +280,8 @@ struct AgentSessionTests { allocateTTY: true ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() if case .currentTerminal = runtime.lastContainerConfiguration!.io { // expected @@ -295,7 +305,8 @@ struct AgentSessionTests { allocateTTY: false ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() if case .standardIO = runtime.lastContainerConfiguration!.io { // expected @@ -323,7 +334,8 @@ struct AgentSessionTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() var isDir: ObjCBool = false #expect(FileManager.default.fileExists(atPath: profileDir.path, isDirectory: &isDir)) @@ -345,7 +357,8 @@ struct AgentSessionTests { cpuCount: 4 ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() #expect(runtime.lastContainerConfiguration?.cpuCount == 4) } @@ -365,7 +378,8 @@ struct AgentSessionTests { memoryLimitMiB: 2048 ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() #expect(runtime.lastContainerConfiguration?.memoryLimitMiB == 2048) } @@ -470,7 +484,8 @@ struct ConfigurationTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let mounts = runtime.lastContainerConfiguration!.mounts let cfgMount = mounts.first { $0.containerPath == "/agent-isolation/agents" } @@ -499,7 +514,8 @@ struct ConfigurationTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let env = runtime.lastContainerConfiguration!.environment #expect(env["AGENTC_CONFIGURATIONS"] == "claude,swift") @@ -530,7 +546,8 @@ struct ConfigurationTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let mounts = runtime.lastContainerConfiguration!.mounts let additionalMount = mounts.first { $0.containerPath == "/data/models" } @@ -571,7 +588,8 @@ struct ConfigurationTests { arguments: ["hello"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let mounts = runtime.lastContainerConfiguration!.mounts #expect(mounts.contains { $0.containerPath == "/opt/tools" }) @@ -601,7 +619,8 @@ struct ConfigurationTests { ) let session = AgentSession(config: config, runtime: runtime) // Should not throw — missing configs are skipped - _ = try await session.run() + try await session.start() + _ = try await session.wait() // Still has the standard mounts let mounts = runtime.lastContainerConfiguration!.mounts @@ -630,7 +649,8 @@ struct ConfigurationTests { arguments: ["original", "args"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run(entrypoint: ["/bin/bash", "-c", "ls -la"]) + try await session.start(entrypoint: ["/bin/bash", "-c", "ls -la"]) + _ = try await session.wait() let env = runtime.lastContainerConfiguration!.environment #expect(env["AGENTC_ENTRYPOINT_OVERRIDE"] == "1") @@ -660,7 +680,8 @@ struct ConfigurationTests { arguments: ["--print", "hello"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let env = runtime.lastContainerConfiguration!.environment #expect(env["AGENTC_ENTRYPOINT_OVERRIDE"] == nil) @@ -690,7 +711,8 @@ struct ConfigurationTests { arguments: [] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run(entrypoint: ["/bin/bash"]) + try await session.start(entrypoint: ["/bin/bash"]) + _ = try await session.wait() let env = runtime.lastContainerConfiguration!.environment #expect(env["AGENTC_ENTRYPOINT_OVERRIDE"] == "1") @@ -724,7 +746,8 @@ struct ConfigurationTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() // Verify the host directory was actually created let expectedSegment = AgentIsolationPathUtils.pathIdentifier(for: "/data/persistent") @@ -738,3 +761,210 @@ struct ConfigurationTests { #expect(isDir.boolValue) } } + +// MARK: - Custom PTY Tests + +@Suite("AgentSession customPTY") +struct AgentSessionCustomPTYTests { + + /// Build a minimal mock session in a scratch directory. + private func makeSession( + customPTY: Bool, + runtime: MockRuntime = MockRuntime(config: .init(storagePath: "/tmp")) + ) throws -> (AgentSession, URL) { + let base = URL(fileURLWithPath: "/tmp/claudec-test-custompty-\(UUID().uuidString)") + let profileDir = base.appendingPathComponent("home") + let configsDir = base.appendingPathComponent("configurations") + try FileManager.default.createDirectory(at: configsDir, withIntermediateDirectories: true) + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: configsDir, + configurations: [], + arguments: ["echo"], + customPTY: customPTY + ) + return (AgentSession(config: config, runtime: runtime), base) + } + + @Test("customPTY defaults to false") + func customPTYDefault() { + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: URL(fileURLWithPath: "/tmp/home"), + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: URL(fileURLWithPath: "/tmp") + ) + #expect(config.customPTY == false) + } + + @Test("Without customPTY, write throws customPTYNotEnabled") + func writeWithoutCustomPTY() async throws { + let (session, base) = try makeSession(customPTY: false) + defer { try? FileManager.default.removeItem(at: base) } + try await session.start() + + #expect(throws: AgentSessionError.customPTYNotEnabled) { + try session.write(Data("hello".utf8)) + } + + _ = try await session.wait() + } + + @Test("Without customPTY, resize throws customPTYNotEnabled") + func resizeWithoutCustomPTY() async throws { + let (session, base) = try makeSession(customPTY: false) + defer { try? FileManager.default.removeItem(at: base) } + try await session.start() + + await #expect(throws: AgentSessionError.customPTYNotEnabled) { + try await session.resize(cols: 80, rows: 24) + } + + _ = try await session.wait() + } + + @Test("Without customPTY, rawOut finishes immediately on start") + func rawOutFinishesWithoutCustomPTY() async throws { + let (session, base) = try makeSession(customPTY: false) + defer { try? FileManager.default.removeItem(at: base) } + try await session.start() + + var collected: [[UInt8]] = [] + for await chunk in session.rawOut { + collected.append(chunk) + } + #expect(collected.isEmpty) + + _ = try await session.wait() + } + + @Test("Without customPTY, IO honors allocateTTY (standardIO when false)") + func standardIOWhenCustomPTYFalse() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let base = URL(fileURLWithPath: "/tmp/claudec-test-custompty-std-\(UUID().uuidString)") + let profileDir = base.appendingPathComponent("home") + let configsDir = base.appendingPathComponent("configurations") + try FileManager.default.createDirectory(at: configsDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: base) } + + let config = IsolationConfig( + image: "test:latest", + profileHomeDir: profileDir, + workspace: URL(fileURLWithPath: "/tmp"), + configurationsDir: configsDir, + configurations: [], + arguments: ["echo"], + allocateTTY: false, + customPTY: false + ) + let session = AgentSession(config: config, runtime: runtime) + try await session.start() + _ = try await session.wait() + + if case .standardIO = runtime.lastContainerConfiguration!.io { + // expected + } else { + Issue.record("Expected .standardIO") + } + } + + @Test("With customPTY, IO is .custom with isTerminal=true") + func customIOWhenCustomPTYTrue() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let (session, base) = try makeSession(customPTY: true, runtime: runtime) + defer { try? FileManager.default.removeItem(at: base) } + + try await session.start() + + if case .custom(_, _, _, let isTerminal) = runtime.lastContainerConfiguration!.io { + #expect(isTerminal == true) + } else { + Issue.record("Expected .custom IO in customPTY mode") + } + + _ = try await session.wait() + } + + @Test("With customPTY, resize forwards to container") + func resizeForwarded() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let (session, base) = try makeSession(customPTY: true, runtime: runtime) + defer { try? FileManager.default.removeItem(at: base) } + + try await session.start() + try await session.resize(cols: 120, rows: 40) + + let container = try #require(runtime.lastContainer) + #expect(container.resizeCalls.count == 1) + #expect(container.resizeCalls.first?.cols == 120) + #expect(container.resizeCalls.first?.rows == 40) + + _ = try await session.wait() + } + + @Test("Operations on an unstarted session throw notStarted") + func notStartedErrors() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let (session, base) = try makeSession(customPTY: true, runtime: runtime) + defer { try? FileManager.default.removeItem(at: base) } + + #expect(throws: AgentSessionError.notStarted) { + try session.write(Data("x".utf8)) + } + await #expect(throws: AgentSessionError.notStarted) { + try await session.resize(cols: 80, rows: 24) + } + await #expect(throws: AgentSessionError.notStarted) { + _ = try await session.wait() + } + } + + @Test("start cannot be called twice") + func startTwice() async throws { + let (session, base) = try makeSession(customPTY: false) + defer { try? FileManager.default.removeItem(at: base) } + + try await session.start() + await #expect(throws: AgentSessionError.alreadyStarted) { + try await session.start() + } + _ = try await session.wait() + } + + @Test("timeout passed to start is forwarded to container.wait") + func timeoutForwarded() async throws { + let runtime = MockRuntime(config: .init(storagePath: "/tmp")) + let (session, base) = try makeSession(customPTY: false, runtime: runtime) + defer { try? FileManager.default.removeItem(at: base) } + + try await session.start(timeout: 42) + _ = try await session.wait() + + let container = try #require(runtime.lastContainer) + #expect(container.lastTimeoutInSeconds == 42) + } +} + +// MARK: - Container Resize Default Tests + +@Suite("ContainerRuntimeContainer default resize") +struct ContainerResizeDefaultTests { + + final class MinimalContainer: ContainerRuntimeContainer, @unchecked Sendable { + var id: String { "mock" } + func wait(timeoutInSeconds: Int64?) async throws -> Int32 { 0 } + func stop() async throws {} + // No resize override — inherits the protocol default. + } + + @Test("Default resize throws resizeNotSupported") + func defaultResizeThrows() async { + let container = MinimalContainer() + await #expect(throws: ContainerRuntimeError.resizeNotSupported) { + try await container.resize(cols: 80, rows: 24) + } + } +} diff --git a/Tests/AgentIsolationTests/MockRuntime.swift b/Tests/AgentIsolationTests/MockRuntime.swift index a4ff4e2..916b73e 100644 --- a/Tests/AgentIsolationTests/MockRuntime.swift +++ b/Tests/AgentIsolationTests/MockRuntime.swift @@ -9,6 +9,7 @@ final class MockRuntime: ContainerRuntime, @unchecked Sendable { var prepareCallCount = 0 var lastContainerConfiguration: ContainerConfiguration? var lastImageRef: String? + var lastContainer: MockContainer? var containerExitCode: Int32 = 0 var removedImageRefs: [String] = [] var removedImageDigests: [String] = [] @@ -41,7 +42,9 @@ final class MockRuntime: ContainerRuntime, @unchecked Sendable { ) async throws -> MockContainer { lastImageRef = imageRef lastContainerConfiguration = configuration - return MockContainer(id: "mock-container", exitCode: containerExitCode) + let container = MockContainer(id: "mock-container", exitCode: containerExitCode) + lastContainer = container + return container } func removeContainer(_ container: MockContainer) async throws { @@ -59,6 +62,8 @@ final class MockContainer: ContainerRuntimeContainer, @unchecked Sendable { let exitCode: Int32 var stopped = false var removed = false + var resizeCalls: [(cols: Int, rows: Int)] = [] + var lastTimeoutInSeconds: Int64? = nil init(id: String, exitCode: Int32) { self.id = id @@ -66,10 +71,15 @@ final class MockContainer: ContainerRuntimeContainer, @unchecked Sendable { } func wait(timeoutInSeconds: Int64?) async throws -> Int32 { - exitCode + lastTimeoutInSeconds = timeoutInSeconds + return exitCode } func stop() async throws { stopped = true } + + func resize(cols: Int, rows: Int) async throws { + resizeCalls.append((cols: cols, rows: rows)) + } } diff --git a/Tests/AgentIsolationTests/PathUtilTests.swift b/Tests/AgentIsolationTests/PathUtilTests.swift index da07aea..5e9b14c 100644 --- a/Tests/AgentIsolationTests/PathUtilTests.swift +++ b/Tests/AgentIsolationTests/PathUtilTests.swift @@ -53,7 +53,8 @@ struct PathUtilTests { arguments: ["echo"] ) let session = AgentSession(config: config, runtime: runtime) - _ = try await session.run() + try await session.start() + _ = try await session.wait() let workDir = runtime.lastContainerConfiguration!.workingDirectory! #expect(workDir == AgentIsolationPathUtils.workspaceContainerPath(for: wsDir)) diff --git a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift index 244cda9..7f9dd32 100644 --- a/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift +++ b/Tests/AgentcIntegrationTests/AgentcIntegrationTests.swift @@ -14,6 +14,27 @@ struct AgentcIntegrationTests { #expect(result.stdout.contains("agentc")) } + @Test("agentc forwards non-zero container exit codes (session start+wait)") + func nonZeroExitCodeFlow() async throws { + // Exercises the AgentSession.start() + wait() path end-to-end: a + // container that exits with code 42 should cause agentc to exit 42. + let result = await runAgentc( + args: [ + "sh", + "--profile", sharedProfile, + "--configurations-dir", sharedConfigurationsDir, + "--no-update-image", + // ShellCommand joins the argv with spaces and runs it via `bash -c`, + // so passing `["exit", "42"]` lands as `bash -c "exit 42"` (builtin). + "--", "exit", "42", + ] + ) + print( + "DIAG nonZeroExitCodeFlow: exitCode=\(result.exitCode) stdout=\(result.stdout.prefix(200)) stderr=\(result.stderr.prefix(200))" + ) + #expect(result.exitCode == 42) + } + @Test("agentc sh -- echo runs command in container") func shCommand() async throws { let result = await runAgentc(