Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 310ab602c4 test: share the global default proxy settings harness
The peer-window case and the multi-instance cleanup case were mounting
the same JSDOM fixture twice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:27:31 +00:00
Cursor Agentand陈大猫 277ac31f3f refactor: clear a deleted global default proxy at the call site
Drop the pass-through field on removeProxyProfileReferences. The profile
manager already knows the active id and clears it directly.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 44bcc92c89 fix: follow global default proxy changes in peer session windows
Session windows disable settings IPC sync, which also skipped the
storage listener and left the resolver singleton at its mount-time id.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 e30055d70e fix: keep CSV proxy export from embedding the global default
Grouped hosts ran through applyGroupDefaults and picked up the ambient
global proxy, while ungrouped hosts exported an empty inheriting cell.
Both paths now opt out of that default so inheritance stays inheritance.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 3546b2baf4 fix: round-trip explicit direct mode in vault CSV
Write the Proxy cell as direct when a host sets directProxy, and import that literal back as an explicit direct connection. An empty Proxy cell still inherits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:50:22 +00:00
Cursor Agentand陈大猫 d6a13e3a8b fix: keep global default proxy persistence across settings instances
Unregister only the setter owned by the unmounting useSettingsState instance so closing Settings still writes the cleared default through the remaining AppLockGate setter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:50:22 +00:00
Cursor Agentand陈大猫 735368e697 feat: add a global default proxy with an explicit direct override
Resolve the proxy as the host setting, then the nearest group default,
then one optional saved profile from synced settings, then a direct
connection. A host can choose direct and skip both inherited defaults.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:10:10 +00:00
2527f140c0 fix: dial SFTP separately on network-device SSH sessions (#3759)
* fix: dial SFTP separately on network-device SSH sessions

Huawei and Ruijie switches drop the shell when SFTP opens on the terminal TCP session. Use the existing dedicated login for network-device mode and network-vendor banners without marking those hosts single-channel bastions.

Fixes #3631

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: block follow-cwd probes on dedicated SFTP logins

Network-device mode and vendor SSH banners already dial SFTP separately,
but follow-terminal-cwd still opened an exec channel on the terminal
transport. Gate that probe with the same dedicated-login decision.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix: skip terminal pwd exec for network-vendor banners when opening SFTP

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 08:26:25 +08:00
陈大猫 aaee43417f fix(sftp): stop follow-cwd race that leaves panel stuck after cd (#3761)
Fixes #3588

Regular follow waits for a trusted live cwd instead of racing the post-command null gap; include connectionPath in follow effect deps so aborted navigates can resync.

Codex clean on 239bda3; CI green; smoke PASS (follow on/off, first open, go-to).
2026-10-20 08:25:40 +08:00
陈大猫 a622d953ad fix(terminal): preserve IIP sequence type with external metadata (#3762) 2026-10-20 01:17:10 +08:00
陈大猫 16c9af6e32 fix(window): keep terminal focus when clicking title-bar window controls (#3758)
Fixes #3757

preventDefault on mousedown for Minimize/Maximize so the xterm textarea keeps focus after minimize/restore. Close left unchanged so inline rename can still blur/commit.
2026-10-20 00:03:41 +08:00
陈大猫 64717fffd5 Merge pull request #3756 from binaricat/fix/3746-inline-image-resize-stride
fix: preserve inline image tile stride across window resize
2026-10-19 21:57:34 +08:00
陈大猫 2ba045c8e7 fix: preserve inline image tile stride across resize 2026-10-19 21:51:46 +08:00
陈大猫 8b0b7706a3 Merge pull request #3750 from Moyucharm/fix/port-forwarding-missing-os
fix(port-forwarding): tolerate missing host OS metadata
2026-10-19 17:33:39 +08:00
陈大猫 fa34a5602e Merge pull request #3753 from GOUKI9999/fix/mimo-autodiscovery
fix(ai): raise CLI version-probe timeout so slow-starting CLIs (mimo/opencode) are discovered
2026-10-19 17:09:10 +08:00
GOUKI9999 d14591b36a fix(ai): raise CLI version-probe timeout so slow-starting CLIs are discovered
Bun-compiled OpenCode-family CLIs answer --version slowly: measured
'mimo --version' at 3.9-5.1s warm (6s+ cold) and 'opencode --version'
at 6s cold on Windows. The 5s probe timeout killed these healthy CLIs
mid-probe, so agent discovery dropped them (exitCode null ->
not plausible) and the settings card showed them as unavailable. Raise
the version-probe timeout to 15s; a missing command still fails spawn
immediately, so only existing-but-slow CLIs pay the extra wait.
2026-10-19 16:59:35 +08:00
Moyucharm 2bb7baebde Merge upstream main to pick up README paste test fix 2026-10-19 16:16:12 +08:00
aa597135b1 ci: narrow spam-comment filter and relay comment edits (#3752)
* ci: hide only risky download links in issue comments

Stop deleting comments that merely mention an archive or executable
name. Match http(s) URLs whose path contains a risky extension, skip
the issue author as well as maintainers and bots, and minimize matches
as spam instead of deleting them.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* ci: relay edited issue comments with truncated bodies

Forward issue_comment edited events and include comment identity,
association, timestamps, and bodies clipped to 4000 characters.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 16:05:29 +08:00
陈大猫 42b8e2f347 Merge pull request #3747 from binaricat/codex/fix-ssh-panel-transfer-reliability
fix: isolate SSH auxiliary failures and validate downloads
2026-10-19 15:40:24 +08:00
9c312b7b98 ci: forward issue events to Grok Bot webhook (#3751)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-19 15:29:49 +08:00
Moyucharm 05e4bf69f7 fix(port-forwarding): tolerate missing host OS metadata
Guard avatar fallback calculations in new/edit panels and wizard host selection. Add real React rendering regression coverage and document baseline verification.
2026-10-19 15:18:54 +08:00
64 changed files with 2388 additions and 232 deletions

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
name: Forward issue events to Grok Bot
on:
issues:
types: [opened, reopened, edited]
issue_comment:
types: [created, edited]
permissions: {}
jobs:
forward:
# PR comments are handled elsewhere; only forward real issues.
if: github.event_name == 'issues' || github.event.issue.pull_request == null
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Forward minimal event to Grok Bot webhook
env:
WEBHOOK_URL: ${{ secrets.GROKBOT_WEBHOOK_URL }}
WEBHOOK_KEY: ${{ secrets.GROKBOT_WEBHOOK_KEY }}
run: |
if [ -z "$WEBHOOK_URL" ] || [ -z "$WEBHOOK_KEY" ]; then
echo "Grok Bot webhook secrets not set; skipping."
exit 0
fi
jq --arg event "$GITHUB_EVENT_NAME" '
def clip:
if type == "string" then .[0:4000] else . end;
{
event: $event,
action: .action,
repository: .repository.full_name,
sender: .sender.login,
issue: (
{
number: .issue.number,
title: .issue.title,
author: .issue.user.login,
url: .issue.html_url
} + (
if $event == "issues" then {
author_association: .issue.author_association,
body: (.issue.body | clip)
} else {} end
)
),
comment: (
if .comment then
{
id: .comment.id,
author: .comment.user.login,
url: .comment.html_url,
node_id: .comment.node_id,
author_association: .comment.author_association,
user_type: .comment.user.type,
created_at: .comment.created_at,
updated_at: .comment.updated_at,
body: (.comment.body | clip)
} + (
if .action == "edited" then {
previous_body: (.changes.body.from | clip)
} else {} end
)
else null end
)
}
' "$GITHUB_EVENT_PATH" \
| curl -fsS --max-time 20 -X POST \
-H "Authorization: Bearer $WEBHOOK_KEY" \
-H "Content-Type: application/json" \
--data @- "$WEBHOOK_URL"
+44 -11
View File
@@ -2,7 +2,7 @@ name: spam-comments
on:
issue_comment:
types: [created]
types: [created, edited]
permissions:
contents: read
@@ -10,13 +10,13 @@ permissions:
pull-requests: write
jobs:
delete-spam:
hide-spam:
runs-on: ubuntu-latest
steps:
- name: Checkout filter rules
uses: actions/checkout@v7
- name: Delete suspected malware spam
- name: Hide suspected malware spam
uses: actions/github-script@v9
with:
script: |
@@ -25,26 +25,59 @@ jobs:
path.join(process.env.GITHUB_WORKSPACE, 'scripts/spam-comment-filter.cjs')
);
const comment = context.payload.comment;
const comment = context.payload.comment || {};
const issue = context.payload.issue || {};
const login = (comment.user && comment.user.login) || 'unknown';
const result = detectSpamComment({
body: comment.body || '',
authorAssociation: comment.author_association,
userType: comment.user && comment.user.type,
commentAuthor: comment.user && comment.user.login,
issueAuthor: issue.user && issue.user.login,
});
core.info(JSON.stringify(result, null, 2));
if (!result.spam) {
core.info('Comment did not match the zip/dangerous-file spam filter.');
core.info('Comment did not match the risky-download spam filter.');
return;
}
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
const matchedUrl = result.matchedUrls.join(', ');
if (!comment.node_id) {
core.setFailed(
`Comment ${comment.id} by @${login} on #${issue.number} matched ${matchedUrl} but has no node_id.`
);
return;
}
try {
await github.graphql(
`mutation($input: MinimizeCommentInput!) {
minimizeComment(input: $input) {
minimizedComment {
isMinimized
}
}
}`,
{
input: {
subjectId: comment.node_id,
classifier: 'SPAM',
},
}
);
} catch (error) {
const message = String((error && error.message) || error);
if (/already minimized/i.test(message)) {
core.info(
`Comment ${comment.id} by @${login} on #${issue.number} is already minimized. Matched URL: ${matchedUrl}`
);
return;
}
throw error;
}
core.notice(
`Deleted comment with zip/dangerous attachment by @${comment.user.login} on #${context.payload.issue.number}: ${result.reasons.join('; ')}`
`Hid comment ${comment.id} by @${login} on #${issue.number} as spam. Matched URL: ${matchedUrl}`
);
+3
View File
@@ -829,6 +829,9 @@ Highlight the focused split pane:
'settings.terminal.connection.x11Display': 'X11 display',
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.connection.defaultProxy': 'Default proxy',
'settings.terminal.connection.defaultProxy.desc': 'Saved proxy used when a host has no proxy of its own and does not inherit one from a group. None connects directly.',
'settings.terminal.connection.defaultProxy.none': 'None',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.hostInfoBar.show': 'Show host information bar',
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
+2
View File
@@ -949,6 +949,8 @@ export const enVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'None',
'hostDetails.proxy.direct': 'Direct (no proxy)',
'hostDetails.proxy.directHint': 'Does not use a group or global default proxy',
'hostDetails.proxy.edit': 'Edit Proxy',
'hostDetails.proxy.configure': 'Configure Proxy',
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
+3
View File
@@ -744,6 +744,9 @@ Resalta el panel dividido enfocado:
'settings.terminal.connection.x11Display': 'Pantalla X11',
'settings.terminal.connection.x11Display.desc': 'Dirección de pantalla local opcional para el reenvío X11. Déjala vacía para usar el valor predeterminado del sistema.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 o DISPLAY)',
'settings.terminal.connection.defaultProxy': 'Proxy predeterminado',
'settings.terminal.connection.defaultProxy.desc': 'Proxy guardado que se usa cuando el host no tiene uno propio y no hereda ninguno de un grupo. Ninguno conecta directamente.',
'settings.terminal.connection.defaultProxy.none': 'Ninguno',
'settings.terminal.section.serverStats': 'Estadísticas del servidor (Linux)',
'settings.terminal.hostInfoBar.show': 'Mostrar barra de información del host',
'settings.terminal.hostInfoBar.show.desc': 'Muestra la dirección del host y la información del servidor sobre la terminal. Los botones de acción siguen disponibles cuando está oculta.',
+2
View File
@@ -939,6 +939,8 @@ export const esVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
'hostDetails.proxy.none': 'Ninguno',
'hostDetails.proxy.direct': 'Directo (sin proxy)',
'hostDetails.proxy.directHint': 'No usa el proxy del grupo ni el predeterminado global',
'hostDetails.proxy.edit': 'Editar proxy',
'hostDetails.proxy.configure': 'Configurar proxy',
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
+3
View File
@@ -782,6 +782,9 @@ export const ruCoreMessages: Messages = {
'settings.terminal.connection.x11Display': 'Дисплей X11',
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.connection.defaultProxy': 'Прокси по умолчанию',
'settings.terminal.connection.defaultProxy.desc': 'Сохраненный прокси, если у хоста нет своего и он не наследует прокси группы. «Нет» — прямое подключение.',
'settings.terminal.connection.defaultProxy.none': 'Нет',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
+2
View File
@@ -987,6 +987,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
'hostDetails.proxy.none': 'Нет',
'hostDetails.proxy.direct': 'Напрямую (без прокси)',
'hostDetails.proxy.directHint': 'Не использует прокси группы или глобальный прокси по умолчанию',
'hostDetails.proxy.edit': 'Редактировать прокси',
'hostDetails.proxy.configure': 'Настроить прокси',
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
@@ -47,6 +47,22 @@ test("localized settings include workspace focus indicator labels", () => {
}
});
test("localized settings include the global default proxy labels", () => {
const keys = [
"settings.terminal.connection.defaultProxy",
"settings.terminal.connection.defaultProxy.desc",
"settings.terminal.connection.defaultProxy.none",
"hostDetails.proxy.direct",
"hostDetails.proxy.directHint",
];
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
for (const locale of locales) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing global default proxy labels`);
}
});
test("localized settings include network proxy labels", () => {
const keys = [
"settings.system.networkProxy.title",
@@ -454,6 +454,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.defaultProxy': '默认代理',
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
'settings.terminal.connection.defaultProxy.none': '无',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
+2
View File
@@ -363,6 +363,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': '配置代理主机',
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
'hostDetails.proxy.none': '无',
'hostDetails.proxy.direct': '直连(不使用代理)',
'hostDetails.proxy.directHint': '不使用分组或全局默认代理',
'hostDetails.proxy.edit': '编辑代理',
'hostDetails.proxy.configure': '配置代理',
'hostDetails.envVars': '环境变量',
@@ -454,6 +454,9 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.defaultProxy': '預設代理',
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
'settings.terminal.connection.defaultProxy.none': '無',
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
+2
View File
@@ -363,6 +363,8 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.jumpHosts.configure': '設定代理主機',
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
'hostDetails.proxy.none': '無',
'hostDetails.proxy.direct': '直連(不使用代理)',
'hostDetails.proxy.directHint': '不使用群組或全域預設代理',
'hostDetails.proxy.edit': '編輯代理',
'hostDetails.proxy.configure': '設定代理',
'hostDetails.envVars': '環境變數',
+13
View File
@@ -51,6 +51,7 @@ import {
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import {
@@ -58,6 +59,10 @@ import {
normalizeHttpNetworkProxySettings,
type HttpNetworkProxySettings,
} from '../../domain/httpNetworkProxy';
import {
normalizeGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
} from '../../domain/globalDefaultProxy';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
isValidUiFontId,
@@ -98,6 +103,7 @@ interface UseSettingsIpcSyncParams {
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
setGlobalDefaultProxyProfileIdState: Dispatch<SetStateAction<string | undefined>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
@@ -146,6 +152,7 @@ export function useSettingsIpcSync({
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setGlobalDefaultProxyProfileIdState,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
@@ -292,6 +299,11 @@ export function useSettingsIpcSync({
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
));
}
if (key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) {
const next = normalizeGlobalDefaultProxyProfileId(value);
publishActiveGlobalDefaultProxyProfileId(next);
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
@@ -354,6 +366,7 @@ export function useSettingsIpcSync({
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setGlobalDefaultProxyProfileIdState,
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
@@ -530,8 +530,12 @@ test("only trusted live terminal cwd recovery resumes follow after initial probe
await act(async () => renderer?.unmount());
});
test("an in-flight follow probe cannot navigate after the focused session changes", async () => {
test("regular follow waits for trusted live cwd instead of polling after command clear", async () => {
// Regression for #3588: command submit clears cwd to null and schedules a
// backend-strict probe. Auto-follow must not preferFreshBackend-poll in that
// gap — that race aborted the navigate and left the panel stuck.
const navigatedPaths: string[] = [];
let probeCalls = 0;
const connection = {
id: "conn-1",
hostId: "host-1",
@@ -550,20 +554,15 @@ test("an in-flight follow probe cannot navigate after the focused session change
},
},
};
let activeSessionId = "session-a";
let activeTerminalCwd: string | null = "/home/alice";
let resolveCwd: ((cwd: string) => void) | null = null;
let deferProbe = false;
let activeTerminalCwdTrusted = false;
let renderer: ReactTestRenderer | null = null;
const onGetTerminalCwd = () => {
if (!deferProbe) return Promise.resolve("/root/session-a");
return new Promise<string>((resolve) => { resolveCwd = resolve; });
};
function Probe() {
useSftpFollowTerminalCwd({
activeSessionId,
activeSessionId: "session-1",
activeTerminalCwd,
activeTerminalCwdTrusted,
canFollowTerminalCwd: true,
connectionId: connection.id,
connectionIsLocal: connection.isLocal,
@@ -575,7 +574,11 @@ test("an in-flight follow probe cannot navigate after the focused session change
hasActiveWork: false,
isVisible: true,
ownerPanelOpen: true,
onGetTerminalCwd,
onGetTerminalCwd: async () => {
probeCalls += 1;
// First-open may probe; keep the pane where it already is.
return connection.currentPath;
},
onPendingFollowOverride: () => {},
sftpRef,
});
@@ -586,23 +589,28 @@ test("an in-flight follow probe cannot navigate after the focused session change
renderer = create(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
const probesAfterOpen = probeCalls;
assert.ok(probesAfterOpen >= 1, "first-open sync still probes");
assert.deepEqual(navigatedPaths, []);
deferProbe = true;
// Command-submit style clear: no trusted cwd yet.
activeTerminalCwd = null;
activeTerminalCwdTrusted = false;
await act(async () => {
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.ok(resolveCwd);
assert.equal(probeCalls, probesAfterOpen, "regular follow must not poll while cwd is cleared");
assert.deepEqual(navigatedPaths, []);
activeSessionId = "session-b";
await act(async () => renderer?.update(React.createElement(Probe)));
// Post-command backend-strict (or OSC 7) publishes a trusted live cwd.
activeTerminalCwd = "/etc";
activeTerminalCwdTrusted = true;
await act(async () => {
resolveCwd?.("/root/from-session-a");
renderer?.update(React.createElement(Probe));
await new Promise((resolve) => setImmediate(resolve));
});
assert.deepEqual(navigatedPaths, ["/root/session-a"]);
assert.deepEqual(navigatedPaths, ["/etc"]);
assert.equal(probeCalls, probesAfterOpen, "trusted live cwd must navigate without another poll");
await act(async () => renderer?.unmount());
});
@@ -302,6 +302,12 @@ export function useSftpFollowTerminalCwd({
&& previousOriginId !== expectedSessionId,
);
if (expectedSessionId) lastFollowOriginIdRef.current = expectedSessionId;
// Auto-follow must wait for a trusted live cwd (OSC 7, post-command
// backend-strict probe, or inferred). Polling preferFreshBackend here while
// cwd is null/untrusted races that probe: navigate starts, the probe then
// invalidates the sync generation, abort restores the previous path, and
// without a connectionPath effect dependency the panel stays put (#3588).
// Manual "Go to terminal directory" and first-open sync still probe.
const usesLiveTerminalCwd = Boolean(activeTerminalCwd && activeTerminalCwdTrusted);
let terminalCwd = usesLiveTerminalCwd ? activeTerminalCwd : null;
if (!terminalCwd && originChanged) {
@@ -311,12 +317,6 @@ export function useSftpFollowTerminalCwd({
homeDir: sftpRef.current.leftPane.connection?.homeDir,
currentPath: sftpRef.current.leftPane.connection?.currentPath,
});
} else if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({
preferFreshBackend: true,
allowRendererFallback: false,
requireActiveShellCwd: true,
});
}
if (!terminalCwd) return;
if (!shouldApplyFollowTerminalCwdSyncResult({
@@ -430,6 +430,7 @@ export function useSftpFollowTerminalCwd({
canFollowTerminalCwd,
connectionId,
connectionIsLocal,
connectionPath,
connectionStatus,
effectiveFollowTerminalCwd,
focusedSessionId,
@@ -0,0 +1,14 @@
import { useSyncExternalStore } from 'react';
import {
getActiveGlobalDefaultProxyProfileId,
subscribeActiveGlobalDefaultProxyProfileId,
} from '../../domain/globalDefaultProxy';
export const useActiveGlobalDefaultProxyProfileId = (): string | undefined => {
return useSyncExternalStore(
subscribeActiveGlobalDefaultProxyProfileId,
getActiveGlobalDefaultProxyProfileId,
getActiveGlobalDefaultProxyProfileId,
);
};
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import {
clearActiveGlobalDefaultProxyProfile,
getActiveGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
} from "../../domain/globalDefaultProxy.ts";
import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE } from "../../infrastructure/config/storageKeys.ts";
import { useSettingsState } from "./useSettingsState.ts";
type Notification = { key: string; value: unknown };
const withSettingsDom = async (
run: (env: { dom: JSDOM; root: Root; notifications: Notification[] }) => Promise<void>,
) => {
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
const globalAny = globalThis as Record<string, unknown>;
const saved = {
window: globalAny.window,
document: globalAny.document,
localStorage: globalAny.localStorage,
actEnv: globalAny.IS_REACT_ACT_ENVIRONMENT,
};
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
const notifications: Notification[] = [];
dom.window.netcatty = {
notifySettingsChanged: (payload: Notification) => {
notifications.push(payload);
},
} as Window["netcatty"];
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-1");
const container = dom.window.document.createElement("div");
dom.window.document.body.appendChild(container);
const root = createRoot(container);
try {
await run({ dom, root, notifications });
} finally {
await act(async () => {
root.unmount();
});
publishActiveGlobalDefaultProxyProfileId(undefined);
container.remove();
dom.window.close();
for (const key of ["window", "document", "localStorage"] as const) {
if (saved[key] === undefined) delete globalAny[key];
else {
Object.defineProperty(globalThis, key, {
configurable: true,
enumerable: true,
writable: true,
value: saved[key],
});
}
}
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
configurable: true,
value: saved.actEnv,
});
}
};
const proxyNotifications = (notifications: Notification[]) => (
notifications.filter((entry) => entry.key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE)
);
test("closing a second settings instance still persists a cleared global default proxy", async () => {
await withSettingsDom(async ({ dom, root, notifications }) => {
let showSecond = true;
function SettingsProbe() {
useSettingsState({ enableSystemEffects: false });
return null;
}
function Harness() {
return React.createElement(
React.Fragment,
null,
React.createElement(SettingsProbe),
showSecond ? React.createElement(SettingsProbe) : null,
);
}
await act(async () => {
root.render(React.createElement(Harness));
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-1");
showSecond = false;
await act(async () => {
root.render(React.createElement(Harness));
});
notifications.length = 0;
await act(async () => {
clearActiveGlobalDefaultProxyProfile();
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
assert.deepEqual(proxyNotifications(notifications), [
{ key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, value: "" },
]);
});
});
test("a peer session window follows global default proxy storage without settings sync", async () => {
await withSettingsDom(async ({ dom, root, notifications }) => {
function PeerSettings() {
useSettingsState({ enableSettingsSync: false, enableSystemEffects: false });
return null;
}
await act(async () => {
root.render(React.createElement(PeerSettings));
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
notifications.length = 0;
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-2");
await act(async () => {
dom.window.dispatchEvent(new dom.window.StorageEvent("storage", {
key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
newValue: "profile-2",
storageArea: dom.window.localStorage,
}));
});
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-2");
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-2");
assert.deepEqual(proxyNotifications(notifications), []);
});
});
+53 -1
View File
@@ -70,6 +70,7 @@ import {
STORAGE_KEY_SHOW_TRAY_ICON,
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
STORAGE_KEY_HTTP_NETWORK_PROXY,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
@@ -104,6 +105,11 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
normalizeGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
registerGlobalDefaultProxyProfileSetter,
} from '../../domain/globalDefaultProxy';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
resolveSftpTransferConcurrency,
@@ -574,6 +580,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
});
const [globalDefaultProxyProfileId, setGlobalDefaultProxyProfileIdState] = useState<string | undefined>(() => {
const next = normalizeGlobalDefaultProxyProfileId(
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
);
publishActiveGlobalDefaultProxyProfileId(next);
return next;
});
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
setHttpNetworkProxyState((prev) => {
const candidate = typeof nextValue === 'function'
@@ -796,6 +809,37 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}
}, [enableSettingsSync]);
const setGlobalDefaultProxyProfileId = useCallback((value: string | undefined) => {
const next = normalizeGlobalDefaultProxyProfileId(value);
setGlobalDefaultProxyProfileIdState(next);
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
publishActiveGlobalDefaultProxyProfileId(next);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
}, [notifySettingsChanged]);
useLayoutEffect(() => {
publishActiveGlobalDefaultProxyProfileId(globalDefaultProxyProfileId);
}, [globalDefaultProxyProfileId]);
useEffect(
() => registerGlobalDefaultProxyProfileSetter(setGlobalDefaultProxyProfileId),
[setGlobalDefaultProxyProfileId],
);
// Peer session windows set enableSettingsSync false, which skips IPC sync.
// This setting still has to follow localStorage or an already-open session
// window keeps the proxy id it read at mount.
useEffect(() => {
const onStorage = (event: StorageEvent) => {
if (event.key !== STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) return;
const next = normalizeGlobalDefaultProxyProfileId(event.newValue);
publishActiveGlobalDefaultProxyProfileId(next);
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
@@ -1079,6 +1123,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
),
);
const nextGlobalDefaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
);
publishActiveGlobalDefaultProxyProfileId(nextGlobalDefaultProxyProfileId);
setGlobalDefaultProxyProfileIdState(nextGlobalDefaultProxyProfileId);
// Custom terminal themes
customThemeStore.loadFromStorage();
@@ -1227,6 +1276,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setAutoUpdateEnabled,
setAutoLaunchEnabled,
setHttpNetworkProxy,
setGlobalDefaultProxyProfileIdState,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
@@ -2211,6 +2261,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
autoLaunchSupported,
httpNetworkProxy,
setHttpNetworkProxy,
globalDefaultProxyProfileId,
setGlobalDefaultProxyProfileId,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
@@ -2233,7 +2285,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, globalDefaultProxyProfileId,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+24
View File
@@ -454,6 +454,30 @@ test("note appearance settings survive sync and trigger auto-sync", async () =>
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
});
test("buildSyncPayload round-trips the global default proxy profile", async () => {
localStorage.clear();
const absent = buildSyncPayload(vault([]));
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
const selected = buildSyncPayload(vault([]));
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
const cleared = buildSyncPayload(vault([]));
assert.equal(cleared.settings?.defaultProxyProfileId, "");
localStorage.clear();
await applySyncPayload(selected, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
await applySyncPayload(cleared, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
));
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
+13
View File
@@ -33,6 +33,7 @@ import {
type SyncPayload,
} from '../domain/sync';
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
import {
@@ -112,6 +113,7 @@ import {
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
@@ -345,6 +347,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
@@ -592,6 +595,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
}
const storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
if (storedDefaultProxyProfileId !== null) {
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
}
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
@@ -906,6 +913,12 @@ async function applySyncableSettings(
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}
if (typeof settings.defaultProxyProfileId === 'string') {
localStorageAdapter.writeString(
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
);
}
const ai = settings.ai;
if (ai) {
+26 -1
View File
@@ -687,7 +687,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
title={t("hostDetails.proxy")}
className="overflow-hidden"
>
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
{form.directProxy ? (
<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">
{t("hostDetails.proxy.direct")}
</Badge>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{t("hostDetails.proxy.directHint")}
</span>
</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>
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
<button
type="button"
+12 -3
View File
@@ -453,7 +453,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
setForm((prev) => {
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
return {
...rest,
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
@@ -465,19 +465,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
const clearProxyConfig = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
return rest as Host;
});
}, []);
const selectProxyProfile = useCallback((profileId: string | undefined) => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
if (!profileId) return rest as Host;
return { ...rest, proxyProfileId: profileId } as Host;
});
}, []);
const selectDirectProxy = useCallback(() => {
setForm((prev) => {
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
return { ...rest, directProxy: true } as Host;
});
}, []);
const addHostToChain = (hostId: string) => {
setForm((prev) => ({
...prev,
@@ -886,8 +893,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
proxyProfiles={proxyProfiles}
identities={identities}
selectedProxyProfileId={form.proxyProfileId}
directProxy={form.directProxy}
onUpdateProxy={updateProxyConfig}
onSelectProxyProfile={selectProxyProfile}
onSelectDirectProxy={selectDirectProxy}
onClearProxy={clearProxyConfig}
onBack={() => setActiveSubPanel("none")}
onCancel={onCancel}
+138
View File
@@ -0,0 +1,138 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider";
import type { Host, PortForwardingRule, PortForwardingType } from "../domain/models";
import { EditPanel } from "./port-forwarding/EditPanel";
import { NewFormPanel } from "./port-forwarding/NewFormPanel";
import { WizardContent } from "./port-forwarding/WizardContent";
import { TooltipProvider } from "./ui/tooltip";
const baseHost: Omit<Host, "os"> = {
id: "host-1",
label: "Forwarding host",
hostname: "example.com",
username: "user",
tags: [],
};
const baseRule: PortForwardingRule = {
id: "rule-1",
label: "Test forwarding rule",
type: "local",
hostId: baseHost.id,
localPort: 8080,
bindAddress: "127.0.0.1",
remoteHost: "localhost",
remotePort: 80,
status: "inactive",
createdAt: 1,
};
const noop = () => undefined;
const panelRenderers = {
edit: (hosts: Host[], draft: PortForwardingRule) => (
<EditPanel
rule={draft}
draft={draft}
hosts={hosts}
onDraftChange={noop}
onSave={noop}
onClose={noop}
onDuplicate={noop}
onDelete={noop}
onOpenHostSelector={noop}
/>
),
new: (hosts: Host[], draft: PortForwardingRule) => (
<NewFormPanel
draft={draft}
hosts={hosts}
onDraftChange={noop}
onSave={noop}
onClose={noop}
onOpenHostSelector={noop}
onOpenWizard={noop}
isValid={false}
/>
),
"remote-host-selection": (hosts: Host[], draft: PortForwardingRule) => (
<WizardContent
step="remote-host-selection"
type={draft.type}
draft={draft}
hosts={hosts}
onTypeChange={noop}
onDraftChange={noop}
onOpenHostSelector={noop}
/>
),
"host-selection": (hosts: Host[], draft: PortForwardingRule) => (
<WizardContent
step="host-selection"
type={draft.type}
draft={draft}
hosts={hosts}
onTypeChange={noop}
onDraftChange={noop}
onOpenHostSelector={noop}
/>
),
};
const hostCases: { name: string; fields: { os?: Host["os"] | "" | null } }[] = [
{ name: "missing OS", fields: {} },
{ name: "undefined OS", fields: { os: undefined } },
{ name: "null OS", fields: { os: null } },
{ name: "empty OS", fields: { os: "" } },
{ name: "Linux", fields: { os: "linux" } },
{ name: "Windows", fields: { os: "windows" } },
{ name: "macOS", fields: { os: "macos" } },
];
function render(panel: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>{panel}</TooltipProvider>
</I18nProvider>,
);
}
for (const [panelName, renderPanel] of Object.entries(panelRenderers)) {
for (const type of ["local", "remote", "dynamic"] as PortForwardingType[]) {
for (const { name, fields } of hostCases) {
test(`${panelName} renders ${type} forwarding with ${name}`, () => {
// Persisted legacy records can violate the required Host.os type at runtime.
const host = { ...baseHost, ...fields } as unknown as Host;
const markup = render(renderPanel([host], { ...baseRule, type }));
assert.match(markup, /Forwarding host/);
assert.match(markup, /lucide-server/);
});
}
}
test(`${panelName} preserves the selected host's distro icon`, () => {
const host: Host = { ...baseHost, os: "linux", distro: "ubuntu" };
const markup = render(renderPanel([host], baseRule));
assert.match(markup, /Forwarding host/);
assert.match(markup, /src="\/distro\/ubuntu\.svg"/);
});
test(`${panelName} renders the host picker when no host is selected`, () => {
const markup = render(renderPanel([], { ...baseRule, hostId: undefined }));
assert.match(markup, /Select a host/);
assert.doesNotMatch(markup, /Forwarding host/);
});
test(`${panelName} renders the host picker when the selected host is unavailable`, () => {
const markup = render(renderPanel([], baseRule));
assert.match(markup, /Select a host/);
assert.doesNotMatch(markup, /Forwarding host/);
});
}
+7
View File
@@ -27,6 +27,10 @@ import {
removeProxyProfileReferences,
updateProxyConfigField,
} from "../domain/proxyProfiles";
import {
clearActiveGlobalDefaultProxyProfile,
getActiveGlobalDefaultProxyProfileId,
} from "../domain/globalDefaultProxy";
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
import {
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
@@ -391,6 +395,9 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
const confirmDelete = () => {
if (!deleteTarget) return;
if (getActiveGlobalDefaultProxyProfileId() === deleteTarget.id) {
clearActiveGlobalDefaultProxyProfile();
}
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
hosts,
groupConfigs,
+8
View File
@@ -141,6 +141,8 @@ type TerminalTabSettingsProps = Pick<
| 'setLocalShellSidePanelAutoOpenTab'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
| 'globalDefaultProxyProfileId'
| 'setGlobalDefaultProxyProfileId'
>;
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
@@ -171,6 +173,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
setLocalShellSidePanelAutoOpenTab,
workspaceFocusStyle,
setWorkspaceFocusStyle,
globalDefaultProxyProfileId,
setGlobalDefaultProxyProfileId,
}) {
const availableFonts = useAvailableFonts();
@@ -204,6 +208,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
availableFonts={availableFonts}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
globalDefaultProxyProfileId={globalDefaultProxyProfileId}
setGlobalDefaultProxyProfileId={setGlobalDefaultProxyProfileId}
/>
);
});
@@ -599,6 +605,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
globalDefaultProxyProfileId={settings.globalDefaultProxyProfileId}
setGlobalDefaultProxyProfileId={settings.setGlobalDefaultProxyProfileId}
/>
</SettingsLazyTab>
)}
+21 -13
View File
@@ -76,8 +76,8 @@ import {
resolveLocateSftpPathInTerminalAction,
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { sftpRequiresDedicatedLogin } from "../domain/sftpDedicatedLogin";
import { classifyDistroId } from "../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
@@ -577,7 +577,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const terminalBackend = useTerminalBackend();
const [sftpBannerDecision, setSftpBannerDecision] = useState<{
sessionId: string;
singleChannel: boolean;
remoteSshVersion?: string;
} | null>(null);
const activeSessionStatusForBanner = activeSessionId
? sessions.find((session) => session.id === activeSessionId)?.status ?? null
@@ -590,7 +590,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}
const readRemoteInfo = terminalBackend.getSessionRemoteInfo;
if (!readRemoteInfo) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
setSftpBannerDecision({ sessionId: activeSessionId });
return;
}
let cancelled = false;
@@ -598,11 +598,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (cancelled) return;
setSftpBannerDecision({
sessionId: activeSessionId,
singleChannel: remoteSoftwareRequiresSingleChannel(info?.remoteSshVersion),
remoteSshVersion: info?.remoteSshVersion,
});
}).catch(() => {
if (!cancelled) {
setSftpBannerDecision({ sessionId: activeSessionId, singleChannel: false });
setSftpBannerDecision({ sessionId: activeSessionId });
}
});
return () => {
@@ -733,8 +733,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
nextStatus: activeSessionStatus ?? (activeSessionId ? "connected" : null),
});
// The peer banner is known only after the terminal handshake. Wait for it
// before choosing a shared terminal channel, so a one-channel bastion
// dials its file login directly.
// before choosing a shared terminal channel, so a one-channel bastion or
// network-vendor switch dials its file login directly.
if (
activeSessionId
&& activeSessionStatus === "connected"
@@ -935,8 +935,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
const dedicatedSftpLogin = sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel;
// Independent login: network-device mode, a network-vendor banner, or a
// one-channel bastion. Do not open SFTP on the terminal connection.
const dedicatedSftpLogin = sftpRequiresDedicatedLogin({
host: activeHost,
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
? sftpBannerDecision.remoteSshVersion
: undefined,
});
const reuseTerminalTransport = !dedicatedSftpLogin && (
Boolean(pendingStrictSourceSessionId) || activeSessionStatus === "connected"
);
@@ -1382,10 +1388,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
activeSessionId={activeSessionId}
bannerRequiresSingleChannel={
sftpBannerDecision?.sessionId === activeSessionId
&& sftpBannerDecision.singleChannel
}
bannerRequiresSingleChannel={sftpRequiresDedicatedLogin({
host: activeHost,
remoteSshVersion: sftpBannerDecision?.sessionId === activeSessionId
? sftpBannerDecision.remoteSshVersion
: undefined,
})}
focusedSessionId={focusedSessionId}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
+8 -4
View File
@@ -38,6 +38,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { useActiveGlobalDefaultProxyProfileId } from "../application/state/useGlobalDefaultProxyProfileId";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
@@ -161,16 +162,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
resolveTransferSourceSessionId,
]);
// Pre-resolve group defaults so SFTP connections inherit group config
// Memoized on vault props, so this subscription is what refreshes the list
// after the global default changes.
const globalDefaultProxyProfileId = useActiveGlobalDefaultProxyProfileId();
const effectiveHosts = useMemo(() => {
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
const inheritanceOptions = { validProxyProfileIds, globalProxyProfileId: globalDefaultProxyProfileId };
return hosts.map(h => {
const withGroupDefaults = h.group
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
: applyGroupDefaults(h, {}, { validProxyProfileIds });
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, inheritanceOptions), inheritanceOptions)
: applyGroupDefaults(h, {}, inheritanceOptions);
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
});
}, [hosts, groupConfigs, proxyProfiles]);
}, [hosts, groupConfigs, proxyProfiles, globalDefaultProxyProfileId]);
const hostWriteSource = writableHosts ?? hosts;
+12 -2
View File
@@ -1162,7 +1162,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
allowRendererFallback?: boolean;
requireActiveShellCwd?: boolean;
}): Promise<string | undefined> => {
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
// The backend pwd probe opens an exec channel on the terminal connection;
// network devices (mode, detected distro, or vendor banner such as Huawei
// `-`) can drop the whole session for it (#1043, #3631).
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
if (!skipBackendPwd && sessionRef.current) {
const info = await terminalBackend.getSessionRemoteInfo?.(sessionRef.current).catch(() => null);
skipBackendPwd = !shouldProbeSessionCwd({
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
remoteSshVersion: info?.remoteSshVersion,
});
}
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: terminalCwdTracker.getRendererCwd(),
rendererCwdSource: terminalCwdTracker.getRendererCwdSource(),
@@ -1175,7 +1185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
requireActiveShellCwd: options?.requireActiveShellCwd,
});
return cwd ?? undefined;
}, [terminalBackend, terminalCwdTracker]);
}, [host, terminalBackend, terminalCwdTracker]);
const clearTerminalCwd = useCallback((options?: { persistRestoreMetadata?: boolean }) => {
terminalCwdTracker.clearRendererCwd();
+9 -1
View File
@@ -1594,7 +1594,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}): Promise<string | null> => {
const sessionId = getActiveTerminalSessionId();
const host = sessionId ? sessionHostsMapRef.current.get(sessionId) : undefined;
const skipBackendPwd = hostRestrictsExtraSshChannels(host);
// Same rule as the terminal's own SFTP path lookup (#3631).
let skipBackendPwd = hostRestrictsExtraSshChannels(host);
if (!skipBackendPwd && sessionId && host) {
const info = await terminalBackend.getSessionRemoteInfo?.(sessionId).catch(() => null);
skipBackendPwd = !shouldProbeSessionCwd({
isNetworkDevice: classifyDistroId(host.distro) === 'network-device',
remoteSshVersion: info?.remoteSshVersion,
});
}
return resolvePreferredTerminalCwd({
rendererCwd: sessionId ? terminalRendererCwdBySessionRef.current.get(sessionId) : undefined,
rendererCwdSource: sessionId
+20
View File
@@ -294,6 +294,26 @@ test("session close button double click stays on the close button", () => {
assert.match(topTabItemsSource, /onDoubleClick=\{stopCloseButtonDoubleClickPropagation\}/);
});
test("main window controls keep mouse presses from taking focus", () => {
const windowControlsSource = topTabItemsSource.slice(
topTabItemsSource.indexOf("export const WindowControls"),
topTabItemsSource.indexOf("WindowControls.displayName"),
);
assert.match(
windowControlsSource,
/const preventWindowControlFocus: React\.MouseEventHandler<HTMLButtonElement> = \(event\) => \{\s*event\.preventDefault\(\);\s*\};/,
);
assert.equal(windowControlsSource.match(/onMouseDown=\{preventWindowControlFocus\}/g)?.length, 2);
const closeButton = windowControlsSource.match(
/<button type="button" className=\{closeControlClassName\}[^>]*>/,
)?.[0] ?? "";
assert.equal(closeButton.includes("onMouseDown"), false);
assert.match(windowControlsSource, /onClick=\{handleMinimize\}/);
assert.match(windowControlsSource, /onClick=\{handleMaximize\}/);
assert.match(windowControlsSource, /onClick=\{handleClose\}/);
});
test("session top tab label only shows the host label for ssh sessions", () => {
assert.equal(
formatSessionTopTabLabel({
+27 -9
View File
@@ -27,8 +27,10 @@ export interface ProxyPanelProps {
proxyProfiles?: ProxyProfile[];
identities?: Identity[];
selectedProxyProfileId?: string;
directProxy?: boolean;
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
onSelectProxyProfile?: (profileId: string | undefined) => void;
onSelectDirectProxy?: () => void;
onClearProxy: () => void;
onBack: () => void;
onCancel: () => void;
@@ -42,8 +44,10 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
proxyProfiles = [],
identities = [],
selectedProxyProfileId,
directProxy = false,
onUpdateProxy,
onSelectProxyProfile,
onSelectDirectProxy,
onClearProxy,
onBack,
onCancel,
@@ -54,13 +58,18 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
}) => {
const { t } = useI18n();
const customValue = '__custom__';
const directValue = '__direct__';
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 hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile && !directProxy);
const selectedValue = directProxy
? directValue
: selectedProfile
? selectedProfile.id
: customValue;
const isUsingProfile = Boolean(selectedProfile) && !directProxy;
const isCommandProxy = isProxyCommandConfig(proxyConfig);
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
@@ -71,8 +80,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
const canSave = directProxy || (isUsingProfile && !hasInvalidIdentity) ||
(!isUsingProfile && !directProxy && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
const manualCredentialsValue = '__manual_credentials__';
const missingIdentityValue = '__missing_identity__';
const selectedIdentity = useMemo(
@@ -103,7 +112,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
}
>
<AsidePanelContent>
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
{(proxyProfiles.length > 0 || hasMissingProfile || onSelectDirectProxy) && 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" />
@@ -111,7 +120,13 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</div>
<Select
value={selectedValue}
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
onValueChange={(value) => {
if (value === directValue) {
onSelectDirectProxy?.();
return;
}
onSelectProxyProfile(value === customValue ? undefined : value);
}}
>
<SelectTrigger
aria-label={t('hostDetails.proxyPanel.savedProxy')}
@@ -120,6 +135,9 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
</SelectTrigger>
<SelectContent>
{onSelectDirectProxy && (
<SelectItem value={directValue}>{t('hostDetails.proxy.direct')}</SelectItem>
)}
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
{proxyProfiles.map((profile) => (
<SelectItem key={profile.id} value={profile.id}>
@@ -163,7 +181,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</Card>
)}
{!isUsingProfile && (
{!isUsingProfile && !directProxy && (
<>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="space-y-2">
@@ -328,7 +346,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
</>
)}
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
</Button>
+1 -1
View File
@@ -134,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
<div className="flex items-center gap-2">
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="tree"
/>
<span>{selectedHost.label}</span>
+1 -1
View File
@@ -134,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
<div className="flex items-center gap-2">
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="tree"
/>
<span>{selectedHost.label}</span>
+10 -2
View File
@@ -123,7 +123,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<DistroAvatar
host={selectedHost}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="sm"
/>
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -228,7 +232,11 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<DistroAvatar
host={selectedHost}
fallback={(selectedHost.os || 'unknown').charAt(0).toUpperCase()}
size="sm"
/>
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -5,10 +5,13 @@ import type {
CursorShape,
HostInfoBarTitleMode,
PasswordPromptAssistMode,
ProxyProfile,
TerminalEmulationType,
TerminalSettings,
} from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_PROXY_PROFILES } from "../../../infrastructure/config/storageKeys";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
@@ -43,6 +46,34 @@ import {
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN,
} from "../../../domain/terminalInlineImages";
const GLOBAL_DEFAULT_PROXY_NONE = "__none__";
const readSavedProxyProfiles = (): ProxyProfile[] => {
const stored = localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
return Array.isArray(stored) ? stored : [];
};
const useSavedProxyProfiles = (): ProxyProfile[] => {
const [profiles, setProfiles] = useState<ProxyProfile[]>(readSavedProxyProfiles);
useEffect(() => {
const refresh = (key?: string | null) => {
if (key && key !== STORAGE_KEY_PROXY_PROFILES) return;
setProfiles(readSavedProxyProfiles());
};
const onAdapterChange = (event: Event) => {
refresh((event as CustomEvent<{ key?: string }>).detail?.key);
};
const onStorage = (event: StorageEvent) => refresh(event.key);
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
window.removeEventListener("storage", onStorage);
};
}, []);
return profiles;
};
const FONT_WEIGHT_OPTIONS = [
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
@@ -87,6 +118,8 @@ function SettingsTerminalTab(props: {
availableFonts: TerminalFont[];
workspaceFocusStyle: 'dim' | 'border';
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
globalDefaultProxyProfileId?: string;
setGlobalDefaultProxyProfileId: (id: string | undefined) => void;
}) {
const {
terminalThemeId,
@@ -117,8 +150,14 @@ function SettingsTerminalTab(props: {
availableFonts,
workspaceFocusStyle,
setWorkspaceFocusStyle,
globalDefaultProxyProfileId,
setGlobalDefaultProxyProfileId,
} = props;
const { t } = useI18n();
const proxyProfiles = useSavedProxyProfiles();
const defaultProxyValue = proxyProfiles.some((profile) => profile.id === globalDefaultProxyProfileId)
? globalDefaultProxyProfileId ?? GLOBAL_DEFAULT_PROXY_NONE
: GLOBAL_DEFAULT_PROXY_NONE;
// Local shell settings state
const [defaultShell, setDefaultShell] = useState<string>("");
@@ -932,6 +971,23 @@ function SettingsTerminalTab(props: {
<SectionHeader title={t("settings.terminal.section.connection")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
anchorId="terminal-default-proxy"
label={t("settings.terminal.connection.defaultProxy")}
description={t("settings.terminal.connection.defaultProxy.desc")}
>
<Select
value={defaultProxyValue}
onChange={(value) => {
setGlobalDefaultProxyProfileId(value === GLOBAL_DEFAULT_PROXY_NONE ? undefined : value);
}}
options={[
{ value: GLOBAL_DEFAULT_PROXY_NONE, label: t("settings.terminal.connection.defaultProxy.none") },
...proxyProfiles.map((profile) => ({ value: profile.id, label: profile.label })),
]}
className="w-56"
/>
</SettingRow>
<SettingRow
anchorId="terminal-verify-host-keys"
label={t("settings.terminal.connection.verifyHostKeys")}
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { remoteSoftwareRequiresSingleChannel } from "../../domain/singleChannelSshBanner.shared.mjs";
import { sftpRequiresDedicatedLogin } from "../../domain/sftpDedicatedLogin";
const sidePanelSource = readFileSync(new URL("../SftpSidePanel.tsx", import.meta.url), "utf8");
const slotSource = readFileSync(
@@ -49,12 +51,99 @@ test("locate-path uses focused session fallback when SFTP cannot reuse the termi
test("single-channel SFTP opens a dedicated connection instead of the terminal channel", () => {
assert.match(
sidePanelSource,
/const dedicatedSftpLogin = sftpBannerDecision\?\.sessionId === activeSessionId\s*&& sftpBannerDecision\.singleChannel;/,
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\);/,
);
assert.doesNotMatch(sidePanelSource, /activeHost\.singleChannelSsh/);
assert.doesNotMatch(sidePanelSource, /sftpBannerDecision\.singleChannel/);
assert.match(sidePanelSource, /reuseTransport: false/);
assert.match(
sidePanelSource,
/const reuseTerminalTransport = !dedicatedSftpLogin &&/,
);
assert.match(
sidePanelSource,
/sourceSessionId: reuseTerminalTransport/,
);
});
test("network-device mode and network-vendor banners force dedicated SFTP", () => {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "network" }, remoteSshVersion: "OpenSSH_9.6" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "-" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0--" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "SSH-2.0-RGOS_SSH" }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion: "OpenSSH_9.6" }),
false,
);
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
});
test("vendor banners block follow-terminal-cwd the same way bastions do", () => {
assert.match(
sidePanelSource,
/bannerRequiresSingleChannel=\{sftpRequiresDedicatedLogin\(\{\s*host: activeHost,\s*remoteSshVersion: sftpBannerDecision\?\.sessionId === activeSessionId\s*\? sftpBannerDecision\.remoteSshVersion\s*: undefined,\s*\}\)\}/,
);
assert.doesNotMatch(sidePanelSource, /remoteSoftwareRequiresSingleChannel/);
for (const remoteSshVersion of ["-", "SSH-2.0--", "BHostSSH_7.0"]) {
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "general" },
remoteSshVersion,
}),
true,
remoteSshVersion,
);
}
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "network" },
remoteSshVersion: "OpenSSH_9.6",
}),
true,
);
});
test("opening SFTP skips the terminal pwd exec for network-vendor banners", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const assertBannerPwdGuard = (source: string, fnName: string) => {
const start = source.indexOf(`const ${fnName} = useCallback`);
assert.ok(start >= 0, fnName);
const body = source.slice(start, start + 1600);
assert.match(body, /let skipBackendPwd = hostRestrictsExtraSshChannels\(host\);/);
assert.match(body, /getSessionRemoteInfo\?\./);
assert.match(body, /skipBackendPwd = !shouldProbeSessionCwd\(\{/);
assert.match(body, /classifyDistroId\(host\.distro\) === 'network-device'/);
assert.match(body, /remoteSshVersion: info\?\.remoteSshVersion/);
assert.match(body, /getSessionPwd: skipBackendPwd/);
};
assertBannerPwdGuard(terminalSource, "resolveSftpInitialPath");
assertBannerPwdGuard(layerSource, "getTerminalCwd");
assert.match(
terminalSource,
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from "\.\.\/domain\/host";/,
);
assert.match(
layerSource,
/import \{ classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd \} from '\.\.\/domain\/host';/,
);
});
@@ -17,8 +17,13 @@ test("SFTP side panel rebinds after same-tab SSH start-over", () => {
/previousStatus:\s*lastSourceSessionStatusRef\.current/,
);
// Reuse the terminal transport only when SSH is connected, or when a strict
// pending source id is already pinned. A dedicated single-channel login never
// reuses that transport. A linked id may arrive while reconnecting.
// pending source id is already pinned. A dedicated login (bastion banner,
// network-vendor banner, or network-device mode) never reuses that
// transport. A linked id may arrive while reconnecting.
assert.match(
sidePanelSource,
/const dedicatedSftpLogin = sftpRequiresDedicatedLogin\(\{/,
);
assert.match(
sidePanelSource,
/const reuseTerminalTransport = !dedicatedSftpLogin && \(\s*Boolean\(pendingStrictSourceSessionId\) \|\| activeSessionStatus === "connected"\s*\);/,
+8 -2
View File
@@ -310,13 +310,19 @@ export const WindowControls: React.FC = memo(() => {
const controlClassName = 'window-control-btn app-no-drag';
const closeControlClassName = 'window-control-btn window-control-btn--close app-no-drag';
// Mouse press would move DOM focus onto minimize/maximize. After restore
// that leaves the terminal textarea unfocused, so keystrokes do nothing.
// Close skips this so the active field still blurs (inline rename commits on blur).
const preventWindowControlFocus: React.MouseEventHandler<HTMLButtonElement> = (event) => {
event.preventDefault();
};
return (
<div className="ml-2 flex h-7 items-center overflow-visible app-no-drag">
<button type="button" className={controlClassName} onClick={handleMinimize}>
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMinimize}>
<Minus size={16} />
</button>
<button type="button" className={controlClassName} onClick={handleMaximize}>
<button type="button" className={controlClassName} onMouseDown={preventWindowControlFocus} onClick={handleMaximize}>
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
</button>
<button type="button" className={closeControlClassName} onClick={handleClose}>
@@ -0,0 +1,106 @@
# Agent Note: Keep port forwarding usable when host OS metadata is missing
Status: implemented
## Problem
[Issue #3748](https://github.com/binaricat/Netcatty/issues/3748) reports that the
port-forwarding page displays `Port forwarding could not load.` when rendering
a selected host whose legacy `os` value is absent, null, or an empty string.
`EditPanel`, `NewFormPanel`, and both host-selection steps in `WizardContent`
evaluate `selectedHost.os[0].toUpperCase()` while constructing `DistroAvatar`
props. Missing or null values fail on indexing; an empty string fails on
`toUpperCase`. This happens before the avatar can render its generic icon.
Although `Host.os` is required by TypeScript, persisted runtime records can lack
that metadata. The original source of the affected records is not established.
## Decision
The four call sites use `(selectedHost.os || 'unknown').charAt(0).toUpperCase()`
to keep the required string fallback safe without changing the shared avatar
API. Valid OS values retain their existing initials. Missing and empty values
use the explicit unknown fallback, and the avatar keeps its existing generic
icon behavior.
The fix stays in the affected presentation code. It does not change host data,
normalization, persistence, or tunnel lifecycle behavior. A shared helper for a
single short expression would add indirection without a separate responsibility,
so the four call sites use the same expression directly.
## Validation
`components/PortForwardingPanels.test.tsx` renders the real components with
`react-dom/server`, the existing i18n provider, and the tooltip provider. It does
not mock the panels or avatar and does not inspect their source text.
The table-driven cases cover all four entry points, all three forwarding types,
and missing, undefined, null, empty, Linux, Windows, and macOS values. Separate
checks preserve distro icons and host-picker behavior when no host is selected
or the selected host is no longer available. Invalid legacy fixture shapes use
one explicit type assertion rather than weakening the production `Host` type.
Before the fix, 48 of the 96 new cases fail with the reported TypeError; the
other 48 pass. After the fix, all 96 pass. Together with the existing avatar,
port-forwarding toolbar, and utility tests, the focused run passes 109 tests:
```sh
node --test --import tsx components/PortForwardingPanels.test.tsx \
components/PortForwardingNew.test.ts components/portForwardingUtils.test.ts \
components/DistroAvatar.test.tsx --test-concurrency=2
```
These are rendering regression tests, not SSH or network end-to-end tests.
Additional checks against upstream commit `786a317`:
- `npm run build` succeeds.
- `npm run lint` succeeds with the same eight existing warnings as upstream.
The four changed TypeScript/TSX files also pass lint with `--max-warnings=0`.
- `LC_ALL=C npm test -- --test-concurrency=1` runs 13,986 tests: 13,944 pass,
four fail, and 38 are skipped. An unmodified upstream worktree runs 13,890
tests with the same four failures and 38 skips, so this change adds 96 passing
cases without adding a failing case. The baseline failures are the SFTP
large-directory persistence timing test, two Bash OSC 7 setup tests, and the
README clipboard-paste screenshot-dimension assertion. The SFTP timing test
passes in isolated runs on both checkouts but fails in these full-suite runs.
A non-English locale additionally causes the existing fish error-message
assertion to fail; both comparison runs use the C locale.
- The full-project `tsc --noEmit --pretty false` check has 759 existing errors.
Its complete output matches the unmodified upstream output after normalizing
checkout paths; none of the changed files has a diagnostic. This is not a
claim that full-project type checking passes.
### Upstream CI follow-up
The original PR CI run [39215269619, attempt 2](https://github.com/binaricat/Netcatty/actions/runs/39215269619/attempts/2)
passes all 96 new regression cases and fails only the existing README paste
assertion. Upstream commit `b366967` fixes that assertion by removing its
coupling to changing screenshot dimensions. The branch incorporates this fix
through a normal merge of upstream `main` at `42b8e2f`, without rewriting the
original fix commit or adding an independent change to the unrelated test.
After synchronization, the focused run including the entire clipboard-paste
test file passes 149 tests. Lint and the production build also pass; lint keeps
the same eight existing warnings. The earlier full-suite and type-check
comparison above describes the original `786a317` baseline, not the updated
base. Current remote CI results are tracked on the PR.
## Alternatives considered
- Remove the unused fallback prop from the four calls: the shared avatar still
requires it in its TypeScript contract. Changing that API and cleaning up all
callers is unnecessary for this targeted bug fix.
- Normalize or rewrite stored host records: broadens the persistence scope,
obscures the rendering assumption, and cannot replace guarding optional
runtime metadata at the point of use.
- Add a new avatar wrapper or fallback utility: unnecessary for this small
expression and would expand the production change without improving the
boundary being fixed.
## Consequences
Incomplete host OS metadata no longer prevents new/edit panels or wizard
host-selection steps from rendering. Existing icons, component contracts, and
host records remain unchanged. The guard is local to port forwarding; auditing
unrelated callers or redesigning the unused fallback API remains out of scope.
+53
View File
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearActiveGlobalDefaultProxyProfile,
getActiveGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
registerGlobalDefaultProxyProfileSetter,
} from "./globalDefaultProxy.ts";
test("clearing the global default uses the settings setter that is still mounted", () => {
const writes: string[] = [];
publishActiveGlobalDefaultProxyProfileId("profile-1");
const unregisterGate = registerGlobalDefaultProxyProfileSetter((id) => {
writes.push(`gate:${id ?? ""}`);
publishActiveGlobalDefaultProxyProfileId(id);
});
const unregisterTab = registerGlobalDefaultProxyProfileSetter((id) => {
writes.push(`tab:${id ?? ""}`);
publishActiveGlobalDefaultProxyProfileId(id);
});
try {
unregisterGate();
clearActiveGlobalDefaultProxyProfile();
assert.deepEqual(writes, ["tab:"]);
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
writes.length = 0;
publishActiveGlobalDefaultProxyProfileId("profile-1");
unregisterTab();
const unregisterGateAgain = registerGlobalDefaultProxyProfileSetter((id) => {
writes.push(`gate:${id ?? ""}`);
publishActiveGlobalDefaultProxyProfileId(id);
});
try {
clearActiveGlobalDefaultProxyProfile();
assert.deepEqual(writes, ["gate:"]);
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
} finally {
unregisterGateAgain();
}
publishActiveGlobalDefaultProxyProfileId("profile-1");
clearActiveGlobalDefaultProxyProfile();
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
assert.deepEqual(writes, ["gate:"]);
} finally {
unregisterGate();
unregisterTab();
publishActiveGlobalDefaultProxyProfileId(undefined);
}
});
+62
View File
@@ -0,0 +1,62 @@
/**
* App-wide default proxy profile. Settings publish the saved id here so
* `applyGroupDefaults` can apply it for every connection path, including
* callers that pass empty group defaults.
*/
let activeId: string | undefined;
const listeners = new Set<() => void>();
let registrationSeq = 0;
// Several settings hooks can be mounted at once (AppLockGate plus a settings
// tab). Each registration owns its cleanup so unmounting one instance falls
// back to the setter that is still mounted.
const persistSetters: Array<{
id: number;
setter: (id: string | undefined) => void;
}> = [];
export const normalizeGlobalDefaultProxyProfileId = (
value: unknown,
): string | undefined => {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || undefined;
};
export const getActiveGlobalDefaultProxyProfileId = (): string | undefined => activeId;
export const publishActiveGlobalDefaultProxyProfileId = (value: unknown): void => {
const next = normalizeGlobalDefaultProxyProfileId(value);
if (next === activeId) return;
activeId = next;
for (const listener of listeners) listener();
};
export const subscribeActiveGlobalDefaultProxyProfileId = (
listener: () => void,
): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const registerGlobalDefaultProxyProfileSetter = (
setter: (id: string | undefined) => void,
): (() => void) => {
const id = ++registrationSeq;
persistSetters.push({ id, setter });
return () => {
const index = persistSetters.findIndex((entry) => entry.id === id);
if (index >= 0) persistSetters.splice(index, 1);
};
};
/** Clear the saved global default and persist that choice when settings are mounted. */
export const clearActiveGlobalDefaultProxyProfile = (): void => {
const persistSetter = persistSetters[persistSetters.length - 1]?.setter;
if (persistSetter) {
persistSetter(undefined);
return;
}
publishActiveGlobalDefaultProxyProfileId(undefined);
};
+115
View File
@@ -1,6 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyGroupDefaults, resolveGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
import {
getActiveGlobalDefaultProxyProfileId,
publishActiveGlobalDefaultProxyProfileId,
} from "./globalDefaultProxy.ts";
import { resolveTelnetPassword, resolveTelnetUsername } from "./host.ts";
import type { GroupConfig, Host } from "./models.ts";
@@ -728,3 +732,114 @@ test("applyGroupDefaults does not inherit the group startup command into local s
assert.equal(result.startupCommand, undefined);
assert.equal(result.startupCommandRunMode, "paste");
});
const globalProxyOptions = {
globalProxyProfileId: "global-proxy",
validProxyProfileIds: new Set(["global-proxy", "group-proxy", "host-proxy"]),
};
test("applyGroupDefaults prefers the host proxy over the group and global defaults", () => {
const result = applyGroupDefaults(
host({ proxyProfileId: "host-proxy" }),
{ proxyProfileId: "group-proxy" },
globalProxyOptions,
);
assert.equal(result.proxyProfileId, "host-proxy");
});
test("applyGroupDefaults prefers the nearest group proxy over the global default", () => {
const groupDefaults = resolveGroupDefaults("prod/api", [
{ path: "prod", proxyProfileId: "group-proxy" },
{ path: "prod/api", proxyProfileId: "child-proxy" },
]);
const result = applyGroupDefaults(host({ group: "prod/api" }), groupDefaults, {
...globalProxyOptions,
validProxyProfileIds: new Set(["global-proxy", "group-proxy", "child-proxy"]),
});
assert.equal(result.proxyProfileId, "child-proxy");
});
test("applyGroupDefaults uses the global default when the host and group have no proxy", () => {
const grouped = applyGroupDefaults(host({ group: "prod" }), {}, globalProxyOptions);
const ungrouped = applyGroupDefaults(host(), {}, globalProxyOptions);
assert.equal(grouped.proxyProfileId, "global-proxy");
assert.equal(ungrouped.proxyProfileId, "global-proxy");
});
test("applyGroupDefaults stays direct when the global default is off", () => {
const result = applyGroupDefaults(host(), {}, { globalProxyProfileId: "" });
assert.equal(result.proxyProfileId, undefined);
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults lets an explicit host direct choice override group and global proxies", () => {
const result = applyGroupDefaults(
host({
directProxy: true,
proxyProfileId: "host-proxy",
proxyConfig: { type: "http", host: "leftover.example.com", port: 8080 },
}),
{ proxyProfileId: "group-proxy" },
globalProxyOptions,
);
assert.equal(result.directProxy, true);
assert.equal(result.proxyProfileId, undefined);
assert.equal(result.proxyConfig, undefined);
});
test("applyGroupDefaults still inherits after the host proxy setting is cleared", () => {
const fromGroup = applyGroupDefaults(
host({ proxyProfileId: "" }),
{ proxyProfileId: "group-proxy" },
globalProxyOptions,
);
const fromGlobal = applyGroupDefaults(
host({ proxyProfileId: "" }),
{},
globalProxyOptions,
);
assert.equal(fromGroup.proxyProfileId, "group-proxy");
assert.equal(fromGlobal.proxyProfileId, "global-proxy");
});
test("applyGroupDefaults ignores a deleted global proxy profile", () => {
const result = applyGroupDefaults(host(), {}, {
globalProxyProfileId: "deleted-proxy",
validProxyProfileIds: new Set(["group-proxy"]),
});
assert.equal(result.proxyProfileId, undefined);
});
test("applyGroupDefaults keeps a missing group proxy instead of using the global default", () => {
const result = applyGroupDefaults(
host(),
{ proxyProfileId: "missing-proxy" },
{
globalProxyProfileId: "global-proxy",
validProxyProfileIds: new Set(["global-proxy"]),
},
);
assert.equal(result.proxyProfileId, "missing-proxy");
});
test("applyGroupDefaults reads the active global default when callers omit it", () => {
publishActiveGlobalDefaultProxyProfileId("global-proxy");
try {
const inherited = applyGroupDefaults(host(), {});
const forcedOff = applyGroupDefaults(host(), {}, { globalProxyProfileId: "" });
assert.equal(inherited.proxyProfileId, "global-proxy");
assert.equal(forcedOff.proxyProfileId, undefined);
} finally {
publishActiveGlobalDefaultProxyProfileId(undefined);
}
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
});
+40
View File
@@ -1,5 +1,9 @@
import type { GroupConfig, Host } from './models';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
import {
getActiveGlobalDefaultProxyProfileId,
normalizeGlobalDefaultProxyProfileId,
} from './globalDefaultProxy';
/**
* Migrate deprecated primary-font ids out of a GroupConfig's
@@ -26,6 +30,12 @@ export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
export interface ApplyGroupDefaultsOptions {
validProxyProfileIds?: ReadonlySet<string>;
/**
* Saved proxy used when the host and its groups do not set one.
* An empty string forces no global default. When the property is omitted,
* the active app setting is used.
*/
globalProxyProfileId?: string;
}
export const hasManualGroupSshCredentials = (config: Partial<GroupConfig>): boolean => [
@@ -50,6 +60,28 @@ const hasUsableProxyProfileId = (
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
};
const resolveGlobalProxyProfileId = (
options?: ApplyGroupDefaultsOptions,
): string | undefined => {
const hasExplicit = Boolean(options)
&& Object.prototype.hasOwnProperty.call(options, 'globalProxyProfileId');
const raw = hasExplicit
? options?.globalProxyProfileId
: getActiveGlobalDefaultProxyProfileId();
const id = normalizeGlobalDefaultProxyProfileId(raw);
if (!id) return undefined;
if (options?.validProxyProfileIds && !options.validProxyProfileIds.has(id)) return undefined;
return id;
};
const withoutInheritedProxy = (host: Host): Host => {
if (host.proxyProfileId === undefined && host.proxyConfig === undefined) return host;
const next = { ...host };
delete next.proxyProfileId;
delete next.proxyConfig;
return next;
};
/**
* 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).
@@ -268,6 +300,14 @@ export function applyGroupDefaults(
(effective as unknown as Record<string, unknown>)[key] = groupValue;
}
}
// Explicit direct beats a host proxy, the nearest group proxy, and the
// global default. Clearing the host setting leaves directProxy unset and
// still inherits.
if (host.directProxy === true) return withoutInheritedProxy(effective);
if (!effective.proxyConfig && !effective.proxyProfileId) {
const globalProxyProfileId = resolveGlobalProxyProfileId(options);
if (globalProxyProfileId) effective.proxyProfileId = globalProxyProfileId;
}
return effective;
}
+6
View File
@@ -217,6 +217,12 @@ export interface Host {
proxy?: string; // Deprecated: use proxyConfig instead
proxyProfileId?: string; // Reference to reusable proxy profile
proxyConfig?: ProxyConfig; // New structured proxy configuration
/**
* Explicit direct connection for this host. Overrides group and global
* proxy defaults. Absent means inherit. This is not the jump-host
* "Direct" choice (`hostChain`).
*/
directProxy?: boolean;
hostChain?: HostChainConfig; // New structured host chain configuration
envVars?: string; // Deprecated: use environmentVariables instead
environmentVariables?: EnvVar[]; // Structured environment variables
+8
View File
@@ -465,6 +465,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.localShell.shell.desc",
sectionKey: "settings.terminal.section.localShell",
},
{
id: "terminal-default-proxy",
tab: "terminal",
labelKey: "settings.terminal.connection.defaultProxy",
descriptionKey: "settings.terminal.connection.defaultProxy.desc",
sectionKey: "settings.terminal.section.connection",
keywords: ["proxy", "socks", "默认代理", "全域"],
},
{
id: "terminal-verify-host-keys",
tab: "terminal",
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
import { sftpRequiresDedicatedLogin } from "./sftpDedicatedLogin";
test("network-device mode forces a dedicated SFTP login before any banner", () => {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "network" } }),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "network" },
remoteSshVersion: "OpenSSH_9.6",
}),
true,
);
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "network" },
remoteSshVersion: "SSH-2.0-OpenSSH_for_Windows_9.5",
}),
true,
);
});
test("Huawei dash and Ruijie banners force a dedicated SFTP login", () => {
for (const remoteSshVersion of ["-", "SSH-2.0--", "SSH-1.99--", "RGOS_SSH", "SSH-2.0-RGOS_SSH"]) {
assert.equal(
sftpRequiresDedicatedLogin({
host: { deviceType: "general" },
remoteSshVersion,
}),
true,
remoteSshVersion,
);
}
// Huawei `-` must not become a global single-channel bastion token.
assert.equal(remoteSoftwareRequiresSingleChannel("-"), false);
assert.equal(remoteSoftwareRequiresSingleChannel("SSH-2.0--"), false);
});
test("other network-vendor banners force a dedicated SFTP login", () => {
for (const remoteSshVersion of [
"SSH-2.0-Cisco-1.25",
"SSH-2.0-H3C-Comware-7.1.064",
"SSH-2.0-ROSSSH",
]) {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
true,
remoteSshVersion,
);
}
});
test("one-channel bastion banners still force a dedicated SFTP login", () => {
for (const remoteSshVersion of [
"BHostSSH_7.0",
"SSH-2.0-TERM-SSHD",
"CLOUDBILITY-4.14",
"SSH-2.0-CloudLinker",
]) {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
true,
remoteSshVersion,
);
}
});
test("ordinary SSH servers keep the shared terminal SFTP transport", () => {
assert.equal(sftpRequiresDedicatedLogin({ host: { deviceType: "general" } }), false);
assert.equal(sftpRequiresDedicatedLogin({ host: null }), false);
assert.equal(sftpRequiresDedicatedLogin({}), false);
for (const remoteSshVersion of ["OpenSSH_9.6", "SSH-2.0-OpenSSH_9.6", "JumpServer", ""]) {
assert.equal(
sftpRequiresDedicatedLogin({ host: { deviceType: "general" }, remoteSshVersion }),
false,
remoteSshVersion,
);
}
});
+25
View File
@@ -0,0 +1,25 @@
import { detectVendorFromSshVersion, hostRestrictsExtraSshChannels } from "./host";
import type { Host } from "./models";
import { remoteSoftwareRequiresSingleChannel } from "./singleChannelSshBanner.shared.mjs";
export type SftpDedicatedLoginInput = {
host?: Pick<Host, "deviceType"> | null;
remoteSshVersion?: string | null;
};
/**
* SFTP must dial its own SSH login when an extra subsystem on the terminal
* TCP session would drop the shell (#3631).
*
* Network-device mode is enough on its own, with or without a banner.
* A network-vendor identification string (Huawei `-` / `SSH-2.0--`, Ruijie
* RGOS, and the other `detectVendorFromSshVersion` matches) and one-channel
* bastion software tokens are included here. This does not stamp
* session.singleChannelSsh; that flag stays reserved for bastion banners.
*/
export function sftpRequiresDedicatedLogin(input: SftpDedicatedLoginInput): boolean {
if (hostRestrictsExtraSshChannels(input.host)) return true;
const remoteSshVersion = input.remoteSshVersion || undefined;
return remoteSoftwareRequiresSingleChannel(remoteSshVersion)
|| detectVendorFromSshVersion(remoteSshVersion) !== "";
}
+5
View File
@@ -368,6 +368,11 @@ export interface SyncPayload {
showHostTreeSidebar?: boolean;
// Workspace focus indicator style
workspaceFocusStyle?: 'dim' | 'border';
/**
* Saved proxy profile for hosts that do not set a proxy and do not inherit
* one from a group. An empty string is an explicit direct default.
*/
defaultProxyProfileId?: string;
// AI configuration
ai?: {
providers?: Array<Record<string, unknown>>;
+5 -3
View File
@@ -16,7 +16,7 @@ export {
import { parseQuickConnectInput } from "./quickConnect";
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
import { parseCsvProxy } from "./vaultImport/csvProxy";
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
import { decodeFinalShellPassword } from "./finalShellPassword";
export {
@@ -532,8 +532,9 @@ const importFromCsv = (text: string): VaultImportResult => {
? decodedPassphrase
: undefined;
const proxyRaw = (proxyIdx >= 0 ? row[proxyIdx] : undefined)?.trim();
const proxyConfig = proxyRaw ? parseCsvProxy(proxyRaw) : undefined;
if (proxyRaw && !proxyConfig) {
const directProxy = Boolean(proxyRaw && isCsvDirectProxyValue(proxyRaw));
const proxyConfig = proxyRaw && !directProxy ? parseCsvProxy(proxyRaw) : undefined;
if (proxyRaw && !directProxy && !proxyConfig) {
issues.push({
level: "warning",
message: `CSV row ${i + 2}: Proxy was ignored because it is not a valid http://, socks5://, or command:// value.`,
@@ -576,6 +577,7 @@ const importFromCsv = (text: string): VaultImportResult => {
notes,
});
if (proxyConfig) host.proxyConfig = proxyConfig;
if (directProxy) host.directProxy = true;
if (localShellSpec && protocol === "local") {
if (localShellSpec.shell) host.localShell = localShellSpec.shell;
if (localShellSpec.shellArgs) host.localShellArgs = localShellSpec.shellArgs;
+102
View File
@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
import { applyGroupDefaults } from "../groupConfig.ts";
import { importVaultHostsFromText } from "../vaultImport.ts";
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
import type { Host } from "../models.ts";
@@ -392,6 +394,106 @@ test("CSV preserves inherited proxies and gives host settings priority", () => {
assert.deepEqual(hosts, original);
});
test("CSV round-trips explicit direct proxy mode and still treats an empty Proxy as inherit", () => {
const staleProxy = { type: "http" as const, host: "stale.example.com", port: 8080 };
const groupProxy = { type: "socks5" as const, host: "group.example.com", port: 1080 };
const hosts: Host[] = [
{
...hostDefaults,
id: "direct",
label: "Direct",
hostname: "direct.example.com",
directProxy: true,
proxyConfig: staleProxy,
},
{
...hostDefaults,
id: "grouped-direct",
label: "Grouped direct",
hostname: "grouped.example.com",
group: "Corp",
directProxy: true,
},
{ ...hostDefaults, id: "inherit", label: "Inherit", hostname: "inherit.example.com" },
];
const { csv } = exportHostsToCsvWithStats(hosts, {
groupConfigs: [{ path: "Corp", proxyConfig: groupProxy }],
});
const lines = csv.replace(/^\uFEFF/, "").trimEnd().split("\r\n");
const proxyIndex = lines[0].split(",").indexOf("Proxy");
const proxyCell = (line: string) => line.split(",")[proxyIndex];
assert.equal(proxyCell(lines[1]), "direct");
assert.equal(proxyCell(lines[2]), "direct");
assert.equal(proxyCell(lines[3]), "");
assert.equal(csv.includes("stale.example.com"), false);
assert.equal(csv.includes("group.example.com"), false);
const imported = importVaultHostsFromText("csv", csv);
assert.deepEqual(imported.issues, []);
assert.equal(imported.hosts[0]?.directProxy, true);
assert.equal(imported.hosts[0]?.proxyConfig, undefined);
assert.equal(imported.hosts[1]?.directProxy, true);
assert.equal(imported.hosts[1]?.proxyConfig, undefined);
assert.equal(imported.hosts[2]?.directProxy, undefined);
assert.equal(imported.hosts[2]?.proxyConfig, undefined);
const options = {
globalProxyProfileId: "global-proxy",
validProxyProfileIds: new Set(["global-proxy"]),
};
const directEffective = applyGroupDefaults(imported.hosts[0], {}, options);
assert.equal(directEffective.directProxy, true);
assert.equal(directEffective.proxyProfileId, undefined);
const inheritEffective = applyGroupDefaults(imported.hosts[2], {}, options);
assert.equal(inheritEffective.directProxy, undefined);
assert.equal(inheritEffective.proxyProfileId, "global-proxy");
const literal = importVaultHostsFromText("csv", "Hostname,Proxy\nmanual.example.com,direct\nempty.example.com,");
assert.deepEqual(literal.issues, []);
assert.equal(literal.hosts[0]?.directProxy, true);
assert.equal(literal.hosts[0]?.proxyConfig, undefined);
assert.equal(literal.hosts[1]?.directProxy, undefined);
});
test("CSV export keeps an empty Proxy cell when only the global default is set", () => {
publishActiveGlobalDefaultProxyProfileId("global-proxy");
try {
const groupProxy = { type: "socks5" as const, host: "group.example.com", port: 1080 };
const hosts: Host[] = [
{ ...hostDefaults, id: "grouped", label: "Grouped", hostname: "grouped.example.com", group: "Corp" },
{ ...hostDefaults, id: "plain", label: "Plain", hostname: "plain.example.com" },
{ ...hostDefaults, id: "edge", label: "Edge", hostname: "edge.example.com", group: "Corp/Edge" },
];
const { csv } = exportHostsToCsvWithStats(hosts, {
groupConfigs: [{ path: "Corp/Edge", proxyConfig: groupProxy }],
proxyProfiles: [{
id: "global-proxy",
label: "Global",
createdAt: 0,
config: {
type: "http",
host: "secret-proxy.example.com",
port: 8080,
username: "alice",
password: "s3cret",
},
}],
});
const lines = csv.replace(/^\uFEFF/, "").trimEnd().split("\r\n");
const proxyIndex = lines[0].split(",").indexOf("Proxy");
const proxyCell = (line: string) => line.split(",")[proxyIndex];
assert.equal(proxyCell(lines[1]), "");
assert.equal(proxyCell(lines[2]), "");
assert.equal(proxyCell(lines[3]), "socks5://group.example.com:1080");
assert.equal(csv.includes("secret-proxy.example.com"), false);
assert.equal(csv.includes("s3cret"), false);
assert.equal(importVaultHostsFromText("csv", csv).hosts[0]?.proxyConfig, undefined);
assert.equal(importVaultHostsFromText("csv", csv).hosts[1]?.proxyConfig, undefined);
} finally {
publishActiveGlobalDefaultProxyProfileId(undefined);
}
});
test("CSV preserves IPv6 proxies and multiline commands with CSV punctuation", () => {
const proxies = [
{ type: "socks5" as const, host: "::1", port: 1080 },
+29 -11
View File
@@ -3,7 +3,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
import { isPluginHostProtocol } from '../pluginConnection';
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
import { formatCsvProxy } from './csvProxy';
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
const UTF8_BOM = "\uFEFF";
@@ -112,7 +112,14 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
};
const proxyProfiles = options.proxyProfiles ?? [];
const inheritanceOptions = { validProxyProfileIds: new Set(proxyProfiles.map((profile) => profile.id)) };
// Materialize group proxies so the file stays self-contained, but never the
// ambient global default. An empty Proxy cell means inherit for grouped and
// ungrouped hosts alike; injecting the global profile would embed its
// credentials and change routing based only on whether `group` is set.
const inheritanceOptions = {
validProxyProfileIds: new Set(proxyProfiles.map((profile) => profile.id)),
globalProxyProfileId: "",
};
for (const host of exportableHosts) {
// For telnet hosts, use telnet-specific port and username
const isTelnet = host.protocol === "telnet";
@@ -132,16 +139,27 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
: "";
// Proxy profiles are materialized inline so the CSV stays self-contained;
// encrypted credential placeholders are never written to the file.
const effectiveHost = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, options.groupConfigs ?? [], inheritanceOptions), inheritanceOptions)
: host;
const proxyConfig = materializeHostProxyProfile(effectiveHost, proxyProfiles).proxyConfig;
if (hasUnreadableProxyCredential(proxyConfig, options.identities)) {
unreadableProxyCredentialCount += 1;
const effectiveHost = applyGroupDefaults(
host,
host.group
? resolveGroupDefaults(host.group, options.groupConfigs ?? [], inheritanceOptions)
: {},
inheritanceOptions,
);
// `direct` is the reversible CSV form of directProxy. An empty Proxy cell
// still means inherit, including a configured global default.
let proxyValue = "";
if (effectiveHost.directProxy === true) {
proxyValue = CSV_DIRECT_PROXY_VALUE;
} else {
const proxyConfig = materializeHostProxyProfile(effectiveHost, proxyProfiles).proxyConfig;
if (hasUnreadableProxyCredential(proxyConfig, options.identities)) {
unreadableProxyCredentialCount += 1;
}
if (proxyConfig) {
proxyValue = formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities));
}
}
const proxyValue = proxyConfig
? formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities))
: "";
const localShellSpec = getLocalShellSpec(host);
rows.push([
+7
View File
@@ -2,9 +2,16 @@ import type { ProxyConfig } from "../models";
import { decodeCsvProxy } from "./csvCredentialFields";
// CSV representation of a per-host proxy configuration:
// direct
// http://[user[:pass]@]host:port
// socks5://[user[:pass]@]host:port
// command://<command text, verbatim after the scheme>
// `direct` is an explicit per-host direct connection. An empty cell still means inherit.
export const CSV_DIRECT_PROXY_VALUE = "direct";
export const isCsvDirectProxyValue = (raw: string): boolean =>
decodeCsvProxy(raw.trim()) === CSV_DIRECT_PROXY_VALUE;
const PROXY_SCHEME_PATTERN = /^(http|socks5|command):\/\/(.*)$/is;
const PROXY_AUTHORITY_PATTERN = /^(?:([^@:/?#\s]+)(?::([^@/?#]*))?@)?(\[[^\[\]@/?#\s]+\]|[^\[\]@:/?#\s]+):(\d+)$/u;
@@ -5,6 +5,12 @@ const { StringDecoder } = require("node:string_decoder");
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
const CODEX_AUTH_VALIDATION_TIMEOUT_MS = 10_000;
const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
// Bun-compiled OpenCode-family CLIs start slowly: `mimo --version` takes
// 3.9–5.1s warm (6s+ cold) and `opencode --version` can exceed 5s on a cold
// cache, so a 5s timeout killed healthy CLIs and dropped them from discovery.
// 15s only affects CLIs that exist but are slow to answer — a missing command
// fails spawn immediately.
const VERSION_PROBE_TIMEOUT_MS = 15_000;
function createAgentCliHelpers(ctx) {
with (ctx) {
@@ -120,7 +126,7 @@ function createAgentCliHelpers(ctx) {
async function probeCliVersion(probeCmd, probeArgs, env) {
try {
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: 5000 });
const result = await runCommand(probeCmd, probeArgs, { env, timeoutMs: VERSION_PROBE_TIMEOUT_MS });
return {
launched: true,
exitCode: result.exitCode,
@@ -430,4 +436,5 @@ module.exports = {
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
DEFAULT_CODEX_CLI_TIMEOUT_MS,
MAX_AGENT_CLI_BUFFER_CHARS,
VERSION_PROBE_TIMEOUT_MS,
};
@@ -10,6 +10,7 @@ const {
CODEX_AUTH_VALIDATION_TIMEOUT_MS,
DEFAULT_CODEX_CLI_TIMEOUT_MS,
MAX_AGENT_CLI_BUFFER_CHARS,
VERSION_PROBE_TIMEOUT_MS,
} = require("./agentCliHelpers.cjs");
function createHungChild() {
@@ -55,6 +56,37 @@ test("runCodexCli applies a default timeout to short status commands", async ()
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
});
test("probeCliVersion allows slow-starting CLIs beyond the old 5s probe limit", async () => {
const child = createHungChild();
const scheduled = [];
const helpers = createAgentCliHelpers({
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
spawn: () => {
queueMicrotask(() => {
child.stdout.emit("data", Buffer.from("0.1.15\n"));
child.emit("close", 0);
});
return child;
},
stripAnsi: (value) => value,
getShellEnv: async () => ({}),
normalizeCliPathForPlatform: (value) => value,
resolveCliFromPathAsync: async () => "/fake/mimo",
setTimeout: (callback, delay) => {
scheduled.push(delay);
return { unref() {} };
},
clearTimeout() {},
});
const result = await helpers.probeCliVersion("/fake/mimo", ["--version"], {});
assert.equal(result.launched, true);
assert.equal(result.exitCode, 0);
assert.equal(result.version, "0.1.15");
assert.equal(scheduled[0], VERSION_PROBE_TIMEOUT_MS);
assert.ok(scheduled[0] > 5000, "version probe timeout must exceed 5s for bun-compiled OpenCode-family CLIs");
});
test("runCodexCli slices a single oversized output chunk to the hard limit", async () => {
const child = createHungChild();
const helpers = createAgentCliHelpers({
+2
View File
@@ -183,6 +183,8 @@ export const STORAGE_KEY_SHOW_TRAY_ICON = 'netcatty_show_tray_icon_v1';
export const STORAGE_KEY_AUTO_LAUNCH_ENABLED = 'netcatty_auto_launch_enabled_v1';
/** App-level HTTP(S) proxy for cloud sync / AI (not SSH ProxyJump). */
export const STORAGE_KEY_HTTP_NETWORK_PROXY = 'netcatty_http_network_proxy_v1';
/** Saved proxy profile used when a host and its groups do not set one. Empty string means explicit none. */
export const STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE = 'netcatty_global_default_proxy_profile_v1';
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
export const STORAGE_KEY_APP_ICON_VARIANT = 'netcatty_app_icon_variant_v1';
+2 -1
View File
@@ -42,7 +42,7 @@
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env npm_config_arch=x64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env npm_config_arch=arm64 NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"postinstall": "patch-package && node scripts/patch-xterm-macos-column-selection.cjs && node scripts/patch-xterm-foreground-intense.cjs && node scripts/patch-xterm-image-tile-stride.cjs && node scripts/patch-xterm-image-iip-type.cjs && electron-builder install-app-deps && node scripts/rebuildPatchedNodePty.cjs && node scripts/patch-xterm-webgl-atlas.cjs",
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"generate:capability-tools": "node scripts/generate-capability-tools.cjs",
@@ -68,6 +68,7 @@
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:local-file-drag:electron": "electron scripts/local-file-drag.live.test.cjs",
"test:tray-panel-layout": "electron scripts/tray-panel-layout.live.test.cjs",
"test:xterm-image-resize": "electron scripts/xterm-image-resize.live.test.cjs",
"test:xterm-foreground-intense": "electron scripts/xterm-foreground-intense.live.test.cjs",
"test:terminal-background-rendering": "electron scripts/terminal-background-rendering.live.test.cjs",
"test:xterm-webgl-overflow": "electron scripts/xterm-webgl-atlas-overflow.live.test.cjs",
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_VERSION = "0.10.0-beta.292";
// External IIP metadata must not overwrite fields.type, the parser's internal
// SequenceType discriminator. In particular, nu_plugin_charton emits type=image/png
// and otherwise skips FILE decoder initialization (Netcatty #3700).
// Pinned-version workaround pending an upstream release; patch both runtime bundles.
const PATCHES = [
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
file,
original: "this._key=t,this.fields[t]=null,!0",
replacement: 'this._key=t,t!=="type"&&(this.fields[t]=null),!0',
})),
...["lib/addon-image.js", "lib/addon-image.mjs"].map(file => ({
file,
original: "_storeValue(e){if(this._key){try{",
replacement: '_storeValue(e){if(this._key){if(this._key==="type")return!0;try{',
})),
];
function patchImageSource(source, patch) {
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(patch.replacement).length - 1;
if (originalCount === 1 && patchedCount === 0) {
return { source: source.replace(patch.original, patch.replacement), changed: true };
}
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
throw new Error(
`${patch.file}: unexpected image-iip-type patch shape ` +
`(original=${originalCount}, patched=${patchedCount})`,
);
}
function patchInstalledImage(root = process.cwd()) {
const base = path.resolve(root, "node_modules/@xterm/addon-image");
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
if (version !== EXPECTED_VERSION) {
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
}
// Validate both complete bundles before writing either one.
const files = new Map();
let changed = 0;
for (const patch of PATCHES) {
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
const result = patchImageSource(source, patch);
files.set(patch.file, result.source);
if (result.changed) changed++;
}
if (changed) {
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
}
return { changed, checked: PATCHES.length };
}
if (require.main === module) {
try {
const result = patchInstalledImage();
console.log(`[patch-xterm-image-iip-type] changed=${result.changed} checked=${result.checked}`);
} catch (error) {
console.error(`[patch-xterm-image-iip-type] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
+130
View File
@@ -0,0 +1,130 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { pathToFileURL } = require("node:url");
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-iip-type.cjs");
for (const patch of PATCHES) {
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
const first = patchImageSource(`before;${patch.original}after`, patch);
assert.equal(first.changed, true);
assert.equal(first.source, `before;${patch.replacement}after`);
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
assert.throws(() => patchImageSource(source, patch), /unexpected image-iip-type patch shape/);
}
});
}
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-iip-type-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = path.join(root, "node_modules/@xterm/addon-image");
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
for (const file of new Set(PATCHES.map(p => p.file))) {
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
}
return { root, base };
}
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
const { root, base } = fixture(t);
const cache = path.join(root, "node_modules/.vite");
fs.mkdirSync(cache);
assert.deepEqual(patchInstalledImage(root), { changed: 4, checked: 4 });
assert.equal(fs.existsSync(cache), false);
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 4 });
});
test("install fails closed on a changed version or bundle before writing anything", t => {
const { root, base } = fixture(t);
const cjs = path.join(base, "lib/addon-image.js");
const original = fs.readFileSync(cjs, "utf8");
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
assert.throws(() => patchInstalledImage(root), /unexpected image-iip-type patch shape/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
});
// Use the shipped handler and WASM decoder, with only browser image decoding and
// storage mocked. The desktop smoke covers the actual bitmap rendering.
const PNG = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==", "base64");
function setup(ImageAddon) {
const event = () => ({ dispose() {} });
const term = {
cols: 80, rows: 24,
_core: {
open() {},
_coreBrowserService: { window: { document: {} } },
optionsService: { onOptionChange: event },
_inputHandler: { onRequestReset: event, _parser: { registerOscHandler: event } },
},
parser: { registerCsiHandler: event, registerEscHandler: event },
buffer: { active: { type: "normal" }, onBufferChange: event },
onRender: event, onResize: event,
};
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, kittySupport: false });
addon.activate(term);
const images = [];
addon._storage.addImage = image => images.push(image);
return { addon, handler: addon._handlers.get("iip"), images };
}
async function send(handler, data, chunkSize) {
const codes = Uint32Array.from(data, c => c.charCodeAt(0));
handler.start();
for (let start = 0; start < codes.length; start += chunkSize) {
handler.put(codes, start, Math.min(start + chunkSize, codes.length));
}
await handler.end(true);
}
for (const format of ["js", "mjs"]) {
test(`${format}: type metadata preserves FILE and multipart decoding, whole and bytewise`, async t => {
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
const { ImageAddon } = format === "js" ? require(file) : await import(pathToFileURL(file).href);
const originalBitmap = Object.getOwnPropertyDescriptor(globalThis, "createImageBitmap");
globalThis.createImageBitmap = async (blob, options) => ({ blob, ...options });
t.after(() => {
if (originalBitmap) Object.defineProperty(globalThis, "createImageBitmap", originalBitmap);
else delete globalThis.createImageBitmap;
});
for (const chunkSize of [4096, 1]) {
const { addon, handler, images } = setup(ImageAddon);
const body = PNG.toString("base64");
const headers = [
"inline=1", // unchanged standard header
"inline=1;type=image/png", // reporter's final metadata field
"type=image/png;inline=1", // do not prevent subsequent known fields
"inline=1;type=application/octet-stream", // no MIME hardcoding
];
for (const header of headers) {
const count = images.length;
await send(handler, `File=size=${PNG.length};${header}:${body}`, chunkSize);
assert.equal(images.length, count + 1, header);
assert.equal(handler._hp.fields.type, 1);
assert.equal(images.at(-1).blob.type, "image/png", "image type still comes from payload bytes");
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
}
const count = images.length;
await send(handler, `MultipartFile=inline=1;size=${PNG.length};type=image/png`, chunkSize);
assert.equal(handler._hp.fields.type, 2, "final type value must not break HeaderParser.end()");
await send(handler, `FilePart=${body}`, chunkSize);
await send(handler, "FileEnd", chunkSize);
assert.equal(images.length, count + 1);
assert.deepEqual(Buffer.from(await images.at(-1).blob.arrayBuffer()), PNG);
await send(handler, `File=inline=0;type=image/png:${body}`, chunkSize);
assert.equal(images.length, count + 1, "non-inline files stay suppressed");
addon.dispose();
}
});
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/* global process, console */
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const EXPECTED_VERSION = "0.10.0-beta.292";
// ImageStorage.addImage assigns tile IDs using ceil(orig.width/origCellSize.width).
// Keep that stride for draw, extractTile and viewportResize. Recomputing it from
// the rescaled canvas can add a column: ceil(400 * 9.51 / 10) / 9.51 > 40.
// DOM cell widths can change fractionally on window resize, shifting each image
// row one more tile and producing diagonal corruption (Netcatty #3746).
// Local pinned-version workaround pending an upstream release, not a merged
// upstream backport. The open draft uses the same original-grid principle:
// https://github.com/xtermjs/xterm.js/issues/5861
// https://github.com/xtermjs/xterm.js/pull/6145
// Patch both runtime bundles using the existing install-time patch pattern.
const PATCHES = [
{
file: "lib/addon-image.js",
original: "const n=e.actual,h=Math.ceil(n.width/o),",
replacement: "const n=e.actual,h=Math.ceil(e.orig.width/e.origCellSize.width),",
},
{
file: "lib/addon-image.js",
original: "const s=e.actual,o=Math.ceil(s.width/A),",
replacement: "const s=e.actual,o=Math.ceil(e.orig.width/e.origCellSize.width),",
},
{
file: "lib/addon-image.js",
original: "const h=Math.ceil(((null===(t=n.actual)||void 0===t?void 0:t.width)||0)/n.actualCellSize.width);",
replacement: "const h=Math.ceil(((null===(t=n.orig)||void 0===t?void 0:t.width)||0)/n.origCellSize.width);",
},
{
file: "lib/addon-image.mjs",
original: "let h=t.actual,g=Math.ceil(h.width/o),",
replacement: "let h=t.actual,g=Math.ceil(t.orig.width/t.origCellSize.width),",
},
{
file: "lib/addon-image.mjs",
original: "let a=t.actual,n=Math.ceil(a.width/A),",
replacement: "let a=t.actual,n=Math.ceil(t.orig.width/t.origCellSize.width),",
},
{
file: "lib/addon-image.mjs",
original: "let h=Math.ceil((d.actual?.width||0)/d.actualCellSize.width);",
replacement: "let h=Math.ceil((d.orig?.width||0)/d.origCellSize.width);",
},
];
function patchImageSource(source, patch) {
const originalCount = source.split(patch.original).length - 1;
const patchedCount = source.split(patch.replacement).length - 1;
if (originalCount === 1 && patchedCount === 0) {
return { source: source.replace(patch.original, patch.replacement), changed: true };
}
if (originalCount === 0 && patchedCount === 1) return { source, changed: false };
throw new Error(
`${patch.file}: unexpected image-tile-stride patch shape ` +
`(original=${originalCount}, patched=${patchedCount})`,
);
}
function patchInstalledImage(root = process.cwd()) {
const base = path.resolve(root, "node_modules/@xterm/addon-image");
const version = JSON.parse(fs.readFileSync(path.join(base, "package.json"), "utf8")).version;
if (version !== EXPECTED_VERSION) {
throw new Error(`unsupported @xterm/addon-image version ${version}; expected ${EXPECTED_VERSION}`);
}
// Validate both complete bundles before writing either one.
const files = new Map();
let changed = 0;
for (const patch of PATCHES) {
const source = files.get(patch.file) ?? fs.readFileSync(path.join(base, patch.file), "utf8");
const result = patchImageSource(source, patch);
files.set(patch.file, result.source);
if (result.changed) changed++;
}
if (changed) {
for (const [file, source] of files) fs.writeFileSync(path.join(base, file), source);
fs.rmSync(path.resolve(root, "node_modules/.vite"), { recursive: true, force: true });
}
return { changed, checked: PATCHES.length };
}
if (require.main === module) {
try {
const result = patchInstalledImage();
console.log(`[patch-xterm-image-tile-stride] changed=${result.changed} checked=${result.checked}`);
} catch (error) {
console.error(`[patch-xterm-image-tile-stride] ERROR: ${error.message}`);
process.exitCode = 1;
}
}
module.exports = { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage };
@@ -0,0 +1,148 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { pathToFileURL } = require("node:url");
const { EXPECTED_VERSION, PATCHES, patchImageSource, patchInstalledImage } = require("./patch-xterm-image-tile-stride.cjs");
for (const patch of PATCHES) {
test(`${patch.file}: ${patch.original.slice(0, 24)} patches once and rejects drift`, () => {
const first = patchImageSource(`before;${patch.original}after`, patch);
assert.equal(first.changed, true);
assert.equal(first.source, `before;${patch.replacement}after`);
assert.deepEqual(patchImageSource(first.source, patch), { source: first.source, changed: false });
for (const source of ["upstream changed", patch.original.repeat(2), patch.replacement.repeat(2), patch.original + patch.replacement]) {
assert.throws(() => patchImageSource(source, patch), /unexpected image-tile-stride patch shape/);
}
});
}
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-image-stride-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const base = path.join(root, "node_modules/@xterm/addon-image");
fs.mkdirSync(path.join(base, "lib"), { recursive: true });
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
for (const file of new Set(PATCHES.map(p => p.file))) {
fs.writeFileSync(path.join(base, file), PATCHES.filter(p => p.file === file).map(p => p.original).join("\n"));
}
return { root, base };
}
test("install patches both bundles, invalidates Vite cache and is idempotent", t => {
const { root, base } = fixture(t);
const cache = path.join(root, "node_modules/.vite");
fs.mkdirSync(cache);
assert.deepEqual(patchInstalledImage(root), { changed: 6, checked: 6 });
assert.equal(fs.existsSync(cache), false);
for (const patch of PATCHES) assert.ok(fs.readFileSync(path.join(base, patch.file), "utf8").includes(patch.replacement));
assert.deepEqual(patchInstalledImage(root), { changed: 0, checked: 6 });
});
test("install fails closed on a changed version or bundle before writing anything", t => {
const { root, base } = fixture(t);
const cjs = path.join(base, "lib/addon-image.js");
const original = fs.readFileSync(cjs, "utf8");
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: "future" }));
assert.throws(() => patchInstalledImage(root), /unsupported @xterm\/addon-image version/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
fs.writeFileSync(path.join(base, "package.json"), JSON.stringify({ version: EXPECTED_VERSION }));
fs.writeFileSync(path.join(base, "lib/addon-image.mjs"), "upstream changed");
assert.throws(() => patchInstalledImage(root), /unexpected image-tile-stride patch shape/);
assert.equal(fs.readFileSync(cjs, "utf8"), original);
});
// Exercise the installed addon itself, not a copy of its stride expression.
// Canvas calls are captured so these regressions also run in the normal Node suite.
function canvas(width, height) {
const calls = [];
const element = { width, height, calls, remove() {} };
element.getContext = () => ({ canvas: element, drawImage: (...args) => calls.push(args), clearRect() {} });
return element;
}
function setup(ImageAddon, { imageWidth = 400, cellWidth = 10, cols = 80, layer = "top" } = {}) {
const event = () => ({ dispose() {} });
const lines = Array.from({ length: 24 }, () => ({
_data: new Uint32Array(100 * 3), _extendedAttrs: {},
getBg(x) { return this._data[x * 3 + 2]; },
}));
const cell = { width: cellWidth, height: 20 };
const buffer = { x: 0, y: 0, ybase: 0, lines: { get: y => lines[y], length: lines.length } };
const term = {
cols, rows: 24, dimensions: { css: { cell } },
_core: {
open() {}, buffer,
_coreBrowserService: { window: { document: { createElement: () => canvas(0, 0) } } },
optionsService: { onOptionChange: event },
_inputHandler: { onRequestReset: event, _dirtyRowTracker: { markDirty() {} }, lineFeed: () => buffer.y++ },
},
parser: { registerCsiHandler: event, registerEscHandler: event },
buffer: { active: { type: "normal" }, onBufferChange: event },
onRender: event, onResize: event,
registerMarker: () => ({ onDispose() {}, dispose() {} }),
};
const addon = new ImageAddon({ enableSizeReports: false, sixelSupport: false, iipSupport: false, kittySupport: false });
addon.activate(term);
const image = canvas(imageWidth, 400);
const imageId = addon._storage.addImage(image, { scrolling: true, layer, zIndex: 0, cursorPos: "vt340" });
const spec = addon._storage._images.get(imageId);
const output = canvas(1000, 480);
addon._renderer._layers.set(layer, output.getContext("2d"));
return { addon, cell, spec, output, lines };
}
for (const format of ["js", "mjs"]) {
const loadAddon = async () => {
const file = path.resolve(__dirname, `../node_modules/@xterm/addon-image/lib/addon-image.${format}`);
return format === "js" ? require(file) : import(pathToFileURL(file).href);
};
for (const layer of ["top", "bottom"]) {
test(`${format}: ${layer} image rows and extracted tiles keep their original stride through repeated resizes`, async () => {
const { ImageAddon } = await loadAddon();
for (const options of [{}, { imageWidth: 397 }, { imageWidth: 384, cellWidth: 9.6 }]) {
const { addon, cell, spec, output, lines } = setup(ImageAddon, { ...options, layer });
const stride = Math.ceil(spec.orig.width / spec.origCellSize.width);
assert.equal(stride, 40);
assert.equal(lines[1]._extendedAttrs[0].tileId, stride);
const originalWidth = cell.width;
for (const width of [originalWidth, 9.51, 10.02, 12, 8.73, originalWidth]) {
cell.width = width;
for (const tileId of [40, 41, 79, 80, 799]) {
addon._renderer.draw(spec, tileId, 3, 2);
const [, sx, sy, , , dx, dy] = output.calls.at(-1);
assert.equal(sx, Math.floor((tileId % stride) * width), `draw x: tile=${tileId}, width=${width}`);
assert.equal(sy, Math.floor(tileId / stride) * cell.height, `draw y: tile=${tileId}, width=${width}`);
assert.equal(dx, Math.floor(3 * width));
assert.equal(dy, 2 * cell.height);
const tile = addon._renderer.extractTile(spec, tileId);
assert.equal(tile.calls[0][1], sx, "extractTile uses the same source column as draw");
assert.equal(tile.calls[0][2], sy, "extractTile uses the same source row as draw");
}
addon._renderer.draw(spec, stride, 0, 1, stride);
assert.equal(output.calls.at(-1)[1], 0, "batched second row starts at the left edge");
assert.equal(output.calls.at(-1)[2], cell.height);
}
assert.equal(spec.actual, spec.orig, "restoring cell metrics restores the original image");
}
});
}
test(`${format}: viewport growth after rescaling does not append the next image row`, async () => {
const { ImageAddon } = await loadAddon();
for (const cols of [20, 40]) {
const { addon, cell, spec, lines } = setup(ImageAddon, { cols });
cell.width = 9.51;
addon._renderer.draw(spec, 0, 0, 0);
assert.equal(spec.actual.width, 381, "rescaled integer canvas would incorrectly imply 41 columns");
addon._storage.viewportResize({ cols: 50, rows: 24 });
for (let row = 0; row < 20; row++) {
for (let col = 0; col < 40; col++) assert.equal(lines[row]._extendedAttrs[col].tileId, row * 40 + col);
assert.equal(lines[row]._extendedAttrs[40], undefined, "must stop at the original row boundary");
}
assert.equal(spec.tileCount, 800);
}
});
}
+103 -50
View File
@@ -6,21 +6,32 @@ const TRUSTED_AUTHOR_ASSOCIATIONS = new Set([
"COLLABORATOR",
]);
const DANGEROUS_FILE_EXTENSION =
"(?:zip|7z|rar|tar\\.gz|tgz|exe|msi|dmg|pkg|deb|rpm|appimage|bat|cmd|ps1|scr|vbs)";
const RISKY_EXTENSIONS = [
".tar.gz",
".tar.xz",
".appimage",
".msix",
".zip",
".7z",
".rar",
".tgz",
".exe",
".msi",
".dmg",
".pkg",
".deb",
".rpm",
".bat",
".cmd",
".ps1",
".scr",
".vbs",
".jar",
".iso",
".lnk",
];
const dangerousFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.${DANGEROUS_FILE_EXTENSION})(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const zipFilePattern = new RegExp(
`(?:^|[\\s([<"'=])([^\\s()[\\]<>\"']+\\.zip)(?=$|[\\s)\\]>\"']|[.,!?;:](?:$|\\s))`,
"gi"
);
const githubUserAttachmentPattern =
/^https:\/\/github\.com\/user-attachments\/files\/\d+\//i;
const URL_PATTERN = /https?:\/\/[^\s<>"'`]+/gi;
function normalizeAssociation(authorAssociation) {
return String(authorAssociation || "").trim().toUpperCase();
@@ -30,65 +41,107 @@ function isTrustedAuthor(authorAssociation) {
return TRUSTED_AUTHOR_ASSOCIATIONS.has(normalizeAssociation(authorAssociation));
}
function extractMatches(body, pattern) {
const files = [];
for (const match of body.matchAll(pattern)) {
files.push(match[1]);
function isSameLogin(left, right) {
const a = String(left || "").trim().toLowerCase();
const b = String(right || "").trim().toLowerCase();
return a.length > 0 && a === b;
}
function trimUrlCandidate(raw) {
let value = String(raw || "");
while (value.length > 0) {
const last = value[value.length - 1];
if (".,;:!?\"']".includes(last)) {
value = value.slice(0, -1);
continue;
}
if (last === ")") {
const opens = (value.match(/\(/g) || []).length;
const closes = (value.match(/\)/g) || []).length;
if (closes > opens) {
value = value.slice(0, -1);
continue;
}
}
break;
}
return [...new Set(files)];
return value;
}
function extractDangerousFiles(body) {
return extractMatches(body, dangerousFilePattern);
function hasRiskyExtension(segment) {
const name = String(segment || "").toLowerCase();
if (!name || name === "." || name === "..") return false;
return RISKY_EXTENSIONS.some((extension) => name.endsWith(extension));
}
function extractZipFiles(body) {
return extractMatches(body, zipFilePattern);
function urlHasRiskyPath(raw) {
let parsed;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
let pathname = parsed.pathname || "";
try {
pathname = decodeURIComponent(pathname);
} catch {
// Keep the raw pathname when it is not valid percent-encoding.
}
return pathname.split("/").some((segment) => hasRiskyExtension(segment));
}
function isGitHubUserAttachment(file) {
return githubUserAttachmentPattern.test(file);
function extractRiskyUrls(body) {
const text = String(body || "");
const found = [];
const seen = new Set();
for (const match of text.matchAll(URL_PATTERN)) {
const raw = trimUrlCandidate(match[0]);
if (!raw || seen.has(raw) || !urlHasRiskyPath(raw)) continue;
seen.add(raw);
found.push(raw);
}
return found;
}
function detectSpamComment({ body, authorAssociation, userType } = {}) {
const normalizedBody = String(body || "").replace(/\s+/g, " ").trim();
const dangerousFiles = extractDangerousFiles(normalizedBody);
const zipFiles = extractZipFiles(normalizedBody);
function detectSpamComment({
body,
authorAssociation,
userType,
commentAuthor,
issueAuthor,
} = {}) {
const trustedAuthor = isTrustedAuthor(authorAssociation);
const botAuthor = String(userType || "").toLowerCase() === "bot";
const botAuthor = String(userType || "").trim().toLowerCase() === "bot";
const authorIsIssueAuthor = isSameLogin(commentAuthor, issueAuthor);
const matchedUrls = extractRiskyUrls(body);
const reasons = [];
let score = 0;
if (zipFiles.length > 0) {
score += 10;
reasons.push(`zip attachment or link: ${zipFiles.join(", ")}`);
} else if (dangerousFiles.length > 0) {
score += 10;
reasons.push(`dangerous downloadable file: ${dangerousFiles.join(", ")}`);
}
// Hard rule: any zip (or other dangerous downloadable) from an untrusted
// human is deleted. Maintainers and bots are exempt.
// Hard rule: hide when an untrusted human who is not the issue author posts
// an http(s) URL whose path contains a risky archive or executable.
// Maintainers, bots, and the issue author are exempt. Bare filenames and
// local paths are not links, so they do not match.
const spam =
!trustedAuthor &&
!botAuthor &&
(zipFiles.length > 0 || dangerousFiles.length > 0);
!authorIsIssueAuthor &&
matchedUrls.length > 0;
return {
spam,
score: spam ? score : 0,
reasons: spam ? reasons : [],
dangerousFiles: zipFiles.length > 0 ? zipFiles : dangerousFiles,
score: spam ? 10 : 0,
reasons: spam ? [`risky download URL: ${matchedUrls.join(", ")}`] : [],
matchedUrls,
trustedAuthor,
botAuthor,
authorIsIssueAuthor,
};
}
module.exports = {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
extractRiskyUrls,
isTrustedAuthor,
RISKY_EXTENSIONS,
};
+139 -88
View File
@@ -2,113 +2,164 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const {
detectSpamComment,
extractDangerousFiles,
extractZipFiles,
isGitHubUserAttachment,
} = require("./spam-comment-filter.cjs");
const { detectSpamComment, extractRiskyUrls } = require("./spam-comment-filter.cjs");
test("flags the fake Netcatty patch spam pattern", () => {
test("issue author pasting a local SecureCRT.exe path is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[netcatty_patch.zip](https://example.com/netcatty_patch.zip)\n\nMan, that terminal rendering bug is such a pain. It looks like the sftp module is tripping over the encoding when it tries to sync the current path. I found a quick patch that fixes the character mapping in the backend so those black boxes finally disappear.",
});
assert.equal(result.spam, true);
});
test("flags fake patch spam when the filename ends a sentence", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "Download this patch.zip. It fixes the backend encoding so the terminal rendering black boxes disappear.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["patch.zip"]);
});
test("flags any zip from an outside user, including hash-named soft bait", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "[63bf1862b52422e38b8cb170.zip](https://github.com/user-attachments/files/29784176/63bf1862b52422e38b8cb170.zip)\nI'd start with the docs inside",
});
assert.equal(result.spam, true);
assert.ok(
result.dangerousFiles.some((file) => file.includes("63bf1862b52422e38b8cb170.zip"))
);
});
test("flags ordinary zip log attachments from outside users", () => {
const result = detectSpamComment({
authorAssociation: "FIRST_TIME_CONTRIBUTOR",
userType: "User",
body: "[debug-logs.zip](https://github.com/user-attachments/files/29784176/debug-logs.zip)\nI attached logs from a failed connection. The app hangs after I click connect, and the logs show the SSH handshake timing out.",
});
assert.equal(result.spam, true);
});
test("flags other dangerous archives from outside users", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "See hotfix.dmg for the workaround.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.dangerousFiles, ["hotfix.dmg"]);
});
test("does not flag trusted maintainers even when sharing zip archives", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
body: "Try this temporary netcatty_patch.zip while I prepare the signed release. It fixes the rendering issue.",
commentAuthor: "reporter",
issueAuthor: "reporter",
body: "It crashes here: C:\\Program Files\\SecureCRT\\SecureCRT.exe",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, []);
});
test("does not flag bot comments that mention zip files", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
body: "Uploaded build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
});
test("does not flag comments without downloadable archives", () => {
test("bare Windows path without a URL is not hidden even from a third party", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
body: "I attached screenshots of the hang after connect. The SSH handshake times out.",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Try Files\\SecureCRT\\SecureCRT.exe and also hotfix.dmg.",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, []);
});
test("extracts risky file names from markdown links and plain text", () => {
assert.deepEqual(
extractDangerousFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."),
["fix.zip", "https://example.com/fix.zip", "hotfix.dmg"]
);
assert.deepEqual(extractZipFiles("[fix.zip](https://example.com/fix.zip) also hotfix.dmg."), [
"fix.zip",
"https://example.com/fix.zip",
test("third party github user-attachments zip URL is hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Grab https://github.com/user-attachments/files/123/fix.zip and run it.",
});
assert.equal(result.spam, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/user-attachments/files/123/fix.zip",
]);
});
test("identifies GitHub user attachment URLs", () => {
assert.equal(
isGitHubUserAttachment("https://github.com/user-attachments/files/29784176/netcatty_fix.zip"),
true
test("third party mediafire zip URL is hidden when the extension is a path segment", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "mallory",
issueAuthor: "reporter",
body: "Download https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
});
assert.equal(result.spam, true);
assert.deepEqual(result.matchedUrls, [
"https://www.mediafire.com/file/abc/netcatty_fix.zip/file",
]);
});
test("maintainer posting a release dmg link is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "OWNER",
userType: "User",
commentAuthor: "maintainer",
issueAuthor: "reporter",
body: "Signed build: https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
});
assert.equal(result.spam, false);
assert.equal(result.trustedAuthor, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/binaricat/Netcatty/releases/download/v1.2.3/Netcatty.dmg",
]);
});
test("issue author uploading their own repro zip is not hidden", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "User",
commentAuthor: "Reporter",
issueAuthor: "reporter",
body: "[repro.zip](https://github.com/user-attachments/files/99/repro.zip)",
});
assert.equal(result.spam, false);
assert.equal(result.authorIsIssueAuthor, true);
assert.deepEqual(result.matchedUrls, [
"https://github.com/user-attachments/files/99/repro.zip",
]);
});
test("query strings do not block a risky path, and a query-only filename does not match", () => {
assert.deepEqual(
extractRiskyUrls("https://example.com/dropper.exe?download=1&arch=win"),
["https://example.com/dropper.exe?download=1&arch=win"]
);
assert.equal(isGitHubUserAttachment("https://example.com/netcatty_fix.zip"), false);
assert.deepEqual(
extractRiskyUrls("https://example.com/download?file=fix.zip"),
[]
);
});
test("matches newly added extensions and ignores non-risky links", () => {
const body = [
"https://example.com/a.jar",
"https://example.com/b.iso",
"https://example.com/c.msix",
"https://example.com/d.lnk",
"https://example.com/e.tar.xz",
"https://example.com/f.tar.gz",
"https://github.com/user-attachments/assets/e1302660-dece-46be-9857-1678a9b13d00",
"https://example.com/package.json",
"https://example.com/notes.zip.txt",
].join("\n");
assert.deepEqual(extractRiskyUrls(body), [
"https://example.com/a.jar",
"https://example.com/b.iso",
"https://example.com/c.msix",
"https://example.com/d.lnk",
"https://example.com/e.tar.xz",
"https://example.com/f.tar.gz",
]);
});
test("member and collaborator exemptions still apply to http links", () => {
for (const authorAssociation of ["MEMBER", "COLLABORATOR"]) {
const result = detectSpamComment({
authorAssociation,
userType: "User",
commentAuthor: "maintainer",
issueAuthor: "reporter",
body: "Mirror: http://example.com/setup.msi",
});
assert.equal(result.spam, false);
assert.deepEqual(result.matchedUrls, ["http://example.com/setup.msi"]);
}
});
test("does not flag bot comments that include a risky URL", () => {
const result = detectSpamComment({
authorAssociation: "NONE",
userType: "Bot",
commentAuthor: "netcatty[bot]",
issueAuthor: "reporter",
body: "Uploaded https://example.com/build-artifacts.zip for CI.",
});
assert.equal(result.spam, false);
assert.equal(result.botAuthor, true);
});
test("strips markdown and sentence punctuation around the URL", () => {
assert.deepEqual(
extractRiskyUrls("See [patch](https://example.com/patch.zip). Thanks!"),
["https://example.com/patch.zip"]
);
assert.deepEqual(
extractRiskyUrls("<https://example.com/tool.EXE>"),
["https://example.com/tool.EXE"]
);
});
+100
View File
@@ -0,0 +1,100 @@
"use strict";
/* global process, __dirname, console, document, requestAnimationFrame */
if (!process.versions.electron) {
require("node:test")("inline image pixels stay aligned across terminal resizes", {
skip: "run with Electron to exercise real DOM and canvas rendering",
}, () => {});
} else {
const { app, BrowserWindow } = require("electron");
const fs = require("node:fs");
const path = require("node:path");
const esbuild = require("esbuild");
const temp = require("../electron/bridges/tempDirBridge.cjs");
const root = path.resolve(__dirname, "..");
const userData = fs.mkdtempSync(`${temp.getTempFilePath("image-resize")}-`);
app.setPath("userData", userData);
app.on("window-all-closed", () => {});
let win;
const cleanup = code => {
win?.destroy();
fs.rmSync(userData, { recursive: true, force: true });
app.exit(code);
};
async function exercise({ Terminal, ImageAddon }) {
const check = (actual, expected, label) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
};
const host = document.createElement("div");
document.body.appendChild(host);
const term = new Terminal({ cols: 40, rows: 18, fontFamily: "monospace", fontSize: 16, cursorBlink: false, allowProposedApi: true });
term.open(host);
const addon = new ImageAddon({ sixelSupport: false, iipSupport: false, kittySupport: false });
term.loadAddon(addon);
const render = async () => {
term.refresh(0, term.rows - 1);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
await render();
const originalCell = { ...addon._renderer.cellSize };
const source = document.createElement("canvas");
source.width = Math.round(originalCell.width * 40);
source.height = Math.round(originalCell.height * 8);
check(Math.ceil(source.width / originalCell.width), 40, "fixture covers 40 image columns");
const context = source.getContext("2d");
const color = (col, row) => [32 + (col % 7) * 32, 32 + row * 24, 192, 255];
for (let row = 0; row < 8; row++) {
for (let col = 0; col < 40; col++) {
const [r, g, b] = color(col, row);
context.fillStyle = `rgb(${r},${g},${b})`;
context.fillRect(Math.floor(col * originalCell.width), Math.floor(row * originalCell.height), Math.ceil(originalCell.width), Math.ceil(originalCell.height));
}
}
// Insert once into the real terminal buffer. All subsequent frames must use
// the original tile IDs, just like an image already printed by any protocol.
const id = addon._storage.addImage(source, { scrolling: true, layer: "top", zIndex: 0, cursorPos: "vt340" });
const spec = addon._storage._images.get(id);
const samples = [];
let roundedStrideChanged = false;
for (const cols of [40, 53, 67, 91, 40, 53, 40]) {
term.resize(cols, 18);
await render();
const cell = addon._renderer.cellSize;
const badStride = Math.ceil(spec.actual.width / cell.width);
roundedStrideChanged ||= badStride !== 40;
const output = host.querySelector(".xterm-image-layer-top").getContext("2d");
for (const row of [0, 1, 2, 7]) {
for (const col of [0, 1, 5, 20, 38]) {
const pixel = [...output.getImageData(Math.floor((col + 0.5) * cell.width), Math.floor((row + 0.5) * cell.height), 1, 1).data];
check(pixel, color(col, row), `canvas pixel at cols=${cols}, cell=${col},${row}`);
const tile = addon.extractTileAtBufferCell(col, row);
const extracted = [...tile.getContext("2d").getImageData(Math.floor(tile.width / 2), Math.floor(tile.height / 2), 1, 1).data];
check(extracted, color(col, row), `extracted tile at cols=${cols}, cell=${col},${row}`);
}
}
samples.push({ cols, cellWidth: cell.width, canvasWidth: spec.actual.width, roundedStride: badStride });
}
check(roundedStrideChanged, true, "exercise fractional cell widths that triggered the regression");
term.dispose();
host.remove();
return { samples, pixelChecks: 280, repeatedResizes: true };
}
void app.whenReady().then(async () => {
const bundle = esbuild.buildSync({
stdin: {
contents: 'export { Terminal } from "@xterm/xterm"; export { ImageAddon } from "@xterm/addon-image";',
resolveDir: root,
},
bundle: true, format: "iife", globalName: "ImageResizeTest", platform: "browser", write: false,
}).outputFiles[0].text;
win = new BrowserWindow({ show: true, width: 1000, height: 600, webPreferences: { backgroundThrottling: false } });
await win.loadURL("data:text/html,<body style='margin:0;background:black'>");
await win.webContents.insertCSS(fs.readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8"));
const result = await win.webContents.executeJavaScript(`${bundle}\n(${exercise.toString()})(ImageResizeTest)`);
console.log("IMAGE_RESIZE_RENDERING_OK", JSON.stringify(result));
cleanup(0);
}).catch(error => { console.error(error); cleanup(1); });
}