Skip to content

The shell provider: one command-execution surface for every platform, through pkg/process #799

Open

Description

@David-Noble-at-work

Spec: 3.5.8-shell-provider.md
(status) ·
Neighbours: 3.5.9 is the PowerShell sibling,
6 owns where commands run and under whose privilege,
pkg/process declares itself the single bridge to os/exec,
6.3 owns the form a command takes and is partly
superseded by the environment approach below.

A non-hermetic Starlark shell needs three things from this provider. It has one, and that one
bypasses the package built to serve it.

The surface is two orthogonal dimensions, not three features:

Form Consequence
Does a shell interpret it? argv vector, or a command line a command line is parsed — quoting, splitting, globbing, substitution — and needs a shell present and chosen
What happens to the caller? waits, or is replaced waiting returns a result; replacement returns nothing, because execve has no process to return to

Which gives four cells, and today only one of them exists:

argv command line
waits shell.run(argv) — #801 shell.run_command(line) — today's shell.exec, hardcoded sh -c
replaced shell.exec(argv) — #802 shell.exec_command(line)

Two verbs and one suffix. run waits, exec replaces, and _command means a shell parses
this
. The grid is legible from the names, following the convention file already sets with
read_text / read_bytes and write_text / write_bytes: same verb, suffix naming the form of
the input.

The names are anchored in what the audience already knows — most are Python-literate:

Name The Python they already write
shell.run(argv) subprocess.run(["git", "clone"])
shell.run_command(line) subprocess.run(line, shell=True)
shell.exec(argv) os.execv(path, argv)
shell.exec_command(line) os.execv("/bin/sh", ["sh", "-c", line])

exec returns to its POSIX meaning. That renames the two actions that have callers today —
shell.exec and powershell.exec — which is the cost of not having exec mean the opposite of what
it means everywhere else, permanently.

Not dispatching on argument type. shell.run(x) accepting a list or a string would be more
Pythonic still, and is rejected deliberately: the shell / no-shell distinction is an injection
boundary. Explicit names make grep -rn 'run_command\|exec_command' find every site where a shell
parses possibly-untrusted text. Type dispatch cannot be grepped for — a reviewer has to notice the
quotes. For an audience wiring commands out of variables, that is worth one extra word.

The fourth cell exists because of the preferred shell. "Become my preferred shell running this
line" is a login-script idiom. Without a chosen shell it is expressible as
exec(["bash", "-lc", line]) — but only by a caller who knows which shell, which is exactly the
knowledge #800 moves into the provider.

The split is not cosmetic. argv versus a command string is a correctness boundary: a command string
is parsed by a shell — quoting, word splitting, globbing and injection all apply — while argv reaches
execve as a vector that nothing reinterprets. A caller building a command from a branch name or an
issue title wants the second and currently has only the first. It is also a dependency boundary:
command execution needs a shell present and chosen; direct execution needs neither.

Command bodies take named values through the environment

The _command cells do not take a concatenated string. The body is a literal the author writes;
values arrive as named entries in the child process's environment; the shell resolves them at
runtime.

shell.run_command(
    body = 'journalctl -u "$unit" --since "$since" | tail -20',
    args = {"unit": name, "since": since},
)

Expansion is not parsing. The shell substitutes a value and does not re-scan it for
metacharacters, so user data never becomes program text. Verified with a hostile value in both
dialects — foo; touch /tmp/PWNED printed literally, nothing ran.

Nothing needs escaping, and there is no $ doubling: $HOME resolves normally beside $unit, and
\$5.00 escapes by ordinary shell rules the author already knows. That would only be needed if the
provider templated the body itself, which is exactly what this avoids.

It removes the PowerShell file tier

This supersedes 6.3's positional-argument tier 2 and,
with it, most of that document's machinery. Measured, not assumed:

Named values File required
Positional args sh -c body sh a b works; pwsh -Command with -args is a ParserError yes, for pwsh — only -File script.ps1 a b
Environment "$unit" in sh, $env:unit in pwsh no, in either

So 6.3 §3.2 (where the file lives), §3.4 (the record is the script, not the path), §6.4 (a script
under sudo readable by the elevated identity) and §3.3 (PowerShell has its own tier 2) all
dissolve. They exist to serve a temp file that neither dialect needs once values travel as
environment rather than argv.

The one thing positional args give that this does not: a body runnable by hand with sh script a b.
Given the body is static and the provider runs it, that is a fair trade.

The contract, and where each part is enforced

Duty Whose Enforced by
Values never enter program text provider the design — environment, not concatenation
A misspelled name fails loudly provider set -u prepended to the body
Names resolve to a known set provider a constructed environment, not the inherited one
References are quoted author lint
No eval of an injected name author lint

The first three are structural; an author cannot get them wrong. The last two are the residue, and
both are real:

  • Unquoted expansion splits and globs. $unit holding two words becomes two arguments;
    holding *.txt it becomes a file list. A correctness bug, not injection — the value still cannot
    run a command — and set -u does not catch it.
  • eval re-opens what expansion closed. eval "echo $v" re-parses the expanded text; the
    payload ran. Nothing at the provider boundary can prevent an author choosing that.

