Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Systemd scope wrappers disable environment expansion

Status: accepted

Accepted on 2026-09-05 for R40 launch argv transparency.

## Context

st2 adds `systemd-run` around every PTY and exec launch when Linux systemd
scope isolation is available. Rust passes the task program and arguments to
that outer process as opaque OS strings, but `systemd-run` performs its own
command-line environment expansion by default. A Linux systemd 260 experiment
passed the literal `$HOME:${UNSET}:$$` through the wrapper and observed `$$`
changed to `$`. The wrapper therefore did not preserve launch argv even though
st2 itself never invoked a shell or edited the argument.

The scope wrapper is also the mechanism behind control-plane replacement
safety. Any correction must retain scope creation, collection, inherited I/O,
PTY behavior, exit status, and outliving-child membership.

## Options

| Option | Tradeoffs |
| --- | --- |
| Disable systemd expansion before the separator — selected | Preserves the original OS strings without changing scope mechanics. |
| Keep systemd's default expansion | Rejected because the live probe changed caller-owned argument data. |
| Rewrite dollar signs into systemd escape sequences | Rejected because st2 would have to interpret every OS string and maintain a wrapper-specific transform. |
| Shell-quote the inner command | Rejected because there is no shell boundary; quote bytes would become task argument bytes, while adding a shell would introduce evaluation. |
| Drop systemd scope isolation for affected commands | Rejected because it would trade argument corruption for violation of R11 control-plane replacement safety. |

## Evidence and Argument

The Linux systemd 260 experiment distinguished the wrapper's default from the
selected option with one literal input. Default expansion changed the terminal
`$$` in `$HOME:${UNSET}:$$` to `$`; `--expand-environment=no` preserved the
complete input. With expansion disabled, non-TTY argv and standard descriptors,
real-PTY terminal status, exit propagation, outliving-child scope membership,
collection, exact-name reuse, and cleanup all retained their prior behavior.

The option is the narrowest boundary fix: systemd owns the unwanted
interpretation, and systemd exposes a switch that removes it. Rewriting task
arguments inside st2 would replace downstream interpretation with an
st2-maintained encoding and would no longer be opaque launch argv.

## Decision

Linux scope launches use this exact outer argument order:

```text
systemd-run --user --scope --collect --quiet --unit=<unit> --expand-environment=no -- <program> <arg>...
```

`--expand-environment=no` is passed as a `systemd-run` option before the `--`
separator. st2 appends the program and each argument after the separator as the
original OS strings. It does not quote, escape, expand, or render those values
through a shell. Detached and degraded-detached modes remain direct
pass-throughs.

## Consequences

- Dollar-bearing literals, including `$HOME`, `${UNSET}`, and `$$`, reach PTY
and exec tasks byte-for-byte in scope mode.
- The systemd option sequence is part of the tested wrapper contract; the only
addition to the prior shape is the expansion-disable option before the
separator.
- macOS and Linux hosts without usable user scopes keep the existing direct
program-and-argv path.
- Scope lifetime and I/O behavior are unchanged. The supporting live evidence
is recorded in the [systemd scope argv experiment](../.experiments/2026-09-05-systemd-scope-argv-transparency.md).
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Systemd scope argv transparency

Date: 2026-09-05
Fixture: Linux with a functioning systemd 260 user manager

## Question

Does `systemd-run --user --scope` preserve dollar-bearing task arguments by
default, and does `--expand-environment=no` correct that boundary without
changing the scope, I/O, PTY, exit-status, collection, or name-reuse behavior
on which st2 already depends?

## Method

A disposable helper was launched through the same ordered scope options used by
st2. It received the single literal argument `$HOME:${UNSET}:$$` and reported
the value it actually received. The probe ran once with systemd's default
command-line expansion and once with `--expand-environment=no` before the `--`
separator.

The expansion-disabled form was then exercised through two transport fixtures:
a non-TTY process with distinct argv, stdin, stdout, stderr, and exit-status
probes, and a real PTY process that reported terminal status for all three
standard descriptors. A final lifetime probe left a child alive after the
wrapper exited, observed scope membership and collection, reused the same unit
name after collection, and checked fixture cleanup.

## Result

| Probe | Observation |
| --- | --- |
| Default `systemd-run` expansion | The received value differed from the supplied literal: the terminal `$$` became `$`. |
| `--expand-environment=no` before `--` | The helper received the complete literal `$HOME:${UNSET}:$$` unchanged. |
| Non-TTY transport | argv, stdin, stdout, and stderr probes were preserved; the wrapper returned the task's exit status 37. |
| Real PTY transport | stdin, stdout, and stderr remained TTYs; the wrapper returned the task's exit status 23. |
| Outliving child | The child remained in the same active scope after wrapper exit. `--collect` unloaded the scope only after the child exited. |
| Unit reuse and cleanup | Exact-name reuse succeeded after collection. No matching process, unit, or temporary fixture path remained. |

