Skip to content

refactor: RemotePayload::wrap could not tell argv from a shell script - #596

Merged
blooop merged 5 commits into
mainfrom
refactor/remote-command-argv-or-script
Sep 9, 2026
Merged

refactor: RemotePayload::wrap could not tell argv from a shell script#596
blooop merged 5 commits into
mainfrom
refactor/remote-command-argv-or-script

Conversation

@blooop

@blooop blooop commented Sep 9, 2026

Copy link
Copy Markdown
Owner

The escalation from #591's review, now that it has its own PR.

The shape, not the instance

Two callers meant opposite things by the same Option<String>:

  • dl's -- <command> handed it words whose quoting the host's shell had already stripped.
  • dotfiles_command handed it a script it composed itself, && and $(...) included.

The type could not tell them apart. So the join that turns the first into a command line lived up in dl, and the second survived only by nobody joining it. #591 fixed one instance of that; this removes the shape that allowed it.

pub enum RemoteCommand {
    Argv(NonEmpty<String>),
    Script(String),
}

RemoteCommand::line() is now the one place either sense becomes a command line, so dl no longer composes one at all — it hands over the words it was given. A caller can no longer pick the wrong reading, because there is no longer a reading to pick.

Two things fall out of holding words instead of a line

Agent detection gets exact. RemoteCommand::agent takes the first word that is not an assignment prefix. herdr::agent_in had to split on whitespace, which would have taken 'my out of a quoted program name. The script arm still uses the whitespace guess, because a script is the caller's own syntax and there is nothing better to do with it.

The reachable half of the finding closes. LaunchVerb::Attach is pub, so an external caller could hand its Option<String> a pre-composed line and get the old dangerous reading. It now takes a RemoteCommand.

aid's half of the same finding

build_agent_command returns Option<NonEmpty<String>>. An empty argv is not a smaller answer but a different one: build_dl_args would emit [<spec>, "--"], and dl reads a separator with nothing after it as a plain interactive attach — an agent was asked for and a shell arrives. dl re-exports NonEmpty for it, since aid may name nothing but dl; dl carries no public-api snapshot, so that re-export moves none.

One behaviour change, in an error rather than a payload

UnquotableCommand now carries the composed line, so a NUL in dl <ws> -- echo <NUL>hi is reported as echo '<NUL>hi' rather than echo <NUL>hi. There is no single string the caller gave any more, and the line is the thing that actually could not be made into a shell word. The refusal itself is unchanged and still tested: shell::join quotes the offending word rather than rejecting it, so the NUL is still there when posix_quote looks at the line.

Every payload assertion is unchanged

The core tests that modelled dl -- <cmd> build Argv now, and their expectations did not move, because both words were already shell-safe. The exception is the one that spelled the bug — claude 'fix the bug' — which was never one argument and now is.

public-api

This deliberately moves the devlaunch-core snapshots. I could not regenerate them here: cargo-public-api 0.52.0 wants to build openssl-sys, and the devcontainer has no OpenSSL headers. The public-api job prints the exact diff, so the snapshots in this PR are whatever CI says they should be rather than my guess at them.

Verification

2406 rust tests pass, cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings clean, 761 in the Python guards, prek clean.

🤖 Generated with Claude Code

Summary by Sourcery

Represent remote commands by their intended shape so argument vectors and shell scripts are serialized safely and consistently.

Bug Fixes:

  • Preserve remote command arguments and shell scripts according to their intended form, preventing quoted arguments from being re-split and shell syntax from being accidentally reinterpreted.
  • Reject empty generated agent command argument lists so an agent request cannot silently degrade into an interactive shell attach.
  • Report the composed remote command when rejecting commands containing unquotable data.

Enhancements:

  • Introduce a typed remote-command representation that centralizes command-line construction and enables exact agent detection for argv commands.
  • Make the breaking LaunchVerb::Attach API distinguish argument vectors from shell scripts.
  • Re-export the non-empty argument-vector type through dl for agent command construction.

