Compare commits

...

22 Commits

Author SHA1 Message Date
陈大猫
43097c43b1 Merge pull request #905 from binaricat/fix/mosh-strip-lc-env
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
Strip LC_* before mosh ssh handshake
2026-05-07 02:03:21 +08:00
bincxz
329e94752b Strip LC_* before mosh ssh handshake
macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
locale name). The system ssh_config has SendEnv LC_*, so the value
leaks to the remote and bash warns "cannot change locale (UTF-8)" on
every login. mosh-server sets its own locale separately, so dropping
LC_* from the spawned ssh's env is the cleanest fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 02:01:57 +08:00
陈大猫
b6a34131f6 Merge pull request #904 from binaricat/fix/mosh-windows-pinned-asset-check
Fix Windows mosh binary fallback selection
2026-05-07 01:42:18 +08:00
LAPTOP-O016UC3M\Qi Chen
3f16818d8d Fix Windows mosh binary fallback selection 2026-05-07 01:36:15 +08:00
陈大猫
3efc9ada8e Fix Windows mosh startup
Fix Windows mosh startup
2026-05-07 01:31:09 +08:00
陈大猫
8efdd1c9cb Merge pull request #901 from binaricat/codex/proxy-library
[codex] add reusable proxy profiles
2026-05-06 18:03:19 +08:00
bincxz
585a654668 Polish proxy form headings 2026-05-06 17:42:28 +08:00
bincxz
72e305fb7a Add reusable proxy profiles 2026-05-06 17:33:46 +08:00
bincxz
012a6bf521 Tone down proxy add button 2026-05-06 15:40:26 +08:00
陈大猫
4c72d5e0af Merge pull request #899 from yuzifu/fix-agent-path
fix: handle Windows agent paths with spaces
2026-05-06 15:36:32 +08:00
bincxz
cedc7f6c5f Align proxy profiles vault styles 2026-05-06 15:34:40 +08:00
bincxz
155463f77c add reusable proxy profiles 2026-05-06 15:20:23 +08:00
yuzifu
e5a74058ad add test unit 2026-05-06 15:12:17 +08:00
yuzifu
4ced32257e fix: handle Windows agent paths with spaces
When the executable file is installed in a directory containing spaces, the Codex and Claude path/version detection do not work.
2026-05-06 13:58:52 +08:00
陈大猫
64e7719715 Merge pull request #896 from yuzifu/fix-session-log
Fix session log
2026-05-06 12:34:07 +08:00
yuzifu
04b5aba62d fix: Preserve pending screen across redundant ED2 2026-05-04 17:27:04 +08:00
yuzifu
9f97f3870d fix: Preserve ED2-cleared screen when no trailing ED3 arrives 2026-05-04 17:15:41 +08:00
yuzifu
6bfd0e17a2 add ED3 test unit 2026-05-04 14:10:30 +08:00
yuzifu
1ac538eedc fix preserve terminal history during log sanitization 2026-05-04 14:07:22 +08:00
yuzifu
d34e23c7b3 preserve history while sanitizing terminal clears
Add a stateful terminal log sanitizer for txt/html session logs so saved output handles backspace, carriage-return overwrites, erase controls, split CSI/OSC sequences, and ANSI styling without leaking terminal control bytes.

Stream txt/html logs through a persistent renderer and write rendered snapshots directly to the final file, avoiding raw temp files and redundant full rewrites.
Preserve prior log history across clear-screen transitions while coalescing TUI repaint loops to avoid stale frame growth.

  Add regression coverage for tmux/zellij-style clears, repeated ED2/ED3 clears, home-clear repaint loops, and shell clear behavior.