## Conclusion

Systemd's default command-line expansion is not transparent to caller-owned
argv. Passing `--expand-environment=no` before the command separator is the
narrow correction: it preserves the complete dollar-bearing literal while all
measured scope lifecycle, descriptor, PTY, exit-status, collection, and reuse
semantics remain unchanged. This supports [R40 launch argv transparency](../requirements.md)
and [decision 0016](../.decisions/0016-systemd-scope-wrappers-disable-environment-expansion.md).

## VRS Impact

- `requirements.md` adds R40 launch argv transparency.
- `ontology.md` defines **launch argv** as the canonical task-wide term.
- `spec.md` fixes the exact systemd scope wrapper order and its deterministic
scope/pass-through tests.
- Decision 0016 records the selected systemd option and rejected alternatives.
13 changes: 13 additions & 0 deletions docs/vrs/ontology.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ A terminal-backed or terminal-free unit declared for an agent.
Authority: [Agent Spec task contract](https://github.com/compoundingtech/evals/blob/main/AGENT-SPEC.md#compact-and-explicit-tasks);
[`Task`](../../crates/agent-spec/src/spec.rs#L121-L148)

### launch argv

The ordered, opaque OS-string sequence comprising a task program and each of
its arguments at the launcher boundary. A platform wrapper may prepend its own
outer arguments, but it does not parse, expand, escape, or otherwise rewrite
the launch argv. This is not a shell command line. Use *provider argv* only
when referring specifically to the canonical agent provider; *launch argv*
applies to every PTY and exec task.

Authority: [R06 restartable launch definitions and R40 launch argv
transparency](requirements.md);
[host-local scheduling and supervision](spec.md#host-local-scheduling-and-supervision)

### agent ID

The explicit catalog-global immutable identifier of one logical agent subject.
Expand Down
6 changes: 6 additions & 0 deletions docs/vrs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ accepted.
- **R06 Restartable launch definitions:** A restarted PTY or exec receives the
complete effective launch definition, including environment and supported
launch fields.
- **R40 Launch argv transparency:** Every st2-added launch wrapper preserves the
effective task program and each argument as ordered, opaque OS strings. A
Linux systemd scope launch disables systemd command-line environment
expansion before the wrapper separator, so dollar-bearing literals including
`$HOME`, `${UNSET}`, and `$$` reach the task byte-for-byte. Detached and
degraded-detached launches remain exact pass-throughs.
- **R07 Verified hooks:** Required hook content is installed explicitly and
verified before a rendered agent depends on it. The selected receipt carries
the binary's real source identity regardless of build system. Ordered
Expand Down
22 changes: 22 additions & 0 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,28 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler
manual PTY restart under a different ambient environment reconstructs the
same effective color policy. Adoption of an already-live task remains
non-mutating: this policy is applied only when st2 creates a generation.
- **R40:** [Launch argv](ontology.md#launch-argv) is opaque at every
st2-added wrapper boundary. In Linux systemd scope mode the exact outer
command order is:

```text
systemd-run --user --scope --collect --quiet --unit=<unit> --expand-environment=no -- <program> <arg>...
```

`--expand-environment=no` is an outer `systemd-run` option immediately before
the `--` separator. `<program>` and every `<arg>` are appended as their
original OS strings, without shell rendering, dollar escaping, or
environment substitution. Detached and degraded-detached modes remain
`<program> <arg>...` pass-throughs with no outer command.

`src/isolate.rs::tests::wrap_scope_disables_expansion_and_preserves_dollar_bearing_argv`
fixes the complete wrapper order and proves literal `$HOME`, `${UNSET}`, and
`$$` after the separator.
`src/isolate.rs::tests::wrap_detached_modes_preserve_exact_program_and_argv`
proves both pass-through modes. The live-system distinction and unaffected
scope semantics are recorded in the
[systemd scope argv experiment](.experiments/2026-09-05-systemd-scope-argv-transparency.md)
and [decision 0016](.decisions/0016-systemd-scope-wrappers-disable-environment-expansion.md).
- **R07:** Hook bundles are explicit, content-addressed, installed separately,
and verified before materialization references them. Their receipts use the
same resolved build identity as the binary's version surfaces for both
Expand Down
Loading
Loading