A lint rule over a literal body — every $name declared in args or allowlisted, references
quoted, no eval — turns those conventions into errors. That is the step that makes this a
guarantee rather than a convenience, and it is the same rule that catches a typo at authoring time
instead of at runtime.

Why the sugar earns its keep

The safe form is expressible today by careful quoting, and the unsafe form remains expressible after
this change, so this is sugar in the strict sense. It is the same move as prepared statements: no new
expressive power, but the safe path becomes the ergonomic one and the unsafe path becomes
conspicuous, which is how the defect class actually dies.

That holds only while command= survives beside run_command. If the escaping form is retired —
6.3 §6.1 leaves that open — this stops being sugar,
because the shape that needs escaping no longer exists. That question is the one to settle, and
it decides whether the rename in the child issues is mechanical or a redesign.

The PowerShell provider is folded in

powershell.exec goes away. Its own spec describes it as the shell provider "otherwise" — the same
*Result shape, the same non-compensability, the same context-bound subprocess. The only differences
are the invocation line (pwsh -NoLogo -NoProfile -Command) and a capability ruling that PowerShell
5.x is unsupported.

Neither needs a provider. Both are properties of a shell you can select: once
shell.run_command honours a chosen shell, powershell.exec(line) is
shell.run_command(line, shell="pwsh").

  • The pwsh-only ruling survives intact as pwsh is a supported shell; powershell.exe is not.
  • -NoLogo -NoProfile becomes a per-shell adapter detail. Every shell needs its own invocation form
    anyway — sh -c, bash -lc, pwsh -Command — and that table is the mechanism, not a provider.
  • The provider is currently untested: zero Go tests, zero fixtures, intaken by phase-8 step 52.
    Collapsing removes an untested surface rather than obliging someone to backfill tests for it.

What is wrong today

shell.exec bypasses pkg/process. The package is explicit — "the single bridge between
os/exec and the runtime environment's status and result channels. Every provider that shells out
goes through Runner so line-splitting, dry-run, cancellation, and error wrapping live in exactly one
place."
pkg/op/provider/shell/provider.go imports os/exec directly and never imports it. So does
powershell/provider.go. This is the violation 6 cites
as the reason internal/pwsh was deleted — "a second process-execution path beside pkg/process" —
now sitting in the two providers whose whole job is shelling out.

