Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 ece2703a4a fix(sftp): fall back once when a disposable upload channel closes
Owner, borrowed, and sudo channels still fail with SFTP channel closed
and do not open another dial. An isolated upload subsystem can close
while the shared browse channel is healthy; that close no longer sets
noTransferFallback.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 15:21:45 +00:00
Cursor Agentand陈大猫 081c6dc0a7 fix(sftp): do not redial after the transfer channel closes
SFTP channel closed was treated as a transient blip, so the file retry
opened a new session and waited on keyboard-interactive while the row
stayed active. A dead channel now fails the transfer once.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 15:09:25 +00:00
Cursor Agentand陈大猫 1b5d183599 fix(sftp): recheck jump-host pins when the target skips verification
A jump host can require host-key verification while the target disables
it. The known-host digest and channel adoption now include that hop, so
a replaced or removed bastion pin does not keep the old single-channel
session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:53:36 +00:00
Cursor Agentand陈大猫 c47d4e17b4 fix(sftp): trust current known hosts and fail a silent channel close
Transfer opens send the known-host list from the latest session acquire,
including a key trusted after the transfer callback was created. Every
adoption path rechecks those records, so a key removed from the vault
is not borrowed when both endpoint digests are empty. Closing the
borrowed SFTP stream rejects pending READ/WRITE even when ssh2 never
invokes the write callback.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:49:36 +00:00
Cursor Agentand陈大猫 bd6d6bf7d5 fix(sftp): fail a transfer that subscribes after the channel ended
end/close is not replayed. If WRITE installs its watcher only after the
channel is already unreadable, fail that waiter instead of waiting on a
callback ssh2 will never send.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 14:05:38 +00:00
Cursor Agentand陈大猫 57cb5e3192 fix(sftp): keep the owner when a reconnect reuses its session id
A same-id adopt stored the alias over the SSH client and leased that id
to itself, so closeSftp could only soft-close. The reconnect alias now
uses its own id and releases the original client after the last borrower.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:54:36 +00:00
Cursor Agentand陈大猫 58978566bb fix(sftp): refuse dead channels and revoked host keys on adopt
A closed SFTP stream is no longer treated as live just because its methods
remain. Adoption that ignores the known-host digest now also requires the
connection's accepted host key to still be trusted by the caller's records.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:43:10 +00:00
Cursor Agentand陈大猫 b4ed76c648 fix(sftp): match borrowed channels and fail a dead upload
Adoption requires the requested file protocol and sudo mode, so sudo plus
SCP still takes the rejecting connect path. Channel lookup ignores only the
known-host trust digest. An unattended transfer dial fails immediately when
the host key is untrusted. A killed SFTP channel or TCP reset fails the
in-flight upload with SFTP channel closed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:30:56 +00:00
Cursor Agentand陈大猫 5facc87597 fix(sftp): hold the owner channel until borrowed uploads release it
Closing the SFTP page only checked leases on the login id, so client.end()
killed the single-channel session while an alias upload was still writing.
Borrowers now lease the owner. If that channel dies anyway, in-flight
READ/WRITE fail with a visible error instead of waiting forever.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:14:14 +00:00
Cursor Agentand陈大猫 48f2c2fd82 fix(sftp): reuse the authenticated channel when the login cannot join the transport pool
Single-channel bastions and sudo SFTP stay out of the shared SSH registry.
The SFTP-page login was the dial leader and never settled that dial, so the
next upload waited forever with no keyboard-interactive prompt. Borrow the
live channel instead, and surface OTP when a new connection is required.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:45:04 +00:00
57089a3abf feat: temporary network device mode and tab-bar quick connect (#3793)
* feat(ssh): opt temporary connections into network device mode

Saved hosts and group-backed clones keep their own device type. SSH
connections with no saved host, including external launches and quick
connect, can start in network device mode when the new Terminal setting
is on. The setting stays off unless the user turns it on.

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

* feat(terminal): quick connect typed addresses from the tab switcher

The tab-bar quick switcher now offers the typed address when it already
parses as a connection target. Choosing that row opens the existing
quick connect wizard, including from a terminal tab where the vault
surface is hidden.

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

* fix(ssh): keep temporary Mosh and ET off network device mode

Those transports are stored as SSH plus a flag. Stamping network device
mode onto them made raw-host checks skip the copy-session working
directory probe. Plain temporary SSH still opts in.

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

* fix(ssh): read temporary connection settings at connect time

VaultView keeps a stale onConnect callback because its memo ignores that
prop. Read both temporary-connection toggles from a ref when connecting
so turning them on or off applies in the same session.

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

* fix(terminal): stop word queries from opening quick connect

Show the switcher quick-connect row only for address-like input, and
place it after matching hosts so Enter still connects to a saved host.
Translate the Hosts, Tabs, and New Workspace labels.

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

* fix(terminal): keep matching tabs ahead of quick connect

An address that matches an open session or workspace was still the
default Enter target when no saved host matched. Put that row after
those tabs so Enter switches to the existing one.

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 18:29:41 +08:00
3c67392455 fix(ui): keep New Host menu icons from shrinking and fix toast contrast when app/system themes differ (#3795)
* fix(vault): keep the New Host local shell icon at 14px

The dropdown was a fixed w-44 flex row. "Local Shell (CMD)" is wider than
the sibling labels, so the icon SVG flex-shrunk below size 14 (and longer
shell names collapsed it entirely). Pin every menu icon with shrink-0 and
let the menu grow with the label.

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

* fix(ui): keep toast text on the app theme surface

Palette fills used a prefers-color-scheme dark variant, so an app-dark session with a light OS left light text on a pale background.

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 18:14:11 +08:00
a01563931b feat(ssh): opt in legacy algorithms for temporary connections (#3792)
Saved hosts keep their own setting. External launches and quick connect
with no saved host can use the same legacy algorithm list when the new
global setting is turned on. It stays off by default.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 16:40:12 +08:00
0ccafbabf7 feat: optional Streamable HTTP for External MCP (#3790)
* feat: add optional Streamable HTTP for External MCP

Serve the existing catalog tools and approval path on a loopback HTTP
endpoint so clients that cannot spawn the stdio launcher, such as Codex
in WSL, can connect with a bearer token. The listener stays off until
the user enables it.

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

* fix: close HTTP sockets before stop and export the Codex bearer

Destroy tracked connections before awaiting server.close so disable does not hang on a half-open request. Prefix the Codex snippet with export so the child process receives the token.

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

* fix: advertise a real client host and return 413 intact

Wildcard binds no longer copy 127.0.0.1 into client configs; the URL stays empty until a reachable client address is set. Oversized POSTs receive a JSON-RPC 413 before the socket is closed.

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

* fix: recheck the bearer token after the HTTP body arrives

A half-open POST already passed auth before readBody. Recheck the live token before dispatch so a rotation during that wait cannot still run a tool.

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

* fix: expire idle Streamable HTTP sessions

JSON responses do not keep a socket open, so a client that never sends DELETE used to pin an MCP session until the listener stopped. Sweep sessions that have been idle for 15 minutes, and skip any session with a request still in flight.

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

* fix: return 404 when an HTTP session id is unknown

GET and DELETE with a nonempty session id that is no longer active now return 404, matching POST and the Streamable HTTP session contract. A missing session id stays 400.

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

* fix: accept bracketed IPv6 origins

Node reports URL.hostname as [::1], so an otherwise valid loopback or wildcard Origin was rejected. Compare the address with the brackets removed.

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

* fix: release rejected HTTP handshakes that never open a session

A 406 or 415 from the SDK returns normally before onsessioninitialized. Detach the approval client and close the MCP server when that happens so rejected initializes do not accumulate.

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 16:39:56 +08:00
cd0fa39f49 feat(terminal): click archive names to fill extraction command (#3269) (#3787)
* feat(terminal): click archive names to fill extraction command

Paste an absolute tar or unzip command when a Linux session
Ctrl/Cmd-clicks a printed archive name. Cwd comes from OSC 7
markers only, so a later directory change cannot retarget an
old relative name.

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

* fix(terminal): anchor wrapped archive links on the continuation row

A token that starts on the next physical row was mapped to the previous
row's exclusive end cell. Inclusive offsets now move to the next segment,
while an end-exclusive offset stays on the row where the match stops.

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

* fix(terminal): separate archive fill from text after the cursor

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

* fix(terminal): separate archive fill across a wrapped draft

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 16:39:44 +08:00
陈大猫andGOUKI9999 c78121888b fix(cli): retry bridge connection with refreshed discovery (#3786)
The tool CLI now re-reads discovery.json for up to ~1.2s (300ms + 900ms) when it
connects during an app restart. It covers a missing or partially written
file, a dead port, and a stale token (AUTH_FAILED or "Authentication
required"), plus a socket that closes during auth/verify. Identity is
port + token + pid, so a bridge restarted on the same port is retried. When
every attempt fails, the original error is surfaced unchanged.

Supersedes #3785. Refs #3443.

Co-authored-by: GOUKI9999 <11640942+GOUKI9999@users.noreply.github.com>
2026-10-20 15:56:02 +08:00
f1be47adc4 fix(sftp): refresh local file metadata before copy (#3694)
* fix(sftp): refresh local file metadata before copy

* fix: address Codex review on PR #3694

* fix: address Codex review on PR #3694

* Revert "fix: address Codex review on PR #3694"

This reverts commit 2848321c61.

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-20 15:03:11 +08:00
95 changed files with 8144 additions and 197 deletions

No files matched your search

+1
View File
@@ -130,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
- **分割ペイン** — 水平・垂直分割でマルチタスク
- **セッション管理** — 複数の接続を並行して扱う
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
+1
View File
@@ -133,6 +133,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Session management** — run multiple connections side-by-side
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
+1
View File
@@ -130,6 +130,7 @@
- **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
+1
View File
@@ -130,6 +130,7 @@
- **分割窗格** —— 水平與垂直分割,方便多工
- **工作階段管理** —— 多條連線並排處理
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
test("handleConnectToHost reads temporary connection settings at call time", () => {
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
const handler = source.slice(handlerStart, handlerEnd);
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
assert.match(handler, /terminalSettings: liveTerminalSettings/);
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
});
+20 -8
View File
@@ -47,6 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -1448,13 +1449,24 @@ export function AppSideEffects() {
name,
), [createWorkspaceFromTargets, resolveEffectiveHost]);
// VaultView memo ignores onConnect identity, so this callback can stay
// closed over an older render. Temporary-connection toggles are read here,
// at connect time, so turning them on or off applies without a reload.
const temporaryConnectionSettingsRef = useRef(terminalSettings);
temporaryConnectionSettingsRef.current = terminalSettings;
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
if (host.ephemeral) {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
temporaryConnectionPrepareOptions(liveTerminalSettings),
);
if (connectionHost.ephemeral) {
setEphemeralHosts((previous) => {
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
if (existingIndex < 0) return [...previous, host];
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
if (existingIndex < 0) return [...previous, connectionHost];
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
});
}
const effectiveHostResolver = alreadyEffective
@@ -1464,16 +1476,16 @@ export function AppSideEffects() {
addConnectionLog,
connectToHost,
discoveredShells,
host,
host: connectionHost,
identities,
keys,
resolveEffectiveHost: effectiveHostResolver,
resolveHostAuth,
resolveShellSetting,
systemInfoRef,
terminalSettings,
}), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
+2
View File
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { toast } from '../../components/ui/toast';
import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
@@ -930,6 +931,7 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
+32
View File
@@ -376,6 +376,38 @@ export const enAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security',
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
'ai.externalMcp.http.enable': 'Enable HTTP',
'ai.externalMcp.http.port': 'Port',
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
'ai.externalMcp.http.listenAddress': 'Listen address',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Client address',
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Regenerate',
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
'ai.externalMcp.http.envLine': 'Codex token env',
'ai.externalMcp.http.notListening': 'Not listening',
'ai.externalMcp.http.listening': 'Listening',
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
'ai.externalMcp.permissionMode.label': 'Write permission mode',
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
+4
View File
@@ -836,6 +836,10 @@ Highlight the focused split pane:
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
+4
View File
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.quickConnectTo': 'Quick connect to {target}',
'qs.hosts': 'Hosts',
'qs.tabs': 'Tabs',
'qs.newWorkspace': 'New Workspace',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells',
+32
View File
@@ -367,6 +367,38 @@ export const esAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad',
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
'ai.externalMcp.http.enable': 'Activar HTTP',
'ai.externalMcp.http.port': 'Puerto',
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Regenerar',
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
'ai.externalMcp.http.notListening': 'Sin escucha',
'ai.externalMcp.http.listening': 'Escuchando',
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
+4
View File
@@ -751,6 +751,10 @@ Resalta el panel dividido enfocado:
'settings.terminal.section.connection': 'Conexión',
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
+4
View File
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Buscar hosts o pestañas',
'qs.quickConnectTo': 'Conexión rápida a {target}',
'qs.hosts': 'Hosts',
'qs.tabs': 'Pestañas',
'qs.newWorkspace': 'Nuevo espacio de trabajo',
'qs.jumpTo': 'Ir a',
'qs.localTerminal': 'Terminal local',
'qs.localShells': 'Shells locales',
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "./en.ts";
import ru from "./ru.ts";
import es from "./es.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
const KEYS = [
"ai.externalMcp.http.title",
"ai.externalMcp.http.description",
"ai.externalMcp.http.enable",
"ai.externalMcp.http.port",
"ai.externalMcp.http.portHint",
"ai.externalMcp.http.allowCustomBind",
"ai.externalMcp.http.allowCustomBind.description",
"ai.externalMcp.http.listenAddress",
"ai.externalMcp.http.listenAddress.placeholder",
"ai.externalMcp.http.clientHost",
"ai.externalMcp.http.clientHost.description",
"ai.externalMcp.http.clientHost.placeholder",
"ai.externalMcp.http.clientHostRequired",
"ai.externalMcp.http.invalidClientHost",
"ai.externalMcp.http.warning",
"ai.externalMcp.http.url",
"ai.externalMcp.http.token",
"ai.externalMcp.http.regenerate",
"ai.externalMcp.http.regenerateHint",
"ai.externalMcp.http.wslHint",
"ai.externalMcp.http.codexSnippet",
"ai.externalMcp.http.claudeCommand",
"ai.externalMcp.http.genericSnippet",
"ai.externalMcp.http.envLine",
"ai.externalMcp.http.notListening",
"ai.externalMcp.http.listening",
"ai.externalMcp.http.portInUse",
"ai.externalMcp.http.requiresExternalMcp",
"ai.externalMcp.http.invalidPort",
"ai.externalMcp.http.invalidAddress",
"ai.externalMcp.http.listenFailed",
"ai.externalMcp.http.legacySseNote",
] as const;
test("External MCP HTTP strings exist in every locale", () => {
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
const missing = KEYS.filter((key) => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
}
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
});
+32
View File
@@ -323,6 +323,38 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
'ai.externalMcp.http.enable': 'Включить HTTP',
'ai.externalMcp.http.port': 'Порт',
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': 'Адрес клиента',
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': 'Создать заново',
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
'ai.externalMcp.http.notListening': 'Не слушает',
'ai.externalMcp.http.listening': 'Слушает',
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
+4
View File
@@ -791,6 +791,10 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
'settings.terminal.section.connection': 'Подключение',
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
+4
View File
@@ -710,6 +710,10 @@ export const ruVaultMessages: Messages = {
// Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.quickConnectTo': 'Быстрое подключение к {target}',
'qs.hosts': 'Хосты',
'qs.tabs': 'Вкладки',
'qs.newWorkspace': 'Новая рабочая область',
'qs.jumpTo': 'Перейти к',
'qs.localTerminal': 'Локальный терминал',
'qs.localShells': 'Локальные оболочки',
@@ -47,6 +47,40 @@ test("localized settings include workspace focus indicator labels", () => {
}
});
test("localized settings include temporary-connection legacy algorithm labels", () => {
const keys = [
"settings.terminal.connection.temporaryLegacyAlgorithms",
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
];
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 temporary legacy algorithm labels`);
}
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
});
test("localized settings include temporary-connection network device mode labels", () => {
const keys = [
"settings.terminal.connection.temporaryNetworkDeviceMode",
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
];
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 temporary network device mode labels`);
}
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
});
test("localized settings include the global default proxy labels", () => {
const keys = [
"settings.terminal.connection.defaultProxy",
+32
View File
@@ -376,6 +376,38 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
'ai.externalMcp.http.enable': '启用 HTTP',
'ai.externalMcp.http.port': '端口',
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenAddress': '监听地址',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': '客户端地址',
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': '重新生成',
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': '通用 JSON',
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
'ai.externalMcp.http.notListening': '未监听',
'ai.externalMcp.http.listening': '监听中',
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
'ai.externalMcp.permissionMode.label': '写操作权限模式',
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
+4
View File
@@ -1139,6 +1139,10 @@ Vaults 标签保持扁平,不使用这块背景。
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
'qs.quickConnectTo': '快速连接到 {target}',
'qs.hosts': '主机',
'qs.tabs': '标签页',
'qs.newWorkspace': '新建工作区',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
@@ -454,6 +454,10 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
'settings.terminal.connection.defaultProxy': '默认代理',
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
'settings.terminal.connection.defaultProxy.none': '无',
+32
View File
@@ -366,6 +366,38 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
'ai.externalMcp.http.title': 'Streamable HTTP',
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
'ai.externalMcp.http.enable': '啟用 HTTP',
'ai.externalMcp.http.port': '連接埠',
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenAddress': '監聽位址',
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
'ai.externalMcp.http.clientHost': '用戶端位址',
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
'ai.externalMcp.http.url': 'URL',
'ai.externalMcp.http.token': 'Bearer token',
'ai.externalMcp.http.regenerate': '重新產生',
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
'ai.externalMcp.http.claudeCommand': 'Claude Code',
'ai.externalMcp.http.genericSnippet': '通用 JSON',
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
'ai.externalMcp.http.notListening': '未監聽',
'ai.externalMcp.http.listening': '監聽中',
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
'ai.externalMcp.permissionMode.label': '寫入權限模式',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
+4
View File
@@ -1141,6 +1141,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
// Quick Switcher
'qs.search.placeholder': '搜尋主機或分頁',
'qs.quickConnectTo': '快速連線到 {target}',
'qs.hosts': '主機',
'qs.tabs': '分頁',
'qs.newWorkspace': '新增工作區',
'qs.jumpTo': '跳轉到',
'qs.localTerminal': '本機終端',
'qs.localShells': '本機 Shell',
@@ -454,6 +454,10 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線',
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
'settings.terminal.connection.defaultProxy': '預設代理',
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
'settings.terminal.connection.defaultProxy.none': '無',
+35
View File
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
openQuickConnectFromInput,
registerQuickConnectOpener,
type QuickConnectOpenRequest,
} from "./quickConnectBridge.ts";
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
const seen: QuickConnectOpenRequest[] = [];
const unregister = registerQuickConnectOpener((request) => {
seen.push(request);
});
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
assert.deepEqual(seen, [{
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
warnings: [],
}]);
unregister();
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
});
test("openQuickConnectFromInput ignores text that is not an address", () => {
const seen: QuickConnectOpenRequest[] = [];
const unregister = registerQuickConnectOpener((request) => {
seen.push(request);
});
assert.equal(openQuickConnectFromInput("core switch"), false);
assert.deepEqual(seen, []);
unregister();
});
+28
View File
@@ -0,0 +1,28 @@
import {
parseQuickConnectInputWithWarnings,
type QuickConnectTarget,
} from "../domain/quickConnect";
export interface QuickConnectOpenRequest {
target: QuickConnectTarget;
warnings: string[];
}
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
let opener: QuickConnectOpener | null = null;
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
opener = next;
return () => {
if (opener === next) opener = null;
};
}
export function openQuickConnectFromInput(input: string): boolean {
const parsed = parseQuickConnectInputWithWarnings(input);
if (!parsed.target || !opener) return false;
opener({ target: parsed.target, warnings: parsed.warnings });
return true;
}
@@ -30,6 +30,7 @@ import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { unwrapSftpIpcError } from "./errors";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
// leave reuseTransport enabled so the main-process registry can reuse an
// authenticated endpoint even when no terminal tab exists.
// No SFTP-page host-key dialog owns this session id. An untrusted key must
// fail the transfer immediately instead of sitting on the verifier TTL.
const openOpts = wantDedicated
? { ...credentials, reuseTransport: false as const }
: credentials;
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
: { ...credentials, hostKeyUnattended: true as const };
// The main-process auth driver owns method ordering, fallback, MFA and
// retry. A renderer-side key-then-password retry performs a second full SSH
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
return {
success: false,
error: err instanceof Error ? err.message : String(error),
error: unwrapSftpIpcError(error),
needsAttention: !!err.dedicatedAttention,
resetCheckpoint: !!err.resetCheckpoint,
};
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
assert.equal(attempts, 1);
});
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
let attempts = 0;
await assert.rejects(
() => runWithTransferRetry(async () => {
attempts += 1;
throw new Error("SFTP channel closed");
}, { retries: 1, delayMs: 0 }),
/SFTP channel closed/,
);
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
});
test("runWithTransferRetry does not retry cancel", async () => {
let attempts = 0;
await assert.rejects(
+3
View File
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
* path) should not be retried.
*/
export function isTransientTransferError(err: unknown): boolean {
// The channel is gone. Retrying opens a new dial, and an OTP host then
// sits on keyboard-interactive while the transfer stays active.
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
if (isSessionError(err)) return true;
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
+4 -3
View File
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { unwrapSftpIpcError } from "./errors";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
if (!isTargetConnectionAtPath(startPath)) return;
updateTargetTab((prev) => ({
...prev,
error: err instanceof Error ? err.message : "Failed to list directory",
error: unwrapSftpIpcError(err) || "Failed to list directory",
loading: false,
reconnecting: false,
}));
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
error: unwrapSftpIpcError(err) || "Connection failed",
}
: null,
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
: (unwrapSftpIpcError(err) || "Connection failed"),
loading: false,
reconnecting: false,
}));
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
});
}
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
const seen: string[] = [];
let fingerprint = "";
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
const restore = installGlobals({});
function Probe() {
const captured = fingerprint;
const acquireTransferSession = React.useCallback(async () => {
seen.push(captured);
throw new Error(`known-hosts:${captured || "empty"}`);
}, [captured]);
ops = useSftpTransfers({
ownerId: "known-hosts-after-trust-3310",
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession,
});
if (!firstStart) firstStart = ops.startTransfer;
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
fingerprint = "trusted-this-session";
await act(async () => { renderer?.update(React.createElement(Probe)); });
await act(async () => {
await firstStart!(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
assert.deepEqual(seen, ["trusted-this-session"]);
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
assert.equal(task?.error, "known-hosts:trusted-this-session");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test("channel death fails the upload without a second session open (#3310)", async () => {
let acquires = 0;
let streams = 0;
const restore = installGlobals({
statLocal: async () => ({ size: 48, lastModified: 1 }),
statSftp: async () => null,
startStreamTransfer: async () => {
streams += 1;
return { error: "SFTP channel closed" };
},
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = "channel-death-no-reconnect-3310";
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => {
acquires += 1;
return {
sftpId: "sftp-borrowed",
release: () => undefined,
discard: () => undefined,
};
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
assert.equal(streams, 1, "channel death must not start another transfer dial");
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.status, "failed");
assert.equal(task?.error, "SFTP channel closed");
assert.equal(
sftpTransferCenterStore.getOwnerTasks(ownerId)
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
.length,
0,
);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
const restore = installGlobals({});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
const ownerId = "ipc-prefix-3310";
function Probe() {
ops = useSftpTransfers({
ownerId,
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
getPaneByConnectionId: () => null,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
acquireTransferSession: async () => {
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
},
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer(
[{ name: "upload.txt", isDirectory: false }],
"left",
"right",
);
});
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
type StartOptions = {
transferId: string;
sourcePath: string;
@@ -712,3 +872,127 @@ test("owned cancelled directory settles recoverable children without dropping co
await rm(root, { recursive: true, force: true });
}
});
for (const liveSize of [37, 3, 0]) {
test(`same-pane local copy discovers ${liveSize} bytes after the listed file changes`, async () => {
const pane = makePane("right");
pane.files = [{ ...makePane("left").files[0], name: "edited.txt", size: 15 }];
const started: StartOptions[] = [];
const restore = installGlobals({
statLocal: async (path: string) => path.endsWith("edited.txt")
? { type: "file", size: liveSize, lastModified: 1758000002000 }
: null,
startStreamTransfer: async (options: StartOptions) => { started.push(options); return {}; },
pauseTransfer: async () => ({ success: false }),
resumeTransfer: async () => ({ success: false }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: `local-edited-${liveSize}`,
getActivePane: () => pane,
getPaneByConnectionId: () => pane,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer([{ name: "edited.txt", isDirectory: false }], "right", "right");
});
assert.equal(ops!.conflicts.length, 1);
assert.equal(ops!.conflicts[0].newSize, liveSize, "conflict must show the saved file's current size");
assert.equal(ops!.conflicts[0].newModified, 1758000002000);
await act(async () => {
await ops!.resolveConflict(ops!.conflicts[0].transferId, "duplicate", false);
await new Promise((resolve) => setTimeout(resolve, 300));
});
assert.equal(started.length, 1);
assert.equal(started[0].totalBytes, liveSize || undefined, "copy must not retain the cached 15-byte size");
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
}
test("no-conflict local copy awaits discovery so a late stat cannot overwrite the settled total", async () => {
const pane = makePane("right");
pane.files = [{ ...makePane("left").files[0], name: "late.txt", size: 15 }];
const started: StartOptions[] = [];
const restore = installGlobals({
// Target conflict stat: missing path means the copy has no conflict.
lstatLocal: async () => null,
statLocal: async (path: string) => {
if (!path.endsWith("late.txt")) return null;
// Let the stream finish before the stat resolves — fired-and-forgotten
// discovery used to patch the terminal task with this stale total.
await new Promise((resolve) => setTimeout(resolve, 200));
return { type: "file", size: 9_999, lastModified: 1758000002000 };
},
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
type: "completed",
transferId: options.transferId,
transferred: 15,
totalBytes: 15,
lifecycleEpoch: 0,
});
return {};
},
pauseTransfer: async () => ({ success: false }),
resumeTransfer: async () => ({ success: false }),
});
let ops: ReturnType<typeof useSftpTransfers> | undefined;
let renderer: ReactTestRenderer | undefined;
function Probe() {
ops = useSftpTransfers({
ownerId: "local-late-stat",
getActivePane: () => pane,
getPaneByConnectionId: () => pane,
getTabByConnectionId: () => null,
updateTab: () => undefined,
refresh: async () => undefined,
clearCacheForConnection: () => undefined,
handleSessionError: () => undefined,
sftpSessionsRef: { current: new Map() },
connectionCacheKeyMapRef: { current: new Map() },
listLocalFiles: async () => [],
listRemoteFiles: async () => [],
});
return null;
}
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
await act(async () => {
await ops!.startTransfer([{ name: "late.txt", isDirectory: false }], "right", "right");
});
// Give the no longer pending deferred stat time to land if it leaked.
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)); });
const row = sftpTransferCenterStore.getSnapshot().tasks.find(
(candidate) => candidate.originalFileName === "late.txt",
);
assert.ok(row);
assert.equal(row.status, "completed");
assert.equal(
row.transferredBytes,
row.totalBytes,
"a deferred stat must not desync the completed row's total from its transferred bytes",
);
} finally {
await act(async () => { renderer?.unmount(); });
restore();
}
});
+49 -28
View File
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types";
import { isMissingStatError } from "./errors";
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -191,6 +191,21 @@ export const useSftpTransfers = ({
const { t } = useI18n();
const tRef = useRef(t);
tRef.current = t;
// startTransfer keeps the processTransfer closure from its first render.
// Read the latest acquire fn so a host key trusted after that render is
// included in the transfer open, instead of the empty list from mount.
const acquireTransferSessionRef = useRef(acquireTransferSession);
acquireTransferSessionRef.current = acquireTransferSession;
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
(hostId, transferId, connectHost) => {
const acquire = acquireTransferSessionRef.current;
if (!acquire) {
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
}
return acquire(hostId, transferId, connectHost);
},
[],
);
// Ref so progress flushes always see the latest visibility without rebinding.
const surfaceVisibleRef = useRef(surfaceVisible);
surfaceVisibleRef.current = surfaceVisible;
@@ -468,7 +483,7 @@ export const useSftpTransfers = ({
setTransfers,
listLocalFiles,
listRemoteFiles,
acquireTransferSession,
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
});
const processTransfer = async (
@@ -602,8 +617,9 @@ export const useSftpTransfers = ({
// survive browse park and tab hide (pool holders, not panel map ids).
if (!sourcePane.connection?.isLocal) {
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
if (acquireTransferSession && sourceHostId) {
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
const acquireSourceSession = acquireTransferSessionRef.current;
if (acquireSourceSession && sourceHostId) {
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
sourceSftpId = sourceWorkLease.sftpId;
}
if (!sourceSftpId) {
@@ -615,8 +631,9 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
if (acquireTransferSession && targetHostId) {
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
const acquireTargetSession = acquireTransferSessionRef.current;
if (acquireTargetSession && targetHostId) {
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
targetSftpId = targetWorkLease.sftpId;
}
if (!targetSftpId) {
@@ -811,8 +828,8 @@ export const useSftpTransfers = ({
})();
// For single files: start size discovery without blocking the conflict
// check, but keep the promise so an actual conflict can await fresh
// size metadata before the dialog is shown.
// check, but always settle the promise before any dialog or transfer
// launch (bounded per-task; the task is already visible/cancellable).
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
// Only await conflict check (fast single stat call)
@@ -823,19 +840,23 @@ export const useSftpTransfers = ({
// null means the plan size is still unknown. A verified 0-byte stat is 0,
// not null, so an empty file does not pick up a stale listing size.
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
if (conflict && sizeDiscoveryPromise) {
if (sizeDiscoveryPromise) {
// The task's totalBytes may still be 0 here because discovery ran
// deferred and updateTask never mutates the local task copy. Await
// discovery (bounded per-task; the task is already visible/cancellable)
// so the overwrite dialog shows the real source size, not 0 (#3559).
// discovery even without a conflict so its statLocal updateTask cannot
// resolve after the transfer finished and overwrite the bridge's
// authoritative total with a stale late size (the conflict dialog also
// needs it: #3559).
const discovered = await sizeDiscoveryPromise;
// Cancel during this stat must win before an apply-to-all Replace
// deletes an existing symlink.
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
if (discovered && discovered.size >= 0) {
conflict.newSize = discovered.size;
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
if (discovered.plan) verifiedPlanBytes = discovered.size;
if (conflict) {
conflict.newSize = discovered.size;
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
if (discovered.plan) verifiedPlanBytes = discovered.size;
}
}
}
@@ -1118,7 +1139,7 @@ export const useSftpTransfers = ({
updateTask({
status: "failed",
error: err instanceof Error ? err.message : "Transfer failed",
error: unwrapSftpIpcError(err) || "Transfer failed",
endTime: Date.now(),
speed: 0,
});
@@ -1267,25 +1288,24 @@ export const useSftpTransfers = ({
// Prefer the fresh re-stat for remote files: a cached listing size can
// be stale and undersized, which would silently truncate downloads
// (#3559). Remote files without a live stat use plan 0 so the bridge
// measures the live size; local sources keep the pane metadata.
// measures the live size. Local files also start unknown so deferred
// discovery reads their current size after edits, not the cached listing.
const fileEntry = fileEntryMap?.get(file.name);
const freshMetadata = freshRemoteMetadata.get(file.name);
const fileSize = file.isDirectory
? 0
: freshMetadata
? freshMetadata.size
: sourcePane.connection!.isLocal
? (fileEntry?.size ?? 0)
: 0;
// Only keep an mtime that is paired with trusted metadata (live re-stat,
// local source size, or directory totals). A remote file whose re-stat
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
: 0;
// Only keep an mtime paired with a live re-stat or directory totals.
// A file without a live stat must stay fully unknown
// (size 0 AND mtime 0 in the plan):
// discoverTransferSize and the conflict check both treat a nonzero
// sourceLastModified as proof the size is already known, so a stale
// mtime would suppress the deferred source re-stat. The overwrite
// dialog still gets the pane listing's cached entry via
// discoverTransferSize's cachedListingStatForDialog fallback.
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
const sourceLastModified = !file.isDirectory && !freshMetadata
? 0
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
@@ -1504,8 +1524,8 @@ export const useSftpTransfers = ({
try {
const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSession(hostId, id)
acquireTransferSession: acquireTransferSessionRef.current
? (hostId, id) => acquireTransferSessionLive(hostId, id)
: undefined,
startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get();
@@ -1997,8 +2017,9 @@ export const useSftpTransfers = ({
let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId;
try {
if (acquireTransferSession && params.sourceHostId) {
sourceWorkLease = await acquireTransferSession(
const acquireDownloadSession = acquireTransferSessionRef.current;
if (acquireDownloadSession && params.sourceHostId) {
sourceWorkLease = await acquireDownloadSession(
params.sourceHostId,
`${task.id}:work-source`,
);
@@ -2064,7 +2085,7 @@ export const useSftpTransfers = ({
const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = err instanceof Error ? err.message : String(err);
const errMsg = unwrapSftpIpcError(err);
patchDownload({
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg,
@@ -3,6 +3,17 @@ import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
readExternalMcpHttpSettings,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
} from "./useExternalMcpToggleState.ts";
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -414,3 +425,30 @@ describe('syncExternalMcpStartupState generation guard', () => {
}
});
});
describe("external MCP HTTP settings", () => {
it("defaults HTTP off on loopback and round-trips the explicit listen option", () => {
const restore = installMemoryLocalStorage();
try {
assert.deepEqual(readExternalMcpHttpSettings(), {
enabled: false,
port: 47391,
allowCustomBind: false,
listenAddress: "0.0.0.0",
clientHost: "",
});
assert.equal(readExternalMcpHttpEnabled(), false);
writeExternalMcpHttpEnabled(true);
assert.equal(writeExternalMcpHttpPort(80), 47391);
assert.equal(writeExternalMcpHttpPort(48080), 48080);
writeExternalMcpHttpAllowCustomBind(true);
writeExternalMcpHttpListenAddress("172.22.0.1");
assert.equal(readExternalMcpHttpEnabled(), true);
assert.equal(readExternalMcpHttpPort(), 48080);
assert.equal(readExternalMcpHttpAllowCustomBind(), true);
assert.equal(readExternalMcpHttpListenAddress(), "172.22.0.1");
} finally {
restore();
}
});
});
@@ -3,6 +3,11 @@ import {
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
@@ -10,6 +15,7 @@ import {
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types';
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
@@ -104,10 +110,23 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
return externalMcpStartupReadyWaitPromise;
}
export type ExternalMcpHttpSettings = {
enabled: boolean;
port: number;
allowCustomBind: boolean;
listenAddress: string;
clientHost: string;
};
type ExternalMcpConfig = {
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
httpListenAddress?: string;
httpClientHost?: string;
};
type ExternalMcpBridge = {
@@ -241,6 +260,109 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
return normalized;
}
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
export function normalizeExternalMcpHttpPort(value: number | null): number {
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
}
return value;
}
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
const address = (value ?? '').trim();
if (!address) return '0.0.0.0';
return address;
}
export function readExternalMcpHttpEnabled(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
}
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
return enabled;
}
export function readExternalMcpHttpPort(): number {
return normalizeExternalMcpHttpPort(
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
);
}
export function writeExternalMcpHttpPort(port: number): number {
const normalized = normalizeExternalMcpHttpPort(port);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
return normalized;
}
export function readExternalMcpHttpAllowCustomBind(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
}
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
return allowCustomBind;
}
export function readExternalMcpHttpListenAddress(): string {
return normalizeExternalMcpHttpListenAddress(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
);
}
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
return normalized;
}
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
return (value ?? '').trim();
}
export function readExternalMcpHttpClientHost(): string {
return normalizeExternalMcpHttpClientHost(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
);
}
export function writeExternalMcpHttpClientHost(clientHost: string): string {
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
return normalized;
}
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
return {
enabled: readExternalMcpHttpEnabled(),
port: readExternalMcpHttpPort(),
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
};
}
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
ExternalMcpConfig,
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
> {
return {
httpEnabled: settings.enabled,
httpPort: settings.port,
httpAllowCustomBind: settings.allowCustomBind,
httpListenAddress: settings.listenAddress,
httpClientHost: settings.clientHost,
};
}
export function shouldStartExternalMcpOnStartup({
enabled,
mode,
@@ -263,11 +385,19 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
http = {
enabled: false,
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
allowCustomBind: false,
listenAddress: '0.0.0.0',
clientHost: '',
},
}: {
enabled: boolean;
mode: ExternalMcpMode;
idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number;
http?: ExternalMcpHttpSettings;
}): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled;
@@ -276,6 +406,7 @@ export function createExternalMcpStartupSyncPlan({
mode,
idleTimeoutMinutes,
sessionIdleTimeoutMinutes,
...httpConfigFromSettings(http),
},
runtimeEnabled,
storedEnabled,
@@ -289,14 +420,17 @@ export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
http: readExternalMcpHttpSettings(),
});
}
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
const http = readExternalMcpHttpSettings();
void bridge?.externalMcpSetConfig?.({
mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
...httpConfigFromSettings(http),
});
}
+9 -1
View File
@@ -10,6 +10,7 @@ import {
User,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
: `${protocolLabel} ${hostEndpoint}`
);
return (
const wizard = (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose}
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</div>
</div>
);
// The vault surface is visibility:hidden while a terminal tab is active.
// Portal so the same wizard opened from the tab-bar switcher stays visible.
if (typeof document !== "undefined" && document.body) {
return createPortal(wizard, document.body);
}
return wizard;
};
export default QuickConnectWizard;
@@ -0,0 +1,163 @@
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, TerminalSession } from "../types";
import { QuickSwitcher } from "./QuickSwitcher";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "saved-1",
label: "core-switch",
hostname: "10.1.1.1",
username: "admin",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
}) as Host;
const renderSwitcher = (
query: string,
results: Host[] = [],
locale = "en",
) => renderToStaticMarkup(
<I18nProvider locale={locale}>
<QuickSwitcher
isOpen
query={query}
results={results}
sessions={[]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
test("quick switcher keeps a matching host ahead of an address row", () => {
const markup = renderSwitcher("10.1.1.1", [host()]);
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
const hostAt = markup.indexOf("core-switch");
const selectedAt = markup.indexOf("bg-primary/15");
assert.ok(selectedAt >= 0);
assert.ok(hostAt > selectedAt);
assert.ok(quickConnectAt > hostAt);
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
});
test("quick switcher does not steal Enter from a saved host label", () => {
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
const hostAt = markup.indexOf(`>${label}<`);
const selectedAt = markup.indexOf("bg-primary/15");
assert.equal(markup.includes("Quick connect to"), false, label);
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
}
});
test("quick switcher keeps a matching open session ahead of an address row", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="en">
<QuickSwitcher
isOpen
query="10.4.4.4"
results={[]}
sessions={[{
id: "sess-1",
hostId: "host-1",
hostLabel: "open-core",
username: "admin",
hostname: "10.4.4.4",
status: "connected",
} as TerminalSession]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
const sessionAt = markup.indexOf("open-core");
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
const selectedAt = markup.indexOf("bg-primary/15");
assert.ok(selectedAt >= 0);
assert.ok(sessionAt > selectedAt);
assert.ok(quickConnectAt > sessionAt);
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
});
test("quick switcher offers quick connect when nothing else matches", () => {
const markup = renderSwitcher("root@10.2.0.8:2222");
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
});
test("quick switcher does not offer quick connect for ordinary search text", () => {
const markup = renderSwitcher("core switch");
assert.doesNotMatch(markup, /Quick connect to/);
});
test("quick switcher quick connect row uses zh-TW wording", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-TW">
<QuickSwitcher
isOpen
query="10.0.0.8"
results={[host()]}
sessions={[]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
onCreateWorkspace={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
assert.match(markup, /快速連線到 10\.0\.0\.8/);
assert.match(markup, />主機</);
assert.match(markup, />新增工作區</);
assert.doesNotMatch(markup, /New Workspace/);
});
test("quick switcher section labels use zh-CN wording", () => {
const markup = renderToStaticMarkup(
<I18nProvider locale="zh-CN">
<QuickSwitcher
isOpen
query=""
results={[host()]}
sessions={[]}
workspaces={[]}
onQueryChange={() => undefined}
onSelect={() => undefined}
onSelectTab={() => undefined}
onClose={() => undefined}
onQuickConnect={() => undefined}
onCreateWorkspace={() => undefined}
showSftpTab={false}
/>
</I18nProvider>,
);
assert.match(markup, />主机</);
assert.match(markup, />标签页</);
assert.match(markup, />新建工作区</);
});
+50 -9
View File
@@ -14,6 +14,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
import type { PaneMagnificationState } from "../domain/paneMagnification";
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
import { matchesSearchQuery } from "../lib/searchMatcher";
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
import { usePluginContributions } from "../application/state/usePluginContributions";
@@ -30,11 +31,12 @@ type QuickSwitcherItemBase = {
shortcut?: string;
pluginId?: string;
icon?: NetcattyPluginIconReference;
quickConnectInput?: string;
};
type QuickSwitcherItem = QuickSwitcherItemBase & (
| { type: "plugin-command"; commandId: string }
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
);
export function buildPaneMagnificationPaletteAction(
@@ -165,6 +167,7 @@ interface QuickSwitcherProps {
paneMagnificationState?: PaneMagnificationState;
onMagnifyCurrentPane?: () => void;
onRestoreMagnifiedPane?: () => void;
onQuickConnect?: (input: string) => void;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -186,6 +189,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
paneMagnificationState = 'unavailable',
onMagnifyCurrentPane,
onRestoreMagnifiedPane,
onQuickConnect,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
@@ -335,19 +339,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
});
};
if (results.length > 0) {
pushHeader("header:hosts", "Hosts");
results.forEach((host) =>
pushItem({ type: "host", id: host.id, data: host }),
);
}
const quickConnectItem: QuickSwitcherItem | null =
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
? {
type: "quick-connect",
id: "quick-connect",
title: t("qs.quickConnectTo", { target: trimmedQuery }),
quickConnectInput: trimmedQuery,
}
: null;
const hasTabsSection =
builtInTabs.length > 0
|| filteredOrphanSessions.length > 0
|| filteredWorkspaces.length > 0;
// The address row is the default Enter target only when nothing else matches.
// Matching hosts and open tabs stay ahead of it.
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
pushItem(quickConnectItem);
}
if (results.length > 0) {
pushHeader("header:hosts", t("qs.hosts"));
results.forEach((host) =>
pushItem({ type: "host", id: host.id, data: host }),
);
if (quickConnectItem) pushItem(quickConnectItem);
}
if (hasTabsSection) {
pushHeader("header:tabs", "Tabs");
pushHeader("header:tabs", t("qs.tabs"));
builtInTabs.forEach((tabId) => {
pushItem({ type: "tab", id: tabId });
});
@@ -357,6 +379,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
filteredOrphanSessions.forEach((s) =>
pushItem({ type: "tab", id: s.id, data: s }),
);
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
}
if (filteredShells.length > 0) {
@@ -445,6 +468,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
switch (item.type) {
case "quick-connect":
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
onClose();
break;
case "host":
onSelect(item.data as Host);
break;
@@ -542,7 +569,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
>
<Plus size={11} />
<span>New Workspace</span>
<span>{t("qs.newWorkspace")}</span>
</button>
)}
</div>
@@ -576,6 +603,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
if (item.type === "quick-connect") {
return (
<div
className={rowClass}
onClick={() => handleItemSelect(item)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
<Terminal size={16} />
</div>
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
</div>
);
}
if (item.type === "host") {
const host = item.data as Host;
const hostRow = (
+13 -4
View File
@@ -163,7 +163,10 @@ import {
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
} from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
import {
@@ -600,18 +603,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const onSessionExitRef = useRef(onSessionExit);
const commandBufferRef = useRef<string>("");
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
const term = termRef.current;
if (!term) return false;
const prompt = detectPrompt(term);
return isIdleShellReadyForCommandInjection({
const readiness = {
sensitiveInputActive: passwordPromptActiveRef.current,
hasLiveTerminal: true,
alternateScreenActive: isTerminalAlternateScreenActive(term),
isAtPrompt: prompt.isAtPrompt,
userInputLength: prompt.userInput.length,
pendingTypedInputLength: commandBufferRef.current.length,
});
};
return options?.allowDraft
? isShellPromptReadyForInsertion(readiness)
: isIdleShellReadyForCommandInjection(readiness);
}), [sessionId]);
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false);
@@ -2350,6 +2356,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}), []);
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
@@ -4345,6 +4353,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
xTermRuntimeContextRef.current = {
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize: effectiveFontSize,
+9 -1
View File
@@ -44,6 +44,7 @@ import React, {
useState,
} from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredNumber } from "../application/state/useStoredNumber";
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
[],
);
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
setQuickConnectTarget(target);
setQuickConnectWarnings(warnings);
setIsQuickConnectOpen(true);
}), []);
// Protocol select state (for hosts with multiple protocols)
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
null,
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
// Only these terminal connection settings are forwarded to
// PortForwarding inside the vault, so compare them directly. Other
// terminal settings (fonts, themes, etc.) don't affect this subtree
// and we don't want to re-render for them.
// and we don't want to re-render for them. onConnect is also omitted:
// temporary-connection toggles are read from a ref at connect time.
prev.terminalSettings?.verifyHostKeys ===
next.terminalSettings?.verifyHostKeys &&
prev.terminalSettings?.keepaliveInterval ===
+14
View File
@@ -51,3 +51,17 @@ test("Vault import entry is disabled while an import is running", () => {
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
});
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
assert.ok(menuStart >= 0);
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
assert.doesNotMatch(menu, /className="w-44"/);
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
});
@@ -998,6 +998,26 @@ function SettingsTerminalTab(props: {
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-temporary-legacy-algorithms"
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
>
<Toggle
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-temporary-network-device-mode"
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
>
<Toggle
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
/>
</SettingRow>
<SettingRow
anchorId="terminal-ssh-auto-reconnect"
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
@@ -0,0 +1,62 @@
import React from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
+70 -59
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
readExternalMcpApprovalAlertMode,
@@ -14,7 +14,18 @@ import {
writeExternalMcpMode,
writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes,
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpClientHost,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpClientHost,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
type ExternalMcpApprovalAlertMode,
type ExternalMcpHttpSettings,
type ExternalMcpMode,
useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState";
@@ -22,70 +33,14 @@ import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor", "other"];
type CopyableCodeBlockProps = {
label?: string;
value: string;
copyKey: string;
copied: string | null;
onCopy: (key: string, text: string) => void;
copyLabel: string;
copiedLabel: string;
emptyLabel?: string;
className?: string;
};
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
value,
copyKey,
copied,
onCopy,
copyLabel,
copiedLabel,
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
return (
<div className={cn("space-y-1.5", className)}>
{label ? (
<div className="text-xs font-medium text-muted-foreground">{label}</div>
) : null}
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!canCopy}
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
onClick={() => void onCopy(copyKey, value)}
aria-label={isCopied ? copiedLabel : copyLabel}
title={isCopied ? copiedLabel : copyLabel}
>
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</Button>
</div>
</div>
);
};
type ExternalMcpStatus = {
ok: boolean;
enabled?: boolean;
@@ -100,6 +55,7 @@ type ExternalMcpStatus = {
sessionIdleTimeoutMinutes?: number;
permissionMode?: string;
error?: string | null;
http?: ExternalMcpHttpViewStatus | null;
};
type ClientSetupStatus = {
@@ -305,6 +261,14 @@ export const ExternalMcpCard: React.FC = () => {
);
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
const [httpSettings, setHttpSettings] = useState<ExternalMcpHttpSettings>(() => ({
enabled: readExternalMcpHttpEnabled(),
port: readExternalMcpHttpPort(),
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
}));
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
@@ -359,6 +323,42 @@ export const ExternalMcpCard: React.FC = () => {
writeExternalMcpSilentSessions(nextSilentSessions);
}, []);
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
const normalized: ExternalMcpHttpSettings = {
enabled: writeExternalMcpHttpEnabled(next.enabled),
port: writeExternalMcpHttpPort(next.port),
allowCustomBind: writeExternalMcpHttpAllowCustomBind(next.allowCustomBind),
listenAddress: writeExternalMcpHttpListenAddress(next.listenAddress),
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
};
setHttpSettings(normalized);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
});
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
}
}, []);
const rotateHttpToken = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpSetConfig) return;
setIsRotatingHttpToken(true);
setActionMessage(null);
try {
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
}
} finally {
setIsRotatingHttpToken(false);
}
}, []);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge();
const includeClients = options?.clients !== false;
@@ -806,6 +806,17 @@ export const ExternalMcpCard: React.FC = () => {
</SettingRow>
</SettingCard>
<ExternalMcpHttpSection
externalMcpEnabled={enabled}
settings={httpSettings}
status={status?.http || null}
copied={copied}
rotating={isRotatingHttpToken}
onCopy={copyText}
onChange={(next) => { void applyHttpSettings(next); }}
onRotateToken={() => { void rotateHttpToken(); }}
/>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
@@ -0,0 +1,323 @@
import React, { useState } from "react";
import {
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
buildCodexHttpToml,
buildExternalMcpHttpUrl,
buildGenericHttpMcpJson,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
} from "../../../../domain/externalMcpHttp";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
export type ExternalMcpHttpViewStatus = {
enabled?: boolean;
listening?: boolean;
port?: number;
listenAddress?: string;
allowCustomBind?: boolean;
clientHost?: string;
url?: string | null;
bearerToken?: string | null;
state?: string;
error?: string | null;
errorCode?: string | null;
};
export type ExternalMcpHttpViewSettings = {
enabled: boolean;
port: number;
allowCustomBind: boolean;
listenAddress: string;
clientHost: string;
};
type ExternalMcpHttpSectionProps = {
externalMcpEnabled: boolean;
settings: ExternalMcpHttpViewSettings;
status: ExternalMcpHttpViewStatus | null;
copied: string | null;
rotating: boolean;
onCopy: (key: string, text: string) => void;
onChange: (next: ExternalMcpHttpViewSettings) => void;
onRotateToken: () => void;
};
function isWildcardAddress(value: string): boolean {
const address = value.trim();
return address === "0.0.0.0" || address === "::";
}
function isIpAddress(value: string): boolean {
const address = value.trim();
const ipv4 = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (ipv4) {
return ipv4.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part);
}
return address.includes(":") && /^[0-9a-fA-F:]+$/.test(address);
}
function parsePort(value: string): number | null {
if (!/^\d+$/.test(value.trim())) return null;
const port = Number(value);
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
return port;
}
export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
externalMcpEnabled,
settings,
status,
copied,
rotating,
onCopy,
onChange,
onRotateToken,
}) => {
const { t } = useI18n();
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
const [portError, setPortError] = useState(false);
const [addressError, setAddressError] = useState(false);
const [clientHostError, setClientHostError] = useState(false);
const listenAddress = settings.allowCustomBind
? (status?.listenAddress || settings.listenAddress)
: "127.0.0.1";
const advertisedHost = status?.clientHost ?? settings.clientHost;
const url = status
? (status.url || "")
: buildExternalMcpHttpUrl(listenAddress, settings.port, advertisedHost);
const showClientHost = settings.allowCustomBind && (
isWildcardAddress(settings.listenAddress) || isWildcardAddress(addressDraft)
);
const token = status?.bearerToken || "";
const listening = Boolean(status?.listening);
const statusLabel = listening
? t("ai.externalMcp.http.listening")
: t("ai.externalMcp.http.notListening");
const statusClass = status?.errorCode
? "text-destructive"
: listening
? "text-emerald-500"
: "text-muted-foreground";
let errorText = "";
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
else if (addressError) errorText = t("ai.externalMcp.http.invalidAddress");
else if (clientHostError) errorText = t("ai.externalMcp.http.invalidClientHost");
else if (status?.errorCode === "client_host_required") errorText = t("ai.externalMcp.http.clientHostRequired");
else if (status?.errorCode === "port_in_use") {
errorText = t("ai.externalMcp.http.portInUse", { port: String(status.port ?? settings.port) });
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
const commitPort = () => {
const port = parsePort(portDraft);
if (port == null) {
setPortError(true);
return;
}
setPortError(false);
setPortDraft(String(port));
if (port !== settings.port) onChange({ ...settings, port });
};
const commitClientHost = () => {
const clientHost = clientHostDraft.trim();
if (clientHost && (!isIpAddress(clientHost) || isWildcardAddress(clientHost))) {
setClientHostError(true);
return;
}
setClientHostError(false);
setClientHostDraft(clientHost);
if (clientHost !== settings.clientHost) onChange({ ...settings, clientHost });
};
const commitAddress = () => {
const address = addressDraft.trim();
if (!isIpAddress(address)) {
setAddressError(true);
return;
}
setAddressError(false);
setAddressDraft(address);
if (address !== settings.listenAddress) onChange({ ...settings, listenAddress: address });
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
</div>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
anchorId="ai-external-mcp-http"
label={t("ai.externalMcp.http.enable")}
>
<Toggle
checked={settings.enabled}
onChange={(enabled) => onChange({ ...settings, enabled })}
ariaLabel={t("ai.externalMcp.http.enable")}
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.http.port")}
description={t("ai.externalMcp.http.portHint")}
>
<input
type="number"
aria-label={t("ai.externalMcp.http.port")}
min={1024}
max={65535}
value={portDraft}
onChange={(event) => {
setPortDraft(event.currentTarget.value);
setPortError(false);
}}
onBlur={commitPort}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
className="w-24 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
/>
</SettingRow>
<SettingRow
label={t("ai.externalMcp.http.allowCustomBind")}
description={t("ai.externalMcp.http.allowCustomBind.description")}
>
<Toggle
checked={settings.allowCustomBind}
onChange={(allowCustomBind) => onChange({ ...settings, allowCustomBind })}
ariaLabel={t("ai.externalMcp.http.allowCustomBind")}
/>
</SettingRow>
{settings.allowCustomBind ? (
<SettingRow label={t("ai.externalMcp.http.listenAddress")}>
<input
type="text"
aria-label={t("ai.externalMcp.http.listenAddress")}
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
value={addressDraft}
onChange={(event) => {
setAddressDraft(event.currentTarget.value);
setAddressError(false);
}}
onBlur={commitAddress}
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
/>
</SettingRow>
) : null}
{showClientHost ? (
<SettingRow
label={t("ai.externalMcp.http.clientHost")}
description={t("ai.externalMcp.http.clientHost.description")}
>
<input
type="text"
aria-label={t("ai.externalMcp.http.clientHost")}
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
value={clientHostDraft}
onChange={(event) => {
setClientHostDraft(event.currentTarget.value);
setClientHostError(false);
}}
onBlur={commitClientHost}
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
/>
</SettingRow>
) : null}
</SettingCard>
{settings.allowCustomBind ? (
<p className="text-xs text-amber-500 leading-5">{t("ai.externalMcp.http.warning")}</p>
) : null}
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.wslHint")}</p>
{settings.enabled && !externalMcpEnabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
) : null}
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.http.url")}
value={url}
copyKey="http-url"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.token")}
value={token}
copyKey="http-token"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.regenerateHint")}</p>
<Button
type="button"
variant="outline"
size="sm"
disabled={!externalMcpEnabled || !settings.enabled || rotating}
onClick={onRotateToken}
>
{t("ai.externalMcp.http.regenerate")}
</Button>
</div>
<CopyableCodeBlock
label={t("ai.externalMcp.http.codexSnippet")}
value={buildCodexHttpToml(url)}
copyKey="http-codex"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.envLine")}
value={token ? buildCodexHttpEnvLine(token) : ""}
copyKey="http-env"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.claudeCommand")}
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
copyKey="http-claude"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.http.genericSnippet")}
value={token ? buildGenericHttpMcpJson(url, token) : ""}
copyKey="http-json"
copied={copied}
onCopy={onCopy}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</div>
</div>
);
};
+6
View File
@@ -177,6 +177,12 @@ export interface NetcattyAiBridge {
mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: number;
httpEnabled?: boolean;
httpPort?: number;
httpAllowCustomBind?: boolean;
httpListenAddress?: string;
httpClientHost?: string;
rotateHttpToken?: boolean;
}) => Promise<Record<string, unknown>>;
externalMcpGetUniversalSetupPrompt?: () => Promise<{
ok: boolean;
@@ -8,6 +8,10 @@ import { SearchAddon } from "@xterm/addon-search";
import { SerializeAddon } from "@xterm/addon-serialize";
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers";
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
@@ -321,6 +325,8 @@ export const resetKittyKeyboardModeStateForSession = (
export type CreateXTermRuntimeContext = {
container: HTMLDivElement;
host: Host;
/** Host OS facts can arrive after the runtime is created. */
getHost?: () => Host;
fontFamilyId: string;
resolvedFontFamily: string;
fontSize: number;
@@ -974,6 +980,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
terminalLinkHandler.activate(event, uri);
});
term.loadAddon(webLinksAddon);
const cwdMarkers = createTerminalCwdMarkers(term);
const archiveLinks = registerTerminalArchiveLinks({
term,
cwdMarkers,
isConnected: () => ctx.statusRef.current === "connected",
isBroadcasting: () => ctx.isBroadcastEnabledRef.current === true,
isHostEligible: () => supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId, { allowDraft: true }),
linkModifier: () => ctx.terminalSettingsRef.current?.linkModifier ?? "none",
});
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
? registerPluginTerminalLinkProvider({
term,
@@ -3250,12 +3266,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (path && path.length > 0) {
currentCwd = path;
ctx.onCwdChange?.(path);
cwdMarkers.note({ cwd: path, host: url.hostname });
logger.debug('[XTerm] OSC 7 CWD update:', path);
}
} else if (data.startsWith('/')) {
// Some shells send just the path without file:// prefix
currentCwd = data;
ctx.onCwdChange?.(data);
cwdMarkers.note({ cwd: data, host: '' });
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
}
} catch (err) {
@@ -3504,6 +3522,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
keywordHighlighter.dispose();
cursorLineHighlighter.dispose();
pluginLinkProviderHost?.dispose();
archiveLinks.dispose();
cwdMarkers.dispose();
pluginProviderHost?.dispose();
eraseScrollbackDisposable.dispose();
for (const disposable of cursorPositionReportRequestDisposables) {
@@ -0,0 +1,477 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks.ts";
import type { LinkModifier } from "../../../domain/models/terminal.ts";
type PrintedLine = { text: string; wrapped: boolean };
function click(partial: Partial<MouseEvent> = {}): MouseEvent {
return {
button: 0,
ctrlKey: false,
altKey: false,
metaKey: false,
...partial,
} as MouseEvent;
}
function makeBufferLine(line: PrintedLine) {
const chars = Array.from(line.text);
return {
isWrapped: line.wrapped,
length: chars.length,
translateToString(trim?: boolean) {
return trim ? line.text.trimEnd() : line.text;
},
getCell(index: number) {
const ch = chars[index];
if (ch === undefined) return undefined;
return {
getChars: () => ch,
getWidth: () => 1,
};
},
};
}
function fixture() {
let lines: PrintedLine[] = [{ text: "/tmp/backup.tar.gz", wrapped: false }];
let cursorX = 0;
let cursorY = 0;
let baseY = 0;
let cols = 80;
let cursorLine = 0;
let type: "normal" | "alternate" = "normal";
let connected = true;
let broadcast = false;
let eligible = true;
let ready = true;
let modifier: LinkModifier = "none";
const pasted: string[] = [];
let provider!: ILinkProvider;
const term = {
get cols() {
return cols;
},
buffer: {
active: {
get type() {
return type;
},
get cursorX() {
return cursorX;
},
get cursorY() {
return cursorY;
},
get baseY() {
return baseY;
},
getLine(index: number) {
const line = lines[index];
return line ? makeBufferLine(line) : undefined;
},
},
},
registerMarker(offset: number) {
return {
line: cursorLine + offset,
isDisposed: false,
dispose() {
this.isDisposed = true;
},
};
},
paste(value: string) {
pasted.push(value);
},
focus() {},
registerLinkProvider(next: ILinkProvider) {
provider = next;
return { dispose() {} };
},
};
const cwdMarkers = createTerminalCwdMarkers(term as never);
registerTerminalArchiveLinks({
term: term as unknown as Terminal,
cwdMarkers,
isConnected: () => connected,
isBroadcasting: () => broadcast,
isHostEligible: () => eligible,
isInputReady: () => ready,
linkModifier: () => modifier,
});
const linksAt = (y: number) => {
let value: ILink[] | undefined;
provider.provideLinks(y, (result) => {
value = result;
});
return value;
};
return {
pasted,
linksAt,
noteAt(line: number, report: { cwd: string; host: string }, x = 0) {
cursorLine = line;
const previousX = cursorX;
cursorX = x;
cwdMarkers.note(report);
cursorX = previousX;
},
setLines(next: PrintedLine[]) {
lines = next;
},
setLine(index: number, text: string, wrapped = false) {
lines[index] = { text, wrapped };
},
setCursor(line: number, x: number) {
cursorY = line;
cursorX = x;
baseY = 0;
},
atPrompt() {
const prompt = lines.length;
lines = [...lines, { text: "", wrapped: false }];
cursorY = prompt;
cursorX = 0;
baseY = 0;
},
setBroadcast(value: boolean) {
broadcast = value;
},
setReady(value: boolean) {
ready = value;
},
setEligible(value: boolean) {
eligible = value;
},
setConnected(value: boolean) {
connected = value;
},
setModifier(value: LinkModifier) {
modifier = value;
},
setAltBuffer(value: boolean) {
type = value ? "alternate" : "normal";
},
};
}
const ctrl = click({ ctrlKey: true });
test("no modifier is a no-op, Ctrl fills one command, and Cmd works on macOS", () => {
const linux = fixture();
const link = linux.linksAt(1)?.[0];
assert.ok(link);
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 18, y: 1 } });
linux.atPrompt();
link.activate(click(), link.text);
assert.deepEqual(linux.pasted, []);
link.activate(ctrl, link.text);
assert.deepEqual(linux.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
assert.equal(linux.pasted[0]?.includes("\r"), false);
assert.equal(linux.pasted[0]?.includes("\n"), false);
const mac = fixture();
const macLink = mac.linksAt(1)?.[0];
assert.ok(macLink);
mac.atPrompt();
macLink.activate(click({ metaKey: true }), macLink.text);
assert.deepEqual(mac.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
});
test("a configured alt modifier is required for archive links", () => {
const f = fixture();
f.setModifier("alt");
const link = f.linksAt(1)?.[0];
assert.ok(link);
link.activate(ctrl, link.text);
assert.deepEqual(f.pasted, []);
link.activate(click({ altKey: true }), link.text);
assert.equal(f.pasted.length, 1);
assert.equal(f.pasted[0]?.includes("\r"), false);
});
test("broadcast, readiness, host eligibility, buffer, and stale text gate provide and activate", () => {
const f = fixture();
const link = f.linksAt(1)?.[0];
assert.ok(link);
f.setBroadcast(true);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setBroadcast(false);
f.setReady(false);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setReady(true);
f.setEligible(false);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setEligible(true);
f.setConnected(false);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setConnected(true);
f.setAltBuffer(true);
assert.equal(f.linksAt(1), undefined);
link.activate(ctrl, link.text);
f.setAltBuffer(false);
f.setLine(0, "changed.tar.gz");
link.activate(ctrl, link.text);
assert.deepEqual(f.pasted, []);
});
test("a non-space before the cursor is kept and separated with one space", () => {
const draft = fixture();
draft.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo", wrapped: false },
]);
draft.setCursor(1, 4);
const link = draft.linksAt(1)?.[0];
assert.ok(link);
link.activate(ctrl, link.text);
assert.equal(draft.pasted[0], " tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
const spaced = fixture();
spaced.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "sudo ", wrapped: false },
]);
spaced.setCursor(1, 5);
const spacedLink = spaced.linksAt(1)?.[0];
assert.ok(spacedLink);
spacedLink.activate(ctrl, spacedLink.text);
assert.equal(spaced.pasted[0], "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
});
test("a non-space after the cursor is separated with one space", () => {
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
const before = fixture();
before.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo", wrapped: false },
]);
before.setCursor(1, 0);
const beforeLink = before.linksAt(1)?.[0];
assert.ok(beforeLink);
beforeLink.activate(ctrl, beforeLink.text);
assert.equal(before.pasted[0], `${command} `);
assert.equal(before.pasted[0]?.includes("\r"), false);
const middle = fixture();
middle.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo", wrapped: false },
]);
middle.setCursor(1, 2);
const middleLink = middle.linksAt(1)?.[0];
assert.ok(middleLink);
middleLink.activate(ctrl, middleLink.text);
assert.equal(middle.pasted[0], ` ${command} `);
const already = fixture();
already.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "echo foo", wrapped: false },
]);
already.setCursor(1, 4);
const alreadyLink = already.linksAt(1)?.[0];
assert.ok(alreadyLink);
alreadyLink.activate(ctrl, alreadyLink.text);
assert.equal(already.pasted[0], ` ${command}`);
});
test("a wrapped draft separates the command from the previous row", () => {
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
const full = " ".repeat(79);
const glued = fixture();
glued.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: `${full}o`, wrapped: false },
{ text: " ", wrapped: true },
]);
glued.setCursor(2, 0);
const gluedLink = glued.linksAt(1)?.[0];
assert.ok(gluedLink);
gluedLink.activate(ctrl, gluedLink.text);
assert.equal(glued.pasted[0], ` ${command}`);
const spaced = fixture();
spaced.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: `${full} `, wrapped: false },
{ text: " ", wrapped: true },
]);
spaced.setCursor(2, 0);
const spacedLink = spaced.linksAt(1)?.[0];
assert.ok(spacedLink);
spacedLink.activate(ctrl, spacedLink.text);
assert.equal(spaced.pasted[0], command);
const separate = fixture();
separate.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: `${full}o`, wrapped: false },
{ text: " ", wrapped: false },
]);
separate.setCursor(2, 0);
const separateLink = separate.linksAt(1)?.[0];
assert.ok(separateLink);
separateLink.activate(ctrl, separateLink.text);
assert.equal(separate.pasted[0], command);
const continued = fixture();
continued.setLines([
{ text: "/tmp/backup.tar.gz", wrapped: false },
{ text: "e".repeat(80), wrapped: false },
{ text: "x", wrapped: true },
]);
continued.setCursor(1, 80);
const continuedLink = continued.linksAt(1)?.[0];
assert.ok(continuedLink);
continuedLink.activate(ctrl, continuedLink.text);
assert.equal(continued.pasted[0], ` ${command} `);
});
test("a name that starts on the next wrapped row is not anchored on the previous row", () => {
const f = fixture();
f.setLines([
{ text: "x ", wrapped: false },
{ text: "/tmp/a.tar", wrapped: true },
]);
const link = f.linksAt(1)?.[0];
assert.ok(link);
assert.equal(link.text, "/tmp/a.tar");
assert.deepEqual(link.range, {
start: { x: 1, y: 2 },
end: { x: 10, y: 2 },
});
const ended = fixture();
ended.setLines([
{ text: "/a.tar", wrapped: false },
{ text: " rest", wrapped: true },
]);
const endedLink = ended.linksAt(1)?.[0];
assert.ok(endedLink);
assert.equal(endedLink.text, "/a.tar");
assert.deepEqual(endedLink.range, {
start: { x: 1, y: 1 },
end: { x: 6, y: 1 },
});
});
test("a wrapped logical line is one multi-row link and fills the full path", () => {
const f = fixture();
f.setLines([
{ text: "/tmp/back", wrapped: false },
{ text: "up.tar.gz", wrapped: true },
]);
const link = f.linksAt(1)?.[0];
assert.ok(link);
assert.deepEqual(link.range, {
start: { x: 1, y: 1 },
end: { x: 9, y: 2 },
});
f.atPrompt();
link.activate(ctrl, link.text);
assert.deepEqual(f.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
});
test("absolute names link without markers and relative names do not", () => {
const relative = fixture();
relative.setLine(0, "bundle.tar.gz");
assert.equal(relative.linksAt(1), undefined);
const absolute = fixture();
absolute.setLine(0, "/var/pack.zip");
const link = absolute.linksAt(1)?.[0];
assert.ok(link);
absolute.atPrompt();
link.activate(ctrl, link.text);
assert.deepEqual(absolute.pasted, ["unzip '/var/pack.zip' -d '/var'"]);
const open = fixture();
open.setLines([
{ text: "prompt", wrapped: false },
{ text: "/var/pack.zip", wrapped: false },
]);
open.noteAt(0, { cwd: "/var", host: "box" });
const openLink = open.linksAt(2)?.[0];
assert.ok(openLink);
open.atPrompt();
openLink.activate(ctrl, openLink.text);
assert.equal(open.pasted[0], "unzip '/var/pack.zip' -d '/var'");
open.setLine(1, "pack.zip");
assert.equal(open.linksAt(2), undefined);
const mismatched = fixture();
mismatched.setLines([
{ text: "prompt", wrapped: false },
{ text: "/tmp/a.tar.gz", wrapped: false },
{ text: "prompt", wrapped: false },
]);
mismatched.noteAt(0, { cwd: "/tmp", host: "box" });
mismatched.noteAt(2, { cwd: "/other", host: "box" });
const mismatchLink = mismatched.linksAt(2)?.[0];
assert.ok(mismatchLink);
mismatched.atPrompt();
mismatchLink.activate(ctrl, mismatchLink.text);
assert.equal(mismatched.pasted[0], "tar -xvzf '/tmp/a.tar.gz' -C '/tmp'");
mismatched.setLine(1, "a.tar.gz");
assert.equal(mismatched.linksAt(2), undefined);
});
test("an absolute path from another host is not a link", () => {
const f = fixture();
f.setLines([
{ text: "prompt", wrapped: false },
{ text: "/tmp/a.tar.gz", wrapped: false },
{ text: "prompt", wrapped: false },
]);
f.noteAt(0, { cwd: "/tmp", host: "outer" });
f.noteAt(2, { cwd: "/tmp", host: "outer" });
f.noteAt(4, { cwd: "/home", host: "inner" });
assert.equal(f.linksAt(2), undefined);
});
test("ls -l links only the archive name and a symlink line links both names", () => {
const listing = fixture();
listing.setLines([
{ text: "$ ls -l", wrapped: false },
{ text: "-rw-r--r-- 1 me me 10 Jan 1 00:00 bundle.tar.gz", wrapped: false },
{ text: "$", wrapped: false },
]);
listing.noteAt(0, { cwd: "/data", host: "box" });
listing.noteAt(2, { cwd: "/data", host: "box" });
const links = listing.linksAt(2);
assert.deepEqual(links?.map((link) => link.text), ["bundle.tar.gz"]);
listing.atPrompt();
links?.[0]?.activate(ctrl, links[0].text);
assert.deepEqual(listing.pasted, ["tar -xvzf '/data/bundle.tar.gz' -C '/data'"]);
const symlink = fixture();
symlink.setLines([
{ text: "$ ls", wrapped: false },
{ text: "a.tar.gz -> b.tar.gz", wrapped: false },
{ text: "$", wrapped: false },
]);
symlink.noteAt(0, { cwd: "/data", host: "box" });
symlink.noteAt(2, { cwd: "/data", host: "box" });
assert.deepEqual(symlink.linksAt(2)?.map((link) => link.text), ["a.tar.gz", "b.tar.gz"]);
});
@@ -0,0 +1,231 @@
import type { IDisposable, ILink, ILinkProvider, Terminal } from "@xterm/xterm";
import {
buildArchiveExtractCommand,
findTerminalArchiveTokens,
resolveArchivePath,
} from "../../../domain/terminalArchives";
import type { LinkModifier } from "../../../domain/models/terminal";
import {
readPluginTerminalBufferText,
type PluginTerminalBufferText,
} from "../pluginTerminalBufferText";
import type { TerminalCwdInspection, TerminalCwdMarkers } from "./terminalCwdMarkers";
const MAX_LOGICAL_LINE_CHARS = 8192;
export interface TerminalArchiveLinkHost {
term: Terminal;
cwdMarkers: TerminalCwdMarkers;
isConnected(): boolean;
isBroadcasting(): boolean;
isHostEligible(): boolean;
isInputReady(): boolean;
linkModifier(): LinkModifier;
}
interface LogicalSegment {
lineNo: number;
text: PluginTerminalBufferText;
stringStart: number;
}
interface LogicalLine {
text: string;
segments: LogicalSegment[];
}
interface BufferCell {
line: number;
cell: number;
}
function readLogicalLine(term: Terminal, oneBasedY: number): LogicalLine | null {
const buffer = term.buffer.active;
const index = oneBasedY - 1;
if (!buffer.getLine(index)) return null;
let start = index;
while (start > 0 && buffer.getLine(start)?.isWrapped) start -= 1;
const segments: LogicalSegment[] = [];
let total = 0;
for (let lineNo = start; ; lineNo += 1) {
const line = buffer.getLine(lineNo);
if (!line) break;
if (lineNo > start && !line.isWrapped) break;
const next = buffer.getLine(lineNo + 1);
const isLast = !next?.isWrapped;
const text = readPluginTerminalBufferText(line, isLast);
if (total + text.text.length > MAX_LOGICAL_LINE_CHARS) return null;
segments.push({ lineNo, text, stringStart: total });
total += text.text.length;
if (isLast) break;
}
if (segments.length === 0) return null;
return {
text: segments.map((segment) => segment.text.text).join(""),
segments,
};
}
function cellAt(
segments: readonly LogicalSegment[],
offset: number,
bound: "inclusive" | "exclusive",
): BufferCell | null {
for (const segment of segments) {
const local = offset - segment.stringStart;
if (local < 0) return null;
const length = segment.text.text.length;
// A boundary offset is the next row's first cell for a token start, and the
// previous row's exclusive end for a match that stops on that row.
if (bound === "inclusive" ? local >= length : local > length) continue;
const cell = segment.text.cellAtStringOffset[local];
if (!Number.isInteger(cell)) return null;
return { line: segment.lineNo, cell };
}
return null;
}
function sameSpan(left: TerminalCwdInspection, right: TerminalCwdInspection): boolean {
if (left.kind !== right.kind) return false;
if (left.kind === "ambiguous") return false;
if (left.kind === "outside") return true;
if (left.kind === "open") return right.kind === "open" && left.host === right.host;
return right.kind === "closed" && left.key === right.key;
}
function modifierMatches(event: MouseEvent, modifier: LinkModifier): boolean {
if (event.button !== 0) return false;
if (modifier === "ctrl") return event.ctrlKey;
if (modifier === "alt") return event.altKey;
if (modifier === "meta") return event.metaKey;
// "none" still requires Ctrl or Cmd. A configured modifier replaces that default.
return event.ctrlKey || event.metaKey;
}
function neighborNeedsSpace(term: Terminal, direction: -1 | 1): boolean {
const buffer = term.buffer.active;
let lineIndex = buffer.baseY + buffer.cursorY;
let line = buffer.getLine(lineIndex);
if (!line || typeof line.getCell !== "function") return false;
let index = buffer.cursorX + (direction < 0 ? -1 : 0);
for (let steps = 0; steps < term.cols + 1; steps += 1) {
if (index < 0 || index >= term.cols) {
if (direction < 0) {
if (!line.isWrapped) return false;
lineIndex -= 1;
} else {
lineIndex += 1;
const next = buffer.getLine(lineIndex);
if (!next?.isWrapped) return false;
}
line = buffer.getLine(lineIndex);
if (!line || typeof line.getCell !== "function") return false;
index = direction < 0 ? term.cols - 1 : 0;
continue;
}
const cell = line.getCell(index);
if (!cell) return false;
if (cell.getWidth() === 0) {
index += direction;
continue;
}
return (cell.getChars() || " ") !== " ";
}
return false;
}
/**
* Ctrl/Cmd-click archive names to paste an absolute extract command.
* Directory labels come only from OSC 7 markers; input is never observed.
*/
export function registerTerminalArchiveLinks(options: TerminalArchiveLinkHost): IDisposable {
let disposed = false;
const { term, cwdMarkers } = options;
const sessionAllowsLinks = (): boolean => options.isConnected()
&& !options.isBroadcasting()
&& options.isHostEligible()
&& options.isInputReady()
&& term.buffer.active.type === "normal";
const cwdFor = (path: string, start: BufferCell, end: BufferCell): string | null | undefined => {
const left = cwdMarkers.inspectCell(start.line, start.cell);
const right = cwdMarkers.inspectCell(end.line, end.cell);
if (!sameSpan(left, right) || left.kind === "ambiguous") return undefined;
const absolute = path.startsWith("/");
if (absolute) {
if (left.kind === "outside") return null;
const latest = cwdMarkers.latestHost();
if (latest != null && left.host !== latest) return undefined;
return null;
}
if (left.kind !== "closed" || !left.trusted) return undefined;
const latest = cwdMarkers.latestHost();
if (latest != null && left.host !== latest) return undefined;
return left.cwd;
};
const provider: ILinkProvider = {
provideLinks(bufferLineNumber, callback) {
if (disposed || !sessionAllowsLinks()) {
callback(undefined);
return;
}
const logical = readLogicalLine(term, bufferLineNumber);
if (!logical || logical.text.length === 0) {
callback(undefined);
return;
}
const links: ILink[] = [];
for (const token of findTerminalArchiveTokens(logical.text)) {
const start = cellAt(logical.segments, token.start, "inclusive");
const endExclusive = cellAt(logical.segments, token.start + token.length, "exclusive");
const last = cellAt(logical.segments, token.start + token.length - 1, "inclusive");
if (!start || !endExclusive || !last) continue;
const cwd = cwdFor(token.path, start, last);
if (cwd === undefined) continue;
const abs = resolveArchivePath(token.path, cwd);
const command = abs ? buildArchiveExtractCommand(abs) : null;
if (!command) continue;
const capturedText = logical.text;
links.push({
range: {
start: { x: start.cell + 1, y: start.line + 1 },
end: { x: endExclusive.cell, y: endExclusive.line + 1 },
},
text: token.text,
decorations: { pointerCursor: true, underline: true },
activate(event) {
if (disposed || !modifierMatches(event, options.linkModifier())) return;
if (!sessionAllowsLinks()) return;
const fresh = readLogicalLine(term, start.line + 1);
if (!fresh || fresh.text !== capturedText) return;
const freshStart = cellAt(fresh.segments, token.start, "inclusive");
const freshLast = cellAt(fresh.segments, token.start + token.length - 1, "inclusive");
if (!freshStart || !freshLast) return;
const freshCwd = cwdFor(token.path, freshStart, freshLast);
if (freshCwd === undefined) return;
const freshAbs = resolveArchivePath(token.path, freshCwd);
const freshCommand = freshAbs ? buildArchiveExtractCommand(freshAbs) : null;
if (!freshCommand || /[\r\n]/u.test(freshCommand)) return;
const lead = neighborNeedsSpace(term, -1) ? " " : "";
const trail = neighborNeedsSpace(term, 1) ? " " : "";
term.paste(`${lead}${freshCommand}${trail}`);
term.focus();
},
});
}
callback(links.length > 0 ? links : undefined);
},
};
const registration = term.registerLinkProvider(provider);
return {
dispose() {
disposed = true;
registration.dispose();
},
};
}
@@ -1,8 +1,16 @@
const commandInjectionReadyReaders = new Map<string, () => boolean>();
export interface TerminalCommandInjectionReadyOptions {
/** Keep a typed draft. Sensitive prompts, alt-screen, and non-prompts still block. */
allowDraft?: boolean;
}
const commandInjectionReadyReaders = new Map<
string,
(options?: TerminalCommandInjectionReadyOptions) => boolean
>();
export function registerTerminalCommandInjectionReadyReader(
sessionId: string,
reader: () => boolean,
reader: (options?: TerminalCommandInjectionReadyOptions) => boolean,
): () => void {
commandInjectionReadyReaders.set(sessionId, reader);
return () => {
@@ -12,7 +20,10 @@ export function registerTerminalCommandInjectionReadyReader(
};
}
/** True when the live terminal reports an idle shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
return commandInjectionReadyReaders.get(sessionId)?.() === true;
/** True when the live terminal reports a shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(
sessionId: string,
options?: TerminalCommandInjectionReadyOptions,
): boolean {
return commandInjectionReadyReaders.get(sessionId)?.(options) === true;
}
@@ -0,0 +1,185 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
type FakeMarker = {
line: number;
isDisposed: boolean;
dispose: () => void;
};
function harness() {
let cursorLine = 0;
let cursorX = 0;
let cols = 80;
let type: "normal" | "alternate" = "normal";
const created: FakeMarker[] = [];
const term = {
get cols() {
return cols;
},
buffer: {
active: {
get type() {
return type;
},
get cursorX() {
return cursorX;
},
},
},
registerMarker(offset: number) {
const marker: FakeMarker = {
line: cursorLine + offset,
isDisposed: false,
dispose() {
this.isDisposed = true;
},
};
created.push(marker);
return marker;
},
};
const markers = createTerminalCwdMarkers(term as never);
return {
markers,
created,
setCursor(line: number, x = 0) {
cursorLine = line;
cursorX = x;
},
setCols(next: number) {
cols = next;
},
setType(next: "normal" | "alternate") {
type = next;
},
};
}
test("prompt-only intervals are trusted only when both ends report the same cwd", () => {
const same = harness();
same.setCursor(0);
same.markers.note({ cwd: "/a", host: "box" });
same.setCursor(3);
same.markers.note({ cwd: "/a", host: "box" });
assert.deepEqual(same.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
const changed = harness();
changed.setCursor(0);
changed.markers.note({ cwd: "/a", host: "box" });
changed.setCursor(3);
changed.markers.note({ cwd: "/a/x", host: "box" });
assert.equal(changed.markers.resolveCell(1, 0), null);
const thenCd = harness();
thenCd.setCursor(0);
thenCd.markers.note({ cwd: "/a", host: "box" });
thenCd.setCursor(4);
thenCd.markers.note({ cwd: "/a/x", host: "box" });
assert.equal(thenCd.markers.resolveCell(2, 0), null);
});
test("a chpwd emitter trusts the interval after the directory change", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(2);
h.markers.note({ cwd: "/a/x", host: "box" });
h.setCursor(5);
h.markers.note({ cwd: "/a/x", host: "box" });
assert.equal(h.markers.resolveCell(1, 0), null);
assert.deepEqual(h.markers.resolveCell(3, 0), { cwd: "/a/x", host: "box" });
});
test("the open interval after the latest marker is not trusted", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(2);
h.markers.note({ cwd: "/a", host: "box" });
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
assert.equal(h.markers.resolveCell(4, 0), null);
});
test("a host mismatch between interval ends is not trusted and latestHost follows the last marker", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "outer" });
h.setCursor(2);
h.markers.note({ cwd: "/a", host: "inner" });
assert.equal(h.markers.resolveCell(1, 0), null);
assert.equal(h.markers.latestHost(), "inner");
});
test("a disposed start or end marker drops the interval", () => {
const start = harness();
start.setCursor(0);
start.markers.note({ cwd: "/a", host: "box" });
start.setCursor(2);
start.markers.note({ cwd: "/a", host: "box" });
start.created[0]?.dispose();
assert.equal(start.markers.resolveCell(1, 0), null);
const end = harness();
end.setCursor(0);
end.markers.note({ cwd: "/a", host: "box" });
end.setCursor(2);
end.markers.note({ cwd: "/a", host: "box" });
end.created[1]?.dispose();
assert.equal(end.markers.resolveCell(1, 0), null);
});
test("a new marker above existing ones drops the later markers", () => {
const h = harness();
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(5);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(10);
h.markers.note({ cwd: "/a", host: "box" });
h.setCursor(3);
h.markers.note({ cwd: "/a", host: "box" });
assert.equal(h.created[1]?.isDisposed, true);
assert.equal(h.created[2]?.isDisposed, true);
assert.equal(h.created[0]?.isDisposed, false);
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
assert.equal(h.markers.resolveCell(6, 0), null);
});
test("cells left of a marker belong to the previous interval and a cols change makes that row ambiguous", () => {
const split = harness();
split.setCursor(0, 0);
split.markers.note({ cwd: "/old", host: "box" });
split.setCursor(5, 10);
split.markers.note({ cwd: "/new", host: "box" });
split.setCursor(9, 0);
split.markers.note({ cwd: "/new", host: "box" });
assert.equal(split.markers.resolveCell(5, 9), null);
assert.deepEqual(split.markers.resolveCell(5, 10), { cwd: "/new", host: "box" });
assert.deepEqual(split.markers.resolveCell(7, 0), { cwd: "/new", host: "box" });
const resized = harness();
resized.setCursor(0, 0);
resized.markers.note({ cwd: "/a", host: "box" });
resized.setCursor(5, 4);
resized.markers.note({ cwd: "/a", host: "box" });
resized.setCursor(9, 0);
resized.markers.note({ cwd: "/a", host: "box" });
resized.setCols(100);
assert.equal(resized.markers.resolveCell(5, 0), null);
assert.equal(resized.markers.resolveCell(5, 4), null);
assert.equal(resized.markers.resolveCell(0, 1), null);
assert.deepEqual(resized.markers.resolveCell(3, 0), { cwd: "/a", host: "box" });
});
test("the alternate buffer does not record cwd markers", () => {
const h = harness();
h.setType("alternate");
h.setCursor(0);
h.markers.note({ cwd: "/a", host: "box" });
assert.equal(h.created.length, 0);
assert.equal(h.markers.resolveCell(0, 0), null);
assert.equal(h.markers.latestHost(), null);
});
@@ -0,0 +1,153 @@
const MAX_CWD_MARKERS = 5000;
interface CwdMarker {
readonly line: number;
readonly isDisposed: boolean;
dispose(): void;
}
export interface TerminalCwdMarkerTerminal {
readonly cols: number;
readonly buffer: {
readonly active: {
readonly type: "normal" | "alternate";
readonly cursorX: number;
};
};
registerMarker(cursorYOffset: number): CwdMarker | undefined;
}
interface CwdMarkerEntry {
id: number;
marker: CwdMarker;
x: number;
cols: number;
cwd: string;
host: string;
}
export interface TerminalCwdCell {
cwd: string;
host: string;
}
export type TerminalCwdInspection =
| { kind: "outside" }
| { kind: "ambiguous" }
| { kind: "open"; host: string; cwd: string }
| { kind: "closed"; host: string; cwd: string; trusted: boolean; key: string };
export interface TerminalCwdMarkers {
note(report: { cwd: string; host: string }): void;
resolveCell(row: number, col: number): TerminalCwdCell | null;
inspectCell(row: number, col: number): TerminalCwdInspection;
latestHost(): string | null;
dispose(): void;
}
/**
* OSC 7 cwd labels. Output is trusted only between two markers that agree.
* Nothing here watches keyboard or paste input.
*/
export function createTerminalCwdMarkers(term: TerminalCwdMarkerTerminal): TerminalCwdMarkers {
let entries: CwdMarkerEntry[] = [];
let nextId = 1;
let disposed = false;
const liveEntries = (): CwdMarkerEntry[] => {
if (entries.some((entry) => entry.marker.isDisposed)) {
entries = entries.filter((entry) => !entry.marker.isDisposed);
}
for (let index = 1; index < entries.length; index += 1) {
if (entries[index - 1].marker.line > entries[index].marker.line) {
entries.sort((left, right) => left.marker.line - right.marker.line);
break;
}
}
return entries;
};
const relation = (
row: number,
col: number,
entry: CwdMarkerEntry,
): "before" | "atOrAfter" | "ambiguous" => {
const line = entry.marker.line;
if (row < line) return "before";
if (row > line) return "atOrAfter";
if (term.cols !== entry.cols) return "ambiguous";
return col < entry.x ? "before" : "atOrAfter";
};
const inspectCell = (row: number, col: number): TerminalCwdInspection => {
const list = liveEntries();
if (list.length === 0) return { kind: "outside" };
let start: CwdMarkerEntry | null = null;
for (const entry of list) {
const where = relation(row, col, entry);
if (where === "ambiguous") return { kind: "ambiguous" };
if (where === "before") {
if (!start) return { kind: "outside" };
if (start.host !== entry.host) return { kind: "ambiguous" };
return {
kind: "closed",
host: start.host,
cwd: start.cwd,
trusted: start.cwd === entry.cwd,
key: `${start.id}:${entry.id}`,
};
}
start = entry;
}
if (!start) return { kind: "outside" };
return { kind: "open", host: start.host, cwd: start.cwd };
};
return {
note(report) {
if (disposed || term.buffer.active.type !== "normal") return;
const marker = term.registerMarker(0);
if (!marker) return;
const fresh: CwdMarkerEntry = {
id: nextId,
marker,
x: term.buffer.active.cursorX,
cols: term.cols,
cwd: report.cwd,
host: report.host,
};
nextId += 1;
const kept: CwdMarkerEntry[] = [];
for (const entry of entries) {
if (entry.marker.isDisposed || entry.marker.line >= marker.line) {
if (!entry.marker.isDisposed) entry.marker.dispose();
continue;
}
kept.push(entry);
}
kept.push(fresh);
while (kept.length > MAX_CWD_MARKERS) {
const removed = kept.shift();
if (removed && !removed.marker.isDisposed) removed.marker.dispose();
}
entries = kept;
},
inspectCell,
resolveCell(row, col) {
const inspection = inspectCell(row, col);
if (inspection.kind !== "closed" || !inspection.trusted) return null;
return { cwd: inspection.cwd, host: inspection.host };
},
latestHost() {
const list = liveEntries();
return list.length > 0 ? list[list.length - 1].host : null;
},
dispose() {
disposed = true;
for (const entry of entries) {
if (!entry.marker.isDisposed) entry.marker.dispose();
}
entries = [];
},
};
}
@@ -214,6 +214,8 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
terminalSettings,
@@ -529,6 +531,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
const runtime = createXTermRuntime({
container: containerRef.current,
host,
getHost: () => archiveHostRef.current,
fontFamilyId,
resolvedFontFamily,
fontSize,
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const toastSource = readFileSync(new URL("./toast.tsx", import.meta.url), "utf8");
test("toast surfaces follow app theme tokens instead of OS palette fills", () => {
const styles = toastSource.slice(
toastSource.indexOf("const TOAST_STYLES"),
toastSource.indexOf("export const ToastProvider"),
);
assert.match(styles, /success: 'border-emerald-600'/);
assert.match(styles, /error: 'border-red-600'/);
assert.match(styles, /warning: 'border-yellow-600'/);
assert.match(styles, /info: 'border-blue-600'/);
// `dark:` is prefers-color-scheme, so these fills stay pale while
// text-foreground / text-muted-foreground follow the `.dark` class.
assert.doesNotMatch(styles, /bg-(?:emerald|red|yellow|blue)-50/);
assert.doesNotMatch(styles, /dark:bg-(?:emerald|red|yellow|blue)-950/);
assert.match(toastSource, /"bg-card animate-in/);
assert.match(toastSource, /text-sm font-medium text-foreground/);
assert.match(toastSource, /text-sm text-muted-foreground break-words/);
});
+8 -4
View File
@@ -82,11 +82,15 @@ const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
info: <Info className="h-4 w-4 text-blue-500" />,
};
// Colored `bg-*-50` / `dark:bg-*-950` fills follow the OS color scheme.
// Title and body use `.dark` tokens (`text-foreground`, `text-muted-foreground`),
// so an app-dark / OS-light session paints light text on a pale fill.
// The container's `bg-card` tracks the app theme; the border and icon mark the variant.
const TOAST_STYLES: Record<ToastType, string> = {
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
error: 'border-red-600 bg-red-50 dark:bg-red-950',
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
success: 'border-emerald-600',
error: 'border-red-600',
warning: 'border-yellow-600',
info: 'border-blue-600',
};
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+10 -9
View File
@@ -1007,42 +1007,43 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="end" alignToParent>
{/* Fixed w-44 flex-shrinks the shell icon when the label is longer than the siblings. */}
<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
className="justify-start gap-2"
onClick={handleNewLocalShellHost}
>
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
<TerminalSquare size={14} className="shrink-0" /> {t("vault.hosts.newLocalShell")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
className="justify-start gap-2"
onClick={() => {
setTargetParentPath(selectedGroupPath);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
<FolderTree size={14} className="shrink-0" /> {t("vault.hosts.newGroup")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
className="justify-start gap-2"
disabled={importProgress?.status === "running"}
onClick={() => {
if (importProgress?.status === "running") return;
setIsImportOpen(true);
}}
>
<Upload size={14} /> {t("vault.hosts.import")}
<Upload size={14} className="shrink-0" /> {t("vault.hosts.import")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
className="justify-start gap-2"
onClick={handleExportHosts}
>
<Download size={14} /> {t("vault.hosts.export")}
<Download size={14} className="shrink-0" /> {t("vault.hosts.export")}
</Button>
</DropdownContent>
</Dropdown>
+54
View File
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import test from "node:test";
import {
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
buildCodexHttpToml,
buildExternalMcpHttpUrl,
buildGenericHttpMcpJson,
} from "./externalMcpHttp.ts";
const require = createRequire(import.meta.url);
const security = require("../electron/bridges/externalMcp/httpSecurity.cjs");
test("HTTP client URL stays on loopback unless a specific listen address is set", () => {
assert.equal(DEFAULT_EXTERNAL_MCP_HTTP_PORT, security.DEFAULT_EXTERNAL_MCP_HTTP_PORT);
assert.equal(
buildExternalMcpHttpUrl("127.0.0.1", 47391),
"http://127.0.0.1:47391/mcp",
);
assert.equal(buildExternalMcpHttpUrl("0.0.0.0", 47391), "");
assert.equal(
buildExternalMcpHttpUrl("0.0.0.0", 47391, "172.22.0.1"),
"http://172.22.0.1:47391/mcp",
);
assert.equal(
buildExternalMcpHttpUrl("172.22.0.1", 47391),
"http://172.22.0.1:47391/mcp",
);
});
test("HTTP snippets use Codex env bearer form, Claude http transport, and generic JSON", () => {
const url = "http://127.0.0.1:47391/mcp";
const token = "abc123";
const toml = buildCodexHttpToml(url);
assert.match(toml, /\[mcp_servers\.netcatty-http\]/);
assert.match(toml, /url = "http:\/\/127\.0\.0\.1:47391\/mcp"/);
assert.match(toml, /bearer_token_env_var = "NETCATTY_MCP_BEARER_TOKEN"/);
assert.equal(buildCodexHttpEnvLine(token), "export NETCATTY_MCP_BEARER_TOKEN='abc123'");
assert.equal(
buildCodexHttpEnvLine("a'b"),
"export NETCATTY_MCP_BEARER_TOKEN='a'\\''b'",
);
assert.equal(
buildClaudeHttpAddCommand(url, token),
"claude mcp add --transport http netcatty-http 'http://127.0.0.1:47391/mcp' --header 'Authorization: Bearer abc123'",
);
const generic = JSON.parse(buildGenericHttpMcpJson(url, token));
assert.equal(generic.mcpServers["netcatty-http"].type, "http");
assert.equal(generic.mcpServers["netcatty-http"].url, url);
assert.equal(generic.mcpServers["netcatty-http"].headers.Authorization, "Bearer abc123");
});
+58
View File
@@ -0,0 +1,58 @@
export const EXTERNAL_MCP_HTTP_BEARER_ENV = "NETCATTY_MCP_BEARER_TOKEN";
export const DEFAULT_EXTERNAL_MCP_HTTP_PORT = 47391;
export const DEFAULT_EXTERNAL_MCP_HTTP_HOST = "127.0.0.1";
export const EXTERNAL_MCP_HTTP_PATH = "/mcp";
function formatHostForUrl(host: string): string {
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
return host;
}
function isWildcardBind(address: string): boolean {
return address === "0.0.0.0" || address === "::";
}
export function buildExternalMcpHttpUrl(listenAddress: string, port: number, clientHost = ""): string {
const host = isWildcardBind(listenAddress) ? clientHost.trim() : listenAddress;
if (!host || isWildcardBind(host)) return "";
return `http://${formatHostForUrl(host)}:${port}${EXTERNAL_MCP_HTTP_PATH}`;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function buildCodexHttpToml(url: string): string {
return [
"[mcp_servers.netcatty-http]",
`url = ${JSON.stringify(url)}`,
`bearer_token_env_var = ${JSON.stringify(EXTERNAL_MCP_HTTP_BEARER_ENV)}`,
].join("\n");
}
export function buildCodexHttpEnvLine(token: string): string {
return `export ${EXTERNAL_MCP_HTTP_BEARER_ENV}=${shellQuote(token)}`;
}
export function buildClaudeHttpAddCommand(url: string, token: string): string {
return [
"claude mcp add --transport http netcatty-http",
shellQuote(url),
"--header",
shellQuote(`Authorization: Bearer ${token}`),
].join(" ");
}
export function buildGenericHttpMcpJson(url: string, token: string): string {
return JSON.stringify({
mcpServers: {
"netcatty-http": {
type: "http",
url,
headers: {
Authorization: `Bearer ${token}`,
},
},
},
}, null, 2);
}
+1
View File
@@ -486,5 +486,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
pluginConnection,
};
delete (sanitized as { singleChannelSsh?: unknown }).singleChannelSsh;
delete (sanitized as { backedBySavedHost?: boolean }).backedBySavedHost;
return stripBuiltInConnectionFieldsForPluginHost(sanitized);
};
+137
View File
@@ -0,0 +1,137 @@
import assert from "node:assert/strict";
import test from "node:test";
import { sanitizeHost } from "./host.ts";
import {
prepareConnectionHost,
resolveConnectionLegacyAlgorithms,
} from "./legacyAlgorithms.ts";
import { normalizeTerminalSettings } from "./models/terminal.ts";
import type { Host } from "./models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "host",
hostname: "bastion.example",
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
});
test("a saved host uses its per-host legacy algorithm value when the global setting is on", () => {
const saved = host({
hostname: "127.0.0.1",
port: 2222,
legacyAlgorithms: false,
});
assert.equal(prepareConnectionHost(saved, true), saved);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: true,
hostLegacyAlgorithms: false,
temporaryConnectionsAllowLegacyAlgorithms: true,
}), false);
});
test("a saved host keeps legacy algorithms enabled when the global setting is off", () => {
const saved = host({ legacyAlgorithms: true });
assert.equal(prepareConnectionHost(saved, false), saved);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: true,
hostLegacyAlgorithms: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
}), true);
});
test("a saved host with no explicit value does not inherit the global temporary setting", () => {
const saved = host({ hostname: "127.0.0.1", legacyAlgorithms: undefined });
assert.equal(prepareConnectionHost(saved, true), saved);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: true,
hostLegacyAlgorithms: undefined,
temporaryConnectionsAllowLegacyAlgorithms: true,
}), false);
});
test("a temporary connection uses the global legacy algorithm value", () => {
const temporary = host({
id: "ephemeral-1",
ephemeral: true,
hostname: "127.0.0.1",
port: 54321,
});
assert.equal(prepareConnectionHost(temporary, true).legacyAlgorithms, true);
assert.equal(prepareConnectionHost(temporary, false).legacyAlgorithms, undefined);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
temporaryConnectionsAllowLegacyAlgorithms: true,
}), true);
assert.equal(resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
hostLegacyAlgorithms: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
}), false);
});
test("an ephemeral clone of a saved host keeps that host's legacy algorithm value", () => {
const cloned = host({
id: "ephemeral-from-saved",
ephemeral: true,
backedBySavedHost: true,
hostname: "127.0.0.1",
port: 61000,
legacyAlgorithms: false,
});
assert.equal(prepareConnectionHost(cloned, true), cloned);
assert.equal(prepareConnectionHost(host({
ephemeral: true,
backedBySavedHost: true,
legacyAlgorithms: true,
}), false).legacyAlgorithms, true);
});
test("temporary connection legacy algorithms default to off", () => {
const temporary = host({ ephemeral: true, hostname: "127.0.0.1" });
assert.equal(resolveConnectionLegacyAlgorithms({ hasSavedHost: false }), false);
assert.equal(prepareConnectionHost(temporary).legacyAlgorithms, undefined);
assert.equal(normalizeTerminalSettings().temporaryConnectionsAllowLegacyAlgorithms, false);
assert.equal(
normalizeTerminalSettings({ temporaryConnectionsAllowLegacyAlgorithms: true })
.temporaryConnectionsAllowLegacyAlgorithms,
true,
);
assert.equal(
normalizeTerminalSettings({
temporaryConnectionsAllowLegacyAlgorithms: "yes" as never,
}).temporaryConnectionsAllowLegacyAlgorithms,
false,
);
});
test("saved-host clones do not persist the runtime backing marker", () => {
const sanitized = sanitizeHost(host({
ephemeral: true,
backedBySavedHost: true,
legacyAlgorithms: true,
}));
assert.equal("backedBySavedHost" in sanitized, false);
assert.equal(sanitized.legacyAlgorithms, true);
});
test("temporary telnet connections do not receive the SSH legacy algorithm flag", () => {
const telnet = host({
ephemeral: true,
protocol: "telnet",
hostname: "127.0.0.1",
});
assert.equal(prepareConnectionHost(telnet, true), telnet);
});
+112
View File
@@ -0,0 +1,112 @@
import type { Host } from "./models";
export interface ConnectionLegacyAlgorithmInput {
/** True when a saved vault host owns this connection, including an ephemeral clone of one. */
hasSavedHost: boolean;
/** Per-host value after that host's own settings are applied. */
hostLegacyAlgorithms?: boolean;
/**
* Global opt-in for SSH connections with no saved host.
* Missing or any non-true value stays off.
*/
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
}
export interface PrepareConnectionHostOptions {
/**
* Global opt-in for SSH connections with no saved host.
* Missing or any non-true value stays off.
*/
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
/**
* Global opt-in that starts SSH connections with no saved host in network
* device mode. Missing or any non-true value leaves deviceType alone.
*/
temporaryConnectionsNetworkDeviceMode?: boolean;
}
/**
* Saved hosts keep their own legacy-algorithm choice. Connections with no
* saved host use the global temporary-connection opt-in, which defaults to
* off. The address is not an input, so 127.0.0.1 alone never opts in.
*/
export function resolveConnectionLegacyAlgorithms(
input: ConnectionLegacyAlgorithmInput,
): boolean {
if (input.hasSavedHost) return input.hostLegacyAlgorithms === true;
return input.temporaryConnectionsAllowLegacyAlgorithms === true;
}
export function connectionHasSavedHost(
host: Pick<Host, "ephemeral" | "backedBySavedHost"> | null | undefined,
): boolean {
if (!host) return false;
if (host.backedBySavedHost === true) return true;
return host.ephemeral !== true;
}
function isTemporarySshHost(
host: Pick<Host, "ephemeral" | "backedBySavedHost" | "protocol">,
): boolean {
if (connectionHasSavedHost(host)) return false;
return host.protocol === undefined || host.protocol === "ssh";
}
function readPrepareConnectionHostOptions(
options?: boolean | PrepareConnectionHostOptions,
): PrepareConnectionHostOptions {
if (typeof options === "boolean") {
return { temporaryConnectionsAllowLegacyAlgorithms: options };
}
return options ?? {};
}
/** Read the two temporary-connection toggles from the settings object given now. */
export function temporaryConnectionPrepareOptions(
settings: {
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
temporaryConnectionsNetworkDeviceMode?: boolean;
} | null | undefined,
): PrepareConnectionHostOptions {
return {
temporaryConnectionsAllowLegacyAlgorithms:
settings?.temporaryConnectionsAllowLegacyAlgorithms,
temporaryConnectionsNetworkDeviceMode:
settings?.temporaryConnectionsNetworkDeviceMode,
};
}
/**
* Stamp temporary-connection defaults onto unsaved temporary SSH hosts.
* Saved hosts, and ephemeral clones of saved hosts, are returned unchanged so
* their own values (including a later group default) still apply.
*
* A boolean second argument is the legacy-algorithm opt-in, matching callers
* from before network device mode existed.
*/
export function prepareConnectionHost<T extends Host>(
host: T,
options?: boolean | PrepareConnectionHostOptions,
): T {
if (!isTemporarySshHost(host)) return host;
const resolved = readPrepareConnectionHostOptions(options);
const legacyAlgorithms = resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
temporaryConnectionsAllowLegacyAlgorithms: resolved.temporaryConnectionsAllowLegacyAlgorithms,
});
const legacyUnchanged = (host.legacyAlgorithms === true) === legacyAlgorithms;
// Mosh and ET are stored as SSH plus a transport flag. Network device mode
// is a plain-SSH CLI behavior; stamping it onto those hosts makes later
// raw-host checks (such as the copy-session cwd probe) treat them as
// network gear.
const stampNetworkDevice = resolved.temporaryConnectionsNetworkDeviceMode === true
&& host.moshEnabled !== true
&& host.etEnabled !== true;
const deviceTypeUnchanged = !stampNetworkDevice || host.deviceType === "network";
if (legacyUnchanged && deviceTypeUnchanged) return host;
return {
...host,
...(legacyUnchanged ? {} : { legacyAlgorithms }),
...(stampNetworkDevice ? { deviceType: "network" as const } : {}),
};
}
+4
View File
@@ -183,6 +183,10 @@ export interface Host {
// Runtime marker for in-memory-only hosts (e.g. password deep links).
// Ephemeral hosts are never persisted to the vault or session restore.
ephemeral?: boolean;
// Runtime marker for an ephemeral host cloned from a saved vault host.
// Those connections keep the saved host's own SSH settings. Pure temporary
// launches (external command lines, quick connect) leave this unset.
backedBySavedHost?: boolean;
// Runtime hint for deep-link launches that target file transfer (e.g.
// JumpServer sftp payloads): auto-open the SFTP side panel on connect.
autoOpenSftpPanel?: boolean;
+18
View File
@@ -143,6 +143,18 @@ export interface TerminalSettings {
// SSH Connection
verifyHostKeys: boolean; // Verify SSH host keys before authenticating
/**
* Offer the per-host legacy SSH algorithm list for connections that have
* no saved host (external launches and quick connect). Saved hosts ignore
* this and keep their own setting. Off by default.
*/
temporaryConnectionsAllowLegacyAlgorithms: boolean;
/**
* Start SSH connections that have no saved host (external launches, deep
* links, and quick connect) in network device mode. Saved hosts and groups
* keep their own device type. Off by default.
*/
temporaryConnectionsNetworkDeviceMode: boolean;
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
sshAutoReconnectEnabled: boolean; // Automatically reconnect SSH sessions after unexpected disconnects
@@ -468,6 +480,10 @@ export const normalizeTerminalSettings = (
return {
...mergedSettings,
temporaryConnectionsAllowLegacyAlgorithms:
mergedSettings.temporaryConnectionsAllowLegacyAlgorithms === true,
temporaryConnectionsNetworkDeviceMode:
mergedSettings.temporaryConnectionsNetworkDeviceMode === true,
rendererType,
autocompleteMaxSuggestions,
multilinePasteConfirmMinLines: normalizeMultilinePasteConfirmMinLines(
@@ -568,6 +584,8 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
// reply to keepalive@openssh.com (older routers/switches) should set their
// own per-host keepaliveOverride and dial these values down.
verifyHostKeys: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
temporaryConnectionsNetworkDeviceMode: false,
keepaliveInterval: 30,
keepaliveCountMax: 10,
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
+24
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
isQuickConnectInput,
isSwitcherQuickConnectInput,
parseQuickConnectInput,
} from "./quickConnect";
@@ -119,3 +120,26 @@ test("quick connect rejects malformed multi-@ inputs", () => {
assert.equal(parseQuickConnectInput("root@10.2.0.8@"), null);
assert.equal(parseQuickConnectInput("root@bad host@jump"), null);
});
test("switcher quick connect accepts only address-like input", () => {
for (const label of ["lab", "web01", "prod", "lab-switch", "localhost"]) {
assert.equal(isQuickConnectInput(label), true);
assert.equal(isSwitcherQuickConnectInput(label), false);
}
for (const address of [
"10.1.1.1",
"root@10.2.0.8:2222",
"example.com",
"host:2222",
"user@lab",
"[::1]",
"fe80::1",
"[2001:db8::1]:2222",
]) {
assert.equal(isSwitcherQuickConnectInput(address), true, address);
}
assert.equal(isSwitcherQuickConnectInput("core switch"), false);
assert.equal(isSwitcherQuickConnectInput("host:99999"), false);
});
+27
View File
@@ -360,3 +360,30 @@ export function parseQuickConnectInput(
export function isQuickConnectInput(input: string): boolean {
return parseQuickConnectInput(input) !== null;
}
const SWITCHER_PORT_SUFFIX = /:(\d{1,5})$/;
/**
* Tab-switcher quick connect is stricter than vault search. A single saved
* label such as "lab" or "lab-switch" must stay a host search, not an address.
* Address-like means `@`, a numeric :port, an IP, or a dot.
*/
export function isSwitcherQuickConnectInput(input: string): boolean {
if (!isQuickConnectInput(input)) return false;
const trimmed = input.trim();
if (trimmed.includes("@") || trimmed.includes(".")) return true;
const portMatch = trimmed.match(SWITCHER_PORT_SUFFIX);
if (portMatch) {
const port = Number(portMatch[1]);
if (port >= 1 && port <= 65535) return true;
}
const hostToken = trimmed.split(/\s+/).pop() ?? trimmed;
const withoutUser = hostToken.includes("@")
? hostToken.slice(hostToken.lastIndexOf("@") + 1)
: hostToken;
const withoutPort = withoutUser.replace(SWITCHER_PORT_SUFFIX, "");
const unbracketed = withoutPort.replace(/^\[|\]$/g, "");
return isBareIPv6(unbracketed);
}
+12
View File
@@ -1,7 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "../application/i18n/locales/en.ts";
import zhCN from "../application/i18n/locales/zh-CN.ts";
import zhTW from "../application/i18n/locales/zh-TW.ts";
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
@@ -122,6 +124,16 @@ test("settings search finds App Lock from the zh-CN locale", () => {
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
});
test("settings search finds temporary network device mode", () => {
const tEnReal = (key: string) => en[key] ?? key;
const tZhTW = (key: string) => zhTW[key] ?? key;
const english = filterSettingsSearchCatalog("network device", tEnReal);
assert.ok(english.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
const traditional = filterSettingsSearchCatalog("網路裝置", tZhTW);
assert.ok(traditional.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
});
test("filterSettingsSearchCatalog can exclude plugins tab", () => {
const hits = filterSettingsSearchCatalog("plugin", tEn, { includePlugins: false });
assert.equal(hits.some((hit) => hit.entry.tab === "plugins"), false);
+25
View File
@@ -480,6 +480,22 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
descriptionKey: "settings.terminal.connection.verifyHostKeys.desc",
sectionKey: "settings.terminal.section.connection",
},
{
id: "terminal-temporary-legacy-algorithms",
tab: "terminal",
labelKey: "settings.terminal.connection.temporaryLegacyAlgorithms",
descriptionKey: "settings.terminal.connection.temporaryLegacyAlgorithms.desc",
sectionKey: "settings.terminal.section.connection",
keywords: ["legacy", "kex", "putty", "旧版算法", "演算法", "堡垒机"],
},
{
id: "terminal-temporary-network-device-mode",
tab: "terminal",
labelKey: "settings.terminal.connection.temporaryNetworkDeviceMode",
descriptionKey: "settings.terminal.connection.temporaryNetworkDeviceMode.desc",
sectionKey: "settings.terminal.section.connection",
keywords: ["network", "device", "switch", "router", "网络设备", "網路裝置"],
},
{
id: "terminal-ssh-auto-reconnect",
tab: "terminal",
@@ -722,6 +738,15 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
labelKey: "ai.externalMcp.title",
keywords: ["mcp"],
},
{
id: "ai-external-mcp-http",
tab: "ai",
aiSubTab: "tools",
labelKey: "ai.externalMcp.http.title",
descriptionKey: "ai.externalMcp.http.description",
sectionKey: "ai.externalMcp.title",
keywords: ["http", "sse", "streamable", "wsl", "codex"],
},
{
id: "ai-external-mcp-approval-alert",
tab: "ai",
+3
View File
@@ -118,6 +118,7 @@ test("buildSshDeepLinkEphemeralHostFromSaved keeps saved settings but overrides
proxyProfileId: "proxy-1",
hostChain: { hostIds: ["jump-1"] },
charset: "utf8",
legacyAlgorithms: true,
moshEnabled: true,
createdAt: 1,
};
@@ -141,6 +142,8 @@ test("buildSshDeepLinkEphemeralHostFromSaved keeps saved settings but overrides
// Group is cleared so effective-host resolution cannot re-inherit
// group credentials over the one-time password.
assert.equal(ephemeral.group, "");
assert.equal(ephemeral.backedBySavedHost, true);
assert.equal(ephemeral.legacyAlgorithms, true);
assert.equal(ephemeral.proxyProfileId, "proxy-1");
assert.deepEqual(ephemeral.hostChain, { hostIds: ["jump-1"] });
assert.equal(ephemeral.charset, "utf8");
+1
View File
@@ -146,6 +146,7 @@ export const buildSshDeepLinkEphemeralHostFromSaved = (
savePassword: false,
group: "",
ephemeral: true,
backedBySavedHost: true,
protocol: "ssh",
moshEnabled: false,
etEnabled: false,
+213
View File
@@ -0,0 +1,213 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
prepareConnectionHost,
temporaryConnectionPrepareOptions,
} from "./legacyAlgorithms.ts";
import { normalizeTerminalSettings } from "./models/terminal.ts";
import type { Host } from "./models.ts";
import { buildQuickConnectHost } from "./quickConnectHost.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "host",
hostname: "bastion.example",
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
});
test("a saved host keeps its own device type when temporary network device mode is on", () => {
const saved = host({
hostname: "10.0.0.8",
deviceType: "general",
});
const unset = host({ hostname: "10.0.0.9" });
const network = host({ hostname: "10.0.0.10", deviceType: "network" });
assert.equal(prepareConnectionHost(saved, {
temporaryConnectionsNetworkDeviceMode: true,
}), saved);
assert.equal(prepareConnectionHost(unset, {
temporaryConnectionsNetworkDeviceMode: true,
}), unset);
assert.equal(prepareConnectionHost(network, {
temporaryConnectionsNetworkDeviceMode: true,
}), network);
});
test("an ephemeral clone of a saved host keeps that host's device type", () => {
const cloned = host({
id: "ephemeral-from-saved",
ephemeral: true,
backedBySavedHost: true,
hostname: "10.0.0.8",
deviceType: "general",
});
assert.equal(prepareConnectionHost(cloned, {
temporaryConnectionsNetworkDeviceMode: true,
}), cloned);
assert.equal(prepareConnectionHost(host({
ephemeral: true,
backedBySavedHost: true,
deviceType: "network",
}), {
temporaryConnectionsNetworkDeviceMode: false,
}).deviceType, "network");
});
test("a temporary SSH connection uses network device mode only when the setting is on", () => {
const temporary = host({
id: "ephemeral-1",
ephemeral: true,
hostname: "10.1.1.1",
port: 22,
});
assert.equal(prepareConnectionHost(temporary, {
temporaryConnectionsNetworkDeviceMode: true,
}).deviceType, "network");
assert.equal(prepareConnectionHost(temporary, {
temporaryConnectionsNetworkDeviceMode: false,
}), temporary);
assert.equal(prepareConnectionHost(temporary).deviceType, undefined);
});
test("temporary network device mode does not clear an explicit device type when the setting is off", () => {
const temporary = host({
ephemeral: true,
hostname: "10.1.1.1",
deviceType: "network",
});
assert.equal(prepareConnectionHost(temporary, {
temporaryConnectionsNetworkDeviceMode: false,
}), temporary);
});
test("temporary network device mode can be combined with the legacy algorithm opt-in", () => {
const temporary = host({
ephemeral: true,
hostname: "192.0.2.10",
});
const prepared = prepareConnectionHost(temporary, {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
});
assert.equal(prepared.legacyAlgorithms, true);
assert.equal(prepared.deviceType, "network");
assert.equal(prepared.hostname, "192.0.2.10");
});
test("temporary Mosh and ET connections do not receive network device mode", () => {
const mosh = host({
ephemeral: true,
protocol: "ssh",
moshEnabled: true,
hostname: "10.1.1.1",
});
const et = host({
ephemeral: true,
protocol: "ssh",
etEnabled: true,
hostname: "10.1.1.1",
});
const options = {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
};
assert.equal(prepareConnectionHost(mosh, options).deviceType, undefined);
assert.equal(prepareConnectionHost(mosh, options).legacyAlgorithms, true);
assert.equal(prepareConnectionHost(et, options).deviceType, undefined);
assert.equal(prepareConnectionHost(et, options).legacyAlgorithms, true);
});
test("temporary telnet connections do not receive network device mode", () => {
const telnet = host({
ephemeral: true,
protocol: "telnet",
hostname: "10.1.1.1",
});
assert.equal(prepareConnectionHost(telnet, {
temporaryConnectionsNetworkDeviceMode: true,
}), telnet);
});
test("an unsaved quick connect host receives temporary connection defaults", () => {
const quickHost = buildQuickConnectHost({
id: "quick-1",
createdAt: 1,
target: { hostname: "10.1.1.1" },
protocol: "ssh",
port: 22,
username: "admin",
authMethod: "password",
password: "secret",
});
const prepared = prepareConnectionHost(quickHost, {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
});
const saved = prepareConnectionHost({ ...quickHost, ephemeral: false }, {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
});
assert.equal(prepared.ephemeral, true);
assert.equal(prepared.legacyAlgorithms, true);
assert.equal(prepared.deviceType, "network");
assert.equal(saved.deviceType, undefined);
assert.equal(saved.legacyAlgorithms, undefined);
});
test("temporary connection toggles are read from the settings object at connect time", () => {
const settings = {
temporaryConnectionsAllowLegacyAlgorithms: false,
temporaryConnectionsNetworkDeviceMode: false,
};
const temporary = host({
id: "ephemeral-live",
ephemeral: true,
hostname: "10.9.9.9",
});
const connect = () => prepareConnectionHost(
temporary,
temporaryConnectionPrepareOptions(settings),
);
assert.equal(connect(), temporary);
settings.temporaryConnectionsNetworkDeviceMode = true;
settings.temporaryConnectionsAllowLegacyAlgorithms = true;
const enabled = connect();
assert.equal(enabled.deviceType, "network");
assert.equal(enabled.legacyAlgorithms, true);
settings.temporaryConnectionsNetworkDeviceMode = false;
settings.temporaryConnectionsAllowLegacyAlgorithms = false;
assert.equal(connect(), temporary);
});
test("temporary connection network device mode defaults to off", () => {
assert.equal(normalizeTerminalSettings().temporaryConnectionsNetworkDeviceMode, false);
assert.equal(
normalizeTerminalSettings({ temporaryConnectionsNetworkDeviceMode: true })
.temporaryConnectionsNetworkDeviceMode,
true,
);
assert.equal(
normalizeTerminalSettings({
temporaryConnectionsNetworkDeviceMode: "yes" as never,
}).temporaryConnectionsNetworkDeviceMode,
false,
);
});
+162
View File
@@ -0,0 +1,162 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import test from "node:test";
import type { Host } from "./models";
import {
buildArchiveExtractCommand,
findTerminalArchiveTokens,
resolveArchivePath,
supportsTerminalArchiveCommands,
} from "./terminalArchives.ts";
const host = {
id: "test",
label: "Test",
hostname: "host",
username: "me",
tags: [],
os: "linux",
} as Host;
function commandFor(printed: string, cwd: string | null = "/work"): string | null {
const token = findTerminalArchiveTokens(printed)[0];
if (!token) return null;
const abs = resolveArchivePath(token.path, cwd);
if (!abs) return null;
return buildArchiveExtractCommand(abs);
}
function splitShell(shell: "sh" | "fish", command: string): string[] | null {
if (shell === "fish") {
const probe = spawnSync("fish", ["-c", "true"], { encoding: "utf8" });
if (probe.error || probe.status !== 0) return null;
const out = execFileSync(
"fish",
["-c", `set argv ${command}; printf '%s\\0' $argv`],
{ encoding: "utf8" },
);
return out.split("\0").slice(0, -1);
}
const out = execFileSync(
"/bin/sh",
["-c", `set -- ${command}; printf '%s\\0' "$@"`],
{ encoding: "utf8" },
);
return out.split("\0").slice(0, -1);
}
test("Linux archive commands exclude Windows, WSL, macOS, and non-shell protocols", () => {
assert.equal(supportsTerminalArchiveCommands(host, "darwin"), false);
assert.equal(supportsTerminalArchiveCommands(host, "win32"), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: "ubuntu" }, "darwin"), true);
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride: "linux" }, "darwin"), true);
for (const osOverride of ["unknown", "windows", "macos", "freebsd"] as const) {
assert.equal(
supportsTerminalArchiveCommands({ ...host, osOverride, distro: "ubuntu" }, "linux"),
false,
);
}
assert.equal(
supportsTerminalArchiveCommands({ ...host, distro: "ubuntu", deviceType: "network" }, "linux"),
false,
);
for (const protocol of ["telnet", "serial", "plugin:test"] as const) {
assert.equal(
supportsTerminalArchiveCommands({ ...host, protocol, osOverride: "linux" }, "linux"),
false,
);
}
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local" }, "darwin"), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local" }, "win32"), false);
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local", os: "linux" }, "linux"), true);
});
test("each agreed suffix maps to the right extraction command, including upper case", () => {
const cases: Array<[string, string]> = [
["a.tar", "tar -xvf '/work/a.tar' -C '/work'"],
["b.tar.gz", "tar -xvzf '/work/b.tar.gz' -C '/work'"],
["c.tgz", "tar -xvzf '/work/c.tgz' -C '/work'"],
["d.tar.bz2", "tar -xvjf '/work/d.tar.bz2' -C '/work'"],
["e.tbz2", "tar -xvjf '/work/e.tbz2' -C '/work'"],
["f.tar.xz", "tar -xvJf '/work/f.tar.xz' -C '/work'"],
["h.zip", "unzip '/work/h.zip' -d '/work'"],
["A.TAR", "tar -xvf '/work/A.TAR' -C '/work'"],
["B.TAR.GZ", "tar -xvzf '/work/B.TAR.GZ' -C '/work'"],
["C.TGZ", "tar -xvzf '/work/C.TGZ' -C '/work'"],
["D.TAR.BZ2", "tar -xvjf '/work/D.TAR.BZ2' -C '/work'"],
["E.TBZ2", "tar -xvjf '/work/E.TBZ2' -C '/work'"],
["F.TAR.XZ", "tar -xvJf '/work/F.TAR.XZ' -C '/work'"],
["H.ZIP", "unzip '/work/H.ZIP' -d '/work'"],
];
for (const [name, expected] of cases) {
assert.equal(commandFor(name), expected, name);
assert.equal(/[\r\n]/u.test(expected), false);
}
for (const name of ["a.gz", "b.rar", "c.7z", "d.txz", "tool.exe", ".tar", ".zip", ".tar.gz"]) {
assert.deepEqual(findTerminalArchiveTokens(name), [], name);
}
});
test("quoted, Unicode, and command-substitution names stay one literal argument", () => {
const line = `"it's $(touch NO).tar.gz" 'my file.tar.gz' 文件.tar "it's.zip"`;
const tokens = findTerminalArchiveTokens(line);
assert.deepEqual(tokens.map((token) => token.path), [
"it's $(touch NO).tar.gz",
"my file.tar.gz",
"文件.tar",
"it's.zip",
]);
for (const token of tokens) {
assert.equal(line.slice(token.start, token.start + token.length), token.text);
const abs = resolveArchivePath(token.path, "/opt");
assert.ok(abs);
const command = buildArchiveExtractCommand(abs);
assert.ok(command);
assert.equal(/[\r\n]/u.test(command), false);
for (const shell of ["sh", "fish"] as const) {
const words = splitShell(shell, command);
if (!words) continue;
assert.ok(words.includes(abs), `${shell} ${command}`);
assert.equal(words.filter((word) => word === abs).length, 1);
}
}
const escaped = findTerminalArchiveTokens(String.raw`my\ file.tar.gz`);
assert.equal(escaped.length, 1);
assert.equal(escaped[0]?.path, "my file.tar.gz");
assert.equal(escaped[0]?.text, String.raw`my\ file.tar.gz`);
});
test("rejects home, urls, control characters, backslashes, history, zip globs, and open quotes", () => {
for (const printed of [
"~/a.tar.gz",
"~user/a.tar",
"http://example.com/a.zip",
"user@h:/a.tar",
"file\u001b.tar.gz",
String.raw`fish\name.tar.gz`,
"'fish\\name.tar.gz'",
"hist!.tar.gz",
"a*.zip",
"a?.zip",
"a[.zip",
'"unfinished.tar.gz',
]) {
assert.deepEqual(findTerminalArchiveTokens(printed), [], printed);
}
assert.equal(findTerminalArchiveTokens("a*.tar.gz").length, 1);
assert.equal(findTerminalArchiveTokens("“pack.zip”")[0]?.path, "pack.zip");
assert.equal(findTerminalArchiveTokens("‘a.tar’")[0]?.path, "a.tar");
});
test("resolveArchivePath normalizes against the interval cwd and keeps absolute paths", () => {
assert.equal(resolveArchivePath("sub/../a.tar.gz", "/x"), "/x/a.tar.gz");
assert.equal(resolveArchivePath("a.tar.gz", null), null);
assert.equal(resolveArchivePath("/opt/a.tar.gz", null), "/opt/a.tar.gz");
assert.equal(resolveArchivePath("/opt/a.tar.gz", "/elsewhere"), "/opt/a.tar.gz");
assert.equal(buildArchiveExtractCommand("/a.tar"), "tar -xvf '/a.tar' -C '/'");
assert.equal(
buildArchiveExtractCommand("/abs/a.zip"),
"unzip '/abs/a.zip' -d '/abs'",
);
});
+180
View File
@@ -0,0 +1,180 @@
import { resolveHostOs } from "./host";
import type { Host } from "./models";
const REMOTE_SHELL_PROTOCOLS = new Set(["ssh", "mosh", "et"]);
/**
* Linux shells only. Windows, including WSL local shells (a win32 app hosting
* a Linux guest), macOS, telnet, serial, plugin protocols, and network devices
* are excluded.
*/
export function supportsTerminalArchiveCommands(host: Host, localPlatform: string): boolean {
if (host.protocol === "local") return localPlatform === "linux";
if (host.protocol && !REMOTE_SHELL_PROTOCOLS.has(host.protocol)) return false;
if (host.deviceType === "network") return false;
return resolveHostOs(host) === "linux";
}
const ARCHIVE_SUFFIXES = [
".tar.gz",
".tar.bz2",
".tar.xz",
".tbz2",
".tgz",
".tar",
".zip",
] as const;
const OPEN_TO_CLOSE: Record<string, string> = {
"'": "'",
'"': '"',
"\u201c": "\u201d",
"\u2018": "\u2019",
};
export interface TerminalArchiveToken {
start: number;
length: number;
text: string;
path: string;
}
function isWhitespace(char: string): boolean {
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
}
function hasControlChar(value: string): boolean {
for (const char of value) {
const code = char.codePointAt(0) ?? 0;
if (code < 32 || code === 127) return true;
}
return false;
}
function archiveSuffix(name: string): string | null {
const lower = name.toLowerCase();
for (const suffix of ARCHIVE_SUFFIXES) {
if (lower.endsWith(suffix) && name.length > suffix.length) return suffix;
}
return null;
}
function rejectedArchivePath(path: string, suffix: string): boolean {
if (!path || hasControlChar(path)) return true;
if (path.startsWith("~")) return true;
const slash = path.indexOf("/");
const head = slash === -1 ? path : path.slice(0, slash);
if (head.includes(":")) return true;
if (path.includes("\\")) return true;
if (path.includes("!")) return true;
if (suffix === ".zip" && /[*?[]/u.test(path)) return true;
return false;
}
/** Whitespace-delimited archive names, including one matched quote pair or escaped spaces. */
export function findTerminalArchiveTokens(line: string): TerminalArchiveToken[] {
const tokens: TerminalArchiveToken[] = [];
let index = 0;
while (index < line.length) {
const char = line[index] ?? "";
if (isWhitespace(char)) {
index += 1;
continue;
}
const closer = OPEN_TO_CLOSE[char];
if (closer) {
const end = line.indexOf(closer, index + 1);
if (end < 0) {
while (index < line.length && !isWhitespace(line[index] ?? "")) index += 1;
continue;
}
const path = line.slice(index + 1, end);
const text = line.slice(index, end + 1);
const suffix = archiveSuffix(path);
if (suffix && !rejectedArchivePath(path, suffix)) {
tokens.push({ start: index, length: text.length, text, path });
}
index = end + 1;
continue;
}
let cursor = index;
let path = "";
while (cursor < line.length && !isWhitespace(line[cursor] ?? "")) {
if (line[cursor] === "\\" && line[cursor + 1] === " ") {
path += " ";
cursor += 2;
continue;
}
path += line[cursor] ?? "";
cursor += 1;
}
const text = line.slice(index, cursor);
const suffix = archiveSuffix(path);
if (suffix && !rejectedArchivePath(path, suffix)) {
tokens.push({ start: index, length: text.length, text, path });
}
index = cursor;
}
return tokens;
}
function posixNormalize(input: string): string {
const absolute = input.startsWith("/");
const parts: string[] = [];
for (const part of input.split("/")) {
if (part === "" || part === ".") continue;
if (part === "..") {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(part);
}
if (absolute) return parts.length > 0 ? `/${parts.join("/")}` : "/";
return parts.length > 0 ? parts.join("/") : ".";
}
function posixDirname(absPath: string): string {
const normalized = posixNormalize(absPath);
if (!normalized.startsWith("/") || normalized === "/") return "/";
const index = normalized.lastIndexOf("/");
if (index <= 0) return "/";
return normalized.slice(0, index);
}
/** Resolve a printed name against an OSC 7 cwd. Relative names without a cwd are unknown. */
export function resolveArchivePath(filePath: string, cwd: string | null): string | null {
if (!filePath) return null;
if (filePath.startsWith("/")) return posixNormalize(filePath);
if (!cwd || !cwd.startsWith("/")) return null;
const normalized = posixNormalize(`${cwd}/${filePath}`);
return normalized.startsWith("/") ? normalized : null;
}
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}
/** Fill-only extract command. The archive is unpacked next to itself. */
export function buildArchiveExtractCommand(absPath: string): string | null {
const suffix = archiveSuffix(absPath);
if (!suffix) return null;
const quotedPath = shellQuote(posixNormalize(absPath));
const quotedDir = shellQuote(posixDirname(absPath));
switch (suffix) {
case ".tar":
return `tar -xvf ${quotedPath} -C ${quotedDir}`;
case ".tar.gz":
case ".tgz":
return `tar -xvzf ${quotedPath} -C ${quotedDir}`;
case ".tar.bz2":
case ".tbz2":
return `tar -xvjf ${quotedPath} -C ${quotedDir}`;
case ".tar.xz":
return `tar -xvJf ${quotedPath} -C ${quotedDir}`;
case ".zip":
return `unzip ${quotedPath} -d ${quotedDir}`;
default:
return null;
}
}
+29 -1
View File
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { isIdleShellReadyForCommandInjection } from "./terminalCommandInjectionReady.ts";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
} from "./terminalCommandInjectionReady.ts";
test("idle shell is ready only at a clean primary-screen prompt", () => {
assert.equal(
@@ -47,3 +50,28 @@ test("rejects sensitive prompts, alt-screen TUIs, partial input, and missing ter
false,
);
});
test("draft insertion still blocks sensitive prompts, alt-screen, and a missing prompt", () => {
const draft = {
hasLiveTerminal: true,
alternateScreenActive: false,
isAtPrompt: true,
userInputLength: 4,
pendingTypedInputLength: 2,
};
assert.equal(isShellPromptReadyForInsertion(draft), true);
assert.equal(isIdleShellReadyForCommandInjection(draft), false);
assert.equal(
isShellPromptReadyForInsertion({ ...draft, sensitiveInputActive: true }),
false,
);
assert.equal(
isShellPromptReadyForInsertion({ ...draft, alternateScreenActive: true }),
false,
);
assert.equal(isShellPromptReadyForInsertion({ ...draft, isAtPrompt: false }), false);
assert.equal(
isShellPromptReadyForInsertion({ ...draft, hasLiveTerminal: false }),
false,
);
});
+17 -6
View File
@@ -1,19 +1,30 @@
/**
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
*/
export function isIdleShellReadyForCommandInjection(options: {
export interface ShellPromptReadiness {
sensitiveInputActive?: boolean;
hasLiveTerminal: boolean;
alternateScreenActive: boolean;
isAtPrompt: boolean;
userInputLength: number;
pendingTypedInputLength?: number;
}): boolean {
}
/**
* Prompt is showing on the primary screen and is not a password/TUI.
* A typed draft is allowed; archive clicks insert at the cursor.
*/
export function isShellPromptReadyForInsertion(options: ShellPromptReadiness): boolean {
if (options.sensitiveInputActive) return false;
if (!options.hasLiveTerminal) return false;
if (options.alternateScreenActive) return false;
if (!options.isAtPrompt) return false;
return true;
}
/**
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
*/
export function isIdleShellReadyForCommandInjection(options: ShellPromptReadiness): boolean {
if (!isShellPromptReadyForInsertion(options)) return false;
if (options.userInputLength > 0) return false;
if ((options.pendingTypedInputLength ?? 0) > 0) return false;
return true;
@@ -0,0 +1,201 @@
"use strict";
const crypto = require("node:crypto");
const net = require("node:net");
const DEFAULT_EXTERNAL_MCP_HTTP_PORT = 47391;
const DEFAULT_EXTERNAL_MCP_HTTP_HOST = "127.0.0.1";
const EXTERNAL_MCP_HTTP_PATH = "/mcp";
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
const EXTERNAL_MCP_HTTP_BEARER_ENV = "NETCATTY_MCP_BEARER_TOKEN";
function isWildcardBind(address) {
return address === "0.0.0.0" || address === "::";
}
function unbracketHostname(hostname) {
const host = String(hostname || "");
if (host.startsWith("[") && host.endsWith("]") && host.length > 2) return host.slice(1, -1);
return host;
}
function isLoopbackHost(hostname) {
const host = unbracketHostname(hostname).toLowerCase();
return host === "localhost" || host === "127.0.0.1" || host === "::1";
}
function isIpLiteral(hostname) {
return net.isIP(unbracketHostname(hostname)) !== 0;
}
function normalizeHttpPort(value) {
const parsed = typeof value === "number" ? value : Number(String(value ?? "").trim());
if (!Number.isInteger(parsed) || parsed < MIN_EXTERNAL_MCP_HTTP_PORT || parsed > MAX_EXTERNAL_MCP_HTTP_PORT) {
return {
ok: false,
errorCode: "invalid_port",
error: `Enter a port from ${MIN_EXTERNAL_MCP_HTTP_PORT} to ${MAX_EXTERNAL_MCP_HTTP_PORT}.`,
};
}
return { ok: true, port: parsed };
}
function normalizeListenAddress(value) {
const address = String(value ?? "").trim();
if (!address || net.isIP(address) === 0) {
return {
ok: false,
errorCode: "invalid_address",
error: "Listen address must be an IP address such as 0.0.0.0 or a vEthernet address.",
};
}
return { ok: true, address };
}
function resolveEffectiveListenAddress({ allowCustomBind, listenAddress }) {
if (!allowCustomBind) return DEFAULT_EXTERNAL_MCP_HTTP_HOST;
const normalized = normalizeListenAddress(listenAddress);
if (!normalized.ok) {
const error = new Error(normalized.error);
error.code = "INVALID_ADDRESS";
error.errorCode = normalized.errorCode;
throw error;
}
return normalized.address;
}
function formatHostForUrl(host) {
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
return host;
}
function normalizeClientHost(value) {
const address = String(value ?? "").trim();
if (!address) return { ok: true, address: "" };
if (net.isIP(address) === 0 || isWildcardBind(address)) {
return {
ok: false,
errorCode: "invalid_client_host",
error: "Client address must be a reachable IP, not 0.0.0.0 or ::.",
};
}
return { ok: true, address };
}
function buildClientUrl(listenAddress, port, clientHost) {
const host = isWildcardBind(listenAddress) ? String(clientHost || "").trim() : listenAddress;
if (!host || isWildcardBind(host) || net.isIP(host) === 0) return null;
return `http://${formatHostForUrl(host)}:${port}${EXTERNAL_MCP_HTTP_PATH}`;
}
function parseHostHeader(hostHeader) {
const raw = String(hostHeader || "").trim();
if (!raw) return null;
if (raw.startsWith("[")) {
const end = raw.indexOf("]");
if (end <= 1) return null;
const hostname = raw.slice(1, end);
const rest = raw.slice(end + 1);
if (rest === "") return { hostname, port: null };
if (!rest.startsWith(":")) return null;
const port = rest.slice(1);
if (!/^\d+$/.test(port)) return null;
return { hostname, port };
}
const colon = raw.lastIndexOf(":");
if (colon === -1) return { hostname: raw, port: null };
const hostname = raw.slice(0, colon);
const port = raw.slice(colon + 1);
if (!hostname || !/^\d+$/.test(port)) return null;
return { hostname, port };
}
function isAllowedHostname(hostname, listenAddress) {
const host = unbracketHostname(hostname);
if (isLoopbackHost(host)) return true;
if (isWildcardBind(listenAddress)) return isIpLiteral(host);
return host.toLowerCase() === String(listenAddress || "").toLowerCase();
}
function invalidHost() {
return { ok: false, status: 421, errorCode: "host", message: "Invalid Host header" };
}
function invalidOrigin() {
return { ok: false, status: 403, errorCode: "origin", message: "Invalid Origin header" };
}
function validateHostHeader(hostHeader, listenAddress, port) {
const parsed = parseHostHeader(hostHeader);
if (!parsed || parsed.port == null) return invalidHost();
if (parsed.port !== String(port)) return invalidHost();
if (!isAllowedHostname(parsed.hostname, listenAddress)) return invalidHost();
return { ok: true };
}
function validateOriginHeader(originHeader, listenAddress) {
if (originHeader == null || originHeader === "") return { ok: true };
let url;
try {
url = new URL(originHeader);
} catch {
return invalidOrigin();
}
if (url.protocol !== "http:" && url.protocol !== "https:") return invalidOrigin();
if (url.username || url.password) return invalidOrigin();
if (!isAllowedHostname(url.hostname, listenAddress)) return invalidOrigin();
return { ok: true };
}
function validateRequestHeaders({ hostHeader, originHeader, listenAddress, port }) {
const host = validateHostHeader(hostHeader, listenAddress, port);
if (!host.ok) return host;
return validateOriginHeader(originHeader, listenAddress);
}
function readBearerToken(authorizationHeader) {
if (typeof authorizationHeader !== "string") return "";
const match = /^Bearer\s+(\S+)\s*$/i.exec(authorizationHeader.trim());
return match ? match[1] : "";
}
function tokensEqual(presented, expected) {
if (typeof expected !== "string" || expected.length === 0) return false;
const presentedText = typeof presented === "string" ? presented : "";
const presentedHash = crypto.createHash("sha256").update(presentedText).digest();
const expectedHash = crypto.createHash("sha256").update(expected).digest();
return crypto.timingSafeEqual(presentedHash, expectedHash);
}
function describeListenError(error, port) {
if (error?.code === "EADDRINUSE") {
return { errorCode: "port_in_use", error: `Port ${port} is already in use.` };
}
if (error?.code === "INVALID_ADDRESS" || error?.errorCode === "invalid_address") {
return { errorCode: "invalid_address", error: error.message };
}
return { errorCode: "listen_failed", error: error?.message || String(error) };
}
module.exports = {
DEFAULT_EXTERNAL_MCP_HTTP_HOST,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
EXTERNAL_MCP_HTTP_BEARER_ENV,
EXTERNAL_MCP_HTTP_PATH,
MAX_EXTERNAL_MCP_HTTP_PORT,
MIN_EXTERNAL_MCP_HTTP_PORT,
buildClientUrl,
describeListenError,
normalizeClientHost,
isAllowedHostname,
isLoopbackHost,
isWildcardBind,
normalizeHttpPort,
normalizeListenAddress,
parseHostHeader,
readBearerToken,
resolveEffectiveListenAddress,
tokensEqual,
validateRequestHeaders,
};
@@ -0,0 +1,137 @@
"use strict";
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const {
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
buildClientUrl,
normalizeHttpPort,
normalizeListenAddress,
readBearerToken,
resolveEffectiveListenAddress,
tokensEqual,
validateRequestHeaders,
} = require("./httpSecurity.cjs");
describe("external MCP HTTP security", () => {
it("keeps an unlikely fixed default port and rejects privileged or empty ports", () => {
assert.equal(DEFAULT_EXTERNAL_MCP_HTTP_PORT, 47391);
assert.equal(normalizeHttpPort(47391).port, 47391);
assert.equal(normalizeHttpPort("8080").port, 8080);
assert.equal(normalizeHttpPort(80).ok, false);
assert.equal(normalizeHttpPort(0).ok, false);
assert.equal(normalizeHttpPort("").ok, false);
assert.equal(normalizeHttpPort(65536).errorCode, "invalid_port");
});
it("binds loopback unless the custom address option is explicit", () => {
assert.equal(resolveEffectiveListenAddress({
allowCustomBind: false,
listenAddress: "0.0.0.0",
}), "127.0.0.1");
assert.equal(resolveEffectiveListenAddress({
allowCustomBind: true,
listenAddress: "0.0.0.0",
}), "0.0.0.0");
assert.equal(resolveEffectiveListenAddress({
allowCustomBind: true,
listenAddress: "172.22.0.1",
}), "172.22.0.1");
assert.equal(normalizeListenAddress("windows-host").ok, false);
assert.throws(
() => resolveEffectiveListenAddress({ allowCustomBind: true, listenAddress: "localhost" }),
(error) => error.code === "INVALID_ADDRESS",
);
});
it("does not advertise loopback for a wildcard bind", () => {
assert.equal(buildClientUrl("0.0.0.0", 47391), null);
assert.equal(buildClientUrl("::", 47391, "127.0.0.1"), "http://127.0.0.1:47391/mcp");
assert.equal(buildClientUrl("0.0.0.0", 47391, "172.22.0.1"), "http://172.22.0.1:47391/mcp");
assert.equal(buildClientUrl("0.0.0.0", 47391, "0.0.0.0"), null);
assert.equal(buildClientUrl("172.22.0.1", 47391), "http://172.22.0.1:47391/mcp");
});
it("accepts only an exact bearer token", () => {
assert.equal(readBearerToken("Bearer secret-token"), "secret-token");
assert.equal(readBearerToken("bearer secret-token"), "secret-token");
assert.equal(readBearerToken("Basic secret-token"), "");
assert.equal(readBearerToken("Bearer"), "");
assert.equal(tokensEqual("secret-token", "secret-token"), true);
assert.equal(tokensEqual("wrong", "secret-token"), false);
assert.equal(tokensEqual("", "secret-token"), false);
assert.equal(tokensEqual("secret-token", ""), false);
});
it("rejects DNS-rebinding host and origin values", () => {
const base = { listenAddress: "127.0.0.1", port: 47391 };
assert.equal(validateRequestHeaders({
...base,
hostHeader: "127.0.0.1:47391",
originHeader: undefined,
}).ok, true);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "localhost:47391",
originHeader: "http://localhost:6274",
}).ok, true);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "evil.example:47391",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "127.0.0.1:47391",
originHeader: "https://evil.example",
}).status, 403);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "127.0.0.1:80",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
...base,
hostHeader: "",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
listenAddress: "0.0.0.0",
port: 47391,
hostHeader: "172.22.0.1:47391",
originHeader: undefined,
}).ok, true);
assert.equal(validateRequestHeaders({
listenAddress: "0.0.0.0",
port: 47391,
hostHeader: "evil.example:47391",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
listenAddress: "172.22.0.1",
port: 47391,
hostHeader: "172.22.0.1:47391",
originHeader: "http://172.22.0.1:6274",
}).ok, true);
assert.equal(validateRequestHeaders({
listenAddress: "172.22.0.1",
port: 47391,
hostHeader: "8.8.8.8:47391",
originHeader: undefined,
}).status, 421);
assert.equal(validateRequestHeaders({
listenAddress: "::1",
port: 47391,
hostHeader: "[::1]:47391",
originHeader: "http://[::1]:6274",
}).ok, true);
assert.equal(validateRequestHeaders({
listenAddress: "::",
port: 47391,
hostHeader: "[2001:db8::1]:47391",
originHeader: "http://[2001:db8::1]:6274",
}).ok, true);
});
});
+550
View File
@@ -0,0 +1,550 @@
"use strict";
/**
* Optional Streamable HTTP endpoint for External MCP.
* Tool calls go through the same progressive MCP registry and
* mcpServerBridge.dispatchBuiltinRpc path as the stdio launcher.
* Legacy HTTP+SSE (protocol 2024-11-05, GET /sse + POST /messages) is not
* mounted: it is a second transport, not a free extra of Streamable HTTP.
*/
const http = require("node:http");
const crypto = require("node:crypto");
const { AsyncLocalStorage } = require("node:async_hooks");
const { EventEmitter } = require("node:events");
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../../cli/externalMcpDiscoveryPath.cjs");
const { normalizeMcpJsonRpcMessage } = require("../../mcp/normalizeMcpCallArguments.cjs");
const {
DEFAULT_EXTERNAL_MCP_HTTP_HOST,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
EXTERNAL_MCP_HTTP_PATH,
readBearerToken,
tokensEqual,
validateRequestHeaders,
} = require("./httpSecurity.cjs");
const MAX_BODY_BYTES = 4 * 1024 * 1024;
const DEFAULT_SESSION_IDLE_MS = 15 * 60 * 1000;
const DEFAULT_SESSION_SWEEP_MS = 60 * 1000;
const requestContext = new AsyncLocalStorage();
class ExternalMcpHttpClient extends EventEmitter {
constructor() {
super();
this.destroyed = false;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.emit("close");
}
}
function trackConnections(server) {
const owned = new Set();
server.on("connection", (socket) => {
owned.add(socket);
socket.on("close", () => owned.delete(socket));
});
return owned;
}
function closeHttpServer(server, owned) {
return new Promise((resolve) => {
// requestTimeout is disabled so a half-open POST does not get cut off
// during a long tool call. close() waits for those sockets, so destroy
// them before awaiting the callback or disable/reconfigure hangs.
server.close(() => resolve());
if (!owned) return;
for (const socket of owned) socket.destroy();
owned.clear();
});
}
function jsonRpcError(message, code = -32000) {
return { jsonrpc: "2.0", error: { code, message }, id: null };
}
function sendJson(res, status, body, extraHeaders) {
if (res.headersSent || res.writableEnded) return;
const payload = JSON.stringify(body);
res.writeHead(status, {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(payload),
"Cache-Control": "no-store",
...extraHeaders,
});
res.end(payload);
}
function headerValue(req, name) {
const value = req.headers[name];
if (Array.isArray(value)) return value[0] || "";
return typeof value === "string" ? value : "";
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
let settled = false;
const fail = (error) => {
if (settled) return;
settled = true;
reject(error);
};
req.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
fail(Object.assign(new Error("Payload too large"), { statusCode: 413 }));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", fail);
});
}
function loadMcpRuntime() {
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
const { StreamableHTTPServerTransport } = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
const { isInitializeRequest } = require("@modelcontextprotocol/sdk/types.js");
const { getCatalogToolDescription } = require("../../mcp/catalogToolMetadata.cjs");
const { NETCATTY_MCP_SERVER_INSTRUCTIONS } = require("../../mcp/netcattyMcpInstructions.cjs");
const { registerProgressiveMcpTools } = require("../../mcp/progressiveMcpTools.cjs");
return {
McpServer,
StreamableHTTPServerTransport,
isInitializeRequest,
getCatalogToolDescription,
NETCATTY_MCP_SERVER_INSTRUCTIONS,
registerProgressiveMcpTools,
};
}
function createExternalMcpHttpServer(deps = {}) {
const chatSessionId = deps.chatSessionId || EXTERNAL_MCP_CHAT_SESSION_ID;
const dispatchRpc = deps.dispatchRpc;
const getPermissionMode = deps.getPermissionMode || (() => "confirm");
const isEnabled = deps.isEnabled || (() => true);
const recordActivity = deps.recordActivity || (() => {});
const attachExternalClient = deps.attachExternalClient || ((client) => () => client.destroy());
const getToken = deps.getToken || (() => "");
const sessionIdleMs = Number.isFinite(deps.sessionIdleMs) && deps.sessionIdleMs >= 0
? deps.sessionIdleMs
: DEFAULT_SESSION_IDLE_MS;
const sessionSweepMs = Number.isFinite(deps.sessionSweepMs) && deps.sessionSweepMs > 0
? deps.sessionSweepMs
: DEFAULT_SESSION_SWEEP_MS;
let mcpRuntime = null;
let httpServer = null;
let listenAddress = DEFAULT_EXTERNAL_MCP_HTTP_HOST;
let listenPort = null;
let sessions = new Map();
let activeSockets = new Set();
let startChain = Promise.resolve();
let generation = 0;
let sweepTimer = null;
function runtime() {
if (!mcpRuntime) mcpRuntime = loadMcpRuntime();
return mcpRuntime;
}
function guardWriteOperation() {
if (getPermissionMode() === "observer") {
return 'Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "auto" in Settings → AI → Safety to allow this action.';
}
return null;
}
async function rpcCall(method, params) {
if (!isEnabled()) {
throw new Error("External MCP is disabled. Re-enable it in Netcatty Settings → AI.");
}
if (typeof dispatchRpc !== "function") {
throw new Error("External MCP HTTP dispatch is unavailable.");
}
try {
recordActivity();
} catch {
// Activity bookkeeping must not fail the tool call.
}
const store = requestContext.getStore();
return await dispatchRpc(method, {
...(params || {}),
chatSessionId,
}, {
externalClient: store?.externalClient || null,
});
}
function createSessionServer() {
const {
McpServer,
getCatalogToolDescription,
NETCATTY_MCP_SERVER_INSTRUCTIONS,
registerProgressiveMcpTools,
} = runtime();
const server = new McpServer({
name: "netcatty-remote-hosts",
version: "1.0.0",
}, {
instructions: NETCATTY_MCP_SERVER_INSTRUCTIONS,
});
server.resource(
"environment",
"netcatty://context",
{ description: "Current Netcatty workspace context: connected hosts, session IDs, and environment description." },
async () => {
const ctx = await rpcCall("netcatty/getContext", { chatSessionId });
return {
contents: [{
uri: "netcatty://context",
mimeType: "application/json",
text: JSON.stringify(ctx, null, 2),
}],
};
},
);
registerProgressiveMcpTools(server, {
rpcCall,
scopeParams: { chatSessionId },
guardWriteOperation,
catalogDescription(toolName, fallback) {
return getCatalogToolDescription(toolName) || fallback;
},
});
return server;
}
function dropSession(sessionId) {
const session = sessions.get(sessionId);
if (!session || session.closing) return;
session.closing = true;
sessions.delete(sessionId);
try {
session.detach?.();
} catch {
// Best-effort approval-client detach.
}
void Promise.resolve(session.server?.close?.()).catch(() => {});
}
function expireIdleSessions() {
const now = Date.now();
for (const [sessionId, session] of sessions) {
if (session.inFlight > 0) continue;
if (now - session.lastActivityAt < sessionIdleMs) continue;
dropSession(sessionId);
}
}
function startSessionSweep() {
if (sweepTimer) clearInterval(sweepTimer);
sweepTimer = setInterval(expireIdleSessions, sessionSweepMs);
sweepTimer.unref?.();
}
function stopSessionSweep() {
if (!sweepTimer) return;
clearInterval(sweepTimer);
sweepTimer = null;
}
async function withSession(session, run) {
session.inFlight += 1;
session.lastActivityAt = Date.now();
try {
await run();
} finally {
session.inFlight = Math.max(0, session.inFlight - 1);
session.lastActivityAt = Date.now();
}
}
async function closeSessions() {
const current = Array.from(sessions.values());
sessions = new Map();
await Promise.all(current.map(async (session) => {
try {
await session.transport?.close?.();
} catch {
// Closing a finished transport is best-effort.
}
try {
session.detach?.();
} catch {
// Best-effort approval-client detach.
}
try {
await session.server?.close?.();
} catch {
// Best-effort MCP server close.
}
}));
}
async function openSession(req, res, body) {
const { StreamableHTTPServerTransport } = runtime();
const externalClient = new ExternalMcpHttpClient();
let detach = () => externalClient.destroy();
try {
const attached = attachExternalClient(externalClient);
if (typeof attached === "function") detach = attached;
} catch {
detach = () => externalClient.destroy();
}
const server = createSessionServer();
const session = {
server,
externalClient,
detach,
transport: null,
sessionId: null,
lastActivityAt: Date.now(),
inFlight: 0,
closing: false,
};
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
enableJsonResponse: true,
onsessioninitialized: (sessionId) => {
session.sessionId = sessionId;
sessions.set(sessionId, session);
},
});
session.transport = transport;
transport.onclose = () => {
if (session.sessionId) dropSession(session.sessionId);
};
transport.onmessage = (message) => {
normalizeMcpJsonRpcMessage(message);
};
try {
await server.connect(transport);
await withSession(session, () => (
requestContext.run({ externalClient }, () => transport.handleRequest(req, res, body))
));
// The SDK answers 406/415 before onsessioninitialized. That is not a throw,
// so the synthetic approval client and MCP server would otherwise stay up.
if (!session.sessionId) {
detach();
try {
await server.close?.();
} catch {
// Ignore close failures after a rejected handshake.
}
}
} catch (error) {
if (session.sessionId) dropSession(session.sessionId);
else detach();
try {
await server.close?.();
} catch {
// Ignore close failures after a failed handshake.
}
throw error;
}
}
async function routeRequest(req, res) {
const pathname = new URL(req.url || "/", "http://127.0.0.1").pathname;
if (pathname !== EXTERNAL_MCP_HTTP_PATH) {
sendJson(res, 404, jsonRpcError("Not found"));
return;
}
if (!isEnabled()) {
sendJson(res, 503, jsonRpcError("External MCP is disabled."));
return;
}
const presented = readBearerToken(headerValue(req, "authorization"));
if (!tokensEqual(presented, getToken())) {
sendJson(res, 401, jsonRpcError("Unauthorized", -32001));
return;
}
const headerCheck = validateRequestHeaders({
hostHeader: headerValue(req, "host"),
originHeader: headerValue(req, "origin"),
listenAddress,
port: listenPort,
});
if (!headerCheck.ok) {
sendJson(res, headerCheck.status, jsonRpcError(headerCheck.message));
return;
}
const method = req.method || "GET";
if (method === "POST") {
let raw = "";
try {
raw = await readBody(req);
} catch (error) {
if (error.statusCode === 413) {
res.once("finish", () => req.destroy());
}
sendJson(res, error.statusCode || 400, jsonRpcError(error.statusCode === 413 ? "Payload too large" : "Invalid request body"));
return;
}
// Auth ran before the body so a half-open POST could not stall the
// socket forever. Recheck after the body arrives so a token rotated
// while this request was incomplete cannot still dispatch.
if (!tokensEqual(presented, getToken())) {
sendJson(res, 401, jsonRpcError("Unauthorized", -32001));
return;
}
let body;
try {
body = raw ? JSON.parse(raw) : undefined;
} catch {
sendJson(res, 400, jsonRpcError("Invalid JSON"));
return;
}
const sessionId = headerValue(req, "mcp-session-id");
if (sessionId) {
const session = sessions.get(sessionId);
if (!session) {
sendJson(res, 404, jsonRpcError("Session not found"));
return;
}
await withSession(session, () => requestContext.run({ externalClient: session.externalClient }, () => (
session.transport.handleRequest(req, res, body)
)));
return;
}
if (!runtime().isInitializeRequest(body)) {
sendJson(res, 400, jsonRpcError("Bad Request: missing session"));
return;
}
await openSession(req, res, body);
return;
}
if (method === "GET" || method === "DELETE") {
const sessionId = headerValue(req, "mcp-session-id");
if (!sessionId) {
sendJson(res, 400, jsonRpcError("Bad Request: missing session"));
return;
}
const session = sessions.get(sessionId);
if (!session) {
sendJson(res, 404, jsonRpcError("Session not found"));
return;
}
await withSession(session, () => requestContext.run({ externalClient: session.externalClient }, () => (
session.transport.handleRequest(req, res)
)));
return;
}
sendJson(res, 405, jsonRpcError("Method not allowed"), { Allow: "GET, POST, DELETE" });
}
async function onRequest(req, res) {
try {
await routeRequest(req, res);
} catch (error) {
if (!res.headersSent) {
sendJson(res, 500, jsonRpcError(error?.message || "Internal error"));
}
}
}
async function startUnlocked({ host, port }) {
const nextGeneration = ++generation;
await stopUnlocked(nextGeneration);
if (nextGeneration !== generation) return getState();
listenAddress = host || DEFAULT_EXTERNAL_MCP_HTTP_HOST;
const server = http.createServer((req, res) => {
void onRequest(req, res);
});
server.requestTimeout = 0;
server.timeout = 0;
const ownedSockets = trackConnections(server);
try {
await new Promise((resolve, reject) => {
const fail = (error) => {
server.off("listening", succeed);
reject(error);
};
const succeed = () => {
server.off("error", fail);
resolve();
};
server.once("error", fail);
server.once("listening", succeed);
server.listen(port, listenAddress);
});
} catch (error) {
await closeHttpServer(server, ownedSockets);
throw error;
}
if (nextGeneration !== generation) {
await closeHttpServer(server, ownedSockets);
return getState();
}
httpServer = server;
activeSockets = ownedSockets;
startSessionSweep();
const address = server.address();
listenPort = address && typeof address === "object" ? address.port : port;
return getState();
}
async function stopUnlocked(expectedGeneration) {
stopSessionSweep();
const server = httpServer;
const ownedSockets = activeSockets;
httpServer = null;
activeSockets = new Set();
listenPort = null;
await closeSessions();
if (server) {
await closeHttpServer(server, ownedSockets);
}
if (expectedGeneration != null && expectedGeneration !== generation) return;
}
function start(options) {
const run = () => startUnlocked(options || {});
const result = startChain.then(run, run);
startChain = result.then(() => {}, () => {});
return result;
}
function stop() {
const nextGeneration = ++generation;
const run = () => stopUnlocked(nextGeneration);
const result = startChain.then(run, run);
startChain = result.then(() => {}, () => {});
return result;
}
function getState() {
return {
listening: Boolean(httpServer),
host: listenAddress,
port: listenPort,
};
}
return {
start,
stop,
getState,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
};
}
module.exports = {
createExternalMcpHttpServer,
ExternalMcpHttpClient,
};
@@ -0,0 +1,373 @@
"use strict";
const http = require("node:http");
const net = require("node:net");
const { describe, it, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { createExternalMcpHttpServer } = require("./httpServer.cjs");
const { EXTERNAL_MCP_CHAT_SESSION_ID } = require("../../cli/externalMcpDiscoveryPath.cjs");
const TOKEN = "test-bearer-token";
function postJson(url, { token, host, origin, body, sessionId, accept }) {
return new Promise((resolve, reject) => {
const target = new URL(url);
const payload = JSON.stringify(body);
const headers = {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Content-Length": Buffer.byteLength(payload),
};
if (accept) headers.Accept = accept;
if (token) headers.Authorization = `Bearer ${token}`;
if (host) headers.Host = host;
if (origin) headers.Origin = origin;
if (sessionId) headers["mcp-session-id"] = sessionId;
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: "POST",
headers,
}, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
sessionId: res.headers["mcp-session-id"] || "",
});
});
});
req.on("error", reject);
req.write(payload);
req.end();
});
}
function requestSession(url, method, sessionId) {
return new Promise((resolve, reject) => {
const target = new URL(url);
const headers = {
Accept: "application/json, text/event-stream",
Authorization: `Bearer ${TOKEN}`,
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method,
headers,
}, (res) => {
res.resume();
res.on("end", () => resolve({ status: res.statusCode }));
});
req.on("error", reject);
req.end();
});
}
async function startServer(overrides = {}) {
const calls = [];
const server = createExternalMcpHttpServer({
getToken: () => TOKEN,
isEnabled: () => true,
dispatchRpc: async (method, params, context) => {
calls.push({ method, params, context });
if (method === "netcatty/exec") {
return { ok: true, stdout: "from-handler", stderr: "", exitCode: 0 };
}
if (method === "netcatty/getContext") {
return { hosts: [] };
}
return { ok: true };
},
...overrides,
});
const state = await server.start({ host: "127.0.0.1", port: 0 });
return {
server,
calls,
url: `http://127.0.0.1:${state.port}/mcp`,
port: state.port,
};
}
describe("external MCP streamable HTTP server", () => {
let active = null;
afterEach(async () => {
if (active) {
await active.server.stop();
active = null;
}
});
it("rejects missing and wrong bearer tokens with the same status", async () => {
active = await startServer();
const body = { jsonrpc: "2.0", id: 1, method: "initialize", params: {} };
const missing = await postJson(active.url, { body });
const wrong = await postJson(active.url, { token: "nope", body });
assert.equal(missing.status, 401);
assert.equal(wrong.status, 401);
assert.equal(missing.body, wrong.body);
assert.equal(active.calls.length, 0);
});
it("rejects a request whose token was rotated while the body was incomplete", async () => {
let token = TOKEN;
let releaseAuth = () => {};
const authSeen = new Promise((resolve) => {
releaseAuth = resolve;
});
let tokenReads = 0;
active = await startServer({
getToken: () => {
tokenReads += 1;
if (tokenReads === 1) releaseAuth();
return token;
},
});
const payload = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {},
});
const result = await new Promise((resolve, reject) => {
const target = new URL(active.url);
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Content-Length": Buffer.byteLength(payload),
Authorization: `Bearer ${TOKEN}`,
},
}, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
});
});
});
req.on("error", reject);
req.write(payload.slice(0, 8));
authSeen.then(() => {
token = "rotated-token";
req.end(payload.slice(8));
}).catch(reject);
});
await Promise.race([
authSeen,
new Promise((_, reject) => setTimeout(() => reject(new Error("auth was not checked")), 2000)),
]);
assert.equal(result.status, 401);
assert.ok(tokenReads >= 2);
assert.equal(active.calls.length, 0);
});
it("detaches the approval client when initialize is rejected before a session exists", async () => {
const attached = [];
active = await startServer({
attachExternalClient: (client) => {
attached.push(client);
return () => client.destroy();
},
});
const rejected = await postJson(active.url, {
token: TOKEN,
accept: "application/json",
body: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "reject-test", version: "1.0.0" },
},
},
});
assert.equal(rejected.status, 406);
assert.equal(attached.length, 1);
assert.equal(attached[0].destroyed, true);
assert.equal(active.calls.length, 0);
});
it("rejects a rebinding Host and a foreign Origin before any tool dispatch", async () => {
active = await startServer();
const body = { jsonrpc: "2.0", id: 1, method: "initialize", params: {} };
const host = await postJson(active.url, {
token: TOKEN,
host: `evil.example:${active.port}`,
body,
});
const origin = await postJson(active.url, {
token: TOKEN,
origin: "https://evil.example",
body,
});
assert.equal(host.status, 421);
assert.equal(origin.status, 403);
assert.equal(active.calls.length, 0);
});
it("lists tools and dispatches terminal_execute through the shared RPC handler", async () => {
active = await startServer();
const client = new Client({ name: "netcatty-http-test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(active.url), {
requestInit: { headers: { Authorization: `Bearer ${TOKEN}` } },
});
try {
await client.connect(transport);
const tools = await client.listTools();
assert.ok(tools.tools.some((tool) => tool.name === "terminal_execute"));
const result = await client.callTool({
name: "terminal_execute",
arguments: { sessionId: "sess-1", command: "pwd" },
});
assert.match(JSON.stringify(result), /from-handler/);
const execCall = active.calls.find((call) => call.method === "netcatty/exec");
assert.ok(execCall, "terminal_execute did not reach dispatchRpc");
assert.equal(execCall.params.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
assert.equal(execCall.params.sessionId, "sess-1");
assert.equal(execCall.params.command, "pwd");
assert.ok(execCall.context.externalClient);
} finally {
await client.close();
}
});
it("expires an HTTP session that never sends DELETE", async () => {
active = await startServer({ sessionIdleMs: 40, sessionSweepMs: 15 });
const initialized = await postJson(active.url, {
token: TOKEN,
body: {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2025-11-25",
capabilities: {},
clientInfo: { name: "expire-test", version: "1.0.0" },
},
},
});
assert.equal(initialized.status, 200);
assert.ok(initialized.sessionId);
const live = await postJson(active.url, {
token: TOKEN,
sessionId: initialized.sessionId,
body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
});
assert.equal(live.status, 200);
await new Promise((resolve) => setTimeout(resolve, 120));
const expired = await postJson(active.url, {
token: TOKEN,
sessionId: initialized.sessionId,
body: { jsonrpc: "2.0", id: 3, method: "tools/list", params: {} },
});
assert.equal(expired.status, 404);
const expiredGet = await requestSession(active.url, "GET", initialized.sessionId);
const expiredDelete = await requestSession(active.url, "DELETE", initialized.sessionId);
assert.equal(expiredGet.status, 404);
assert.equal(expiredDelete.status, 404);
const missing = await requestSession(active.url, "GET", "");
assert.equal(missing.status, 400);
});
it("is not listening after stop", async () => {
active = await startServer();
const port = active.port;
await active.server.stop();
await assert.rejects(
postJson(`http://127.0.0.1:${port}/mcp`, {
token: TOKEN,
body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
}),
(error) => error.code === "ECONNREFUSED",
);
assert.equal(active.server.getState().listening, false);
active = null;
});
it("returns 413 before closing an oversized request", async () => {
active = await startServer();
const payload = Buffer.alloc((4 * 1024 * 1024) + 64, 0x61);
const result = await new Promise((resolve, reject) => {
const target = new URL(active.url);
const req = http.request({
hostname: target.hostname,
port: target.port,
path: target.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
"Content-Length": payload.length,
Authorization: `Bearer ${TOKEN}`,
},
}, (res) => {
const chunks = [];
res.on("data", (chunk) => chunks.push(chunk));
res.on("end", () => {
resolve({
status: res.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
});
});
});
req.on("error", reject);
req.end(payload);
});
assert.equal(result.status, 413);
assert.match(result.body, /Payload too large/);
});
it("stop finishes while a client holds an incomplete request", async () => {
active = await startServer();
const socket = net.connect(active.port, "127.0.0.1");
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
socket.write(`POST /mcp HTTP/1.1\r\nHost: 127.0.0.1:${active.port}\r\nContent-Length: 100\r\n\r\n`);
let hung = false;
const timer = setTimeout(() => {
hung = true;
}, 2000);
await active.server.stop();
clearTimeout(timer);
socket.destroy();
assert.equal(hung, false);
assert.equal(active.server.getState().listening, false);
active = null;
});
it("reports port in use instead of switching ports", async () => {
const blocker = http.createServer((_req, res) => res.end("busy"));
await new Promise((resolve) => blocker.listen(0, "127.0.0.1", resolve));
const port = blocker.address().port;
const server = createExternalMcpHttpServer({ getToken: () => TOKEN });
active = { server, calls: [], url: "", port };
await assert.rejects(
server.start({ host: "127.0.0.1", port }),
(error) => error.code === "EADDRINUSE",
);
assert.equal(server.getState().listening, false);
await new Promise((resolve) => blocker.close(() => resolve()));
});
});
+204 -1
View File
@@ -25,6 +25,18 @@ const {
DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES,
normalizeSessionIdleTimeoutMinutes,
} = require("./mcpServerBridge/sessionIdleManager.cjs");
const { createExternalMcpHttpServer } = require("./externalMcp/httpServer.cjs");
const {
DEFAULT_EXTERNAL_MCP_HTTP_HOST,
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
buildClientUrl,
describeListenError,
isWildcardBind,
normalizeClientHost,
normalizeHttpPort,
normalizeListenAddress,
resolveEffectiveListenAddress,
} = require("./externalMcp/httpSecurity.cjs");
const EXTERNAL_MCP_MODE_TEMPORARY = "temporary";
const EXTERNAL_MCP_MODE_PERSISTENT = "persistent";
@@ -60,6 +72,7 @@ function createExternalMcpController(options = {}) {
readSkillContent: options.readSkillContent || readBundledNetcattySkillContent,
buildUniversalSetupPrompt: options.buildUniversalSetupPrompt || buildUniversalSetupPrompt,
randomBytes: options.randomBytes || ((size) => crypto.randomBytes(size)),
createHttpServer: options.createHttpServer || createExternalMcpHttpServer,
Date: options.Date || Date,
setTimeout: options.setTimeout || setTimeout,
clearTimeout: options.clearTimeout || clearTimeout,
@@ -86,6 +99,17 @@ function createExternalMcpController(options = {}) {
let codexSetup = null;
let claudeSetup = null;
let grokSetup = null;
let httpEnabled = false;
let httpPort = DEFAULT_EXTERNAL_MCP_HTTP_PORT;
let httpAllowCustomBind = false;
let httpListenAddress = "0.0.0.0";
let httpClientHost = "";
let httpListening = false;
let httpError = null;
let httpErrorCode = null;
let httpRuntime = null;
let httpApplyTail = Promise.resolve();
let httpGeneration = 0;
function getNow() {
return deps.Date.now();
@@ -143,6 +167,133 @@ function createExternalMcpController(options = {}) {
return bridge.getScopedSessionIds(deps.chatSessionId).length;
}
function getHttpRuntime() {
if (!httpRuntime) {
httpRuntime = deps.createHttpServer({
chatSessionId: deps.chatSessionId,
getToken: () => lastKnownToken || "",
isEnabled: () => isEnabled(),
recordActivity,
getPermissionMode: () => getBridge()?.getPermissionMode?.() || "confirm",
attachExternalClient: (client) => {
const bridge = getBridge();
if (typeof bridge?.attachExternalMcpClient === "function") {
return bridge.attachExternalMcpClient(client);
}
return () => client?.destroy?.();
},
dispatchRpc: async (method, params, context) => {
const bridge = getBridge();
if (typeof bridge?.dispatchBuiltinRpc !== "function") {
throw new Error("MCP bridge dispatch is unavailable.");
}
return await bridge.dispatchBuiltinRpc(method, {
...(params || {}),
chatSessionId: deps.chatSessionId,
}, {
externalSocket: context?.externalClient || null,
});
},
});
}
return httpRuntime;
}
function effectiveHttpListenAddress() {
try {
return resolveEffectiveListenAddress({
allowCustomBind: httpAllowCustomBind,
listenAddress: httpListenAddress,
});
} catch {
return httpAllowCustomBind ? httpListenAddress : DEFAULT_EXTERNAL_MCP_HTTP_HOST;
}
}
function buildHttpStatus() {
let httpState = "disabled";
if (httpEnabled && httpError) httpState = "error";
else if (httpEnabled && httpListening) httpState = "running";
else if (httpEnabled && enabled) httpState = "starting";
const listenAddress = effectiveHttpListenAddress();
const url = buildClientUrl(listenAddress, httpPort, httpClientHost);
let error = httpError;
let errorCode = httpErrorCode;
if (!errorCode && isWildcardBind(listenAddress) && !url) {
errorCode = "client_host_required";
error = "Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.";
}
return {
enabled: httpEnabled,
listening: httpListening,
port: httpPort,
listenAddress,
allowCustomBind: httpAllowCustomBind,
clientHost: httpClientHost,
url,
bearerToken: httpEnabled && enabled && lastKnownToken ? lastKnownToken : null,
state: httpState,
error,
errorCode,
};
}
function scheduleHttpApply() {
const generation = ++httpGeneration;
const run = () => applyHttpOnce(generation);
httpApplyTail = httpApplyTail.then(run, run);
return httpApplyTail;
}
async function applyHttpOnce(generation) {
if (!enabled || !httpEnabled) {
if (httpRuntime) {
try {
await httpRuntime.stop();
} catch {
// Stopping a listener that never bound is best-effort.
}
}
if (generation !== httpGeneration) return;
httpListening = false;
if (!httpEnabled) {
httpError = null;
httpErrorCode = null;
}
return;
}
let host = DEFAULT_EXTERNAL_MCP_HTTP_HOST;
try {
host = resolveEffectiveListenAddress({
allowCustomBind: httpAllowCustomBind,
listenAddress: httpListenAddress,
});
await getHttpRuntime().start({ host, port: httpPort });
if (generation !== httpGeneration) {
await getHttpRuntime().stop();
return;
}
httpListening = true;
httpError = null;
httpErrorCode = null;
} catch (listenError) {
if (generation !== httpGeneration) return;
httpListening = false;
const described = describeListenError(listenError, httpPort);
httpError = described.error;
httpErrorCode = described.errorCode;
try {
await getHttpRuntime().stop();
} catch {
// The failed bind should not leave a socket behind.
}
}
}
function whenHttpSettled() {
return httpApplyTail;
}
function buildStatus() {
const bridge = getBridge();
const hostRunning = Boolean(bridge && typeof bridge.getOrCreateHost === "function" && lastKnownPort);
@@ -164,6 +315,7 @@ function createExternalMcpController(options = {}) {
permissionMode: bridge?.getPermissionMode?.() || "confirm",
hostRunning,
error,
http: buildHttpStatus(),
};
}
@@ -215,6 +367,7 @@ function createExternalMcpController(options = {}) {
if (typeof sessionSyncHandler === "function") {
await sessionSyncHandler();
}
await scheduleHttpApply();
}
async function stopActiveRuntime() {
@@ -290,6 +443,7 @@ function createExternalMcpController(options = {}) {
// Best-effort cancel flag.
}
}
await scheduleHttpApply();
}
async function setEnabledUnlocked(nextEnabled) {
@@ -408,6 +562,51 @@ function createExternalMcpController(options = {}) {
scheduleIdleShutdown();
}
let httpChanged = false;
if (config.httpEnabled != null && Boolean(config.httpEnabled) !== httpEnabled) {
httpEnabled = Boolean(config.httpEnabled);
httpChanged = true;
}
if (config.httpPort != null) {
const normalizedPort = normalizeHttpPort(config.httpPort);
if (normalizedPort.ok && normalizedPort.port !== httpPort) {
httpPort = normalizedPort.port;
httpError = null;
httpErrorCode = null;
httpChanged = true;
}
}
if (config.httpAllowCustomBind != null && Boolean(config.httpAllowCustomBind) !== httpAllowCustomBind) {
httpAllowCustomBind = Boolean(config.httpAllowCustomBind);
httpChanged = true;
}
if (config.httpListenAddress != null) {
const normalizedAddress = normalizeListenAddress(config.httpListenAddress);
if (normalizedAddress.ok && normalizedAddress.address !== httpListenAddress) {
httpListenAddress = normalizedAddress.address;
httpChanged = true;
} else if (!normalizedAddress.ok && (httpAllowCustomBind || config.httpAllowCustomBind)) {
httpError = normalizedAddress.error;
httpErrorCode = normalizedAddress.errorCode;
}
}
if (config.httpClientHost != null) {
const normalizedClientHost = normalizeClientHost(config.httpClientHost);
if (normalizedClientHost.ok && normalizedClientHost.address !== httpClientHost) {
httpClientHost = normalizedClientHost.address;
}
}
if (config.rotateHttpToken === true && isEnabled()) {
const bridge = getBridge();
if (typeof bridge?.issueExternalMcpAuthToken === "function") {
lastKnownToken = bridge.issueExternalMcpAuthToken();
writeDiscoveryFromBridge();
}
}
if (httpChanged) {
void scheduleHttpApply();
}
return buildStatus();
}
@@ -482,6 +681,7 @@ function createExternalMcpController(options = {}) {
state = "disabled";
lastKnownPort = null;
lastKnownToken = null;
void scheduleHttpApply();
}
async function getCodexStatus() {
@@ -550,7 +750,9 @@ function createExternalMcpController(options = {}) {
});
ipcMain.handle("netcatty:external-mcp:set-config", async (event, payload) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
return setConfig(payload || {});
setConfig(payload || {});
await whenHttpSettled();
return getStatus();
});
ipcMain.handle("netcatty:external-mcp:get-universal-setup-prompt", async (event) => {
if (!guard(event)) return { ok: false, error: "Unauthorized IPC sender" };
@@ -602,6 +804,7 @@ function createExternalMcpController(options = {}) {
getGrokStatus,
addToGrok,
getUniversalSetupPrompt,
whenHttpSettled,
EXTERNAL_MCP_CHAT_SESSION_ID: deps.chatSessionId,
};
}
@@ -228,4 +228,95 @@ describe("externalMcpController", () => {
assert.equal(status.state, "running");
assert.ok(written.length >= 2);
});
it("does not listen on HTTP until the toggle is on, and stops when External MCP is disabled", async () => {
const events = [];
let listening = false;
const { controller } = createController({
createHttpServer: () => ({
start: async () => {
listening = true;
events.push("start");
},
stop: async () => {
listening = false;
events.push("stop");
},
getState: () => ({ listening }),
}),
});
await controller.setEnabled(true);
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.listening, false);
assert.equal(controller.getStatus().http.enabled, false);
assert.deepEqual(events, []);
controller.setConfig({ httpEnabled: true, httpPort: 47391 });
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.listening, true);
assert.equal(controller.getStatus().http.url, "http://127.0.0.1:47391/mcp");
assert.equal(controller.getStatus().http.bearerToken, "tok-1");
controller.setConfig({ httpAllowCustomBind: true, httpListenAddress: "0.0.0.0" });
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.url, null);
assert.equal(controller.getStatus().http.errorCode, "client_host_required");
controller.setConfig({ httpClientHost: "172.22.0.1" });
assert.equal(controller.getStatus().http.url, "http://172.22.0.1:47391/mcp");
await controller.setEnabled(false);
await controller.whenHttpSettled();
assert.equal(listening, false);
assert.equal(controller.getStatus().http.listening, false);
assert.equal(controller.getStatus().http.bearerToken, null);
assert.deepEqual(events, ["start", "start", "stop"]);
});
it("routes HTTP dispatch through the bridge handler and rotates the shared external token", async () => {
let token = "tok-0";
let issued = 0;
const calls = [];
const bridge = createFakeBridge();
bridge.issueExternalMcpAuthToken = () => {
issued += 1;
token = `tok-${issued}`;
return token;
};
bridge.getExternalMcpAuthToken = () => token;
bridge.dispatchBuiltinRpc = async (method, params, context) => {
calls.push({ method, params, socket: context?.externalSocket || null });
return { ok: true };
};
let captured = null;
const { controller } = createController({
mcpServerBridge: bridge,
createHttpServer: (deps) => {
captured = deps;
return {
start: async () => {},
stop: async () => {},
getState: () => ({ listening: true }),
};
},
});
await controller.setEnabled(true);
controller.setConfig({ httpEnabled: true, rotateHttpToken: true });
await controller.whenHttpSettled();
assert.equal(controller.getStatus().http.bearerToken, "tok-2");
assert.equal(written.at(-1).options.token, "tok-2");
const client = { id: "http-session" };
const result = await captured.dispatchRpc("netcatty/exec", {
sessionId: "sess-9",
command: "pwd",
chatSessionId: "attacker",
}, { externalClient: client });
assert.equal(result.ok, true);
assert.equal(calls[0].method, "netcatty/exec");
assert.equal(calls[0].params.chatSessionId, EXTERNAL_MCP_CHAT_SESSION_ID);
assert.equal(calls[0].params.sessionId, "sess-9");
assert.equal(calls[0].socket, client);
});
});
+27 -1
View File
@@ -209,13 +209,32 @@ const createHostVerifier = ({
knownHosts = [],
verifyHostKeys = true,
bootEpoch,
// Transfer dials have no SFTP-page prompt owner. Waiting out the request
// TTL leaves the upload on "Waiting..." with no dialog and no error.
unattended = false,
onAccepted,
}) => (rawKey, callback) => {
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const keyInfo = describeHostKey(rawKey);
const decision = classifyHostKey({
knownHosts,
hostname,
@@ -225,10 +244,16 @@ const createHostVerifier = ({
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
@@ -241,6 +266,7 @@ const createHostVerifier = ({
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
+14
View File
@@ -83,6 +83,19 @@ function markExternalMcpSocket(socket) {
}
}
function attachExternalMcpClient(client) {
markExternalMcpSocket(client);
return () => {
if (!client || client.destroyed) return;
client.destroyed = true;
try {
client.emit?.("close");
} catch {
// Synthetic HTTP clients may not be emitters.
}
};
}
function issueExternalMcpAuthToken() {
externalAuthToken = crypto.randomBytes(32).toString("hex");
return externalAuthToken;
@@ -2549,6 +2562,7 @@ module.exports = {
setExternalMcpHooks,
disconnectExternalMcpClients,
issueExternalMcpAuthToken,
attachExternalMcpClient,
revokeExternalMcpAuthToken,
getExternalMcpAuthToken,
syncLiveSessionsToExternalScope,
@@ -49,6 +49,60 @@ test("self-heals a deleted discovery file while the TCP host stays alive", async
assert.equal(fixed.token, original.token);
});
test("self-heal rewrites files whose pid drifted and files that are invalid JSON", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
bridge.init({ cliDiscoveryFilePath: discoveryPath });
const port = await bridge.getOrCreateHost();
const current = readDiscovery(discoveryPath);
// Correct port/token but a pid from a previous app instance: the file is
// stale from the CLI's perspective and must be rewritten.
fs.writeFileSync(discoveryPath, JSON.stringify({
port,
token: current.token,
pid: process.pid + 1,
permissionMode: current.permissionMode,
updatedAt: "stale",
}));
bridge.ensureCliDiscoveryFile();
const repid = readDiscovery(discoveryPath);
assert.equal(repid.pid, process.pid);
assert.equal(repid.port, port);
// A file that cannot be parsed is treated as missing and rewritten.
fs.writeFileSync(discoveryPath, "{not json");
bridge.ensureCliDiscoveryFile();
const reparsed = readDiscovery(discoveryPath);
assert.equal(reparsed.port, port);
assert.equal(reparsed.token, current.token);
assert.equal(reparsed.pid, process.pid);
});
test("self-heal leaves a current discovery file untouched", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
bridge.init({ cliDiscoveryFilePath: discoveryPath });
const port = await bridge.getOrCreateHost();
const before = fs.readFileSync(discoveryPath, "utf8");
bridge.ensureCliDiscoveryFile();
const after = fs.readFileSync(discoveryPath, "utf8");
assert.equal(after, before);
assert.equal(JSON.parse(after).port, port);
});
test("ensureCliDiscoveryFile does nothing while the host is down", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discovery-test-"));
const discoveryPath = path.join(dir, "discovery.json");
+25 -8
View File
@@ -1576,17 +1576,32 @@ async function runRemoteUploadTransaction(client, localPath, remotePath, options
}
const cleanupStage = async () => {
// The owner channel is already gone. unlink/delete would wait forever for
// a callback ssh2 never sends, and the transfer would stay on screen.
const channel = client?.sftp;
if (channel && (channel.readable === false || channel.destroyed === true)) return;
const removeStage = scpMode
? getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
})
: typeof client.delete === "function"
? client.delete(encodedStagedPath)
: null;
if (!removeStage) return;
let timer = null;
try {
if (scpMode) {
await getScpBackendForClient(client).remove(stagedLogical, {
recursive: false,
encoding,
});
} else if (typeof client.delete === "function") {
await client.delete(encodedStagedPath);
}
await Promise.race([
Promise.resolve(removeStage),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("SFTP channel closed")), 2000);
if (typeof timer.unref === "function") timer.unref();
}),
]);
} catch {
// Best-effort cleanup of a partial stage.
} finally {
if (timer) clearTimeout(timer);
}
};
@@ -2385,6 +2400,7 @@ const {
completeTransportDial,
failTransportDial,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
endpointAllowsReuse,
@@ -2407,6 +2423,7 @@ const openConnectionApi = createOpenConnectionApi({
createTransport, borrowTransport, findTransportByEndpoint,
beginTransportDial, waitForTransportDial, completeTransportDial, failTransportDial,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
});
File diff suppressed because it is too large. Load diff
+45 -2
View File
@@ -555,6 +555,41 @@ function createFileOpsApi(ctx) {
return true;
}
/**
* Drop the alias's lease on the login client. If the SFTP page already
* asked to close that client, end it only after the last borrower leaves.
*/
function releaseSharedChannelOwner(client) {
const ownerId = client?.__netcattySharedChannelOwnerId;
const aliasId = client?.__netcattySharedChannelAliasId;
if (!ownerId || !aliasId || client.__netcattyOwnerLeaseReleased) return;
client.__netcattyOwnerLeaseReleased = true;
let store;
let result;
try {
const lease = require("../sftpTransferSessionLease.cjs");
store = lease.sftpTransferSessionLeaseStore;
result = store.release(ownerId, lease.channelAliasLeaseId(aliasId));
} catch {
return;
}
if (!result?.shouldHardClose) return;
const closeToken = store.getPendingHardCloseToken(ownerId);
setImmediate(() => {
if (store.isHeld(ownerId)) {
store.markSoftClosed(ownerId);
return;
}
if (!store.commitHardClose(ownerId, closeToken)) return;
void closeSftp(null, { sftpId: ownerId, force: true }).catch((err) => {
console.warn(
`[SFTP] Deferred close of borrowed owner ${ownerId} failed:`,
err?.message || err,
);
});
});
}
/**
* Close an SFTP connection.
* Also cleans up any jump host connections and file watchers if present.
@@ -666,8 +701,16 @@ function createFileOpsApi(ctx) {
};
}
} catch { /* optional */ }
// Transport-managed / session-backed: close channel + return lease only.
if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
// Alias handles share the login channel. Closing the transfer id must
// not end that channel or the SFTP page listing dies with the upload.
// Releasing the owner lease lets a deferred closeSftp(owner) finish
// only after every borrower is gone.
if (client.__netcattySharedChannelAlias) {
client.__netcattyDisposed = true;
client.sftp = null;
try { await client.end?.(); } catch { /* alias end only detaches */ }
releaseSharedChannelOwner(client);
} else if (client.__netcattyRefHolder || client.__netcattyTransportManaged || client.__netcattySessionBacked) {
try {
if (client.sftp && typeof client.sftp.end === "function") client.sftp.end();
else if (client.sftp && typeof client.sftp.close === "function") client.sftp.close();
@@ -77,6 +77,17 @@ function shouldRegisterFreshSftpTransport(options) {
return allowsSharedSftpTransport(options);
}
function sftpChannelIsLive(sftp) {
// A closed ssh2 SFTP stream still has readdir/stat/open. readable/destroyed
// are what change when the subsystem or the socket has already ended.
return !!sftp
&& sftp.readable !== false
&& sftp.destroyed !== true
&& typeof sftp.readdir === "function"
&& typeof sftp.stat === "function"
&& typeof sftp.open === "function";
}
/** Sudo is part of the requested connection contract; never downgrade it. */
async function openRequiredSudoSftp({
connectSudoSftp,
@@ -239,6 +250,12 @@ function createOpenConnectionApi(ctx) {
port: jump.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: jump.verifyHostKeys ?? options.verifyHostKeys,
unattended: options.hostKeyUnattended === true,
onAccepted: (info) => {
if (!info) return;
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
options._acceptedHostKeys.push(info);
},
});
// Auth - support agent (certificate), key, and password fallback
@@ -767,6 +784,142 @@ function createOpenConnectionApi(ctx) {
});
}
/**
* A live SFTP channel that was not parked in the SSH transport registry
* (single-channel bastion, or sudo elevation). Another open must borrow
* that channel. A second subsystem drops TERM-SSHD / similar logins, and
* a fresh SSH dial would ask for OTP again.
*/
function hopsRequiringHostKeyCheck(request) {
const hops = [];
const add = (hostname, port) => {
const normalized = String(hostname || "").trim().toLowerCase();
if (!normalized) return;
hops.push({ hostname: normalized, port: Number(port) || 22 });
};
if (request?.verifyHostKeys !== false) add(request?.hostname, request?.port);
for (const hop of Array.isArray(request?.jumpHosts) ? request.jumpHosts : []) {
if ((hop?.verifyHostKeys ?? request?.verifyHostKeys) === false) continue;
add(hop?.hostname, hop?.port);
}
return hops;
}
function acceptedHostKeysStillTrusted(source, request) {
// Every hop whose effective policy still verifies must have an accepted
// key that classifies as trusted. Disabling the target does not skip a
// jump host that explicitly verifies.
const required = hopsRequiringHostKeyCheck(request);
if (required.length === 0) return true;
const keys = source?.__netcattyAcceptedHostKeys;
if (!Array.isArray(keys) || keys.length === 0) return false;
const knownHosts = Array.isArray(request?.knownHosts) ? request.knownHosts : [];
return required.every((hop) => keys.some((key) => {
const hostname = String(key?.hostname || "").trim().toLowerCase();
const port = Number(key?.port) || 22;
if (hostname !== hop.hostname || port !== hop.port) return false;
return hostKeyVerifier.classifyHostKey({
knownHosts,
hostname: key.hostname,
port: key.port || 22,
keyType: key.keyType,
fingerprint: key.fingerprint,
}).status === "trusted";
}));
}
function authenticatedChannelMatches(client, endpoint, request) {
const exact = buildEndpointKey(endpoint);
const exactMatch = !!(exact && client.__netcattyEndpointKey === exact);
if (!exactMatch) {
// The known-host digest can change when a still-valid key is saved, or
// when an unrelated record is added. That may reuse the live channel.
if (typeof buildChannelAdoptionKey !== "function") return false;
const adoption = buildChannelAdoptionKey(endpoint);
if (!adoption || client.__netcattyChannelAdoptionKey !== adoption) return false;
}
// Exact endpoint keys are not enough. A session trusted from an empty
// list and a later open that is also empty (the key was removed) share
// a digest, and must still be checked against the caller's records.
return acceptedHostKeysStillTrusted(client, request);
}
function channelContractMatches(options, source) {
if (Boolean(options?.sudo) !== Boolean(source?.__netcattySudoMode)) return false;
const requested = normalizeFileProtocol(options?.fileProtocol);
const owner = source?.__netcattyFileProtocol === "scp" ? "scp" : "sftp";
// auto follows the live channel. A forced protocol must be the same one
// already open, or SCP would silently come back as SFTP.
if (requested === "auto") return true;
return requested === owner;
}
function findAuthenticatedChannelOwner(endpoint, request) {
if (!endpoint || !sftpClients || typeof sftpClients.entries !== "function") return null;
for (const [sftpId, client] of sftpClients.entries()) {
if (!sftpId || !client || client.__netcattyDisposed || client.__netcattySharedChannelAlias) continue;
if (client.__netcattyTransportManaged) continue;
if (!authenticatedChannelMatches(client, endpoint, request)) continue;
if (!client.__netcattySingleChannelSsh && !client.__netcattySudoMode) continue;
if (!sftpChannelIsLive(client.sftp)) continue;
return { sftpId, client };
}
return null;
}
function adoptAuthenticatedSftpChannel(endpoint, connId, options) {
const found = findAuthenticatedChannelOwner(endpoint, options);
if (!found || typeof createSessionBackedSftpClient !== "function") return null;
const { sftpId: ownerId, client: source } = found;
if (!channelContractMatches(options, source)) return null;
// A pane reconnect can ask for the owner's own id while a transfer still
// holds that soft-closed session. Storing the alias under that id would
// drop the SSH client and lease the id to itself, so closeSftp could
// never hard-close it.
const aliasId = connId === ownerId ? `${ownerId}:reconnect:${randomUUID()}` : connId;
let leaseStore = null;
let leaseId = null;
try {
const lease = require("../sftpTransferSessionLease.cjs");
leaseStore = lease.sftpTransferSessionLeaseStore;
leaseId = lease.channelAliasLeaseId(aliasId);
// Hold the owner until this alias is torn down. closeSftp(owner) then
// soft-closes instead of ending the only SFTP channel mid-transfer.
if (!leaseStore.acquire(ownerId, leaseId)) return null;
} catch {
return null;
}
try {
const alias = createSessionBackedSftpClient(aliasId, source.client, {
singleChannelSsh: true,
});
alias.sftp = source.sftp;
alias.__netcattyFileProtocol = source.__netcattyFileProtocol || "sftp";
alias.__netcattySudoMode = !!source.__netcattySudoMode;
// Keep uploads on this channel. A disposable subsystem would either drop
// the single-channel login or bypass sudo elevation.
alias.__netcattySingleChannelSsh = true;
alias.__netcattySharedChannelAlias = true;
alias.__netcattySharedChannelOwnerId = ownerId;
alias.__netcattySharedChannelAliasId = aliasId;
alias.__netcattyEndpointKey = source.__netcattyEndpointKey;
alias.__netcattyChannelAdoptionKey = source.__netcattyChannelAdoptionKey;
alias.__netcattyTransportManaged = false;
alias.end = async () => {
alias.__netcattyDisposed = true;
alias.sftp = null;
};
sftpClients.set(aliasId, alias);
return {
sftpId: aliasId,
fileProtocol: alias.__netcattyFileProtocol === "scp" ? "scp" : "sftp",
};
} catch (err) {
try { leaseStore?.release(ownerId, leaseId); } catch { /* ignore */ }
throw err;
}
}
/**
* Open a new SFTP connection
* Supports jump host connections when options.jumpHosts is provided
@@ -778,6 +931,32 @@ function createOpenConnectionApi(ctx) {
});
let pendingDialCoordination = options._pendingDialCoordination || null;
// Same rejection as openSftpForSession / the post-ready fresh path.
// Doing it before adoption stops {sudo, fileProtocol:'scp'} from
// borrowing an elevated SFTP channel and reporting success.
if (Boolean(options.sudo) && normalizeFileProtocol(options.fileProtocol) === "scp") {
throw new Error(
"Sudo Mode is not supported with File Protocol set to SCP. Disable Sudo Mode or use Auto/SFTP.",
);
}
// Do this before dial coordination. An in-flight single-channel login is
// the dial leader and never registers a transport; joining it waits
// forever and never shows keyboard-interactive.
if (options.reuseTransport !== false && !pendingDialCoordination) {
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
if (adopted) {
sendSftpProgress(
event.sender,
connId,
options.hostname,
"connected",
"reused authenticated SFTP session",
);
return adopted;
}
}
const openOnSharedTransport = async (transport, detail = "reusing shared SSH transport") => {
if (
options.singleChannelSsh
@@ -870,6 +1049,20 @@ function createOpenConnectionApi(ctx) {
: await waitForTransportDial(coordination);
return await openOnSharedTransport(transport, "reused coordinated transport");
} catch (coordinationErr) {
// The leader may have published a live channel and then rejected
// this dial (single-channel bastion, or sudo which is not parked).
// Adopt that channel before failing the transfer or dialing again.
const adopted = adoptAuthenticatedSftpChannel(reuseEndpoint, connId, options);
if (adopted) {
sendSftpProgress(
event.sender,
connId,
options.hostname,
"connected",
"reused authenticated SFTP session",
);
return adopted;
}
if (
coordination.role === "join"
&& coordinationErr?.code !== "ERR_SFTP_SINGLE_CHANNEL_BASTION"
@@ -897,6 +1090,9 @@ function createOpenConnectionApi(ctx) {
const client = new SftpClient();
let peerSingleChannelSsh = options.singleChannelSsh === true;
client.__netcattyEndpointKey = buildEndpointKey(reuseEndpoint);
if (typeof buildChannelAdoptionKey === "function") {
client.__netcattyChannelAdoptionKey = buildChannelAdoptionKey(reuseEndpoint);
}
let freshClientClosed = false;
const closeFreshClient = () => {
if (freshClientClosed) return;
@@ -993,6 +1189,12 @@ function createOpenConnectionApi(ctx) {
port: options.port || 22,
knownHosts: options.knownHosts,
verifyHostKeys: options.verifyHostKeys,
unattended: options.hostKeyUnattended === true,
onAccepted: (info) => {
if (!info) return;
if (!Array.isArray(options._acceptedHostKeys)) options._acceptedHostKeys = [];
options._acceptedHostKeys.push(info);
},
});
// Use the tunneled socket if we have one
@@ -1398,7 +1600,25 @@ function createOpenConnectionApi(ctx) {
if (client.client) {
client.client.__netcattySingleChannelSsh = peerSingleChannelSsh;
}
if (Array.isArray(options._acceptedHostKeys) && options._acceptedHostKeys.length > 0) {
client.__netcattyAcceptedHostKeys = options._acceptedHostKeys.slice();
}
sftpClients.set(connId, client);
// Publish the live channel before rejecting waiters. A single-channel
// login cannot join the transport registry; leaving the dial pending
// makes the next SFTP-page transfer wait forever with no OTP prompt.
if (pendingDialCoordination && !client.__netcattyTransportManaged) {
const unshared = new Error(
peerSingleChannelSsh || options.singleChannelSsh === true
? "This host allows one SSH channel. Reuse the authenticated SFTP session instead of opening another."
: "SFTP connection was not added to the shared SSH transport registry",
);
if (peerSingleChannelSsh || options.singleChannelSsh === true) {
unshared.code = "ERR_SFTP_SINGLE_CHANNEL_BASTION";
}
failTransportDial(pendingDialCoordination, unshared);
}
// Store jump connections for cleanup when SFTP is closed (legacy path
// only — transport-managed connections park chain on the registry).
@@ -1415,6 +1635,16 @@ function createOpenConnectionApi(ctx) {
// Cleanup jump connections on error
cleanupPendingConnection();
closeFreshClient();
if (
options.hostKeyUnattended === true
&& /Host denied \(verification failed\)|Host key rejected/i.test(String(err?.message || ""))
) {
const denied = new Error(`Host key verification required for ${options.hostname || "host"}`);
denied.code = "ERR_HOST_KEY_UNVERIFIED";
denied.cause = err;
if (pendingDialCoordination) failTransportDial(pendingDialCoordination, denied);
throw denied;
}
if (shouldRetrySftpKeyboardInteractiveFirst(options, authConfig, err)) {
console.log("[SFTP] Password auth removed keyboard-interactive; retrying with keyboard-interactive first...");
return openSftp(event, {
@@ -16,8 +16,18 @@
*
* This is intentionally independent of UI panels. Browse can go away; transfers
* keep their leased sessions until they complete.
*
* A borrowed SFTP handle (single-channel / sudo alias) also acquires a lease
* on the owner sftpId for the alias lifetime. closeSftp(owner) then soft-closes
* until the last borrower releases, instead of ending the shared channel.
*/
const CHANNEL_ALIAS_LEASE_PREFIX = "sftp-channel-alias:";
function channelAliasLeaseId(aliasId) {
return `${CHANNEL_ALIAS_LEASE_PREFIX}${aliasId}`;
}
/**
* @typedef {object} SftpTransferSessionLeaseStore
* @property {(sftpId: string, transferId: string) => boolean} acquire
@@ -196,4 +206,5 @@ const sftpTransferSessionLeaseStore = createSftpTransferSessionLeaseStore();
module.exports = {
createSftpTransferSessionLeaseStore,
sftpTransferSessionLeaseStore,
channelAliasLeaseId,
};
+25 -3
View File
@@ -369,7 +369,13 @@ function parseKnownHostTarget(value) {
* text or the full known-hosts vault.
*/
function fingerprintKnownHostTrust(endpoint) {
if (!endpoint || endpoint.verifyHostKeys === false) return "disabled";
if (!endpoint) return "disabled";
const jumps = Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : [];
// A jump host can force verification while the target turns it off. The
// target flag alone must not drop that hop's pin from the digest.
const anyVerification = endpoint.verifyHostKeys !== false
|| jumps.some((hop) => (hop?.verifyHostKeys ?? endpoint.verifyHostKeys) !== false);
if (!anyVerification) return "disabled";
const targets = [];
const addTarget = (hostname, port, verifyHostKeys = true) => {
if (verifyHostKeys === false) return;
@@ -378,7 +384,7 @@ function fingerprintKnownHostTrust(endpoint) {
targets.push(`${normalizedHostname}|${Number(port) || 22}`);
};
addTarget(endpoint.hostname, endpoint.port, endpoint.verifyHostKeys);
for (const hop of Array.isArray(endpoint.jumpHosts) ? endpoint.jumpHosts : []) {
for (const hop of jumps) {
addTarget(
hop?.hostname,
hop?.port,
@@ -481,7 +487,12 @@ function fingerprintAuth(endpoint) {
certificate: endpoint.certificate ? secureDigest(endpoint.certificate) : "none",
requiresMfa: Boolean(endpoint.requiresMfa),
verifyHostKeys: endpoint.verifyHostKeys !== false,
knownHostTrust: fingerprintKnownHostTrust(endpoint),
// Channel adoption may compare a sibling key that ignores this digest, then
// checks the connection's accepted host key against the caller's records.
// Parked transport reuse still uses the full key.
knownHostTrust: endpoint.ignoreKnownHostTrustForReuse
? "ignored"
: fingerprintKnownHostTrust(endpoint),
authPolicyVersion: endpoint.authPolicyVersion ?? null,
useSshAgent: endpoint.useSshAgent ?? null,
identityAgent: endpoint.identityAgent ?? null,
@@ -628,6 +639,16 @@ function buildEndpointKey(endpoint) {
].join("|");
}
/**
* Same route as buildEndpointKey, but the known-host trust digest is ignored.
* Used only to find an already-authenticated SFTP channel after the renderer
* known-host list changes (trust during this app session).
*/
function buildChannelAdoptionKey(endpoint) {
if (!endpoint || typeof endpoint !== "object") return null;
return buildEndpointKey({ ...endpoint, ignoreKnownHostTrustForReuse: true });
}
function sameEndpoint(a, b) {
const left = normalizeEndpoint(a);
const right = normalizeEndpoint(b);
@@ -1544,6 +1565,7 @@ module.exports = {
setDefaultTransportIdleTtlMs,
getDefaultTransportIdleTtlMs,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
resolveConnectionKeepalivePolicy,
normalizeEndpoint,
@@ -27,6 +27,7 @@ const {
setDefaultTransportIdleTtlMs,
getDefaultTransportIdleTtlMs,
buildEndpointKey,
buildChannelAdoptionKey,
buildConnectionReuseEndpoint,
normalizeEndpoint,
endpointAllowsReuse,
@@ -905,6 +906,46 @@ test("endpoint key changes when known-host trust content changes", () => {
);
});
test("a jump host pin changes the endpoint key when the target disables verification", () => {
const base = {
hostId: "jump-pin-target-disabled",
hostname: "target.example",
port: 22,
username: "alice",
authMethod: "password",
password: "secret",
verifyHostKeys: false,
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: true }],
};
const pinned = (publicKey) => buildConnectionReuseEndpoint({
...base,
knownHosts: [{
hostname: "[bastion.example]:2222",
port: 2222,
keyType: "ssh-ed25519",
publicKey,
}],
});
const first = pinned("ssh-ed25519 AAAA_JUMP_FIRST");
const rotated = pinned("ssh-ed25519 AAAA_JUMP_ROTATED");
assert.notEqual(buildEndpointKey(rotated), buildEndpointKey(first));
assert.equal(buildChannelAdoptionKey(rotated), buildChannelAdoptionKey(first));
const bothOff = {
...base,
jumpHosts: [{ hostname: "bastion.example", port: 2222, verifyHostKeys: false }],
};
const offFirst = buildEndpointKey(buildConnectionReuseEndpoint({
...bothOff,
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_FIRST" }],
}));
const offRotated = buildEndpointKey(buildConnectionReuseEndpoint({
...bothOff,
knownHosts: [{ hostname: "[bastion.example]:2222", port: 2222, publicKey: "ssh-ed25519 AAAA_JUMP_ROTATED" }],
}));
assert.equal(offRotated, offFirst);
});
test("endpoint key changes when an identity file changes in place", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-pool-key-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+208 -18
View File
@@ -1830,6 +1830,16 @@ function execSshCommandCancellable(sshClient, command, transfer) {
});
}
function markDisposableUploadChannel(channel) {
if (!channel || typeof channel !== "object") return channel;
// This subsystem can die without taking the shared browse connection with it.
try { channel.__netcattyDisposableChannel = true; } catch { /* ignore */ }
if (channel.sftp && typeof channel.sftp === "object" && channel.sftp !== channel) {
try { channel.sftp.__netcattyDisposableChannel = true; } catch { /* ignore */ }
}
return channel;
}
async function openIsolatedSftpChannel(client, signal = null) {
// The dedicated one-channel login already holds its only session channel.
// Opening another SFTP subsystem drops that login, so stay on the browse channel.
@@ -2468,7 +2478,9 @@ async function uploadFile(
let isolated = null;
if (!client.__netcattySudoMode) {
try {
isolated = await openIsolatedSftpChannel(client, transfer?.signal);
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
);
} catch (err) {
rememberPipelineError(err);
console.warn(
@@ -2534,7 +2546,9 @@ async function uploadFile(
if (!isolated && !client.__netcattySudoMode) {
try {
isolated = await openIsolatedSftpChannel(client, transfer?.signal);
isolated = markDisposableUploadChannel(
await openIsolatedSftpChannel(client, transfer?.signal),
);
} catch (err) {
rememberPipelineError(err);
console.warn(
@@ -3058,10 +3072,8 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
transfer.abort = previousAbort;
}
try { sftp.removeListener?.("error", onOpenChannelError); } catch { /* ignore */ }
if (!isWriteOpen) {
sftp.removeListener?.("end", onOpenChannelClosed);
sftp.removeListener?.("close", onOpenChannelClosed);
}
sftp.removeListener?.("end", onOpenChannelClosed);
sftp.removeListener?.("close", onOpenChannelClosed);
fn(value);
};
@@ -3119,7 +3131,9 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
};
const onOpenChannelClosed = () => {
onOpenChannelError(new Error("SFTP channel closed before the source file was opened"));
onOpenChannelError(isWriteOpen
? sftpChannelClosedError(null, sftp)
: new Error("SFTP channel closed before the source file was opened"));
};
if (transfer.cancelled) {
@@ -3131,15 +3145,15 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
transfer.abort = abortDuringOpen;
sftp.on?.("error", onOpenChannelError);
if (!isWriteOpen) {
sftp.on?.("end", onOpenChannelClosed);
sftp.on?.("close", onOpenChannelClosed);
// Write OPEN used to skip close/end. Killing the server channel then left
// the OPEN callback unanswered, so the upload stayed active with no error.
sftp.on?.("end", onOpenChannelClosed);
sftp.on?.("close", onOpenChannelClosed);
if (!isWriteOpen && sftp.readable === false) {
// A failed isolated download may fall back after the browse channel's
// end event already fired. ssh2 never answers a new OPEN on that channel.
if (sftp.readable === false) {
onOpenChannelClosed();
return;
}
onOpenChannelClosed();
return;
}
try {
@@ -3398,10 +3412,13 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
let settled = false;
let timer = null;
let unwatchAbort = null;
let unwatchDeath = () => {};
const cleanup = () => {
if (timer) clearTimeout(timer);
unwatchAbort?.();
unwatchAbort = null;
try { unwatchDeath(); } catch { /* ignore */ }
unwatchDeath = () => {};
signal?.removeEventListener?.("abort", onAbort);
};
const finish = (error, count) => {
@@ -3411,6 +3428,7 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
if (error) reject(error);
else resolve(Number(count) || 0);
};
unwatchDeath = watchSftpChannelDeath(sftp, (error) => finish(error));
const onAbort = () => {
const error = new Error("Transfer cancelled");
error.code = "ABORT_ERR";
@@ -3440,7 +3458,7 @@ async function readSftpRange(sftp, handle, buffer, position, length, options = {
received,
length - received,
position + received,
(error, count) => finish(error, count),
(error, count) => finish(error ? normalizeSftpChannelLoss(sftp, error) : error, count),
);
} catch (error) {
finish(error);
@@ -3680,12 +3698,181 @@ async function readLocalRange(fileHandle, buffer, position, length) {
}
}
/**
* One close/error listener per channel. ssh2 does not invoke pending READ/WRITE
* callbacks when the owner connection is destroyed, so each in-flight request
* must fail itself or the transfer stays at a partial percent forever.
* EOF (`end`) and the parent SSH client/socket are included: killing the
* server SFTP process often ends the channel without a client-side `error`,
* and a TCP reset is delivered on the SSH client rather than the SFTP stream.
*/
const sftpChannelDeathWaiters = new WeakMap();
function disposableUploadChannel(sftp) {
return sftp?.__netcattyDisposableChannel === true
|| sftp?.sftp?.__netcattyDisposableChannel === true;
}
function sftpChannelClosedError(cause, sftp) {
const error = new Error("SFTP channel closed");
error.code = "ERR_SFTP_CHANNEL_CLOSED";
error.sftpChannelClosed = true;
// The owner/borrowed/sudo channel is gone: do not fall through into another
// dial. A disposable upload subsystem can still fail over to the live browse
// channel.
if (!disposableUploadChannel(sftp)) error.noTransferFallback = true;
if (cause) error.cause = cause;
return error;
}
function isSftpChannelLossError(error) {
if (!error || typeof error !== "object") return false;
if (error.sftpChannelClosed) return true;
const code = String(error.code || "");
if (
code === "ECONNRESET"
|| code === "EPIPE"
|| code === "ECONNABORTED"
|| code === "ERR_SFTP_CHANNEL_CLOSED"
) {
return true;
}
const message = String(error.message || "");
return /no response from server/i.test(message)
|| /socket hang up/i.test(message)
|| /connection reset/i.test(message)
|| error.level === "client-socket";
}
function normalizeSftpChannelLoss(sftp, error) {
if (!error) return error;
if (error.sftpChannelClosed && (error.noTransferFallback || disposableUploadChannel(sftp))) {
return error;
}
const dead = !sftp || sftp.readable === false || sftp.destroyed === true;
if (dead || isSftpChannelLossError(error)) return sftpChannelClosedError(error, sftp);
return error;
}
function sftpChannelDeathTargets(sftp) {
const targets = [];
const add = (value) => {
if (!value || typeof value.on !== "function" || targets.includes(value)) return;
targets.push(value);
};
// ssh2-sftp-client `.on` listens on the SSH connection. Channel close/end
// is emitted on the inner stream the borrowed owner actually writes.
if (sftp?.sftp && sftp.sftp !== sftp) add(sftp.sftp);
add(sftp);
return targets;
}
function rejectPendingSftpRequests(sftp, error) {
const requests = sftp?._requests;
if (!requests || typeof requests !== "object") return;
const pending = requests;
sftp._requests = {};
for (const key of Object.keys(pending)) {
const callback = pending[key]?.cb;
if (typeof callback !== "function") continue;
try { callback(error); } catch { /* ignore */ }
}
}
function watchSftpChannelDeath(sftp, onDeath) {
const channels = sftpChannelDeathTargets(sftp);
const primary = channels[0];
if (!primary) return () => {};
let entry = sftpChannelDeathWaiters.get(primary);
if (!entry) {
const waiters = new Set();
const fail = (error, { flushRequests = false } = {}) => {
const wrapped = normalizeSftpChannelLoss(
primary,
error instanceof Error
? error
: new Error(String(error?.message || error || "SFTP channel error")),
);
// CHANNEL_CLOSE emits close/end and leaves READ/WRITE without a reply.
// A normal SFTP status error must not cancel the other in-flight requests.
if (flushRequests) {
for (const channel of channels) rejectPendingSftpRequests(channel, wrapped);
}
for (const waiter of [...waiters]) waiter(wrapped);
};
const client = primary._client || sftp?._client;
entry = {
waiters,
channels,
client,
onError: (error) => fail(error || new Error("SFTP channel error")),
onClose: () => fail(sftpChannelClosedError(null, sftp), { flushRequests: true }),
onClientError: (error) => {
if (isSftpChannelLossError(error)) fail(error || sftpChannelClosedError());
},
};
for (const channel of channels) {
channel.on("error", entry.onError);
channel.on("close", entry.onClose);
channel.on("end", entry.onClose);
}
if (client && typeof client.on === "function" && !channels.includes(client)) {
client.on("close", entry.onClose);
client.on("end", entry.onClose);
client.on("error", entry.onClientError);
}
sftpChannelDeathWaiters.set(primary, entry);
}
entry.waiters.add(onDeath);
// end/close is not replayed. OPEN can finish, the channel can die, and only
// then does WRITE/READ subscribe — readable is already false and the callback
// never comes. Fail this waiter after returning so the caller can unwatch.
const alreadyDead = channels.some((channel) => channel.readable === false || channel.destroyed === true);
if (alreadyDead) {
queueMicrotask(() => {
if (!entry.waiters.has(onDeath)) return;
onDeath(sftpChannelClosedError(null, sftp));
});
}
return () => {
const current = sftpChannelDeathWaiters.get(primary);
if (!current) return;
current.waiters.delete(onDeath);
if (current.waiters.size > 0) return;
for (const channel of current.channels) {
try { channel.removeListener("error", current.onError); } catch { /* ignore */ }
try { channel.removeListener("close", current.onClose); } catch { /* ignore */ }
try { channel.removeListener("end", current.onClose); } catch { /* ignore */ }
}
if (current.client && typeof current.client.removeListener === "function") {
try { current.client.removeListener("close", current.onClose); } catch { /* ignore */ }
try { current.client.removeListener("end", current.onClose); } catch { /* ignore */ }
try { current.client.removeListener("error", current.onClientError); } catch { /* ignore */ }
}
if (sftpChannelDeathWaiters.get(primary) === current) sftpChannelDeathWaiters.delete(primary);
};
}
function writeSftpRange(sftp, handle, buffer, position, length) {
return new Promise((resolve, reject) => {
sftp.write(handle, buffer, 0, length, position, (error) => {
let settled = false;
let unwatch = () => {};
const finish = (error) => {
if (settled) return;
settled = true;
try { unwatch(); } catch { /* ignore */ }
if (error) reject(error);
else resolve();
});
};
unwatch = watchSftpChannelDeath(sftp, finish);
try {
sftp.write(handle, buffer, 0, length, position, (error) => {
if (error) finish(normalizeSftpChannelLoss(sftp, error));
else finish();
});
} catch (error) {
finish(error);
}
});
}
@@ -4729,7 +4916,10 @@ async function uploadFileConcurrent(
// CLOSE request would hang forever with no callback from ssh2.
if (remoteHandle) {
const skipClose = disposeChannel && (failed || transfer.cancelled);
if (!skipClose) {
// A closed channel never answers CLOSE. Skip it; the 2s race is only for
// a live shared session. Falling through to another strategy on a dead
// channel is handled by noTransferFallback on the close error.
if (!skipClose && sftp?.readable !== false) {
let closeTimeout = null;
try {
await Promise.race([
+152
View File
@@ -13092,6 +13092,158 @@ test("single-channel SFTP upload does not open a second subsystem", async (t) =>
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
test("a closed disposable upload channel falls back once to the healthy shared channel", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-disposable-upload-fallback-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const localPath = path.join(tempDir, "fresh.bin");
const payload = Buffer.from("disposable-fallback");
await fs.promises.writeFile(localPath, payload);
const targetPath = "/tmp/fresh.bin";
const remoteFiles = new Map();
let isolatedOpens = 0;
let sharedWrites = 0;
const rememberFile = (sftp) => {
sftp.lstat = (remotePath, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
callback(error);
return;
}
callback(null, {
size: remoteFiles.get(key).length,
mode: 0o100644,
isDirectory: () => false,
isSymbolicLink: () => false,
});
};
sftp.open = (remotePath, _flags, callback) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) remoteFiles.set(key, Buffer.alloc(0));
callback(null, Buffer.from(key));
};
sftp.close = (_handle, callback) => callback(null);
};
const shared = createFastSftp({});
rememberFile(shared);
shared.write = (handle, buffer, offset, length, position, callback) => {
sharedWrites += 1;
const key = handle.toString();
const next = Buffer.alloc(Math.max(remoteFiles.get(key)?.length || 0, position + length));
if (remoteFiles.has(key)) remoteFiles.get(key).copy(next);
buffer.copy(next, position, offset, offset + length);
remoteFiles.set(key, next);
setImmediate(() => callback(null));
};
const client = {
sftp: shared,
stat: async (remotePath) => {
const key = String(remotePath);
if (!remoteFiles.has(key)) {
const error = new Error("ENOENT");
error.code = 2;
throw error;
}
return { size: remoteFiles.get(key).length, isDirectory: false };
},
delete: async (remotePath) => { remoteFiles.delete(String(remotePath)); },
async rename(fromPath, toPath) {
remoteFiles.set(String(toPath), remoteFiles.get(String(fromPath)));
remoteFiles.delete(String(fromPath));
},
client: {
sftp(callback) {
isolatedOpens += 1;
const isolated = createFastSftp({});
rememberFile(isolated);
isolated.write = () => {
isolated.readable = false;
isolated.emit("close");
};
callback(null, isolated);
},
},
};
transferBridge.init({ sftpClients: new Map([["target", client]]) });
const result = await transferBridge.startTransfer({ sender: createSender() }, {
transferId: "disposable-upload-fallback",
sourcePath: localPath,
targetPath,
sourceType: "local",
targetType: "sftp",
targetSftpId: "target",
totalBytes: payload.length,
resumable: false,
});
assert.equal(result.error, undefined, result.error);
assert.ok(isolatedOpens >= 1);
assert.ok(sharedWrites >= 1);
assert.deepEqual(remoteFiles.get(targetPath), payload);
});
for (const mode of ["single-channel", "sudo"]) {
test(`a closed ${mode} owner channel fails the upload without another dial`, async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `netcatty-${mode}-owner-close-`));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
const localPath = path.join(tempDir, "fresh.bin");
const payload = Buffer.from("owner-channel-closed");
await fs.promises.writeFile(localPath, payload);
let secondChannelOpens = 0;
const shared = createFastSftp({
open(_remotePath, _flags, callback) {
callback(null, Buffer.from("remote-handle"));
},
write() {
shared.readable = false;
shared.emit("close");
},
close(_handle, callback) {
callback(null);
},
});
const client = {
__netcattySingleChannelSsh: mode === "single-channel",
__netcattySudoMode: mode === "sudo",
sftp: shared,
stat: async () => ({ size: 0, isDirectory: false }),
delete: async () => {},
rename: async () => {},
client: {
sftp(callback) {
secondChannelOpens += 1;
callback(new Error("second SFTP channel must not open"));
},
},
};
transferBridge.init({ sftpClients: new Map([["target", client]]) });
const result = await Promise.race([
transferBridge.startTransfer({ sender: createSender() }, {
transferId: `${mode}-owner-channel-closed`,
sourcePath: localPath,
targetPath: "/tmp/fresh.bin",
sourceType: "local",
targetType: "sftp",
targetSftpId: "target",
totalBytes: payload.length,
resumable: false,
}),
new Promise((_, reject) => {
setTimeout(() => reject(new Error("upload hung after the owner channel closed")), 3000);
}),
]);
assert.match(result.error || "", /SFTP channel closed/);
assert.equal(secondChannelOpens, 0);
});
}
test("single-channel SFTP download does not open a second subsystem", async (t) => {
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "netcatty-single-channel-download-"));
t.after(async () => fs.promises.rm(tempDir, { recursive: true, force: true }));
+123 -20
View File
@@ -49,8 +49,64 @@ function loadDiscovery() {
return parsed;
}
async function connectClient() {
const discovery = loadDiscovery();
// Restart-window race (#3443): the TCP bridge listens on an ephemeral port
// (`server.listen(0, "127.0.0.1")` in electron/bridges/mcpServerBridge.cjs)
// and rewrites the discovery file on every app launch. A CLI that reads the
// discovery file while the app is restarting can connect to a port that is
// already dead, or to a reused port whose token is stale, and fail until the
// chat is reopened.
//
// Retry a bounded number of times by re-reading the discovery file, including
// the initial read. The old host removes the file before the new one writes
// it, so a missing, unreadable, or unparseable file at startup is the same
// transient gap as a later re-read. Identity is port + token + pid (the same
// triple `isCliDiscoveryFileCurrent` uses), because the OS may hand the new
// bridge the same ephemeral port. A socket that closes or resets while
// auth/verify is in flight is retried the same way: the process that accepted
// the handshake may already be gone. An unchanged identity is not connected
// again, but the remaining delays still
// elapse so a rewrite later in the budget is observed. At most two extra
// attempts (300ms + 900ms, ~1.2s). When every attempt fails, the original
// error is surfaced unchanged.
const CONNECT_RETRY_DELAYS_MS = [300, 900];
function discoveryIdentity(record) {
return {
port: record?.port,
token: record?.token,
pid: record?.pid,
};
}
function sameDiscoveryIdentity(left, right) {
return left?.port === right?.port
&& left?.token === right?.token
&& left?.pid === right?.pid;
}
// Stale token: the bridge returns `{ ok: false }` (AUTH_FAILED) or, in the
// live host, RPC -32001 "Authentication required. Send auth/verify with valid
// token first." A socket that closes or resets before that response arrives
// is the same restart. Other RPC failures mean this bridge is current.
function isRetryableConnectError(err) {
if (
err?.code === "CONNECT_FAILED"
|| err?.code === "AUTH_FAILED"
|| err?.code === "CONNECTION_CLOSED"
|| err?.code === "CONNECTION_ERROR"
) {
return true;
}
return err?.code === "RPC_ERROR"
&& typeof err.message === "string"
&& err.message.includes("Authentication required");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function establishConnection(discovery) {
const socket = await new Promise((resolve, reject) => {
const sock = net.createConnection({ host: "127.0.0.1", port: discovery.port }, () => resolve(sock));
sock.setEncoding("utf8");
@@ -76,27 +132,74 @@ async function connectClient() {
},
});
const authResult = await client.call("auth/verify", { token: discovery.token });
if (!authResult?.ok) {
throw createError("AUTH_FAILED", "Failed to authenticate to Netcatty TCP bridge.");
}
try {
const statusResult = await client.call("netcatty/getStatus", {});
client.ingestBridgeStatus(statusResult);
} catch {
// Keep the default RPC timeout when bridge status cannot be fetched.
const authResult = await client.call("auth/verify", { token: discovery.token });
if (!authResult?.ok) {
throw createError("AUTH_FAILED", "Failed to authenticate to Netcatty TCP bridge.");
}
try {
const statusResult = await client.call("netcatty/getStatus", {});
client.ingestBridgeStatus(statusResult);
} catch {
// Keep the default RPC timeout when bridge status cannot be fetched.
}
return {
discovery,
async call(method, params) {
return await client.call(method, params);
},
close() {
client.close();
},
};
} catch (err) {
client.close();
throw err;
}
}
async function connectClient() {
const discoveryPath = getCliDiscoveryFilePath();
let lastError = null;
let attemptedIdentity = null;
// Bounded retry: at most CONNECT_RETRY_DELAYS_MS.length extra attempts
// (adds at most ~1.2s of latency in the worst case). The initial read is
// inside the loop so a restart gap with no discovery file yet can heal.
for (let attempt = 0; attempt <= CONNECT_RETRY_DELAYS_MS.length; attempt += 1) {
if (attempt > 0) {
if (!discoveryPath) break;
await sleep(CONNECT_RETRY_DELAYS_MS[attempt - 1]);
}
let discovery = null;
try {
discovery = loadDiscovery();
} catch (err) {
// Missing, unreadable, or still being written. Use the next delay.
// A connect or auth error from an earlier attempt stays the one we
// surface; this load error matters only when no bridge was reached.
if (!lastError) lastError = err;
continue;
}
if (attemptedIdentity && sameDiscoveryIdentity(discovery, attemptedIdentity)) {
// Do not reconnect to the bridge that just failed. Keep the remaining
// delays so a later rewrite inside the budget is still observed.
continue;
}
attemptedIdentity = discoveryIdentity(discovery);
try {
return await establishConnection(discovery);
} catch (err) {
lastError = err;
if (!isRetryableConnectError(err)) break;
}
}
return {
discovery,
async call(method, params) {
return await client.call(method, params);
},
close() {
client.close();
},
};
throw lastError;
}
module.exports = {
+631
View File
@@ -0,0 +1,631 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { connectClient } = require("./netcattyRpcClient.cjs");
const { TOOL_CLI_DISCOVERY_ENV_VAR } = require("./discoveryPath.cjs");
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const STALE_AUTH_MESSAGE = "Authentication required. Send auth/verify with valid token first.";
// Minimal NDJSON bridge stand-in. acceptToken rejects every other token the
// way the live host does (RPC -32001, then close). authResult "not-ok"
// returns `{ ok: false }` instead, which connectClient maps to AUTH_FAILED.
function startFakeBridge({
port = 0,
acceptToken = null,
authResult = "ok",
closeDuringAuth = null,
} = {}) {
let authAttempts = 0;
return new Promise((resolve, reject) => {
const server = net.createServer((socket) => {
let buffer = "";
socket.on("error", () => {});
socket.setEncoding("utf8");
socket.on("data", (chunk) => {
buffer += chunk;
let newlineIndex;
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
if (!line.trim()) continue;
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
if (message?.id == null) continue;
if (message.method === "auth/verify") {
authAttempts += 1;
if (closeDuringAuth && authAttempts === 1) {
if (closeDuringAuth === "reset") {
socket.destroy(Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
} else {
socket.destroy();
}
return;
}
const presented = message.params?.token;
const accepted = acceptToken == null || presented === acceptToken;
if (!accepted && authResult === "not-ok") {
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: false } })}\n`);
continue;
}
if (!accepted) {
socket.write(`${JSON.stringify({
jsonrpc: "2.0",
id: message.id,
error: { code: -32001, message: STALE_AUTH_MESSAGE },
})}\n`);
return;
}
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } })}\n`);
continue;
}
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: message.id, result: {} })}\n`);
}
});
});
server.once("error", reject);
server.listen(port, "127.0.0.1", () => resolve({ server, port: server.address().port }));
});
}
// Reserve a port and release it so connections to it are refused, simulating
// a bridge that died after the CLI read the discovery file.
function getDeadPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
server.close(() => resolve(port));
});
});
}
function writeDiscoveryFile(discoveryPath, port, token, pid = 424242) {
fs.writeFileSync(discoveryPath, `${JSON.stringify({ port, token, pid })}\n`);
}
function setupDiscoveryEnv(t, discoveryPath) {
const previous = process.env[TOOL_CLI_DISCOVERY_ENV_VAR];
process.env[TOOL_CLI_DISCOVERY_ENV_VAR] = discoveryPath;
t.after(() => {
if (previous === undefined) {
delete process.env[TOOL_CLI_DISCOVERY_ENV_VAR];
} else {
process.env[TOOL_CLI_DISCOVERY_ENV_VAR] = previous;
}
});
}
function countConnectAttempts(t) {
const original = net.createConnection;
let attempts = 0;
net.createConnection = (...args) => {
attempts += 1;
return original(...args);
};
t.after(() => {
net.createConnection = original;
});
return () => attempts;
}
function countDiscoveryReads(t, discoveryPath, onRead) {
const original = fs.readFileSync;
let reads = 0;
fs.readFileSync = function (filePath, ...args) {
if (filePath === discoveryPath) {
reads += 1;
if (onRead) {
const stub = onRead(reads);
if (stub !== undefined) return stub;
}
}
return original.call(this, filePath, ...args);
};
t.after(() => {
fs.readFileSync = original;
});
return () => reads;
}
function isOriginalConnectError(err) {
return err?.code === "CONNECT_FAILED"
&& typeof err?.message === "string"
&& err.message.startsWith("Failed to connect to Netcatty TCP bridge: ");
}
test("connectClient retries with a refreshed discovery file after a stale-port failure", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
const { server, port: bridgePort } = await startFakeBridge();
t.after(() => {
server.close();
});
const token = "test-token";
writeDiscoveryFile(discoveryPath, deadPort, token);
// Simulate the app finishing its restart: discovery is rewritten with the
// new ephemeral port while the CLI is inside its first retry delay.
const refresh = (async () => {
await sleep(50);
writeDiscoveryFile(discoveryPath, bridgePort, token);
})();
const client = await connectClient();
await refresh;
assert.equal(client.discovery.port, bridgePort);
assert.equal(getAttempts(), 2);
// The connection is usable end-to-end.
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient stops retrying and surfaces the original error when discovery identity is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const deadPort = await getDeadPort();
writeDiscoveryFile(discoveryPath, deadPort, "test-token");
const startedAt = Date.now();
await assert.rejects(connectClient(), isOriginalConnectError);
const elapsed = Date.now() - startedAt;
// Same port, token, and pid: do not open another TCP connection, but keep
// both delays so a later rewrite is still visible.
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `unchanged identity should keep the remaining delay, elapsed ${elapsed}ms`);
});
test("connectClient keeps polling when discovery disappears and surfaces the original connect error", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const deadPort = await getDeadPort();
writeDiscoveryFile(discoveryPath, deadPort, "test-token");
const refresh = (async () => {
await sleep(50);
fs.rmSync(discoveryPath, { force: true });
})();
const startedAt = Date.now();
await assert.rejects(connectClient(), isOriginalConnectError);
const elapsed = Date.now() - startedAt;
await refresh;
// Missing file is transient: both bounded delays are consumed, and the
// original connect error is what the caller sees.
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `missing discovery should consume both delays, elapsed ${elapsed}ms`);
});
test("connectClient connects after a transient missing discovery file is rewritten", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
const { server, port: bridgePort } = await startFakeBridge();
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, deadPort, "stale-token", 1);
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads === 2) {
const err = new Error("ENOENT: no such file or directory");
err.code = "ENOENT";
throw err;
}
if (reads >= 3) {
return `${JSON.stringify({ port: bridgePort, token: "fresh-token", pid: 2 })}\n`;
}
return undefined;
});
const client = await connectClient();
assert.equal(client.discovery.port, bridgePort);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(client.discovery.pid, 2);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 3);
client.close();
await sleep(20);
});
test("connectClient treats unparseable discovery as transient and keeps the original connect error", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
writeDiscoveryFile(discoveryPath, deadPort, "test-token");
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => (
reads > 1 ? "{not json" : undefined
));
const startedAt = Date.now();
await assert.rejects(connectClient(), isOriginalConnectError);
const elapsed = Date.now() - startedAt;
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `invalid discovery should consume both delays, elapsed ${elapsed}ms`);
});
test("connectClient retries when a restarted bridge reuses the port with a new token and pid", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token" });
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
const record = reads === 1
? { port, token: "stale-token", pid: 1 }
: { port, token: "fresh-token", pid: 2 };
return `${JSON.stringify(record)}\n`;
});
// The replacement bridge already owns the same ephemeral port. The first
// TCP attempt still fails because the CLI is holding the pre-restart record.
const originalConnect = net.createConnection;
let attempts = 0;
net.createConnection = (...args) => {
attempts += 1;
if (attempts === 1) {
const socket = new net.Socket();
process.nextTick(() => {
const err = new Error("connect ECONNREFUSED 127.0.0.1");
err.code = "ECONNREFUSED";
socket.emit("error", err);
});
return socket;
}
return originalConnect(...args);
};
t.after(() => {
net.createConnection = originalConnect;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(client.discovery.pid, 2);
assert.equal(attempts, 2);
assert.equal(getReads(), 2);
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient refreshes discovery after a stale-token auth rejection on a reused port", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token" });
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
const record = reads === 1
? { port, token: "stale-token", pid: 11 }
: { port, token: "fresh-token", pid: 22 };
return `${JSON.stringify(record)}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(client.discovery.pid, 22);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 2);
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient surfaces the original auth error when a stale token's discovery identity is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token", authResult: "not-ok" });
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, port, "stale-token", 11);
await assert.rejects(
connectClient(),
(err) => err?.code === "AUTH_FAILED"
&& err.message === "Failed to authenticate to Netcatty TCP bridge.",
);
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
});
test("connectClient surfaces the bridge auth error when the stale token's discovery identity is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const { server, port } = await startFakeBridge({ acceptToken: "fresh-token" });
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, port, "stale-token", 11);
await assert.rejects(
connectClient(),
(err) => err?.code === "RPC_ERROR" && err.message === STALE_AUTH_MESSAGE,
);
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
});
test("connectClient polls an initially missing discovery file and then connects", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge();
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads === 1) {
const err = new Error("ENOENT: no such file or directory");
err.code = "ENOENT";
throw err;
}
return `${JSON.stringify({ port, token: "fresh-token", pid: 7 })}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(client.discovery.token, "fresh-token");
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 2);
client.close();
await sleep(20);
});
test("connectClient retries when the initial discovery file is unreadable", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge();
t.after(() => {
server.close();
});
countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads === 1) {
const err = new Error("EACCES: permission denied");
err.code = "EACCES";
throw err;
}
return `${JSON.stringify({ port, token: "fresh-token", pid: 8 })}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(getAttempts(), 1);
client.close();
await sleep(20);
});
test("connectClient treats an initially partial discovery file as transient", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge();
t.after(() => {
server.close();
});
countDiscoveryReads(t, discoveryPath, (reads) => (
reads === 1
? "{not json"
: `${JSON.stringify({ port, token: "fresh-token", pid: 9 })}\n`
));
const client = await connectClient();
assert.equal(client.discovery.port, port);
assert.equal(getAttempts(), 1);
client.close();
await sleep(20);
});
test("connectClient surfaces the original APP_NOT_RUNNING error when discovery never appears", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const startedAt = Date.now();
await assert.rejects(
connectClient(),
(err) => err?.code === "APP_NOT_RUNNING"
&& err.message.startsWith(`Netcatty discovery file is missing at ${discoveryPath}.`),
);
const elapsed = Date.now() - startedAt;
assert.equal(getAttempts(), 0);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `missing startup discovery should consume both delays, elapsed ${elapsed}ms`);
});
test("connectClient retries when the socket closes during auth and discovery changes", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const { server, port } = await startFakeBridge({ closeDuringAuth: "close" });
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
const record = reads === 1
? { port, token: "token", pid: 1 }
: { port, token: "token", pid: 2 };
return `${JSON.stringify(record)}\n`;
});
const client = await connectClient();
assert.equal(client.discovery.pid, 2);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 2);
await client.call("netcatty/getStatus", {});
client.close();
await sleep(20);
});
test("connectClient surfaces the original transport error when auth is reset and discovery is unchanged", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const getReads = countDiscoveryReads(t, discoveryPath);
const { server, port } = await startFakeBridge({ closeDuringAuth: "reset" });
t.after(() => {
server.close();
});
writeDiscoveryFile(discoveryPath, port, "token", 4);
await assert.rejects(
connectClient(),
(err) => (err?.code === "CONNECTION_CLOSED" || err?.code === "CONNECTION_ERROR")
&& typeof err?.message === "string"
&& (
err.message === "Connection to Netcatty TCP bridge closed."
|| err.message.startsWith("Connection to Netcatty TCP bridge failed: ")
),
);
assert.equal(getAttempts(), 1);
assert.equal(getReads(), 3);
});
test("connectClient observes a discovery rewrite after an unchanged reread", async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-cli-connect-test-"));
const discoveryPath = path.join(dir, "discovery.json");
t.after(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
setupDiscoveryEnv(t, discoveryPath);
const getAttempts = countConnectAttempts(t);
const deadPort = await getDeadPort();
const { server, port: bridgePort } = await startFakeBridge();
t.after(() => {
server.close();
});
const getReads = countDiscoveryReads(t, discoveryPath, (reads) => {
if (reads < 3) {
return `${JSON.stringify({ port: deadPort, token: "stale-token", pid: 1 })}\n`;
}
return `${JSON.stringify({ port: bridgePort, token: "fresh-token", pid: 2 })}\n`;
});
const startedAt = Date.now();
const client = await connectClient();
const elapsed = Date.now() - startedAt;
assert.equal(client.discovery.port, bridgePort);
assert.equal(client.discovery.pid, 2);
assert.equal(getAttempts(), 2);
assert.equal(getReads(), 3);
assert.ok(elapsed >= 1100, `later rewrite should wait out both delays, elapsed ${elapsed}ms`);
client.close();
await sleep(20);
});
Vendored
+7
View File
@@ -120,6 +120,13 @@ declare global {
passphrase?: string;
knownHosts?: import("./domain/models").KnownHost[];
verifyHostKeys?: boolean;
/**
* When true, an untrusted host key fails the dial instead of waiting for
* a renderer prompt. Transfer opens set this: their session id is not the
* SFTP page's host-key dialog, so an unanswered prompt leaves the upload
* on "Waiting...".
*/
hostKeyUnattended?: boolean;
// Environment variables to set in the remote shell
env?: Record<string, string>;
// Proxy configuration
+10
View File
@@ -233,6 +233,16 @@ export const STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES = 'netcatty_ai_session_
export const STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS = 'netcatty_ai_external_mcp_silent_sessions_v1';
/** External MCP confirm-mode approval alert: off, unfocused, or always. Default unfocused. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT = 'netcatty_ai_external_mcp_approval_alert_v1';
/** External MCP Streamable HTTP listener. Off unless the user enables it. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED = 'netcatty_ai_external_mcp_http_enabled_v1';
/** Fixed Streamable HTTP port so client configs stay stable across restarts. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT = 'netcatty_ai_external_mcp_http_port_v1';
/** When true, HTTP may bind an address other than 127.0.0.1. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND = 'netcatty_ai_external_mcp_http_allow_custom_bind_v1';
/** IP address used only when custom bind is enabled. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS = 'netcatty_ai_external_mcp_http_listen_address_v1';
/** Address copied into client configs when the listener is a wildcard bind. */
export const STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST = 'netcatty_ai_external_mcp_http_client_host_v1';
/** AI panel diagnostic hide list (comma-separated part names). */
export const STORAGE_KEY_AI_PANEL_DIAGNOSTIC_HIDE = 'netcatty.aiDebug.hide';
/** AI panel React profiler toggle. */