mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-14 10:56:33 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ece2703a4a | ||
|
|
081c6dc0a7 | ||
|
|
1b5d183599 | ||
|
|
c47d4e17b4 | ||
|
|
bd6d6bf7d5 | ||
|
|
57cb5e3192 | ||
|
|
58978566bb | ||
|
|
b4ed76c648 | ||
|
|
5facc87597 | ||
|
|
48f2c2fd82 | ||
|
|
57089a3abf | ||
|
|
3c67392455 | ||
|
|
a01563931b | ||
|
|
0ccafbabf7 | ||
|
|
cd0fa39f49 | ||
|
|
c78121888b | ||
|
|
f1be47adc4 |
No files matched your search
@@ -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 + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
|
||||
@@ -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\]/);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"], /鏡像網路/);
|
||||
});
|
||||
@@ -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) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -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': '无',
|
||||
|
||||
@@ -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)仍可能有自己的工具核准介面。',
|
||||
|
||||
@@ -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': '無',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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, />新建工作区</);
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -146,6 +146,7 @@ export const buildSshDeepLinkEphemeralHostFromSaved = (
|
||||
savePassword: false,
|
||||
group: "",
|
||||
ephemeral: true,
|
||||
backedBySavedHost: true,
|
||||
protocol: "ssh",
|
||||
moshEnabled: false,
|
||||
etEnabled: false,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user