Dry-run appears not to be honoured. Runner implements it (dryRun bool // when true, narrate the command but skip execution). shell.exec contains zero occurrences of dry-run and goes straight
to cmd.Run(). Whether a dry-run graph reaches the provider at all is an executor question not yet
traced, so this is suspected rather than proven — but nothing in the provider prevents it.

The preferred shell is declared and never read. schema/devlore-config.json carries
lore.preferences.shell — "Preferred shell", a free string with no enum. It appears in production
code nowhere; the only references are config_test.go fixtures exercising generic nested-key get,
set and delete. The key is settable, persists, and changes nothing.

The name exec is already spent, and spent wrongly

In POSIX exec is replacement — execve overlays, it does not spawn. shell.exec currently names
the action that spawns a shell and waits, which is the opposite. Adding the replacing action forces a
decision:

Option Execution Command execution Process replacement Cost
Keep shell.run shell.exec (unchanged) shell.replace exec keeps meaning the opposite of what it means everywhere else
Correct shell.run shell.command shell.exec renames the one action that already has callers

A related question: does direct process execution belong in a provider named shell? Running a
process without a shell is not a shell operation. Grouping the three keeps them discoverable, which
is a real argument, but the name will mislead.

Why replacement is the hard one

execve returns only on failure — "the successful call has no process to return to". Go models it
func Exec(argv0 string, argv []string, envv []string) (err error): a nil return is unreachable.
That contract has no row in the action taxonomy, which assumes every action returns a T to a
caller, and it cannot use Runner, which needs a command to run rather than a process to become.

It is admissible only in the ambient immediate starlarkbridge.Runtime. In a graph it removes
the executor's entire contract — no receipt to stamp, no compensator to unwind, no context left to
cancel. The Graph == nil seam that already separates the two dispatch stacks is where that is
enforced.

The script interrogates the result

Ruled 2026-09-13: the calling code must be able to interrogate what a command did. A non-zero exit is an outcome, not an error.

  • run and run_command return the *Result — command, exit_code, stdout, stderr — whatever the exit code. The script decides what a non-zero exit means.
  • The error return is reserved for failing to run at all: program not found, shell missing, cancelled. It carries the cause.
  • Captured stderr is never discarded. Narrating it as it streams does not replace returning it.
  • exec and exec_command return only on failure; that error carries the cause and whatever stderr the attempt produced.

This overturns today's contract. shell.exec returns a non-nil error beside its *Result on a non-zero exit, and the bridge raises, so every exit_code != 0 check in Starlark is unreachable (#824). The 2026-09-10 comment below names this as the decision the rewrite had to affirm or overturn.

It constrains #800. pkg/process.Runner cannot serve it as written: Run and Capture stream stderr to Status.Warn without capturing it, and fold a non-zero exit into wrapExitError, which keeps the exit code only as text. Routing through Runner needs a method that tees stderr to narration and into the result, and returns the exit code as a value.

Observed cost, 2026-09-13: star gh issues report failed all day with Error in shell.exec: exit status 1. The connection reset by peer that explained it was captured and thrown away.

Children

Open question, deferred: start-and-wait

Whether to support launching a process without waiting for it — Node's and Rust's spawn — is
deliberately out of scope here and worth settling later.

The current view is that it is not a verb. A process you do not wait for has identity, a
lifetime, and a natural inverse: kill it. That is a resource with a compensator, not an action
returning a handle. An action returning a PID hands the executor something it cannot reason about —
no receipt that means anything, nothing to unwind, no idempotency on resume.

Two further reasons to defer: graph-level parallelism already covers "run these three at once", so
process-level asynchrony duplicates it for the common case; and the genuine remaining need — a dev
server, a watcher, a tunnel — is a lifecycle, which is the resource shape again.

Nothing in the tree runs a process without waiting today: every path through pkg/process ends in
cmd.Run(), with no Start(), no Wait(), and no process handle anywhere. So spawn would also be
the wrong name for any of the four actions above, since in both Node and Rust it promises exactly the
not-waiting this provider does not do.

Plan and design documents

Kind Document
Plan not yet — the plan arrives when the branch does; it must carry the result contract (#824)
Design draft in hand, uncommitted; lands with the first worktree

Reshaped 2026-09-07: this was an epic with six items and no feature — the shape the process rules out (an epic with no features is not an epic). It is now the fifth feature of Epic: Providers (#815), beside pkg (#816), file (#836), lint (#837) and plan (#856); its items carry **Feature:** #799. What it delivers is what #861, noblefactor-ops#151 and every cross-platform star command wait on: shell.exec through pkg/process with dry-run and narration (#800), argv execution (#801), execve (#802), the error-only signature (#803), the environment-over-args rule and the PowerShell file tier (#804), and a non-zero exit returned rather than raised, stderr intact (#824).

Epic: #815

Activity

  1. changed the title [-]Epic: The shell provider[/-] [+]The shell provider: one command-execution surface for every platform, through pkg/process[/+] on Sep 8, 2026
  2. David-Noble-at-work commented on Sep 10, 2026

    @David-Noble-at-work
    ContributorAuthor

    Facts preserved from devlore-cli.shell-exec-product before that dead branch is torn down. These document
    the current shell.exec (the 2026-08-28 "Starlark surface and runtime availability" work). The
    rewrite renames it run_command and reclaims exec for execve — so treat each as a behaviour the new
    run_command must consciously keep or change, not as prose to paste.

    The return value. shell.exec returns a *Result that is not opaque: every exported field is
    reachable, and nothing declares that surface — no +devlore: directive, no registration, no generated
    file. The bridge resolves the receiver type by reflection when none is registered, rendering Go names in
    the config vocabulary (ExitCode → exit_code).

    result = shell.exec(command="echo hello")
    result.command    # "echo hello"
    result.stdout     # "hello\n"
    result.exit_code  # 0

    Three consequences, each guessed wrong at least once:

    • The value is the record, not a field. type(result) and "%s" % result are both "Result" — not
      the command string, not stdout.
    • The wrapper is unconditionally truthy. Truthiness follows the receiver, not any field; a successful
      command is truthy whether or not it wrote anything.
    • A non-zero exit is unobservable from a script. Exec is a fallible action: it reports failure as an
      error, never a Result carrying a non-zero exit_code. A script never sees the failing record. (This
      is the design decision the rewrite most needs to affirm or overturn.)

    In a planned graph. The surface above is immediate only (non-hermetic runtime; star and the test
    harness, never a phase script). A planned invocation is a different value: plan.shell.exec(...) yields an
    *op.Invocation; the *Result does not exist until dispatch, so field projection off a node's output is
    unavailable at plan time (plan.shell.exec(...).stdout → "Invocation" object has no attribute "stdout").
    Two consequences for decision trees: a plan.case(when=…) on a planned shell.exec routes on the whole
    *Result (truthy on success regardless of output), and a failing command raises rather than routing to
    default=. A shell command is therefore not usable as a host-detection predicate — worth stating in the
    rewrite, since command execution is its subject.

    Unavailable while planning. Exec makes no deterministic claim, and a hermetic runtime admits a method
    only when it claims one; running a command at plan time would make the graph depend on the planning machine
    (2.4 forbids it).

    Registration, as of that work: op.NewProviderFlags(op.SurfaceScript|op.SurfaceWorkflow, op.PlacementQualified) — op.RoleAction was retired by #726.

    A test gap the rewrite inherits: a non-zero exit_code is unassertable from a Starlark test, precisely
    because a failing command surfaces as an error, not a Result. (The fixture that documents the passing
    surface, test_imm_shell.star, was corrected in #875; the corpus-wide run gate is #664.)

  3. added 2 commits that reference this issue on Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area:ArchitectureSystem architecture and designEpic:ProvidersThe provider surface: one feature per providerfeatureDeliverable unit within an epic

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions