Repository navigation
The shell provider: one command-execution surface for every platform, through pkg/process #799
Description
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.
$unitholdingtwo wordsbecomes two arguments;
holding*.txtit becomes a file list. A correctness bug, not injection — the value still cannot
run a command — andset -udoes not catch it. evalre-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.exeis not. -NoLogo -NoProfilebecomes 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.
runandrun_commandreturn 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.
execandexec_commandreturn 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
- The shell provider cannot run a process directly: argv execution is absent #801 — Execution: direct process execution, argv, through
Runner - shell.exec bypasses pkg/process: no dry-run, no narration, and the preferred shell is never read #800 — Command execution: route through
Runner, honour the preferred shell, restore dry-run - The shell provider cannot replace the current process: no execve action #802 — Process replacement: the divergent action, immediate-mode only
- The action taxonomy has no error-only signature, and no way to say an action is immediate-mode only #803 — Taxonomy: an error-only signature row, and an admissible-dispatch-mode column
- 6.3 command execution: the environment supersedes positional args and the PowerShell file tier #804 — Documentation: bring 6.3 into agreement with the environment approach
- shell.exec raises on a non-zero exit instead of returning the result, and the captured stderr is lost #824 — Result contract: a non-zero exit returns the result, stderr intact
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
[-]Epic: The shell provider[/-][+]The shell provider: one command-execution surface for every platform, through pkg/process[/+]fix(devlore-test): three fixtures assert text the runtime no longer p…
David-Noble-at-work commentedon Sep 10, 2026
Facts preserved from
devlore-cli.shell-exec-productbefore that dead branch is torn down. These documentthe current
shell.exec(the2026-08-28"Starlark surface and runtime availability" work). Therewrite renames it
run_commandand reclaimsexecforexecve— so treat each as a behaviour the newrun_commandmust consciously keep or change, not as prose to paste.The return value.
shell.execreturns a*Resultthat is not opaque: every exported field isreachable, and nothing declares that surface — no
+devlore:directive, no registration, no generatedfile. The bridge resolves the receiver type by reflection when none is registered, rendering Go names in
the config vocabulary (
ExitCode→exit_code).Three consequences, each guessed wrong at least once:
type(result)and"%s" % resultare both"Result"— notthe command string, not stdout.
command is truthy whether or not it wrote anything.
Execis a fallible action: it reports failure as anerror, never a
Resultcarrying a non-zeroexit_code. A script never sees the failing record. (Thisis the design decision the rewrite most needs to affirm or overturn.)
In a planned graph. The surface above is immediate only (non-hermetic runtime;
starand the testharness, never a phase script). A planned invocation is a different value:
plan.shell.exec(...)yields an*op.Invocation; the*Resultdoes not exist until dispatch, so field projection off a node's output isunavailable at plan time (
plan.shell.exec(...).stdout→"Invocation" object has no attribute "stdout").Two consequences for decision trees: a
plan.case(when=…)on a plannedshell.execroutes on the whole*Result(truthy on success regardless of output), and a failing command raises rather than routing todefault=. A shell command is therefore not usable as a host-detection predicate — worth stating in therewrite, since command execution is its subject.
Unavailable while planning.
Execmakes no deterministic claim, and a hermetic runtime admits a methodonly 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.RoleActionwas retired by #726.A test gap the rewrite inherits: a non-zero
exit_codeis unassertable from a Starlark test, preciselybecause a failing command surfaces as an error, not a
Result. (The fixture that documents the passingsurface,
test_imm_shell.star, was corrected in #875; the corpus-wide run gate is #664.)docs(plans): #903 Phase 3 verified on the live path; both plans complete
fix(star): self uninstall removes only the extensions it installed (#919