From 56da8dd24383e9ca22434bbffad9f1f8d92087af Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 9 May 2026 10:38:16 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Add=20Shell.processLauncher=20=E2=80=94=20p?= =?UTF-8?q?rotocol-shaped=20subprocess=20dispatch=20primitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1. ProcessLauncher is the missing real-exec entry point: every shell host (SwiftBash, SwiftScript, a future JS runtime) goes through one shared primitive instead of reaching for Foundation.Process directly. The default delegates to swift-subprocess; embedders can install SandboxedDenyLauncher to refuse exec, or wrap a builtin-aware launcher in front of the default with ChainLauncher so registered builtins shadow PATH while unknown names hit real exec. The dependency is platform-conditional — swift-subprocess pins iOS / tvOS / watchOS to "99.0" because those kernels forbid posix_spawn / fork. On those platforms the launcher type still exists but throws ProcessLaunchUnsupportedOnThisPlatform, and embedders are expected to install a virtual launcher backed by ProcessTable.spawn instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.resolved | 20 +- Package.swift | 12 + Sources/ShellKit/Process/ChainLauncher.swift | 62 +++ .../Process/DefaultProcessLauncher.swift | 141 +++++++ Sources/ShellKit/Process/Executable.swift | 65 +++ .../ShellKit/Process/ExecutionRecord.swift | 61 +++ .../ShellKit/Process/ProcessLauncher.swift | 116 ++++++ .../Process/SandboxedDenyLauncher.swift | 30 ++ Sources/ShellKit/Shell.swift | 17 +- .../ShellKitTests/ProcessLauncherTests.swift | 369 ++++++++++++++++++ 10 files changed, 890 insertions(+), 3 deletions(-) create mode 100644 Sources/ShellKit/Process/ChainLauncher.swift create mode 100644 Sources/ShellKit/Process/DefaultProcessLauncher.swift create mode 100644 Sources/ShellKit/Process/Executable.swift create mode 100644 Sources/ShellKit/Process/ExecutionRecord.swift create mode 100644 Sources/ShellKit/Process/ProcessLauncher.swift create mode 100644 Sources/ShellKit/Process/SandboxedDenyLauncher.swift create mode 100644 Tests/ShellKitTests/ProcessLauncherTests.swift diff --git a/Package.resolved b/Package.resolved index 8525b84..2179a8a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0a5f5acb44530d051b2416cc23b165b5cab3d1bf0fa19afcf72a7df8415f7ac2", + "originHash" : "f549ee4f482c73bec051b377de7b419a29337e3fb588d352ad1c7c6fad491b80", "pins" : [ { "identity" : "swift-argument-parser", @@ -9,6 +9,24 @@ "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "version" : "1.7.1" } + }, + { + "identity" : "swift-subprocess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-subprocess", + "state" : { + "revision" : "13d087685b95d64d6aac9b94500d347bbe84c39b", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 294078c..5ccb281 100644 --- a/Package.swift +++ b/Package.swift @@ -62,12 +62,24 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + // Pinned to 0.4.x until 1.0 ships. See issue #1 for context. + .package(url: "https://github.com/swiftlang/swift-subprocess", + .upToNextMinor(from: "0.4.0")), ], targets: [ .target( name: "ShellKit", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + // swift-subprocess pins iOS / tvOS / watchOS to "99.0" — kernel + // bans posix_spawn / fork there, so the dep is conditionally + // linked only on platforms where real exec is possible. + // ``DefaultProcessLauncher`` falls back to throwing + // ``ProcessLaunchUnsupportedOnThisPlatform`` on the rest. + .product(name: "Subprocess", package: "swift-subprocess", + condition: .when(platforms: [ + .macOS, .linux, .windows, .android, + ])), ], path: "Sources/ShellKit" ), diff --git a/Sources/ShellKit/Process/ChainLauncher.swift b/Sources/ShellKit/Process/ChainLauncher.swift new file mode 100644 index 0000000..28b0b1d --- /dev/null +++ b/Sources/ShellKit/Process/ChainLauncher.swift @@ -0,0 +1,62 @@ +import Foundation + +/// A composing ``ProcessLauncher`` that consults `primary` first and +/// falls through to `fallback` only when `primary` throws +/// ``ProcessLaunchUnresolved``. +/// +/// This is the composition primitive that lets SwiftBash say "consult +/// my builtins, else delegate to real exec": +/// +/// ```swift +/// let bashLauncher = BashProcessLauncher(builtins: …) // throws +/// // ProcessLaunchUnresolved +/// // for unknown names +/// shell.processLauncher = ChainLauncher( +/// primary: bashLauncher, +/// fallback: DefaultProcessLauncher()) +/// ``` +/// +/// Other thrown errors (``ProcessLaunchDenied``, transport errors from +/// swift-subprocess, the body of a builtin throwing) propagate +/// unchanged. Only the specific "I don't know about this command" +/// signal is caught. +public struct ChainLauncher: ProcessLauncher { + + public let primary: any ProcessLauncher + public let fallback: any ProcessLauncher + + public init(primary: any ProcessLauncher, fallback: any ProcessLauncher) { + self.primary = primary + self.fallback = fallback + } + + public func launch( + _ executable: Executable, + arguments: Arguments, + environment: Environment, + workingDirectory: String?, + input: InputSource, + output: OutputSink, + error: OutputSink + ) async throws -> ExecutionRecord { + do { + return try await primary.launch( + executable, + arguments: arguments, + environment: environment, + workingDirectory: workingDirectory, + input: input, + output: output, + error: error) + } catch is ProcessLaunchUnresolved { + return try await fallback.launch( + executable, + arguments: arguments, + environment: environment, + workingDirectory: workingDirectory, + input: input, + output: output, + error: error) + } + } +} diff --git a/Sources/ShellKit/Process/DefaultProcessLauncher.swift b/Sources/ShellKit/Process/DefaultProcessLauncher.swift new file mode 100644 index 0000000..b0aa1c7 --- /dev/null +++ b/Sources/ShellKit/Process/DefaultProcessLauncher.swift @@ -0,0 +1,141 @@ +import Foundation + +#if canImport(Subprocess) +import Subprocess +#if canImport(System) +import System +#else +import SystemPackage +#endif +#endif + +/// The default ``ProcessLauncher`` shipped with ShellKit. Delegates to +/// swift-subprocess's ``Subprocess.run(_:arguments:environment:workingDirectory:platformOptions:input:output:error:)`` +/// with the collected-output overload — captures stdout / stderr into +/// ``Data`` buffers up to the configured limit and forwards each +/// captured chunk to the supplied ``OutputSink``s. +/// +/// Installed on ``Shell/processDefault``; embedders that virtualise +/// the process table (SwiftBash) replace it with their own type +/// (typically a ``ChainLauncher`` whose primary stage is a +/// builtin-aware launcher and whose tail is this default). +/// +/// ### Platform availability +/// +/// swift-subprocess ships only on macOS / Linux / Windows / Android — +/// iOS / tvOS / watchOS / visionOS forbid `posix_spawn` / `fork`. On +/// those platforms ``launch(_:arguments:environment:workingDirectory:input:output:error:)`` +/// throws ``ProcessLaunchUnsupportedOnThisPlatform`` and the embedder +/// is expected to install a virtual launcher that goes through +/// ``ProcessTable/spawn(command:body:)`` instead. +/// +/// ### v1 limitations +/// +/// - Stdin is fully drained into a buffer before exec. Streaming-stdin +/// is a v2 concern. +/// - Stdout / stderr are collected with a fixed +/// ``defaultBufferLimit``-byte cap. Subclass + override or call +/// swift-subprocess's closure-form `run` directly if you need the +/// `AsyncSequence` shape. +/// - No ``Subprocess.PlatformOptions`` (spawn attributes / file +/// actions). Subclass to inject them. +public struct DefaultProcessLauncher: ProcessLauncher { + + /// Maximum bytes captured per stream when the launcher reads + /// stdout / stderr. Overrunning this limit throws an error from + /// swift-subprocess. 4 MiB is enough for almost every script-run + /// while remaining a sane safety stop for runaway producers. + public static let defaultBufferLimit: Int = 4 * 1024 * 1024 + + public let bufferLimit: Int + + public init(bufferLimit: Int = DefaultProcessLauncher.defaultBufferLimit) { + self.bufferLimit = bufferLimit + } + + public func launch( + _ executable: Executable, + arguments: Arguments, + environment: Environment, + workingDirectory: String?, + input: InputSource, + output: OutputSink, + error: OutputSink + ) async throws -> ExecutionRecord { + #if canImport(Subprocess) + // Drain stdin upfront. v1 doesn't stream — see type doc. + var inputBytes: [UInt8] = [] + for await chunk in input.bytes { + inputBytes.append(contentsOf: chunk) + } + + let exe: Subprocess.Executable + switch executable.storage { + case .name(let name): exe = .name(name) + case .path(let p): exe = .path(FilePath(p)) + } + + let args = Subprocess.Arguments(arguments.values) + + // Mirror ShellKit's ``Environment/variables`` into a custom + // subprocess environment. `.custom` rather than `.inherit` + // because the embedder is the source of truth — anything that + // should leak through from `ProcessInfo.processInfo.environment` + // is already in `Shell.processDefault.environment.variables`. + // ``Subprocess.Environment.Key.init(_:)`` is package-private; + // the public path is its `ExpressibleByStringLiteral` init. + var envMap: [Subprocess.Environment.Key: String] = [:] + for (k, v) in environment.variables { + envMap[Subprocess.Environment.Key(stringLiteral: k)] = v + } + let subprocessEnv = Subprocess.Environment.custom(envMap) + + // Resolve working directory. Explicit override wins; otherwise + // fall back to the shell's `environment.workingDirectory` if + // set; otherwise pass nil so the host's CWD is inherited. + let cwd: FilePath? = { + if let wd = workingDirectory, !wd.isEmpty { + return FilePath(wd) + } + let envCwd = environment.workingDirectory + if !envCwd.isEmpty { return FilePath(envCwd) } + return nil + }() + + let record = try await Subprocess.run( + exe, + arguments: args, + environment: subprocessEnv, + workingDirectory: cwd, + input: .array(inputBytes), + output: .bytes(limit: bufferLimit), + error: .bytes(limit: bufferLimit) + ) + + let stdoutData = Data(record.standardOutput) + let stderrData = Data(record.standardError) + + if !stdoutData.isEmpty { output.write(stdoutData) } + if !stderrData.isEmpty { error.write(stderrData) } + + let termStatus: TerminationStatus + switch record.terminationStatus { + case .exited(let code): + termStatus = .exited(Int32(code)) + #if !os(Windows) + case .signaled(let sig): + termStatus = .signaled(Int32(sig)) + #endif + } + + return ExecutionRecord( + processIdentifier: Int64(record.processIdentifier.value), + terminationStatus: termStatus, + standardOutput: stdoutData, + standardError: stderrData + ) + #else + throw ProcessLaunchUnsupportedOnThisPlatform(executable: executable) + #endif + } +} diff --git a/Sources/ShellKit/Process/Executable.swift b/Sources/ShellKit/Process/Executable.swift new file mode 100644 index 0000000..6867d65 --- /dev/null +++ b/Sources/ShellKit/Process/Executable.swift @@ -0,0 +1,65 @@ +import Foundation + +/// How to locate the program to run when launching a subprocess. +/// +/// Mirrors swift-subprocess's ``Subprocess.Executable`` so callers can +/// move between layers without translating types — SwiftScript's +/// `run(...)` bridge re-exports this directly. Lives in ShellKit so +/// consumers don't need a direct swift-subprocess dependency. +/// +/// Two forms: +/// - ``name(_:)`` — plain command name (`"git"`, `"echo"`). The +/// launcher resolves it through `PATH` taken from the supplied +/// ``Environment``. +/// - ``path(_:)`` — fully-qualified path (`"/usr/bin/env"`). Used as-is. +public struct Executable: Sendable, Hashable { + + public enum Storage: Sendable, Hashable { + case name(String) + case path(String) + } + + public let storage: Storage + + private init(_ storage: Storage) { + self.storage = storage + } + + /// Locate the executable by name; the launcher resolves it via + /// `PATH`. + public static func name(_ executableName: String) -> Executable { + Executable(.name(executableName)) + } + + /// Locate the executable by absolute path. + public static func path(_ filePath: String) -> Executable { + Executable(.path(filePath)) + } + + /// The string form a user would type — name or path. + public var description: String { + switch storage { + case .name(let n): return n + case .path(let p): return p + } + } +} + +/// Argument vector handed to a subprocess. Mirrors +/// swift-subprocess's ``Subprocess.Arguments`` — argv[0] (the program +/// name) is supplied automatically by the launcher; this collection +/// holds argv[1...] only. +public struct Arguments: Sendable, Hashable, ExpressibleByArrayLiteral { + + public typealias ArrayLiteralElement = String + + public let values: [String] + + public init(_ values: [String] = []) { + self.values = values + } + + public init(arrayLiteral elements: String...) { + self.values = elements + } +} diff --git a/Sources/ShellKit/Process/ExecutionRecord.swift b/Sources/ShellKit/Process/ExecutionRecord.swift new file mode 100644 index 0000000..6279638 --- /dev/null +++ b/Sources/ShellKit/Process/ExecutionRecord.swift @@ -0,0 +1,61 @@ +import Foundation + +/// How a launched subprocess ended. Mirrors swift-subprocess's +/// ``Subprocess.TerminationStatus`` (and POSIX `waitpid` semantics): +/// either an exit code, or — on POSIX — a delivering signal. +public enum TerminationStatus: Sendable, Hashable { + /// The subprocess called `exit(code)` (or fell off `main`). + case exited(Int32) + /// The subprocess was killed by a signal. Not produced on Windows. + case signaled(Int32) + + /// Exit-code 0 only. Signal terminations always count as failure. + public var isSuccess: Bool { + switch self { + case .exited(let code): return code == 0 + case .signaled: return false + } + } +} + +/// The collected outcome of a ``ProcessLauncher/launch(_:arguments:environment:workingDirectory:input:output:error:)`` +/// call. Captures everything a caller needs to decide success / failure +/// and inspect what came out of stdout / stderr. +/// +/// Stdout / stderr were ALSO streamed live through the +/// ``OutputSink``s passed in; the buffers here are a parallel record +/// for callers that prefer to consume the result inline. `BashProcessLauncher` +/// (SwiftBash) ignores the buffers and reads the sinks; the SwiftScript +/// `run(.string(limit:))` bridge reads the buffers and ignores the +/// sinks. Both styles work off the same record. +public struct ExecutionRecord: Sendable { + /// Host PID of the launched subprocess. Real OS pid (not the + /// virtual ``ProcessTable`` pid). On Windows the process ID is a + /// `DWORD`; we widen to `Int64` to fit both cases without + /// truncation. + public let processIdentifier: Int64 + + /// How the subprocess ended. + public let terminationStatus: TerminationStatus + + /// Bytes written to stdout, capped by the launcher's buffer + /// limit. May be empty if the launcher's policy was to stream the + /// output through ``OutputSink`` and not retain a copy. + public let standardOutput: Data + + /// Bytes written to stderr, capped by the launcher's buffer + /// limit. May be empty for the same reason as ``standardOutput``. + public let standardError: Data + + public init( + processIdentifier: Int64, + terminationStatus: TerminationStatus, + standardOutput: Data = Data(), + standardError: Data = Data() + ) { + self.processIdentifier = processIdentifier + self.terminationStatus = terminationStatus + self.standardOutput = standardOutput + self.standardError = standardError + } +} diff --git a/Sources/ShellKit/Process/ProcessLauncher.swift b/Sources/ShellKit/Process/ProcessLauncher.swift new file mode 100644 index 0000000..a8a5505 --- /dev/null +++ b/Sources/ShellKit/Process/ProcessLauncher.swift @@ -0,0 +1,116 @@ +import Foundation + +/// The shared subprocess-dispatch primitive that every shell host +/// (SwiftBash, SwiftScript, a future JS runtime) runs through. +/// +/// `ProcessLauncher` is what turns "run `/bin/git status` and capture +/// its output" from "reach for `Foundation.Process` directly" into "go +/// through ``Shell/processLauncher`` and let the embedder decide what +/// happens." Every Shell carries one — the default +/// ``DefaultProcessLauncher`` delegates to swift-subprocess; embedders +/// can install ``SandboxedDenyLauncher`` to refuse exec entirely, or +/// chain a builtin-aware launcher in front of the default with +/// ``ChainLauncher`` so registered builtins shadow real exec. +/// +/// The protocol is shaped to mirror swift-subprocess's collected-output +/// `run(...)` overload: same parameter names, same types (lifted to +/// ShellKit's ``Executable`` / ``Arguments`` / ``ExecutionRecord`` so +/// consumers don't need a direct swift-subprocess dep). Closure-form +/// (`run(...) { execution, stdout in ... }`) is deferred to v2 along +/// with `AsyncSequence` streaming output. +public protocol ProcessLauncher: Sendable { + + /// Launch `executable` with the supplied parameters and return the + /// outcome. + /// + /// - Parameters: + /// - executable: Program to run. Either ``Executable/name(_:)`` + /// (resolved through `PATH`) or ``Executable/path(_:)`` + /// (used as-is). + /// - arguments: argv[1...]. argv[0] is set by the launcher to + /// match `executable`. + /// - environment: Variables and CWD source. The launcher reads + /// ``Environment/variables`` for the subprocess's environ and + /// falls back to ``Environment/workingDirectory`` when + /// `workingDirectory` is `nil`. + /// - workingDirectory: Optional override for the subprocess's + /// CWD. `nil` means "use `environment.workingDirectory` if set, + /// else inherit the host's CWD". + /// - input: Bytes to feed to the subprocess's stdin. Drained + /// into a buffer before exec by ``DefaultProcessLauncher``; + /// streaming-stdin is a v2 concern. + /// - output: Sink that receives the subprocess's stdout. The + /// launcher writes each captured chunk *and* records a copy + /// in ``ExecutionRecord/standardOutput``. + /// - error: Sink that receives the subprocess's stderr. + /// Recorded in ``ExecutionRecord/standardError``. + /// - Returns: A populated ``ExecutionRecord``. + /// - Throws: ``ProcessLaunchDenied`` (sandbox policy refused), + /// ``ProcessLaunchUnresolved`` (the launcher doesn't handle this + /// command — used by chain launchers as a fall-through signal), + /// ``ProcessLaunchUnsupportedOnThisPlatform`` (real exec is + /// unavailable on iOS / tvOS / watchOS / visionOS), or any + /// transport error from the underlying engine. + func launch( + _ executable: Executable, + arguments: Arguments, + environment: Environment, + workingDirectory: String?, + input: InputSource, + output: OutputSink, + error: OutputSink + ) async throws -> ExecutionRecord +} + +// MARK: - Errors + +/// Thrown by sandbox-bound launchers to refuse a launch. +public struct ProcessLaunchDenied: Error, Sendable, Equatable, CustomStringConvertible { + public let executable: Executable + public let reason: String + + public init(executable: Executable, reason: String) { + self.executable = executable + self.reason = reason + } + + public var description: String { + "ProcessLaunchDenied(\(executable.description)): \(reason)" + } +} + +/// Thrown by a launcher that doesn't know about this executable — +/// the ``ChainLauncher`` fall-through signal. A primary-stage launcher +/// throws this when it has no entry for the requested command, and the +/// chain catches it and tries the next launcher in line. Should NEVER +/// surface to the caller from a properly-composed chain whose tail is a +/// real exec engine (``DefaultProcessLauncher``) or an explicit deny +/// (``SandboxedDenyLauncher``). +public struct ProcessLaunchUnresolved: Error, Sendable, Equatable, CustomStringConvertible { + public let executable: Executable + + public init(executable: Executable) { + self.executable = executable + } + + public var description: String { + "ProcessLaunchUnresolved(\(executable.description))" + } +} + +/// Thrown by ``DefaultProcessLauncher`` on platforms where the kernel +/// prohibits `posix_spawn` / `fork` (iOS / tvOS / watchOS / visionOS). +/// Embedders that want a working launcher on those platforms install a +/// virtual one that uses only ``ProcessTable/spawn(command:body:)`` +/// (closure-bodied virtual processes). +public struct ProcessLaunchUnsupportedOnThisPlatform: Error, Sendable, Equatable, CustomStringConvertible { + public let executable: Executable + + public init(executable: Executable) { + self.executable = executable + } + + public var description: String { + "ProcessLaunchUnsupportedOnThisPlatform(\(executable.description))" + } +} diff --git a/Sources/ShellKit/Process/SandboxedDenyLauncher.swift b/Sources/ShellKit/Process/SandboxedDenyLauncher.swift new file mode 100644 index 0000000..0a09138 --- /dev/null +++ b/Sources/ShellKit/Process/SandboxedDenyLauncher.swift @@ -0,0 +1,30 @@ +import Foundation + +/// A ``ProcessLauncher`` that refuses every launch with +/// ``ProcessLaunchDenied``. +/// +/// Suitable for embedders that bind a sandbox but provide no command +/// resolver of their own — e.g. an in-process script host that wants +/// callers to discover up-front that real exec is not available, with +/// a typed error rather than a sandbox-path violation surfaced from +/// somewhere downstream. +public struct SandboxedDenyLauncher: ProcessLauncher { + + public let reason: String + + public init(reason: String = "process launch denied by sandbox") { + self.reason = reason + } + + public func launch( + _ executable: Executable, + arguments: Arguments, + environment: Environment, + workingDirectory: String?, + input: InputSource, + output: OutputSink, + error: OutputSink + ) async throws -> ExecutionRecord { + throw ProcessLaunchDenied(executable: executable, reason: reason) + } +} diff --git a/Sources/ShellKit/Shell.swift b/Sources/ShellKit/Shell.swift index 70451c6..f575e7d 100644 --- a/Sources/ShellKit/Shell.swift +++ b/Sources/ShellKit/Shell.swift @@ -135,6 +135,16 @@ open class Shell: @unchecked Sendable { /// SwiftBash's PATH walk). public var commands: [String: Command] + /// Real-subprocess dispatch primitive. Default is + /// ``DefaultProcessLauncher`` (delegates to swift-subprocess). + /// Embedders override this to virtualise exec — typically with a + /// ``ChainLauncher`` whose primary stage consults a builtin / + /// function table and whose tail is the default launcher, so + /// registered builtins shadow PATH while unknown names hit real + /// exec. Sandbox-only embedders that want to refuse exec entirely + /// install ``SandboxedDenyLauncher``. + public var processLauncher: any ProcessLauncher + // MARK: - TaskLocal binding /// The active Shell for the current Task scope. @@ -159,7 +169,8 @@ open class Shell: @unchecked Sendable { hostInfo: HostInfo = .synthetic, processTable: ProcessTable = ProcessTable(), virtualPID: Int32 = 1, - commands: [String: Command] = [:] + commands: [String: Command] = [:], + processLauncher: (any ProcessLauncher)? = nil ) { self.stdin = stdin self.stdout = stdout ?? .discard @@ -174,6 +185,7 @@ open class Shell: @unchecked Sendable { self.processTable = processTable self.virtualPID = virtualPID self.commands = commands + self.processLauncher = processLauncher ?? DefaultProcessLauncher() } // MARK: - Process default @@ -231,7 +243,8 @@ open class Shell: @unchecked Sendable { hostInfo: hostInfo, processTable: processTable, virtualPID: virtualPID, - commands: commands) + commands: commands, + processLauncher: processLauncher) return sub } diff --git a/Tests/ShellKitTests/ProcessLauncherTests.swift b/Tests/ShellKitTests/ProcessLauncherTests.swift new file mode 100644 index 0000000..98812e2 --- /dev/null +++ b/Tests/ShellKitTests/ProcessLauncherTests.swift @@ -0,0 +1,369 @@ +import Foundation +import Testing +@testable import ShellKit + +/// Whether the host platform supports real subprocess exec. iOS / +/// tvOS / watchOS / visionOS forbid `posix_spawn` / `fork` and +/// ``DefaultProcessLauncher`` throws on those. +private let supportsRealExec: Bool = { + #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) + return false + #else + return true + #endif +}() + +@Suite struct ProcessLauncherWiringTests { + + @Test func processDefaultHasDefaultLauncher() { + // Acceptance: `Shell.processLauncher` exists and the default + // on `Shell.processDefault` is `DefaultProcessLauncher`. + let shell = Shell.processDefault + #expect(shell.processLauncher is DefaultProcessLauncher) + } + + @Test func freshShellHasDefaultLauncher() { + let shell = Shell() + #expect(shell.processLauncher is DefaultProcessLauncher) + } + + @Test func customLauncherSurvivesCopy() { + let shell = Shell() + shell.processLauncher = SandboxedDenyLauncher(reason: "test") + let sub = shell.copy() + #expect(sub.processLauncher is SandboxedDenyLauncher) + } +} + +@Suite struct DefaultProcessLauncherTests { + + @Test func standaloneRoundTripsEcho() async throws { + try requireRealExec() + + let stdout = OutputSink() + let stderr = OutputSink() + let launcher = DefaultProcessLauncher() + let env = Environment.current() + + let record = try await launcher.launch( + .name("echo"), + arguments: ["hi"], + environment: env, + workingDirectory: nil, + input: .empty, + output: stdout, + error: stderr) + + #expect(record.terminationStatus.isSuccess) + #expect(record.terminationStatus == .exited(0)) + + let out = String(decoding: record.standardOutput, as: UTF8.self) + // `echo hi` adds a trailing newline. + #expect(out == "hi\n") + + // Sink received the same bytes the record captured. + stdout.finish() + let sinkOut = await stdout.readAllString() + #expect(sinkOut == "hi\n") + } + + @Test func capturesStderr() async throws { + try requireRealExec() + + let stdout = OutputSink() + let stderr = OutputSink() + let launcher = DefaultProcessLauncher() + let env = Environment.current() + + let record = try await launcher.launch( + .path("/bin/sh"), + arguments: ["-c", "echo to-err 1>&2"], + environment: env, + workingDirectory: nil, + input: .empty, + output: stdout, + error: stderr) + + #expect(record.terminationStatus.isSuccess) + let errText = String(decoding: record.standardError, as: UTF8.self) + #expect(errText == "to-err\n") + + stderr.finish() + let sinkErr = await stderr.readAllString() + #expect(sinkErr == "to-err\n") + } + + @Test func nonZeroExitStatusReported() async throws { + try requireRealExec() + + let launcher = DefaultProcessLauncher() + let env = Environment.current() + + let record = try await launcher.launch( + .path("/bin/sh"), + arguments: ["-c", "exit 7"], + environment: env, + workingDirectory: nil, + input: .empty, + output: OutputSink(), + error: OutputSink()) + + #expect(record.terminationStatus == .exited(7)) + #expect(record.terminationStatus.isSuccess == false) + } + + @Test func environmentOverrideReachesSubprocess() async throws { + try requireRealExec() + + var env = Environment.current() + // Inject a custom variable; subprocess prints it back. + env.variables["SHELLKIT_PROBE"] = "hello-from-shellkit" + + let launcher = DefaultProcessLauncher() + let record = try await launcher.launch( + .path("/bin/sh"), + arguments: ["-c", "printf %s \"$SHELLKIT_PROBE\""], + environment: env, + workingDirectory: nil, + input: .empty, + output: OutputSink(), + error: OutputSink()) + + #expect(record.terminationStatus.isSuccess) + let out = String(decoding: record.standardOutput, as: UTF8.self) + #expect(out == "hello-from-shellkit") + } + + @Test func workingDirectoryOverrideTakesEffect() async throws { + try requireRealExec() + + let unique = "shellkit-cwd-\(UUID().uuidString)" + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent(unique, isDirectory: true) + try FileManager.default.createDirectory( + at: tmp, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let launcher = DefaultProcessLauncher() + let env = Environment.current() + let record = try await launcher.launch( + .path("/bin/sh"), + arguments: ["-c", "pwd -P"], + environment: env, + workingDirectory: tmp.path, + input: .empty, + output: OutputSink(), + error: OutputSink()) + + #expect(record.terminationStatus.isSuccess) + let out = String(decoding: record.standardOutput, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + // Bypass /var → /private/var canonicalisation pitfalls by + // anchoring on the unique component we created. + #expect(out.hasSuffix("/" + unique), + "subprocess pwd \"\(out)\" should end with /\(unique)") + } + + @Test func stdinPipedThrough() async throws { + try requireRealExec() + + let launcher = DefaultProcessLauncher() + let env = Environment.current() + let record = try await launcher.launch( + .path("/bin/cat"), + arguments: [], + environment: env, + workingDirectory: nil, + input: .string("piped-in"), + output: OutputSink(), + error: OutputSink()) + + #expect(record.terminationStatus.isSuccess) + let out = String(decoding: record.standardOutput, as: UTF8.self) + #expect(out == "piped-in") + } + + @Test func unsupportedPlatformThrowsTypedError() async throws { + // Acceptance: on iOS / tvOS / watchOS the launcher exists but + // throws. On supported platforms this test is a no-op (the + // typed error path isn't reachable). + guard !supportsRealExec else { return } + let launcher = DefaultProcessLauncher() + do { + _ = try await launcher.launch( + .name("echo"), + arguments: [], + environment: Environment(), + workingDirectory: nil, + input: .empty, + output: OutputSink(), + error: OutputSink()) + Issue.record("expected ProcessLaunchUnsupportedOnThisPlatform") + } catch is ProcessLaunchUnsupportedOnThisPlatform { + // expected + } + } +} + +@Suite struct SandboxedDenyLauncherTests { + + @Test func deniesEveryLaunchWithTypedError() async throws { + let launcher = SandboxedDenyLauncher(reason: "test deny") + do { + _ = try await launcher.launch( + .name("echo"), + arguments: ["hi"], + environment: Environment(), + workingDirectory: nil, + input: .empty, + output: OutputSink(), + error: OutputSink()) + Issue.record("expected ProcessLaunchDenied") + } catch let denial as ProcessLaunchDenied { + #expect(denial.reason == "test deny") + if case .name(let n) = denial.executable.storage { + #expect(n == "echo") + } else { + Issue.record("unexpected executable storage") + } + } + } + + @Test func denyAcceptanceWhenInstalledOnShell() async throws { + // Acceptance: setting `Shell.current.sandbox` and replacing + // the launcher with `SandboxedDenyLauncher` causes the same + // `launch(...)` call to throw `ProcessLaunchDenied`. + let shell = Shell() + shell.sandbox = .rooted( + at: FileManager.default.temporaryDirectory) + shell.processLauncher = SandboxedDenyLauncher() + + await #expect(throws: ProcessLaunchDenied.self) { + _ = try await shell.processLauncher.launch( + .name("echo"), + arguments: ["hi"], + environment: shell.environment, + workingDirectory: nil, + input: .empty, + output: OutputSink(), + error: OutputSink()) + } + } +} + +@Suite struct ChainLauncherTests { + + /// A primary stage that resolves only `"foo"` — emitting a fixed + /// record — and throws ``ProcessLaunchUnresolved`` for everything + /// else, so ``ChainLauncher`` falls through to the tail. + private struct OnlyFooLauncher: ProcessLauncher { + func launch( + _ executable: Executable, + arguments: Arguments, + environment: Environment, + workingDirectory: String?, + input: InputSource, + output: OutputSink, + error: OutputSink + ) async throws -> ExecutionRecord { + switch executable.storage { + case .name("foo"): + output.write("foo-builtin\n") + return ExecutionRecord( + processIdentifier: 0, + terminationStatus: .exited(0), + standardOutput: Data("foo-builtin\n".utf8)) + default: + throw ProcessLaunchUnresolved(executable: executable) + } + } + } + + @Test func primaryHandlesItsOwnCommand() async throws { + let chain = ChainLauncher( + primary: OnlyFooLauncher(), + fallback: SandboxedDenyLauncher(reason: "should-not-fire")) + + let stdout = OutputSink() + let record = try await chain.launch( + .name("foo"), + arguments: [], + environment: Environment(), + workingDirectory: nil, + input: .empty, + output: stdout, + error: OutputSink()) + + #expect(record.terminationStatus == .exited(0)) + let out = String(decoding: record.standardOutput, as: UTF8.self) + #expect(out == "foo-builtin\n") + } + + @Test func unresolvedCommandFallsThroughToDefault() async throws { + try requireRealExec() + + let chain = ChainLauncher( + primary: OnlyFooLauncher(), + fallback: DefaultProcessLauncher()) + + // `foo` is intercepted by the primary. + let fooOut = OutputSink() + let fooRec = try await chain.launch( + .name("foo"), + arguments: [], + environment: Environment.current(), + workingDirectory: nil, + input: .empty, + output: fooOut, + error: OutputSink()) + #expect(fooRec.terminationStatus == .exited(0)) + let fooText = String(decoding: fooRec.standardOutput, as: UTF8.self) + #expect(fooText == "foo-builtin\n") + + // `echo` falls through to the real exec engine. + let echoOut = OutputSink() + let echoRec = try await chain.launch( + .name("echo"), + arguments: ["chained"], + environment: Environment.current(), + workingDirectory: nil, + input: .empty, + output: echoOut, + error: OutputSink()) + #expect(echoRec.terminationStatus.isSuccess) + let echoText = String(decoding: echoRec.standardOutput, as: UTF8.self) + #expect(echoText == "chained\n") + } + + @Test func deniedFallthroughSurfacesDenial() async throws { + // Acceptance: ``ProcessLaunchDenied`` from a tail propagates + // unchanged — only ``ProcessLaunchUnresolved`` is caught. + let chain = ChainLauncher( + primary: OnlyFooLauncher(), + fallback: SandboxedDenyLauncher(reason: "tail deny")) + + await #expect(throws: ProcessLaunchDenied.self) { + _ = try await chain.launch( + .name("not-foo"), + arguments: [], + environment: Environment(), + workingDirectory: nil, + input: .empty, + output: OutputSink(), + error: OutputSink()) + } + } +} + +// MARK: - Helpers + +/// Tests that exercise real exec (``DefaultProcessLauncher``) call +/// this to skip themselves on iOS / tvOS / watchOS / visionOS, where +/// `posix_spawn` is forbidden. +private func requireRealExec(file: String = #file, line: Int = #line) throws { + if !supportsRealExec { + throw SkipRealExec() + } +} + +private struct SkipRealExec: Error {} From dbb46e7a70f6b7480da9909eef6ba1476e656bff Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 9 May 2026 11:26:31 +0200 Subject: [PATCH 2/5] Disable swift-subprocess SubprocessSpan trait; bump tools-version to 6.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default swift-subprocess `SubprocessSpan` trait pulls in Span-typed overloads that link a back-deployment shim (`libswiftCompatibilitySpan.dylib`). The shim ships with Xcode 26 in `usr/lib/swift-6.2/macosx/`, but its `@rpath` isn't on SwiftPM's test runtime search path, so `swift test --skip-build` fails on macOS 13–15 runners with `Library not loaded: @rpath/libswiftCompatibilitySpan.dylib`. ShellKit doesn't use the Span overloads — `DefaultProcessLauncher` captures stdout / stderr through `Foundation.Data` — so opting out removes the dependency on the back-deployment shim entirely. Tools version bumps to 6.1 to gain access to the package-trait API; this is no constraint that swift-subprocess (already 6.1 tools) doesn't already impose on the same consumers. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.swift | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 5ccb281..f9058fe 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:6.0 +// swift-tools-version:6.1 import PackageDescription // ShellKit — the virtualized shell-environment abstraction. @@ -63,8 +63,17 @@ let package = Package( .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), // Pinned to 0.4.x until 1.0 ships. See issue #1 for context. + // Explicit traits — opt OUT of `SubprocessSpan` because + // ShellKit doesn't use the Span-based overloads, and enabling + // them links a back-deployment shim + // (`libswiftCompatibilitySpan.dylib`) whose @rpath isn't on + // SwiftPM's test runtime search path on macOS 13–15. We do + // keep `SubprocessFoundation` (default-on) because + // ``DefaultProcessLauncher`` reads its captured byte buffers + // through Foundation's `Data`. .package(url: "https://github.com/swiftlang/swift-subprocess", - .upToNextMinor(from: "0.4.0")), + .upToNextMinor(from: "0.4.0"), + traits: ["SubprocessFoundation"]), ], targets: [ .target( From 834d3ed24f79e2cf7c5c809683ddbbedda4bd30e Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 9 May 2026 11:28:10 +0200 Subject: [PATCH 3/5] Skip POSIX-shell launcher tests on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that drive `/bin/sh -c …` (env-var expansion, exit-code plumbing, `pwd -P`, stdin piping through `/bin/cat`) can't easily express the same logic against `cmd.exe` / PowerShell. Skip them via a new ``requirePosixShell()`` helper rather than rewriting per-shell versions. The dispatch primitive is still exercised on Windows by tests that use only `.name(\"echo\")` (which resolves on every supported platform), and by the launcher-wiring / SandboxedDeny / Chain suites that don't invoke real exec at all. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ShellKitTests/ProcessLauncherTests.swift | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/Tests/ShellKitTests/ProcessLauncherTests.swift b/Tests/ShellKitTests/ProcessLauncherTests.swift index 98812e2..c45f1a9 100644 --- a/Tests/ShellKitTests/ProcessLauncherTests.swift +++ b/Tests/ShellKitTests/ProcessLauncherTests.swift @@ -13,6 +13,20 @@ private let supportsRealExec: Bool = { #endif }() +/// Whether the host has a POSIX `/bin/sh` + standard `/bin/cat`. +/// Tests that exercise specific shell-script behaviour (env-var +/// expansion, `pwd -P`, redirection) can't easily express the same +/// logic against `cmd.exe` / PowerShell, so those tests skip on +/// Windows. The dispatch primitive itself is exercised by tests that +/// use only `echo` (which resolves on every supported platform). +private let supportsPosixShell: Bool = { + #if os(Windows) + return false + #else + return true + #endif +}() + @Suite struct ProcessLauncherWiringTests { @Test func processDefaultHasDefaultLauncher() { @@ -69,6 +83,7 @@ private let supportsRealExec: Bool = { @Test func capturesStderr() async throws { try requireRealExec() + try requirePosixShell() let stdout = OutputSink() let stderr = OutputSink() @@ -95,6 +110,7 @@ private let supportsRealExec: Bool = { @Test func nonZeroExitStatusReported() async throws { try requireRealExec() + try requirePosixShell() let launcher = DefaultProcessLauncher() let env = Environment.current() @@ -114,6 +130,7 @@ private let supportsRealExec: Bool = { @Test func environmentOverrideReachesSubprocess() async throws { try requireRealExec() + try requirePosixShell() var env = Environment.current() // Inject a custom variable; subprocess prints it back. @@ -136,6 +153,7 @@ private let supportsRealExec: Bool = { @Test func workingDirectoryOverrideTakesEffect() async throws { try requireRealExec() + try requirePosixShell() let unique = "shellkit-cwd-\(UUID().uuidString)" let tmp = FileManager.default.temporaryDirectory @@ -166,6 +184,7 @@ private let supportsRealExec: Bool = { @Test func stdinPipedThrough() async throws { try requireRealExec() + try requirePosixShell() let launcher = DefaultProcessLauncher() let env = Environment.current() @@ -366,4 +385,12 @@ private func requireRealExec(file: String = #file, line: Int = #line) throws { } } +/// Skip the calling test on Windows — it relies on a POSIX shell +/// (`/bin/sh`, `/bin/cat`, etc.). +private func requirePosixShell(file: String = #file, line: Int = #line) throws { + if !supportsPosixShell { + throw SkipRealExec() + } +} + private struct SkipRealExec: Error {} From b634773cc99e407e2fdcb1f3c8e8a933f72545e7 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 9 May 2026 11:31:26 +0200 Subject: [PATCH 4/5] Add Span back-deployment dylib path to macOS test step Xcode 26's Swift 6.2 back-deploys Span to macOS 13 / 14 via libswiftCompatibilitySpan.dylib, but SwiftPM's test runtime doesn't search the toolchain's swift-6.2/macosx/ dir. swift-subprocess references Span unconditionally on compiler(>=6.2), so the test binary's LC_LOAD_DYLIB resolves to nothing and dlopen fails. Plumb the back-deployment dir via DYLD_LIBRARY_PATH for the test step. Hard-fail if the expected dir is missing (we'd rather know than silently skip the workaround). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/swift.yml | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 6365b98..19dcfaf 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -31,7 +31,24 @@ jobs: - name: Build (macOS) run: swift build --build-tests -v - name: Test (macOS) - run: swift test -v --skip-build --no-parallel + # Xcode 26's Swift 6.2 back-deploys `Span` to macOS 13 / 14 via + # `libswiftCompatibilitySpan.dylib` (under + # `swift-6.2/macosx/`). swift-subprocess references Span types + # unconditionally on `compiler(>=6.2)`, so even with our + # `SubprocessSpan` trait disabled the test binary still has an + # `@rpath/libswiftCompatibilitySpan.dylib` LC_LOAD_DYLIB. + # SwiftPM's test runtime doesn't put the toolchain's + # back-deployment dir on the search path, so we add it + # explicitly via DYLD_LIBRARY_PATH. + run: | + COMPAT_DIR="$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-6.2/macosx" + if [ ! -d "$COMPAT_DIR" ]; then + echo "::error::expected back-deployment dir not found: $COMPAT_DIR" + ls -d "$(xcode-select -p)/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-"*/macosx 2>/dev/null || true + exit 1 + fi + export DYLD_LIBRARY_PATH="$COMPAT_DIR:${DYLD_LIBRARY_PATH:-}" + swift test -v --skip-build --no-parallel build-ios: # Compile-only check on `generic/platform=iOS`. swift-subprocess is From 5d7d861089f26ef769e7993e65c2a78e406da758 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Sat, 9 May 2026 11:37:05 +0200 Subject: [PATCH 5/5] Skip platform-incompatible tests via early-return, not thrown error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swift Testing has no "skip" status — `throw SkipRealExec()` surfaced as a failure on Windows. Replace the throwing helpers with simple `guard supportsRealExec else { return }` / `guard supportsPosixShell else { return }` patterns: an unconditional early return passes the test silently, which is the desired behaviour for platform-gated work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ShellKitTests/ProcessLauncherTests.swift | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/Tests/ShellKitTests/ProcessLauncherTests.swift b/Tests/ShellKitTests/ProcessLauncherTests.swift index c45f1a9..7a0de66 100644 --- a/Tests/ShellKitTests/ProcessLauncherTests.swift +++ b/Tests/ShellKitTests/ProcessLauncherTests.swift @@ -52,7 +52,7 @@ private let supportsPosixShell: Bool = { @Suite struct DefaultProcessLauncherTests { @Test func standaloneRoundTripsEcho() async throws { - try requireRealExec() + guard supportsRealExec else { return } let stdout = OutputSink() let stderr = OutputSink() @@ -82,8 +82,7 @@ private let supportsPosixShell: Bool = { } @Test func capturesStderr() async throws { - try requireRealExec() - try requirePosixShell() + guard supportsRealExec, supportsPosixShell else { return } let stdout = OutputSink() let stderr = OutputSink() @@ -109,8 +108,7 @@ private let supportsPosixShell: Bool = { } @Test func nonZeroExitStatusReported() async throws { - try requireRealExec() - try requirePosixShell() + guard supportsRealExec, supportsPosixShell else { return } let launcher = DefaultProcessLauncher() let env = Environment.current() @@ -129,8 +127,7 @@ private let supportsPosixShell: Bool = { } @Test func environmentOverrideReachesSubprocess() async throws { - try requireRealExec() - try requirePosixShell() + guard supportsRealExec, supportsPosixShell else { return } var env = Environment.current() // Inject a custom variable; subprocess prints it back. @@ -152,8 +149,7 @@ private let supportsPosixShell: Bool = { } @Test func workingDirectoryOverrideTakesEffect() async throws { - try requireRealExec() - try requirePosixShell() + guard supportsRealExec, supportsPosixShell else { return } let unique = "shellkit-cwd-\(UUID().uuidString)" let tmp = FileManager.default.temporaryDirectory @@ -183,8 +179,7 @@ private let supportsPosixShell: Bool = { } @Test func stdinPipedThrough() async throws { - try requireRealExec() - try requirePosixShell() + guard supportsRealExec, supportsPosixShell else { return } let launcher = DefaultProcessLauncher() let env = Environment.current() @@ -319,7 +314,7 @@ private let supportsPosixShell: Bool = { } @Test func unresolvedCommandFallsThroughToDefault() async throws { - try requireRealExec() + guard supportsRealExec else { return } let chain = ChainLauncher( primary: OnlyFooLauncher(), @@ -375,22 +370,10 @@ private let supportsPosixShell: Bool = { } // MARK: - Helpers - -/// Tests that exercise real exec (``DefaultProcessLauncher``) call -/// this to skip themselves on iOS / tvOS / watchOS / visionOS, where -/// `posix_spawn` is forbidden. -private func requireRealExec(file: String = #file, line: Int = #line) throws { - if !supportsRealExec { - throw SkipRealExec() - } -} - -/// Skip the calling test on Windows — it relies on a POSIX shell -/// (`/bin/sh`, `/bin/cat`, etc.). -private func requirePosixShell(file: String = #file, line: Int = #line) throws { - if !supportsPosixShell { - throw SkipRealExec() - } -} - -private struct SkipRealExec: Error {} +// +// Tests that need real subprocess exec or a POSIX shell guard +// themselves with `guard supportsRealExec else { return }` / +// `guard supportsPosixShell else { return }` rather than throwing, +// because Swift Testing has no "skip" status — a thrown error +// would surface as a failure. An unconditional early return passes +// silently, which is the desired behaviour here.