Skip to content

feat(command-assist): one-line installer for remote shell integration - #308

Merged
benyblack merged 9 commits into
mainfrom
feat/remote-shell-integration-installer
Aug 11, 2026
Merged

feat(command-assist): one-line installer for remote shell integration#308
benyblack merged 9 commits into
mainfrom
feat/remote-shell-integration-installer

Conversation

@benyblack

Copy link
Copy Markdown
Owner

Replaces the copy-a-300-line-snippet flow for remote shell integration with a one-line
installer. Settings now offers "Copy installer" first and keeps the plain snippet as the
secondary path.

The first eight commits are pre-existing work from an earlier session. The last commit is
this round's review response - four blockers plus follow-ups, detailed below.

How it works

The one-liner inlines a gzip+base64 payload, decodes it to a temp file, and runs it with
sh / &. Three arms: bash+zsh, fish, PowerShell.

Nothing is fetched over the network. The payload is fully inlined - no curl, no URL,
no supply-chain surface. That is the whole security story, and it is also the whole
mitigation: a user cannot verify that the 7 KB blob on their clipboard corresponds to the
reviewable sources in assets/shell-integration/install/. The threat model in
docs/command-assist/RemoteShellIntegration.md now says so plainly, and points at
"Copy plain snippet" as the out for anyone who wants to read what they are pasting.

Injection is structurally impossible rather than escaped away: the only interpolated value
is the base64 blob, whose alphabet is [A-Za-z0-9+/=], so it cannot contain a quote, $,
backtick, ;, newline, or $(. No host name, path, or other user-controlled value reaches
the generated command at all. Verified independently in all three shells, including fish's
distinct single-quote rules and PowerShell's backtick escape.

Neither installer needs an exec bit or a non-noexec /tmp - sh <file> and & <file>
both read rather than execute.

Review blockers found and fixed

This branch was reviewed before merge. Four blockers, each reproduced with evidence, each
re-verified against that same reproduction after the fix.

1. Both installers claimed success when the rc append had failed. printf ... >> and
Add-Content had their status unchecked, so with a root-owned, chattr +i, or read-only
rc file the output read nova: added loader line to ~/.bashrc and exited 0. This is the
same bug an earlier commit on this branch already fixed for fish - the other two arms were
never checked. The asymmetry that gives it away: the snippet write was checked, only the rc
append was not. Now both appends are status-checked; on failure the installer says it could
not write, prints the loader line for the user to add by hand, and exits non-zero.

2. The mktemp fallback was a local code-execution hole.
mktemp 2>/dev/null || printf /tmp/nova-si.%s "$$" - mktemp gives 0600 and O_EXCL, the
fallback gives neither. /tmp/nova-si.<pid> is predictable ($$ is visible in ps), and
> follows symlinks and does not change an existing file's mode or owner. On a shared host,
another local user pre-creates the path 0666, waits for a paste on a box where mktemp is
absent (busybox) or fails (unwritable $TMPDIR, a common hardening setting), and overwrites
the contents between the redirect and sh "$__nova_t". A symlink variant needs no race at
all: point it at ~/.bashrc and the redirect destroys the rc file. The fallback is deleted;
mktemp failing now fails the install cleanly. A safe fallback would need set -C plus an
unpredictable name - rejected as more machinery than the case is worth, and recorded as such.

3. The "one line" is 7.7 KB, and the failure was misdiagnosed. N_TTY_BUF_SIZE is 4096
and applies whenever ICANON is on - docker exec -it <c> sh, busybox/Alpine ash, dash
as /bin/sh, serial consoles, pwsh without PSReadLine. Interactive bash/zsh/fish use raw
mode via readline/ZLE, which is why this passed manual testing. Truncated, the decode failed
and the installer reported this host needs base64 and gzip on a host that had both,
sending users to install coreutils on a production container.
Now each of bash/zsh and pwsh embeds its payload length and compares before decoding, and
the decode branch keeps the base64/gzip message - accurate, because truncation has been
ruled out first. Also folded in: the branch is now the pipeline's exit status rather than
[ -s ], which closes a related hole where gzip -dc writes output before failing, so a
partially inflated payload passed the size check and got executed - writing a truncated
snippet and appending a loader line that every future shell would then source.

Honest limit, and it is written into the code and the docs rather than glossed: the
length check catches mid-stream byte loss (a flaky link, a multiplexer dropping a paste
chunk). It cannot catch a canonical-mode tail cut. The opening quote sits at byte 9 and the
closing quote at 7966, so a 4096-byte cut always lands inside the payload literal and the
shell fails first with unexpected EOF while looking for matching ' - none of our code
runs. A test now feeds the first 4095 bytes to a real bash and pins exactly that.

4. The idempotency guard was a bare substring match. A .bashrc containing only
# I disabled nova-shell-integration on purpose made the installer report "already present"
and write nothing - so a user who had previously commented the line out could never install.
Now anchored to a non-comment occurrence (^[^#]*), which also correctly tolerates an
indented loader line inside an if block.

Also in the fix commit

  • The fish arm had no error handling at all, unlike its bash sibling - now symmetric
    (mktemp check, decode-status branch, failure messages).
  • fish deliberately carries NO length check. It is the one arm that fits under 4096
    (3680 bytes, 416 to spare); adding the check cost 324 bytes and left only 92, putting the
    arm on the edge of the very failure the check exists to report. A test asserts both the
    bound and the absence of the check, so the reasoning is enforced rather than just written
    down.
  • PowerShell printed the literal string $PROFILE instead of the resolved path.
  • & $__nova_t moved out of the decode try, so an error from the installer script is no
    longer reported as "the payload did not unpack".
  • An unreachable Test-Path after [IO.File]::WriteAllText (which throws) became a real
    try/catch.
  • base64 -d is the GNU spelling; older macOS only accepts -D. Now probed once, with the
    bare letter stored so fish's set never sees an option-shaped value.
  • Two wrong size figures in a doc comment (off by 15-35%) replaced with a statement that
    cannot rot, pointing at the test that holds the measurement.
  • Changelog-style comments ("this reverses the argument the class used to make", "rather
    than the 300-line paste this replaced") rewritten to state the reasoning as it stands.

Testing

RemoteShellIntegrationInstallerTests 30, RemoteInstallerIntegrationTests 20 passed /
2 skipped, RemoteShellIntegrationSnippetTests 44, PaneRemoteShellIntegrationTests 17.
Clean build, 0 errors, no warnings in any changed file.

The integration tests really do run real shells - bash, fish, pwsh via Process,
asserting on files on disk, with the last one going through a real PTY and the production
OSC 133 parser. Every test scopes HOME / -ProfilePath / -DestDir to a per-test temp
directory; none can touch a real dotfile.

Two skips, both explicit rather than silent: FishOneLiner_RunsUnderRealFish (no fish on
Windows - it runs on Linux CI, which installs it) and a read-only-rc variant that self-skips
by trying the append rather than guessing the platform.

New guard tests are mutation-checked. Note for anyone repeating that: copy /b preserves
the source file's mtime, so a restore-from-backup can leave the file older than the compiled
DLL, MSBuild skips the recompile, and a --no-build run re-executes the mutant binary while
the source on disk is correct - a way to certify a mutant as clean. Touch the file and
rebuild.

.gitattributes was verified, not assumed: git check-attr and git ls-files --eol
confirm text eol=lf and i/lf w/lf on all three installers - LF in the index and in the
Windows working tree, checked as bytes. Without that, every installer would arrive with
bad interpreter: /bin/bash^M.

Not verified

  • The fish one-liner has never been executed - no fish on this box, and it is the only
    wholly unexercised shell path locally. Linux CI runs it.
  • A real 4096-byte tty truncation. Byte lengths and quote offsets were measured precisely
    and the ICANON limit is well established, but nothing was pasted into a live ICANON shell.
  • PowerShell 3.0/4.0 ([...]::new() needs 5.0+) and ExecutionPolicy Restricted blocking
    & $__nova_t. Worth a troubleshooting line later.
  • Older macOS base64 -D - the probe is written but no macOS was available to run it.
  • The Settings button row: two pills now share a 360px column, and the arithmetic suggests
    they may overflow (~443px). Flagged deliberately rather than blind-fixed - it wants eyes,
    not a guess. SettingsWindow.axaml layout is otherwise untouched by the fix commit.

Note on CI weight

These tests carry [Trait("Category","ShellIntegration")] and so land in the App.Tests lane,
which ci.yml marks non-blocking (#81, Avalonia headless deadlock). So the only real
verification of a security-sensitive artifact runs in a job that cannot fail the build.
Pre-existing structure, not introduced here, but it changes what "the tests pass" is worth
on this particular PR.

benyblack and others added 9 commits August 11, 2026 16:52
Adds RemoteInstallerIntegrationTests, which pastes the one-line installer command
into a real Git Bash with HOME redirected to a temp dir and checks the filesystem
afterwards: snippet written byte-for-byte, loader line added/deduped, hand-placed
loader line recognized, decode-failure reporting, the resulting interactive shell's
OSC 133 lifecycle, and that nothing leaks into the calling shell.

Fixes a wording defect the run exposed in nova-install.sh: the "loader line already
there" message dropped the word "present", diverging from the design doc's specified
wording ("already present - unchanged").
Review finding: the sh-under-bash coverage for the fish installer never exercised the
fish-syntax one-liner wrapper itself, so a quoting/precedence defect there could ship
undetected. Adds FishOneLiner_RunsUnderRealFish, skipped when fish is absent (always on
Windows).
…ippet

Adds the third and final remote installer variant. Payload decode is pure
.NET (Convert.FromBase64String + GZipStream), no external tools needed -
the reason this variant motivated the whole design, since a Windows
remote's `cat` is Get-Content and can never satisfy the old cat > file
recipe. The installer runs via the call operator (child scope, never
dot-sourced) and writes with an explicit no-BOM UTF8Encoding rather than
Set-Content -Encoding utf8NoBOM, which Windows PowerShell 5.1 lacks.
…ain snippet secondary

Settings' "Remote shell integration" row now offers Copy installer (the
one-line BuildInstallerCommand paste) as the primary action and Copy plain
snippet as the secondary one, wired through a shared selected-shell helper.
BuildInstallInstructions' XML remark is rewritten to record why the old
"can't read a base64 blob before running" objection was reversed rather than
deleted. Docs' Install section now documents the one-liner flow instead of
the manual cat > file + rc-edit recipe.

No remote host was available in this environment, so the manual step of
pasting the installer into a real SSH session was not performed - it is
listed as an outstanding manual check in the task report.
…file edit

CopyRemoteShellIntegrationInstallerAsync's status message was one sentence
for all three shells, claiming the installer "adds the loader line to your
config file if it isn't already there." False for fish: its installer
writes into conf.d, which fish auto-sources, and never touches an rc file -
contradicting the installer's own printed output and this same commit's
docs, which already special-case fish.

Branch on RemoteShellIntegrationSnippets.GetLoaderLine(shell), already null
for fish for exactly this reason, instead of naming fish or switching on the
enum. Non-fish shells keep the same sentence with "rc file" in place of
"config file", matching the docs' vocabulary.
… a trailing newline

Whole-branch review of the remote installer feature found a real bug: nova-install.sh's
printf >> and nova-install.ps1's Add-Content both append the loader line directly at EOF,
so an rc file/profile whose last byte isn't a newline gets the loader glued onto the
user's last line - on a remote host, while the installer still reports success, and
re-running can never self-heal since the grep/Select-String marker check still matches.
Both installers now ensure the file ends in a newline before appending.

Also closes four cheap gaps the same review flagged: a stale comment overstating what
Installer_LeavesNothingBehindInTheCallingShell actually probes, no coverage of the zsh
and "unknown shell" dispatch arms, no real-pwsh parse check of the generated PowerShell
one-liner, and a .gitattributes rule that didn't reach nova-install.ps1 one directory
down from its siblings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four review blockers on the one-line remote shell-integration installer.

The rc append was never status-checked in the sh or the PowerShell installer.
A rc file the user cannot write - root-owned, chattr +i, a read-only $HOME, a
full disk - makes >> and Add-Content fail while the script carries on to print
"added loader line" over the top of the real error, so the user is told it
worked, gets no marks, and goes looking in the right file for a line that was
never written. Both appends in each installer are now checked; on failure they
say so, print the loader line to place by hand, and exit non-zero. The same fix
had already been made for fish, without a test - hence the tests below.

The mktemp fallback in the bash/zsh one-liner is gone. mktemp gives 0600 and
O_EXCL; /tmp/nova-si.$$ gives neither, $$ is visible in ps, and > follows
symlinks and does not change an existing file's mode. Another local user can
pre-create the path and rewrite it between the redirect and sh "$__nova_t", or
aim it at ~/.bashrc and let the redirect truncate that. A safe fallback needs
set -C plus an unpredictable name, which is not worth the machinery; mktemp
failing now fails the install with a message naming mktemp.

Truncation. A decode failure used to be reported as "this host needs base64 and
gzip" whatever caused it, including a payload that arrived short. The bash/zsh
and pwsh arms now carry their payload length and check it before decoding, and
the decode branch is chosen on the pipeline's exit status rather than on the
temp file being non-empty - gzip -dc writes what it inflated before failing, so
a corrupt payload could leave a broken installer that [ -s ] waved through and
sh ran.

What that check does and does not cover is worth being exact about, because the
obvious reading is wrong. It catches bytes lost from the MIDDLE of the line
while the tail still arrives: a flaky link, a multiplexer dropping a chunk of a
paste. It does not catch a canonical-mode tty cut and cannot. N_TTY_BUF_SIZE is
4096 and the line is 8688 bytes, but the payload literal opens at byte 9 and
closes at 7966, so a cut at 4096 always lands inside the quoted blob and takes
the closing quote with it - bash answers "unexpected EOF while looking for
matching '", pwsh "The string is missing the terminator: '.", and none of our
code runs. A test now feeds the first 4095 bytes of the real one-liner to bash
and pins that behaviour, so nobody re-reads the guard as covering it.

fish carries no length check. Its line is 3680 bytes and fits under 4096, so it
is the one arm a tty cannot truncate - and a check costs ~320 bytes of exactly
the headroom that makes that true. It keeps every other guard bash has.

The idempotency guard matched the marker anywhere in the rc file, including
inside a comment, so a rc file containing only "# I disabled
nova-shell-integration on purpose" got "already present - unchanged" and no
loader line. Both installers now anchor to a non-comment occurrence.

Also: the fish one-liner had no error handling at all and now matches its bash
sibling; base64 -d falls back to -D for pre-Ventura macOS via a one-shot probe;
pwsh's & $__nova_t moved outside the decode's try, where a terminating error
from the installer script itself was being reported as "the payload did not
unpack"; the PowerShell installer names the resolved profile path instead of
printing the literal string $PROFILE, and its unreachable post-WriteAllText
Test-Path is a try/catch; the class doc's payload sizes were wrong by 15-35% and
are now stated as a property with the measurement left to a test; comments that
narrated the diff rather than the code are rewritten; the docs page gets an
honest paragraph on pasting an opaque blob, a section on the 4096-byte limit
describing the symptom you actually get, and the new messages in troubleshooting.

Tests: failed-rc-append for sh and pwsh (output, by-hand line, exit status),
comment-only mention for both, a 4095-byte truncated paste, the payload-length
check and its agreement with the real payload, fish's sub-4096 length as the
reason it has no check, no predictable temp path, the base64 -D probe,
decode-on-status, fish parity, and a length tripwire.
FishOneLiner_RunsUnderRealFish no longer passes on a false success claim.
The bash integration harness runs the one-liner from a file rather than through
bash -c: Git Bash's MSYS runtime caps a reconstructed argv just under 8192
characters, and the length check pushed the line from 7721 to 8688, past it.
@sonarqubecloud

Copy link
Copy Markdown

@benyblack
benyblack merged commit 7c110c3 into main Aug 11, 2026
27 checks passed
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