Nesarf/dsh-shell-surfaces

dsh-shell-surfaces

The machine's non-pwsh execution surfaces as one DSH tool: a WSL Linux userland, nushell's structured pipelines, the MSYS2 userland (bash/zsh/fish/brush), and a real console for interactive programs.

未分類bashdeepseek-harnessdsh

dsh-shell-surfaces

https://github.com/Nesarf/dsh-shell-surfaces -- a DSH plugin.

dsh plugin --profile <profile> add github:Nesarf/dsh-shell-surfaces          # the tip of main
dsh plugin --profile <profile> add github:Nesarf/dsh-shell-surfaces#v1.0.0  # a tag

The tag is the version that can be named. Installing from main gets whatever it holds today; installing a tag gets the commit that was decided on -- which is what "install the same thing again tomorrow" and "go back to what worked" both need. v1.0.0 is the first such point: thirty-five faces, two views of the same rows, and sizes a reader can drag.

The badge is the whole test workflow: the pure self-test, and a live check that runs one real command on every surface the runner has -- on Windows and on Linux -- then feeds those answers into the same table a person reads. A green badge means "the table and the argv shape still hold, and both platforms still answered", not just "the linter passed".

One DSH tool for the execution surfaces this machine has -- thirty-two faces at the last measurement: a WSL Linux userland and the ten shells inside it, the MSYS2 userland's six, four interpreters, both PowerShells, four window hosts, a real console, and nushell.

It exists because a rule that is only written down is not a capability. The selection rules for these surfaces have been recorded for a while, but a session could only act on them by composing a command string for pwsh and hoping the quoting survived the trip. Here they are callable.

shell_surfaces(action="surfaces")                    what exists here, and where each was looked for
shell_surfaces(action="run", surface="wsl",  command="grep -P '(?<=Linux )[0-9.]+' /proc/version")
shell_surfaces(action="run", surface="nu",   command="open data.json | where n > 3 | to json")
shell_surfaces(action="run", surface="msys", command="pacman -Q | wc -l")
shell_surfaces(action="run", surface="pwsh", command="Get-CimInstance Win32_OperatingSystem | Select-Object Caption")
shell_surfaces(action="run", surface="pty",  command="powershell -File install.ps1", send=["y\n"])

Contents

The file is long because each section answers a question a person actually asked while using this, and the answer is not one sentence. The map below is the short version: read the section you need, skip the rest.

What you want to know · Where is PowerShell still the fallback? · PowerShell is a surface here can a command outlive the call? · Background work stays here too does confinement apply here? · The same sandbox as the built-in shell tools can state survive between calls? · Persistent sessions what else is installed on this machine? · Finding this machine's shells run Python, Perl, JS, VBScript · Interpreters are faces too reach another machine · An ssh destination is a surface run it in a container · A container is a face too what does the list look like? · The list is a tree which faces work together? · Who works with whom why one tool instead of many? · Why one tool and not seven what does a call render as? · The row a call renders as a surface refuses to answer · Opening a path instead of refusing it what is deliberately not supported? · What it is not why are the arguments shaped that way? · The facts behind the argument lists how do I run the checks? · Verification what is still unverified? · Honest boundaries

PowerShell is a surface here

"Fall back to pwsh" used to mean "leave this tool and use the other one". The pwsh surface closes that gap: a PowerShell command is an ordinary call here, the same shape as every other surface, so a session that hits an awkward surface does not have to change its tool to get a PowerShell job done. Background jobs, timeout promotion, escalation approval and persistent sessions work on it as well -- they are surface features here, not privileges of one tool.

Both PowerShells are addressable, and that took two entries rather than one. pwsh is PowerShell 7 and is the better default; powershell is the 5.1 that ships with Windows. Keeping them behind a single DSH_PWSH variable meant a machine holding both could reach only one of them, while "does this script still work on 5.1" is a question two surfaces answer and one variable cannot.

The one shape this tool does not carry is a terminal session that keeps a drawn prompt or a cursor. That is where the built-in pwsh tool still earns its place; "the command is PowerShell" is not a reason to go there.

A surface-level failure is also not a reason to change tools. When a surface is absent, cannot spawn, or times out, the tool says so and names the next call (action="surfaces") that stays inside this plugin; the failure is about the surface, not about the tool.

Background work stays here too

run_in_background: true starts the command as a job and returns its id: the output ring streams it, job_output reads it, job_kill stops it. No timeout applies. promote_on_timeout: true turns a timeout into that same job instead of killing the process -- the output collected before the budget expired is handed over exactly once, and the rest keeps streaming.

That is why the built-in pwsh tool now covers only approval/sandbox escalation and the persistent session: the job machinery no longer sends a session back to it. A composition without the jobs service gets a refusal sentence for a background request rather than a job nobody can collect, and the pty surface has no background mode at all, because a console transcript is read while the program is alive.

The same sandbox as the built-in shell tools

A confined deployment confines this tool too. Each call resolves the session's policy through ctx.sandboxPolicy, wraps the exact argv it is about to spawn through ctx.sandbox.confine, and reports the mode, enforcement and denial facts in the same vocabulary the built-in shell tools use. danger-full-access never consults the provider, and a composition without the sandbox services runs exactly as before.

A file effect the mode denies is a policy answer, not a command failure. sandbox_permissions plus justification asks ctx.approval for exactly one wider call -- the grant is consumed by that call and never persisted -- and an escalation that is refused, cancelled or has no approval channel stops before anything runs. The same-turn denial hint rides the tool result, so the sanctioned retry does not depend on recalling the description.

Persistent sessions

Pass session: to run a command inside one long-lived shell per (agent, surface, name): the current directory, variables and modules persist across calls, and session_reset: true starts a fresh one. The session is a process with a pipe, not a terminal -- a random nonce line marks where one command's output ends, which is the only signal every supported shell prints without a prompt.

Ten surfaces carry a session, in three shapes:

  • pwsh, msys, git-bash, zsh and tcsh keep exe + a flag list

(-Command - / -l -s / -s), which is the default shape.

  • dash, brush and busybox have no login flag to give (dash is the strict

POSIX sh by design, brush has no mount table, and BusyBox is ash), so their session is -s under the PATH the table sets -- busybox sh -s for the last.

  • wsl needs the distribution on its command line (`wsl.exe -d Nyarch --

bash -l -s), so its entry builds the argv itself through argvFor. Measured 2026-10-24: cd /tmp && export DSS_PROBE=41 then echo "$DSS_PROBE:$(pwd)" answered 41:/tmp` -- the directory and the variable both survived.

fish needed a different shape rather than a refusal. It treats a piped stdin as a script file and waits for EOF before running any of it -- measured 2026-10-24, with -l and with -l -i alike, a session that writes one command at a time got silence. The opening is to keep stdin away from the script parser: the session argv runs fish's own line reader, while read -l dss_line; eval $dss_line; end, so each line is evaluated on its own. Measured: cd /tmp and set -x DSS_X 41 survived into the next call as X=41;P=/tmp.

A session is a real process, and three things close one: the sweep before each session call (reapIdleSessions, thirty minutes of idle by default, DSH_SESSION_IDLE_MS to change it), a timer that sweeps the same budget on its own clock so a shell nobody returns to is still noticed, and the disposal of the conversation that owned it -- session/disposed closes the shells of every agent that named that session. Plugin unload closes whatever is left. A call that closed something says so in its message, because the next call to that shell starting fresh is otherwise a surprise.

A derived wsl- face deliberately has no session: the session's argv is bash -l -s, so a session there would quietly run bash while the row claims elvish or ysh. A session runs one command at a time, so the job flags are refused with it, and pty has no session at all: a console transcript is read while the program is alive.

Finding this machine's shells

The table is a set of defaults, and the scan is what makes them true on a machine that moved its installs. The model is borrowed from mega-index-map's library_detect: a built-in candidate table (shell names, the usual install roots, the WSL launcher) resolved against PATH and this machine's own locations, with every hit carrying where it was found. A scan costs one PATH listing per directory, a few stat calls, one wsl -l -q, and one command -v inside each distribution -- it is not a walk of the drive.

What the scan adds to the table:

Found · Becomes a WSL distribution other than the default · its own wsl- surface a Git-for-Windows or Cygwin bash · a git-bash surface (the MSYS2 one keeps msys) cmd.exe · a cmd surface (cmd /c) a terminal program that can be driven (wt, mintty, wezterm, tabby) · a face of its own -- through the dss-exec shim for the first two, its own pane API for WezTerm, a private profile for Tabby (see "Opening a path instead of refusing it") a terminal program with no such entry (alacritty, conhost, kitty without a GUI session) · reported in discovered with an openWith line saying how it could be reached, if at all a terminal installed inside a distribution (kitty, tmux, screen) · reported with wsl: as its source; the Windows PATH cannot see it tcsh or BusyBox in the MSYS2 userland · surfaces of their own: tcsh (-c) and busybox (sh -c) dash in the MSYS2 userland · a dash surface: the strict POSIX sh, where a bash-ism fails instead of being forgiven a shell installed inside a distribution (elvish, ksh, xonsh, mksh, tcsh, BusyBox) · its own wsl- surface, which runs *that* shell rather than bash oils-for-unix in a distribution · two faces, wsl-osh and wsl-ysh: the bash-compatible half and the strict half that rejects what bash forgives

A shell that lives inside a distribution used to be reachable only by writing bash -lc 'elvish -c ...' by hand -- a wrapper the caller had to quote correctly every time, for a shell the scan had already found. wsl-elvish (and its siblings) run the shell itself: the command is written to the distribution's stdin as exec elvish -c '', so the outer bash gets one word it passes through untouched. wsl-bash does not exist on purpose -- wsl already is bash.

The command travels on stdin, not as an argument

wsl.exe recomposes the words it was handed into one command line and hands that line to the distribution, where it is parsed once more. A $VAR in an argument was therefore expanded by a shell that had never run the loop defining it. Measured 2026-10-20, through this surface:

for n in alpha beta; do echo "n=[$n]"; done   ->  n=[] and n=[]   (wanted alpha, beta)
echo 'single=[$HOME]'                         ->  single=[/home/...]  (single quotes lost too)

while the same commands on the msys surface answered correctly. Base64 behind eval did not help -- that wrapper was re-parsed the same way. The one channel wsl.exe does not rewrite is stdin, so the command now goes there and bash reads it with -l -s. Scripts are unaffected: a file path was always safe.

The distribution probe is bounded: a cold instance that outlasts the budget reports nothing for that distribution instead of blocking the scan.

action="surfaces" answers with both halves: the surfaces this tool can run now, and the discovered report (kind, name, path, source) the scan produced. A discovered path also replaces a guessed default, so a machine whose PowerShell lives outside Program Files still answers.

refresh: true re-runs the scan on a surfaces query, so a shell installed since start-up appears without a restart. The surface argument is an open string for the same reason: the scan decides the ids, and the executor validates one against the scan it is holding rather than against a list fixed at registration.

Interpreters are faces too

python, node and perl are surfaces of their own rather than something to spell out inside a pwsh call. They pass the same test every shell here passes: they take a command and answer with stdout and an exit code. Their kind is interpreter, and the guide says what makes them different -- the code travels after -c (Python), -e (Node), or -e (Perl), and a pipeline belongs inside the code rather than around it.

Perl gets one environment entry of its own: PERL_BADLANG=0. Measured on this machine, a Perl run under the UTF-8 request warns about a locale it cannot honour before printing anything, and that warning arrives on stderr on every single call. The variable silences exactly that warning and changes nothing else.

cscript is the one interpreter whose input is a file: cscript //nologo takes a .vbs path and has no -c. That is what inlineFile exists for -- the command is written to a temporary .vbs in the spill directory, cscript runs it, and the file is removed the moment the run settles. Measured: the code never appears in argv, and the spill directory does not grow across calls.

An ssh destination is a surface

The host list comes from the user's own ~/.ssh/config, and ssh is already the "flags then command" argv shape every shell surface uses, so an ssh destination needs no new machinery: each host becomes an ssh- face whose flags end with the host and whose command is the last argument.

Two kinds of entry are dropped on purpose, because a face that fails on every call is worse than no face:

  • a host with User git -- that speaks the git protocol, not a shell, and the far

side refuses anything else. This machine's config carries github.com with User git, and it is dropped for exactly that reason.

  • a wildcard (Host *.internal) -- that is a pattern for matching, not a

destination that can be dialled.

BatchMode=yes is not a preference. Without it a host that wants a password sits at a prompt nobody can type into, and the call reads as hung; with it, that host fails at once and says so.

StrictHostKeyChecking=accept-new is the other half of that argument. A host seen for the first time is recorded; a host whose key has changed is still refused. Without it the first call to any new destination dies with "Host key verification failed", which reads as a broken face rather than as an unrecorded host -- measured 2026-10-24 by clearing the entry from known_hosts and calling the surface.

When ssh cannot get as far as the command it answers 255, and the result carries a note saying so: an unreachable host, a refused key or a changed host key is a surface-level problem, not a failed command. Measured 2026-10-24 by stopping the daemon and calling the surface (exit=255, Connection refused, note present); with the daemon up the same call answers and the note is absent.

Standing up a loopback destination, so the face can be tested at all

This machine's ssh family was empty -- its only config entry is git-only -- and a face nobody can call is a claim, not a capability. Nothing here needs a second machine. Measured on this one (2026-10-24) with the distribution's own openssh 10.3p1:

# inside the distribution, as root
ssh-keygen -A                         # this image had no host keys at all
mkdir -p /run/sshd
/usr/sbin/sshd -p 2222 -o ListenAddress=127.0.0.1 \
               -o PubkeyAuthentication=yes -o PasswordAuthentication=no

then one dedicated keypair on the Windows side, its public half in the distribution user's ~/.ssh/authorized_keys (700 / 600), and this in ~/.ssh/config:

Host dss-loopback
  HostName 127.0.0.1
  Port 2222
  User nyarch
  IdentityFile ~/.ssh/id_ed25519_loopback
  IdentitiesOnly yes

action="surfaces" then reports ssh-dss-loopback [remote], and a command through it answers in 367 ms. The daemon binds loopback only and accepts keys only.

It can outlive a distribution restart, which is worth doing: a face that disappears on reboot fails the first time anyone needs it. The image runs systemd (/etc/wsl.conf sets systemd=true) and sshd_config already includes /etc/ssh/sshd_config.d/, so systemctl enable --now sshd is enough -- the drop-in above supplies the port and the key-only policy. Measured 2026-10-24 by wsl --shutdown and waking the distribution again: 15 s cold start, then ssh-after-restart with up 0 minute, active, enabled.

A trap worth writing down: generating that key with -N '""' in PowerShell makes the two quote characters the passphrase. The symptom is not "wrong passphrase" -- the server says Server accepts key and then the client never sends the signature (we did not send a packet), because BatchMode cannot ask. ssh-keygen -y -P '' is the check: it prints the public key when there is none.

rbash is scanned and reported but given no face: a restricted shell refuses the first cd, PATH assignment or redirection, so a surface that looks like bash and then declines to do bash things would mislead. Shells that are merely not installed -- yash, posh, oksh -- are already in the candidate table; install one and the next scan reports it, and promoting it to a face is a two-line change.

A container is a face too

Every container runtime the scan finds inside a distribution gets one: run --rm sh -c '', handed over on stdin the same way a derived shell's command is. It is deliberately throwaway -- a face is one command, not a session -- so nothing persists between calls; when state matters, wsl is the surface to use. A machine with neither runtime reports neither face.

Both runtimes are present on this machine (2026-10-24) and they are not interchangeable: podman is rootless, docker is not. DSH_PODMAN_IMAGE and DSH_DOCKER_IMAGE (or one DSH_CONTAINER_IMAGE) pick the image, default docker.io/library/alpine:latest, pulled on the first call.

Getting them to run took four unrelated fixes, none of them the runtimes' fault:

  • libphonenumber pinned an older protobuf than podman wanted. Upgrading the two

together resolved it -- the repository's current libphonenumber already depends on the new protobuf.

  • the pacman keyring was a 32-byte empty pubring.kbx, and every install failed

with keyring is not writable. pacman-key --init plus --populate archlinux rebuilt it with 180 keys. Note that pkill -f gpg-agent is the wrong way to quiet the agent: the pattern matches the shell running it, which kills the command itself. The keyring went bad again after wsl --shutdown, so the rebuild is a thing to expect rather than a one-off.

  • rootless podman needs libsubid.so.6, which the installed shadow (4.18.0) did

not provide; that package was upgraded for it. With subuid/subgid already in place the container ran first try: container-face-ok and ID=alpine in 2.6 s.

  • docker could not pull: dockerd uses the distribution's own network stack and