CI:

  • Add pull-request version availability checks and distinguish publish reruns from pushes that move a version onto an existing tag.

Documentation:

  • Document the public API surface count and the breaking remote-command API change in the changelog and development documentation.

Tests:

  • Update command and launch tests to use typed argument vectors while preserving existing payload behavior and covering corrected quoting and error reporting.

Chores:

  • Refresh public API snapshots for the new public command type and API changes.

Two callers meant opposite things by the same `Option<String>`. `dl`'s
`-- <command>` handed it words that had already had their quoting stripped by
the host's shell, and `dotfiles_command` handed it a script it had composed
itself, `&&` and `$(...)` included. The type could not tell them apart, so the
join that turns the first into a line lived in `dl` and the second had to be
left alone by hoping nobody joined it. That is the shape #591 fixed one instance
of; this removes the shape.

`RemoteCommand::Argv(NonEmpty<String>) | ::Script(String)` is the split, and the
quoting moves in beside it: `line()` is the one place either sense becomes a
command line, so `dl` no longer composes one at all -- it hands over the words it
was given. A caller can no longer pick the wrong reading, because there is no
longer one reading to pick.

Two things fall out of having the words rather than a line. `RemoteCommand::agent`
finds the program exactly for argv -- the first word that is not an assignment --
where `herdr::agent_in` had to split on whitespace and would have taken `'my` out
of a quoted program name. And `LaunchVerb::Attach`'s field can no longer be
handed a pre-composed line by an external caller, which was the reachable half of
the finding: it is `pub`.

aid's half of the same finding: `build_agent_command` returns
`Option<NonEmpty<String>>`. An empty argv is not a smaller answer but a different
one -- `build_dl_args` would emit `[<spec>, "--"]`, and dl reads a separator with
nothing after it as a plain interactive attach, so an agent that was asked for
arrives as a shell. `dl` re-exports `NonEmpty` for it, since aid may name nothing
else; dl carries no public-api snapshot, so that re-export moves none.

One behaviour change, in an error rather than in a payload. `UnquotableCommand`
now carries the composed line, so a NUL in `dl <ws> -- echo <NUL>hi` is reported
as `echo '<NUL>hi'` rather than `echo <NUL>hi`. There is no single string the
caller gave any more, and the line is the thing that actually could not be made
into a shell word. The refusal itself is unchanged and still tested: `shell::join`
quotes the offending word rather than rejecting it, so the NUL is still there when
`posix_quote` looks.

Every existing payload assertion is unchanged. The core tests that modelled
`dl -- <cmd>` now build `Argv`, and their expectations did not move because both
words were already shell-safe -- except the one that spelled the bug,
`claude 'fix the bug'`, which was never one argument and now is.

Claude-Session: https://claude.ai/code/session_01XvY78XEnrkzNjxZE5EtnyW

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 21 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

The PR replaces ambiguous remote command strings with a typed argv-or-script model, centralizes shell-line construction in core, and propagates that distinction through launch, agent detection, dotfiles, and public attach APIs. It also makes empty agent commands unrepresentable and updates error semantics and regression tests to reflect the new composition boundary.

Sequence diagram for typed command wrapping

sequenceDiagram
    participant DL
    participant Launch
    participant RemoteCommand
    participant RemotePayload
    participant RemoteShell

    alt argv from dl
        DL->>Launch: Attach(RemoteCommand::Argv(words))
        Launch->>RemoteCommand: line()
        RemoteCommand-->>Launch: shell::join(words)
    else composed dotfiles script
        Launch->>RemoteCommand: Script(script)
        RemoteCommand-->>Launch: line()
    end
    Launch->>RemotePayload: wrap(command, zellij)
    RemotePayload->>RemoteShell: bash -lc quoted(line)
    RemoteShell-->>RemotePayload: execute command
Loading

Flow diagram for exact agent detection