2026-05-04 14:01:37 +08:00
陈大猫
31bf5396cb Bundle mosh terminfo on Linux and macOS (#890) (#894) 2026-05-04 11:09:12 +08:00
陈大猫
2feecaa9b6 Fix Windows mosh terminfo bundle (#889) 2026-05-01 22:51:15 +08:00
78 changed files with 5196 additions and 746 deletions

View File

@@ -9,7 +9,7 @@ name: build-mosh-binaries
# (`binaricat/Netcatty-mosh-bin` by default).
#
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
# or refreshing mosh binaries on every push.
on:
workflow_dispatch:
inputs:
@@ -129,48 +129,22 @@ jobs:
path: out/
# ------------------------------------------------------------------
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
# source. Cygwin's POSIX runtime can't be fully statically linked, so
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
# path is preserved as `fetch-windows.sh` for emergency fallback.
# Windows x64 pinned standalone client.
# Do not compile this in CI: the upstream Cygwin build can clear the
# terminal and never render output on Windows. Ship the SHA256-pinned
# FluentTerminal standalone binary verified by fetch-windows.sh.
# ------------------------------------------------------------------
build-windows-x64:
name: build-windows-x64
runs-on: windows-latest
fetch-windows-x64:
name: fetch-windows-x64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Cygwin
uses: cygwin/cygwin-install-action@v5
with:
add-to-path: false
# Keep package signature checks, but avoid the setup.exe hash
# fetch path that currently fails on windows-latest runners.
check-hash: false
packages: >
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
libncursesw-devel zlib-devel protobuf-compiler
- name: Build mosh-client.exe (win32-x64)
shell: pwsh
- name: Fetch pinned mosh-client.exe (win32-x64)
run: |
$ErrorActionPreference = "Stop"
$cygwinBin = "C:\cygwin\bin"
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
$script = @'
set -euo pipefail
cd "__WORKSPACE__"
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
export ARCH=x64
export OUT_DIR="__WORKSPACE__/out"
export OUT_DIR="${GITHUB_WORKSPACE}/out"
mkdir -p "$OUT_DIR"
bash scripts/build-mosh/build-windows.sh
'@
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
bash scripts/build-mosh/fetch-windows.sh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
@@ -179,12 +153,8 @@ jobs:
# ------------------------------------------------------------------
# Windows arm64 — intentionally not built.
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
# release for aarch64 as of this commit), so we don't attempt an
# arm64 mosh build. arm64 Windows installs fall through to the
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
# When upstream Cygwin ships a stable arm64 build, drop the same
# cygwin-install-action job below with `platform: arm64`.
# The pinned upstream source only provides x64. arm64 Windows builds
# should be added only after we have a tested standalone arm64 client.
# ------------------------------------------------------------------
# ------------------------------------------------------------------
@@ -196,7 +166,7 @@ jobs:
- build-linux-x64
- build-linux-arm64
- build-macos-universal
- build-windows-x64
- fetch-windows-x64
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
permissions:
@@ -241,7 +211,8 @@ jobs:
fi
{
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'

5
.gitignore vendored
View File

@@ -66,10 +66,11 @@ build_with_vs2022.bat
# Bundled mosh-client binaries fetched at pack time by
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
# committed; the actual binaries (and on Windows the Cygwin DLL
# bundle that ships alongside mosh-client.exe) are pulled from the
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
# and the bundled ncurses terminfo database are all pulled from the
# dedicated mosh binary repository, never committed.
/resources/mosh/*/mosh-client
/resources/mosh/*/mosh-client.exe
/resources/mosh/*/mosh-client-*-dlls/
/resources/mosh/*/*.dll
/resources/mosh/*/terminfo/

35
App.tsx
View File

@@ -16,6 +16,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
@@ -253,6 +254,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -263,6 +265,7 @@ function App({ settings }: { settings: SettingsState }) {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -453,6 +456,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -467,6 +471,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
identities,
keys,
proxyProfiles,
knownHosts,
portForwardingRulesForSync,
snippetPackages,
@@ -527,7 +532,7 @@ function App({ settings }: { settings: SettingsState }) {
return () => {
cancelled = true;
};
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
// Memoized "apply a remote payload safely" callback. Stable identity
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
@@ -560,6 +565,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -605,7 +611,7 @@ function App({ settings }: { settings: SettingsState }) {
if (start) {
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
return;
@@ -808,9 +814,11 @@ function App({ settings }: { settings: SettingsState }) {
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
});
@@ -1501,11 +1509,21 @@ function App({ settings }: { settings: SettingsState }) {
});
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
return applyGroupDefaults(host, groupDefaults);
}, [groupConfigs]);
const withGroupDefaults = host.group
? applyGroupDefaults(
host,
resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }),
{ validProxyProfileIds: proxyProfileIdSet },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
@@ -1847,6 +1865,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
snippets={snippets}
snippetPackages={snippetPackages}
customGroups={customGroups}
@@ -1870,6 +1889,7 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateHosts={updateHosts}
onUpdateKeys={updateKeys}
onUpdateIdentities={updateIdentities}
onUpdateProxyProfiles={updateProxyProfiles}
onUpdateSnippets={updateSnippets}
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
@@ -1895,6 +1915,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
@@ -1911,6 +1932,7 @@ function App({ settings }: { settings: SettingsState }) {
<TerminalLayerMount
hosts={hosts}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
identities={identities}
snippets={snippets}
@@ -2216,6 +2238,7 @@ function App({ settings }: { settings: SettingsState }) {
hosts: emptyVaultConflict.hostCount,
keys: emptyVaultConflict.keyCount,
snippets: emptyVaultConflict.snippetCount,
proxyProfiles: emptyVaultConflict.proxyProfileCount,
})}</div>
</div>
)}

View File

@@ -481,7 +481,7 @@ const en: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
@@ -499,6 +499,7 @@ const en: Messages = {
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.proxyProfiles': 'proxy profiles',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
@@ -514,11 +515,28 @@ const en: Messages = {
// Vault navigation
'vault.nav.hosts': 'Hosts',
'vault.nav.keychain': 'Keychain',
'vault.nav.proxies': 'Proxies',
'vault.nav.portForwarding': 'Port Forwarding',
'vault.nav.snippets': 'Snippets',
'vault.nav.knownHosts': 'Known Hosts',
'vault.nav.logs': 'Logs',
'proxyProfiles.action.add': 'Add Proxy',
'proxyProfiles.search.placeholder': 'Search proxies…',
'proxyProfiles.section.proxies': 'Proxies',
'proxyProfiles.count.items': '{count} items',
'proxyProfiles.empty.title': 'No Proxies',
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
'proxyProfiles.usage': '{count} linked',
'proxyProfiles.copyName': '{name} Copy',
'proxyProfiles.panel.newTitle': 'New Proxy',
'proxyProfiles.field.name': 'Proxy name',
'proxyProfiles.error.required': 'Name, host, and port are required.',
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
'proxyProfiles.viewMode': 'Proxy view mode',
'proxyProfiles.delete.title': 'Delete proxy?',
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
'vault.groups.title': 'Groups',
'vault.groups.total': '{count} total',
'vault.groups.hostsCount': '{count} Hosts',
@@ -1114,6 +1132,12 @@ const en: Messages = {
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.remove': 'Remove Proxy',
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
'hostDetails.proxyPanel.missing': 'Missing',
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
'hostDetails.envVars': 'Environment Variables',
'hostDetails.envVars.add': 'Add Environment Variable',
'hostDetails.envVars.title': 'Environment Variables',

View File

@@ -290,7 +290,7 @@ const zhCN: Messages = {
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段{proxyProfiles} 个代理',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
@@ -308,6 +308,7 @@ const zhCN: Messages = {
'sync.entityType.hosts': '主机',
'sync.entityType.keys': '密钥',
'sync.entityType.identities': '身份',
'sync.entityType.proxyProfiles': '代理配置',
'sync.entityType.snippets': '代码片段',
'sync.entityType.customGroups': '分组',
'sync.entityType.snippetPackages': '片段包',
@@ -323,11 +324,28 @@ const zhCN: Messages = {
// Vault navigation
'vault.nav.hosts': '主机',
'vault.nav.keychain': '钥匙串',
'vault.nav.proxies': '代理',
'vault.nav.portForwarding': '端口转发',
'vault.nav.snippets': '代码片段',
'vault.nav.knownHosts': '已知主机',
'vault.nav.logs': '日志',
'proxyProfiles.action.add': '添加代理',
'proxyProfiles.search.placeholder': '搜索代理…',
'proxyProfiles.section.proxies': '代理',
'proxyProfiles.count.items': '{count} 项',
'proxyProfiles.empty.title': '暂无代理',
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
'proxyProfiles.usage': '已关联 {count} 处',
'proxyProfiles.copyName': '{name} 副本',
'proxyProfiles.panel.newTitle': '新建代理',
'proxyProfiles.field.name': '代理名称',
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
'proxyProfiles.viewMode': '代理显示方式',
'proxyProfiles.delete.title': '删除代理?',
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
'vault.groups.title': '分组',
'vault.groups.total': '共 {count} 个',
'vault.groups.hostsCount': '{count} 台主机',
@@ -1539,13 +1557,19 @@ const zhCN: Messages = {
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': 'Proxy',
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
'hostDetails.proxyPanel.credentials': 'Credentials',
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
'hostDetails.proxyPanel.identities': 'Identities',
'hostDetails.proxyPanel.remove': '移除 Proxy',
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
'hostDetails.proxyPanel.credentials': '凭据',
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
'hostDetails.proxyPanel.identities': '身份',
'hostDetails.proxyPanel.remove': '移除代理',
'hostDetails.proxyPanel.savedProxy': '已保存代理',
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
'hostDetails.proxyPanel.customProxy': '自定义代理',
'hostDetails.proxyPanel.missing': '缺失',
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
'hostDetails.envVars.title': '环境变量',
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',

View File

@@ -0,0 +1,53 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("buildSftpHostCredentials rejects missing jump hosts", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["missing-jump"] } }),
hosts: [],
keys: [],
identities: [],
}),
/Jump host "missing-jump" is missing/,
);
});
test("buildSftpHostCredentials rejects missing saved proxy profiles", () => {
assert.throws(
() => buildSftpHostCredentials({
host: host({ proxyProfileId: "missing-proxy" }),
hosts: [],
keys: [],
identities: [],
}),
/Saved proxy for host "Host" is missing/,
);
});
test("buildSftpHostCredentials rejects missing saved proxy profiles on jump hosts", () => {
const jumpHost = host({ id: "jump-1", label: "Jump", proxyProfileId: "missing-proxy" });
assert.throws(
() => buildSftpHostCredentials({
host: host({ hostChain: { hostIds: ["jump-1"] } }),
hosts: [jumpHost],
keys: [],
identities: [],
}),
/Saved proxy for jump host "Jump" is missing/,
);
});

View File

@@ -9,94 +9,111 @@ interface UseSftpHostCredentialsParams {
identities: Identity[];
}
export const buildSftpHostCredentials = ({
host,
hosts,
keys,
identities,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds.map((hostId) => {
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
if (!jumpHost) {
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
}
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
return jumpHost;
}).map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
};
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
},
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
[hosts, identities, keys],
);

View File

@@ -30,6 +30,7 @@ interface AutoSyncConfig {
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
identities?: SyncPayload['identities'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];
@@ -110,6 +111,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
remotePayload: SyncPayload;
hostCount: number;
keyCount: number;
proxyProfileCount: number;
snippetCount: number;
} | null>(null);
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
@@ -142,6 +144,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
proxyProfiles: config.proxyProfiles,
snippets: config.snippets,
customGroups: config.customGroups,
snippetPackages: config.snippetPackages,
@@ -152,6 +155,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.hosts,
config.keys,
config.identities,
config.proxyProfiles,
config.snippets,
config.customGroups,
config.snippetPackages,
@@ -444,6 +448,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
remotePayload,
hostCount: remotePayload.hosts?.length ?? 0,
keyCount: remotePayload.keys?.length ?? 0,
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
snippetCount: remotePayload.snippets?.length ?? 0,
});
});

View File

@@ -0,0 +1,117 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAutoStartRuleBlockReason, isAutoStartProxyReady } from "./usePortForwardingAutoStart.ts";
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
const proxyProfile = (id: string): ProxyProfile => ({
id,
label: "Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
});
const rule = (overrides: Partial<PortForwardingRule> = {}): PortForwardingRule => ({
id: "rule-1",
label: "Rule",
type: "local",
localPort: 8080,
bindAddress: "127.0.0.1",
remoteHost: "127.0.0.1",
remotePort: 80,
hostId: "host-1",
autoStart: true,
status: "inactive",
createdAt: 1,
...overrides,
});
test("isAutoStartProxyReady waits when a host saved proxy is unresolved", () => {
assert.equal(
isAutoStartProxyReady(
host({ proxyProfileId: "missing-proxy" }),
[],
[],
[],
),
false,
);
});
test("isAutoStartProxyReady waits when a missing host proxy has a group fallback", () => {
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "group-proxy" }];
const currentHost = host({ group: "prod", proxyProfileId: "missing-proxy" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost],
[proxyProfile("group-proxy")],
groupConfigs,
),
false,
);
});
test("isAutoStartProxyReady waits when a group saved proxy is unresolved", () => {
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "missing-proxy" }];
const currentHost = host({ group: "prod" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost],
[],
groupConfigs,
),
false,
);
});
test("isAutoStartProxyReady checks group-inherited jump hosts", () => {
const currentHost = host({ group: "prod" });
const jumpHost = host({ id: "jump-1", proxyProfileId: "missing-proxy" });
assert.equal(
isAutoStartProxyReady(
currentHost,
[currentHost, jumpHost],
[],
[{ path: "prod", hostChain: { hostIds: ["jump-1"] } }],
),
false,
);
});
test("getAutoStartRuleBlockReason only blocks the affected rule", () => {
const goodHost = host();
const badHost = host({ id: "host-2", proxyProfileId: "missing-proxy" });
const hosts = [goodHost, badHost];
const isHostAuthReady = () => true;
assert.equal(
getAutoStartRuleBlockReason(rule({ id: "good", hostId: "host-1" }), hosts, [], [], isHostAuthReady),
undefined,
);
assert.equal(
getAutoStartRuleBlockReason(rule({ id: "bad", hostId: "host-2" }), hosts, [], [], isHostAuthReady),
"Proxy or jump host configuration is not ready",
);
});
test("getAutoStartRuleBlockReason marks rules without a host", () => {
assert.equal(
getAutoStartRuleBlockReason(rule({ hostId: undefined }), [], [], [], () => true),
"Rule host is not configured",
);
});

View File

@@ -4,8 +4,9 @@
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useCallback, useEffect, useRef } from "react";
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { GroupConfig, Host, Identity, PortForwardingRule, ProxyProfile, SSHKey } from "../../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -17,26 +18,97 @@ import {
import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
isVaultInitialized: boolean;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
groupConfigs: GroupConfig[];
}
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
const AUTO_START_AUTH_NOT_READY_ERROR = "Host authentication configuration is not ready";
export const isAutoStartProxyReady = (
host: Host,
allHosts: Host[],
proxyProfiles: ProxyProfile[],
groupConfigs: GroupConfig[],
seen = new Set<string>(),
): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfiles.map((profile) => profile.id));
const rawGroupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs)
: {};
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
: {};
const missingHostProxyProfile = Boolean(
host.proxyProfileId && !validProxyProfileIds.has(host.proxyProfileId),
);
const missingGroupProxyProfile = Boolean(
!host.proxyConfig &&
!host.proxyProfileId &&
rawGroupDefaults.proxyProfileId &&
!validProxyProfileIds.has(rawGroupDefaults.proxyProfileId),
);
const effectiveHost = applyGroupDefaults(host, groupDefaults, { validProxyProfileIds });
const hasProxyReplacement = Boolean(
effectiveHost.proxyConfig ||
(effectiveHost.proxyProfileId && validProxyProfileIds.has(effectiveHost.proxyProfileId)),
);
if ((missingHostProxyProfile || missingGroupProxyProfile) && !hasProxyReplacement) {
return false;
}
const chainIds = effectiveHost.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = allHosts.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isAutoStartProxyReady(chainHost, allHosts, proxyProfiles, groupConfigs, seen)) return false;
}
return true;
};
export const getAutoStartRuleBlockReason = (
rule: PortForwardingRule,
hosts: Host[],
proxyProfiles: ProxyProfile[],
groupConfigs: GroupConfig[],
isHostAuthReady: (host: Host) => boolean,
): string | undefined => {
if (!rule.hostId) return "Rule host is not configured";
const host = hosts.find((candidate) => candidate.id === rule.hostId);
if (!host) return "Host not found";
if (!isHostAuthReady(host)) return AUTO_START_AUTH_NOT_READY_ERROR;
if (!isAutoStartProxyReady(host, hosts, proxyProfiles, groupConfigs)) {
return AUTO_START_PROXY_NOT_READY_ERROR;
}
return undefined;
};
/**
* Auto-starts port forwarding rules that have autoStart enabled.
* This hook should be called at the App level to run on app launch.
*/
export const usePortForwardingAutoStart = ({
isVaultInitialized,
hosts,
keys,
identities,
proxyProfiles,
groupConfigs,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
@@ -77,16 +149,53 @@ export const usePortForwardingAutoStart = ({
identitiesRef.current = identities;
}, [identities]);
useEffect(() => {
proxyProfilesRef.current = proxyProfiles;
}, [proxyProfiles]);
useEffect(() => {
groupConfigsRef.current = groupConfigs;
}, [groupConfigs]);
const resolveEffectiveHost = useCallback((host: Host): Host => {
if (!host.group) return host;
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
return applyGroupDefaults(host, defaults);
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfilesRef.current.map((profile) => profile.id));
const withGroupDefaults = host.group
? applyGroupDefaults(
host,
resolveGroupDefaults(host.group, groupConfigsRef.current, { validProxyProfileIds }),
{ validProxyProfileIds },
)
: applyGroupDefaults(host, {}, { validProxyProfileIds });
return materializeHostProxyProfile(withGroupDefaults, proxyProfilesRef.current);
}, []);
const resolveEffectiveHosts = useCallback(
(items: Host[]): Host[] => items.map((host) => resolveEffectiveHost(host)),
[resolveEffectiveHost],
);
const updateStoredRuleStatus = useCallback(
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((rule) =>
rule.id === ruleId
? {
...rule,
status,
error,
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
}
: rule,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
[],
);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -99,40 +208,49 @@ export const usePortForwardingAutoStart = ({
) ?? [];
const rule = rules.find((r) => r.id === ruleId);
if (!rule || !rule.hostId) {
return { success: false, error: "Rule or host not found" };
if (!rule) {
const error = "Rule not found";
onStatusChange("error", error);
return { success: false, error };
}
if (!rule.hostId) {
const error = "Rule host is not configured";
onStatusChange("error", error);
return { success: false, error };
}
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
if (!rawHost) {
return { success: false, error: "Host not found" };
const error = "Host not found";
onStatusChange("error", error);
return { success: false, error };
}
const blockReason = getAutoStartRuleBlockReason(
rule,
hostsRef.current,
proxyProfilesRef.current,
groupConfigsRef.current,
(host) => isHostAuthReady(host),
);
if (blockReason) {
onStatusChange("error", blockReason);
return { success: false, error: blockReason };
}
const host = resolveEffectiveHost(rawHost);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
};
setReconnectCallback(handleReconnect);
return () => {
setReconnectCallback(null);
};
}, [resolveEffectiveHost]);
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
// Auto-start rules on app launch
useEffect(() => {
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
if (!isVaultInitialized) return;
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
@@ -149,7 +267,7 @@ export const usePortForwardingAutoStart = ({
// Only start rules that are not already active
const autoStartRules = rules.filter((r) => {
if (!r.autoStart || !r.hostId) return false;
if (!r.autoStart) return false;
// Check if there's an active connection for this rule
const conn = getActiveConnection(r.id);
// Only start if not already connecting or active
@@ -162,39 +280,45 @@ export const usePortForwardingAutoStart = ({
// Start each auto-start rule
for (const rule of autoStartRules) {
const rawHost = hosts.find((h) => h.id === rule.hostId);
if (rawHost) {
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const updatedRules = currentRules.map((r) =>
r.id === rule.id
? {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
}
: r,
);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
},
true, // Enable reconnect for auto-start rules
);
const blockReason = getAutoStartRuleBlockReason(
rule,
hosts,
proxyProfiles,
groupConfigs,
(host) => isHostAuthReady(host),
);
if (blockReason) {
updateStoredRuleStatus(rule.id, "error", blockReason);
continue;
}
if (!rawHost) continue;
const host = resolveEffectiveHost(rawHost);
void startPortForward(
rule,
host,
resolveEffectiveHosts(hosts),
keys,
identities,
(status, error) => {
updateStoredRuleStatus(rule.id, status, error);
},
true, // Enable reconnect for auto-start rules
);
}
};
void runAutoStart();
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
}, [
groupConfigs,
hosts,
identities,
isHostAuthReady,
isVaultInitialized,
keys,
proxyProfiles,
resolveEffectiveHost,
resolveEffectiveHosts,
updateStoredRuleStatus,
]);
};

View File

@@ -8,6 +8,7 @@ import {
KeyCategory,
KnownHost,
ManagedSource,
ProxyProfile,
ShellHistoryEntry,
Snippet,
SSHKey,
@@ -26,6 +27,7 @@ import {
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_PROXY_PROFILES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
@@ -36,16 +38,19 @@ import {
decryptHosts,
decryptIdentities,
decryptKeys,
decryptProxyProfiles,
encryptGroupConfigs,
encryptHosts,
encryptIdentities,
encryptKeys,
encryptProxyProfiles,
} from "../../infrastructure/persistence/secureFieldAdapter";
type ExportableVaultData = {
hosts: Host[];
keys: SSHKey[];
identities?: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
@@ -106,6 +111,7 @@ export const useVaultState = () => {
const [hosts, setHosts] = useState<Host[]>([]);
const [keys, setKeys] = useState<SSHKey[]>([]);
const [identities, setIdentities] = useState<Identity[]>([]);
const [proxyProfiles, setProxyProfiles] = useState<ProxyProfile[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [customGroups, setCustomGroups] = useState<string[]>([]);
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
@@ -121,6 +127,7 @@ export const useVaultState = () => {
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
const proxyProfilesWriteVersion = useRef(0);
const groupConfigsWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
@@ -130,13 +137,14 @@ export const useVaultState = () => {
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const proxyProfilesReadSeq = useRef(0);
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
const ver = ++hostsWriteVersion.current;
encryptHosts(cleaned).then((enc) => {
return encryptHosts(cleaned).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
@@ -145,7 +153,7 @@ export const useVaultState = () => {
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
const ver = ++keysWriteVersion.current;
encryptKeys(data).then((enc) => {
return encryptKeys(data).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
@@ -154,12 +162,21 @@ export const useVaultState = () => {
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
const ver = ++identitiesWriteVersion.current;
encryptIdentities(data).then((enc) => {
return encryptIdentities(data).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
setProxyProfiles(data);
const ver = ++proxyProfilesWriteVersion.current;
return encryptProxyProfiles(data).then((enc) => {
if (ver === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
setSnippets(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
@@ -188,7 +205,7 @@ export const useVaultState = () => {
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
setGroupConfigs(data);
const ver = ++groupConfigsWriteVersion.current;
encryptGroupConfigs(data).then((enc) => {
return encryptGroupConfigs(data).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
@@ -198,6 +215,7 @@ export const useVaultState = () => {
updateHosts([]);
updateKeys([]);
updateIdentities([]);
updateProxyProfiles([]);
updateSnippets([]);
updateSnippetPackages([]);
updateCustomGroups([]);
@@ -209,6 +227,7 @@ export const useVaultState = () => {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,
@@ -414,6 +433,20 @@ export const useVaultState = () => {
}
}
const savedProxyProfiles =
localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
if (savedProxyProfiles) {
const proxyVer = ++proxyProfilesWriteVersion.current;
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
if (proxyVer === proxyProfilesWriteVersion.current) {
setProxyProfiles(decryptedProfiles);
encryptProxyProfiles(decryptedProfiles).then((enc) => {
if (proxyVer === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}
}
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
@@ -528,6 +561,18 @@ export const useVaultState = () => {
return;
}
if (key === STORAGE_KEY_PROXY_PROFILES) {
const next = safeParse<ProxyProfile[]>(event.newValue) ?? [];
++proxyProfilesWriteVersion.current;
const seq = ++proxyProfilesReadSeq.current;
const writeAtStart = proxyProfilesWriteVersion.current;
decryptProxyProfiles(next).then((dec) => {
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
setProxyProfiles(dec);
});
return;
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
setSnippets(next);
@@ -621,30 +666,35 @@ export const useVaultState = () => {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
knownHosts,
groupConfigs,
}),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
const importData = useCallback(
(payload: Partial<ExportableVaultData>) => {
if (payload.hosts) updateHosts(payload.hosts);
if (payload.keys) updateKeys(payload.keys);
if (payload.identities) updateIdentities(payload.identities);
(payload: Partial<ExportableVaultData>): Promise<void> => {
const encryptedWrites: Promise<void>[] = [];
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
if (payload.snippets) updateSnippets(payload.snippets);
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
return Promise.all(encryptedWrites).then(() => undefined);
},
[
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateCustomGroups,
updateSnippetPackages,
@@ -654,9 +704,9 @@ export const useVaultState = () => {
);
const importDataFromString = useCallback(
(jsonString: string) => {
(jsonString: string): Promise<void> => {
const data = JSON.parse(jsonString);
importData(data);
return importData(data);
},
[importData],
);
@@ -666,6 +716,7 @@ export const useVaultState = () => {
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -677,6 +728,7 @@ export const useVaultState = () => {
updateHosts,
updateKeys,
updateIdentities,
updateProxyProfiles,
updateSnippets,
updateSnippetPackages,
updateCustomGroups,

View File

@@ -49,7 +49,8 @@ const knownHost = (id = "kh-1"): KnownHost => ({
hostname: `${id}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${id}`,
publicKey: `SHA256:${id}`,
discoveredAt: 1,
});
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
@@ -73,6 +74,25 @@ test("buildSyncPayload treats known hosts as local-only data", () => {
assert.equal("knownHosts" in payload, false);
});
test("buildSyncPayload includes reusable proxy profiles", () => {
const proxyProfiles = [
{
id: "proxy-1",
label: "Office Proxy",
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
createdAt: 1,
updatedAt: 1,
},
];
const payload = buildSyncPayload({
...vault(),
proxyProfiles,
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
});
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
assert.equal(
hasMeaningfulCloudSyncData({
@@ -94,8 +114,17 @@ test("buildLocalVaultPayload preserves known hosts for local backups", () => {
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
});
test("applySyncPayload ignores legacy cloud known hosts", () => {
test("applySyncPayload ignores legacy cloud known hosts", async () => {
let imported: Record<string, unknown> | null = null;
const proxyProfiles = [
{
id: "proxy-1",
label: "Office Proxy",
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
createdAt: 1,
updatedAt: 1,
},
];
const payload: SyncPayload = {
hosts: [],
keys: [],
@@ -103,10 +132,11 @@ test("applySyncPayload ignores legacy cloud known hosts", () => {
snippets: [],
customGroups: [],
knownHosts: [knownHost("kh-legacy")],
proxyProfiles,
syncedAt: 1,
};
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
applySyncPayload(payload, {
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
@@ -114,9 +144,96 @@ test("applySyncPayload ignores legacy cloud known hosts", () => {
assert.ok(imported);
assert.equal("knownHosts" in imported, false);
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
});
test("applyLocalVaultPayload restores known hosts from local backups", () => {
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "missing-proxy",
}],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
syncedAt: 1,
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
});
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "missing-proxy",
}],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
assert.equal("groupConfigs" in imported, false);
});
test("applySyncPayload waits for async vault imports", async () => {
let finished = false;
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
};
const promise = applySyncPayload(payload, {
importVaultData: async () => {
await new Promise((resolve) => setTimeout(resolve, 1));
finished = true;
},
});
assert.equal(finished, false);
await promise;
assert.equal(finished, true);
});
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [],
@@ -128,7 +245,7 @@ test("applyLocalVaultPayload restores known hosts from local backups", () => {
syncedAt: 1,
};
applyLocalVaultPayload(payload, {
await applyLocalVaultPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},

View File

@@ -13,6 +13,7 @@ import type {
Identity,
KnownHost,
PortForwardingRule,
ProxyProfile,
SftpBookmark,
Snippet,
SSHKey,
@@ -63,6 +64,7 @@ export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
snippets: Snippet[];
customGroups: string[];
snippetPackages?: string[];
@@ -81,6 +83,7 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
@@ -104,6 +107,7 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
(payload.keys?.length ?? 0) > 0 ||
(payload.snippets?.length ?? 0) > 0 ||
(payload.identities?.length ?? 0) > 0 ||
(payload.proxyProfiles?.length ?? 0) > 0 ||
(payload.customGroups?.length ?? 0) > 0 ||
(payload.snippetPackages?.length ?? 0) > 0 ||
(payload.portForwardingRules?.length ?? 0) > 0 ||
@@ -119,7 +123,7 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
/** Callbacks used by `applySyncPayload` to import data into local state. */
interface SyncPayloadImporters {
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
importVaultData: (jsonString: string) => void;
importVaultData: (jsonString: string) => void | Promise<void>;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
/** Called after synced settings have been written to localStorage. */
@@ -337,6 +341,7 @@ export function buildSyncPayload(
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
proxyProfiles: vault.proxyProfiles,
snippets: vault.snippets,
customGroups: vault.customGroups,
snippetPackages: vault.snippetPackages,
@@ -368,13 +373,14 @@ function applyPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
options: { includeLocalOnlyData: boolean },
): void {
): Promise<void> {
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,
snippets: payload.snippets,
customGroups: payload.customGroups,
};
@@ -388,35 +394,35 @@ function applyPayload(
vaultImport.groupConfigs = payload.groupConfigs;
}
importers.importVaultData(JSON.stringify(vaultImport));
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
});
}
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: false });
): Promise<void> {
return applyPayload(payload, importers, { includeLocalOnlyData: false });
}
export function applyLocalVaultPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
applyPayload(payload, importers, { includeLocalOnlyData: true });
): Promise<void> {
return applyPayload(payload, importers, { includeLocalOnlyData: true });
}

View File

@@ -22,6 +22,7 @@ import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { customThemeStore } from "../application/state/customThemeStore";
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import {
EnvVar,
@@ -29,6 +30,7 @@ import {
Host,
Identity,
ProxyConfig,
ProxyProfile,
SSHKey,
} from "../types";
import ThemeSelectPanel from "./ThemeSelectPanel";
@@ -51,6 +53,7 @@ import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
import { useAvailableFonts } from "../application/state/fontStore";
import { toast } from "./ui/toast";
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
@@ -59,6 +62,7 @@ interface GroupDetailsPanelProps {
config: GroupConfig | undefined;
availableKeys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
allHosts: Host[];
groups: string[];
terminalThemeId: string;
@@ -74,6 +78,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
config,
availableKeys,
identities: _identities,
proxyProfiles = [],
allHosts,
groups,
terminalThemeId,
@@ -105,7 +110,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
@@ -132,6 +137,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
// Environment variables state
const [newEnvName, setNewEnvName] = useState("");
const [newEnvValue, setNewEnvValue] = useState("");
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
const update = <K extends keyof GroupConfig>(key: K, value: GroupConfig[K] | undefined) => {
setForm((prev) => ({ ...prev, [key]: value }));
@@ -156,6 +171,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
delete next.startupCommand;
delete next.legacyAlgorithms;
delete next.backspaceBehavior;
delete next.proxyProfileId;
delete next.proxyConfig;
delete next.hostChain;
delete next.environmentVariables;
@@ -182,27 +198,38 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
// Proxy helpers
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
setForm((prev) => ({
...prev,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
}));
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
};
});
},
[],
);
const clearProxyConfig = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
return rest;
});
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
if (!profileId) return rest;
return { ...rest, proxyProfileId: profileId };
});
}, []);
// Chain helpers
const chainedHosts = useMemo(() => {
const ids = form.hostChain?.hostIds || [];
@@ -297,6 +324,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
setNameError(t("vault.groups.errors.invalidChars"));
return;
}
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (sshEnabled && hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
setActiveSubPanel("proxy");
return;
}
setNameError(null);
const newPath = parentGroup
@@ -320,7 +360,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
...(form.proxyConfig !== undefined && { proxyConfig: form.proxyConfig }),
...(form.proxyProfileId !== undefined && { proxyProfileId: form.proxyProfileId }),
...(normalizedProxyConfig !== undefined && { proxyConfig: normalizedProxyConfig }),
...(form.hostChain !== undefined && { hostChain: form.hostChain }),
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
@@ -360,7 +401,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
@@ -849,11 +893,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
<Globe size={14} className="text-muted-foreground" />
<span className="text-sm">{t("hostDetails.proxy")}</span>
</div>
<div className="flex items-center gap-2">
{form.proxyConfig?.host && (
<Badge variant="secondary" className="text-xs">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
</Badge>
<div className="flex min-w-0 items-center gap-2">
{(form.proxyConfig?.host || form.proxyProfileId) && (
<div title={proxySummaryLabel} className="min-w-0">
<Badge
variant="secondary"
className="max-w-[160px] truncate text-xs"
>
{proxySummaryLabel}
</Badge>
</div>
)}
<ChevronRight size={14} className="text-muted-foreground" />
</div>

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { Host } from "../types.ts";
import HostDetailsPanel from "./HostDetailsPanel.tsx";
const hostWithMissingProxyProfile: Host = {
id: "host-1",
label: "DB",
hostname: "db.example.com",
username: "root",
tags: [],
os: "linux",
port: 22,
protocol: "ssh",
authMethod: "password",
proxyProfileId: "missing-proxy",
createdAt: 1,
};
const renderHostDetails = () =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(HostDetailsPanel, {
initialData: hostWithMissingProxyProfile,
availableKeys: [],
identities: [],
proxyProfiles: [],
groups: [],
managedSources: [],
allTags: [],
allHosts: [],
terminalThemeId: "default",
terminalFontSize: 14,
onSave: () => {},
onCancel: () => {},
}),
),
);
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
const markup = renderHostDetails();
assert.match(markup, /Missing saved proxy/);
assert.doesNotMatch(markup, /undefined:undefined/);
});

View File

@@ -37,6 +37,7 @@ import {
LINUX_DISTRO_OPTIONS,
NETWORK_DEVICE_OPTIONS,
} from "../domain/host";
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
@@ -48,7 +49,7 @@ import {
} from "../domain/terminalAppearance";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
@@ -69,6 +70,7 @@ import { Textarea } from "./ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
import { toast } from "./ui/toast";
// Import host-details sub-panels
import {
@@ -97,6 +99,7 @@ interface HostDetailsPanelProps {
initialData?: Host | null;
availableKeys: SSHKey[];
identities: Identity[];
proxyProfiles?: ProxyProfile[];
groups: string[];
managedSources?: ManagedSource[];
allTags?: string[]; // All available tags for autocomplete
@@ -117,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
initialData,
availableKeys,
identities,
proxyProfiles = [],
groups,
managedSources = [],
allTags = [],
@@ -260,6 +264,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const selectedProxyProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
[form.proxyProfileId, proxyProfiles],
);
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
const proxySummaryType = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missing")
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
const proxySummaryLabel = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? selectedProxyProfile.label
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
const proxySummaryTooltip = hasMissingProxyProfile
? t("hostDetails.proxyPanel.missingSaved")
: selectedProxyProfile
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
@@ -274,27 +296,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
setForm((prev) => ({
...prev,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
}));
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
return {
...rest,
proxyConfig: {
type: prev.proxyConfig?.type || "http",
host: prev.proxyConfig?.host || "",
port: prev.proxyConfig?.port || 8080,
...prev.proxyConfig,
[field]: value,
},
} as Host;
});
},
[],
);
const clearProxyConfig = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
return rest as Host;
});
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
const addHostToChain = (hostId: string) => {
setForm((prev) => ({
...prev,
@@ -342,6 +375,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
const handleSubmit = () => {
if (!form.hostname) return;
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
toast.error(
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
);
setActiveSubPanel("proxy");
return;
}
if (hasMissingProxyProfile) {
toast.error(t("hostDetails.proxyPanel.missingSaved"));
setActiveSubPanel("proxy");
return;
}
// If label is empty, use hostname as label
let finalLabel = form.label?.trim() || form.hostname;
const finalGroup = groupInputValue.trim() || form.group || "";
@@ -377,8 +423,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
finalManagedSourceId = undefined;
}
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
const cleaned: Host = {
...form,
...formWithoutProxyDraft,
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
@@ -536,7 +584,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return (
<ProxyPanel
proxyConfig={form.proxyConfig}
proxyProfiles={proxyProfiles}
selectedProxyProfileId={form.proxyProfileId}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
@@ -1758,35 +1809,40 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
{form.proxyConfig?.host ? (
<button
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{form.proxyConfig.type?.toUpperCase()}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{form.proxyConfig.host}:{form.proxyConfig.port}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearProxyConfig();
}}
/>
</button>
{form.proxyConfig?.host || form.proxyProfileId ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{proxySummaryType}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{proxySummaryLabel}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{proxySummaryTooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
aria-label={t("hostDetails.proxyPanel.remove")}
onClick={clearProxyConfig}
>
<X size={14} />
</Button>
</div>
) : (
<Button
variant="ghost"

View File

@@ -22,7 +22,7 @@ import { resolveHostAuth } from "../domain/sshAuth";
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel";
@@ -68,6 +68,7 @@ interface KeychainManagerProps {
keys: SSHKey[];
identities?: Identity[];
hosts?: Host[];
proxyProfiles?: ProxyProfile[];
customGroups?: string[];
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
@@ -84,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
keys,
identities = [],
hosts = [],
proxyProfiles = [],
customGroups = [],
managedSources = [],
onSave,
@@ -1234,6 +1236,7 @@ echo $3 >> "$FILE"`);
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}

View File

@@ -10,7 +10,7 @@ import {
Shuffle,
Zap,
} from "lucide-react";
import React, { useCallback, useState } from "react";
import React, { useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
@@ -19,9 +19,11 @@ import {
ManagedSource,
PortForwardingRule,
PortForwardingType,
ProxyProfile,
SSHKey,
} from "../domain/models";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -69,6 +71,7 @@ interface PortForwardingProps {
customGroups: string[];
managedSources?: ManagedSource[];
groupConfigs?: GroupConfig[];
proxyProfiles?: ProxyProfile[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -81,6 +84,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
customGroups: _customGroups,
managedSources = [],
groupConfigs = [],
proxyProfiles = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -113,6 +117,20 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
new Set(),
);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveEffectiveHost = useCallback(
(host: Host): Host => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
},
[groupConfigs, proxyProfileIdSet, proxyProfiles],
);
// Start a port forwarding tunnel
const handleStartTunnel = useCallback(
@@ -127,9 +145,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
return;
}
const _host = _rawHost.group
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
: _rawHost;
const _host = resolveEffectiveHost(_rawHost);
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
setPendingOperations((prev) => new Set([...prev, rule.id]));
let errorShown = false;
@@ -138,7 +155,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
hosts,
effectiveHosts,
keys,
identities,
(status, error) => {
@@ -169,7 +186,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel
@@ -853,6 +870,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}

View File

@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { ProxyProfile } from "../types.ts";
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
const proxyProfile: ProxyProfile = {
id: "proxy-1",
label: "Office Proxy",
config: {
type: "socks5",
host: "office-proxy.example.com",
port: 1080,
},
createdAt: 1,
};
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(ProxyPanel, {
proxyConfig: undefined,
proxyProfiles: [],
selectedProxyProfileId: undefined,
onUpdateProxy: () => {},
onSelectProxyProfile: () => {},
onClearProxy: () => {},
onBack: () => {},
onCancel: () => {},
layout: "inline",
...props,
}),
),
);
test("ProxyPanel shows saved proxy selection when reusable profiles exist", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
selectedProxyProfileId: proxyProfile.id,
});
assert.match(markup, /Saved proxy/);
assert.match(markup, /office-proxy\.example\.com:1080/);
assert.doesNotMatch(markup, /Proxy host/);
});
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
});
assert.match(markup, /Saved proxy/);
assert.match(markup, /Proxy host/);
assert.match(markup, /manual-proxy\.example\.com/);
});
test("ProxyPanel shows a clear missing state for stale saved proxy selections", () => {
const markup = renderPanel({
proxyProfiles: [proxyProfile],
selectedProxyProfileId: "missing-proxy",
});
assert.match(markup, /Missing saved proxy/);
assert.match(markup, /Proxy host/);
});
test("ProxyPanel disables saving invalid manual proxy ports", () => {
const markup = renderPanel({
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 65536 },
});
assert.match(markup, /Port must be between 1 and 65535/);
assert.match(markup, /disabled=""/);
});

View File

@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { isValidProxyPort } from "../domain/proxyProfiles.ts";
import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
import type { ProxyProfile } from "../types.ts";
import { ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
const proxyProfile: ProxyProfile = {
id: "proxy-1",
label: "Office Proxy",
config: {
type: "http",
host: "127.0.0.1",
port: 8080,
},
createdAt: 1,
};
const installStorageStub = (viewMode: string | null = null) => {
const values = new Map<string, string>();
if (viewMode) {
values.set(STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, viewMode);
}
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
},
});
};
const renderManager = (viewMode: string | null = null) => {
installStorageStub(viewMode);
return renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(ProxyProfilesManager, {
proxyProfiles: [proxyProfile],
hosts: [],
groupConfigs: [],
onUpdateProxyProfiles: () => {},
onUpdateHosts: () => {},
onUpdateGroupConfigs: () => {},
}),
),
);
};
test("ProxyProfilesManager uses the shared Vault grid card style by default", () => {
const markup = renderManager();
assert.match(markup, /Add Proxy/);
assert.match(markup, /aria-label="Search proxies…"/);
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
assert.match(markup, /Office Proxy/);
assert.match(markup, /127\.0\.0\.1:8080/);
});
test("ProxyProfilesManager uses the shared Vault list row style when persisted", () => {
const markup = renderManager("list");
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
assert.match(markup, /Office Proxy/);
assert.match(markup, /127\.0\.0\.1:8080/);
});
test("ProxyProfilesManager validates proxy ports", () => {
assert.equal(isValidProxyPort(1), true);
assert.equal(isValidProxyPort(65535), true);
assert.equal(isValidProxyPort(0), false);
assert.equal(isValidProxyPort(65536), false);
assert.equal(isValidProxyPort(10.5), false);
});

View File

@@ -0,0 +1,538 @@
import {
AlertTriangle,
Check,
ChevronDown,
Copy,
Globe,
KeyRound,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Search,
Settings2,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { isValidProxyPort, removeProxyProfileReferences } from "../domain/proxyProfiles";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
} from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types";
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Card } from "./ui/card";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "./ui/context-menu";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
import { Input } from "./ui/input";
import { toast } from "./ui/toast";
interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
hosts: Host[];
groupConfigs: GroupConfig[];
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateHosts: (hosts: Host[]) => void;
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
}
const createDraftProfile = (): ProxyProfile => {
const now = Date.now();
return {
id: crypto.randomUUID(),
label: "",
config: {
type: "http",
host: "",
port: 8080,
},
createdAt: now,
updatedAt: now,
};
};
const getProfileUsageCount = (
profileId: string,
hosts: Host[],
groupConfigs: GroupConfig[],
): number =>
hosts.filter((host) => host.proxyProfileId === profileId).length +
groupConfigs.filter((config) => config.proxyProfileId === profileId).length;
type ProxyProfilesViewMode = "grid" | "list";
interface ProxyProfileCardProps {
profile: ProxyProfile;
usageCount: number;
viewMode: ProxyProfilesViewMode;
isSelected: boolean;
onClick: () => void;
onEdit: () => void;
onDuplicate: () => void;
onDelete: () => void;
}
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
profile,
usageCount,
viewMode,
isSelected,
onClick,
onEdit,
onDuplicate,
onDelete,
}) => {
const { t } = useI18n();
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
const accessibleLabel = `${profile.label}, ${profile.config.type.toUpperCase()}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<button
type="button"
aria-label={accessibleLabel}
className={cn(
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
viewMode === "grid"
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
isSelected && "ring-2 ring-primary",
)}
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
<Globe size={18} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-semibold truncate">{profile.label}</div>
<Badge variant="secondary" className="text-[10px] shrink-0">
{profile.config.type.toUpperCase()}
</Badge>
</div>
<div className="text-[11px] font-mono text-muted-foreground truncate">
{profile.config.host}:{profile.config.port} -{" "}
{usageLabel}
</div>
</div>
</div>
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onEdit}>
<Pencil size={14} className="mr-2" />
{t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={onDuplicate}>
<Copy size={14} className="mr-2" />
{t("action.duplicate")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
<Trash2 size={14} className="mr-2" />
{t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
proxyProfiles,
hosts,
groupConfigs,
onUpdateProxyProfiles,
onUpdateHosts,
onUpdateGroupConfigs,
}) => {
const { t } = useI18n();
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
"grid",
);
const proxyProfilesViewMode: ProxyProfilesViewMode =
viewMode === "list" ? "list" : "grid";
const [draft, setDraft] = useState<ProxyProfile | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
const usageByProfileId = useMemo(() => {
const map = new Map<string, number>();
for (const profile of proxyProfiles) {
map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs));
}
return map;
}, [groupConfigs, hosts, proxyProfiles]);
const filteredProfiles = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return proxyProfiles;
return proxyProfiles.filter((profile) =>
profile.label.toLowerCase().includes(q) ||
profile.config.host.toLowerCase().includes(q) ||
profile.config.type.toLowerCase().includes(q),
);
}, [proxyProfiles, search]);
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
setDraft((prev) => {
if (!prev) return prev;
return {
...prev,
config: {
...prev.config,
[field]: value,
},
};
});
};
const openCreate = () => {
setDraft(createDraftProfile());
};
const openEdit = (profile: ProxyProfile) => {
setDraft({
...profile,
config: { ...profile.config },
});
};
const duplicateProfile = (profile: ProxyProfile) => {
const now = Date.now();
onUpdateProxyProfiles([
...proxyProfiles,
{
...profile,
id: crypto.randomUUID(),
label: t("proxyProfiles.copyName", { name: profile.label }),
config: { ...profile.config },
createdAt: now,
updatedAt: now,
},
]);
};
const saveDraft = () => {
if (!draft) return;
const label = draft.label.trim();
const host = draft.config.host.trim();
if (!label || !host || !draft.config.port) {
toast.error(t("proxyProfiles.error.required"));
return;
}
if (!isValidProxyPort(draft.config.port)) {
toast.error(t("proxyProfiles.error.port"));
return;
}
const saved: ProxyProfile = {
...draft,
label,
config: {
...draft.config,
host,
port: Number(draft.config.port),
username: draft.config.username?.trim() || undefined,
password: draft.config.password || undefined,
},
updatedAt: Date.now(),
};
onUpdateProxyProfiles(
proxyProfiles.some((profile) => profile.id === saved.id)
? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile)
: [...proxyProfiles, saved],
);
setDraft(null);
};
const confirmDelete = () => {
if (!deleteTarget) return;
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
hosts,
groupConfigs,
});
onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id));
onUpdateHosts(cleaned.hosts);
onUpdateGroupConfigs(cleaned.groupConfigs);
if (draft?.id === deleteTarget.id) {
setDraft(null);
}
setDeleteTarget(null);
};
return (
<div className="h-full flex relative">
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0">
<div className="h-14 px-4 py-2 flex items-center gap-3">
<Button
onClick={openCreate}
variant="secondary"
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
>
<Plus size={14} />
{t("proxyProfiles.action.add")}
</Button>
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
<div className="relative flex-shrink min-w-[100px]">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={t("proxyProfiles.search.placeholder")}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={t("proxyProfiles.search.placeholder")}
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
/>
</div>
<Dropdown>
<DropdownTrigger asChild>
<Button
aria-label={t("proxyProfiles.viewMode")}
variant="ghost"
size="icon"
className="h-10 w-10 flex-shrink-0"
>
{proxyProfilesViewMode === "grid" ? (
<LayoutGrid size={16} />
) : (
<ListIcon size={16} />
)}
<ChevronDown size={10} className="ml-0.5" />
</Button>
</DropdownTrigger>
<DropdownContent className="w-32" align="end">
<Button
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={14} /> {t("vault.view.grid")}
</Button>
<Button
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
className="w-full justify-start gap-2 h-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={14} /> {t("vault.view.list")}
</Button>
</DropdownContent>
</Dropdown>
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
{t("proxyProfiles.section.proxies")}
</h2>
<span className="text-xs text-muted-foreground">
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
</span>
</div>
{filteredProfiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Globe size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("proxyProfiles.empty.title")}
</h3>
<p className="text-sm text-center max-w-sm mb-4">
{t("proxyProfiles.empty.desc")}
</p>
<Button onClick={openCreate}>
<Plus size={14} className="mr-2" />
{t("proxyProfiles.action.add")}
</Button>
</div>
) : (
<div
className={
proxyProfilesViewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredProfiles.map((profile) => (
<ProxyProfileCard
key={profile.id}
profile={profile}
usageCount={usageByProfileId.get(profile.id) ?? 0}
viewMode={proxyProfilesViewMode}
isSelected={draft?.id === profile.id}
onClick={() => openEdit(profile)}
onEdit={() => openEdit(profile)}
onDuplicate={() => duplicateProfile(profile)}
onDelete={() => setDeleteTarget(profile)}
/>
))}
</div>
)}
</div>
</div>
</div>
{draft && (
<AsidePanel
open={true}
onClose={() => setDraft(null)}
title={draft.label || t("proxyProfiles.panel.newTitle")}
>
<AsidePanelContent>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("proxyProfiles.field.name")}</p>
</div>
<Input
aria-label={t("proxyProfiles.field.name")}
value={draft.label}
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
placeholder={t("proxyProfiles.field.name")}
className="h-10"
/>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("field.type")}</p>
</div>
<div className="flex gap-2">
<Button
variant={draft.config.type === "http" ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
onClick={() => updateDraftConfig("type", "http")}
>
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
HTTP
</Button>
<Button
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
onClick={() => updateDraftConfig("type", "socks5")}
>
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
SOCKS5
</Button>
</div>
</div>
<div className="flex gap-2">
<Input
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
value={draft.config.host}
onChange={(event) => updateDraftConfig("host", event.target.value)}
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
className="h-10 flex-1"
/>
<Input
aria-label={t("hostDetails.port")}
type="number"
value={draft.config.port || ""}
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
placeholder="3128"
min={1}
max={65535}
step={1}
className="h-10 w-24 text-center"
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
</div>
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
</div>
<Input
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
value={draft.config.username || ""}
onChange={(event) => updateDraftConfig("username", event.target.value)}
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
className="h-10"
/>
<Input
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
type="password"
value={draft.config.password || ""}
onChange={(event) => updateDraftConfig("password", event.target.value)}
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
className="h-10"
/>
</Card>
</AsidePanelContent>
<AsidePanelFooter>
<Button className="w-full" onClick={saveDraft}>
{t("common.save")}
</Button>
</AsidePanelFooter>
</AsidePanel>
)}
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle size={18} className="text-destructive" />
{t("proxyProfiles.delete.title")}
</DialogTitle>
<DialogDescription>
{deleteTarget
? t("proxyProfiles.delete.desc", {
name: deleteTarget.label,
count: usageByProfileId.get(deleteTarget.id) ?? 0,
})
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={confirmDelete}>
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default ProxyProfilesManager;

View File

@@ -8,7 +8,7 @@ import {
import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, SSHKey } from "../types";
import { Host, ProxyProfile, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
@@ -37,6 +37,7 @@ interface SelectHostPanelProps {
// Props for inline host creation
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
proxyProfiles?: ProxyProfile[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -57,6 +58,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onNewHost,
availableKeys = [],
identities = [],
proxyProfiles = [],
managedSources = [],
onSaveHost,
onCreateGroup,
@@ -411,6 +413,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
initialData={null}
availableKeys={availableKeys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={customGroups}
managedSources={managedSources}
allHosts={hosts}

View File

@@ -113,6 +113,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
hosts,
keys,
identities,
proxyProfiles,
snippets,
customGroups,
snippetPackages,
@@ -137,8 +138,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
() => ({ hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
);
return (

View File

@@ -41,6 +41,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
writableHosts?: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
@@ -74,6 +75,7 @@ interface SftpSidePanelProps {
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hosts,
writableHosts,
keys,
identities,
updateHosts,
@@ -98,6 +100,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onRequestTerminalFocus,
}) => {
const { t } = useI18n();
const hostWriteSource = writableHosts ?? hosts;
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {
@@ -622,6 +625,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return (
<SftpContextProvider
hosts={hosts}
writableHosts={hostWriteSource}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -741,6 +745,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
prev.hosts === next.hosts &&
prev.writableHosts === next.writableHosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&

View File

@@ -24,8 +24,9 @@ import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
import { toast } from "./ui/toast";
@@ -54,6 +55,7 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
groupConfigs?: import('../domain/models').GroupConfig[];
proxyProfiles?: ProxyProfile[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
@@ -71,6 +73,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keys,
identities,
groupConfigs = [],
proxyProfiles = [],
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
@@ -109,14 +112,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() =>
hosts.map(h => {
if (!h.group) return h;
const defaults = resolveGroupDefaults(h.group, groupConfigs);
return applyGroupDefaults(h, defaults);
}),
[hosts, groupConfigs],
);
const effectiveHosts = useMemo(() => {
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
return hosts.map(h => {
const withGroupDefaults = h.group
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
: applyGroupDefaults(h, {}, { validProxyProfileIds });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
});
}, [hosts, groupConfigs, proxyProfiles]);
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
@@ -323,7 +327,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
return (
<SftpContextProvider
hosts={hosts}
hosts={effectiveHosts}
writableHosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
@@ -462,7 +467,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
</div>
<SftpOverlays
hosts={hosts}
hosts={effectiveHosts}
sftp={sftp}
visibleTransfers={visibleTransfers}
showHostPickerLeft={showHostPickerLeft}
@@ -507,6 +512,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&

View File

@@ -4,7 +4,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useStoredViewMode } from '../application/state/useStoredViewMode';
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
import { cn, isMacPlatform } from '../lib/utils';
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { Host, ProxyProfile, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
@@ -35,6 +35,7 @@ interface SnippetsManagerProps {
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
// Props for inline host creation
availableKeys?: SSHKey[];
proxyProfiles?: ProxyProfile[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -58,6 +59,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onPackagesChange,
onRunSnippet,
availableKeys = [],
proxyProfiles = [],
managedSources = [],
onSaveHost,
onCreateGroup,
@@ -723,6 +725,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onBack={handleTargetPickerBack}
onContinue={handleTargetPickerBack}
availableKeys={availableKeys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}

View File

@@ -624,8 +624,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh: (term) => {
sessionStartersRef.current?.startSSH(term);
onStartSession: (term) => {
const starters = sessionStartersRef.current;
if (!starters) return;
if (host.moshEnabled) {
starters.startMosh(term);
return;
}
starters.startSSH(term);
},
setStatus: (next) => setStatus(next),
setProgressLogs,

View File

@@ -0,0 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
const baseProps = {
hosts: [],
groupConfigs: [],
proxyProfiles: [],
keys: [],
identities: [],
snippets: [],
snippetPackages: [],
sessions: [],
workspaces: [],
draggingSessionId: null,
terminalTheme: {},
accentMode: "theme",
customAccent: null,
terminalSettings: {},
fontSize: 14,
hotkeyScheme: "default",
keyBindings: [],
sftpDefaultViewMode: "list",
sftpDoubleClickBehavior: "open",
sftpAutoSync: false,
sftpShowHiddenFiles: false,
sftpUseCompressedUpload: false,
sftpAutoOpenSidebar: false,
editorWordWrap: false,
setEditorWordWrap: () => {},
onHotkeyAction: () => {},
onUpdateHost: () => {},
onToggleWorkspaceViewMode: () => {},
onSetWorkspaceFocusedSession: () => {},
onSplitSession: () => {},
toggleScriptsSidePanelRef: { current: null },
};
test("TerminalLayer re-renders when group configs change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{ ...baseProps, groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }] } as never,
),
false,
);
});
test("TerminalLayer re-renders when proxy profiles change", () => {
assert.equal(
terminalLayerAreEqual(
baseProps as never,
{
...baseProps,
proxyProfiles: [{
id: "proxy-1",
label: "Office Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
}],
} as never,
),
false,
);
});

View File

@@ -36,9 +36,10 @@ import {
} from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
import { SftpSidePanel } from './SftpSidePanel';
@@ -56,6 +57,7 @@ import { RippleButton } from './ui/ripple';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
import { terminalLayerAreEqual } from './terminalLayerMemo';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
@@ -386,6 +388,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
groupConfigs: GroupConfig[];
proxyProfiles: ProxyProfile[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
@@ -448,6 +451,7 @@ interface TerminalLayerProps {
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
groupConfigs,
proxyProfiles,
keys,
identities,
snippets,
@@ -879,6 +883,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const h of hosts) map.set(h.id, h);
return map;
}, [hosts]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveHosts = useMemo(
() => hosts.map((host) => {
const groupDefaults = host.group
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return materializeHostProxyProfile(
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
}),
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
);
// Pre-compute fallback hosts to avoid creating new objects on every render
const sessionHostsMap = useMemo(() => {
@@ -888,9 +908,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (rawHost) {
// Apply group config defaults so Terminal sees the merged host
const groupDefaults = rawHost.group
? resolveGroupDefaults(rawHost.group, groupConfigs)
? resolveGroupDefaults(rawHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
const existingHost = applyGroupDefaults(rawHost, groupDefaults);
const existingHost = materializeHostProxyProfile(
applyGroupDefaults(rawHost, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
@@ -932,7 +955,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}
return map;
}, [sessions, hostMap, groupConfigs]);
}, [sessions, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
@@ -945,15 +968,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const rawChainHost = hostMap.get(hostId);
if (!rawChainHost) return undefined;
const chainGroupDefaults = rawChainHost.group
? resolveGroupDefaults(rawChainHost.group, groupConfigs)
? resolveGroupDefaults(rawChainHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
: {};
return applyGroupDefaults(rawChainHost, chainGroupDefaults);
return materializeHostProxyProfile(
applyGroupDefaults(rawChainHost, chainGroupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
proxyProfiles,
);
})
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
const validAIScopeTargetIds = useMemo(() => {
const ids = new Set<string>();
@@ -1282,9 +1308,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
if (activeWorkspace && focusedSessionId) {
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
}
// For solo session: use stored host (from when SFTP was opened)
if (activeSession) {
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
}
return sftpHostForTab.get(activeTabId) ?? null;
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, focusedSessionId, sessionHostsMap, sftpHostForTab]);
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, activeSession, focusedSessionId, sessionHostsMap, sftpHostForTab]);
// Keep sftpHostForTab in sync with focus changes in workspace mode
// so that the toggle check uses the currently displayed host.
@@ -2338,7 +2366,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return (
<SftpSidePanel
key={tabId}
hosts={hosts}
hosts={effectiveHosts}
writableHosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
@@ -2653,40 +2682,5 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
);
};
// Only re-render when data props change - activeTabId/isVisible are now managed internally via store subscription
const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProps): boolean => {
return (
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.sessions === next.sessions &&
prev.workspaces === next.workspaces &&
prev.draggingSessionId === next.draggingSessionId &&
prev.terminalTheme === next.terminalTheme &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.identities === next.identities
);
};
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
TerminalLayer.displayName = 'TerminalLayer';

View File

@@ -11,6 +11,8 @@ import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import type { Host } from "../domain/models";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
@@ -117,10 +119,14 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities, groupConfigs } = useVaultState();
const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
@@ -335,10 +341,14 @@ const TrayPanelContent: React.FC = () => {
if (isActive) {
void stopTunnel(rule.id);
} else {
const host = rawHost.group
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
: rawHost;
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}

View File

@@ -8,6 +8,7 @@ test("VaultView re-renders when an external section navigation request changes",
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
@@ -30,3 +31,42 @@ test("VaultView re-renders when an external section navigation request changes",
false,
);
});
test("VaultView re-renders when proxy profiles change", () => {
const baseProps = {
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
snippetPackages: [],
customGroups: [],
knownHosts: [],
shellHistory: [],
connectionLogs: [],
sessions: [],
managedSources: [],
groupConfigs: {},
terminalThemeId: "default",
terminalFontSize: 14,
navigateToSection: null,
};
assert.equal(
vaultViewAreEqual(
baseProps as never,
{
...baseProps,
proxyProfiles: [
{
id: "proxy-1",
label: "Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
},
],
} as never,
),
false,
);
});

View File

@@ -12,6 +12,7 @@ import {
FileSymlink,
FolderPlus,
FolderTree,
Globe,
Key,
LayoutGrid,
List,
@@ -55,6 +56,7 @@ import {
Identity,
KnownHost,
ManagedSource,
ProxyProfile,
SerialConfig,
SSHKey,
ShellHistoryEntry,
@@ -69,6 +71,7 @@ import { HostTreeView } from "./HostTreeView";
import KeychainManager from "./KeychainManager";
import KnownHostsManager from "./KnownHostsManager";
import PortForwarding from "./PortForwardingNew";
import ProxyProfilesManager from "./ProxyProfilesManager";
import QuickConnectWizard from "./QuickConnectWizard";
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
import SerialConnectModal from "./SerialConnectModal";
@@ -104,7 +107,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
type DropTarget =
| { kind: "root" }
@@ -115,6 +118,7 @@ interface VaultViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
proxyProfiles: ProxyProfile[];
snippets: Snippet[];
snippetPackages: string[];
customGroups: string[];
@@ -136,6 +140,7 @@ interface VaultViewProps {
onUpdateHosts: (hosts: Host[]) => void;
onUpdateKeys: (keys: SSHKey[]) => void;
onUpdateIdentities: (identities: Identity[]) => void;
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
onUpdateSnippets: (snippets: Snippet[]) => void;
onUpdateSnippetPackages: (pkgs: string[]) => void;
onUpdateCustomGroups: (groups: string[]) => void;
@@ -163,6 +168,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
hosts,
keys,
identities,
proxyProfiles,
snippets,
snippetPackages,
customGroups,
@@ -184,6 +190,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onUpdateHosts,
onUpdateKeys,
onUpdateIdentities,
onUpdateProxyProfiles,
onUpdateSnippets,
onUpdateSnippetPackages,
onUpdateCustomGroups,
@@ -296,6 +303,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (!group) return undefined;
return resolveGroupDefaults(group, groupConfigs);
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
// Quick connect state
const [quickConnectTarget, setQuickConnectTarget] = useState<{
hostname: string;
@@ -343,8 +354,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Check if host has multiple protocols enabled (using effective/resolved host)
const hasMultipleProtocols = useCallback((host: Host) => {
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
let count = 0;
// SSH is always available as base protocol (unless explicitly set to something else)
if (effective.protocol === "ssh" || !effective.protocol) count++;
@@ -355,7 +366,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// If protocol is explicitly telnet (not ssh), count it
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
return count > 1;
}, [groupConfigs]);
}, [groupConfigs, proxyProfileIdSet]);
// Handle host connect with protocol selection
const handleHostConnect = useCallback(
@@ -363,14 +374,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (hasMultipleProtocols(host)) {
// Pass effective host to protocol dialog so it shows correct ports/protocols
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
setProtocolSelectHost(effective);
} else {
onConnect(host);
}
},
[hasMultipleProtocols, onConnect, groupConfigs],
[hasMultipleProtocols, onConnect, groupConfigs, proxyProfileIdSet],
);
// Handle protocol selection
@@ -475,8 +486,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const handleCopyCredentials = useCallback((host: Host) => {
// Apply group defaults so inherited credentials are included
const effective = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
: host;
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
// Only use telnet-specific port and credentials when protocol is explicitly telnet
// Don't treat telnetEnabled as primary - that's just an optional protocol
const isTelnet = effective.protocol === "telnet";
@@ -519,7 +530,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
navigator.clipboard.writeText(text).then(() => {
toast.success(t('vault.hosts.copyCredentials.toast.success'));
});
}, [identities, groupConfigs, t]);
}, [identities, groupConfigs, proxyProfileIdSet, t]);
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
const toggleHostPinned = useCallback((hostId: string) => {
@@ -1669,6 +1680,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
variant={currentSection === "proxies" ? "secondary" : "ghost"}
className={cn(
"w-full h-10",
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
currentSection === "proxies" &&
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("proxies");
}}
>
<Globe size={16} className="flex-shrink-0" />
{!sidebarCollapsed && t("vault.nav.proxies")}
</RippleButton>
</TooltipTrigger>
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.proxies")}</TooltipContent>}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
@@ -2826,6 +2857,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
onRunSnippet={onRunSnippet}
availableKeys={keys}
proxyProfiles={proxyProfiles}
managedSources={managedSources}
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
onCreateGroup={(groupPath) =>
@@ -2840,6 +2872,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
keys={keys}
identities={identities}
hosts={hosts}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
managedSources={managedSources}
onSave={(k) => onUpdateKeys([...keys, k])}
@@ -2877,11 +2910,22 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
}
/>
)}
{currentSection === "proxies" && (
<ProxyProfilesManager
proxyProfiles={proxyProfiles}
hosts={hosts}
groupConfigs={groupConfigs}
onUpdateProxyProfiles={onUpdateProxyProfiles}
onUpdateHosts={onUpdateHosts}
onUpdateGroupConfigs={onUpdateGroupConfigs}
/>
)}
{currentSection === "port" && (
<PortForwarding
hosts={hosts}
keys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
customGroups={customGroups}
managedSources={managedSources}
groupConfigs={groupConfigs}
@@ -2924,6 +2968,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
config={groupConfigs.find(c => c.path === editingGroupPath)}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
allHosts={hosts}
groups={allGroupPaths}
terminalThemeId={terminalThemeId}
@@ -2944,6 +2989,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
initialData={editingHost}
availableKeys={keys}
identities={identities}
proxyProfiles={proxyProfiles}
groups={allGroupPaths}
managedSources={managedSources}
allTags={allTags}
@@ -3207,6 +3253,7 @@ export const vaultViewAreEqual = (
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.proxyProfiles === next.proxyProfiles &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.customGroups === next.customGroups &&

View File

@@ -2,20 +2,25 @@
* Proxy Configuration Sub-Panel
* Panel for configuring HTTP/SOCKS5 proxy settings
*/
import { Check,Trash2 } from 'lucide-react';
import React from 'react';
import { Check, Globe, KeyRound, Trash2 } from 'lucide-react';
import React, { useCallback, useMemo } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { isValidProxyPort } from '../../domain/proxyProfiles';
import { cn } from '../../lib/utils';
import { ProxyConfig } from '../../types';
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
import { ProxyConfig, ProxyProfile } from '../../types';
import { AsidePanel, AsidePanelContent, type AsidePanelLayout } from '../ui/aside-panel';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
export interface ProxyPanelProps {
proxyConfig?: ProxyConfig;
proxyProfiles?: ProxyProfile[];
selectedProxyProfileId?: string;
onUpdateProxy: (field: keyof ProxyConfig, value: string | number) => void;
onSelectProxyProfile?: (profileId: string | undefined) => void;
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
@@ -24,97 +29,180 @@ export interface ProxyPanelProps {
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
proxyConfig,
proxyProfiles = [],
selectedProxyProfileId,
onUpdateProxy,
onSelectProxyProfile,
onClearProxy,
onBack,
onCancel,
layout = 'overlay',
}) => {
const { t } = useI18n();
const customValue = '__custom__';
const selectedProfile = useMemo(
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
[proxyProfiles, selectedProxyProfileId],
);
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
const isUsingProfile = Boolean(selectedProfile);
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
const hasInvalidManualProxyPort = hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
const canSave = isUsingProfile || (hasManualProxyHost && !hasInvalidManualProxyPort);
const handleBack = useCallback(() => {
if (hasInvalidManualProxyPort) return;
onBack();
}, [hasInvalidManualProxyPort, onBack]);
return (
<AsidePanel
open={true}
onClose={onCancel}
title={t('hostDetails.proxyPanel.title')}
showBackButton={true}
onBack={onBack}
onBack={handleBack}
layout={layout}
actions={
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
<Button size="sm" onClick={handleBack} disabled={!canSave}>
{t('common.save')}
</Button>
}
>
<AsidePanelContent>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">{t('field.type')}</p>
<div className="flex gap-2">
<Button
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'http')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
HTTP
</Button>
<Button
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'socks5')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
SOCKS5
</Button>
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.savedProxy')}</p>
</div>
</div>
<Select
value={selectedValue}
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.savedProxy')}
className="h-10"
>
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
</SelectTrigger>
<SelectContent>
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
{proxyProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
{profile.label}
</SelectItem>
))}
</SelectContent>
</Select>
{hasMissingProfile && (
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
{t('hostDetails.proxyPanel.missingSaved')}
</div>
)}
{selectedProfile && (
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
<div className="flex min-w-0 items-center gap-2">
<Badge variant="secondary" className="text-xs shrink-0">
{selectedProfile.config.type.toUpperCase()}
</Badge>
<span className="truncate">
{selectedProfile.config.host}:{selectedProfile.config.port}
</span>
</div>
</div>
)}
</Card>
)}
<div className="flex gap-2">
<Input
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
{!isUsingProfile && (
<>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('field.type')}</p>
</div>
<div className="flex gap-2">
<Button
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'http')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
HTTP
</Button>
<Button
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
size="sm"
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
onClick={() => onUpdateProxy('type', 'socks5')}
>
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
SOCKS5
</Button>
</div>
</div>
<div className="flex gap-2">
<Input
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
value={proxyConfig?.host || ""}
onChange={(e) => onUpdateProxy('host', e.target.value)}
className="h-10 flex-1"
/>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
<Input
aria-label={t('hostDetails.port')}
type="number"
placeholder="3128"
min={1}
max={65535}
step={1}
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
/>
</div>
</div>
{hasInvalidManualProxyPort && (
<p className="text-xs text-destructive">
{t('proxyProfiles.error.port')}
</p>
)}
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
</div>
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
</div>
<Input
type="number"
placeholder="3128"
value={proxyConfig?.port || ""}
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
className="h-10 w-20 text-center"
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
</div>
</div>
</Card>
<Input
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
</Card>
</>
)}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
</div>
<Input
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
value={proxyConfig?.username || ""}
onChange={(e) => onUpdateProxy('username', e.target.value)}
className="h-10"
/>
<Input
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
type="password"
value={proxyConfig?.password || ""}
onChange={(e) => onUpdateProxy('password', e.target.value)}
className="h-10"
/>
<Button variant="ghost" size="sm" className="text-primary" onClick={() => { }}>
{t('hostDetails.proxyPanel.identities')}
</Button>
</Card>
{proxyConfig?.host && (
{(proxyConfig?.host || selectedProxyProfileId) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>

View File

@@ -19,7 +19,7 @@ import { SettingsTabContent } from "../settings-ui";
export default function SettingsSyncTab(props: {
vault: SyncableVaultData;
portForwardingRules: PortForwardingRule[];
importDataFromString: (data: string) => void;
importDataFromString: (data: string) => void | Promise<void>;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;

View File

@@ -108,6 +108,8 @@ export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];
// Raw hosts list for bookmark persistence and other host writes.
writableHosts: Host[];
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
@@ -159,6 +161,12 @@ export const useSftpHosts = () => {
return context.hosts;
};
// Hook to get raw hosts for writeback
export const useSftpWritableHosts = () => {
const context = useSftpContext();
return context.writableHosts;
};
// Hook to get host updater
export const useSftpUpdateHosts = () => {
const context = useSftpContext();
@@ -167,6 +175,7 @@ export const useSftpUpdateHosts = () => {
interface SftpContextProviderProps {
hosts: Host[];
writableHosts?: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
@@ -177,6 +186,7 @@ interface SftpContextProviderProps {
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
writableHosts,
updateHosts,
draggedFiles,
dragCallbacks,
@@ -188,11 +198,12 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
const value = useMemo<SftpContextValue>(
() => ({
hosts,
writableHosts: writableHosts ?? hosts,
updateHosts,
leftCallbacks,
rightCallbacks,
}),
[hosts, updateHosts, leftCallbacks, rightCallbacks],
[hosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
);
// Memoize drag context separately so only drag consumers re-render on drag state changes

View File

@@ -14,6 +14,7 @@ import {
useSftpHosts,
useSftpPaneCallbacks,
useSftpUpdateHosts,
useSftpWritableHosts,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
@@ -96,6 +97,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const writableHosts = useSftpWritableHosts();
const { t } = useI18n();
const hostId = pane.connection?.hostId;
@@ -141,12 +143,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
// Bookmark support
const updateHosts = useSftpUpdateHosts();
const currentHost = useMemo(
() => hosts.find((h) => h.id === pane.connection?.hostId),
[hosts, pane.connection?.hostId],
() => writableHosts.find((h) => h.id === pane.connection?.hostId),
[writableHosts, pane.connection?.hostId],
);
const onUpdateHost = useCallback(
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
[hosts, updateHosts],
(updated: Host) => updateHosts(writableHosts.map((h) => (h.id === updated.id ? updated : h))),
[updateHosts, writableHosts],
);
const remoteBookmarks = useSftpBookmarks({
host: currentHost,

View File

@@ -18,6 +18,7 @@ export {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpWritableHosts,
useSftpUpdateHosts,
useActiveTabId,
useIsPaneActive,

View File

@@ -11,7 +11,7 @@ export const useTerminalAuthState = ({
pendingAuthRef,
termRef,
onUpdateHost,
onStartSsh,
onStartSession,
setStatus,
setProgressLogs,
}: {
@@ -19,7 +19,7 @@ export const useTerminalAuthState = ({
pendingAuthRef: RefObject<PendingAuth>;
termRef: RefObject<XTerm | null>;
onUpdateHost?: (host: Host) => void;
onStartSsh: (term: XTerm) => void;
onStartSession: (term: XTerm) => void;
setStatus: (status: TerminalSession["status"]) => void;
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
}) => {
@@ -106,7 +106,7 @@ export const useTerminalAuthState = ({
logger.warn("Failed to clear terminal", err);
}
onStartSsh(term);
onStartSession(term);
},
[
authKeyId,
@@ -116,7 +116,7 @@ export const useTerminalAuthState = ({
authUsername,
host,
isValid,
onStartSsh,
onStartSession,
onUpdateHost,
pendingAuthRef,
saveCredentials,

View File

@@ -1,10 +1,26 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
const noop = () => undefined;
test("getMissingChainHostIds reports unresolved jump hosts", () => {
assert.deepEqual(
getMissingChainHostIds(
{
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
hostChain: { hostIds: ["jump-1", "jump-2"] },
} as never,
[{ id: "jump-1" }] as never,
),
["jump-2"],
);
});
test("startMosh does not pass legacy configured mosh client paths to the backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
@@ -155,3 +171,617 @@ test("startMosh passes the saved password to the mosh backend", async () => {
assert.equal(capturedOptions.username, "alice");
assert.equal(capturedOptions.password, "saved-secret");
});
test("startMosh passes configured key material to the mosh backend", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
password: "wrong-password",
authMethod: "key",
identityFileId: "key-1",
identityFilePaths: ["/should/not/be/used"],
port: 2200,
},
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
passphrase: "key-passphrase",
}],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.password, "wrong-password");
assert.equal(capturedOptions.privateKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----");
assert.equal(capturedOptions.keyId, "key-1");
assert.equal(capturedOptions.passphrase, "key-passphrase");
assert.equal(capturedOptions.identityFilePaths, undefined);
});
test("startMosh asks for credential re-entry when saved key material cannot be decrypted", async () => {
let started = false;
let needsAuth = false;
let retryMessage: string | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
authMethod: "key",
identityFileId: "key-1",
port: 2200,
},
keys: [{
id: "key-1",
label: "Deploy key",
privateKey: "enc:v1:djEwAAAA",
}],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: (value: boolean) => { needsAuth = value; },
setAuthRetryMessage: (message: string | null) => { retryMessage = message; },
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.equal(needsAuth, true);
assert.match(retryMessage || "", /Saved credentials cannot be decrypted/);
});
test("startMosh omits identity file paths when password auth is explicit", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
authMethod: "password",
password: "saved-secret",
identityFilePaths: ["/should/not/be/used"],
port: 2200,
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.ok(capturedOptions);
assert.equal(capturedOptions.password, "saved-secret");
assert.equal(capturedOptions.identityFilePaths, undefined);
});
test("startMosh rejects missing saved proxy profiles", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
proxyProfileId: "missing-proxy",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Saved proxy/);
});
test("startMosh rejects configured proxies instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
port: 2200,
proxyProfileId: "proxy-1",
proxyConfig: { type: "http", host: "proxy.example.com", port: 3128 },
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Mosh does not support proxy/);
});
test("startMosh rejects jump host chains instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => {
started = true;
return "mosh-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
hostChain: { hostIds: ["jump-1"] },
port: 2200,
},
keys: [],
resolvedChainHosts: [{ id: "jump-1", hostname: "jump.example.test" }],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
assert.equal(started, false);
assert.match(error, /Mosh does not support jump host chains/);
});
test("startTelnet rejects missing saved proxy profiles", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => {
started = true;
return "telnet-session";
},
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
telnetPort: 2323,
proxyProfileId: "missing-proxy",
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
assert.equal(started, false);
assert.match(error, /Saved proxy/);
});
test("startTelnet rejects configured proxies instead of connecting directly", async () => {
let started = false;
let error = "";
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => {
started = true;
return "telnet-session";
},
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = {
host: {
id: "host-1",
label: "Example",
hostname: "example.test",
username: "alice",
telnetPort: 2323,
proxyProfileId: "proxy-1",
proxyConfig: { type: "http", host: "proxy.example.com", port: 3128 },
},
keys: [],
resolvedChainHosts: [],
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: (message: string) => { error = message; },
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
};
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
assert.equal(started, false);
assert.match(error, /Telnet does not support proxy/);
});

View File

@@ -143,6 +143,16 @@ export type TerminalSessionStartersContext = {
) => void;
};
export const getMissingChainHostIds = (
host: Host,
resolvedChainHosts: Host[],
): string[] => {
const requestedIds = host.hostChain?.hostIds ?? [];
if (requestedIds.length === 0) return [];
const resolvedIds = new Set(resolvedChainHosts.map((chainHost) => chainHost.id));
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
};
const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
const env: Record<string, string> = {
TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color",
@@ -337,6 +347,24 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
const missingChainHostIds = getMissingChainHostIds(ctx.host, ctx.resolvedChainHosts);
if (missingChainHostIds.length > 0) {
const base = tr(
"terminal.auth.jumpHostMissing",
"A configured jump host is missing. Open host settings and repair the jump host chain.",
);
const suffix = missingChainHostIds.length > 2
? ` +${missingChainHostIds.length - 2}`
: "";
const message = `${base} (${missingChainHostIds.slice(0, 2).join(", ")}${suffix})`;
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
@@ -372,6 +400,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const rawProxyPassword = ctx.host.proxyConfig?.password;
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
const proxyConfig = ctx.host.proxyConfig
? {
@@ -384,6 +419,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
const message = `Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -720,6 +763,22 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
return;
}
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
if (ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) {
const message = "Telnet does not support proxy connections. Use SSH for this host or remove the proxy from this connection.";
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
try {
const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startTelnetSession({
@@ -754,6 +813,39 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
try {
const stopMosh = (message: string) => {
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
};
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
stopMosh(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
const hasConfiguredJumpHostChain =
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
ctx.resolvedChainHosts.length > 0;
if (hasConfiguredJumpHostChain) {
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
return;
}
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
if (unresolvedJumpProxyHost) {
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
const hasConfiguredProxy =
Boolean(ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) ||
ctx.resolvedChainHosts.some((jumpHost) => Boolean(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port));
if (hasConfiguredProxy) {
stopMosh("Mosh does not support proxy connections. Use SSH for this host or remove the proxy from this connection.");
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
@@ -770,12 +862,44 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const authMethod = resolvedAuth.authMethod;
const key = authMethod === "password" ? undefined : resolvedAuth.key;
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== "password";
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setStatus("connecting");
return;
}
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startMoshSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: sanitizeCredentialValue(key?.privateKey),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
: undefined,
identityFilePaths: authMethod !== "password" && !key ? ctx.host.identityFilePaths : undefined,
port: ctx.host.port || 22,
moshServerPath: ctx.host.moshServerPath,
agentForwarding: ctx.host.agentForwarding,

View File

@@ -0,0 +1,36 @@
export const terminalLayerAreEqual = (
prev: Record<string, unknown>,
next: Record<string, unknown>,
): boolean => (
prev.hosts === next.hosts &&
prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles &&
prev.keys === next.keys &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.sessions === next.sessions &&
prev.workspaces === next.workspaces &&
prev.draggingSessionId === next.draggingSessionId &&
prev.terminalTheme === next.terminalTheme &&
prev.accentMode === next.accentMode &&
prev.customAccent === next.customAccent &&
prev.terminalSettings === next.terminalSettings &&
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&
prev.onUpdateHost === next.onUpdateHost &&
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onSplitSession === next.onSplitSession &&
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
prev.identities === next.identities
);

View File

@@ -88,6 +88,12 @@ export const findSyncPayloadEncryptedCredentialPaths = (
}
});
payload.proxyProfiles?.forEach((profile, index) => {
if (isEncryptedCredentialPlaceholder(profile.config.password)) {
issues.push(`proxyProfiles[${index}].config.password`);
}
});
payload.groupConfigs?.forEach((config, index) => {
if (isEncryptedCredentialPlaceholder(config.password)) {
issues.push(`groupConfigs[${index}].password`);

132
domain/groupConfig.test.ts Normal file
View File

@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig.ts";
import type { GroupConfig, Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
...overrides,
});
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
};
const result = applyGroupDefaults(host({ proxyProfileId: "proxy-1" }), groupDefaults);
assert.equal(result.proxyProfileId, "proxy-1");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults lets a host custom proxy override a group proxy profile", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const customProxy = { type: "socks5" as const, host: "host-proxy.example.com", port: 1080 };
const result = applyGroupDefaults(host({ proxyConfig: customProxy }), groupDefaults);
assert.equal(result.proxyProfileId, undefined);
assert.deepEqual(result.proxyConfig, customProxy);
});
test("resolveGroupDefaults treats saved and custom proxies as one inherited setting", () => {
const resolved = resolveGroupDefaults("prod/api", [
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "child-proxy",
},
]);
assert.equal(resolved.proxyProfileId, "child-proxy");
assert.equal(resolved.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group proxy", () => {
const groupDefaults: Partial<GroupConfig> = {
proxyProfileId: "group-proxy",
};
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
groupDefaults,
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile when no group fallback exists", () => {
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{},
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults keeps a missing host proxy profile instead of using group custom proxy", () => {
const groupProxy = { type: "http" as const, host: "group-proxy.example.com", port: 3128 };
const result = applyGroupDefaults(
host({ proxyProfileId: "missing-proxy" }),
{ proxyConfig: groupProxy },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps a missing group proxy marker when there is no fallback", () => {
const resolved = resolveGroupDefaults(
"prod",
[{ path: "prod", proxyProfileId: "missing-proxy" }],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
});
test("applyGroupDefaults inherits a missing group proxy marker so connect paths can fail", () => {
const result = applyGroupDefaults(
host({ group: "prod" }),
{ proxyProfileId: "missing-proxy" },
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(result.proxyProfileId, "missing-proxy");
assert.equal(result.proxyConfig, undefined);
});
test("resolveGroupDefaults keeps missing child proxy profiles instead of using parent proxy", () => {
const resolved = resolveGroupDefaults(
"prod/api",
[
{
path: "prod",
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
},
{
path: "prod/api",
proxyProfileId: "missing-proxy",
},
],
{ validProxyProfileIds: new Set(["group-proxy"]) },
);
assert.equal(resolved.proxyProfileId, "missing-proxy");
assert.equal(resolved.proxyConfig, undefined);
});

View File

@@ -1,5 +1,17 @@
import type { GroupConfig, Host } from './models';
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;
}
const hasUsableProxyProfileId = (
proxyProfileId: string | undefined,
options?: ApplyGroupDefaultsOptions,
): boolean => {
if (!proxyProfileId) return false;
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
};
/**
* Resolve merged group defaults by walking the ancestor chain.
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
@@ -7,6 +19,7 @@ import type { GroupConfig, Host } from './models';
export function resolveGroupDefaults(
groupPath: string,
groupConfigs: GroupConfig[],
options?: ApplyGroupDefaultsOptions,
): Partial<GroupConfig> {
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
const parts = groupPath.split('/').filter(Boolean);
@@ -17,6 +30,14 @@ export function resolveGroupDefaults(
const config = configMap.get(ancestorPath);
if (config) {
for (const [key, value] of Object.entries(config)) {
if (
key === 'proxyProfileId' &&
typeof value === 'string' &&
options?.validProxyProfileIds &&
!options.validProxyProfileIds.has(value)
) {
delete merged.proxyConfig;
}
if (
(key === 'theme' && config.themeOverride === false) ||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
@@ -26,6 +47,12 @@ export function resolveGroupDefaults(
continue;
}
if (key !== 'path' && value !== undefined) {
if (key === 'proxyProfileId') {
delete merged.proxyConfig;
}
if (key === 'proxyConfig') {
delete merged.proxyProfileId;
}
merged[key] = value;
}
}
@@ -48,7 +75,7 @@ export function resolveGroupDefaults(
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
'port', 'protocol', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand',
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
@@ -59,10 +86,20 @@ const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
* Returns a new host object — does NOT mutate the original.
*/
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
export function applyGroupDefaults(
host: Host,
groupDefaults: Partial<GroupConfig>,
options?: ApplyGroupDefaultsOptions,
): Host {
const effective = { ...host };
const hostHasUsableProxyProfile = hasUsableProxyProfileId(host.proxyProfileId, options);
for (const key of INHERITABLE_KEYS) {
const hostValue = (host as unknown as Record<string, unknown>)[key];
if (key === 'proxyProfileId') {
if (host.proxyConfig !== undefined || !groupDefaults.proxyProfileId) continue;
}
if (key === 'proxyConfig' && (host.proxyProfileId !== undefined || hostHasUsableProxyProfile)) continue;
const hostValue = (effective as unknown as Record<string, unknown>)[key];
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
(effective as unknown as Record<string, unknown>)[key] = groupValue;

View File

@@ -11,6 +11,14 @@ export interface ProxyConfig {
password?: string;
}
export interface ProxyProfile {
id: string;
label: string;
config: ProxyConfig;
createdAt: number;
updatedAt?: number;
}
// Host chain configuration for jump host / bastion connections
export interface HostChainConfig {
hostIds: string[]; // Array of host IDs in order (first = closest to client)
@@ -83,6 +91,7 @@ export interface Host {
startupCommand?: string;
hostChaining?: string; // Deprecated: use hostChain instead
proxy?: string; // Deprecated: use proxyConfig instead
proxyProfileId?: string; // Reference to reusable proxy profile
proxyConfig?: ProxyConfig; // New structured proxy configuration
hostChain?: HostChainConfig; // New structured host chain configuration
envVars?: string; // Deprecated: use environmentVariables instead
@@ -205,6 +214,7 @@ export interface GroupConfig {
port?: number;
protocol?: 'ssh' | 'telnet';
agentForwarding?: boolean;
proxyProfileId?: string;
proxyConfig?: ProxyConfig;
hostChain?: HostChainConfig;
startupCommand?: string;

View File

@@ -0,0 +1,91 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host, ProxyProfile } from "./models.ts";
import {
isCompleteProxyConfig,
normalizeManualProxyConfig,
materializeHostProxyProfile,
removeProxyProfileReferences,
} from "./proxyProfiles.ts";
const profile = (overrides: Partial<ProxyProfile> = {}): ProxyProfile => ({
id: "proxy-1",
label: "Office Proxy",
config: {
type: "socks5",
host: "proxy.example.com",
port: 1080,
username: "alice",
password: "secret",
},
createdAt: 1,
updatedAt: 1,
...overrides,
});
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Server",
hostname: "server.example.com",
username: "root",
os: "linux",
tags: [],
protocol: "ssh",
...overrides,
});
test("materializeHostProxyProfile resolves a selected proxy profile", () => {
const resolved = materializeHostProxyProfile(
host({ proxyProfileId: "proxy-1" }),
[profile()],
);
assert.deepEqual(resolved.proxyConfig, profile().config);
});
test("materializeHostProxyProfile keeps explicit custom proxy ahead of profile reference", () => {
const customProxy = {
type: "http" as const,
host: "custom.example.com",
port: 3128,
};
const resolved = materializeHostProxyProfile(
host({ proxyProfileId: "proxy-1", proxyConfig: customProxy }),
[profile()],
);
assert.deepEqual(resolved.proxyConfig, customProxy);
});
test("removeProxyProfileReferences clears hosts and group configs that use a deleted profile", () => {
const result = removeProxyProfileReferences("proxy-1", {
hosts: [
host({ id: "host-1", proxyProfileId: "proxy-1" }),
host({ id: "host-2", proxyProfileId: "proxy-2" }),
],
groupConfigs: [
{ path: "prod", proxyProfileId: "proxy-1" },
{ path: "dev", proxyProfileId: "proxy-2" },
],
});
assert.equal(result.hosts[0].proxyProfileId, undefined);
assert.equal(result.hosts[1].proxyProfileId, "proxy-2");
assert.equal(result.groupConfigs[0].proxyProfileId, undefined);
assert.equal(result.groupConfigs[1].proxyProfileId, "proxy-2");
});
test("normalizeManualProxyConfig clears empty proxy drafts", () => {
assert.equal(
normalizeManualProxyConfig({ type: "http", host: "", port: 8080 }),
undefined,
);
});
test("isCompleteProxyConfig requires host and a valid port", () => {
assert.equal(isCompleteProxyConfig({ type: "http", host: "", port: 8080 }), false);
assert.equal(isCompleteProxyConfig({ type: "http", host: "proxy.example.com", port: 0 }), false);
assert.equal(isCompleteProxyConfig({ type: "http", host: "proxy.example.com", port: 3128 }), true);
});

77
domain/proxyProfiles.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "./models";
const cloneProxyConfig = (config: ProxyConfig): ProxyConfig => ({
...config,
});
export const isValidProxyPort = (port: unknown): boolean => {
const value = Number(port);
return Number.isInteger(value) && value >= 1 && value <= 65535;
};
export const isEmptyProxyConfigDraft = (config: ProxyConfig | undefined): boolean => {
if (!config) return true;
return !config.host.trim() && !config.username?.trim() && !config.password?.trim();
};
export const isCompleteProxyConfig = (config: ProxyConfig | undefined): boolean => {
return Boolean(config?.host.trim()) && isValidProxyPort(config?.port);
};
export const normalizeManualProxyConfig = (
config: ProxyConfig | undefined,
): ProxyConfig | undefined => {
if (!config || isEmptyProxyConfigDraft(config)) return undefined;
return {
...config,
host: config.host.trim(),
username: config.username?.trim() || undefined,
password: config.password || undefined,
};
};
export function findProxyProfile(
proxyProfileId: string | undefined,
proxyProfiles: ProxyProfile[],
): ProxyProfile | undefined {
if (!proxyProfileId) return undefined;
return proxyProfiles.find((profile) => profile.id === proxyProfileId);
}
export function materializeHostProxyProfile<T extends Host>(
host: T,
proxyProfiles: ProxyProfile[],
): T {
if (host.proxyConfig || !host.proxyProfileId) return host;
const profile = findProxyProfile(host.proxyProfileId, proxyProfiles);
if (!profile) return host;
return {
...host,
proxyConfig: cloneProxyConfig(profile.config),
};
}
const clearProxyProfileId = <T extends { proxyProfileId?: string }>(
item: T,
proxyProfileId: string,
): T => {
if (item.proxyProfileId !== proxyProfileId) return item;
const { proxyProfileId: _proxyProfileId, ...rest } = item;
return rest as T;
};
export function removeProxyProfileReferences(
proxyProfileId: string,
data: {
hosts: Host[];
groupConfigs: GroupConfig[];
},
): {
hosts: Host[];
groupConfigs: GroupConfig[];
} {
return {
hosts: data.hosts.map((host) => clearProxyProfileId(host, proxyProfileId)),
groupConfigs: data.groupConfigs.map((config) => clearProxyProfileId(config, proxyProfileId)),
};
}

View File

@@ -164,6 +164,7 @@ export interface SyncPayload {
hosts: import('./models').Host[];
keys: import('./models').SSHKey[];
identities?: import('./models').Identity[];
proxyProfiles?: import('./models').ProxyProfile[];
snippets: import('./models').Snippet[];
customGroups: string[];
snippetPackages?: string[];

View File

@@ -156,6 +156,26 @@ test("only non-hosts entity shrinks → reports that entity", () => {
}
});
test("proxy profile shrink is protected like other synced vault entities", () => {
const proxyProfiles = (n: number) =>
Array.from({ length: n }, (_, i) => ({
id: `proxy-${i}`,
label: `Proxy ${i}`,
config: { type: "http", host: `proxy-${i}.example.com`, port: 3128 },
createdAt: i,
}));
const base = payload({ proxyProfiles: proxyProfiles(10) } as Partial<SyncPayload>);
const out = payload({ proxyProfiles: [] } as Partial<SyncPayload>);
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) {
assert.equal(result.entityType, "proxyProfiles");
assert.equal(result.reason, "large-shrink");
}
});
test("knownHosts shrink is ignored because known hosts are local-only", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });

View File

@@ -9,6 +9,7 @@ export type ShrinkFinding =
| 'hosts'
| 'keys'
| 'identities'
| 'proxyProfiles'
| 'snippets'
| 'customGroups'
| 'snippetPackages'
@@ -28,6 +29,7 @@ const CHECKED_ENTITIES = [
'hosts',
'keys',
'identities',
'proxyProfiles',
'snippets',
'customGroups',
'snippetPackages',

View File

@@ -26,8 +26,9 @@ const knownHosts = (n: number): SyncPayload["knownHosts"] =>
hostname: `host-${i}.example.com`,
port: 22,
keyType: "ssh-ed25519",
fingerprint: `SHA256:${i}`,
})) as SyncPayload["knownHosts"];
publicKey: `SHA256:${i}`,
discoveredAt: 1,
}));
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
const result = mergeSyncPayloads(
@@ -38,3 +39,100 @@ test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
assert.equal("knownHosts" in result.payload, false);
});
test("mergeSyncPayloads merges reusable proxy profiles by id", () => {
const localProfile = {
id: "proxy-local",
label: "Local Proxy",
config: { type: "http", host: "local.example.com", port: 3128 },
createdAt: 1,
updatedAt: 1,
};
const remoteProfile = {
id: "proxy-remote",
label: "Remote Proxy",
config: { type: "socks5", host: "remote.example.com", port: 1080 },
createdAt: 2,
updatedAt: 2,
};
const result = mergeSyncPayloads(
payload(),
payload({ proxyProfiles: [localProfile] } as Partial<SyncPayload>),
payload({ proxyProfiles: [remoteProfile] } as Partial<SyncPayload>),
);
assert.deepEqual(result.payload.proxyProfiles?.map((item) => item.id).sort(), [
"proxy-local",
"proxy-remote",
]);
});
test("mergeSyncPayloads preserves proxy profiles when remote payload predates them", () => {
const proxy = {
id: "proxy-1",
label: "Office Proxy",
config: { type: "http", host: "proxy.example.com", port: 3128 },
createdAt: 1,
};
const result = mergeSyncPayloads(
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
payload(),
);
assert.deepEqual(result.payload.proxyProfiles, [proxy]);
});
test("mergeSyncPayloads keeps missing proxy references visible to connection guards", () => {
const result = mergeSyncPayloads(
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [{
id: "proxy-1",
label: "Old Proxy",
config: { type: "http", host: "old.example.com", port: 3128 },
createdAt: 1,
}],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
payload({
hosts: [{
id: "host-1",
label: "Host",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
proxyProfileId: "proxy-1",
}],
proxyProfiles: [],
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
}),
);
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
});

View File

@@ -344,6 +344,7 @@ export function mergeSyncPayloads(
hosts: [],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
snippetPackages: [],
@@ -363,6 +364,12 @@ export function mergeSyncPayloads(
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const baseProxyProfiles = b.proxyProfiles ?? [];
const proxyProfiles = mergeEntityArrays(
baseProxyProfiles,
local.proxyProfiles ?? baseProxyProfiles,
remote.proxyProfiles ?? baseProxyProfiles,
);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
@@ -376,11 +383,16 @@ export function mergeSyncPayloads(
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
const baseGroupConfigs = b.groupConfigs ?? [];
const groupConfigsResult = mergeEntityArrays(
wrapGC(baseGroupConfigs),
wrapGC(local.groupConfigs ?? baseGroupConfigs),
wrapGC(remote.groupConfigs ?? baseGroupConfigs),
);
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, snippets, portForwardingRules, groupConfigsResult];
[hosts, keys, identities, proxyProfiles, snippets, portForwardingRules, groupConfigsResult];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -416,15 +428,18 @@ export function mergeSyncPayloads(
});
}
const groupConfigs = unwrapGC(groupConfigsResult.merged);
const payload: SyncPayload = {
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
proxyProfiles: proxyProfiles.merged,
snippets: snippets.merged,
customGroups,
snippetPackages,
portForwardingRules: portForwardingRules.merged,
groupConfigs: unwrapGC(groupConfigsResult.merged),
groupConfigs,
settings,
syncedAt: Date.now(),
};

View File

@@ -168,6 +168,29 @@ function shouldUseShellForCommand(command) {
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
}
function quoteWindowsShellArg(value) {
const arg = String(value ?? "");
if (!arg) return "\"\"";
return `"${arg.replace(/"/g, '\\"')}"`;
}
function buildWindowsShellCommandLine(command, args) {
return [command, ...(args || [])].map(quoteWindowsShellArg).join(" ");
}
function prepareCommandForSpawn(command, args) {
const spawnArgs = Array.isArray(args) ? args : [];
if (!shouldUseShellForCommand(command)) {
return { command, args: spawnArgs, shell: false };
}
return {
command: buildWindowsShellCommandLine(command, spawnArgs),
args: [],
shell: true,
};
}
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
@@ -380,6 +403,9 @@ module.exports = {
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
quoteWindowsShellArg,
buildWindowsShellCommandLine,
prepareCommandForSpawn,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,

View File

@@ -2,10 +2,12 @@ const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildWindowsShellCommandLine,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isPlausibleCliVersionOutput,
prepareCommandForSpawn,
trackSessionIdlePrompt,
} = require("./shellUtils.cjs");
@@ -76,6 +78,30 @@ test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
});
test("buildWindowsShellCommandLine quotes command paths and args with spaces", () => {
assert.equal(
buildWindowsShellCommandLine("C:\\Program Files\\Codex\\codex.cmd", ["login", "status"]),
"\"C:\\Program Files\\Codex\\codex.cmd\" \"login\" \"status\"",
);
});
test("prepareCommandForSpawn wraps Windows cmd shims as a single shell command", () => {
const result = prepareCommandForSpawn("C:\\Program Files\\Codex\\codex.cmd", ["--version"]);
if (process.platform === "win32") {
assert.deepEqual(result, {
command: "\"C:\\Program Files\\Codex\\codex.cmd\" \"--version\"",
args: [],
shell: true,
});
} else {
assert.deepEqual(result, {
command: "C:\\Program Files\\Codex\\codex.cmd",
args: ["--version"],
shell: false,
});
}
});
test("tracks PowerShell idle prompt after SSH output", () => {
const session = {};

View File

@@ -26,7 +26,7 @@ const {
const {
stripAnsi,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
prepareCommandForSpawn,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
isPlausibleCliVersionOutput,
@@ -1392,11 +1392,12 @@ function registerHandlers(ipcMain) {
async function runCommand(command, args, options) {
return await new Promise((resolve, reject) => {
const child = spawn(command, args || [], {
const spawnSpec = prepareCommandForSpawn(command, args || []);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
cwd: options?.cwd || undefined,
env: options?.env || process.env,
shell: shouldUseShellForCommand(command),
shell: spawnSpec.shell,
windowsHide: true,
});
@@ -2006,10 +2007,11 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
const sessionId = `codex_login_${randomUUID()}`;
const child = spawn(codexCliPath, ["login"], {
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
const child = spawn(spawnSpec.command, spawnSpec.args, {
stdio: ["ignore", "pipe", "pipe"],
env: shellEnv,
shell: shouldUseShellForCommand(codexCliPath),
shell: spawnSpec.shell,
windowsHide: true,
});

View File

@@ -4,6 +4,7 @@ const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const { prepareCommandForSpawn } = require("./ai/shellUtils.cjs");
function createIpcMainStub() {
const handlers = new Map();
@@ -116,6 +117,10 @@ function loadBridgeWithMocks(options = {}) {
? options.normalizeCliPathForPlatform(...args)
: args[0],
shouldUseShellForCommand: () => false,
prepareCommandForSpawn: (...args) =>
typeof options.prepareCommandForSpawn === "function"
? options.prepareCommandForSpawn(...args)
: prepareCommandForSpawn(...args),
isPlausibleCliVersionOutput: (value) =>
typeof options.isPlausibleCliVersionOutput === "function"
? options.isPlausibleCliVersionOutput(value)
@@ -532,6 +537,128 @@ test("resolve-cli accepts stored bundled Codex ACP path", async (t) => {
}
});
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex.cmd");
fs.writeFileSync(
codexPath,
"@echo off\r\necho codex-cli 1.2.3\r\n",
"utf8",
);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
assert.deepEqual(result, {
path: codexPath,
version: "codex-cli 1.2.3",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli probes Windows Claude cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, "claude.cmd");
fs.writeFileSync(
claudePath,
"@echo off\r\necho 2.1.123 (Claude Code)\r\n",
"utf8",
);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
version: "2.1.123 (Claude Code)",
available: true,
});
} finally {
restore();
}
});
test("resolve-cli probes Windows Claude exe paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude exe resolve "));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const claudePath = path.join(tempDir, "claude.exe");
fs.copyFileSync(process.execPath, claudePath);
const { bridge, restore } = loadBridgeWithMocks({
prepareCommandForSpawn,
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
assert.equal(typeof resolveHandler, "function");
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
assert.deepEqual(result, {
path: claudePath,
version: process.version,
available: true,
});
} finally {
restore();
}
});
test("resolve-cli falls back to bundled Codex ACP when a stored path is stale", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stale-"));
t.after(() => {

View File

@@ -124,10 +124,12 @@ function flushBuffer(entry) {
}
}
function renderSnapshotContent(entry) {
function renderSnapshotContent(entry, { finalize = false } = {}) {
if (finalize) entry.renderer.finish();
const renderOptions = finalize ? undefined : { includePendingClearedScreen: true };
return entry.isHtml
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(), entry.hostLabel, entry.startTime)
: entry.renderer.toString();
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(renderOptions), entry.hostLabel, entry.startTime)
: entry.renderer.toString(renderOptions);
}
function scheduleSnapshot(entry) {
@@ -206,9 +208,9 @@ async function stopStream(sessionId) {
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
} else if (!entry.disabled && entry.snapshotDirty) {
} else if (!entry.disabled) {
try {
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8");
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry, { finalize: true }), "utf8");
entry.snapshotDirty = false;
} catch (err) {
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);

View File

@@ -0,0 +1,83 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
appendData,
startStream,
stopStream,
} = require("./sessionLogStreamManager.cjs");
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
appendData(sessionId, "before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
const filePath = await waitForFileContent(directory, "before tui\n\nframe one\n\nframe two");
assert.equal(fs.readFileSync(filePath, "utf8"), "before tui\n\nframe one\n\nframe two");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
test("txt stream finalization commits pending ED2 cleared screens", async () => {
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
try {
startStream(sessionId, {
hostLabel: "host",
hostname: "host.example",
directory,
format: "txt",
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
});
appendData(sessionId, "before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
const filePath = await stopStream(sessionId);
assert.equal(fs.readFileSync(filePath, "utf8"), "before tui\n\nframe one\n\nframe two");
} finally {
await stopStream(sessionId);
fs.rmSync(directory, { recursive: true, force: true });
}
});
async function waitForFileContent(directory, expectedContent) {
const deadline = Date.now() + 3000;
let lastContent = "";
while (Date.now() < deadline) {
const filePath = findFirstTxtFile(directory);
if (filePath && fs.existsSync(filePath)) {
lastContent = fs.readFileSync(filePath, "utf8");
if (lastContent === expectedContent) return filePath;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.fail(`Timed out waiting for live snapshot content. Last content: ${JSON.stringify(lastContent)}`);
}
function findFirstTxtFile(directory) {
if (!fs.existsSync(directory)) return null;
for (const hostDirName of fs.readdirSync(directory)) {
const hostDir = path.join(directory, hostDirName);
if (!fs.statSync(hostDir).isDirectory()) continue;
const fileName = fs.readdirSync(hostDir).find((name) => name.endsWith(".txt"));
if (fileName) return path.join(hostDir, fileName);
}
return null;
}

View File

@@ -4,7 +4,12 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { addBundledMoshDllPath, resolveBareMoshClient } = require("./terminalBridge.cjs");
const {
addBundledMoshDllPath,
addBundledMoshRuntimeEnv,
addBundledMoshTerminfoEnv,
resolveBareMoshClient,
} = require("./terminalBridge.cjs");
function makeTmp() {
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resolve-"));
@@ -120,6 +125,102 @@ test("Windows dev mosh-client updates the PATH key used by child process env", (
assert.equal(Object.prototype.hasOwnProperty.call(env, "Path"), false);
});
test("Linux mosh-client prefers a sibling bundled terminfo dir", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
const terminfo = path.join(tmp, "resources", "mosh", "linux-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
assert.equal(env.TERMINFO, terminfo);
const dirs = env.TERMINFO_DIRS.split(":");
assert.equal(dirs[0], terminfo);
assert.ok(dirs.includes("/usr/share/terminfo"));
});
test("Linux mosh-client falls back to distro paths when no bundle present", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
writeExecutable(client);
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
assert.equal(env.TERMINFO, undefined);
const dirs = env.TERMINFO_DIRS.split(":");
assert.ok(dirs.includes("/etc/terminfo"));
assert.ok(dirs.includes("/lib/terminfo"));
assert.ok(dirs.includes("/usr/share/terminfo"));
});
test("Linux mosh-client merges caller-supplied TERMINFO_DIRS between bundle and system defaults", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
const terminfo = path.join(tmp, "resources", "mosh", "linux-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
const env = { TERMINFO_DIRS: "/home/user/.terminfo" };
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
const dirs = env.TERMINFO_DIRS.split(":");
assert.equal(dirs[0], terminfo);
assert.equal(dirs[1], "/home/user/.terminfo");
assert.ok(dirs.includes("/usr/share/terminfo"));
});
test("Darwin mosh-client uses macOS-aware terminfo search paths", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "darwin-universal", "mosh-client");
writeExecutable(client);
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "darwin" });
const dirs = env.TERMINFO_DIRS.split(":");
assert.ok(dirs.includes("/usr/share/terminfo"));
assert.ok(dirs.includes("/opt/homebrew/share/terminfo"));
});
test("Windows mosh-client points ncurses at bundled terminfo", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const terminfo = path.join(tmp, "resources", "mosh", "win32-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
const env = {};
addBundledMoshTerminfoEnv(env, client, { platform: "win32" });
assert.equal(env.TERMINFO, terminfo);
assert.equal(env.TERMINFO_DIRS, terminfo);
});
test("Windows mosh runtime env includes DLL path and terminfo", () => {
const tmp = makeTmp();
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
const terminfo = path.join(tmp, "resources", "mosh", "win32-x64", "terminfo");
writeExecutable(client);
fs.mkdirSync(dllDir, { recursive: true });
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
fs.mkdirSync(path.join(terminfo, "78"), { recursive: true });
fs.writeFileSync(path.join(terminfo, "78", "xterm-256color"), "terminfo");
const env = { Path: "C:\\Windows\\System32" };
addBundledMoshRuntimeEnv(env, client, { platform: "win32", arch: "x64" });
assert.equal(env.Path.split(";")[0], dllDir);
assert.equal(env.TERMINFO, terminfo);
assert.equal(env.TERMINFO_DIRS, terminfo);
});
test("removed Mosh client detection APIs are not exposed to the renderer", () => {
const bridgeSource = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
const preloadSource = fs.readFileSync(path.join(__dirname, "..", "preload.cjs"), "utf8");

View File

@@ -7,7 +7,9 @@ const os = require("node:os");
const fs = require("node:fs");
const net = require("node:net");
const { randomUUID } = require("node:crypto");
const { execFile } = require("node:child_process");
const path = require("node:path");
const { promisify } = require("node:util");
const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
@@ -20,6 +22,9 @@ const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
const { discoverShells } = require("./shellDiscovery.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const execFileAsync = promisify(execFile);
// Shared references
let sessions = null;
@@ -856,27 +861,234 @@ function addBundledMoshDllPath(env, bareClient, opts = {}) {
return dllDir ? prependEnvPath(env, dllDir, opts) : env;
}
function createMoshSshPasswordResponder(sshPty, password) {
if (typeof password !== "string" || password.length === 0) {
function findBundledMoshTerminfoDir(bareClient, _opts = {}) {
if (!bareClient) return null;
const terminfoDir = path.join(path.dirname(bareClient), "terminfo");
const hasXterm256 =
fs.existsSync(path.join(terminfoDir, "x", "xterm-256color")) ||
fs.existsSync(path.join(terminfoDir, "78", "xterm-256color"));
return hasXterm256 ? terminfoDir : null;
}
// Standard locations where distros / package managers install the compiled
// terminfo database. Used as a fallback only — the bundled directory ships
// with the mosh release and is preferred. See issue #890 for context.
const LINUX_SYSTEM_TERMINFO_DIRS = [
"/etc/terminfo",
"/lib/terminfo",
"/usr/share/terminfo",
"/usr/lib/terminfo",
];
const DARWIN_SYSTEM_TERMINFO_DIRS = [
"/usr/share/terminfo",
"/opt/homebrew/share/terminfo",
"/usr/local/share/terminfo",
"/opt/local/share/terminfo",
];
function addBundledMoshTerminfoEnv(env, bareClient, opts = {}) {
const platform = opts.platform || process.platform;
const terminfoDir = findBundledMoshTerminfoDir(bareClient, opts);
if (platform === "win32") {
if (!terminfoDir) return env;
env.TERMINFO = terminfoDir;
env.TERMINFO_DIRS = terminfoDir;
return env;
}
// POSIX. The bundled terminfo is the source of truth — our static
// ncurses' compiled-in default points at a build-time temp dir that no
// longer exists on the user's machine. Fall back to standard distro
// paths when the bundle is absent (e.g. running against an older mosh
// binary release that pre-dates the bundle). A caller-supplied
// TERMINFO_DIRS is preserved between the bundle and the system defaults.
const existing = (typeof env.TERMINFO_DIRS === "string" && env.TERMINFO_DIRS.length > 0)
? env.TERMINFO_DIRS.split(":").filter(Boolean)
: [];
const systemDirs = platform === "darwin" ? DARWIN_SYSTEM_TERMINFO_DIRS : LINUX_SYSTEM_TERMINFO_DIRS;
const dirs = [];
if (terminfoDir) dirs.push(terminfoDir);
for (const dir of existing) {
if (!dirs.includes(dir)) dirs.push(dir);
}
for (const dir of systemDirs) {
if (!dirs.includes(dir)) dirs.push(dir);
}
if (terminfoDir) {
env.TERMINFO = terminfoDir;
}
env.TERMINFO_DIRS = dirs.join(":");
return env;
}
function addBundledMoshRuntimeEnv(env, bareClient, opts = {}) {
addBundledMoshDllPath(env, bareClient, opts);
addBundledMoshTerminfoEnv(env, bareClient, opts);
return env;
}
function createMoshSshPasswordResponder(sshPty, password, passphrase) {
if (
(typeof password !== "string" || password.length === 0) &&
(typeof passphrase !== "string" || passphrase.length === 0)
) {
return () => {};
}
let answered = false;
let answeredPassword = false;
let answeredPassphrase = false;
let tail = "";
return (chunk) => {
if (answered) return;
if (answeredPassword && answeredPassphrase) return;
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
if (!text) return;
tail = (tail + text).slice(-512);
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && /(^|[\r\n]).*passphrase.*:\s*$/i.test(tail)) {
answeredPassphrase = true;
sshPty.write(`${passphrase}\r`);
return;
}
if (typeof password !== "string" || password.length === 0 || answeredPassword) return;
if (!/(^|[\r\n]).*password:\s*$/i.test(tail)) return;
answered = true;
answeredPassword = true;
sshPty.write(`${password}\r`);
};
}
function normalizeMoshIdentityPath(keyPath) {
if (typeof keyPath !== "string") return null;
const trimmed = keyPath.trim();
if (!trimmed) return null;
if (trimmed === "~") return os.homedir();
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
return trimmed;
}
function safeMoshAuthFileName(sessionId, keyId, suffix) {
const safeId = String(keyId || sessionId || randomUUID())
.replace(/[^a-zA-Z0-9_-]/g, "_")
.slice(0, 80);
return `mosh-auth-${safeId}-${randomUUID()}${suffix}`;
}
async function writeMoshAuthTempFile(fileName, content) {
const target = tempDirBridge.getTempFilePath(fileName);
const normalized = content.endsWith("\n") ? content : `${content}\n`;
let created = false;
try {
const handle = await fs.promises.open(target, "wx", 0o600);
created = true;
await handle.close();
await restrictMoshAuthFilePermissions(target, { failClosed: true });
await fs.promises.writeFile(target, normalized, { flag: "w", mode: 0o600 });
try {
await fs.promises.chmod(target, 0o600);
} catch {
// Best effort on Windows; ACL hardening above is the security boundary.
}
} catch (err) {
if (created) cleanupMoshAuthTempFiles([target]);
throw err;
}
return target;
}
async function restrictMoshAuthFilePermissions(target, opts = {}) {
if (process.platform !== "win32") return true;
let username = process.env.USERNAME;
if (!username) {
try {
username = os.userInfo().username;
} catch {
username = "";
}
}
if (!username) {
if (opts.failClosed) {
throw new Error("Failed to restrict private key ACLs: unable to resolve current Windows user");
}
return false;
}
const identities = [];
if (process.env.USERDOMAIN) identities.push(`${process.env.USERDOMAIN}\\${username}`);
identities.push(username);
let lastError = null;
for (const identity of identities) {
try {
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
await execFileAsync("icacls.exe", [target, "/inheritance:r"], { windowsHide: true });
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
return true;
} catch (err) {
lastError = err;
}
}
const message = lastError?.message || String(lastError || "unknown error");
if (opts.failClosed) {
throw new Error(`Failed to restrict private key ACLs: ${message}`);
}
console.warn("[Mosh] Failed to restrict private key ACLs:", message);
return false;
}
function cleanupMoshAuthTempFiles(files) {
for (const file of files || []) {
try {
fs.unlinkSync(file);
} catch {
// Best effort cleanup; Settings > System can clear Netcatty temp files.
}
}
}
async function buildMoshSshAuthArgs(options, sessionId) {
const sshArgs = [];
const tempFiles = [];
try {
if (typeof options.privateKey === "string" && options.privateKey.trim().length > 0) {
const keyPath = await writeMoshAuthTempFile(
safeMoshAuthFileName(sessionId, options.keyId, ".pem"),
options.privateKey,
);
tempFiles.push(keyPath);
sshArgs.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
if (typeof options.certificate === "string" && options.certificate.trim().length > 0) {
const certPath = await writeMoshAuthTempFile(
safeMoshAuthFileName(sessionId, options.keyId, "-cert.pub"),
options.certificate,
);
tempFiles.push(certPath);
sshArgs.push("-o", `CertificateFile=${certPath}`);
}
} else if (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length > 0) {
for (const keyPath of options.identityFilePaths) {
const normalized = normalizeMoshIdentityPath(keyPath);
if (normalized) sshArgs.push("-i", normalized);
}
if (sshArgs.length > 0) {
sshArgs.push("-o", "IdentitiesOnly=yes");
}
}
} catch (err) {
cleanupMoshAuthTempFiles(tempFiles);
throw err;
}
return { sshArgs, tempFiles };
}
/**
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
* user's terminal PTY* so password / 2FA / known-hosts prompts render
@@ -906,6 +1118,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
const rows = options.rows || 24;
const optionsEnv = options.env || {};
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
const moshAuth = await buildMoshSshAuthArgs(options, sessionId);
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
host: options.hostname,
@@ -913,20 +1126,34 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
username: options.username,
lang,
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
sshArgs: moshAuth.sshArgs,
});
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
// macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
// locale name). System ssh_config has `SendEnv LC_*`, so without scrubbing
// these the remote shell tries to setlocale("UTF-8") and prints a warning
// on every connection. mosh-server sets the locale it needs separately.
for (const key of Object.keys(sshEnv)) {
if (key.startsWith("LC_")) delete sshEnv[key];
}
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
}
const sshPty = pty.spawn(sshExe, sshArgs, {
cols,
rows,
env: sshEnv,
cwd: os.homedir(),
encoding: null,
});
let sshPty;
try {
sshPty = pty.spawn(sshExe, sshArgs, {
cols,
rows,
env: sshEnv,
cwd: os.homedir(),
encoding: null,
});
} catch (err) {
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
throw err;
}
const session = {
proc: sshPty,
@@ -947,6 +1174,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
rows,
moshHandshakePhase: "ssh",
moshHandshakeResult: null,
moshAuthTempFiles: moshAuth.tempFiles,
};
sessions.set(sessionId, session);
@@ -967,7 +1195,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
session.flushPendingData = flush;
const sniffer = moshHandshake.createMoshConnectSniffer();
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password);
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password, options.passphrase);
// Forward bytes from the ssh PTY to the renderer, redacting the
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
@@ -991,8 +1219,10 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
sshPty.onExit(({ exitCode, signal }) => {
if (sessions.get(sessionId) !== session || session.closed) {
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
return;
}
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
try {
@@ -1052,7 +1282,7 @@ function swapToMoshClient(session, options, ctx) {
key: parsed.key,
lang,
});
addBundledMoshDllPath(env, bareClient);
addBundledMoshRuntimeEnv(env, bareClient);
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
}
@@ -1402,6 +1632,8 @@ function closeSession(event, payload) {
}
} catch (err) {
console.warn("Close failed", err);
} finally {
cleanupMoshAuthTempFiles(session.moshAuthTempFiles);
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
@@ -1627,6 +1859,8 @@ module.exports = {
bundledMoshClient,
resolveBareMoshClient,
addBundledMoshDllPath,
addBundledMoshTerminfoEnv,
addBundledMoshRuntimeEnv,
startSerialSession,
listSerialPorts,
writeToSession,

View File

@@ -219,6 +219,98 @@ test("startMoshSession writes the saved password when ssh prompts for one", asyn
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
});
test("startMoshSession passes vault private keys to ssh via a temp identity file", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
keyId: "key-1",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
password: "wrong-password",
},
{ moshClientLookup: h.lookupOpts },
);
const keyFlagIndex = h.spawns[0].args.indexOf("-i");
assert.notEqual(keyFlagIndex, -1);
const keyPath = h.spawns[0].args[keyFlagIndex + 1];
assert.equal(fs.existsSync(keyPath), true);
assert.equal(h.spawns[0].args.includes("IdentitiesOnly=yes"), true);
assert.equal(h.spawns[0].args.includes("alice@example.com"), true);
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(keyPath), false);
});
test("startMoshSession uses unique temp identity files for concurrent sessions with the same key", async (t) => {
const h = makeHarness(t);
const authOptions = {
keyId: "key-1",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
};
await h.bridge.startMoshSession(
h.event,
{ ...h.options, ...authOptions },
{ moshClientLookup: h.lookupOpts },
);
await h.bridge.startMoshSession(
h.event,
{ ...h.options, ...authOptions, sessionId: "mosh-test-session-2" },
{ moshClientLookup: h.lookupOpts },
);
const firstKeyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
const secondKeyPath = h.spawns[1].args[h.spawns[1].args.indexOf("-i") + 1];
assert.notEqual(firstKeyPath, secondKeyPath);
assert.equal(fs.existsSync(firstKeyPath), true);
assert.equal(fs.existsSync(secondKeyPath), true);
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(firstKeyPath), false);
assert.equal(fs.existsSync(secondKeyPath), true);
h.spawns[1].emitExit({ exitCode: 255, signal: 0 });
assert.equal(fs.existsSync(secondKeyPath), false);
});
test("closeSession removes Mosh temp identity files even before ssh exits", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
keyId: "key-1",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
},
{ moshClientLookup: h.lookupOpts },
);
const keyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
assert.equal(fs.existsSync(keyPath), true);
h.bridge.closeSession(h.event, { sessionId: "mosh-test-session" });
assert.equal(fs.existsSync(keyPath), false);
});
test("startMoshSession writes the saved passphrase when ssh prompts for the temp key", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(
h.event,
{
...h.options,
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
passphrase: "key-passphrase",
},
{ moshClientLookup: h.lookupOpts },
);
h.spawns[0].emitData("Enter passphrase for key 'mosh-auth-key-1.pem':");
assert.deepEqual(h.spawns[0].writes, ["key-passphrase\r"]);
});
test("startMoshSession handshake path sends the existing exit event after mosh-client exits", async (t) => {
const h = makeHarness(t);
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });

View File

@@ -36,9 +36,14 @@ class TerminalTextRenderer {
this.lines = [[]];
this.row = 0;
this.col = 0;
this.screenBaseRow = 0;
this.state = "normal";
this.escapeBuffer = "";
this.style = createDefaultStyle();
this.cursorMovedHomeByCsi = false;
this.justStartedLogScreen = false;
this.hasPreservedScreenHistory = false;
this.pendingClearedScreen = null;
}
feed(input) {
@@ -52,18 +57,21 @@ class TerminalTextRenderer {
finish() {
this.state = "normal";
this.escapeBuffer = "";
this.#commitPendingClearedScreen();
return this.toString();
}
toString() {
return this.lines
toString({ includePendingClearedScreen = false } = {}) {
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
return lines
.map((line) => line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""))
.join("\n")
.replace(/\n+$/g, "");
}
toHtmlContent() {
return this.lines
toHtmlContent({ includePendingClearedScreen = false } = {}) {
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
return lines
.map((line) => renderLineHtml(line))
.join("\n")
.replace(/\n+$/g, "");
@@ -109,10 +117,12 @@ class TerminalTextRenderer {
break;
case "\r":
this.col = 0;
this.cursorMovedHomeByCsi = false;
break;
case "\n":
this.row += 1;
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "\t":
@@ -156,33 +166,40 @@ class TerminalTextRenderer {
switch (final) {
case "A":
this.row = Math.max(0, this.row - n);
this.row = Math.max(this.screenBaseRow, this.row - n);
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "B":
case "E":
this.row += n;
if (final === "E") this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "C":
this.col += n;
this.cursorMovedHomeByCsi = false;
break;
case "D":
this.col = Math.max(0, this.col - n);
this.cursorMovedHomeByCsi = false;
break;
case "F":
this.row = Math.max(0, this.row - n);
this.row = Math.max(this.screenBaseRow, this.row - n);
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.#ensureLine();
break;
case "G":
this.col = Math.max(0, n - 1);
this.cursorMovedHomeByCsi = false;
break;
case "H":
case "f":
this.row = Math.max(0, (values[0] || 1) - 1);
this.row = this.screenBaseRow + Math.max(0, (values[0] || 1) - 1);
this.col = Math.max(0, (values[1] || 1) - 1);
this.cursorMovedHomeByCsi = this.row === this.screenBaseRow && this.col === 0;
this.#ensureLine();
break;
case "J":
@@ -262,6 +279,8 @@ class TerminalTextRenderer {
line[this.col] = createCell(ch, this.style);
this.col += 1;
}
this.cursorMovedHomeByCsi = false;
this.justStartedLogScreen = false;
}
#eraseLine(mode) {
@@ -282,22 +301,119 @@ class TerminalTextRenderer {
#eraseDisplay(mode) {
this.#ensureLine();
if (mode === 2 || mode === 3) {
this.lines = [[]];
this.row = 0;
this.col = 0;
if (mode === 3 && this.pendingClearedScreen) {
this.#commitPendingClearedScreen();
return;
}
if (mode === 3) {
this.pendingClearedScreen = null;
return;
}
if (mode === 2) {
if (this.hasPreservedScreenHistory) {
this.#clearCurrentLogScreen({ keepPending: true });
return;
}
this.#startNewLogScreen();
return;
}
if (mode === 1) {
this.lines = this.lines.slice(this.row);
this.row = 0;
for (let i = this.screenBaseRow; i < this.row; i += 1) {
this.lines[i] = [];
}
this.#eraseLine(1);
return;
}
if (
this.row === this.screenBaseRow &&
this.col === 0 &&
this.cursorMovedHomeByCsi &&
!this.hasPreservedScreenHistory
) {
this.#startNewLogScreen();
return;
}
this.#eraseLine(0);
this.lines.length = this.row + 1;
}
#clearCurrentLogScreen({ keepPending = false } = {}) {
const targetRow = this.row;
if (keepPending && this.#currentLogScreenHasContent()) {
this.pendingClearedScreen = {
lines: cloneLines(this.lines.slice(this.screenBaseRow)),
baseRow: this.screenBaseRow,
};
} else if (!keepPending) {
this.pendingClearedScreen = null;
}
for (let i = this.screenBaseRow; i < this.lines.length; i += 1) {
this.lines[i] = [];
}
this.row = Math.max(this.screenBaseRow, targetRow);
this.#ensureLine();
this.cursorMovedHomeByCsi = false;
this.justStartedLogScreen = true;
}
#commitPendingClearedScreen() {
const pending = this.pendingClearedScreen;
if (!pending) return;
const relativeRow = Math.max(0, this.row - pending.baseRow);
const col = this.col;
const { lines, screenBaseRow } = this.#buildLinesWithPendingClearedScreen(pending);
this.lines = lines;
this.screenBaseRow = screenBaseRow;
this.row = this.screenBaseRow + relativeRow;
this.col = col;
this.#ensureLine();
this.cursorMovedHomeByCsi = false;
this.justStartedLogScreen = true;
this.hasPreservedScreenHistory = true;
this.pendingClearedScreen = null;
}
#linesWithPendingClearedScreen() {
const pending = this.pendingClearedScreen;
if (!pending) return this.lines;
return this.#buildLinesWithPendingClearedScreen(pending).lines;
}
#buildLinesWithPendingClearedScreen(pending) {
const prefix = this.lines.slice(0, pending.baseRow);
const activeLines = this.lines.slice(pending.baseRow);
const pendingLines = trimTrailingBlankLines(pending.lines);
return {
lines: prefix.concat(pendingLines, [[]], activeLines.length > 0 ? activeLines : [[]]),
screenBaseRow: prefix.length + pendingLines.length + 1,
};
}
#startNewLogScreen() {
if (this.justStartedLogScreen) return;
const hasContent = this.lines.some((line) => getTrimmedLineLength(line) > 0);
if (hasContent) {
this.lines.push([]);
this.row = this.lines.length - 1;
} else {
this.lines = [[]];
this.row = 0;
}
this.screenBaseRow = this.row;
this.col = 0;
this.cursorMovedHomeByCsi = false;
this.justStartedLogScreen = true;
this.hasPreservedScreenHistory = hasContent;
this.pendingClearedScreen = null;
}
#currentLogScreenHasContent() {
for (let i = this.screenBaseRow; i < this.lines.length; i += 1) {
if (getTrimmedLineLength(this.lines[i]) > 0) return true;
}
return false;
}
#ensureLine() {
while (this.lines.length <= this.row) this.lines.push([]);
}
@@ -351,6 +467,18 @@ function createCell(ch, style) {
};
}
function cloneLines(lines) {
return lines.map((line) => line.map((cell) => (cell ? createCell(cell.ch, cell.style) : cell)));
}
function trimTrailingBlankLines(lines) {
let length = lines.length;
while (length > 0 && getTrimmedLineLength(lines[length - 1]) === 0) {
length -= 1;
}
return lines.slice(0, length);
}
function renderLineHtml(line) {
let html = "";
let runText = "";

View File

@@ -0,0 +1,153 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createTerminalTextRenderer,
terminalDataToPlainText,
} = require("./terminalLogSanitizer.cjs");
const { terminalDataToHtml } = require("./sessionLogsBridge.cjs");
test("plain text rendering applies backspace edits", () => {
assert.equal(terminalDataToPlainText("hellp\bo\n"), "hello");
});
test("plain text rendering applies carriage-return overwrites", () => {
assert.equal(terminalDataToPlainText("progress 10%\rprogress 100%\n"), "progress 100%");
});
test("plain text rendering applies erase-line controls", () => {
assert.equal(terminalDataToPlainText("loading...\r\x1b[Kdone\n"), "done");
});
test("erase display from carriage return preserves overwrite semantics", () => {
assert.equal(terminalDataToPlainText("progress 10%\r\x1b[Jprogress 20%\n"), "progress 20%");
});
test("stateful renderer handles CSI sequences split across chunks", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("red \x1b[");
renderer.feed("31mtext\x1b[0m\n");
assert.equal(renderer.finish(), "red text");
});
test("plain text rendering removes OSC payloads", () => {
assert.equal(terminalDataToPlainText("before\x1b]0;secret title\x07after\n"), "beforeafter");
});
test("HTML rendering escapes content and strips terminal controls", () => {
const html = terminalDataToHtml("a < b\x1b[31m & c\x1b[0m\r\x1b[Kdone\n", "host<1>", 0);
assert.equal(html.includes("\x1b"), false);
assert.equal(html.includes("[31m"), false);
assert.equal(html.includes("done"), true);
assert.equal(html.includes("a &lt; b"), false);
assert.equal(html.includes("host&lt;1&gt;"), true);
});
test("display clear preserves prior log history", () => {
assert.equal(
terminalDataToPlainText("login banner\n$ tmux\n\x1b[H\x1b[2Jtmux pane\n"),
"login banner\n$ tmux\n\ntmux pane",
);
});
test("ED3 after ED2 does not add a duplicate log separator", () => {
assert.equal(
terminalDataToPlainText("login banner\n$ clear\n\x1b[H\x1b[2J\x1b[3Jafter clear\n"),
"login banner\n$ clear\n\nafter clear",
);
});
test("cursor home after display clear stays within the new log screen", () => {
assert.equal(
terminalDataToPlainText("old1\nold2\n\x1b[2J\x1b[Hnew\n"),
"old1\nold2\n\nnew",
);
});
test("erase display backward after full clear preserves prior log history", () => {
assert.equal(
terminalDataToPlainText("old\n\x1b[2Jnew\x1b[1Jafter\n"),
"old\n\n after",
);
});
test("clear from home preserves prior log history", () => {
assert.equal(
terminalDataToPlainText("before zellij\n$ zellij\n\x1b[H\x1b[Jzellij pane\n"),
"before zellij\n$ zellij\n\nzellij pane",
);
});
test("home clear repaint updates current preserved screen instead of appending frames", () => {
assert.equal(
terminalDataToPlainText("before tui\n\x1b[H\x1b[Jframe one\n\x1b[H\x1b[Jframe two\n"),
"before tui\n\nframe two",
);
});
test("home ED2 preserves cleared screens even without ED3", () => {
assert.equal(
terminalDataToPlainText("before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n"),
"before tui\n\nframe one\n\nframe two",
);
});
test("repeated ED2 preserves cleared screens even without ED3", () => {
assert.equal(
terminalDataToPlainText("before tui\n\x1b[2Jframe one\r\x1b[2Jframe two\n"),
"before tui\n\nframe one\n\nframe two",
);
});
test("redundant ED2 keeps pending cleared screen when current screen is empty", () => {
assert.equal(
terminalDataToPlainText("before\n\x1b[2Jfirst\x1b[2J\x1b[2Jsecond\n"),
"before\n\nfirst\n\n second",
);
});
test("home ED2 preserves each cleared shell frame without ED3", () => {
assert.equal(
terminalDataToPlainText("before\n\x1b[H\x1b[2Jone\n\x1b[H\x1b[2Jtwo\n"),
"before\n\none\n\ntwo",
);
});
test("home ED2 repaint does not accumulate every intermediate frame", () => {
assert.equal(
terminalDataToPlainText("before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n\x1b[H\x1b[2Jframe three\n"),
"before tui\n\nframe two\n\nframe three",
);
});
test("committing pending ED2 preserves cursor movement before printable output", () => {
assert.equal(
terminalDataToPlainText("before\n\x1b[H\x1b[2Jone\n\x1b[2J\x1b[10;5Htext\n"),
"before\n\none\n\n\n\n\n\n\n\n\n\n\n text",
);
});
test("pending ED2 snapshot rendering does not mutate repaint state", () => {
const renderer = createTerminalTextRenderer();
renderer.feed("before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
assert.equal(
renderer.toString({ includePendingClearedScreen: true }),
"before tui\n\nframe one\n\nframe two",
);
renderer.feed("\x1b[H\x1b[2Jframe three\n");
assert.equal(renderer.finish(), "before tui\n\nframe two\n\nframe three");
});
test("later shell clear preserves intervening screen output", () => {
assert.equal(
terminalDataToPlainText("before\n\x1b[H\x1b[2Jfirst screen\n\x1b[H\x1b[2J\x1b[3Jsecond screen\n"),
"before\n\nfirst screen\n\nsecond screen",
);
});
test("standalone ED3 preserves current visible screen", () => {
assert.equal(
terminalDataToPlainText("before\n\x1b[H\x1b[2Jscreen\n\x1b[3Jafter\n"),
"before\n\nscreen\nafter",
);
});

5
global.d.ts vendored
View File

@@ -178,6 +178,11 @@ declare global {
hostname: string;
username?: string;
password?: string;
privateKey?: string;
certificate?: string;
keyId?: string;
passphrase?: string;
identityFilePaths?: string[];
port?: number;
moshServerPath?: string;
moshClientPath?: string;

View File

@@ -28,10 +28,12 @@ export const STORAGE_KEY_KNOWN_HOSTS = 'netcatty_known_hosts_v1';
export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
export const STORAGE_KEY_PROXY_PROFILES = 'netcatty_proxy_profiles_v1';
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_profiles_view_mode_v1';
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';

View File

@@ -9,7 +9,7 @@
* function degrades to a no-op — values pass through unmodified.
*/
import type { GroupConfig, Host, Identity, SSHKey } from "../../domain/models";
import type { GroupConfig, Host, Identity, ProxyProfile, SSHKey } from "../../domain/models";
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
import { netcattyBridge } from "../services/netcattyBridge";
@@ -123,6 +123,30 @@ export function decryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig
return Promise.all(configs.map(decryptGroupConfigSecrets));
}
// ---------------------------------------------------------------------------
// ProxyProfile
// ---------------------------------------------------------------------------
export async function encryptProxyProfileSecrets(profile: ProxyProfile): Promise<ProxyProfile> {
const out = { ...profile, config: { ...profile.config } };
out.config.password = await encryptField(out.config.password);
return out;
}
export async function decryptProxyProfileSecrets(profile: ProxyProfile): Promise<ProxyProfile> {
const out = { ...profile, config: { ...profile.config } };
out.config.password = await decryptField(out.config.password);
return out;
}
export function encryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyProfile[]> {
return Promise.all(profiles.map(encryptProxyProfileSecrets));
}
export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyProfile[]> {
return Promise.all(profiles.map(decryptProxyProfileSecrets));
}
// ---------------------------------------------------------------------------
// Provider Connection (Cloud Sync)
// ---------------------------------------------------------------------------

View File

@@ -1421,6 +1421,7 @@ export class CloudSyncManager {
buildPayload(data: {
hosts: SyncPayload['hosts'];
keys: SyncPayload['keys'];
proxyProfiles?: SyncPayload['proxyProfiles'];
snippets: SyncPayload['snippets'];
customGroups: SyncPayload['customGroups'];
snippetPackages?: SyncPayload['snippetPackages'];

View File

@@ -380,6 +380,10 @@ export const startPortForward = async (
// Generate a unique tunnel ID
const tunnelId = `pf-${rule.id}-${Date.now()}`;
if (host.proxyProfileId && !host.proxyConfig) {
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
}
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key;
const proxy = host.proxyConfig
@@ -403,6 +407,9 @@ export const startPortForward = async (
jumpHosts = resolvedJumpHosts
.filter((jumpHost): jumpHost is Host => Boolean(jumpHost))
.map((jumpHost, index) => {
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
}
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);

View File

@@ -33,7 +33,7 @@
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts application/state/*/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

View File

@@ -8,23 +8,23 @@ directly (see `electron/bridges/moshHandshake.cjs` and
## How binaries land here
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on
relevant pushes/PRs, or on a manual `workflow_dispatch`. It uses
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
produce one binary per target from upstream `mobile-shell/mosh`
source:
1. `.github/workflows/build-mosh-binaries.yml` builds or fetches
`mosh-client` on relevant pushes/PRs, or on a manual
`workflow_dispatch`. It uses `scripts/build-mosh/build-linux.sh` and
`scripts/build-mosh/build-macos.sh` for Linux/macOS, and
`scripts/build-mosh/fetch-windows.sh` for the pinned Windows binary:
| target | provenance |
|-------------------|-----------------------------------------------------------------|
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
| `win32-x64` | upstream source, Cygwin GCC, ships with bundled Cygwin DLLs |
| `win32-x64` | FluentTerminal-pinned standalone fallback, SHA256 pinned |
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
`fetch-windows.sh` is preserved as an emergency fallback that pulls
the FluentTerminal-pinned binary; it's no longer wired into the
default workflow.
The upstream Cygwin Windows build path was removed from the default
workflow because the tested build clears the terminal but never
renders remote output on Windows.
2. When manually dispatched with `release_tag`, that workflow publishes
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
@@ -50,8 +50,8 @@ directly (see `electron/bridges/moshHandshake.cjs` and
whatever happens to be installed on the developer machine.
Official Windows package builds currently ship x64 only for bundled
Mosh coverage. Windows arm64 packaging should be re-enabled there
after the `build-mosh-binaries` workflow can produce `win32-arm64`.
Mosh coverage. Windows arm64 packaging should be added only after we
have a tested standalone arm64 client.
The directory is otherwise empty (binaries are gitignored).
@@ -61,12 +61,12 @@ The directory is otherwise empty (binaries are gitignored).
(https://github.com/mobile-shell/mosh).
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
is permitted.
- The Windows binary is built in CI from upstream
https://github.com/mobile-shell/mosh @ tag `MOSH_REF` (default
`mosh-1.4.0`) using the Cygwin GCC toolchain. The bundled DLLs are
redistributable Cygwin runtime libraries — see
`mosh-client-win32-x64-dlls/README.txt` (generated by the build) for
the per-DLL license listing.
- The default Windows x64 binary is the FluentTerminal-pinned
standalone `mosh-client.exe` from
https://github.com/felixse/FluentTerminal @ commit `bad0f85`, pinned
by SHA256 in `scripts/fetch-mosh-binaries.cjs`. The old Cygwin build
path is intentionally not used for Windows releases while it
reproduces the blank-screen runtime issue.
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
ncurses MIT) are compatible with GPL-3.0.
@@ -98,12 +98,12 @@ For macOS the build needs an Xcode toolchain; see
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
`ssh` client for the remote bootstrap. System-installed `mosh` /
`mosh-client` binaries are intentionally ignored.
- Windows binary built in-CI from upstream source via Cygwin GCC; ships
alongside `cygwin1.dll` + transitive deps so it runs on a stock
Windows machine without a Cygwin install.
- Windows x64 currently ships the FluentTerminal-pinned standalone
client because the upstream Cygwin bundle can blank after terminal
initialization on Windows.
## Roadmap
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
leg using the same `build-windows.sh` script.
- Add Windows arm64 only after a tested standalone arm64 client is
available.
- Make `MOSH_REF` track upstream release tags automatically.

View File

@@ -4,11 +4,17 @@
# Inputs (env):
# MOSH_REF — git ref of mobile-shell/mosh to build (e.g. mosh-1.4.0)
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
# OUT_DIR — directory to write mosh-client-linux-<arch> + sha256
# OUT_DIR — directory to write mosh-client-linux-<arch>.tar.gz + sha256
#
# Output:
# $OUT_DIR/mosh-client-linux-<arch>
# $OUT_DIR/mosh-client-linux-<arch>.sha256
# $OUT_DIR/mosh-client-linux-<arch>.tar.gz (binary + terminfo bundle)
# $OUT_DIR/mosh-client-linux-<arch>.tar.gz.sha256
#
# The bundle ships a private terminfo database next to the binary because
# our statically-linked ncurses has its compiled-in TERMINFO path pointing
# at the build-time prefix (a temp dir). Without bundling, mosh-client on
# distros lacking /usr/share/terminfo (or stripped containers) fails with
# "Terminfo database could not be found." See issue #890.
#
# Strategy: build OpenSSL, protobuf, ncurses as static archives in a
# scratch prefix, then build mosh against those and link libstdc++/libgcc
@@ -87,7 +93,9 @@ git -C mosh checkout --detach FETCH_HEAD
LIBS="-ldl -lpthread"
make -j"$(nproc)" )
OUT_BIN="$OUT_DIR/mosh-client-linux-$ARCH"
BUNDLE_DIR="$WORK/linux-$ARCH-bundle"
mkdir -p "$BUNDLE_DIR"
OUT_BIN="$BUNDLE_DIR/mosh-client"
cp mosh/src/frontend/mosh-client "$OUT_BIN"
strip "$OUT_BIN"
@@ -110,5 +118,38 @@ if grep -Ev '^(linux-vdso\.so\.1|lib(c|m|pthread|rt|dl|resolv|util|z)\.so\.[0-9]
exit 1
fi
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH" > "mosh-client-linux-$ARCH.sha256" )
cat "$OUT_DIR/mosh-client-linux-$ARCH.sha256"
# Bundle the terminfo entries our statically-linked ncurses needs. The
# ncurses `make install` above populated $PREFIX/share/terminfo/ with the
# full upstream terminfo.src. Ship a curated subset so users hit a
# working entry regardless of TERM.
TERMINFO_SRC="$PREFIX/share/terminfo"
TERMINFO_OUT="$BUNDLE_DIR/terminfo"
mkdir -p "$TERMINFO_OUT"
copy_terminfo_entry() {
local name="$1"
for src in "$TERMINFO_SRC"/?/"$name" "$TERMINFO_SRC"/??/"$name"; do
[ -f "$src" ] || continue
local rel
rel=$(basename "$(dirname "$src")")
mkdir -p "$TERMINFO_OUT/$rel"
cp "$src" "$TERMINFO_OUT/$rel/$name"
return 0
done
return 1
}
for entry in xterm-256color xterm xterm-color vt100 vt220 ansi screen screen-256color tmux tmux-256color dumb linux; do
copy_terminfo_entry "$entry" || echo "WARN: terminfo entry $entry not found in $TERMINFO_SRC" >&2
done
if [ ! -f "$TERMINFO_OUT/x/xterm-256color" ] && [ ! -f "$TERMINFO_OUT/78/xterm-256color" ]; then
echo "ERROR: failed to bundle xterm-256color terminfo for mosh-client (linux-$ARCH)." >&2
exit 1
fi
echo "--- bundled terminfo ---"
find "$TERMINFO_OUT" -type f -print
BUNDLE_TGZ="$OUT_DIR/mosh-client-linux-$ARCH.tar.gz"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "mosh-client" "terminfo" )
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH.tar.gz" > "mosh-client-linux-$ARCH.tar.gz.sha256" )
cat "$OUT_DIR/mosh-client-linux-$ARCH.tar.gz.sha256"

View File

@@ -7,8 +7,13 @@
# MACOSX_DEPLOYMENT_TARGET — minimum macOS version (default 11.0)
#
# Output:
# $OUT_DIR/mosh-client-darwin-universal
# $OUT_DIR/mosh-client-darwin-universal.sha256
# $OUT_DIR/mosh-client-darwin-universal.tar.gz (binary + terminfo bundle)
# $OUT_DIR/mosh-client-darwin-universal.tar.gz.sha256
#
# The bundle ships a private terminfo database next to the binary because
# our statically-linked ncurses has its compiled-in TERMINFO path pointing
# at the build-time prefix (a temp dir). Without bundling, mosh-client
# fails with "Terminfo database could not be found." See issue #890.
#
# Strategy: build OpenSSL/protobuf/ncurses for arm64 and x86_64
# (cross-compile via Apple clang's -arch flag), link mosh-client per arch,
@@ -110,7 +115,16 @@ build_arch() {
CFLAGS="$CFLAGS_COMMON" CXXFLAGS="$CFLAGS_COMMON" LDFLAGS="$LDFLAGS_COMMON"
make -j"$(sysctl -n hw.ncpu)"
make -C include install
make -C ncurses install )
make -C ncurses install
# Compile + install the terminfo database for the native arch only.
# `tic` runs on the build host, so a cross-target build can't compile
# terminfo entries — but the .src database is arch-independent, so a
# single native-arch install populates $PREFIX/share/terminfo/ with
# the data we bundle below.
if [ "$ARCH" = "$NATIVE_ARCH" ]; then
make -C progs install
make -C misc install
fi )
# mosh per-arch build
( cd mosh-src
@@ -138,7 +152,9 @@ else
build_arch arm64
fi
OUT_BIN="$OUT_DIR/mosh-client-darwin-universal"
BUNDLE_DIR="$WORK/darwin-universal-bundle"
mkdir -p "$BUNDLE_DIR"
OUT_BIN="$BUNDLE_DIR/mosh-client"
lipo -create "$WORK/mosh-client-arm64" "$WORK/mosh-client-x86_64" -output "$OUT_BIN"
strip -x "$OUT_BIN" || true
@@ -157,5 +173,36 @@ if otool -L "$OUT_BIN" | tail -n +2 | awk '{print $1}' | grep -Ev "^(/usr/lib/|/
exit 1
fi
( cd "$OUT_DIR" && shasum -a 256 "mosh-client-darwin-universal" > "mosh-client-darwin-universal.sha256" )
cat "$OUT_DIR/mosh-client-darwin-universal.sha256"
# Bundle the terminfo entries our statically-linked ncurses needs. Pull
# from the native-arch prefix where `make -C misc install` ran tic above.
TERMINFO_SRC="$WORK/prefix-$NATIVE_ARCH/share/terminfo"
TERMINFO_OUT="$BUNDLE_DIR/terminfo"
mkdir -p "$TERMINFO_OUT"
copy_terminfo_entry() {
local name="$1"
for src in "$TERMINFO_SRC"/?/"$name" "$TERMINFO_SRC"/??/"$name"; do
[ -f "$src" ] || continue
local rel
rel=$(basename "$(dirname "$src")")
mkdir -p "$TERMINFO_OUT/$rel"
cp "$src" "$TERMINFO_OUT/$rel/$name"
return 0
done
return 1
}
for entry in xterm-256color xterm xterm-color vt100 vt220 ansi screen screen-256color tmux tmux-256color dumb; do
copy_terminfo_entry "$entry" || echo "WARN: terminfo entry $entry not found in $TERMINFO_SRC" >&2
done
if [ ! -f "$TERMINFO_OUT/x/xterm-256color" ] && [ ! -f "$TERMINFO_OUT/78/xterm-256color" ]; then
echo "ERROR: failed to bundle xterm-256color terminfo for mosh-client (darwin-universal)." >&2
exit 1
fi
echo "--- bundled terminfo ---"
find "$TERMINFO_OUT" -type f -print
BUNDLE_TGZ="$OUT_DIR/mosh-client-darwin-universal.tar.gz"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "mosh-client" "terminfo" )
( cd "$OUT_DIR" && shasum -a 256 "mosh-client-darwin-universal.tar.gz" > "mosh-client-darwin-universal.tar.gz.sha256" )
cat "$OUT_DIR/mosh-client-darwin-universal.tar.gz.sha256"

View File

@@ -1,157 +0,0 @@
#!/usr/bin/env bash
# Build mosh-client.exe from upstream mobile-shell/mosh source inside a
# Cygwin environment. Phase 1 pinned a third-party prebuilt
# (FluentTerminal); this rebuilds it in CI so we own the provenance
# end-to-end and ship the same upstream version everywhere.
#
# Cygwin doesn't make full static linking practical (cygwin1.dll
# implements the POSIX runtime; it must be present at runtime), so we
# bundle every required Cygwin DLL alongside `mosh-client.exe`. This
# keeps the binary reproducible and self-contained — the only
# environmental requirement is the Cygwin Project's GPL-3.0 DLLs, all
# of which we redistribute under their respective licenses.
#
# Inputs (env):
# MOSH_REF — git ref of mobile-shell/mosh (e.g. mosh-1.4.0)
# ARCH — x64 (only — Cygwin's arm64 port isn't release-ready)
# OUT_DIR — directory to write mosh-client-win32-<arch>.exe + DLL bundle
#
# Output:
# $OUT_DIR/mosh-client-win32-<arch>.exe
# $OUT_DIR/mosh-client-win32-<arch>-dlls/*.dll
# $OUT_DIR/mosh-client-win32-<arch>.sha256
#
# Expected to run inside a Cygwin bash login shell (set up by the CI's
# cygwin-install-action with development packages already installed).
set -euo pipefail
: "${MOSH_REF:?missing MOSH_REF}"
: "${ARCH:?missing ARCH}"
: "${OUT_DIR:?missing OUT_DIR}"
validate_mosh_ref() {
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|| [[ "$MOSH_REF" == *..* ]] \
|| [[ "$MOSH_REF" == *@\{* ]] \
|| [[ "$MOSH_REF" == */ ]] \
|| [[ "$MOSH_REF" == *.lock ]]; then
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
exit 1
fi
}
validate_mosh_ref
if [ "$ARCH" != "x64" ]; then
echo "ERROR: only ARCH=x64 supported by the Cygwin Windows build (got: $ARCH)." >&2
exit 1
fi
# Sanity: must run under Cygwin so we have access to cygcheck and the
# Cygwin gcc toolchain.
if ! uname -a | grep -qi CYGWIN; then
echo "ERROR: build-windows.sh must run inside a Cygwin shell." >&2
uname -a >&2
exit 1
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
mkdir -p "$OUT_DIR"
cd "$WORK"
# Build mosh against the Cygwin-supplied OpenSSL, protobuf, ncurses.
# Static linking against those is not supported by the upstream
# build for Cygwin, so we accept the dynamic deps and bundle the DLLs.
git init mosh
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
git -C mosh fetch --depth 1 origin "$MOSH_REF"
git -C mosh checkout --detach FETCH_HEAD
cd mosh
./autogen.sh
./configure --enable-completion=no --disable-server \
CXXFLAGS="-O2 -static-libgcc -static-libstdc++" \
LDFLAGS="-static-libgcc -static-libstdc++"
make -j"$(nproc)"
OUT_EXE="$OUT_DIR/mosh-client-win32-x64.exe"
DLL_DIR="$OUT_DIR/mosh-client-win32-x64-dlls"
mkdir -p "$DLL_DIR"
cp src/frontend/mosh-client.exe "$OUT_EXE"
strip "$OUT_EXE"
echo "--- file ---"
file "$OUT_EXE"
echo "--- size ---"
ls -lh "$OUT_EXE"
# Walk the import graph via cygcheck and copy every Cygwin-shipped DLL
# (paths that normalize to /usr/bin/) so the binary runs anywhere without
# an external Cygwin install.
echo "--- cygcheck ---"
CYGCHECK_OUT="$WORK/cygcheck.txt"
cygcheck "$OUT_EXE" | tee "$CYGCHECK_OUT"
bundled_count=0
while IFS= read -r line; do
candidate=$(printf '%s' "$line" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
case "$candidate" in
*.dll|*.DLL)
# Convert Windows-style paths to Cygwin paths if present.
cyg_candidate=$(cygpath -u "$candidate" 2>/dev/null || echo "$candidate")
case "$cyg_candidate" in
/usr/bin/*.dll|/usr/bin/*.DLL)
if [ -f "$cyg_candidate" ]; then
base=$(basename "$cyg_candidate")
if [ ! -f "$DLL_DIR/$base" ]; then
cp "$cyg_candidate" "$DLL_DIR/$base"
echo "bundled DLL: $base"
bundled_count=$((bundled_count + 1))
fi
fi
;;
esac
;;
esac
done < "$CYGCHECK_OUT"
if [ "$bundled_count" -eq 0 ] || [ ! -f "$DLL_DIR/cygwin1.dll" ]; then
echo "ERROR: failed to bundle required Cygwin DLLs for mosh-client.exe." >&2
exit 1
fi
echo "--- bundled DLLs ---"
ls -lh "$DLL_DIR"
# License: the Cygwin DLLs ship under various GPL-compatible licenses.
# Ship a top-level NOTICE so end users can see what we redistributed.
cat > "$DLL_DIR/README.txt" <<'EOF'
This directory bundles the Cygwin runtime DLLs required by
mosh-client.exe (built from https://github.com/mobile-shell/mosh ).
cygwin1.dll : LGPL-3.0 (Cygwin Project, https://cygwin.com/)
cygcrypto-*.dll : Apache-2.0 (OpenSSL Project, https://www.openssl.org/)
cygprotobuf-*.dll : BSD-3-Clause (Google, https://github.com/protocolbuffers/protobuf)
cygncursesw-*.dll : MIT-style (Free Software Foundation)
cygintl-*.dll : LGPL-2.1 (GNU gettext)
cyggcc_s-*.dll, cygstdc++ : GPL-3.0 with GCC Runtime Library Exception
The full text of each license is reproduced in the upstream source
tree of the respective project.
EOF
# Bundle exe + DLLs into a single tar.gz artifact for distribution.
# fetch-mosh-binaries.cjs unpacks the tarball into the local
# resources/mosh/win32-x64/ directory.
BUNDLE_TGZ="$OUT_DIR/mosh-client-win32-x64.tar.gz"
BUNDLE_DIR="$WORK/win32-x64-bundle"
mkdir -p "$BUNDLE_DIR"
cp "$OUT_EXE" "$BUNDLE_DIR/mosh-client.exe"
cp -R "$DLL_DIR" "$BUNDLE_DIR/mosh-client-win32-x64-dlls"
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" \
"mosh-client.exe" \
"mosh-client-win32-x64-dlls" )
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.exe" > "mosh-client-win32-x64.sha256" )
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.tar.gz" > "mosh-client-win32-x64.tar.gz.sha256" )
cat "$OUT_DIR/mosh-client-win32-x64.sha256"
cat "$OUT_DIR/mosh-client-win32-x64.tar.gz.sha256"

View File

@@ -1,12 +1,8 @@
#!/usr/bin/env bash
# Phase-1 source: pin to the FluentTerminal-shipped mosh-cygwin standalone
# Source: pin to the FluentTerminal-shipped mosh-cygwin standalone
# build (PE32+ x86-64, statically linked Cygwin runtime, no cygwin1.dll
# dependency). FluentTerminal is GPL-3.0 same license as netcatty
# and the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
#
# Phase-2 replaced this fetch with an in-CI Cygwin build from upstream
# source so we own the provenance end-to-end.
#
# dependency). FluentTerminal is GPL-3.0, same license as Netcatty, and
# the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
# The pinned commit is FluentTerminal master @ bad0f85 (2019-09-12), which
# is the commit where the prebuilt mosh-client.exe was added to the repo.
# Verifying SHA256 against a frozen value protects against silent updates.

View File

@@ -23,6 +23,13 @@
// MOSH_BIN_RES_DIR — override output dir for tests.
// MOSH_BIN_ALLOW_UNVERIFIED=true — explicit local escape hatch for mirrors
// without SHA256SUMS. Never use for release builds.
// MOSH_BIN_FORCE_WINDOWS_CYGWIN=true — debug escape hatch for the upstream
// Cygwin Windows bundle. The default Windows x64 asset
// is the FluentTerminal-pinned standalone client because
// the current Cygwin build clears the terminal and never
// renders remote output on Windows.
// MOSH_BIN_WINDOWS_LEGACY_URL / MOSH_BIN_WINDOWS_LEGACY_SHA256 — test/mirror
// overrides for that pinned Windows fallback.
const fs = require("node:fs");
const path = require("node:path");
@@ -35,22 +42,84 @@ const { main: resolveMoshBinRelease } = require("./resolve-mosh-bin-release.cjs"
const ROOT = path.resolve(__dirname, "..");
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "mosh");
const WINDOWS_LEGACY_FLUENT_MOSH_CLIENT = {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe",
sha256: "5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286",
};
// (file basename in the release -> relative subpath under resources/mosh/)
// Using flat names in the release for SHA256SUMS readability, then
// fanning out into platform-arch subdirs locally.
//
// `extract` indicates a tar.gz archive containing the binary + helper
// DLLs (Windows). The tarball is unpacked into the platform-arch
// directory so resources/mosh/win32-x64/ ends up with mosh-client.exe
// alongside cygwin1.dll, cygcrypto-*.dll, etc.
// Linux/macOS targets are tar.gz bundles containing the binary plus the
// runtime helpers each platform needs. Windows x64 defaults to the
// SHA256-pinned FluentTerminal standalone exe because the tested Cygwin
// bundle clears the terminal and never renders remote output on Windows.
// Bundling terminfo lets bundled Posix mosh-client builds work on
// minimal hosts that don't have a
// system ncurses-base — see issue #890.
//
// `legacy` describes the pre-bundle artifact name some published mosh
// binary releases still ship (Linux/Darwin used flat files before the
// bundle layout). When SHA256SUMS lists only the legacy name we fall
// back to it so existing releases keep working until a new tag is
// republished with the bundle layout.
const TARGETS = [
{ platform: "linux", arch: "x64", file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
{ platform: "linux", arch: "arm64", file: "mosh-client-linux-arm64", local: "linux-arm64/mosh-client" },
{ platform: "darwin", arch: "universal", file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
{ platform: "win32", arch: "x64", file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz" },
{
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
},
{
platform: "linux", arch: "arm64",
file: "mosh-client-linux-arm64.tar.gz", localDir: "linux-arm64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-arm64", local: "linux-arm64/mosh-client" },
},
{
platform: "darwin", arch: "universal",
file: "mosh-client-darwin-universal.tar.gz", localDir: "darwin-universal", extract: "tar.gz",
legacy: { file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
},
{
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: WINDOWS_LEGACY_FLUENT_MOSH_CLIENT,
preferLegacy: true,
},
];
function applyReleaseAssetOverrides(asset, opts = {}) {
if (asset.id !== WINDOWS_LEGACY_FLUENT_MOSH_CLIENT.id) return asset;
return {
...asset,
url: opts.windowsLegacyUrl || asset.url,
sha256: opts.windowsLegacySha256 || asset.sha256,
};
}
function selectReleaseAsset(target, sums, opts = {}) {
const primary = { file: target.file, extract: target.extract, local: target.local, localDir: target.localDir };
if (!target.legacy) return primary;
if (target.preferLegacy && !opts.forceWindowsCygwin) {
const legacy = applyReleaseAssetOverrides(target.legacy, opts);
if (sums.get(target.legacy.file) === legacy.sha256) {
return { file: target.legacy.file, local: target.legacy.local, sha256: legacy.sha256 };
}
return legacy;
}
// SHA256SUMS unavailable (allowUnverified mirror) — keep the primary
// and let download / extraction errors surface naturally.
if (sums.size === 0) return primary;
if (sums.has(target.file)) return primary;
if (sums.has(target.legacy.file)) {
return applyReleaseAssetOverrides({ file: target.legacy.file, local: target.legacy.local }, opts);
}
return primary;
}
function log(msg) { console.log(`[fetch-mosh-binaries] ${msg}`); }
function warn(msg) { console.warn(`[fetch-mosh-binaries] WARN ${msg}`); }
@@ -183,6 +252,20 @@ function assertExtractedTreeSafe(root) {
}
}
function assertBundledTerminfo(extractDir, target) {
const terminfoDir = path.join(extractDir, "terminfo");
const terminfoEntry = [
path.join(terminfoDir, "x", "xterm-256color"),
path.join(terminfoDir, "78", "xterm-256color"),
].find((entry) => fs.existsSync(entry));
if (terminfoEntry && !fs.lstatSync(terminfoEntry).isFile()) {
throw new Error(`${target.file} contained invalid terminfo for xterm-256color`);
}
if (!terminfoEntry) {
warn(`${target.file} did not contain terminfo for xterm-256color; ${target.platform}-${target.arch} mosh packaging will fall back to host system terminfo (issue #890).`);
}
}
function normalizeWindowsBundle(extractDir, target) {
const genericExe = path.join(extractDir, "mosh-client.exe");
const legacyExe = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}.exe`);
@@ -196,9 +279,28 @@ function normalizeWindowsBundle(extractDir, target) {
if (!fs.existsSync(dllDir) || !fs.statSync(dllDir).isDirectory()) {
throw new Error(`${target.file} did not contain ${path.basename(dllDir)}/`);
}
assertBundledTerminfo(extractDir, target);
chmodExecutable(genericExe);
}
function normalizePosixBundle(extractDir, target) {
const binary = path.join(extractDir, "mosh-client");
const legacyBinary = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}`);
if (!fs.existsSync(binary) && fs.existsSync(legacyBinary)) {
fs.renameSync(legacyBinary, binary);
}
if (!fs.existsSync(binary) || !fs.lstatSync(binary).isFile()) {
throw new Error(`${target.file} did not contain mosh-client`);
}
assertBundledTerminfo(extractDir, target);
chmodExecutable(binary);
}
function normalizeBundle(extractDir, target) {
if (target.platform === "win32") return normalizeWindowsBundle(extractDir, target);
return normalizePosixBundle(extractDir, target);
}
function replaceDir(srcDir, destDir) {
fs.rmSync(destDir, { recursive: true, force: true });
fs.mkdirSync(path.dirname(destDir), { recursive: true });
@@ -226,9 +328,7 @@ function unpackTarGz(buf, target, { resDir }) {
stdio: "inherit",
});
assertExtractedTreeSafe(extractDir);
if (target.platform === "win32") {
normalizeWindowsBundle(extractDir, target);
}
normalizeBundle(extractDir, target);
replaceDir(extractDir, destDir);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
@@ -236,38 +336,54 @@ function unpackTarGz(buf, target, { resDir }) {
return destDir;
}
function writeFlatAsset(buf, target, asset, { resDir }) {
const dest = path.join(resDir, asset.local);
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-flat-"));
const tmpDest = path.join(tmpRoot, path.basename(dest));
try {
fs.writeFileSync(tmpDest, buf);
if (target.platform !== "win32") fs.chmodSync(tmpDest, 0o755);
replaceDir(tmpRoot, path.dirname(dest));
} catch (err) {
fs.rmSync(tmpRoot, { recursive: true, force: true });
throw err;
}
return dest;
}
async function fetchOne(target, sums, opts) {
const { baseUrl, resDir, allowUnverified = false } = opts;
const url = `${baseUrl}/${target.file}`;
const asset = selectReleaseAsset(target, sums, opts);
if (asset.file !== target.file) {
log(`using legacy asset ${asset.file} for ${target.platform}-${target.arch}`);
}
const url = asset.url || `${baseUrl}/${asset.file}`;
let buf;
try {
buf = await follow(url);
} catch (err) {
throw new Error(`download failed for ${target.file}: ${err.message}`);
throw new Error(`download failed for ${asset.file}: ${err.message}`);
}
const expected = sums.get(target.file);
const expected = asset.sha256 || sums.get(asset.file);
const actual = crypto.createHash("sha256").update(buf).digest("hex");
if (expected && expected !== actual) {
throw new Error(`SHA256 mismatch for ${target.file}: expected ${expected}, got ${actual}`);
throw new Error(`SHA256 mismatch for ${asset.file}: expected ${expected}, got ${actual}`);
}
if (!expected) {
if (!allowUnverified) {
throw new Error(`no SHA256 entry for ${target.file}`);
throw new Error(`no SHA256 entry for ${asset.file}`);
}
warn(`no SHA256 entry for ${target.file} - accepting actual ${actual}`);
warn(`no SHA256 entry for ${asset.file} - accepting actual ${actual}`);
}
if (target.extract === "tar.gz") {
if (asset.extract === "tar.gz") {
const destDir = unpackTarGz(buf, target, { resDir });
log(`unpacked ${target.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
log(`unpacked ${asset.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
return true;
}
const dest = path.join(resDir, target.local);
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, buf);
if (target.platform !== "win32") fs.chmodSync(dest, 0o755);
const dest = writeFlatAsset(buf, target, asset, { resDir });
log(`wrote ${path.relative(ROOT, dest)} (${buf.length} bytes, sha256=${actual})`);
return true;
}
@@ -299,6 +415,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
`https://github.com/${owner}/${repo}/releases/download/${encodeURIComponent(release)}`;
const resDir = path.resolve(env.MOSH_BIN_RES_DIR || DEFAULT_RES_DIR);
const allowUnverified = env.MOSH_BIN_ALLOW_UNVERIFIED === "true";
const forceWindowsCygwin = env.MOSH_BIN_FORCE_WINDOWS_CYGWIN === "true";
const platformFilter = hostTarget?.platform || platformArg;
const archFilter = hostTarget?.arch || archArg;
@@ -310,7 +427,14 @@ async function main(argv = process.argv.slice(2), env = process.env) {
if (platformFilter && target.platform !== platformFilter) continue;
if (archFilter && target.arch !== archFilter) continue;
total += 1;
if (await fetchOne(target, sums, { baseUrl, resDir, allowUnverified })) ok += 1;
if (await fetchOne(target, sums, {
baseUrl,
resDir,
allowUnverified,
forceWindowsCygwin,
windowsLegacyUrl: env.MOSH_BIN_WINDOWS_LEGACY_URL,
windowsLegacySha256: env.MOSH_BIN_WINDOWS_LEGACY_SHA256,
})) ok += 1;
}
log(`done - ${ok}/${total} binaries written`);
if (ok < total) throw new Error(`only wrote ${ok}/${total} requested binaries`);
@@ -331,8 +455,10 @@ module.exports = {
resolveHostTarget,
resolveTarArchiveInvocation,
parseSums,
selectReleaseAsset,
validateTarEntries,
assertExtractedTreeSafe,
unpackTarGz,
writeFlatAsset,
main,
};

View File

@@ -15,6 +15,7 @@ const {
replaceDir,
resolveHostTarget,
resolveTarArchiveInvocation,
selectReleaseAsset,
} = require("./fetch-mosh-binaries.cjs");
function makeTmp(t) {
@@ -183,6 +184,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
const tar = makeTarGz(t, {
"mosh-client-win32-x64.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
@@ -195,6 +197,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
@@ -202,6 +205,65 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll")), true);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo", "x", "xterm-256color")), true);
});
test("fetch-mosh-binaries accepts legacy Windows bundles without terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
});
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
});
const { stderr } = await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
});
assert.match(stderr, /did not contain terminfo for xterm-256color/);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
});
test("fetch-mosh-binaries rejects invalid Windows terminfo entries", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const srcDir = makeTmp(t);
fs.writeFileSync(path.join(srcDir, "mosh-client.exe"), "exe");
fs.mkdirSync(path.join(srcDir, "mosh-client-win32-x64-dlls"), { recursive: true });
fs.writeFileSync(path.join(srcDir, "mosh-client-win32-x64-dlls", "cygwin1.dll"), "dll");
fs.mkdirSync(path.join(srcDir, "terminfo", "x", "xterm-256color"), { recursive: true });
const tarPath = path.join(makeTmp(t), "invalid-terminfo.tar.gz");
execFileSync("tar", ["-czf", tarPath, "-C", srcDir, "mosh-client.exe", "mosh-client-win32-x64-dlls", "terminfo"], { stdio: "pipe" });
const tar = fs.readFileSync(tarPath);
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
}),
/invalid terminfo for xterm-256color/,
);
});
test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", async (t) => {
@@ -209,6 +271,7 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
const tar = makeTarGz(t, {
"mosh-client.exe": "exe",
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-win32-x64.tar.gz": tar,
@@ -222,6 +285,7 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",
@@ -229,6 +293,264 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
);
});
test("selectReleaseAsset prefers the bundled tarball when listed in SHA256SUMS", () => {
const target = {
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
};
const sums = new Map([
["mosh-client-linux-x64.tar.gz", "abc"],
["mosh-client-linux-x64", "def"],
]);
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-linux-x64.tar.gz");
});
test("selectReleaseAsset falls back to the legacy flat asset when only it is published", () => {
const target = {
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
};
const sums = new Map([["mosh-client-linux-x64", "def"]]);
const asset = selectReleaseAsset(target, sums);
assert.equal(asset.file, "mosh-client-linux-x64");
assert.equal(asset.local, "linux-x64/mosh-client");
assert.equal(asset.extract, undefined);
});
test("selectReleaseAsset stays on the primary when SHA256SUMS is empty (unverified mirror)", () => {
const target = {
platform: "linux", arch: "x64",
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
};
assert.equal(selectReleaseAsset(target, new Map()).file, "mosh-client-linux-x64.tar.gz");
});
test("selectReleaseAsset prefers the pinned Windows standalone client by default", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
preferLegacy: true,
};
const sums = new Map([["mosh-client-win32-x64.tar.gz", "def"]]);
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-win32-x64.exe");
assert.equal(selectReleaseAsset(target, sums, { forceWindowsCygwin: true }).file, "mosh-client-win32-x64.tar.gz");
});
test("selectReleaseAsset uses the released Windows standalone asset when published", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
preferLegacy: true,
};
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "abc"]]));
assert.equal(asset.file, "mosh-client-win32-x64.exe");
assert.equal(asset.local, "win32-x64/mosh-client.exe");
assert.equal(asset.url, undefined);
assert.equal(asset.sha256, "abc");
});
test("selectReleaseAsset ignores a released Windows asset when its checksum is not the pinned standalone", () => {
const target = {
platform: "win32", arch: "x64",
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
legacy: {
id: "windows-fluentterminal-standalone",
file: "mosh-client-win32-x64.exe",
local: "win32-x64/mosh-client.exe",
url: "https://example.test/mosh-client.exe",
sha256: "abc",
},
preferLegacy: true,
};
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "def"]]));
assert.equal(asset.file, "mosh-client-win32-x64.exe");
assert.equal(asset.local, "win32-x64/mosh-client.exe");
assert.equal(asset.url, "https://example.test/mosh-client.exe");
assert.equal(asset.sha256, "abc");
});
test("fetch-mosh-binaries downloads the pinned Windows standalone client", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const flat = Buffer.from("working-windows-standalone");
fs.mkdirSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls"), { recursive: true });
fs.mkdirSync(path.join(resDir, "win32-x64", "terminfo", "78"), { recursive: true });
fs.writeFileSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"), "stale");
fs.writeFileSync(path.join(resDir, "win32-x64", "terminfo", "78", "xterm-256color"), "stale");
const baseUrl = await serveAssets(t, {
SHA256SUMS: "",
});
const legacyBaseUrl = await serveAssets(t, {
"mosh-client-win32-x64.exe": flat,
});
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
MOSH_BIN_WINDOWS_LEGACY_URL: `${legacyBaseUrl}/mosh-client-win32-x64.exe`,
MOSH_BIN_WINDOWS_LEGACY_SHA256: sha256(flat),
CI: "true",
},
stdio: "pipe",
});
const dest = path.join(resDir, "win32-x64", "mosh-client.exe");
assert.equal(fs.existsSync(dest), true);
assert.equal(fs.readFileSync(dest, "utf8"), "working-windows-standalone");
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls")), false);
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo")), false);
});
test("fetch-mosh-binaries falls back to the legacy flat asset for older releases", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const flat = Buffer.from("legacy-binary");
fs.mkdirSync(path.join(resDir, "linux-x64", "terminfo", "78"), { recursive: true });
fs.writeFileSync(path.join(resDir, "linux-x64", "terminfo", "78", "xterm-256color"), "stale");
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64": flat,
SHA256SUMS: `${sha256(flat)} mosh-client-linux-x64\n`,
});
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
assert.equal(fs.readFileSync(path.join(resDir, "linux-x64", "mosh-client"), "utf8"), "legacy-binary");
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "terminfo")), false);
});
test("fetch-mosh-binaries unpacks the Linux tarball with bundled terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "binary",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "terminfo", "x", "xterm-256color")), true);
});
test("fetch-mosh-binaries warns when the Linux tarball lacks terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "binary",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
const { stderr } = await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.match(stderr, /did not contain terminfo for xterm-256color/);
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
});
test("fetch-mosh-binaries unpacks the Darwin tarball with bundled terminfo", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"mosh-client": "binary",
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-darwin-universal.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-darwin-universal.tar.gz\n`,
});
await execFileAsync(process.execPath, [script, "--platform=darwin", "--arch=universal"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
});
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "mosh-client")), true);
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "terminfo", "x", "xterm-256color")), true);
});
test("fetch-mosh-binaries rejects a Linux tarball without mosh-client", async (t) => {
const resDir = path.join(makeTmp(t), "resources", "mosh");
const tar = makeTarGz(t, {
"terminfo/x/xterm-256color": "terminfo",
});
const baseUrl = await serveAssets(t, {
"mosh-client-linux-x64.tar.gz": tar,
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
});
await assert.rejects(
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
env: {
...process.env,
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: resDir,
CI: "true",
},
stdio: "pipe",
}),
/did not contain mosh-client/,
);
});
test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: process.platform === "win32" }, async (t) => {
const srcDir = makeTmp(t);
fs.writeFileSync(path.join(srcDir, "outside.exe"), "outside");
@@ -250,6 +572,7 @@ test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: pro
MOSH_BIN_RELEASE: "test",
MOSH_BIN_BASE_URL: baseUrl,
MOSH_BIN_RES_DIR: path.join(makeTmp(t), "resources", "mosh"),
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
CI: "true",
},
stdio: "pipe",

View File

@@ -32,30 +32,52 @@ function moshExtraResources(platform) {
if (platform === "darwin") {
const file = path.join(moshRoot, "darwin-universal", "mosh-client");
if (!hasFile(file)) return [];
return [
const resources = [
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
];
const terminfoDir = path.join(moshRoot, "darwin-universal", "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: "resources/mosh/darwin-universal/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] });
}
return resources;
}
if (platform === "linux") {
const arch = requestedArch();
const file = path.join(moshRoot, `linux-${arch}`, "mosh-client");
if (!hasFile(file)) return [];
return [{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] }];
const resources = [
{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] },
];
const terminfoDir = path.join(moshRoot, `linux-${arch}`, "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: `resources/mosh/linux-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
}
return resources;
}
if (platform === "win32") {
// Windows ships mosh-client.exe + Cygwin DLL bundle (cygwin1.dll,
// cygcrypto-*.dll, etc.) — copy the entire arch directory so the
// exe finds its DLLs at runtime via Windows' default search order.
// Windows normally ships the pinned standalone mosh-client.exe. Keep
// optional DLL/terminfo packaging so older Cygwin bundles remain usable.
const arch = requestedArch();
const exe = path.join(moshRoot, `win32-${arch}`, "mosh-client.exe");
const dllDir = path.join(moshRoot, `win32-${arch}`, `mosh-client-win32-${arch}-dlls`);
if (!hasFile(exe) || !hasDir(dllDir)) return [];
return [
if (!hasFile(exe)) return [];
const resources = [
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
{ from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`, to: "mosh/", filter: ["**/*"] },
];
if (hasDir(dllDir)) {
resources.push({
from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`,
to: `mosh/mosh-client-win32-${arch}-dlls/`,
filter: ["**/*"],
});
}
const terminfoDir = path.join(moshRoot, `win32-${arch}`, "terminfo");
if (hasDir(terminfoDir)) {
resources.push({ from: `resources/mosh/win32-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
}
return resources;
}
return [];

View File

@@ -8,7 +8,10 @@ const { moshExtraResources } = require("./mosh-extra-resources.cjs");
function makeTmp(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resources-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
t.after(() => {
if (process.cwd().startsWith(dir)) process.chdir(os.tmpdir());
fs.rmSync(dir, { recursive: true, force: true });
});
return dir;
}
@@ -29,7 +32,7 @@ function writeFile(filePath) {
fs.writeFileSync(filePath, "x");
}
test("moshExtraResources returns concrete Linux arch paths", (t) => {
test("moshExtraResources returns concrete Linux arch paths (legacy bundle without terminfo)", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "linux-x64", "mosh-client"));
@@ -40,18 +43,78 @@ test("moshExtraResources returns concrete Linux arch paths", (t) => {
]);
});
test("moshExtraResources packages bundled terminfo on Linux when present", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "arm64");
writeFile(path.join(root, "resources", "mosh", "linux-arm64", "mosh-client"));
writeFile(path.join(root, "resources", "mosh", "linux-arm64", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("linux");
assert.deepEqual(got, [
{ from: "resources/mosh/linux-arm64/", to: "mosh/", filter: ["mosh-client"] },
{ from: "resources/mosh/linux-arm64/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
]);
});
test("moshExtraResources packages bundled terminfo on Darwin when present", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "darwin-universal", "mosh-client"));
writeFile(path.join(root, "resources", "mosh", "darwin-universal", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("darwin");
assert.deepEqual(got, [
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
{ from: "resources/mosh/darwin-universal/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
]);
});
test("moshExtraResources returns concrete Windows arch paths only when that arch exists", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
writeFile(path.join(root, "resources", "mosh", "win32-x64", "terminfo", "x", "xterm-256color"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
{
from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/",
to: "mosh/mosh-client-win32-x64-dlls/",
filter: ["**/*"],
},
{ from: "resources/mosh/win32-x64/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
]);
process.env.npm_config_arch = "arm64";
assert.deepEqual(moshExtraResources("win32"), []);
});
test("moshExtraResources keeps legacy Windows bundles packageable", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
{
from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/",
to: "mosh/mosh-client-win32-x64-dlls/",
filter: ["**/*"],
},
]);
});
test("moshExtraResources packages standalone Windows mosh-client.exe", (t) => {
const root = makeTmp(t);
withCwdAndArch(t, root, "x64");
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
const got = moshExtraResources("win32");
assert.deepEqual(got, [
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
{ from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/", to: "mosh/", filter: ["**/*"] },
]);
process.env.npm_config_arch = "arm64";
assert.deepEqual(moshExtraResources("win32"), []);
});