Skip to content

Add Shell.processLauncher — protocol-shaped subprocess dispatch primitive #1

@odrobnik

Description

@odrobnik

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.Process under 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 — calls swift-subprocess.run(...) directly. Used by Shell.processDefault.
  • SandboxedDenyLauncher — throws a typed ProcessLaunchDenied error. Suitable for embedders that bind a sandbox but no command resolver.
  • ChainLauncher — composes two launchers: tries the first, falls through to the second on ProcessLaunchUnresolved. 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:

  1. Consults the builtin/function table for executable.name. If matched, runs the builtin closure inside processTable.spawn (gives virtual pid + cancellation for free).
  2. Falls through (via ChainLauncher) to DefaultProcessLauncher for unmatched names — real exec via swift-subprocess, gated by Shell.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.x until 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: ExecutionResultExecutionOutcome, CollectedResultExecutionRecord).

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 subclass DefaultProcessLauncher.
  • 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.processLauncher exists; default on Shell.processDefault is DefaultProcessLauncher.
  • try await Shell.current.processLauncher.launch(.name("echo"), arguments: ["hi"], …) round-trips on macOS / Linux / Windows.
  • Setting Shell.current.sandbox and replacing the launcher with SandboxedDenyLauncher causes the same call to throw ProcessLaunchDenied.
  • ChainLauncher composes — a custom launcher that resolves only "foo" falls through to DefaultProcessLauncher for "echo", and both work in one test.
  • iOS / tvOS / watchOS build the module; launch(...) throws ProcessLaunchUnsupportedOnThisPlatform on 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions