Repository navigation
Add Shell.processLauncher — protocol-shaped subprocess dispatch primitive #1
Description
Add Shell.processLauncher — protocol-shaped subprocess dispatch primitive
Motivation
ShellKit already owns the host-touching surfaces a polyglot script runtime needs — Sandbox URL gate, NetworkConfig allow-list, Environment, HostInfo, IO sinks, ProcessTable (virtual). The one piece missing is a real subprocess dispatch primitive. Today there is no way to "run /bin/git status and capture its output" through ShellKit; consumers reach for Foundation.Process or swift-subprocess directly, bypassing every Shell.current policy.
This becomes acute now that:
- SwiftScript (Cocoanetics/SwiftScript#3) wants to bridge a subprocess API for script authors. It can deny
Foundation.Processunder a sandbox (Gate every Foundation IO door behind the host sandbox SwiftScript#4), but has no positive story for "run a command, get back stdout". - SwiftBash owns command resolution (builtins shadowing PATH) but has no shared primitive that other runtimes (SwiftScript, a future JS runtime) can call into to reach those builtins.
- Polyglot scripting is the natural endpoint. JavaScriptCore ships on macOS/iOS already; a JS runtime that wants
child_process.exec("ls")to hit a SwiftBash-registered builtin needs the same dispatch primitive SwiftScript needs.
Each runtime brings its own native surface API (swift-subprocess.run for Swift, Node-flavored child_process for JS, bash command syntax for bash). The dispatch and policy below those surfaces should be one primitive in ShellKit, not three parallel implementations in three repos.
Current state
| ShellKit primitive | Owns | Used by |
|---|---|---|
Shell.sandbox: Sandbox? |
URL/path allow-list | SwiftScript (path gating), SwiftBash |
Shell.networkConfig: NetworkConfig |
Host + method allow-list | SwiftScript (URL gating), SwiftBash |
Shell.environment: Environment |
env vars + cwd | SwiftScript (getenv/cwd), SwiftBash |
Shell.hostInfo: HostInfo |
hostname / username / pid | SwiftScript (ProcessInfo), SwiftBash |
Shell.processTable: ProcessTable |
virtual pid registry, closure-as-task spawn | SwiftBash (background jobs) |
| (missing) | real subprocess dispatch | — |
ProcessTable.spawn(command:body:) takes a Swift closure body — it models virtual processes (bash builtins, async pipelines), not real exec. There is no entry point that takes an executable path / name and runs it.
Proposed shape
A ProcessLauncher protocol matching the existing pattern (typed surface, embedder-replaceable, composable):
public protocol ProcessLauncher: Sendable {
func launch(
_ executable: Executable,
arguments: Arguments,
environment: Environment,
workingDirectory: FilePath?,
input: InputSource,
output: OutputSink,
error: OutputSink
) async throws -> ExecutionRecord
}
extension Shell {
/// Defaults to `DefaultProcessLauncher` on `Shell.processDefault`,
/// which delegates to `swift-subprocess.run(...)`. Embedders that
/// virtualise the process table (SwiftBash) inject their own type.
public var processLauncher: any ProcessLauncher { get set }
}Executable, Arguments, Environment, ExecutionRecord, InputSource, OutputSink mirror the swift-subprocess types (so SwiftScript can re-export them in its bridge with no translation), but live in ShellKit so consumers don't need a direct swift-subprocess dep.
Default implementations ShellKit ships
DefaultProcessLauncher— callsswift-subprocess.run(...)directly. Used byShell.processDefault.SandboxedDenyLauncher— throws a typedProcessLaunchDeniederror. Suitable for embedders that bind a sandbox but no command resolver.ChainLauncher— composes two launchers: tries the first, falls through to the second onProcessLaunchUnresolved. The composition primitive that lets SwiftBash say "consult my builtins, else delegate to real exec".
Consumers
SwiftScript (Cocoanetics/SwiftScript#3)
Hand-rolled bridge module exposing swift-subprocess-shaped script-side API:
// Script-side:
let r = try await run(.name("git"), arguments: ["status"], output: .string(limit: 4096))The bridge body calls Shell.current.processLauncher.launch(...). No direct swift-subprocess dependency in SwiftScript's runtime path; just type re-exports.
SwiftBash
Registers a BashProcessLauncher that:
- Consults the builtin/function table for
executable.name. If matched, runs the builtin closure insideprocessTable.spawn(gives virtual pid + cancellation for free). - Falls through (via
ChainLauncher) toDefaultProcessLauncherfor unmatched names — real exec via swift-subprocess, gated byShell.sandbox.
Same launcher answers Swift script calls and bash command-line invocations — registering an ls builtin makes it reachable from both.
JavaScript runtime (future)
A JS host built on JSContext bridges Node-flavored child_process.exec / spawn:
const { stdout } = await child_process.exec("git status");The bridge converts JS strings → Executable.name/Arguments, calls Shell.current.processLauncher.launch(...), wraps ExecutionRecord as a JS Promise resolution. Same dispatch point, same policy, same builtin registry.
Platform support
swift-subprocess supports macOS / Linux / Windows / BSD; not iOS / tvOS / watchOS / visionOS (kernel-level prohibition on posix_spawn / fork for sandboxed apps, not a missing API). The proposed DefaultProcessLauncher therefore wraps in #if os(macOS) || os(Linux) || os(Windows) || os(Android) (or the negative form). On unsupported platforms the launcher type still exists but launch(...) throws ProcessLaunchUnsupportedOnThisPlatform. Embedders can still inject a virtual launcher that uses only processTable.spawn (closure bodies) — that path keeps working on iOS.
Dependencies
- swift-subprocess pinned to
0.4.xuntil 1.0 lands. The 1.0 milestone is open (5 issues remaining, originally due Feb 2026 and now ~3 months overdue), and 0.4 is documented as the last release supporting Swift 6.1. Expect a churn pass when 1.0 ships (recent renames:ExecutionResult→ExecutionOutcome,CollectedResult→ExecutionRecord).
Out of scope
- The JS runtime itself. This issue only ships the dispatch primitive; the JS host is a separate project that consumes it.
- Closure-form
run(...) { execution, stdout in ... }swift-subprocess overloads. v1 ships only the collected-output overload. PlatformOptions(spawn attrs / file actions). Embedders that need them can subclassDefaultProcessLauncher.- Streaming
AsyncSequence<Data>output / span-based input. Defer to v2. - PTY support. Tracked separately if/when swift-subprocess settles on a shape (swiftlang/swift-subprocess#175).
Acceptance
Shell.processLauncherexists; default onShell.processDefaultisDefaultProcessLauncher.try await Shell.current.processLauncher.launch(.name("echo"), arguments: ["hi"], …)round-trips on macOS / Linux / Windows.- Setting
Shell.current.sandboxand replacing the launcher withSandboxedDenyLaunchercauses the same call to throwProcessLaunchDenied. ChainLaunchercomposes — a custom launcher that resolves only"foo"falls through toDefaultProcessLauncherfor"echo", and both work in one test.- iOS / tvOS / watchOS build the module;
launch(...)throwsProcessLaunchUnsupportedOnThisPlatformon those platforms unless a virtual launcher is injected. - Tests cover: standalone passthrough, sandbox deny, chain fallthrough, environment override, working-directory override, captured stdout / stderr, non-zero termination status.
Activity
Add Shell.processLauncher — protocol-shaped subprocess dispatch primi…
Add Shell.processLauncher — subprocess dispatch primitive (#2)
SubprocessModule: bridge `import Subprocess` to Shell.processLauncher (…
BashProcessLauncher: route Shell.processLauncher through the bash com…