* fix(ai): make SDK deps optional, degrade gracefully when missing
- Move @anthropic-ai/claude-agent-sdk and @github/copilot-sdk
from dependencies to optionalDependencies so npm install does
not fail when they are unavailable
- claudeDriver.listClaudeModels: catch import error, return [] silently
- copilotDriver.listCopilotModels: catch import error, return [] silently
- sdkStreamHandlers: downgrade log from console.error to console.debug
The renderer already falls back to curated model presets when
list-models returns [], so no functional change.
* fixup: honor queryFn before SDK import; regenerate lockfile with optional markers
* fixup: guard runTurn against missing SDK modules
runClaudeTurn and runCopilotTurn now catch dynamic import errors
and emit a user-friendly error message instead of crashing with
a raw module-not-found error.
* feat(sftp): add Open with system default for native Windows file association (fixes#1236)
Adds a new 'Open with system default' option to the SFTP context menu
that uses Electron's shell.openPath() to invoke the native OS file opening
mechanism. On Windows, this uses ShellExecute, which works with UWP/WinUI
apps (Photos, Paint, etc.) whose executable paths change with updates.
The existing 'Open with...' (browse for executable) is preserved.
Closes#1236
* fix(sftp): add file watch support to openWithSystemDefault for SFTP auto-sync
P2: The new default-app open flow was not starting a file watch
when SFTP auto-sync was enabled, so edits saved in the system
default app were not uploaded back to the remote host.
Mirrors the downloadToTempAndOpen behavior — accepts an optional
{ enableWatch } parameter and starts a file watch when set.
* fix(sftp): mark transfer as failed when system default open fails
P2: When shell.openPath fails for a remote file, the transfer queue
still shows success because downloadToTemp had already completed the
temp download. Now updates externalTransferId to 'failed' before
throwing, matching downloadToTempAndOpen's behavior.
Add full EternalTerminal (ET) protocol support alongside the existing SSH, Mosh, Telnet, and Serial protocols. ET automatically reconnects when the network drops or the user's IP changes, making it ideal for mobile and unreliable networks.
* fix: route Cmd+W through existing tab close flow
Keep the original tab-close behavior intact, and close the main or settings window only when there is no active closable tab to handle.
* fix: fall back to closing non-listener windows on Cmd+W
Treat BrowserWindow instances that do not participate in Netcatty's command-close bridge as regular closable windows. Keep the existing command-close path for the main and settings windows, and add tests that cover both the fallback close behavior and the renderer-capable send path.
TerminalPane forwards sshDebugLogEnabled to its child at render (L717) but
never destructured it from props, so rendering threw
"ReferenceError: sshDebugLogEnabled is not defined". The prop already exists
in TerminalPaneProps and the memo comparator; only the destructuring was
missing. tsc flags it (TS2304) but the build does not gate on tsc. Introduced
in 85f486e6.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): commit app to quit before quitAndInstall on macOS (#1215)
macOS in-place auto-update downloaded and unpacked the new version but
never installed it: the app appeared to close, ShipIt never ran, and no
restart happened. A full uninstall + reinstall did not help; only a
manual DMG replace worked.
Root cause is a code-level coordination bug, not the release pipeline.
The published mac zips are correctly Developer-ID signed, notarized, and
stapled (Team H7WS5L2ML4, consistent across 1.1.17 and 1.1.20), and
latest-mac.yml is well-formed — so Squirrel.Mac signature validation
passes. The failure is that quitAndInstall() drives app.quit() while two
normal-quit behaviors keep the process alive:
1. the main-window close handler hides to tray when close-to-tray is
enabled (it only closes when isQuitting is true), and
2. the before-quit dirty-editor guard preventDefault()s the quit for a
5s renderer round-trip.
Either keeps the parent process running, so Squirrel.Mac's ShipIt helper
— which waits on the parent PID to die before swapping the bundle —
lands in launchd "pending spawn / on-demand-only" limbo and the service
is removed without installing. This matches the reporter's diagnosis
exactly ("ShipIt 没有真正启动安装器", launchd on-demand-only).
Fix: before quitAndInstall fires app.quit(), mark the app as quitting
for an update via windowManager.setQuittingForUpdate(true). That sets
isQuitting (bypassing close-to-tray) and the before-quit handler now
returns early when isQuittingForUpdate() is true (skipping the
dirty-editor round-trip), so the process exits cleanly and ShipIt can
run. The same fix also covers the latent Windows NSIS case where
close-to-tray would block an in-place update.
If the install never actually quits the app (quitAndInstall throws, or
returns without app.quit() on a Squirrel follow-up error / stale
download), the quitting-for-update flags are rolled back — synchronously
on throw, and via a short unref'd watchdog otherwise — so the app does
not get stuck permanently bypassing close-to-tray and the quit guard.
Tests: unit tests for the new windowManager flags and for the install
handler — ordering (setQuittingForUpdate before quitAndInstall), tray
cleanup still runs, no-op when the updater fails to load, rollback on
synchronous throw, and watchdog rollback when the app never quits.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): keep dirty-editor guard during update install
setQuittingForUpdate only bypasses close-to-tray (so the window actually
closes and Squirrel.Mac's ShipIt can swap the bundle); it must NOT skip the
unsaved-work guard, or clicking "Restart Now" with a dirty SFTP editor would
silently lose edits. If the user cancels to save, the quit aborts and
autoUpdateBridge's watchdog clears the quitting-for-update flags.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): clear update-quit state when the quit is cancelled
When the user clicks "Restart Now" with a dirty editor open, the before-quit
guard cancels the quit (settle "stay"). The update path had already called
setQuittingForUpdate(true) (which flips isQuitting=true to bypass close-to-tray
for the install), so without clearing it the app stays in a quitting state —
close-to-tray and other !isQuitting-gated behavior bypassed — until the 10s
watchdog fires. Clear it immediately on the cancelled-quit path (#1215 review).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(auto-update): check for unsaved editors before quitAndInstall (#1215)
The previous fix bypassed close-to-tray so the app process actually exits and
Squirrel.Mac's ShipIt can swap the bundle, while keeping the before-quit
dirty-editor guard as the unsaved-work safety net. But on macOS that net has a
hole: quitAndInstall() closes the window FIRST and only then fires before-quit.
Once setQuittingForUpdate(true) lets the main window truly close (instead of
hiding to tray), the before-quit guard can run after the window is already gone
— isReachableByUser is false, so it commits the quit and silently drops unsaved
SFTP edits.
Fix: move the dirty-editor check to the moment the user clicks "Restart Now",
in the install handler, BEFORE setQuittingForUpdate / quitAndInstall — while the
window and renderer are still alive:
- dirty -> abort the install (don't set the quitting flags, don't
quitAndInstall) and broadcast netcatty:update:needs-save so the
renderer prompts the user to save and retry.
- clean -> proceed with the existing flow (commit-to-quit, tray cleanup,
quitAndInstall, watchdog).
- no reachable main window / crashed renderer -> install directly (no user to
ask), matching the before-quit fail-open path.
The before-quit dirty guard is kept as defense-in-depth: if the window is still
reachable it re-checks (clean, since we just verified), and if it's already gone
it lets the quit through — which is now safe because the install handler already
confirmed there were no unsaved editors.
The request/reply/timeout round-trip is extracted into a shared helper,
electron/bridges/dirtyEditorGuard.cjs (queryDirtyEditors), so the install
handler and main.cjs's before-quit guard use one implementation. main.cjs's
before-quit is refactored onto it (behavior preserved: sender-filtered reply,
fail-open timeout, and the setQuittingForUpdate(false) rollback when the user
cancels to save).
The needs-save notice is BROADCAST to every window, not just the queried main
window: "Restart to Update" can be clicked from the Settings window, which would
otherwise see the click do nothing. preload exposes onUpdateNeedsSave; the
subscription lives in useUpdateCheck (state layer), and both consumers — App.tsx
(main window) and SettingsPage (settings window) — pass an onNeedsSave callback
that shows an actionable toast ("save your editors, then click Restart Now
again") in en / zh-CN / ru.
Also lengthen the quitting-for-update rollback watchdog from 10s to 60s. On
macOS quitAndInstall() can return while Squirrel is still pulling the downloaded
ZIP from the local update server before it closes the windows; on a large/slow
update that can exceed 10s. Clearing isQuitting that early would let the eventual
native quit hit a non-quitting close-to-tray handler and strand the install
again — the exact #1215 failure. The longer window only fires when the app is
realistically stuck, at the cost of close-to-tray staying bypassed a little
longer in the rare genuine-failure case.
Tests: queryDirtyEditors (result / no-dirty / timeout / wrong-sender / dead or
crashed webContents / send-throws / no-ipcMain paths); install handler pre-check
(dirty -> no quitAndInstall + needs-save broadcast to all windows; clean ->
quitAndInstall runs; no main window -> installs without asking). Existing
install-handler tests updated for the now-async handler.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The terminal output path decodes remote bytes with an iconv decoder built
from the user's configured charset (GB18030, etc.), but the input path
serialized keystrokes as UTF-8 unconditionally. On a non-UTF-8 device that
made input and output asymmetric: with GB18030 selected the device's command
completion decoded correctly while manually typed Chinese went out as UTF-8
bytes and showed up garbled (and the reverse with UTF-8 selected).
Make input symmetric with output:
- Add electron/bridges/terminalEncoding.cjs as the single source of truth for
charset normalization plus encodeTerminalInput(), which encodes a keystroke
string with the same iconv charset (returning the string untouched for UTF-8
and for unset/unknown encodings so the transport's native serialization and
the Mosh/local-PTY paths are unchanged). ASCII control bytes and CSI escape
sequences pass through byte-for-byte under GB18030.
- terminalBridge.writeToSession() now encodes outgoing data via
encodeTerminalInput(session.encoding) before writing to the SSH stream,
telnet socket, or serial port. Telnet IAC 0xFF escaping still runs on the
encoded bytes.
- session.encoding (the input charset) is now kept in lock-step with the
output decoder everywhere the decoder is configured:
* Telnet/serial already stored session.encoding; writeToSession now reads
it for input too.
* SSH mirrors session.encoding wherever it sets sessionEncodings: the
GB-variant pre-seed at session start and the runtime setEncoding handler.
The pre-seed stays gated to GB variants to match the renderer's two-value
encoding state, so behavior for other/arbitrary charsets is unchanged —
the renderer still pushes the effective encoding via setEncoding on
attach, and that handler keeps both halves in sync.
- The SSH startup command is encoded with the same charset as interactive
input.
Mosh stays UTF-8 (mosh-client is UTF-8-only and sets LANG accordingly), and
local PTY stays UTF-8 — neither sets session.encoding, so their input is
untouched.
Adds unit tests for the encoding helper (GB18030/UTF-8 round-trips, ASCII
control preservation, symmetry with the output decoder) and integration tests
driving writeToSession over a raw TCP device to assert GB18030 bytes on the
wire and a UTF-8 regression guard.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
GoogleDriveAdapter refreshed its access token only in memory: the rotated
tokens were never written back, so the next launch loaded a stale access
token and the user was forced to reconnect. This is the same defect #1208
fixed for OneDrive, which GoogleDriveAdapter never received because it did
not expose setOnTokensRefreshed — making the shared
attachTokenRefreshPersistence a no-op for Google.
Add setOnTokensRefreshed to GoogleDriveAdapter and route both refresh
points (setTokens / ensureValidToken) through a refreshTokens helper that
stores the rotated tokens in memory and notifies the persistence callback,
so attachTokenRefreshPersistence now encrypts and persists them.
Google's refresh response usually omits refresh_token (it does not rotate
on every refresh), so refreshTokens carries the previous refresh token
forward when the response lacks one — otherwise the persisted connection
would become unrefreshable.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Auto-refresh OneDrive sync token and prompt reconnect on dead refresh token
OneDrive cloud sync (#1189) broke for users on 1.1.20 and only recovered
after disconnecting and re-authorizing. Two gaps caused this:
1. Refreshed tokens were never persisted. When OneDriveAdapter silently
refreshed the access token mid-session, the rotated tokens lived only in
the adapter's in-memory state — CloudSyncManager never read them back or
saved them. Microsoft consumer refresh tokens rotate on every refresh and
invalidate the previous one, so the next app launch loaded a stale,
rotated-out refresh token. Eventually that stored token was dead and sync
failed, forcing a manual reconnect.
Fix: OneDriveAdapter now exposes setOnTokensRefreshed(); the manager wires
it so every silent refresh writes the rotated tokens back into provider
state and encrypted storage (attachTokenRefreshPersistence /
persistRefreshedProviderTokens), keeping the stored refresh token current.
2. A genuinely dead refresh token surfaced as a raw, generic error with no
guidance. The bridge now detects invalid_grant / interaction_required /
consent_required / login_required on refresh and tags the error with a
stable marker. OneDriveAdapter normalizes these to
OneDriveReauthRequiredError; the marker survives IPC and error re-wrapping
so the condition stays detectable, and the UI strips it to show a clean
"OneDrive session expired, please reconnect." message.
Shared marker + detection/clean helpers live in domain/sync.ts so the bridge,
adapter, and UI use one source of truth. Scope is limited to OneDrive; other
providers (Gist/iCloud/Google/WebDAV/S3) are untouched.
Tests: OneDriveAdapter refresh-persistence + reauth detection, manager
token-persistence wiring, and bridge invalid_grant tagging.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Drop OneDrive to a reconnect state when its refresh token is dead
codex review: detecting a dead refresh token was not enough. A provider in
`error` state that still holds tokens stays "ready for sync"
(isProviderReadyForSync), and syncAllProviders resets such providers back to
`connected` and retries — so auto-sync kept hammering the dead refresh token
and the user never got a stable reconnect prompt.
Now, when a sync/download error indicates OneDrive reauth is required, the
manager clears the stale tokens and tears down the cached adapter
(handleProviderReauthRequired), leaving the provider in an error state with no
credentials. With no tokens, isProviderReadyForSync returns false so auto-sync
stops retrying, and the card shows a clean "please reconnect" message with a
Connect button. The account is preserved for display; the error message is
stripped of the internal marker.
Wired into the syncToProvider / uploadToProvider / downloadFromProvider error
paths. Added tests for the clear-on-reauth behavior and provider/error scoping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Clear OneDrive reauth during inspection paths; include service tests in npm test
codex review round 2:
P2 — A dead OneDrive refresh token can also surface during startup remote
inspection and the syncAllProviders preflight conflict check, both of which run
through inspectProviderRemoteState. That path swallowed the error into an
{error} tuple without clearing the stale tokens, so the provider stayed
retryable. Wired the reauth handler into inspectProviderRemoteState's catch,
covering sync preflight, syncAll preflight, and startup inspection in one place.
Made the handler idempotent so the operation's own catch can also call it
without re-saving.
P3 — New tests live under infrastructure/services/, which npm test's globs did
not cover (the pre-existing syncAllStorageMethods.test.ts was also uncovered).
Added infrastructure/services/*.test.ts and infrastructure/services/*/*.test.ts
to the test script.
Full suite: 1400 tests, 0 failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* test: drop unmatched services test glob from npm test
The npm test script listed both infrastructure/services/*.test.ts and the
nested infrastructure/services/*/*.test.ts. No .test.ts files live directly
under infrastructure/services/ (the OneDrive adapter and cloudSync tests are
in adapters/ and cloudSync/ subdirs), so the flat glob never matched.
Under a default POSIX shell (sh/bash without nullglob), an unmatched glob is
passed through literally, so node --test received the raw pattern. On Node
versions without test-runner glob support this aborts with
"Could not find '.../infrastructure/services/*.test.ts'" before any test runs,
breaking npm test.
Remove the redundant flat glob; the nested glob already covers the new
OneDrive adapter and cloudSync tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* Show host info for Mosh sessions via a stats companion SSH connection
Mosh sessions run over UDP through a local mosh-client PTY and carry no
ssh2 connection (session.conn), so getServerStats could not open an exec
channel and the terminal's host-info bar (CPU/memory/disk/network) stayed
empty — unlike SSH sessions (issue #1198).
Add a best-effort, non-interactive companion SSH connection that is opened
lazily on the first stats poll for a Mosh session, reusing the credentials
the Mosh handshake already validated, and assign it to session.conn so the
existing stats path works unchanged:
- electron/bridges/sshBridge/moshStatsConnection.cjs: new helper that
builds the companion connection. It never prompts (only stored password,
parseable private key, unencrypted/stored-passphrase identity files, or
ssh-agent), shares one in-flight attempt across concurrent polls, treats
auth rejection as permanent but transient errors as retryable, and skips
host-key verification like the existing one-off execCommand path.
- sessionOps.getServerStats: establish the companion connection for Mosh
sessions that lack session.conn before running the stats command;
degrades gracefully to the existing "not connected" error otherwise.
- moshSession.swapToMoshClient: stash the handshake credentials and
algorithm settings on session.moshStatsAuth once the handshake succeeds.
- terminalBridge closeSession / cleanupAllSessions and the mosh-client exit
handler: tear down the companion connection (it has no session.stream).
- Forward legacyAlgorithms / skipEcdsaHostKey / algorithmOverrides through
the renderer's Mosh starter and the bridge type so the companion
negotiates the same algorithms the interactive session would.
Tests cover the helper (auth selection, agent fallback, dedup, permanent vs
transient failure, late-ready discard), the getServerStats integration, and
the moshStatsAuth stash + companion teardown on close.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: target SSH host and settle on mid-handshake close
- moshSession: store options.hostname (the SSH endpoint) on moshStatsAuth
instead of parsed.host. A `MOSH IP` line advertises the UDP endpoint for
mosh-client, which can differ from the SSH host on NAT / multi-homed
setups; the companion is an SSH connection and must target the SSH host.
- moshStatsConnection: resolve the pending attempt from the "close" handler
when the socket drops mid-handshake without a prior "ready"/"error", so an
awaiting getServerStats call (and session.moshStatsConnPromise) cannot
hang indefinitely. Treated as transient so the next poll may retry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: keyboard-interactive password for stats companion
PAM-backed SSH servers often offer password auth only via
keyboard-interactive, not the plain "password" method. The Mosh handshake's
system ssh handles that through its PTY responder, so without it the
companion stats connection would fail auth on those hosts even with a saved
password, leaving the stats bar empty.
When a saved password is present, enable tryKeyboard and attach a
non-interactive keyboard-interactive handler that auto-fills the password
for a single password prompt (using the existing
isAutoFillablePasswordChallenge predicate) and finishes empty on
2FA/OTP/multi-prompt challenges. It auto-fills at most once so a wrong
password can't drive a retry loop, and it never shows a modal — the
companion stays fully non-interactive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: verify host key before sending a saved password
A background companion connection that auto-submits a saved password to an
unverified host could disclose it to a spoofed / MITM server (P1). Gate
password auth (plain and keyboard-interactive) behind a silent, trusted-only
host-key check against Netcatty's known-hosts store:
- moshStatsConnection: when the companion would authenticate with a
password, attach an ssh2 hostVerifier that accepts only a key already
"trusted" in known-hosts (via hostKeyVerifier.classifyHostKey) and rejects
unknown/changed keys outright — no prompt, so the password is never sent to
an unvetted host and no host-key dialog pops for a background poll.
Public-key / agent auth proves possession via a signature and discloses no
reusable secret, so it is not gated (matches the existing execCommand
precedent; the handshake already vetted the host via system ssh).
- Thread knownHosts through the renderer Mosh starter, the bridge type, and
session.moshStatsAuth.
Note: a password-auth Mosh host that Netcatty has never seen via its own SSH
path (so it is absent from Netcatty's known-hosts) will not get the stats
companion until its key is known — the safe default. Key/agent-auth hosts are
unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: transient pre-handshake polls and agent+password auth
Two functional gaps in the stats companion:
- Missing moshStatsAuth is now transient, not permanent. The renderer can
mark a Mosh session "connected" (and start polling) from the SSH
bootstrap's visible PTY output before the swap to mosh-client assigns
moshStatsAuth. Previously that first poll set moshStatsConnFailed
permanently, so the companion was never attempted after the handshake
actually completed. Now it just returns null and a later poll retries.
- A saved password no longer suppresses ssh-agent auth. A public-key host
that authenticates via the agent may still carry a stored password; the
companion now offers the agent alongside the password (ssh2 tries agent
first) instead of attempting password-only and failing permanently. An
explicit private key still suppresses the agent fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: gate password at authHandler, not whole connection
The previous fix installed a trusted-only hostVerifier whenever a password
was present. Because ssh2 verifies the host before any auth method, that
rejected the entire connection on a host absent from Netcatty's known-hosts
— blocking key/agent auth too, even though those never need to send the
password.
Move the gate from the transport to the auth layer:
- A trust-tracking hostVerifier records whether the live host key is trusted
(during the transport handshake) and then accepts the transport so
public-key / agent auth can proceed on any host.
- A function-form authHandler offers none -> agent -> publickey always, and
appends password + keyboard-interactive only when the host key is trusted.
Result: key/agent auth works on hosts Netcatty hasn't vetted, while a saved
password is still never sent to an untrusted host. Public-key / agent auth
remains ungated (no reusable secret is disclosed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: isolate Mosh stats connection from session.conn
Storing the stats companion on session.conn made it look like the session's
primary interactive SSH connection. Other bridges key off session.conn —
getSessionPwd assumes its exec channel is a sibling of the interactive shell,
and SFTP / MCP exec run over session.conn — but a Mosh session's shell lives
on the UDP mosh-client, not this background connection. After a stats poll
they could return a bogus cwd or operate over the wrong connection.
Keep the companion strictly on session.moshStatsConn:
- ensureMoshStatsConnection stores/reuses/clears only session.moshStatsConn.
- getServerStats reads session.conn || session.moshStatsConn (real SSH still
uses conn; Mosh uses the companion) and only opens one when neither exists.
- closeSession / cleanupAllSessions / the mosh-client exit handler tear down
session.moshStatsConn.
This leaves session.conn untouched for Mosh, so getSessionPwd / SFTP / MCP
exec behave exactly as before (no primary SSH connection for Mosh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: don't count pre-handshake Mosh polls as failures
useServerStats gives up after 3 consecutive failures. A Mosh session can be
marked "connected" (and start polling) from the SSH bootstrap's visible
output before swapToMoshClient stores moshStatsAuth, during which
ensureMoshStatsConnection returns null. Previously getServerStats reported
that as a normal failure, so a handshake taking ~15s (3 polls) would
permanently disable stats for the session even after credentials became
available.
Introduce a `pending` result:
- getServerStats returns { success: false, pending: true } for a Mosh session
that has no connection yet and no moshStatsAuth and hasn't permanently
failed. Once moshStatsAuth is set (or the companion permanently fails), it
reports a normal failure again.
- useServerStats treats `pending` as neutral: it does not update stats and
does not increment the consecutive-failure counter, so polling continues
until the handshake completes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* Address codex review: verify host key for all Mosh stats companion auth
The companion installed a trust-tracking host verifier only when a saved
password was present; key/agent-only connections fell back to ssh2's
default of accepting any host key. A background, user-invisible connection
that authenticated against an unverified host could let a MITM/DNS-spoofed
host feed bogus host-info to the user and enumerate the ssh-agent's public
keys — breaking the host-key guarantee the interactive session enforces.
Attach the host verifier for every auth method and reject an unknown or
changed host key outright (never prompting; stats just stay empty). Treat
an untrusted host as a permanent failure so polling stops reconnecting.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(mosh): trust system known_hosts for the stats companion host-key check
The Mosh stats companion opens a background ssh2 connection and only rides
on a host whose live key is already trusted, rejecting unknown/changed keys
as a permanent failure. Trust was sourced solely from Netcatty's in-app
known-hosts snapshot (options.knownHosts).
But a Mosh session is bootstrapped by the system `ssh`, which vets and
records the host key in the user's OpenSSH known_hosts (~/.ssh/known_hosts,
etc). Netcatty's snapshot is never updated by that handshake, so a host
trusted purely via system ssh was misread as "unknown" and the companion
permanently disabled — Mosh stats never appeared unless the user manually
scanned/imported the host into Netcatty (codex P2).
Add a system-known_hosts trust source (systemKnownHosts.cjs) and consult it
in the companion verifier when the in-app snapshot does not already vouch
for the key. Matching is by the LIVE key's SHA-256 fingerprint, so trust is
granted only for the exact key the user's own OpenSSH already trusts; an
arbitrary or mismatched key is never accepted. Unknown/changed keys stay
rejected and remain a permanent failure.
The parser handles the OpenSSH known_hosts(5) format that the in-app scan
parser does not fully cover for matching: plain hosts, comma lists,
[host]:port, hashed |1|salt|HMAC-SHA1(salt,token) entries (with the
bracketed token for non-default ports, verified against ssh-keygen -H),
multiple key types, @revoked (forces NOT trusted) and @cert-authority
(skipped). Wildcard/negation patterns are deliberately not honored. Paths
mirror localFsBridge.readKnownHosts and are cross-platform (incl. Windows
%PROGRAMDATA%\ssh\known_hosts). All errors fail closed.
ssh2 ships no known_hosts parser, so this is implemented in CommonJS using
node:crypto and covered by unit tests (real ssh-keygen hashed fixtures,
revoked/cert-authority, fingerprint mismatch, fail-closed) plus companion
integration tests (system-only trust accepts; neither source trusts ->
rejected + permanent; key rotation not rescued; optional-dependency safety).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
From the local codex review of the ET jump-host changes:
- A jump host authenticated by a saved reference key had its on-disk key path
dropped: privateKey is undefined for reference keys and only
jumpHost.identityFilePaths was forwarded, so ET jump auth fell back to
defaults despite a valid key being selected. Mirror startSSH and forward the
reference key's filePath as an IdentityFile (with the same password-method
and keyId fallback guards).
- A jump host listening on a non-default ET server port was always contacted
on 2022: the bridge reads jump.etPort (--jport) but the renderer never sent
it. Forward jumpHost.etPort and add etPort to NetcattyJumpHost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review: for an ET host the connection dialog displayed host.port
(the SSH port, e.g. 22), but ET connectivity hinges on the etserver port
(host.etPort, default 2022). Showing 22 is misleading when a connection is
actually stuck on the ET port. Display etPort (falling back to 2022) for ET
hosts instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review: startEt only checked resolvedChainHosts.length, so a
configured jump chain that failed to resolve (missing/invalid host ID) would
silently fall back to a direct connection — possibly to the wrong target —
unlike startSSH, which explicitly rejects missing chain host IDs.
Add the same getMissingChainHostIds check startSSH uses, erroring early with
a clear message when a configured jump host cannot be resolved. Also base the
"at most one jump host" limit on the configured chain (host.hostChain.hostIds)
rather than only the resolved list, so a second hop whose ID fails to resolve
cannot slip past the check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses two correctness issues from the codex review of the ET protocol
support.
codex P1 #1 (Unix askpass helper): on macOS/Linux the SSH_ASKPASS helper
was the askpass .cjs itself, relying on its `#!/usr/bin/env node` shebang.
Packaged Electron builds put no `node` on the user's PATH, so ssh could not
run the helper and ET could not supply the saved password / key passphrase,
failing the connection outright. Mirror the existing Windows .cmd wrapper:
write a small /bin/sh wrapper that execs the helper through process.execPath
with ELECTRON_RUN_AS_NODE=1 (process.execPath is POSIX single-quoted to
survive spaces in an .app path).
codex P1 #2 (jump host routing): a jumped ET host only got an ssh
ProxyCommand, which fixes the SSH bootstrap but leaves ET opening its TCP
socket straight at the destination etserver — which fails whenever the
destination ET port is not directly reachable. Use ET's own
--jumphost/--jport so ET connects its socket to the jumphost's etserver and
reaches the destination over the SSH tunnel ET sets up (`ssh -J`). Per-hop
jump credentials move from the ProxyCommand into a `Host <jumphost>` ssh_config
block (OpenSSH applies command-line -o only to the final hop, so jump settings
must come from config); the destination's comma/space options are scoped under
a `Host <dest>` block with a ProxyJump so the standalone ssh used for distro
detection tunnels through the jump too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirror the existing mosh bundling pipeline so that release and tag
builds automatically fetch and package the EternalTerminal `et`
client alongside mosh-client.
Two more review findings on the connection-reuse path:
- Verify the source connection's endpoint matches the duplicate's
requested hostname/port/username before reusing it. A saved host edited
after the source tab connected would otherwise let the copy silently
open on the old connection and run commands on the wrong machine.
findReusableSession now takes the requested target and requires an exact
endpoint match; the session records its actual SSH endpoint at connect.
- Wrap conn.shell() in the reuse path with try/catch. ssh2 can throw
synchronously (e.g. "Not connected") if the borrowed transport dropped
between findReusableSession and the shell request; without this the
up-front connection ref hold would leak. On a synchronous throw we now
drop the error listener, release the ref, and fall back to a fresh
connection.
Adds tests for endpoint mismatch and synchronous shell failure.
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two reference-counting races found in review:
- Reuse path read the source's connRef only inside the async conn.shell()
callback. If the source tab closed while the shell was opening,
releaseConnectionRef could drop the count to zero and end the shared
connection out from under the opening channel. Now pin the connection
(acquireConnectionRef) before issuing the async shell request and hand
the hold over to the real session once the channel opens; release it on
any failure so fallback to a fresh connection doesn't leak the count.
- closeSession read session.connRef *after* stream.close(), but closing the
channel can synchronously fire the stream "close" handler that nulls
connRef and releases the connection — so the post-close check fell into
the legacy path and ended the shared connection a second time. Snapshot
the multiplexing flag before closing the channel.
Adds a regression test covering "source closed while the copied shell is
still opening".
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading _logStreamToken back off the session map in the connection-level
close/error/timeout handlers could let a late close from an old transport
stop a newer same-sessionId log stream after a reconnect, regressing the
token guard from #916. Capture the owner channel's log stream token in the
connection closure and pass it to stopStream, matching the original
closure-scoped behavior.
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Duplicating an SSH tab ("Copy Tab") used to open a brand-new SSH
connection, forcing a second MFA prompt on hosts with multi-factor auth.
Like Tabby's session multiplexing, open a new shell *channel* on the
source tab's already-authenticated connection instead, so the duplicate
reuses the existing transport and skips key exchange + authentication.
- copySession records the source session id (reuseConnectionFromSessionId)
for connected, non-mosh SSH sessions; it is threaded down to the SSH
bridge as options.sourceSessionId.
- startSession.cjs gains a reuse path that opens conn.shell() on the
source connection. The shell wiring is extracted into a shared
setupShellSession helper used by both the fresh and reuse paths.
- Connection lifecycle is reference-counted via a new sshConnectionPool
module (mirrors Tabby ref/unref/destroy): the shared transport + jump
host chain are torn down only when the last channel closes, so closing
a copy — or the original while a copy is open — never kills siblings.
terminalBridge.closeSession routes SSH teardown through the same release.
- Reuse falls back to a fresh connection when the source is gone, and is
skipped for X11 hosts (X11 is negotiated per channel).
Tests: sshConnectionPool refcount unit tests, bridge reuse integration
tests (reuse vs fresh, sibling survival, X11 skip, fallback), and
terminalBridge close lifecycle tests.
Refs #1204
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex P2: the panel maxWidth constants used by the horizontal clamp's
totalWidth excluded each panel's padding + border (inline styles default to
content-box), so a padded detail tooltip at its 280px max could still render
wider than reserved and spill off-screen.
Set box-sizing: border-box on the shared panel style so every maxWidth is the
true outer width and totalWidth is exact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex P2: totalWidth and the vertical height reservation depended on the
hovered/selected item's detail panel, so moving the mouse between a
no-detail row and a detail row could change the clamped position and shift
the popup under the pointer (flicker, unreliable clicks near the edge).
Reserve detail-panel width/height from a set-level flag (any non-path row
with a description) so placement stays stable while hovering; the tooltip
itself still renders only for the active row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The terminal completion popup could spill past the window edges and ignored
the theme accent color (#1202):
- Horizontal: the left-edge clamp only reserved the main list width (400px),
so expanding a directory near the right edge pushed the cascading sub-dir
panels and the detail tooltip off-screen. Now the clamp accounts for the
full assembly width (main list + sub-dir panels + detail tooltip) and pins
to the left padding when it is wider than the viewport.
- Vertical: extracted the flip/clamp math into a pure, unit-tested
computeAutocompletePopupPlacement() so "not enough room below -> flip up
and bound the height" is verifiable. The detail tooltip is now height-
bounded and scrolls instead of overflowing for long snippet descriptions.
- Accent: the selected/hover row background and the active-row accent rail now
derive from the active terminal theme's cursor/selection colors (which track
the user's accent setting) instead of a hardcoded blue.
Adds terminalAutocompleteLayout.test.ts covering downward/upward placement,
bottom-of-viewport flip, height clamping, and horizontal clamping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The top-bar AI button previously dispatched netcatty:toggle-ai-panel,
which was wired to handleOpenAI -> handleSwitchSidePanelTab('ai'). That
switch is a no-op when AI is already the open sub-panel, so the panel
could not be dismissed by clicking the button again.
Add a dedicated handleToggleAiFromTopBar that routes through a new
resolveAiSidePanelToggleIntent helper: a second click on an already-open
AI panel closes the side panel, while a click from a closed panel or a
different sub-panel switches to AI. The top-bar event listener now uses
this toggle handler. handleOpenAI stays a plain switch so the AI icon in
the side-panel rail remains idempotent like the other rail tabs.
Also remove the non-functional reminder (bell) button from the top bar;
it had no click handler and no backing feature.
Refs #1194
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Alibaba Cloud Linux hosts (os-release ID="alinux") previously showed the
generic Linux icon because normalizeDistroId fell through to the catch-all
`linux` branch ('alinux'.includes('linux') is true).
- Add a dedicated `alinux` branch in normalizeDistroId, matching the
os-release ID, the legacy `aliyun` ID, and the NAME/PRETTY_NAME text
"Alibaba Cloud Linux"; place it before the generic linux fallback.
- Register `alinux` in LINUX_DISTRO_OPTIONS so it is selectable in the
manual distro override and classified as linux-like.
- Add the brand SVG (public/distro/alinux.svg) plus logo/color mappings
in DistroAvatar (brand color #FF6A00).
- Add the localized label in en / ru / zh-CN.
- Cover normalizeDistroId's alinux cases (ID, legacy aliyun, PRETTY_NAME)
with unit tests, including a regression guard against the generic linux
fallback.
Icon source: simple-icons "Alibaba Cloud" mark (CC0-1.0, public domain),
matching the existing distro icon set already sourced from simple-icons.
Closes#1200
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document the implementation status of EternalTerminal integration
across all layers (domain, electron, application, components, i18n)
with testing notes and known limitations.
Add ET configuration section in HostDetailsAdvancedSections (etPort,
etTerminalPath). Add ET option to ProtocolSelectDialog. Wire ET session
creation in createTerminalSessionStarters with proxy and multi-jump
validation. Add ET badges and labels in Terminal, TerminalToolbar,
TerminalConnectionDialog, and TerminalLayerSupport. Propagate ET
settings through GroupDetailsPanel, GroupSshSettingsSection, and
VaultView.
Terminal: protocol label, proxy-unsupported warning, multi-jump
limitation notice. Vault: ET settings section title, etPort and
etTerminalPath field labels with descriptions.
Resolve 'et' protocol in tray panel and host connection handlers with
priority over mosh. Propagate etEnabled through session factories and
useSessionState. Expose etAvailable guard and startEtSession wrapper
in useTerminalBackend.
Add startEtSession to the preload API surface, routing through the
netcatty:et:start IPC channel. Define the startEtSession options type
in netcatty-bridge-session.d.ts with ET-specific parameters (etPort,
terminalPath, jumpHosts, etc.).
Add etSession.cjs: full ET session lifecycle including SSH bootstrap with
host-key verification, etclient PTY spawning, temp directory management,
and external auth artifact cleanup. Wire the session API into
terminalBridge.cjs with IPC handler registration. Add bundledEtClient
resolver that locates the platform-specific et binary in both packaged
and dev environments. Include unit tests for both etSession and
bundledEtClient.
Add 'et' to HostProtocol union type. Add etEnabled, etPort, and
etTerminalPath fields to Host and GroupConfig interfaces. Update
vaultImport type guards to exclude 'et' from importable protocols.
Register ET fields in groupConfig inheritable keys.
Download and package the et binaries produced by build-et-binaries:
- resolve-et-bin-release picks the latest et-bin-* release; fetch-et-binaries
downloads the platform client into resources/et/, verifying SHA256SUMS.
- et-extra-resources emits the electron-builder extraResources entry only
when the binary is on disk, so pack still works without a bundled et.
- electron-builder.config.cjs wires et into the mac/win/linux bundles;
package.json adds the fetch:et scripts.