You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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):
publicprotocolProcessLauncher:Sendable{func launch(
_ executable:Executable,
arguments:Arguments,
environment:Environment,
workingDirectory:FilePath?,
input:InputSource,
output:OutputSink,
error:OutputSink)asyncthrows->ExecutionRecord}extensionShell{
/// Defaults to `DefaultProcessLauncher` on `Shell.processDefault`,
/// which delegates to `swift-subprocess.run(...)`. Embedders that
/// virtualise the process table (SwiftBash) inject their own type.
publicvarprocessLauncher:anyProcessLauncher{getset}}
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".
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 inside processTable.spawn (gives virtual pid + cancellation for free).
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:
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: 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 subclass DefaultProcessLauncher.
Streaming AsyncSequence<Data> output / span-based input. Defer to v2.
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.
Add
Shell.processLauncher— protocol-shaped subprocess dispatch primitiveMotivation
ShellKit already owns the host-touching surfaces a polyglot script runtime needs —
SandboxURL gate,NetworkConfigallow-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 statusand capture its output" through ShellKit; consumers reach forFoundation.Processorswift-subprocessdirectly, bypassing everyShell.currentpolicy.This becomes acute now that:
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".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.runfor Swift, Node-flavoredchild_processfor 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
Shell.sandbox: Sandbox?Shell.networkConfig: NetworkConfigShell.environment: Environmentgetenv/cwd), SwiftBashShell.hostInfo: HostInfoProcessInfo), SwiftBashShell.processTable: ProcessTableProcessTable.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
ProcessLauncherprotocol matching the existing pattern (typed surface, embedder-replaceable, composable):Executable,Arguments,Environment,ExecutionRecord,InputSource,OutputSinkmirror 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: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
BashProcessLauncherthat:executable.name. If matched, runs the builtin closure insideprocessTable.spawn(gives virtual pid + cancellation for free).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
lsbuiltin makes it reachable from both.JavaScript runtime (future)
A JS host built on JSContext bridges Node-flavored
child_process.exec/spawn:The bridge converts JS strings →
Executable.name/Arguments, callsShell.current.processLauncher.launch(...), wrapsExecutionRecordas a JS Promise resolution. Same dispatch point, same policy, same builtin registry.Platform support
swift-subprocesssupports macOS / Linux / Windows / BSD; not iOS / tvOS / watchOS / visionOS (kernel-level prohibition onposix_spawn/forkfor sandboxed apps, not a missing API). The proposedDefaultProcessLaunchertherefore wraps in#if os(macOS) || os(Linux) || os(Windows) || os(Android)(or the negative form). On unsupported platforms the launcher type still exists butlaunch(...)throwsProcessLaunchUnsupportedOnThisPlatform. Embedders can still inject a virtual launcher that uses onlyprocessTable.spawn(closure bodies) — that path keeps working on iOS.Dependencies
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
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.AsyncSequence<Data>output / span-based input. Defer to v2.Acceptance
Shell.processLauncherexists; default onShell.processDefaultisDefaultProcessLauncher.try await Shell.current.processLauncher.launch(.name("echo"), arguments: ["hi"], …)round-trips on macOS / Linux / Windows.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.launch(...)throwsProcessLaunchUnsupportedOnThisPlatformon those platforms unless a virtual launcher is injected.