8000 8000 feat(terminal): upload local files in direct SSH panes by LevSky22 · Pull Request #198 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

feat(terminal): upload local files in direct SSH panes - #198

Merged
amirlehmam merged 13 commits into
amirlehmam:masterfrom
LevSky22:feat/remote-file-upload-v1.11
Aug 24, 2026
Merged

amirlehmam merged 13 commits into
amirlehmam:masterfrom
LevSky22:feat/remote-file-upload-v1.11

Conversation

@LevSky22
@LevSky22 LevSky22 commented Aug 24, 2026 •
Copy link
Copy Markdown
Contributor

Summary

  • upload pasted screenshots, Explorer-copied files, and dropped files when a pane is directly connected over SSH
  • preserve SSH destination, port, identities, supported connection options, working directory, and exact Windows OpenSSH installation
  • use one main-process insertion resolver with per-surface ordering and cancellation
  • place uploads in a private unique remote batch directory and insert safely quoted remote paths
  • add independent paste/drop settings, Shift-drop bypass, translated failures, documentation, and generated resources
  • harden renderer ownership, DOM File conversion, lifecycle report ordering, nested-session ambiguity, and configuration validation

The design follows cmux's user-facing behavior where Windows provides equivalent evidence. Windows has no reliable tty foreground process group, so process ancestry is deliberately corroborating evidence only and can never independently select an upload destination. Unknown or contradictory destinations fail closed.

Dependencies

This PR is opened as a draft because its branch currently contains those two foundation fixes. Once they merge, the branch can be rebased and the resulting feature-only diff marked ready.

Validation

  • npm test: 1,530 passed, 11 skipped
  • automated SSH/SCP upload tests cover argument fidelity, matching client selection, private remote-directory setup, multi-file ordering, cleanup, failures, and cancellation
  • detector tests cover managed and shell-reported sessions, stale/out-of-order reports, nested ambiguity, and the Windows background-SSH fail-safe
  • renderer/preload tests cover genuine DOM File conversion, surface ownership, queued insertion, remounts, and teardown
  • npm run typecheck
  • npm run build:main
  • npm run build:renderer
  • npm run verify:resources
  • PowerShell integration parser check
  • git diff --check
8000

Live end-to-end verification

The feature was also exercised through a running wmux/Claude Code terminal against a real SSH host using Windows OpenSSH:

  • a generated 120×50 PNG placed on the Windows file-drop clipboard and pasted with Ctrl+Shift+V increased the remote upload count from 0 to 1
  • the uploaded remote file was verified as the expected 120×50 PNG
  • plain-text paste through the same binding left the upload count unchanged at 1 and still reached the terminal
  • the remote temporary test file was cleaned up afterward

The subsequent v1.11 rebase and final private-directory, ownership, cancellation, and fail-safe hardening were covered by the automated tests above. That final hardened revision was not separately re-smoked against the host.

Repository-wide lint remains at the existing upstream baseline of 14 errors and 13 warnings; this branch adds no new lint findings.

Closes #195

LevSky22 and others added 13 commits August 24, 2026 12:07
A pasted screenshot or a dropped file used to insert a local Windows path.
Inside an ssh pane that path does not exist on the far side, so the paste was
silently useless — and an agent running there could not read it.

Detect that a pane is inside an interactive ssh session, scp the file to
/tmp/wmux-drop-<uuid>.<ext> on that host, and type the shell-escaped remote
path instead. Shift inverts on drop; `[remote]` in config.toml turns it off.

Detection has three sources feeding one per-surface cache: the `wmux ssh`
shell spec, a shell-integration preexec report of a typed ssh, and a
background Win32_Process probe for the nested cases. The first two are
latency caches over the third, which costs ~550ms per sweep on Windows where
cmux gets the same answer from ps in milliseconds.

Clipboard paste also handles a copied FILE, not just a bitmap: Explorer's
Ctrl+C puts a file reference on the clipboard with no image and no text, so
that paste previously did nothing at all while dragging the same file worked.

Ported from cmux (manaflow-ai/cmux), including its scp argv composition,
the IPv6 bracketing fix (PR #6874) and the all-or-nothing rollback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The guard skipped recording the ssh session on a reused PTY because the
renderer had by then overwritten SurfaceRef.shell with the resolved
executable, so a remount re-reported a destination-less `…\ssh.exe` and
downgraded a correctly detected pane to "local".

That write now targets resolvedShell, so every call carries the requested
spec and the special case has nothing left to defend against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The orphan reaper and the ssh probe had each grown their own copy of the
same invocation: absolute powershell.exe, -NoProfile -NonInteractive,
windowsHide, pipe-delimited Write-Output, resolve-empty on failure. The
security-relevant part — resolving the interpreter by absolute path so a
writeable PATH directory cannot shadow it — was therefore stated twice, and
`systemRoot()` four times across src/main.

Extract queryWin32Processes(). Fields are PowerShell expressions rather than
property names because both callers need computed columns: an epoch from
CreationDate, and a CommandLine with newlines flattened.

pty-ledger keeps queryProcesses / parseProcessLines / PROBE_TIMEOUT_MS as its
public surface — tests pin all three — and delegates. Also resolves the
name collision where ssh-detect declared a private PROBE_TIMEOUT_MS at 20s
beside pty-ledger's exported 60s; the sweep's is now SWEEP_TIMEOUT_MS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every input to "what should this paste type" already lived in main — the
clipboard, the ssh detector, the filesystem, scp, the user config — so the
renderer was making up to four IPC round trips per Ctrl+V transcribing main's
own data back to it, including one on every plain text paste that could never
succeed.

Main now answers the whole question. remote:resolve-paste reads its own
clipboard; remote:resolve-drop takes the paths webUtils already resolved.
Both return the finished text, so the renderer's Ctrl+V is one call and a
terminal.paste().

- remote-file-insert.ts moves to main as remote-insert.ts, and shell-quote
  leaves shared/ for main/ — both were only in those places because the
  renderer had been given the job. The injected FileInsertionDeps become
  direct calls, so the tests need no window.wmux shims.
- clipboard:paste-image and clipboard:read-files stop being IPC handlers and
  become private functions behind the resolvers. read-text / write-text stay:
  Ctrl+C and the Ctrl+Shift+V text path still use them.
- REMOTE_DETECT is deleted rather than replaced by a push over
  METADATA_UPDATE. With the decision in main the renderer never asks whether
  a pane is remote, so there is no paste-time await left for a push to
  remove; the only remaining use would be a tab-bar affordance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The upload failure notification was hardcoded English while useTerminal.ts
imports useT and uses it forty lines away for the bell.

Main hands back the host and the transport's own complaint rather than a
finished sentence, so the renderer — the only side that knows the user's
language — composes it. Placeholders follow the existing convention
(palette.newWorkspaceWithLayout): {host} and {reason} substituted after t().

English only. The non-English locales already sit ~18 keys behind en.ts, and
t()'s fallback renders those in English, so this matches the repo's state
rather than inventing seventeen translations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fixes from a reuse/simplification/efficiency/altitude pass over C1-C7.

Efficiency, and the one real regression: resolve-paste armed the ssh probe
before reading the clipboard, so a plain TEXT paste woke a ~550ms enumeration
of every process on the machine — and since start() resets the idle counter,
pasting more than once per 15s kept that sweep alive indefinitely. The probe
is now armed only when the paste actually holds files. Also, the shared
Win32_Process helper returned a split array that both callers immediately
re-joined and re-split; it hands back raw stdout now, which is what the two
line parsers already wanted.

Reuse: the "wmux uses Windows' own OpenSSH, never PATH's" rule was stated in
three places. It lives once in system32.opensshPath, which both the spawn
path and the upload path call. The unreachable `scp` arm is gone (nothing
sets a workspace shell to scp), as is the last hand-built System32 path in
src/main — in the very file this branch taught to import the helper.

Simplification: resolveInsertion took a policy object, a mode and an invert
flag to compute one boolean; it takes the boolean. The config mapping moves
to uploadEnabled() beside it, which keeps the two-toggle independence
testable rather than stranding it in an IPC handler. InsertionResult was
declared three times and agreed only structurally; UploadResult no longer
crosses the boundary and moves to remote-upload. Two stale docblocks that
outlived the handlers they described are gone.

Altitude: updateSurface and patchSurface no longer accept `shell`, so the
respawn spec cannot be overwritten again by construction rather than by
comment — which is what C3 now stakes ssh detection on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wmux has two paste paths for historical reasons: Ctrl+V is intercepted by
xterm's key handler, while the configurable `paste` action (Ctrl+Shift+V by
default) arrives as a wmux:paste-terminal event on a separate effect. The
second one read only text.

That was survivable while both were dumb. Once Ctrl+V learned to upload
images and Explorer-copied files, the two bindings quietly meant different
things: with a screenshot on the clipboard, Ctrl+V uploaded it and typed the
remote path while Ctrl+Shift+V did nothing at all — no error, no paste.

Both now go through the same resolver. applyInsertion moves to module scope
since two effects need it, which is also why the divergence existed.

Shift is not an invert modifier here and cannot be — it is part of the
binding — so both are plain "paste whatever is on the clipboard". Shift-invert
remains drag-and-drop only.

Verified against a real SSH host: a copied image file through Ctrl+Shift+V
uploads (0 -> 1, remote file is the expected 120x50 PNG); plain text through
the same binding uploads nothing and still types the text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amirlehmam
Copy link
Copy Markdown
Owner

Merged. I rebased the branch onto master myself once #196 and #197 landed (the squash-merge changed their SHAs, so the branch went CONFLICTING the moment I marked it ready) — the result was byte-identical to your head, and the PR then showed the feature-only diff you described: +3774 −162 across 36 files. Full suite green on the integration branch: 1,536 passed, 6 skipped.

This is an unusually well-argued contribution and I want to be specific about why I took it at this size.

The Windows safety boundary is the whole thing, and you got it right. The precedence rule in ssh-detect.ts — probe evidence may corroborate a managed or reported session but may never establish one — is not a nicety, it is the entire difference between this feature and a way to silently ship a user's screenshot to a background ssh host &. You documented why cmux can be laxer (macOS ps -t <tty> answers the foreground-process-group question; ConPTY has no tpgid), and then you actually held the line in refresh() instead of quietly relaxing it for the convenient case. Same for the nested-ssh block in detect() and the fail-closed remoteHint path.

Other things I checked and liked:

  • posixShellQuote is unconditional single-quoting, with the reasoning stated. Conditional quoting on a filesystem whose conventions we don't know is how this goes wrong.
  • execFile with an argv array throughout; destination never reaches a shell.
  • The all-or-nothing batch with rm -rf rollback. A partial batch handing the user some remote paths and some silence is genuinely worse than a clean failure.
  • report_command is filtered to ssh in the shell integration, not in main — so the pipe never carries the user's other command lines at all. Not forwarding it to renderers on top of that is the right belt-and-braces.
  • Taking DOM File objects in resolveDrop rather than path strings. Accepting paths would have been an arbitrary local-file upload API for a compromised renderer.

One follow-up I pushed on top (24f4554):

SshDetector.refresh() awaited a full Get-CimInstance Win32_Process sweep before reading its layers — but by your own precedence rule, with neither managed nor reported present every branch below that await already returns null. So pasting a screenshot into an ordinary local pane paid ~550ms enumerating every process on the machine, and start() then held the sweep on a 3s timer for five more passes, to answer a question a local pane cannot ask. I short-circuited it in the detector (so every caller benefits) and stopped arming the probe from resolveFor unless remoteHint already names a destination. Behaviour is unchanged; it's purely the local path getting its latency back.

That change made your never treats a probe-only descendant as the foreground ssh session test pass vacuously, so I reworked it to give a second surface an authoritative report — the sweep genuinely runs and the background ssh is still refused, which is what the test was written to prove — and added a separate test asserting the sweep isn't called at all for a pane with no report.

Also in that commit: terminal.uploadFailed into the other 17 bundled locales (it shipped English-only, so every non-English user got the fallback string), plus four SonarJS findings cleared on the way past — the sequenced-report regex is now a linear hand-written split rather than ^(seq=\d+)\s+(.*)$ run over whole command lines, the void promise discards became explicit catches, and the ancestry walk moved out of attributeSshProcesses into owningSurface.

Two notes for anyone reading later, neither blocking:

  1. The BatchMode=yes trade means key/agent auth only. That is the right default — there is no TTY for a passphrase prompt to go to — but it's worth remembering when someone reports "upload does nothing" on a password-auth host. The failure toast surfaces scp's own last stderr line, which should make that self-diagnosing.
  2. refresh() returning null when the exact client can't be corroborated is strict enough that a CIM failure blocks upload on a pane that is remote. Fail-closed is correct here; I just want the reasoning on the record if it ever gets reported as a bug.

Shipping in v1.12.0. Thanks — this raised the bar.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e30c8d3cbd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/main/ipc-handlers.ts
IPC_CHANNELS.REMOTE_RESOLVE_DROP,
(event, surfaceId: SurfaceId, localPaths: unknown, invert: boolean) => {
if (!ownsLiveSurface(surfaceId, event.sender)) return { text: null };
const source: PasteSource = { kind: 'files', localPaths: regularFilePaths(localPaths) };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve directory paths when resolving drops

When a user drops a directory into any terminal, regularFilePaths removes it before resolveInsertion can choose the local-path fallback, leaving an empty source and inserting nothing. This also affects remote panes when upload is disabled or Shift is held, and regresses the previous drop handler, which inserted every path returned by webUtils; pass the authenticated dropped paths through and let resolveInsertion reject non-regular files for upload.

Useful? React with 👍 / 👎.

# Accept bare ssh, quoted paths (including spaces), unquoted absolute paths,
# and PowerShell's call operator. Keep the executable token exact so words
# such as `myssh.exe` are not mistaken for the OpenSSH client.
if ($line -notmatch '^\s*(?:&\s*"ssh(?:\.exe)?"|(?:&\s*)?"[^"]*[\\/]ssh(?:\.exe)?"|(?:&\s*)?[^\s"]*[\\/]ssh(?:\.exe)?|(?:&\s*)?ssh(?:\.exe)?)(?:\s|$)') { return }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Strip PowerShell's call operator before reporting SSH

When SSH is invoked through PowerShell's call operator, such as & "C:\Program Files\OpenSSH\ssh.exe" user@host, this pattern accepts and reports the full line beginning with &, but sshSessionFrom only accepts command lines whose first parsed argument normalizes to ssh. The report is therefore discarded, and because refresh refuses probe-only evidence, paste/drop falls back to a local path rather than uploading; normalize away the call operator before sending or parsing the report.

Useful? React with 👍 / 👎.

@amirlehmam
Copy link
Copy Markdown
Owner

Released: v1.12.0. CI green end to end — 1,536 tests, resource verification, electron-builder package, portable zip and latest.yml (pointing at the NSIS setup.exe, so installed clients update cleanly).

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.

Upload pasted and dropped local files in direct SSH panes

2 participants

0