flowchart LR
    Command["RemoteCommand"] --> Kind{Command shape}
    Kind -->|Argv| Assign["Skip assignment prefixes"]
    Assign --> Named["agent_named(first program word)"]
    Kind -->|Script| Split["agent_in(script)"]
    Split --> Guess["Whitespace-based program guess"]
Loading

File-Level Changes

Change Details Files
Introduce a typed remote-command representation that preserves whether input is argv or an authored shell script.
  • Add RemoteCommand::Argv(NonEmpty<String>) and RemoteCommand::Script(String).
  • Centralize argv quoting and script pass-through in RemoteCommand::line().
  • Update attach, SSH, payload wrapping, and dotfiles flows to carry the typed command.
rust/devlaunch-core/src/flows/launch.rs
rust/dl/src/launch.rs
Make agent detection operate on the original command shape and avoid reparsing argv words.
  • Add exact program-name detection for already-split argv.
  • Retain whitespace-based detection only for script commands.
  • Use typed commands throughout session and routing logic.
rust/devlaunch-core/src/clients/herdr.rs
rust/devlaunch-core/src/flows/launch.rs
Prevent empty agent argv results from being interpreted as an interactive attach.
  • Change agent command construction to return Option<NonEmpty<String>>.
  • Re-export NonEmpty from dl for aid without expanding its public API surface.
  • Adapt argv assembly and tests to the non-empty collection.
rust/aid/src/rewrite.rs
rust/dl/src/lib.rs
Update payload error reporting and regression coverage for the centralized command composition.
  • Report the composed, shell-quoted line in UnquotableCommand.
  • Convert command payload and launch tests to construct RemoteCommand::Argv.
  • Preserve script behavior and verify quoted arguments, agent naming, routing, and NUL refusal.
rust/devlaunch-core/src/flows/launch.rs
rust/dl/src/launch.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.87%. Comparing base (7653de3) to head (3782590).

Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.13% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 95.13% <100.00%> (+<0.01%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Regenerated by CI rather than here: `cargo-public-api` 0.52.0 builds
`openssl-sys`, and the devcontainer carries no OpenSSL headers, so the job's own
diff is the source of these two files.

`public-api.api.txt` is a **promise change, and a break**:
`LaunchVerb::Attach::command` goes from `Option<String>` to
`Option<RemoteCommand>` at both paths it is rendered at. That is the point of the
change rather than a side effect -- the `pub` field taking a pre-composed line was
the reachable half of the finding, since an external caller could hand it a shell
script where dl hands it argv and nothing in the type objected.

`public-api.rest.txt` gains `RemoteCommand` and its derives. `line()` is the only
inherent method on it; `agent()` stays `pub(crate)`, because naming the agent is
dl's business with a session manager and not a promise to anyone outside.

Claude-Session: https://claude.ai/code/session_01XvY78XEnrkzNjxZE5EtnyW
…stale

`RemoteCommand` is a type `api` never re-exports but a promised signature names
-- `LaunchVerb::Attach::command` -- so it is contract surface classified as
binary surface, and it lands in `public-api.rest.txt` with 13 rows. That is the
documented limit of the classifier rather than a new hole, and the count of types
in it goes 37 to 38.

Three sites carry that count and `test_public_api_snapshots_doc.py` holds all
three: `docs/development.md`, the `public-api-snapshots.sh` header, and
`devlaunch-core/src/lib.rs`.

A fourth figure was wrong before this change and the guard could not see it.
"a row whose subject is one of the 39" is from e79e752, when the residual was
39; 3b7b2c7 and a2d8e7e later moved the count to 36 and then 37 and updated only
the sentence the guard matches on, which is the "N types" phrasing. So the same
paragraph said 37 in one clause and 39 in the next. Both now say 38, in all three
files.

Claude-Session: https://claude.ai/code/session_01XvY78XEnrkzNjxZE5EtnyW
@blooop
blooop merged commit 999a180 into main Sep 9, 2026
15 checks passed
@blooop
blooop deleted the refactor/remote-command-argv-or-script branch September 9, 2026 16:35
@blooop blooop mentioned this pull request Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant