mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
131ed03ffa | ||
|
|
7ec9cd652b | ||
|
|
e7d7a12a91 | ||
|
|
86d8c5279d | ||
|
|
e0cb6034fe | ||
|
|
b54c5e3a9b |
No files matched your search
@@ -9,6 +9,7 @@ import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
|
||||
@@ -153,7 +154,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings, knownHosts);
|
||||
}, isPortForwardingAutoReconnectEnabled(rule), terminalSettings, knownHosts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -28,3 +28,35 @@ test("top tabs chrome theme keeps accent foreground in sync", () => {
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--primary-foreground'\)/);
|
||||
assert.match(supportSource, /removeStylePropertyIfSet\(tabsRoot, '--accent-foreground'\)/);
|
||||
});
|
||||
|
||||
test("bottom title bar receives and clears the same active theme as the tabs", async () => {
|
||||
const { applyTopTabsChromeThemeVars, clearTopTabsChromeThemeVars } = await import('./topTabsChromeTheme.ts');
|
||||
const { TERMINAL_THEMES } = await import('../../infrastructure/config/terminalThemes.ts');
|
||||
const makeRoot = () => {
|
||||
const values = new Map<string, string>();
|
||||
return { style: {
|
||||
getPropertyValue: (key: string) => values.get(key) ?? '',
|
||||
setProperty: (key: string, value: string) => values.set(key, value),
|
||||
removeProperty: (key: string) => values.delete(key),
|
||||
}};
|
||||
};
|
||||
const tabs = makeRoot();
|
||||
const titlebar = makeRoot();
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'document');
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
|
||||
querySelector: (selector: string) => selector === '[data-top-tabs-root]' ? tabs : titlebar,
|
||||
}});
|
||||
try {
|
||||
applyTopTabsChromeThemeVars(TERMINAL_THEMES[0]);
|
||||
for (const property of ['--secondary', '--foreground', '--top-tabs-bg']) {
|
||||
assert.notEqual(tabs.style.getPropertyValue(property), '');
|
||||
assert.equal(titlebar.style.getPropertyValue(property), tabs.style.getPropertyValue(property));
|
||||
}
|
||||
clearTopTabsChromeThemeVars();
|
||||
assert.equal(tabs.style.getPropertyValue('--secondary'), '');
|
||||
assert.equal(titlebar.style.getPropertyValue('--secondary'), '');
|
||||
} finally {
|
||||
if (original) Object.defineProperty(globalThis, 'document', original);
|
||||
else Reflect.deleteProperty(globalThis, 'document');
|
||||
}
|
||||
});
|
||||
@@ -70,23 +70,31 @@ const TOP_TABS_THEME_PROPERTIES = [
|
||||
'--muted-foreground',
|
||||
] as const;
|
||||
|
||||
export function getTopTabsChromeRoots(): HTMLElement[] {
|
||||
if (typeof document === 'undefined') return [];
|
||||
return [
|
||||
document.querySelector<HTMLElement>('[data-top-tabs-root]'),
|
||||
document.querySelector<HTMLElement>('[data-tab-window-titlebar]'),
|
||||
].filter((root): root is HTMLElement => root !== null);
|
||||
}
|
||||
|
||||
let topTabsChromeThemeVarsApplied = false;
|
||||
|
||||
export function clearTopTabsChromeThemeVars(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (!topTabsChromeThemeVarsApplied) return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(tabsRoot, property);
|
||||
for (const root of getTopTabsChromeRoots()) {
|
||||
for (const property of TOP_TABS_THEME_PROPERTIES) {
|
||||
removeStylePropertyIfSet(root, property);
|
||||
}
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = false;
|
||||
}
|
||||
|
||||
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
const roots = getTopTabsChromeRoots();
|
||||
if (roots.length === 0) return;
|
||||
|
||||
const bg = hexToHslToken(theme.colors.background);
|
||||
const fg = hexToHslToken(theme.colors.foreground);
|
||||
@@ -97,19 +105,21 @@ export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
|
||||
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
|
||||
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
|
||||
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
for (const tabsRoot of roots) {
|
||||
setStylePropertyIfChanged(tabsRoot, '--background', bg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--accent-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
|
||||
setStylePropertyIfChanged(tabsRoot, '--primary-foreground', accentForeground);
|
||||
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
|
||||
setStylePropertyIfChanged(tabsRoot, '--border', border);
|
||||
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
|
||||
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
|
||||
}
|
||||
topTabsChromeThemeVarsApplied = true;
|
||||
}
|
||||
@@ -358,6 +358,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Click once to select a host (accent highlight in grid view), click again to connect. Groups work the same way. Default remains single-click connect.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Only show ungrouped hosts at root',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
|
||||
'settings.appearance.tabBarPosition': 'Tab bar position',
|
||||
'settings.appearance.tabBarPosition.top': 'Top',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Bottom',
|
||||
'settings.vault.showSftpTab': 'Show SFTP tab',
|
||||
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
|
||||
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
|
||||
|
||||
@@ -266,6 +266,8 @@ export const enVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
'pf.form.autoStart': 'Auto Start',
|
||||
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
|
||||
'pf.form.autoReconnect': 'Auto Reconnect',
|
||||
'pf.form.autoReconnectDesc': 'Automatically reconnect when the connection drops',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'New Folder',
|
||||
|
||||
@@ -273,6 +273,9 @@ export const esCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Haz clic una vez para seleccionar un host (resaltado con el color de acento en la vista de cuadrícula), haz clic de nuevo para conectarte. Los grupos funcionan igual. El valor predeterminado sigue siendo conectarse con un solo clic.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Mostrar solo hosts sin grupo en la raíz',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Cuando está activado, la lista de hosts de la raíz solo muestra hosts sin grupo. Abre un grupo desde la barra lateral para ver los hosts agrupados.',
|
||||
'settings.appearance.tabBarPosition': 'Posición de la barra de pestañas',
|
||||
'settings.appearance.tabBarPosition.top': 'Arriba',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Abajo',
|
||||
'settings.vault.showSftpTab': 'Mostrar pestaña SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Muestra la vista SFTP independiente en la barra de pestañas superior. Cuando está oculta, usa el panel lateral SFTP dentro de la sesión.',
|
||||
'settings.vault.showHostTreeSidebar': 'Mostrar barra lateral de lista de hosts',
|
||||
|
||||
@@ -266,6 +266,8 @@ export const esVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Detener y eliminar',
|
||||
'pf.form.autoStart': 'Inicio automático',
|
||||
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
|
||||
'pf.form.autoReconnect': 'Reconexión automática',
|
||||
'pf.form.autoReconnectDesc': 'Vuelve a conectar automáticamente si se pierde la conexión',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Nueva carpeta',
|
||||
|
||||
@@ -342,6 +342,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': 'Один клик выбирает хост (акцентная рамка в сетке), повторный клик подключает. Группы работают так же. По умолчанию — подключение одним кликом.',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': 'Показывать в корне только хосты без группы',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
|
||||
'settings.appearance.tabBarPosition': 'Расположение панели вкладок',
|
||||
'settings.appearance.tabBarPosition.top': 'Сверху',
|
||||
'settings.appearance.tabBarPosition.bottom': 'Снизу',
|
||||
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
|
||||
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
|
||||
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
|
||||
|
||||
@@ -304,6 +304,8 @@ export const ruVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': 'Остановить и удалить',
|
||||
'pf.form.autoStart': 'Автозапуск',
|
||||
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
|
||||
'pf.form.autoReconnect': 'Автопереподключение',
|
||||
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
|
||||
|
||||
// SFTP
|
||||
'sftp.newFolder': 'Новая папка',
|
||||
|
||||
@@ -356,6 +356,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '单击选中主机(网格模式显示强调色边框),再次单击连接;分组同理。默认仍为单击直接连接。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目录只显示未分组主机',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
|
||||
'settings.appearance.tabBarPosition': '标签栏位置',
|
||||
'settings.appearance.tabBarPosition.top': '顶部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '显示 SFTP 标签页',
|
||||
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
|
||||
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
|
||||
|
||||
@@ -980,6 +980,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
'pf.form.autoStart': '自动启动',
|
||||
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
|
||||
'pf.form.autoReconnect': '自动重连',
|
||||
'pf.form.autoReconnectDesc': '连接断开后自动尝试重新连接',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本地',
|
||||
|
||||
@@ -358,6 +358,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.vault.selectBeforeConnectDesc': '點一下選取主機(網格模式會顯示強調色邊框),再點一下才連線;群組同理。預設仍是點一下直接連線。',
|
||||
'settings.vault.showOnlyUngroupedHostsInRoot': '根目錄只顯示未歸類主機',
|
||||
'settings.vault.showOnlyUngroupedHostsInRootDesc': '開啟後,主機庫根目錄的主機列表只顯示沒有群組的主機,已歸類的主機請從左側群組進入檢視。',
|
||||
'settings.appearance.tabBarPosition': '分頁列位置',
|
||||
'settings.appearance.tabBarPosition.top': '頂部',
|
||||
'settings.appearance.tabBarPosition.bottom': '底部',
|
||||
'settings.vault.showSftpTab': '顯示 SFTP 分頁',
|
||||
'settings.vault.showSftpTabDesc': '在頂部分頁列顯示獨立的 SFTP 檢視。關閉後可改用工作階段內左側的 SFTP 側欄。',
|
||||
'settings.vault.showHostTreeSidebar': '顯示主機列表側欄',
|
||||
|
||||
@@ -980,6 +980,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'pf.deleteActive.confirm': '關閉並刪除',
|
||||
'pf.form.autoStart': '自動啟動',
|
||||
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
|
||||
'pf.form.autoReconnect': '自動重連',
|
||||
'pf.form.autoReconnectDesc': '連線中斷後自動嘗試重新連線',
|
||||
|
||||
// SFTP (pane + conflict)
|
||||
'sftp.pane.local': '本機',
|
||||
|
||||
@@ -23,6 +23,7 @@ export type SettingsChromeSnapshot = {
|
||||
darkUiThemeId: string;
|
||||
uiLanguage: UILanguage;
|
||||
windowOpacity: number;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
showRecentHosts: boolean;
|
||||
@@ -55,6 +56,7 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
|
||||
darkUiThemeId: 'default',
|
||||
uiLanguage: 'en',
|
||||
windowOpacity: 1,
|
||||
tabBarPosition: 'top',
|
||||
showSftpTab: true,
|
||||
showHostTreeSidebar: true,
|
||||
showRecentHosts: true,
|
||||
@@ -80,6 +82,7 @@ export function settingsChromeSnapshotsEqual(
|
||||
&& a.darkUiThemeId === b.darkUiThemeId
|
||||
&& a.uiLanguage === b.uiLanguage
|
||||
&& a.windowOpacity === b.windowOpacity
|
||||
&& a.tabBarPosition === b.tabBarPosition
|
||||
&& a.showSftpTab === b.showSftpTab
|
||||
&& a.showHostTreeSidebar === b.showHostTreeSidebar
|
||||
&& a.showRecentHosts === b.showRecentHosts
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
@@ -100,6 +102,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
|
||||
@@ -146,6 +149,7 @@ export function useSettingsIpcSync({
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -300,6 +304,9 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
|
||||
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
|
||||
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -360,6 +367,7 @@ export function useSettingsIpcSync({
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||
import type { CustomKeyBindings, HotkeyScheme, SessionLogFormat, TerminalSettings, UILanguage } from '../../domain/models';
|
||||
import { parseCustomKeyBindingsStorageRecord } from '../../domain/customKeyBindings';
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -90,6 +92,7 @@ interface UseSettingsStorageSyncParams {
|
||||
showRecentHosts: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
tabBarPosition: TabBarPosition;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
terminalSidePanelAutoOpen: boolean;
|
||||
@@ -139,6 +142,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
|
||||
setHostClickBehaviorState: Dispatch<SetStateAction<HostClickBehavior>>;
|
||||
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
|
||||
setTabBarPositionState: Dispatch<SetStateAction<TabBarPosition>>;
|
||||
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
|
||||
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
|
||||
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -176,7 +180,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
@@ -185,7 +189,7 @@ export function useSettingsStorageSync({
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -200,7 +204,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
@@ -210,7 +214,7 @@ export function useSettingsStorageSync({
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
@@ -431,6 +435,9 @@ export function useSettingsStorageSync({
|
||||
setShowOnlyUngroupedHostsInRootState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TAB_BAR_POSITION) {
|
||||
setTabBarPositionState(normalizeTabBarPosition(e.newValue));
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SHOW_SFTP_TAB && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== s.showSftpTab) {
|
||||
@@ -575,7 +582,7 @@ export function useSettingsStorageSync({
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
setShowRecentHostsState,
|
||||
setShowSftpTabState,
|
||||
setTabBarPositionState, setShowSftpTabState,
|
||||
setShellOnlyTabNumberShortcutsState,
|
||||
setShowTabNumberBadgesState,
|
||||
setDisableTerminalFontZoomState,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import TestRenderer, { act } from 'react-test-renderer';
|
||||
import { useCodexConfigModel } from './useCodexConfigModel';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const agent: ExternalAgentConfig = {
|
||||
id: 'discovered_codex', name: 'Codex', command: 'codex', sdkBackend: 'codex', enabled: true,
|
||||
env: { CODEX_HOME: '/fixture/custom' },
|
||||
};
|
||||
|
||||
for (const codexRuntime of ['sdk', 'app-server'] as const) {
|
||||
test(`${codexRuntime}: quick send waits for the picker probe and receives its custom model`, async () => {
|
||||
let resolve!: (result: unknown) => void;
|
||||
const calls: unknown[] = [];
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
|
||||
aiCodexGetIntegration: (opts: unknown) => { calls.push(opts); return new Promise(done => { resolve = done; }); },
|
||||
} } });
|
||||
let result!: ReturnType<typeof useCodexConfigModel>;
|
||||
function Harness({ config }: { config: ExternalAgentConfig }) {
|
||||
result = useCodexConfigModel(config, true);
|
||||
return null;
|
||||
}
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
try {
|
||||
const config = { ...agent, codexRuntime };
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
|
||||
assert.equal(result.model, null);
|
||||
let sent = false;
|
||||
const sending = result.loadModel().then(model => { sent = true; return model; });
|
||||
await Promise.resolve();
|
||||
assert.equal(sent, false);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual((calls[0] as { agentEnv: unknown }).agentEnv, agent.env);
|
||||
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
|
||||
assert.equal(await sending, 'glm-5');
|
||||
assert.equal(result.model, 'glm-5');
|
||||
// A different agent must not inherit the previous home/model or promise.
|
||||
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
|
||||
assert.equal(result.model, null);
|
||||
assert.equal(calls.length, 2);
|
||||
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
|
||||
assert.equal(result.model, null);
|
||||
} finally {
|
||||
await act(async () => root?.unmount());
|
||||
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('non-Codex agents keep their model flow without probing Codex', async () => {
|
||||
let result!: ReturnType<typeof useCodexConfigModel>;
|
||||
function Harness() { result = useCodexConfigModel(undefined, true); return null; }
|
||||
let root!: TestRenderer.ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => { root = TestRenderer.create(React.createElement(Harness)); });
|
||||
assert.equal(await result.loadModel(), null);
|
||||
} finally { await act(async () => root.unmount()); }
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
|
||||
import { getManualAgentCommand, matchesManagedAgentConfig } from '../../infrastructure/ai/managedAgents';
|
||||
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Share the config probe between the picker and sends, scoped to this agent. */
|
||||
export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVisible: boolean) {
|
||||
const loadModel = useMemo(() => {
|
||||
let pending: Promise<string | null> | undefined;
|
||||
return () => {
|
||||
pending ??= Promise.resolve().then(async () => {
|
||||
if (!isVisible || !agent || !matchesManagedAgentConfig(agent, 'codex')) return null;
|
||||
const info = await getNetcattyBridge()?.aiCodexGetIntegration?.({
|
||||
codexPath: getManualAgentCommand(agent),
|
||||
agentEnv: agent.env,
|
||||
});
|
||||
return info?.customConfig?.model || null;
|
||||
}).catch(() => null);
|
||||
return pending;
|
||||
};
|
||||
// Reopening the panel refreshes config changed outside Netcatty.
|
||||
}, [agent, isVisible]);
|
||||
const [result, setResult] = useState<{ loadModel: typeof loadModel; model: string | null }>();
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
let cancelled = false;
|
||||
void loadModel().then(model => {
|
||||
if (!cancelled) setResult({ loadModel, model });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isVisible, loadModel]);
|
||||
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAutoStartRuleBlockReason,
|
||||
isAutoStartProxyReady,
|
||||
isPortForwardingAutoStartEnabled,
|
||||
isPortForwardingReconnectRequested,
|
||||
} from "./usePortForwardingAutoStart.ts";
|
||||
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
|
||||
@@ -177,6 +178,15 @@ test("reconnect eligibility follows the current auto-start setting", () => {
|
||||
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("reconnect is requested for auto-start and auto-reconnect rules only", () => {
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true, autoReconnect: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: true })], "rule-1"), true);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false })], "rule-1"), false);
|
||||
assert.equal(isPortForwardingReconnectRequested([], "rule-1"), false);
|
||||
});
|
||||
|
||||
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
|
||||
const env = installStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
||||
|
||||
@@ -8,6 +8,7 @@ import { GroupConfig, Host, Identity, KnownHost, PortForwardingRule, ProxyProfil
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
getActiveConnection,
|
||||
@@ -103,6 +104,11 @@ export const isPortForwardingAutoStartEnabled = (
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
|
||||
|
||||
export const isPortForwardingReconnectRequested = (
|
||||
rules: PortForwardingRule[],
|
||||
ruleId: string,
|
||||
): boolean => rules.some((rule) => rule.id === ruleId && isPortForwardingAutoReconnectEnabled(rule));
|
||||
|
||||
export const shouldStartPortForwardingAutoStartRule = (
|
||||
rule: PortForwardingRule,
|
||||
connection?: { status: PortForwardingRule["status"] },
|
||||
@@ -110,6 +116,18 @@ export const shouldStartPortForwardingAutoStartRule = (
|
||||
!connection || connection.status === "inactive" || connection.status === "error"
|
||||
);
|
||||
|
||||
/**
|
||||
* Network-recovery counterpart of `shouldStartPortForwardingAutoStartRule`:
|
||||
* unlike launch-time auto-start, rules that only opt in via `autoReconnect`
|
||||
* are also restarted after network restoration.
|
||||
*/
|
||||
export const shouldRestartPortForwardingReconnectRule = (
|
||||
rule: PortForwardingRule,
|
||||
connection?: { status: PortForwardingRule["status"] },
|
||||
): boolean => isPortForwardingAutoReconnectEnabled(rule) && (
|
||||
!connection || connection.status === "inactive" || connection.status === "error"
|
||||
);
|
||||
|
||||
export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
|
||||
restartAutoStartRules: (recoverableRuleIds: ReadonlySet<string>) => Promise<void>,
|
||||
): Promise<void> => {
|
||||
@@ -118,7 +136,7 @@ export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
|
||||
) ?? [];
|
||||
const recoverableRuleIds = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
if (rule.autoStart && resetReconnectAttempts(rule.id)) {
|
||||
if (isPortForwardingAutoReconnectEnabled(rule) && resetReconnectAttempts(rule.id)) {
|
||||
recoverableRuleIds.add(rule.id);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +202,7 @@ export const runPortForwardingAutoStart = async ({
|
||||
recoveryRuleIds,
|
||||
}: RunPortForwardingAutoStartOptions): Promise<void> => {
|
||||
await syncWithBackend({
|
||||
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
shouldReconnect: (ruleId) => isPortForwardingReconnectRequested(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
),
|
||||
@@ -197,9 +215,11 @@ export const runPortForwardingAutoStart = async ({
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const autoStartRules = rules.filter((rule) =>
|
||||
(!recoveryRuleIds || recoveryRuleIds.has(rule.id)) &&
|
||||
(!recoveryRuleIds || isReconnectRecoveryEligible(rule.id)) &&
|
||||
shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
|
||||
recoveryRuleIds
|
||||
? recoveryRuleIds.has(rule.id) &&
|
||||
isReconnectRecoveryEligible(rule.id) &&
|
||||
shouldRestartPortForwardingReconnectRule(rule, getActiveConnection(rule.id))
|
||||
: shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
|
||||
);
|
||||
|
||||
if (autoStartRules.length === 0) return;
|
||||
@@ -385,7 +405,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
|
||||
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingReconnectRequested(
|
||||
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
|
||||
ruleId,
|
||||
));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition, type TabBarPosition } from '../../domain/tabBarPosition';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
|
||||
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
|
||||
@@ -78,6 +79,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
@@ -413,6 +415,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
return stored ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT;
|
||||
});
|
||||
const [tabBarPosition, setTabBarPositionState] = useState<TabBarPosition>(() =>
|
||||
normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)),
|
||||
);
|
||||
const [showSftpTab, setShowSftpTabState] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
return stored ?? DEFAULT_SHOW_SFTP_TAB;
|
||||
@@ -1012,6 +1017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
const storedShowOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
setTabBarPositionState(normalizeTabBarPosition(readStoredString(STORAGE_KEY_TAB_BAR_POSITION)));
|
||||
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
|
||||
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
|
||||
@@ -1198,6 +1204,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
setWorkspaceFocusStyleState,
|
||||
setTabBarPositionState,
|
||||
setShowHostTreeSidebarState,
|
||||
setTerminalSidePanelAutoOpenState,
|
||||
setTerminalSidePanelAutoOpenTabState,
|
||||
@@ -1254,7 +1261,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
@@ -1264,7 +1271,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
|
||||
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
|
||||
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setTabBarPositionState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
@@ -1394,6 +1401,14 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
notifySettingsChanged(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT, enabled);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setTabBarPosition = useCallback((value: TabBarPosition) => {
|
||||
const next = normalizeTabBarPosition(value);
|
||||
setTabBarPositionState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TAB_BAR_POSITION, next);
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
const setShowSftpTab = useCallback((enabled: boolean) => {
|
||||
setShowSftpTabState(enabled);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
|
||||
@@ -1865,6 +1880,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
darkUiThemeId,
|
||||
uiLanguage,
|
||||
windowOpacity,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
showRecentHosts,
|
||||
@@ -1891,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
showHostTreeSidebar,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
showRecentHosts,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
showTabNumberBadges,
|
||||
terminalSettings.dynamicTabTitleMode,
|
||||
@@ -2063,7 +2080,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
showSftpTab,
|
||||
setTabBarPosition,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
setShowHostTreeSidebar,
|
||||
@@ -2139,7 +2158,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, tabBarPosition, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -32,6 +32,11 @@ export const useTrayPanelBackend = () => {
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const startPortForwardFromTrayPanel = useCallback(async (ruleId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.startPortForwardFromTrayPanel?.(ruleId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
@@ -71,6 +76,7 @@ export const useTrayPanelBackend = () => {
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
closeSessionFromTrayPanel,
|
||||
startPortForwardFromTrayPanel,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
|
||||
@@ -1963,3 +1963,21 @@ test("applySyncPayload applies pluginSidecars through the production applier hoo
|
||||
|
||||
assert.equal((applied as SyncPayload["pluginSidecars"])?.entries[0].value.clock, 3);
|
||||
});
|
||||
|
||||
test("tab bar position survives settings export and import, with a safe fallback", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
const payload = buildSyncPayload(vault([]));
|
||||
assert.equal(payload.settings?.tabBarPosition, "bottom");
|
||||
localStorage.removeItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION);
|
||||
await applySyncPayload(payload, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
|
||||
const invalid = { ...payload, settings: { tabBarPosition: "left" } } as unknown as SyncPayload;
|
||||
await applySyncPayload(invalid, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "top");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION, "bottom");
|
||||
await applySyncPayload({ ...payload, settings: {} }, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TAB_BAR_POSITION), "bottom");
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeTabBarPosition } from '../domain/tabBarPosition';
|
||||
/**
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
@@ -92,6 +93,7 @@ import {
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
@@ -315,6 +317,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_HOST_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_TAB_BAR_POSITION,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
@@ -542,6 +545,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
}
|
||||
const showOnlyUngroupedHostsInRoot = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
|
||||
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
|
||||
const tabBarPosition = localStorageAdapter.readString(STORAGE_KEY_TAB_BAR_POSITION);
|
||||
if (tabBarPosition != null) settings.tabBarPosition = normalizeTabBarPosition(tabBarPosition);
|
||||
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
|
||||
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
|
||||
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
|
||||
@@ -795,6 +800,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
settings.showOnlyUngroupedHostsInRoot,
|
||||
);
|
||||
}
|
||||
if (settings.tabBarPosition != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TAB_BAR_POSITION, normalizeTabBarPosition(settings.tabBarPosition));
|
||||
}
|
||||
if (settings.showSftpTab != null) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
isInlineTextAttachment,
|
||||
} from '../application/state/terminalSelectionAttachment';
|
||||
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import { useCodexConfigModel } from '../application/state/useCodexConfigModel';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
@@ -843,35 +843,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
[currentAgentConfig],
|
||||
);
|
||||
|
||||
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
|
||||
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
setCodexCustomConfigResolved(false);
|
||||
if (!isCodexManagedAgent) {
|
||||
setCodexConfigModel(null);
|
||||
return;
|
||||
}
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void Promise.resolve(
|
||||
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
|
||||
).then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
setCodexCustomConfigResolved(hasCustom);
|
||||
}).catch(() => {
|
||||
if (!cancelled) {
|
||||
setCodexConfigModel(null);
|
||||
setCodexCustomConfigResolved(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
|
||||
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
|
||||
currentAgentConfig, isVisible,
|
||||
);
|
||||
|
||||
const agentModelMapRef = useRef(agentModelMap);
|
||||
agentModelMapRef.current = agentModelMap;
|
||||
@@ -1035,18 +1009,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
|
||||
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
|
||||
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
|
||||
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
|
||||
|
||||
const agentModelPresets = useMemo(() => {
|
||||
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
|
||||
if (hasCodexCustomConfig) {
|
||||
if (runtimePresets) {
|
||||
return runtimePresets;
|
||||
}
|
||||
if (codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
return [];
|
||||
if (hasCodexCustomConfig && codexConfigModel) {
|
||||
return [{ id: codexConfigModel, name: codexConfigModel }];
|
||||
}
|
||||
if (runtimePresets) return runtimePresets;
|
||||
const presets = getAgentModelPresets(
|
||||
@@ -1236,8 +1204,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
if (sendBridge?.aiSyncWebSearch) {
|
||||
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
|
||||
}
|
||||
let sendSelectedAgentModel = selectedAgentModel;
|
||||
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
// A quick send must await the same config probe as the picker. Its model
|
||||
// also takes precedence over App Server's built-in model catalog.
|
||||
const configModel = await loadCodexConfigModel();
|
||||
let sendSelectedAgentModel = configModel ?? selectedAgentModel;
|
||||
if (!configModel && currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
|
||||
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
|
||||
if (runtimeTarget) {
|
||||
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
|
||||
@@ -1421,7 +1392,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions,
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isPortForwardingRuleStartable,
|
||||
isPortForwardingRuleStoppable,
|
||||
} from "../domain/portForwardingBulkActions";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../domain/portForwardingReconnect";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
@@ -207,7 +208,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
rule.autoStart, // Enable reconnect for auto-start rules
|
||||
isPortForwardingAutoReconnectEnabled(rule), // Enable reconnect for auto-reconnect rules (auto-start implies reconnect)
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
);
|
||||
@@ -516,6 +517,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
remoteHost: newFormDraft.remoteHost,
|
||||
remotePort: newFormDraft.remotePort,
|
||||
hostId: newFormDraft.hostId,
|
||||
autoStart: newFormDraft.autoStart ?? false,
|
||||
autoReconnect: isPortForwardingAutoReconnectEnabled(newFormDraft),
|
||||
});
|
||||
|
||||
setShowNewForm(false);
|
||||
@@ -714,6 +717,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
remoteHost: draftRule.remoteHost,
|
||||
remotePort: draftRule.remotePort,
|
||||
hostId: draftRule.hostId,
|
||||
autoStart: draftRule.autoStart ?? false,
|
||||
autoReconnect: isPortForwardingAutoReconnectEnabled(draftRule),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -545,6 +545,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setHostClickBehavior={settings.setHostClickBehavior}
|
||||
showOnlyUngroupedHostsInRoot={settings.showOnlyUngroupedHostsInRoot}
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
tabBarPosition={settings.tabBarPosition}
|
||||
setTabBarPosition={settings.setTabBarPosition}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
showHostTreeSidebar={settings.showHostTreeSidebar}
|
||||
|
||||
+23
-8
@@ -226,6 +226,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const { t } = useI18n();
|
||||
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
|
||||
const {
|
||||
tabBarPosition,
|
||||
hotkeyScheme,
|
||||
showTabNumberBadges,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
@@ -267,7 +268,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
const [hostTreeChromeReady, setHostTreeChromeReady] = useState(false);
|
||||
const [hostTreeGutterExiting, setHostTreeGutterExiting] = useState(false);
|
||||
const [rootTabsCompact, setRootTabsCompact] = useState(false);
|
||||
const showWindowControls = !isMacClient;
|
||||
const tabsAtBottom = tabBarPosition === 'bottom';
|
||||
const showWindowControls = !isMacClient && !tabsAtBottom;
|
||||
|
||||
// Tab reorder drag state
|
||||
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
|
||||
@@ -994,12 +996,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
}, [isMacClient, maximize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-tab-window-titlebar
|
||||
data-section="top-tabs"
|
||||
className={cn("h-9 shrink-0 items-end justify-end bg-secondary app-drag", tabsAtBottom ? "flex" : "hidden")}
|
||||
style={dragRegionNoSelect}
|
||||
onDoubleClick={handleTitleBarDoubleClick}
|
||||
>
|
||||
{!isMacClient && <WindowControls />}
|
||||
</div>
|
||||
<div
|
||||
data-top-tabs-root
|
||||
data-position={tabBarPosition}
|
||||
data-section="top-tabs"
|
||||
className="relative w-full bg-secondary app-drag"
|
||||
className="relative w-full shrink-0 bg-secondary app-drag"
|
||||
style={{
|
||||
...dragRegionNoSelect,
|
||||
order: tabsAtBottom ? 2 : undefined,
|
||||
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
|
||||
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
|
||||
}}
|
||||
@@ -1010,12 +1024,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState={updateScrollState}
|
||||
/>
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div className={cn("absolute inset-x-0 h-1 app-drag pointer-events-auto z-10", tabsAtBottom ? "bottom-0" : "top-0")} style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-9 flex items-end gap-0 app-drag overflow-visible"
|
||||
className={cn("h-9 flex gap-0 app-drag overflow-visible", tabsAtBottom ? "items-start" : "items-end")}
|
||||
style={{
|
||||
...dragRegionStyle,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12,
|
||||
paddingLeft: isMacClient && !isWindowFullscreen && !tabsAtBottom ? 76 : 12,
|
||||
paddingRight: showWindowControls ? 0 : 12,
|
||||
}}
|
||||
>
|
||||
@@ -1063,7 +1077,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{hasHostTreeToggleSurface && (
|
||||
<div
|
||||
ref={hostTreeToggleSlotRef}
|
||||
className="top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 self-end app-no-drag"
|
||||
className={cn("top-tab-host-tree-toggle-slot mb-0 flex-shrink-0 app-no-drag", tabsAtBottom ? "self-start" : "self-end")}
|
||||
data-section="top-tabs-host-tree-toggle"
|
||||
data-visible={effectiveShowHostTreeToggle ? 'true' : 'false'}
|
||||
style={noDragRegionStyle}
|
||||
@@ -1173,7 +1187,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
|
||||
className={cn("h-7 w-7 flex-shrink-0 app-no-drag rounded-none", tabsAtBottom ? "self-start" : "self-end")}
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenQuickSwitcher}
|
||||
>
|
||||
@@ -1187,7 +1201,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
|
||||
{/* Fixed right controls — utility icons + window controls share one h-7 row */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7 overflow-visible"
|
||||
className={cn("flex-shrink-0 flex items-center gap-0.5 app-drag h-7 overflow-visible", tabsAtBottom ? "self-start" : "self-end")}
|
||||
style={dragRegionStyle}
|
||||
data-section="top-tabs-toolbar-actions"
|
||||
>
|
||||
@@ -1262,6 +1276,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -10,10 +10,8 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import type { Host, KnownHost } from "../domain/models";
|
||||
import type { KnownHost } from "../domain/models";
|
||||
import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
|
||||
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
@@ -136,11 +134,7 @@ const WorkspaceGroup: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
interface TrayPanelContentProps {
|
||||
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
|
||||
}
|
||||
|
||||
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
@@ -148,27 +142,23 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
closeSessionFromTrayPanel,
|
||||
startPortForwardFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys, identities, proxyProfiles, groupConfigs, knownHosts, updateKnownHosts } = useVaultState();
|
||||
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
|
||||
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
|
||||
// independent from (and typically empty compared to) the main App's — it's
|
||||
// used here only for its storage-sync side effects, never for closeSession.
|
||||
useSessionState({ persistSessionRestore: false });
|
||||
const {
|
||||
rules: portForwardingRules,
|
||||
startTunnel,
|
||||
stopTunnel,
|
||||
hasRuntimeTunnel,
|
||||
} = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
const effectiveKnownHosts = useMemo(
|
||||
() => getEffectiveKnownHosts(knownHosts) ?? [],
|
||||
[knownHosts],
|
||||
@@ -444,16 +434,9 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
|
||||
if (!result.success && result.error) toast.error(result.error);
|
||||
});
|
||||
} else {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart, terminalSettings, effectiveKnownHosts);
|
||||
void startPortForwardFromTrayPanel(rule.id).then((result) => {
|
||||
if (result && !result.success && result.error) toast.error(result.error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
@@ -519,7 +502,7 @@ type SettingsState = AppLockGateRenderContext["settings"];
|
||||
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent terminalSettings={settings.terminalSettings} />
|
||||
<TrayPanelContent />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { ChevronDown,Copy,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host,PortForwardingRule } from '../../domain/models';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
@@ -182,6 +183,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Reconnect Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPortForwardingAutoReconnectEnabled(draft)}
|
||||
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { ChevronDown,Zap } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models';
|
||||
import { cn } from '../../lib/utils';
|
||||
@@ -182,6 +183,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
|
||||
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Reconnect Toggle */}
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
|
||||
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPortForwardingAutoReconnectEnabled(draft)}
|
||||
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
|
||||
/>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="space-y-2">
|
||||
<Button
|
||||
|
||||
@@ -645,6 +645,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
}, 900);
|
||||
}, []);
|
||||
|
||||
const codexManagedAgent = useMemo(
|
||||
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
|
||||
[externalAgents],
|
||||
);
|
||||
|
||||
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
@@ -655,7 +660,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const integration = await bridge.aiCodexGetIntegration(opts);
|
||||
const integration = await bridge.aiCodexGetIntegration({ ...opts, agentEnv: codexManagedAgent?.env });
|
||||
if (!isCurrentRequest()) return;
|
||||
setCodexIntegration(integration);
|
||||
} catch (err) {
|
||||
@@ -667,7 +672,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [codexManagedAgent?.env]);
|
||||
|
||||
const codexCommittedPath = useMemo(
|
||||
() => getManagedAgentCommandPath(externalAgents, "codex") || codexPathInfo?.path || undefined,
|
||||
@@ -682,10 +687,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
codexCommittedPath
|
||||
), [codexCommittedPath]);
|
||||
|
||||
const codexManagedAgent = useMemo(
|
||||
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
|
||||
[externalAgents],
|
||||
);
|
||||
const codexRuntime = codexManagedAgent?.codexRuntime ?? 'sdk';
|
||||
|
||||
const refreshCodexAppServerStatus = useCallback(async () => {
|
||||
@@ -861,7 +862,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride() });
|
||||
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!result.ok || !result.session) {
|
||||
throw new Error(result.error || "Failed to start Codex login");
|
||||
@@ -876,7 +877,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, [getCodexPathOverride]);
|
||||
}, [getCodexPathOverride, codexManagedAgent?.env]);
|
||||
|
||||
const handleCancelCodexLogin = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
@@ -912,7 +913,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setCodexError(null);
|
||||
setIsCodexLoading(true);
|
||||
try {
|
||||
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride() });
|
||||
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
|
||||
if (!isCurrentRequest()) return;
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to log out from Codex");
|
||||
@@ -928,7 +929,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
setIsCodexLoading(false);
|
||||
}
|
||||
}
|
||||
}, [getCodexPathOverride, refreshCodexIntegration]);
|
||||
}, [getCodexPathOverride, refreshCodexIntegration, codexManagedAgent?.env]);
|
||||
|
||||
const refreshUserSkillsStatus = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
|
||||
@@ -60,6 +60,8 @@ function SettingsAppearanceTab(props: {
|
||||
setHostClickBehavior: (behavior: "connect" | "select") => void;
|
||||
showOnlyUngroupedHostsInRoot: boolean;
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
tabBarPosition: 'top' | 'bottom';
|
||||
setTabBarPosition: (position: 'top' | 'bottom') => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
showHostTreeSidebar: boolean;
|
||||
@@ -129,6 +131,8 @@ function SettingsAppearanceTab(props: {
|
||||
setHostClickBehavior,
|
||||
showOnlyUngroupedHostsInRoot,
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
tabBarPosition,
|
||||
setTabBarPosition,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
showHostTreeSidebar,
|
||||
@@ -479,6 +483,29 @@ function SettingsAppearanceTab(props: {
|
||||
onChange={setShowOnlyUngroupedHostsInRoot}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-tab-bar-position"
|
||||
label={t('settings.appearance.tabBarPosition')}
|
||||
>
|
||||
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
|
||||
{(['top', 'bottom'] as const).map((position) => (
|
||||
<button
|
||||
key={position}
|
||||
type="button"
|
||||
aria-pressed={tabBarPosition === position}
|
||||
onClick={() => setTabBarPosition(position)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
|
||||
tabBarPosition === position
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t(`settings.appearance.tabBarPosition.${position}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="appearance-vault-show-sftp-tab"
|
||||
label={t('settings.vault.showSftpTab')}
|
||||
|
||||
@@ -152,11 +152,11 @@ export interface FetchBridge {
|
||||
export interface NetcattyAiBridge {
|
||||
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
|
||||
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }) => Promise<CodexIntegrationStatus>;
|
||||
aiCodexStartLogin?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
|
||||
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
|
||||
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
|
||||
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
|
||||
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTopTabsChromeRoots } from '../../application/app/topTabsChromeTheme';
|
||||
import React, { createContext, lazy, memo, Suspense, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
@@ -227,22 +228,22 @@ export const clearHostTreePreviewVars = () => {
|
||||
|
||||
export const clearTopTabsPreviewVars = () => {
|
||||
if (typeof document === 'undefined') return;
|
||||
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
|
||||
if (!tabsRoot) return;
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--background');
|
||||
removeStylePropertyIfSet(tabsRoot, '--foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--secondary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--border');
|
||||
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
|
||||
for (const tabsRoot of getTopTabsChromeRoots()) {
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-fg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-muted');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-active-bg');
|
||||
removeStylePropertyIfSet(tabsRoot, '--top-tabs-accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--background');
|
||||
removeStylePropertyIfSet(tabsRoot, '--foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent');
|
||||
removeStylePropertyIfSet(tabsRoot, '--accent-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--primary-foreground');
|
||||
removeStylePropertyIfSet(tabsRoot, '--secondary');
|
||||
removeStylePropertyIfSet(tabsRoot, '--border');
|
||||
removeStylePropertyIfSet(tabsRoot, '--muted-foreground');
|
||||
}
|
||||
};
|
||||
|
||||
export const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
|
||||
@@ -27,6 +27,9 @@ export interface PortForwardingRule {
|
||||
hostId?: string;
|
||||
// Auto-start: if true, this rule will automatically start when the app launches
|
||||
autoStart?: boolean;
|
||||
// Auto-reconnect: if true, the tunnel automatically reconnects (with bounded
|
||||
// retries) after an unexpected disconnect, regardless of auto-start
|
||||
autoReconnect?: boolean;
|
||||
/**
|
||||
* Runtime projection for the UI. Authoritative phase lives in the Electron
|
||||
* main-process registry; this field is rebuilt from snapshots / events and
|
||||
|
||||
@@ -23,6 +23,15 @@ const parsePort = (value: unknown, name: string): Result<number> => {
|
||||
: { ok: false, error: `${name} must be an integer between 1 and 65535.` };
|
||||
};
|
||||
|
||||
const parseToggle = (value: unknown, name: string): Result<boolean | undefined> => {
|
||||
if (value === undefined) return { ok: true, value: undefined };
|
||||
if (typeof value === 'boolean') return { ok: true, value };
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(normalized)) return { ok: true, value: true };
|
||||
if (['false', '0', 'no'].includes(normalized)) return { ok: true, value: false };
|
||||
return { ok: false, error: `${name} must be true or false.` };
|
||||
};
|
||||
|
||||
export const validatePortForwardingHost = (
|
||||
hosts: Host[],
|
||||
hostId: string | undefined,
|
||||
@@ -61,16 +70,12 @@ function buildRule(
|
||||
remotePort = parsedRemotePort.value;
|
||||
}
|
||||
if (!existing && !newRule) return { ok: false, error: 'New rule id and timestamp are required.' };
|
||||
let autoStart = existing?.autoStart ?? false;
|
||||
if (source.autoStart !== undefined) {
|
||||
if (typeof source.autoStart === 'boolean') autoStart = source.autoStart;
|
||||
else {
|
||||
const normalized = String(source.autoStart).trim().toLowerCase();
|
||||
if (['true', '1', 'yes'].includes(normalized)) autoStart = true;
|
||||
else if (['false', '0', 'no'].includes(normalized)) autoStart = false;
|
||||
else return { ok: false, error: 'autoStart must be true or false.' };
|
||||
}
|
||||
}
|
||||
const autoStartToggle = parseToggle(source.autoStart, 'autoStart');
|
||||
if ('error' in autoStartToggle) return autoStartToggle;
|
||||
const autoStart = autoStartToggle.value ?? existing?.autoStart ?? false;
|
||||
const autoReconnectToggle = parseToggle(source.autoReconnect, 'autoReconnect');
|
||||
if ('error' in autoReconnectToggle) return autoReconnectToggle;
|
||||
const autoReconnect = autoReconnectToggle.value ?? existing?.autoReconnect;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -82,6 +87,7 @@ function buildRule(
|
||||
...(type === 'dynamic' ? {} : { remoteHost, remotePort }),
|
||||
hostId,
|
||||
autoStart,
|
||||
...(autoReconnect === undefined ? {} : { autoReconnect }),
|
||||
status: existing?.status ?? 'inactive',
|
||||
error: existing?.error,
|
||||
lastUsedAt: existing?.lastUsedAt,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "./portForwardingReconnect";
|
||||
|
||||
describe("isPortForwardingAutoReconnectEnabled", () => {
|
||||
it("returns true when autoReconnect is enabled", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: true }), true);
|
||||
});
|
||||
|
||||
it("returns true for auto-start rules (pre-existing behavior)", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true }), true);
|
||||
});
|
||||
|
||||
it("honors explicit disable even for auto-start rules", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true, autoReconnect: false }), false);
|
||||
});
|
||||
|
||||
it("returns false when neither flag opts in", () => {
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({}), false);
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: false }), false);
|
||||
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: false }), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { PortForwardingRule } from "./models";
|
||||
|
||||
/**
|
||||
* Whether a port-forwarding rule should automatically reconnect (with bounded
|
||||
* retries) after an unexpected disconnect.
|
||||
*
|
||||
* Legacy rules without `autoReconnect` retain their auto-start reconnect
|
||||
* behavior. An explicit choice always takes precedence.
|
||||
*/
|
||||
export const isPortForwardingAutoReconnectEnabled = (
|
||||
rule: Pick<PortForwardingRule, "autoStart" | "autoReconnect">,
|
||||
): boolean => rule.autoReconnect ?? rule.autoStart === true;
|
||||
@@ -158,6 +158,12 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.vault.showOnlyUngroupedHostsInRootDesc",
|
||||
sectionKey: "settings.vault.title",
|
||||
},
|
||||
{
|
||||
id: "appearance-tab-bar-position",
|
||||
tab: "appearance",
|
||||
labelKey: "settings.appearance.tabBarPosition",
|
||||
sectionKey: "settings.vault.title",
|
||||
},
|
||||
{
|
||||
id: "appearance-vault-show-sftp-tab",
|
||||
tab: "appearance",
|
||||
|
||||
@@ -337,6 +337,7 @@ export interface SyncPayload {
|
||||
// Vault: root list shows only ungrouped hosts
|
||||
showOnlyUngroupedHostsInRoot?: boolean;
|
||||
// Top tabs: show standalone SFTP view tab
|
||||
tabBarPosition?: 'top' | 'bottom';
|
||||
showSftpTab?: boolean;
|
||||
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
|
||||
shellOnlyTabNumberShortcuts?: boolean;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type TabBarPosition = 'top' | 'bottom';
|
||||
|
||||
export function normalizeTabBarPosition(value: unknown): TabBarPosition {
|
||||
return value === 'bottom' ? 'bottom' : 'top';
|
||||
}
|
||||
@@ -262,9 +262,21 @@ function parseCodexConfigToml(text) {
|
||||
* because the config.toml is a strong signal the user doesn't want that.
|
||||
*/
|
||||
function readCodexCustomProviderConfig(shellEnv) {
|
||||
// Codex honors $CODEX_HOME and reads $CODEX_HOME/config.toml (see
|
||||
// codexAppServer/protocol.schema.json), so prefer it over the legacy
|
||||
// ~/.codex location — otherwise a custom provider configured under
|
||||
// CODEX_HOME would be missed (or an unrelated default-home config could
|
||||
// shadow what the CLI actually runs).
|
||||
const codexHome = typeof shellEnv?.CODEX_HOME === "string" ? shellEnv.CODEX_HOME.trim() : "";
|
||||
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
|
||||
if (codexHome) {
|
||||
return readCodexCustomProviderConfigFrom(path.join(codexHome, "config.toml"), shellEnv);
|
||||
}
|
||||
if (!home) return null;
|
||||
const configPath = path.join(home, ".codex", "config.toml");
|
||||
return readCodexCustomProviderConfigFrom(path.join(home, ".codex", "config.toml"), shellEnv);
|
||||
}
|
||||
|
||||
function readCodexCustomProviderConfigFrom(configPath, shellEnv) {
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
let text;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
MAX_CODEX_LOGIN_OUTPUT_CHARS,
|
||||
@@ -13,6 +16,7 @@ const {
|
||||
extractCodexError,
|
||||
isCodexAuthError,
|
||||
normalizeCodexIntegrationState,
|
||||
readCodexCustomProviderConfig,
|
||||
recordCodexLoginSession,
|
||||
stopCodexLoginProcess,
|
||||
} = require("./codexHelpers.cjs");
|
||||
@@ -177,3 +181,97 @@ test("extractCodexError handles circular structured errors", () => {
|
||||
|
||||
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
|
||||
});
|
||||
|
||||
test("readCodexCustomProviderConfig parses a switcher-style third-party config.toml", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".codex", "config.toml"),
|
||||
[
|
||||
'model_provider = "ccs"',
|
||||
'model = "glm-5"',
|
||||
"",
|
||||
"[model_providers.ccs]",
|
||||
'name = "Coding Plan"',
|
||||
'base_url = "https://example.invalid/v1"',
|
||||
'wire_api = "responses"',
|
||||
'env_key = "CODING_PLAN_API_KEY"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = readCodexCustomProviderConfig({
|
||||
HOME: tempDir,
|
||||
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
|
||||
});
|
||||
|
||||
assert.equal(config?.providerName, "ccs");
|
||||
assert.equal(config?.displayName, "Coding Plan");
|
||||
assert.equal(config?.baseUrl, "https://example.invalid/v1");
|
||||
assert.equal(config?.model, "glm-5");
|
||||
assert.equal(config?.envKey, "CODING_PLAN_API_KEY");
|
||||
assert.equal(config?.envKeyPresent, true);
|
||||
});
|
||||
|
||||
test("readCodexCustomProviderConfig returns null for the default openai provider", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".codex", "config.toml"),
|
||||
'model_provider = "openai"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.equal(readCodexCustomProviderConfig({ HOME: tempDir }), null);
|
||||
});
|
||||
|
||||
test("readCodexCustomProviderConfig honors CODEX_HOME over ~/.codex", (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-home-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// An unrelated config in the default home must be ignored when CODEX_HOME
|
||||
// points elsewhere.
|
||||
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, ".codex", "config.toml"),
|
||||
'model_provider = "stale"\n\n[model_providers.stale]\nname = "Stale"\n',
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const codexHome = path.join(tempDir, "custom-home");
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(codexHome, "config.toml"),
|
||||
[
|
||||
'model_provider = "ccs"',
|
||||
'model = "glm-5"',
|
||||
"",
|
||||
"[model_providers.ccs]",
|
||||
'name = "Coding Plan"',
|
||||
'base_url = "https://example.invalid/v1"',
|
||||
'env_key = "CODING_PLAN_API_KEY"',
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const config = readCodexCustomProviderConfig({
|
||||
HOME: tempDir,
|
||||
CODEX_HOME: codexHome,
|
||||
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
|
||||
});
|
||||
|
||||
assert.equal(config?.providerName, "ccs");
|
||||
assert.equal(config?.model, "glm-5");
|
||||
});
|
||||
@@ -14,9 +14,9 @@ function buildLiveShellProbe(marker) {
|
||||
const { dispatcher } = bashHistoryScratchNames(marker);
|
||||
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
// Continuation lines stay within canonical input limits. Each echo carries
|
||||
// the marker so the renderer also hides continuation prompts.
|
||||
return ` true ${marker}; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
// Start display suppression in the PTY before any continuation is read,
|
||||
// independently of PS2 and echo mode. Keep every later physical line short.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -34,19 +34,27 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const pty = new EventEmitter();
|
||||
let job;
|
||||
let probing = true;
|
||||
let pendingInput = '';
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
if (data === "\x03") return;
|
||||
pendingInput += String(data);
|
||||
if (!pendingInput.endsWith('\n')) return;
|
||||
if (!pendingInput.endsWith('\n') || pendingInput.endsWith('\\\n')) return;
|
||||
if (probing) {
|
||||
// Wait for the complete probe, including paced chunks and continuations.
|
||||
if (!pendingInput.includes("printf '%s'")) return;
|
||||
pendingInput = '';
|
||||
probing = false;
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`));
|
||||
return;
|
||||
}
|
||||
if (!pendingInput.includes(`${job.marker}_E:`)) return;
|
||||
const script = pendingInput.replace(/^\x0b\x15/, '');
|
||||
pendingInput = '';
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
|
||||
};
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 3000 });
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /no-newline-output/);
|
||||
|
||||
@@ -418,7 +418,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") {
|
||||
if (!foundStart && typeof onProbeAborted === "function") {
|
||||
try {
|
||||
onProbeAborted(marker);
|
||||
} catch {
|
||||
|
||||
@@ -902,16 +902,15 @@ test("posix wrapper isolates set -e failures from the active shell", () => {
|
||||
assert.doesNotMatch(result.stdout, /SHOULD_NOT_PRINT/);
|
||||
});
|
||||
|
||||
test("posix wrapper types multi-line commands as one physical line (no PS2 leak) and preserves semantics", () => {
|
||||
test("posix wrapper marks every continuation and preserves multi-line command semantics", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand(
|
||||
"echo first\necho \"it's quoted\"\n\necho last",
|
||||
"posix",
|
||||
marker,
|
||||
);
|
||||
// A single physical line: the interactive shell must never show PS2
|
||||
// ("> ") continuation echoes, which would leak past the preload filter.
|
||||
assert.equal(wrapped.indexOf("\n"), wrapped.length - 1);
|
||||
// Every continuation carries the marker so its PS2 echo stays hidden.
|
||||
assert.ok(wrapped.trimEnd().split("\n").every(line => line.includes(marker)));
|
||||
|
||||
const result = spawnSync("sh", ["-c", wrapped], { encoding: "utf8" });
|
||||
assert.equal(result.error, undefined);
|
||||
@@ -926,10 +925,10 @@ test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on b
|
||||
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
|
||||
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
|
||||
const wrapped = buildWrappedCommand(command, "posix", marker, true);
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') <= 480));
|
||||
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
assert.equal(result.stdout, `\n${marker}_I\n\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
});
|
||||
|
||||
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
|
||||
@@ -1377,6 +1376,22 @@ test("posix wrapper avoids history expansion in interactive zsh", (t) => {
|
||||
assert.doesNotMatch(result.stderr, /event not found/);
|
||||
});
|
||||
|
||||
|
||||
test("cancelled POSIX input without a live probe resets display suppression", async () => {
|
||||
const pty = new EventEmitter();
|
||||
const resets = [];
|
||||
pty.write = () => {};
|
||||
const job = startPtyJob(pty, "echo should-not-start", {
|
||||
shellKind: "posix", probeLiveShell: false, timeoutMs: 1000,
|
||||
expectedPrompt: "ready$ ", onProbeAborted: marker => resets.push(marker),
|
||||
});
|
||||
pty.emit("data", `${job.marker}_I\nCUSTOM> `);
|
||||
job.cancel();
|
||||
pty.emit("data", "\nready$ ");
|
||||
assert.equal((await job.resultPromise).error, "Cancelled");
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
});
|
||||
|
||||
test("startPtyJob types the wrapper one code point per write for strict bastions (#3146)", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes = [];
|
||||
|
||||
@@ -213,8 +213,20 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
// Invocation-specific scratch names avoid readonly user variables. Clear the
|
||||
// history-bearing scratch through the verified dispatcher, never plain unset.
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
|
||||
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
|
||||
const previous = `${entry}_prev`;
|
||||
const unsetNames = `${entry} ${previous}${keepDispatcher ? '' : ` ${dispatcher}`}`;
|
||||
// With cmdhist disabled each physical line is a separate history entry.
|
||||
// Keep deleting marked tail entries only while their numbers decrease; a
|
||||
// shadowed history function that makes no progress must fall back, not loop.
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;`,
|
||||
`${entry}=$($${dispatcher} history 1);while :;do case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};${previous}=\${${entry}%%[[:space:]]*};$${dispatcher} history -d "$${previous}";`,
|
||||
`${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};[ "\${${entry}%%[[:space:]]*}" -lt "$${previous}" ]||break;;*) break;;esac;;*) break;;esac;done;`,
|
||||
`case "$${entry}" in *${marker}*) continue;;esac;`,
|
||||
`$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
@@ -223,9 +235,10 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
let cmdAssign = commandLines.length > 1
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
|
||||
// Canonical PTYs limit bytes per physical input line, regardless of write
|
||||
// pacing. Emit bounded quoted pieces inside one command substitution; each
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 350) {
|
||||
// Reserve room below BusyBox's 512-byte editor limit for the assignment,
|
||||
// marker and continuation syntax. Write pacing cannot bypass this limit.
|
||||
// Emit bounded quoted pieces inside one command substitution; each
|
||||
// continuation keeps the marker visible to the terminal echo filter.
|
||||
const writes = [];
|
||||
for (const [index, line] of commandLines.entries()) {
|
||||
@@ -234,7 +247,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
for (const character of line) {
|
||||
const quoted = escapePosixSingleQuoted(character);
|
||||
const size = Buffer.byteLength(quoted, 'utf8');
|
||||
if (bytes + size > 512) {
|
||||
if (bytes + size > 300) {
|
||||
writes.push(`printf '%s' '${chunk}'`);
|
||||
chunk = '';
|
||||
bytes = 0;
|
||||
@@ -247,16 +260,23 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
||||
}
|
||||
const historyCleanup = buildBashHistoryCleanup(marker);
|
||||
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
|
||||
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
|
||||
const separator = prefix.length + suffix.length + 2 > 1000
|
||||
? `; \\\n: '${marker}'; ` : "; ";
|
||||
return `${prefix}${separator}${suffix}`;
|
||||
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
|
||||
// every physical line below that limit, even for short user commands.
|
||||
// Emit a short standalone input marker before the shell reads any PS2
|
||||
// continuation. A leading newline separates it from concurrent PTY echo.
|
||||
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
|
||||
` : '${marker}'; ${cmdAssign}`,
|
||||
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
|
||||
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
|
||||
historyCleanup,
|
||||
`(exit $__NCMCP_rc); }`,
|
||||
].join(`; \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
// A live probe leaves its completion marker unterminated to hide the next
|
||||
// prompt. With terminal echo disabled, only the wrapper can end that line.
|
||||
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
|
||||
// marker line; keep the fish behavior controlled by separateStartMarker.
|
||||
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
@@ -332,7 +352,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
|
||||
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
||||
// Without that config the prefix is harmless; it just doesn't
|
||||
// suppress history recording.
|
||||
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
|
||||
for (const [customization, listHistory] of [
|
||||
[':', 'command builtin history'],
|
||||
['history() { builtin history "$@" | cat; }', 'command builtin history'],
|
||||
['command() { :; }', '\\builtin history'],
|
||||
]) {
|
||||
for (const probe of [false, true]) {
|
||||
test(`separate Bash history lines are removed (${customization}, probe=${probe})`, () => {
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nshopt -u cmdhist\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\nprintf "\\n"\n' + listHistory + '\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const probe of [false, true]) {
|
||||
test(`zsh ignores both logical input commands with HIST_IGNORE_SPACE (probe=${probe})`, (t) => {
|
||||
const input = 'HISTFILE=/dev/null; HISTSIZE=100; setopt HIST_IGNORE_SPACE\necho user_one\n'
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\necho user_two\nfc -l 1\nexit\n';
|
||||
const result = spawnSync('zsh', ['-f', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
if (result.error?.code === 'ENOENT') {
|
||||
t.skip('zsh is not installed');
|
||||
return;
|
||||
}
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { execViaPty } = require('./ptyExec.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
const reportedCommand = "head -5 /etc/openwrt_release 2>/dev/null; echo '---'; uname -a";
|
||||
const literal = "quote' \\ $HOME `false` " + '\u4e2d\u6587\ud83d\ude42'.repeat(100);
|
||||
const cases = [
|
||||
[reportedCommand, /DISTRIB_RELEASE='24\.10\.6'/, 0],
|
||||
["printf '%s' \"it's quoted\"", "it's quoted", 0],
|
||||
[`cat <<'END_LITERAL'\n${literal}\n\nlast\nEND_LITERAL`, `${literal}\n\nlast`, 0],
|
||||
["printf '%s' '" + 'x'.repeat(340) + "'", 'x'.repeat(340), 0],
|
||||
['printf failed; exit 7', 'failed', 7],
|
||||
];
|
||||
|
||||
test('OpenWrt input lines fit its 512-byte editor, including probe and cleanup', () => {
|
||||
for (const source of [buildLiveShellProbe(marker), ...cases.map(([cmd]) => buildWrappedCommand(cmd, 'posix', marker, true))]) {
|
||||
for (const line of source.trimEnd().split('\n')) {
|
||||
assert.ok(Buffer.byteLength(line) <= 480, `input line has ${Buffer.byteLength(line)} bytes: ${line}`);
|
||||
assert.ok(line.includes(marker), 'each continuation must remain hidden by the terminal echo filter');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Point at a disposable OpenWrt 24.10.6 Dropbear instance with blank root
|
||||
// password enabled (-B). No existing router configuration is read or modified.
|
||||
test('real OpenWrt Dropbear PTY executes commands with and without the live probe', {
|
||||
skip: !process.env.NETCATTY_OPENWRT_SSH_PORT,
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { Client } = require('ssh2');
|
||||
const client = new Client();
|
||||
await new Promise((resolve, reject) => client.once('ready', resolve).once('error', reject).connect({
|
||||
host: '127.0.0.1', port: Number(process.env.NETCATTY_OPENWRT_SSH_PORT), username: 'root', password: '',
|
||||
}));
|
||||
try {
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
const stream = await new Promise((resolve, reject) => client.shell({ term: 'xterm', cols: 240, rows: 30 }, (error, value) => error ? reject(error) : resolve(value)));
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timer = setTimeout(() => reject(new Error('OpenWrt prompt missing')), 5000);
|
||||
const onData = data => {
|
||||
output += data;
|
||||
if (output.includes(':~# ')) {
|
||||
clearTimeout(timer);
|
||||
stream.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
stream.on('data', onData);
|
||||
});
|
||||
for (const [command, expected, exitCode] of cases) {
|
||||
const result = await execViaPty(stream, command, {
|
||||
shellKind: 'posix', probeLiveShell, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(result.exitCode, exitCode, JSON.stringify({ probeLiveShell, result }));
|
||||
if (typeof expected === 'string') assert.equal(result.stdout, expected);
|
||||
else assert.match(result.stdout, expected);
|
||||
}
|
||||
} finally { stream.close(); }
|
||||
}
|
||||
} finally { client.end(); }
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const { registerProviderHandlers } = require("./aiBridge/providerHandlers.cjs"),
|
||||
const { createVaultAgentBridge } = require("./aiBridge/vaultAgentBridge.cjs");
|
||||
const { registerAgentDiscoveryHandlers } = require("./aiBridge/agentDiscoveryHandlers.cjs"), { registerAgentProcessHandlers } = require("./aiBridge/agentProcessHandlers.cjs"), { registerSdkStreamHandlers } = require("./aiBridge/sdk/sdkStreamHandlers.cjs");
|
||||
const { probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth } = require("./aiBridge/agentAuthProbes.cjs");
|
||||
const { buildSdkAgentEnv } = require("./aiBridge/sdk/env.cjs");
|
||||
|
||||
// ── Extracted modules ──
|
||||
const {
|
||||
@@ -946,6 +947,7 @@ function createHandlerContext(ipcMain) {
|
||||
killTrackedProcessTree,
|
||||
safeSend,
|
||||
withCliDiscoveryEnv,
|
||||
buildSdkAgentEnv,
|
||||
validateSender,
|
||||
validateSenderOrSettings,
|
||||
envPairsToObject,
|
||||
|
||||
@@ -109,6 +109,9 @@ function loadBridgeWithMocks(options = {}) {
|
||||
"./ai/codexHelpers.cjs": {
|
||||
codexLoginSessions: new Map(),
|
||||
appendCodexLoginOutput() {},
|
||||
createCodexLoginOutputDecoder: () => ({ write() {}, end() {} }),
|
||||
clearCodexLoginKillTimer() {},
|
||||
recordCodexLoginSession: (session) => options.recordCodexLoginSession?.(session),
|
||||
toCodexLoginSessionResponse: (session) => ({ sessionId: session.id, codexPath: session.codexPath }),
|
||||
getActiveCodexLoginSession: () =>
|
||||
typeof options.getActiveCodexLoginSession === "function"
|
||||
@@ -120,7 +123,10 @@ function loadBridgeWithMocks(options = {}) {
|
||||
: realNormalizeCodexIntegrationState(...args),
|
||||
appendCodexChatGptValidationFailure: (rawOutput, validationError) =>
|
||||
`${rawOutput}\n\nChatGPT auth validation failed:\n${validationError}`.trim(),
|
||||
readCodexCustomProviderConfig: () => null,
|
||||
readCodexCustomProviderConfig: (...args) =>
|
||||
typeof options.readCodexCustomProviderConfig === "function"
|
||||
? options.readCodexCustomProviderConfig(...args)
|
||||
: null,
|
||||
getCodexCustomConfigPreflightError: () => null,
|
||||
extractCodexError: (err) => ({ message: err?.message || String(err) }),
|
||||
isCodexAuthError: (...args) =>
|
||||
@@ -971,6 +977,84 @@ test("codex login does not reuse an active session from a different resolved pat
|
||||
}
|
||||
});
|
||||
|
||||
for (const homeVariable of ["HOME", "CODEX_HOME"]) {
|
||||
for (const sameHome of [true, false]) {
|
||||
test(`codex login ${sameHome ? "reuses" : "rejects"} an active session with ${sameHome ? "matching" : "changed"} ${homeVariable}`, async () => {
|
||||
const originalHome = path.join(os.tmpdir(), "codex-login-original");
|
||||
const requestedHome = sameHome ? originalHome : path.join(os.tmpdir(), "codex-login-other");
|
||||
const existingSession = {
|
||||
id: "codex_login_existing",
|
||||
state: "running",
|
||||
process: { killed: false },
|
||||
codexPath: "/fixture/codex",
|
||||
credentialHomeKey: homeVariable === "HOME" ? path.join(originalHome, ".codex") : originalHome,
|
||||
};
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
shellEnv: { HOME: originalHome },
|
||||
resolveCliFromPathAsync: () => existingSession.codexPath,
|
||||
getActiveCodexLoginSession: () => existingSession,
|
||||
prepareCommandForSpawn: () => { throw new Error("An active login must not spawn another process"); },
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
try {
|
||||
const result = await ipcMain.handlers.get("netcatty:ai:codex:start-login")(
|
||||
{ sender: { id: 1 } },
|
||||
{ agentEnv: { [homeVariable]: requestedHome } },
|
||||
);
|
||||
assert.equal(result.ok, sameHome, JSON.stringify(result));
|
||||
if (sameHome) {
|
||||
assert.equal(result.session.sessionId, existingSession.id);
|
||||
} else {
|
||||
assert.match(result.error, /different credential home/);
|
||||
assert.equal(result.session, undefined);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const action of ["start-login", "logout"]) {
|
||||
test(`codex ${action} child processes use the requested agent home`, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-agent-home-"));
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||||
const codexPath = path.join(tempDir, "codex.cjs");
|
||||
const observedPath = path.join(tempDir, "observed.jsonl");
|
||||
fs.writeFileSync(codexPath, `
|
||||
require('node:fs').appendFileSync(${JSON.stringify(observedPath)}, JSON.stringify({
|
||||
args: process.argv.slice(2), HOME: process.env.HOME, CODEX_HOME: process.env.CODEX_HOME,
|
||||
}) + '\\n');
|
||||
console.log('Not logged in');
|
||||
`);
|
||||
const agentEnv = { HOME: path.join(tempDir, "agent-home"), CODEX_HOME: path.join(tempDir, "agent-codex") };
|
||||
let finishLogin;
|
||||
const loginFinished = new Promise((resolve) => { finishLogin = resolve; });
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
shellEnv: { HOME: path.join(tempDir, "default-home"), CODEX_HOME: path.join(tempDir, "default-codex") },
|
||||
prepareCommandForSpawn: (command, args) => ({ command: process.execPath, args: [command, ...args], shell: false }),
|
||||
recordCodexLoginSession: (session) => {
|
||||
if (session.state !== "running") finishLogin(session.state);
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||||
bridge.registerHandlers(ipcMain);
|
||||
try {
|
||||
const result = await ipcMain.handlers.get(`netcatty:ai:codex:${action}`)({ sender: { id: 1 } }, { codexPath, agentEnv });
|
||||
assert.equal(result.ok, true, JSON.stringify(result));
|
||||
if (action === "start-login") assert.equal(await loginFinished, "success");
|
||||
const observed = fs.readFileSync(observedPath, "utf8").trim().split("\n").map((line) => JSON.parse(line));
|
||||
assert.deepEqual(observed, (action === "start-login" ? [["login"]] : [["logout"], ["login", "status"]])
|
||||
.map((args) => ({ args, ...agentEnv })));
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("codex integration keeps ChatGPT connected when the SDK validation probe fails", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||||
t.after(() => {
|
||||
@@ -1014,6 +1098,107 @@ test("codex integration keeps ChatGPT connected when the SDK validation probe fa
|
||||
}
|
||||
});
|
||||
|
||||
test("codex integration surfaces config.toml provider even when auth.json reports an API-key login", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, "codex");
|
||||
fs.writeFileSync(
|
||||
codexPath,
|
||||
`#!${process.execPath}\nconsole.log('Logged in using an API key');\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const customConfig = {
|
||||
providerName: "ccs",
|
||||
displayName: "Coding Plan",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
envKey: null,
|
||||
envKeyPresent: false,
|
||||
hasHardcodedApiKey: true,
|
||||
model: "glm-5",
|
||||
authHash: "hash",
|
||||
};
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shellEnv: { HOME: tempDir },
|
||||
readCodexCustomProviderConfig: () => customConfig,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
|
||||
const result = await handler({ sender: { id: 1 } }, { codexPath });
|
||||
|
||||
assert.equal(result.state, "connected_custom_config", JSON.stringify(result));
|
||||
assert.equal(result.isConnected, true);
|
||||
assert.equal(result.customConfig?.model, "glm-5");
|
||||
assert.equal(result.customConfig?.providerName, "ccs");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("codex integration keeps a validated ChatGPT login visible but still returns config.toml provider", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, "codex");
|
||||
fs.writeFileSync(
|
||||
codexPath,
|
||||
`#!${process.execPath}\nconsole.log('Logged in using ChatGPT');\n`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
|
||||
const customConfig = {
|
||||
providerName: "ccs",
|
||||
displayName: "Coding Plan",
|
||||
baseUrl: null,
|
||||
envKey: null,
|
||||
envKeyPresent: false,
|
||||
hasHardcodedApiKey: true,
|
||||
model: "glm-5",
|
||||
authHash: "hash",
|
||||
};
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
normalizeCliPathForPlatform: (value) => value,
|
||||
shellEnv: { HOME: tempDir },
|
||||
readCodexCustomProviderConfig: () => customConfig,
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
|
||||
const result = await handler({ sender: { id: 1 } }, { codexPath });
|
||||
|
||||
assert.equal(result.state, "connected_chatgpt", JSON.stringify(result));
|
||||
assert.equal(result.isConnected, true);
|
||||
assert.equal(result.customConfig?.model, "glm-5");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
|
||||
t.after(() => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { createHash } = require("node:crypto");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
|
||||
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
|
||||
@@ -8,6 +9,7 @@ const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
|
||||
function createAgentCliHelpers(ctx) {
|
||||
with (ctx) {
|
||||
const codexAuthValidationInFlight = new Map();
|
||||
let codexValidationKey = null;
|
||||
async function runCommand(command, args, options) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
@@ -143,9 +145,15 @@ function createAgentCliHelpers(ctx) {
|
||||
throw new Error(`Codex CLI path not found: ${requestedPath}`);
|
||||
}
|
||||
const codexCliPath = configuredPath || await resolveCliFromPathAsync("codex", shellEnv) || "codex";
|
||||
// Callers probing on behalf of a specific agent pass `options.env` (the
|
||||
// merged shell + agent env, as built for SDK spawns) so CODEX_HOME/HOME
|
||||
// overrides select the same config the agent turn will use.
|
||||
const env = options?.env && typeof options.env === "object" && Object.keys(options.env).length > 0
|
||||
? options.env
|
||||
: shellEnv;
|
||||
return await runCommand(codexCliPath, args, {
|
||||
cwd: options?.cwd?.trim() || undefined,
|
||||
env: shellEnv,
|
||||
env,
|
||||
timeoutMs: Number.isFinite(options?.timeoutMs)
|
||||
? Number(options.timeoutMs)
|
||||
: DEFAULT_CODEX_CLI_TIMEOUT_MS,
|
||||
@@ -166,10 +174,21 @@ function createAgentCliHelpers(ctx) {
|
||||
}
|
||||
|
||||
async function validateCodexChatGptAuth(options) {
|
||||
const shellEnv = options?.env || await getShellEnv();
|
||||
// Hash sorted entries so equivalent env objects share probes without
|
||||
// retaining raw credentials in cache keys.
|
||||
const envKey = createHash("sha256")
|
||||
.update(JSON.stringify(Object.entries(shellEnv).sort(([a], [b]) => a.localeCompare(b))))
|
||||
.digest("hex");
|
||||
const maxAgeMs = options?.maxAgeMs ?? 30000;
|
||||
const now = Date.now();
|
||||
const rawRequestedCodexPath = String(options?.codexPath || "").trim();
|
||||
const requestedCodexPath = rawRequestedCodexPath ? normalizeCliPathForPlatform?.(rawRequestedCodexPath) : null;
|
||||
const validationKey = JSON.stringify([requestedCodexPath, envKey]);
|
||||
const cacheValidation = (result) => {
|
||||
codexValidationKey = validationKey;
|
||||
setCodexValidationCache(result);
|
||||
};
|
||||
if (rawRequestedCodexPath && !requestedCodexPath) {
|
||||
const result = {
|
||||
ok: false,
|
||||
@@ -178,24 +197,22 @@ function createAgentCliHelpers(ctx) {
|
||||
error: `Codex CLI path not found: ${rawRequestedCodexPath}`,
|
||||
code: "ENOENT",
|
||||
};
|
||||
setCodexValidationCache(result);
|
||||
return result;
|
||||
}
|
||||
const cached = getCodexValidationCache();
|
||||
if (cached && now - cached.checkedAt < maxAgeMs && (cached.codexPath || null) === requestedCodexPath) return cached;
|
||||
const inFlightKey = requestedCodexPath || "__auto__";
|
||||
if (codexValidationKey === validationKey && cached && now - cached.checkedAt < maxAgeMs) return cached;
|
||||
const inFlightKey = validationKey;
|
||||
const existingValidation = codexAuthValidationInFlight.get(inFlightKey);
|
||||
if (existingValidation) return existingValidation;
|
||||
|
||||
const validationPromise = (async () => {
|
||||
const shellEnv = await getShellEnv();
|
||||
const rawCodexPath = requestedCodexPath || await resolveSdkBinPathAsync("codex", shellEnv);
|
||||
const codexPath = rawCodexPath && typeof resolveCodexExecutableForSdk === "function"
|
||||
? resolveCodexExecutableForSdk(rawCodexPath) || null
|
||||
: rawCodexPath;
|
||||
if (!codexPath) {
|
||||
const result = { ok: false, checkedAt: now, codexPath: requestedCodexPath, error: "codex binary not found", code: "ENOENT" };
|
||||
setCodexValidationCache(result);
|
||||
cacheValidation(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -244,12 +261,12 @@ function createAgentCliHelpers(ctx) {
|
||||
|
||||
await Promise.race([probePromise, timeoutPromise]);
|
||||
const result = { ok: true, checkedAt: now, codexPath, error: null };
|
||||
setCodexValidationCache(result);
|
||||
cacheValidation(result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalized = extractCodexError(error);
|
||||
const result = { ok: false, checkedAt: now, codexPath, error: normalized.message, code: normalized.code };
|
||||
setCodexValidationCache(result);
|
||||
cacheValidation(result);
|
||||
return result;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
@@ -125,7 +125,7 @@ test("runCodexCli omits an incomplete UTF-8 suffix at its byte limit", async ()
|
||||
assert.doesNotMatch(result.stdout, /�/u);
|
||||
});
|
||||
|
||||
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
|
||||
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout, ...overrides }) {
|
||||
return createAgentCliHelpers({
|
||||
getCodexValidationCache: () => null,
|
||||
setCodexValidationCache() {},
|
||||
@@ -138,6 +138,147 @@ function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
|
||||
loadCodexSdk,
|
||||
...(setTimeout ? { setTimeout } : {}),
|
||||
...(clearTimeout ? { clearTimeout } : {}),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("agent auth validation does not reuse results from a different environment", async () => {
|
||||
const defaultEnv = { HOME: "/default-home" };
|
||||
const agentEnv = { HOME: "/agent-home", CODEX_HOME: "/agent-codex", CUSTOM_KEY: "test-value" };
|
||||
const defaultFailure = { ok: false, checkedAt: Date.now(), codexPath: "/fake/codex", error: "default login expired" };
|
||||
let cached = defaultFailure;
|
||||
const receivedEnvs = [];
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => defaultEnv,
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
constructor(options) { receivedEnvs.push(options.env); }
|
||||
startThread() {
|
||||
return { async runStreamed() {
|
||||
return { events: (async function* () { yield { type: "turn.completed" }; })() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: agentEnv });
|
||||
assert.equal(agentResult.ok, true);
|
||||
assert.deepEqual(receivedEnvs, [agentEnv]);
|
||||
assert.equal(cached, agentResult);
|
||||
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
assert.equal(defaultResult.ok, true);
|
||||
assert.deepEqual(receivedEnvs, [agentEnv, defaultEnv]);
|
||||
assert.equal(cached, defaultResult);
|
||||
});
|
||||
|
||||
test("agent auth failure cannot become the default environment's cached result", async () => {
|
||||
let cached = null;
|
||||
const receivedHomes = [];
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => ({ HOME: "/default-home" }),
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
constructor(options) { this.home = options.env.HOME; receivedHomes.push(this.home); }
|
||||
startThread() {
|
||||
const home = this.home;
|
||||
return { async runStreamed() {
|
||||
if (home === "/agent-home") throw new Error("agent login expired");
|
||||
return { events: (async function* () { yield { type: "turn.completed" }; })() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: { HOME: "/agent-home" } });
|
||||
assert.equal(agentResult.ok, false);
|
||||
assert.equal(cached, agentResult);
|
||||
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
|
||||
assert.equal(defaultResult.ok, true);
|
||||
assert.deepEqual(receivedHomes, ["/agent-home", "/default-home"]);
|
||||
assert.equal(cached, defaultResult);
|
||||
});
|
||||
|
||||
test("an invalid explicit CLI path does not poison automatic discovery auth validation", async () => {
|
||||
let cached = null;
|
||||
let probeCount = 0;
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => ({ HOME: "/agent-home" }),
|
||||
normalizeCliPathForPlatform: () => null,
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
startThread() {
|
||||
probeCount += 1;
|
||||
return { async runStreamed() {
|
||||
return { events: (async function* () { yield { type: "turn.completed" }; })() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const invalid = await helpers.validateCodexChatGptAuth({ codexPath: "/missing/codex" });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.code, "ENOENT");
|
||||
assert.equal(cached, null);
|
||||
assert.equal(probeCount, 0);
|
||||
const discovered = await helpers.validateCodexChatGptAuth({});
|
||||
assert.equal(discovered.ok, true);
|
||||
assert.equal(discovered.codexPath, "/fake/codex");
|
||||
assert.equal(probeCount, 1);
|
||||
assert.equal(await helpers.validateCodexChatGptAuth({}), discovered);
|
||||
assert.equal(probeCount, 1);
|
||||
});
|
||||
|
||||
for (const codexPath of ["/fake/codex", undefined]) {
|
||||
test(`equivalent auth environments coalesce and cache with ${codexPath ? "explicit" : "discovered"} CLI path; credential rotation probes again`, async () => {
|
||||
const defaultEnv = { HOME: "/agent-home", CUSTOM_KEY: "old-test-key" };
|
||||
let cached = null;
|
||||
let runCount = 0;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const helpers = createValidationHelpers({
|
||||
getShellEnv: async () => ({ ...defaultEnv }),
|
||||
getCodexValidationCache: () => cached,
|
||||
setCodexValidationCache: (result) => { cached = result; },
|
||||
loadCodexSdk: async () => ({
|
||||
Codex: class {
|
||||
startThread() {
|
||||
return { async runStreamed() {
|
||||
runCount += 1;
|
||||
return { events: (async function* () {
|
||||
await gate;
|
||||
yield { type: "turn.completed" };
|
||||
})() };
|
||||
} };
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const options = { codexPath };
|
||||
const first = helpers.validateCodexChatGptAuth({ ...options, env: defaultEnv });
|
||||
const second = helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } });
|
||||
const third = helpers.validateCodexChatGptAuth(options);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(runCount, 1);
|
||||
release();
|
||||
const results = await Promise.all([first, second, third]);
|
||||
assert.equal(results[0].ok, true);
|
||||
assert.equal(results[0], results[1]);
|
||||
assert.equal(results[0], results[2]);
|
||||
assert.equal(await helpers.validateCodexChatGptAuth(options), results[0]);
|
||||
assert.equal(await helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } }), results[0]);
|
||||
assert.equal(runCount, 1);
|
||||
const rotated = await helpers.validateCodexChatGptAuth({ ...options, env: { ...defaultEnv, CUSTOM_KEY: "new-test-key" } });
|
||||
assert.equal(rotated.ok, true);
|
||||
assert.notEqual(rotated, results[0]);
|
||||
assert.equal(runCount, 2);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ async function probeCursorSdkAvailability(shellEnv, options = {}) {
|
||||
|
||||
function registerAgentDiscoveryHandlers(ctx) {
|
||||
with (ctx) {
|
||||
async function getCodexAgentEnv(options) {
|
||||
return buildSdkAgentEnv({
|
||||
shellEnv: await getShellEnv(),
|
||||
requestedAgentEnv: normalizeAgentEnv(options?.agentEnv),
|
||||
withCliDiscoveryEnv,
|
||||
});
|
||||
}
|
||||
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (options?.refreshShellEnv) {
|
||||
@@ -265,7 +273,9 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
invalidateShellEnvCache();
|
||||
}
|
||||
try {
|
||||
const codexCliOptions = { codexPath: options?.codexPath };
|
||||
// Probe the same config and credentials as the managed agent's turns.
|
||||
const effectiveEnv = await getCodexAgentEnv(options);
|
||||
const codexCliOptions = { codexPath: options?.codexPath, env: effectiveEnv };
|
||||
const result = await runCodexCli(["login", "status"], codexCliOptions);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
.filter((chunk) => chunk.trim().length > 0)
|
||||
@@ -275,7 +285,7 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
let effectiveRawOutput = rawOutput;
|
||||
|
||||
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath, env: effectiveEnv });
|
||||
if (!validation.ok) {
|
||||
if (isCodexAuthError(validation)) {
|
||||
try {
|
||||
@@ -299,17 +309,21 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
// functional from the CLI but would look "not_logged_in" here. Probe
|
||||
// config.toml so we can surface that as a valid ready state instead of
|
||||
// pushing the user into the ChatGPT login flow.
|
||||
//
|
||||
// Probe even when auth.json reports a login: provider switcher tools
|
||||
// (cc-switch, ccs) write an API key into auth.json while config.toml's
|
||||
// `model_provider` actually selects a third-party provider, and
|
||||
// config.toml is what Codex uses. Keep a validated ChatGPT login as the
|
||||
// displayed state, but still return customConfig so the chat model
|
||||
// picker can surface the configured third-party model.
|
||||
let customConfig = null;
|
||||
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
customConfig = readCodexCustomProviderConfig(shellEnv);
|
||||
if (customConfig) {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
try {
|
||||
customConfig = readCodexCustomProviderConfig(effectiveEnv);
|
||||
if (customConfig && state !== "connected_chatgpt") {
|
||||
state = "connected_custom_config";
|
||||
}
|
||||
} catch {
|
||||
customConfig = null;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -342,16 +356,21 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
}
|
||||
|
||||
try {
|
||||
const shellEnv = await getShellEnv();
|
||||
const shellEnv = await getCodexAgentEnv(options);
|
||||
const codexCliPath = requestedCodexPath
|
||||
|| await resolveCliFromPathAsync("codex", shellEnv)
|
||||
|| "codex";
|
||||
const credentialHomeKey = shellEnv.CODEX_HOME?.trim()
|
||||
|| require("node:path").join(shellEnv.HOME || shellEnv.USERPROFILE || require("node:os").homedir(), ".codex");
|
||||
const existingSession = getActiveCodexLoginSession();
|
||||
if (existingSession) {
|
||||
const existingPath = existingSession.codexPath || null;
|
||||
if (existingPath && codexCliPath !== existingPath) {
|
||||
return { ok: false, error: "A Codex login is already running for a different CLI path." };
|
||||
}
|
||||
if (existingSession.credentialHomeKey !== credentialHomeKey) {
|
||||
return { ok: false, error: "A Codex login is already running for a different credential home." };
|
||||
}
|
||||
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
|
||||
}
|
||||
|
||||
@@ -373,6 +392,7 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
error: null,
|
||||
exitCode: null,
|
||||
codexPath: codexCliPath,
|
||||
credentialHomeKey,
|
||||
};
|
||||
|
||||
const stdoutDecoder = createCodexLoginOutputDecoder(session);
|
||||
@@ -454,7 +474,7 @@ function registerAgentDiscoveryHandlers(ctx) {
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const codexCliOptions = { codexPath: options?.codexPath };
|
||||
const codexCliOptions = { codexPath: options?.codexPath, env: await getCodexAgentEnv(options) };
|
||||
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
|
||||
invalidateCodexValidationCache();
|
||||
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
|
||||
|
||||
@@ -1271,6 +1271,21 @@ function registerHandlers(ipcMain) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:startPortForward", async (_event, ruleId) => {
|
||||
const win = await openMainWindowReady();
|
||||
if (!win) return { success: false, error: "Main window is not available" };
|
||||
if (isAppRuntimeLocked()) {
|
||||
sendOrQueuePortForwardToggle(win, ruleId, true);
|
||||
return { success: true };
|
||||
}
|
||||
const delivered = await sendToMainWindow("netcatty:trayPanel:startPortForward", ruleId, {
|
||||
focus: false,
|
||||
});
|
||||
return delivered
|
||||
? { success: true }
|
||||
: { success: false, error: "Main window is not ready" };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:closeSession", async (_event, sessionId) => {
|
||||
const delivered = await sendToMainWindow("netcatty:trayPanel:closeSession", sessionId, {
|
||||
focus: false,
|
||||
|
||||
@@ -1770,3 +1770,55 @@ test("setCloseToTray(false) still destroys an unpinned tray as before", async ()
|
||||
bridge.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("tray panel forwarding start reaches the main renderer coordinator", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const sentMessages = [];
|
||||
const mainWin = new FakeWindow();
|
||||
mainWin.webContents = { send: (...args) => sentMessages.push(args) };
|
||||
electronModule.BrowserWindow.getAllWindows = () => [mainWin];
|
||||
bridge.init({ electronModule, getMainWindow: () => mainWin });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const result = await ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "reconnect-rule");
|
||||
assert.deepEqual(result, { success: true });
|
||||
assert.ok(sentMessages.some((args) =>
|
||||
args[0] === "netcatty:trayPanel:startPortForward" && args[1] === "reconnect-rule"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
test("tray panel forwarding waits for a newly created main renderer", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const mainWin = new FakeWindow();
|
||||
let main = null;
|
||||
let releaseReady;
|
||||
let delivered = false;
|
||||
const ready = new Promise((resolve) => { releaseReady = resolve; });
|
||||
electronModule.BrowserWindow.getAllWindows = () => main ? [main] : [];
|
||||
bridge.init({
|
||||
electronModule,
|
||||
getMainWindow: () => main,
|
||||
ensureMainWindow: async () => { main = mainWin; return mainWin; },
|
||||
sendWhenRendererReady: async (win, channel, ruleId) => {
|
||||
assert.equal(win, mainWin);
|
||||
assert.equal(channel, "netcatty:trayPanel:startPortForward");
|
||||
assert.equal(ruleId, "new-main-rule");
|
||||
await ready;
|
||||
delivered = true;
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
const pending = ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "new-main-rule");
|
||||
assert.equal(delivered, false);
|
||||
releaseReady();
|
||||
assert.deepEqual(await pending, { success: true });
|
||||
assert.equal(delivered, true);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,7 @@ let sftpClients = null;
|
||||
let electronModule = null;
|
||||
let sessions = null;
|
||||
let reportOpenedSessionActivity = null;
|
||||
let reportSuppressedError = null;
|
||||
const rendererSftpSourceSessions = new Map();
|
||||
const REMOTE_DELETE_EXEC_OPEN_TIMEOUT_MS = 15_000;
|
||||
const REMOTE_DELETE_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
|
||||
@@ -734,6 +735,9 @@ function init(deps) {
|
||||
reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function"
|
||||
? deps.reportOpenedSessionActivity
|
||||
: null;
|
||||
reportSuppressedError = typeof deps.reportSuppressedError === "function"
|
||||
? deps.reportSuppressedError
|
||||
: null;
|
||||
rendererSftpSourceSessions.clear();
|
||||
}
|
||||
|
||||
@@ -2015,10 +2019,14 @@ async function openSftpForSession(_event, payload) {
|
||||
}
|
||||
|
||||
try {
|
||||
await requireSftpChannel(client, {
|
||||
// This is a fresh client: retain the initial channel-open error instead
|
||||
// of passing through recovery, which replaces it with a generic error.
|
||||
const channel = await tryOpenSftpChannel(client, {
|
||||
signal: payload?.abortSignal,
|
||||
timeoutMs: payload?.timeoutMs,
|
||||
});
|
||||
if (!hasSftpChannelApi(channel)) throw new Error("SFTP channel unavailable");
|
||||
client.sftp = channel;
|
||||
client.__netcattyFileProtocol = "sftp";
|
||||
} catch (sftpErr) {
|
||||
if (fileProtocol === "sftp") throw sftpErr;
|
||||
@@ -2027,6 +2035,23 @@ async function openSftpForSession(_event, payload) {
|
||||
`[SFTP] openSftpForSession SFTP channel failed for ${sessionId}; falling back to SCP mode:`,
|
||||
sftpErr?.message || String(sftpErr),
|
||||
);
|
||||
// The terminal worker has no Electron app: forward to main's logger.
|
||||
try {
|
||||
const source = "sftpBridge.openSftpForSession";
|
||||
const message = `SFTP channel failed for ${sourceSessionId}; falling back to SCP mode (${fileProtocol})`;
|
||||
if (reportSuppressedError) {
|
||||
reportSuppressedError(source, sftpErr, message);
|
||||
} else {
|
||||
require("./crashLogBridge.cjs").captureDiagnostic(source, message, {
|
||||
sessionId: sourceSessionId,
|
||||
fileProtocol,
|
||||
reason: sftpErr?.message || String(sftpErr),
|
||||
reasonCode: sftpErr?.code ?? sftpErr?.level ?? undefined,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Diagnostic failures must not prevent the fallback.
|
||||
}
|
||||
client.__netcattyFileProtocol = "scp";
|
||||
client.sftp = null;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const test = require("node:test");
|
||||
const bridge = require("./sftpBridge.cjs");
|
||||
|
||||
for (const loggerThrows of [false, true]) {
|
||||
test(`Auto fallback reports the channel failure; loggerThrows=${loggerThrows}`, async () => {
|
||||
const failure = Object.assign(new Error("subsystem request rejected"), { code: "CHANNEL_OPEN_FAILURE" });
|
||||
const reports = [];
|
||||
const conn = {
|
||||
sftp(callback) { callback(failure); },
|
||||
exec(command, callback) {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.close = () => {};
|
||||
callback(null, stream);
|
||||
queueMicrotask(() => {
|
||||
if (!command.includes("command -v scp")) {
|
||||
stream.emit("data", Buffer.from("RAW:/home/test\n"));
|
||||
}
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
},
|
||||
};
|
||||
bridge.init({
|
||||
electronModule: { webContents: { fromId: () => null } },
|
||||
sessions: new Map([["fallback-session", { conn }]]),
|
||||
sftpClients: new Map(),
|
||||
reportSuppressedError(...args) {
|
||||
reports.push(args);
|
||||
if (loggerThrows) throw new Error("logger unavailable");
|
||||
},
|
||||
});
|
||||
const opened = await bridge.openSftpForSession(null, { sessionId: "fallback-session" });
|
||||
try {
|
||||
assert.equal(opened.fileProtocol, "scp");
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0][0], "sftpBridge.openSftpForSession");
|
||||
assert.equal(reports[0][1], failure);
|
||||
assert.match(reports[0][2], /fallback-session.*SCP mode \(auto\)/);
|
||||
} finally {
|
||||
await bridge.closeSftp(null, { sftpId: opened.sftpId });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -190,8 +190,16 @@ function createScpBackend(deps = {}) {
|
||||
async function stat(remotePath, options = {}) {
|
||||
const signal = options.signal || null;
|
||||
const encoding = options.encoding || "utf-8";
|
||||
const result = await runOrThrow(buildStatCommand(remotePath, encoding), { signal });
|
||||
return parseStatRecord(result.stdout);
|
||||
// allowNonZero: parseStatRecord needs the exit code + stderr to tell a real
|
||||
// ENOENT (exit 2) apart from an empty exec-channel response.
|
||||
const result = await runOrThrow(buildStatCommand(remotePath, encoding), {
|
||||
allowNonZero: true,
|
||||
signal,
|
||||
});
|
||||
return parseStatRecord(result.stdout, {
|
||||
stderr: result.stderr,
|
||||
exitCode: result.code,
|
||||
});
|
||||
}
|
||||
|
||||
async function mkdir(remotePath, options = {}) {
|
||||
|
||||
@@ -175,6 +175,137 @@ describe("scpBackend browse/manage with fake exec", () => {
|
||||
assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing");
|
||||
});
|
||||
|
||||
it("reports ENOENT when the stat command exits 2 with an ENOENT marker", async () => {
|
||||
const missingBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "ENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => missingBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT" && err.message === "No such file",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ENOENT when a shell banner precedes the marker on stderr", async () => {
|
||||
// Codex: non-interactive shells may print warnings before the command, so
|
||||
// the ENOENT marker can appear on a later stderr line.
|
||||
const bannerBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "warning: env cleared\nENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => bannerBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT" && err.message === "No such file",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports ENOENT when stdout contains a login banner", async () => {
|
||||
const bannerBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "Welcome\n", stderr: "ENOENT\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => bannerBackend.stat("/home/test/gone.txt"),
|
||||
(err) => err.code === "ENOENT",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mask an empty exec response as ENOENT in stat", async () => {
|
||||
const emptyBackend = createScpBackend({
|
||||
// Simulates the reporter's case: the exec channel returns no output at
|
||||
// all (e.g. channel negotiation failure) — not a missing file.
|
||||
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => emptyBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /empty response/);
|
||||
assert.ok(!(err instanceof Error && err.message === "No such file"));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces stderr and exit code when the stat command fails without output", async () => {
|
||||
const failingBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 1 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => failingBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /exit 1/);
|
||||
assert.match(err.message, /syntax error/);
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(err.stderr, "sh: syntax error");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat exit 2 without an ENOENT marker as a missing file", async () => {
|
||||
// Codex: a remote shell failure (e.g. syntax/usage error) can also exit 2;
|
||||
// without the ENOENT marker it must surface the real stderr, not ENOENT.
|
||||
const shellFailBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 2 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => shellFailBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.equal(err.code, "EMPTY_RESPONSE");
|
||||
assert.match(err.message, /exit 2/);
|
||||
assert.match(err.message, /syntax error/);
|
||||
assert.equal(err.exitCode, 2);
|
||||
assert.equal(err.stderr, "sh: syntax error");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat a stdout ENOENT marker with a nonzero, non-2 exit as missing", async () => {
|
||||
// Codex: a forced-command wrapper can print "ENOENT" on stdout before
|
||||
// failing with another status; it must not be reported as ENOENT.
|
||||
const wrapperBackend = createScpBackend({
|
||||
exec: async () => ({ stdout: "ENOENT\n", stderr: "denied\n", code: 126 }),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => wrapperBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.notEqual(err.code, "ENOENT");
|
||||
assert.equal(err.exitCode, 126);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects stat metadata forwarded by a wrapper that exits nonzero", async () => {
|
||||
// Codex: a forced-command wrapper can forward the stat record but exit
|
||||
// nonzero; the metadata is untrustworthy and must not be returned.
|
||||
const wrapperBackend = createScpBackend({
|
||||
exec: async () => ({
|
||||
stdout: "f|-rw-r--r--|5|1700000000|/home/test/readme.txt|12345\n",
|
||||
stderr: "forced command failed\n",
|
||||
code: 1,
|
||||
}),
|
||||
execStream: async () => createMockStream(),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => wrapperBackend.stat("/home/test/readme.txt"),
|
||||
(err) => {
|
||||
assert.match(err.message, /exited 1/);
|
||||
assert.match(err.message, /forced command failed/);
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(err.stderr, "forced command failed");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves home directory", async () => {
|
||||
const home = await backend.homeDir();
|
||||
assert.equal(home, "/home/test");
|
||||
|
||||
@@ -327,16 +327,57 @@ function parseLsLaOutput(stdout, { basePath = "" } = {}) {
|
||||
return results;
|
||||
}
|
||||
|
||||
function parseStatRecord(stdout) {
|
||||
function parseStatRecord(stdout, { stderr = "", exitCode = null } = {}) {
|
||||
const line = String(stdout || "").trim().split(/\r?\n/)[0] || "";
|
||||
const stderrText = String(stderr || "").trim();
|
||||
// A missing path emits an ENOENT marker and exits 2. Shell startup
|
||||
// messages may precede it on either stream; exit status alone is not enough.
|
||||
const hasMissingMarker = line === "ENOENT"
|
||||
|| stderrText.split(/\r?\n/).some((entry) => entry.trim() === "ENOENT");
|
||||
if (hasMissingMarker && (exitCode == null || exitCode === 2)) {
|
||||
throw new ScpShellError("No such file", "ENOENT");
|
||||
}
|
||||
if (!line || line === "ENOENT") {
|
||||
const err = new ScpShellError("No such file", "ENOENT");
|
||||
err.code = "ENOENT";
|
||||
const detail = [
|
||||
"exec channel returned empty response",
|
||||
exitCode != null ? `exit ${exitCode}` : null,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail, "EMPTY_RESPONSE");
|
||||
err.code = "EMPTY_RESPONSE";
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
// Any other nonzero status is a remote execution failure — even when stdout
|
||||
// looks like a valid record (e.g. a forced-command wrapper that forwards the
|
||||
// stat record before failing). Such metadata is untrustworthy, so reject it
|
||||
// instead of letting the caller proceed on it.
|
||||
if (exitCode != null && exitCode !== 0) {
|
||||
const detail = [
|
||||
`stat command exited ${exitCode}`,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail);
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 5) {
|
||||
throw new ScpShellError(`Malformed stat record: ${line.slice(0, 80)}`);
|
||||
// Non-empty stdout that isn't a stat record: the remote command may have
|
||||
// failed (nonzero exit, e.g. a restricted shell printing a banner) while
|
||||
// still writing to stdout. Preserve exit code and stderr so failures with
|
||||
// output aren't reduced to a bare "malformed" message.
|
||||
const detail = [
|
||||
`Malformed stat record: ${line.slice(0, 80)}`,
|
||||
exitCode != null ? `exit ${exitCode}` : null,
|
||||
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
|
||||
].filter(Boolean).join("; ");
|
||||
const err = new ScpShellError(detail);
|
||||
err.exitCode = exitCode;
|
||||
err.stderr = stderrText;
|
||||
throw err;
|
||||
}
|
||||
const [t, modeStr, sizeStr, mtimeStr, abs, inoStr] = parts;
|
||||
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
|
||||
|
||||
@@ -327,6 +327,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
|
||||
remotePort: { type: "number", optional: true, description: "Required except for dynamic forwarding." },
|
||||
hostId: { type: "string", description: "Vault host ID used for the tunnel." },
|
||||
autoStart: { type: "string", optional: true, description: "true or false." },
|
||||
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
|
||||
},
|
||||
"portforward.rules.update": {
|
||||
ruleId: { type: "string", description: "Port forwarding rule ID." },
|
||||
@@ -338,6 +339,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
|
||||
remotePort: { type: "number", optional: true, description: "Remote port." },
|
||||
hostId: { type: "string", optional: true, description: "Vault host ID used for the tunnel." },
|
||||
autoStart: { type: "string", optional: true, description: "true or false." },
|
||||
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
|
||||
},
|
||||
"portforward.rules.duplicate": {
|
||||
ruleId: { type: "string", description: "Port forwarding rule ID to copy." },
|
||||
|
||||
@@ -95,7 +95,7 @@ const _mcpLineMetas = new Map(); // sessionId -> trailing fragment metadata
|
||||
const _mcpPendingMetas = new Map(); // sessionId -> metadata from filtered-empty chunks
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
const _mcpDroppingWrappedLine = new Set(); // sessionIds with a split marker echo line in progress
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> probe marker awaiting its command
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> input/probe marker awaiting command start
|
||||
const _mcpAbortedProbes = new Map(); // sessionId -> bounded set of cancelled probe markers
|
||||
const MAX_MCP_BUFFERED_LINE_CHARS = 64 * 1024;
|
||||
|
||||
@@ -122,7 +122,9 @@ function filterProbePromptFragment(sessionId, fragment) {
|
||||
if (_mcpProbePrompts.get(sessionId) === reset[1]) _mcpProbePrompts.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_Q/);
|
||||
// I starts suppression before multiline input; Q keeps it active after
|
||||
// the live shell probe. Both end only at the matching command start/reset.
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_[IQ]/);
|
||||
if (completion && !_mcpAbortedProbes.get(sessionId)?.has(completion[1])) {
|
||||
_mcpProbePrompts.set(sessionId, completion[1]);
|
||||
}
|
||||
|
||||
@@ -1571,8 +1571,13 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
onTrayTogglePortForward: (callback) => {
|
||||
const handler = (_event, ruleId, start) => callback(ruleId, start);
|
||||
const startHandler = (_event, ruleId) => callback(ruleId, true);
|
||||
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
||||
ipcRenderer.on("netcatty:trayPanel:startPortForward", startHandler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
||||
ipcRenderer.removeListener("netcatty:trayPanel:startPortForward", startHandler);
|
||||
};
|
||||
},
|
||||
|
||||
// Tray panel actions forwarded to main window
|
||||
@@ -1600,6 +1605,8 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
||||
connectToHostFromTrayPanel: (hostId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
|
||||
startPortForwardFromTrayPanel: (ruleId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:startPortForward", ruleId),
|
||||
closeSessionFromTrayPanel: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:closeSession", sessionId),
|
||||
onTrayPanelCloseRequest: (callback) => {
|
||||
|
||||
@@ -1780,3 +1780,96 @@ test("real PTY multiline prompt is displayed only after the AI command", {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenWrt bounded wrapper continuations stay hidden across fragmented echoes", () => {
|
||||
const { buildWrappedCommand } = require('./bridges/ai/ptyExecHelpers.cjs');
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
const sessionId = 'openwrt-echo';
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
preload.api.onSessionData(sessionId, chunk => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId, data: `${marker}_I\n` });
|
||||
const wrapped = buildWrappedCommand("printf 'visible-output\\n'", 'posix', marker);
|
||||
const echo = wrapped.trimEnd().split('\n').map((line, index) => `${index ? '> ' : ''}${line}\r\n`).join('');
|
||||
const data = `${echo}${marker}_S\r\nvisible-output\r\n${marker}_E:0\r\n`;
|
||||
for (let offset = 0; offset < data.length; offset += 7) {
|
||||
preload.handlers.get('netcatty:data')({}, { sessionId, data: data.slice(offset, offset + 7) });
|
||||
}
|
||||
assert.equal(received.join(''), 'visible-output\r\n');
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("ordinary text resembling an OpenWrt continuation is released", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("ordinary-continuation", chunk => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "ordinary-continuation", data: "> : '" });
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "> : '");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const echo of [true, false]) {
|
||||
test(`real PTY hides custom multiline secondary prompts (echo=${echo})`, {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
|
||||
}, async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'READY> ', PS2: 'CUSTOM\nCONT> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
const received = [];
|
||||
let raw = '';
|
||||
const send = data => preload.handlers.get('netcatty:data')({}, { sessionId: 'custom-ps2', data });
|
||||
preload.api.onSessionData('custom-ps2', data => received.push(data));
|
||||
terminal.onData(data => { raw += data; send(data); });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes('READY> ')) {
|
||||
if (Date.now() > deadline) throw new Error('Missing Bash prompt');
|
||||
await sleep(10);
|
||||
}
|
||||
raw = '';
|
||||
await sleep(120);
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
if (!echo) { terminal.write('stty -echo\r'); await ready(); }
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
received.length = 0;
|
||||
const result = await require('./bridges/ai/ptyExec.cjs').execViaPty(terminal, "printf 'visible-result\\n'", {
|
||||
shellKind: 'posix', probeLiveShell, stripMarkers: true, timeoutMs: 3000,
|
||||
onProbeAborted: (marker) => send(`${marker}_R\n`),
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
await sleep(160);
|
||||
const display = received.join('');
|
||||
assert.match(display, /visible-result/);
|
||||
assert.doesNotMatch(display, /CUSTOM|CONT>|__NCMCP_/, JSON.stringify({ display, raw }));
|
||||
assert.match(display, /READY> /);
|
||||
}
|
||||
} finally { terminal.kill(); preload.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("aborted input releases custom prompts and ignores a late input marker", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("aborted-input", data => received.push(data));
|
||||
const send = data => preload.handlers.get("netcatty:data")({}, { sessionId: "aborted-input", data });
|
||||
send("__NCMCP_input___I\nCUSTOM\nCONT> ");
|
||||
send("__NCMCP_input___R\n");
|
||||
send("normal output\n");
|
||||
send("__NCMCP_input___I\nlate prompt\n");
|
||||
assert.equal(received.join(""), "normal output\nlate prompt\n");
|
||||
} finally { preload.cleanup(); }
|
||||
});
|
||||
@@ -18,8 +18,10 @@ class FakePty extends EventEmitter {
|
||||
|
||||
write(data) {
|
||||
this.writes.push(String(data));
|
||||
if (String(data).includes("command sh -c")) {
|
||||
const marker = String(data).match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
const input = this.writes.join("");
|
||||
if (!this.probeReplied && input.includes("command sh -c") && input.endsWith("_Q'\n")) {
|
||||
this.probeReplied = true;
|
||||
const marker = input.match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
queueMicrotask(() => this.emit("data", `${marker}_P:\n${marker}_Q`));
|
||||
}
|
||||
}
|
||||
@@ -52,9 +54,19 @@ function createFakeEvent() {
|
||||
};
|
||||
}
|
||||
|
||||
function extractMarker(writes) {
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.ok(wrapper, "expected wrapped command to be written to the PTY");
|
||||
async function waitForWrapper(writes) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const input = writes.join("");
|
||||
const wrapper = input.slice(input.lastIndexOf("\x0b\x15"));
|
||||
if (wrapper.includes("_cmd") && wrapper.includes("_E:") && wrapper.endsWith("\n") && !wrapper.endsWith("\\\n")) return wrapper;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail("expected complete wrapped command to be written to the PTY");
|
||||
}
|
||||
|
||||
async function extractMarker(writes) {
|
||||
const wrapper = await waitForWrapper(writes);
|
||||
const match = wrapper.match(/(__NCMCP_[A-Za-z0-9_]+__)/);
|
||||
assert.ok(match, "expected command wrapper to contain an MCP marker");
|
||||
return match[1];
|
||||
@@ -144,7 +156,7 @@ test("worker AI background jobs start, poll, stop, and block overlapping exec",
|
||||
},
|
||||
]);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nready\r\n`);
|
||||
await nextTick();
|
||||
|
||||
@@ -207,7 +219,7 @@ test("worker chat cancellation stops matching background jobs", async () => {
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nrunning\r\n`);
|
||||
await nextTick();
|
||||
|
||||
@@ -261,11 +273,11 @@ test("worker background job probes unset remote shellKind before wrapping", asyn
|
||||
// Login fish is a soft hint (not pinned); wrapper should be fish-native.
|
||||
assert.equal(sessions.get("ssh-fish").shellKind, undefined);
|
||||
assert.equal(sessions.get("ssh-fish")._loginShellKind, "fish");
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
const wrapper = await waitForWrapper(pty.writes);
|
||||
assert.match(wrapper, /set -l __NCMCP_.*_cmd/);
|
||||
assert.doesNotMatch(wrapper, / sh -c '/);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
@@ -305,7 +317,7 @@ test("worker background job reserves the session while shellKind probe is pendin
|
||||
const first = await firstStart;
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
@@ -301,6 +301,7 @@ function main() {
|
||||
selectZmodemUploadFiles,
|
||||
selectZmodemDownloadDirectory,
|
||||
terminalDataPipeline,
|
||||
reportSuppressedError: reportWorkerError,
|
||||
};
|
||||
|
||||
runtime = createTerminalWorkerRuntime({
|
||||
|
||||
@@ -2130,3 +2130,8 @@ html[data-theme-transition] *::after {
|
||||
--terminal-host-tree-drop-bg: var(--nc-term-host-tree-drop-bg, transparent);
|
||||
--terminal-host-tree-folder-fg: var(--nc-term-host-tree-folder-fg, var(--foreground));
|
||||
}
|
||||
|
||||
/* Bottom tabs meet the content above, retaining the same tab elements. */
|
||||
[data-top-tabs-root][data-position="bottom"] .netcatty-tab {
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
}
|
||||
@@ -1993,6 +1993,11 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
@@ -2056,6 +2061,11 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -2077,6 +2077,11 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
@@ -2141,6 +2146,11 @@
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false."
|
||||
},
|
||||
"autoReconnect": {
|
||||
"type": "string",
|
||||
"optional": true,
|
||||
"description": "true or false. Reconnect automatically after unexpected disconnects."
|
||||
}
|
||||
},
|
||||
"policy": {
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
updatePortForwardingRule,
|
||||
validatePortForwardingHost,
|
||||
} from '../../domain/portForwardingAgentOps';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { deleteGroup, upsertGroup } from '../../domain/vaultGroupAgentOps';
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
@@ -182,6 +183,7 @@ export function sanitizePortForwardRuleForAgent(rule: PortForwardingRule): Recor
|
||||
remotePort: rule.remotePort,
|
||||
hostId: rule.hostId,
|
||||
autoStart: rule.autoStart,
|
||||
autoReconnect: rule.autoReconnect,
|
||||
status: rule.status,
|
||||
error: rule.error,
|
||||
createdAt: rule.createdAt,
|
||||
@@ -1393,7 +1395,7 @@ export async function handleVaultAgentOp(
|
||||
deps.keys,
|
||||
deps.identities,
|
||||
undefined,
|
||||
false,
|
||||
isPortForwardingAutoReconnectEnabled(rule),
|
||||
deps.terminalSettings,
|
||||
deps.knownHosts,
|
||||
);
|
||||
|
||||
@@ -248,6 +248,7 @@ export const STORAGE_KEY_HOST_CLICK_BEHAVIOR = 'netcatty_host_click_behavior_v1'
|
||||
export const STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = 'netcatty_show_only_ungrouped_hosts_in_root_v1';
|
||||
|
||||
// Top tabs: Show standalone SFTP view tab
|
||||
export const STORAGE_KEY_TAB_BAR_POSITION = 'netcatty_tab_bar_position_v1';
|
||||
export const STORAGE_KEY_SHOW_SFTP_TAB = 'netcatty_show_sftp_tab_v1';
|
||||
export const STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR = 'netcatty_show_host_tree_sidebar_v1';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import test from "node:test";
|
||||
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect.ts";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, PortForwardingRule, SSHKey } from "../../domain/models.ts";
|
||||
@@ -2317,3 +2318,45 @@ test("startAllPortForwards skips tracked error runtimes and stopAll stops them",
|
||||
stopAndCleanupRule("bulk-idle-after-error");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
});
|
||||
|
||||
for (const type of ["local", "remote", "dynamic"] as const) {
|
||||
test(`${type} reconnect follows live rule choices and respects manual stop`, async (t) => {
|
||||
const liveRule = rule({ id: `reconnect-choice-${type}`, type, autoStart: true });
|
||||
let listener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
onPortForwardStatus: (_id: string, callback: typeof listener) => {
|
||||
listener = callback;
|
||||
return () => undefined;
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
} },
|
||||
});
|
||||
setReconnectCallback(async () => ({ success: true }), () => isPortForwardingAutoReconnectEnabled(liveRule));
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(liveRule.id);
|
||||
});
|
||||
const start = async () => {
|
||||
await startPortForward(liveRule, host(), [], [], [], () => undefined,
|
||||
isPortForwardingAutoReconnectEnabled(liveRule));
|
||||
listener?.("active");
|
||||
};
|
||||
// Legacy auto-start still reconnects, until explicitly turned off while active.
|
||||
await start();
|
||||
liveRule.autoReconnect = false;
|
||||
listener?.("error", "network dropped");
|
||||
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
|
||||
await stopAndCleanupRuleAndWait(liveRule.id);
|
||||
// Enabling reconnect while active takes effect without restarting the tunnel.
|
||||
liveRule.autoStart = false;
|
||||
await start();
|
||||
liveRule.autoReconnect = true;
|
||||
listener?.("error", "network dropped");
|
||||
assert.ok(getActiveConnection(liveRule.id)?.reconnectTimerCallback);
|
||||
await stopPortForward(liveRule.id, () => undefined);
|
||||
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
selectStoppablePortForwardingRules,
|
||||
} from '../../domain/portForwardingBulkActions';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { resolveHostKeepalive } from '../../domain/host';
|
||||
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
|
||||
@@ -215,11 +216,21 @@ const scheduleReconnectIfNeeded = (
|
||||
enableReconnect: boolean,
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
): boolean => {
|
||||
if (!enableReconnect || !reconnectCallback || shouldReconnectRule?.(ruleId) === false) {
|
||||
// The rule's autoReconnect setting may change while the tunnel is running
|
||||
// (e.g. toggled in the edit panel). The start-time flag alone would keep
|
||||
// rejecting reconnects after such a change, so also consult the dynamic
|
||||
// rule gate, which reads fresh rule state.
|
||||
const effectiveEnableReconnect =
|
||||
enableReconnect || shouldReconnectRule?.(ruleId) === true;
|
||||
if (
|
||||
!effectiveEnableReconnect ||
|
||||
!reconnectCallback ||
|
||||
shouldReconnectRule?.(ruleId) === false
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (rulesPendingCleanup.has(ruleId)) {
|
||||
deferredReconnects.set(ruleId, { enableReconnect, onStatusChange });
|
||||
deferredReconnects.set(ruleId, { enableReconnect: effectiveEnableReconnect, onStatusChange });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1392,7 +1403,7 @@ export const startAllPortForwards = async (
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => onStatusChange(rule.id, status, error),
|
||||
Boolean(rule.autoStart),
|
||||
isPortForwardingAutoReconnectEnabled(rule),
|
||||
terminalSettings,
|
||||
knownHosts,
|
||||
);
|
||||
|
||||
Vendored
+3
-3
@@ -29,7 +29,7 @@ declare global {
|
||||
acpArgs?: string[];
|
||||
}>>;
|
||||
aiPrewarmShellEnv?(): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }): Promise<{
|
||||
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
|
||||
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected: boolean;
|
||||
rawOutput: string;
|
||||
@@ -45,7 +45,7 @@ declare global {
|
||||
authHash: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
aiCodexStartLogin?(options?: { codexPath?: string }): Promise<{
|
||||
aiCodexStartLogin?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
|
||||
ok: boolean;
|
||||
session?: {
|
||||
sessionId: string;
|
||||
@@ -85,7 +85,7 @@ declare global {
|
||||
};
|
||||
error?: string;
|
||||
}>;
|
||||
aiCodexLogout?(options?: { codexPath?: string }): Promise<{
|
||||
aiCodexLogout?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
|
||||
ok: boolean;
|
||||
state?: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
|
||||
isConnected?: boolean;
|
||||
|
||||
+1
@@ -108,6 +108,7 @@ declare global {
|
||||
quitApp?(): Promise<{ success: boolean }>;
|
||||
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
|
||||
startPortForwardFromTrayPanel?(ruleId: string): Promise<{ success: boolean; error?: string }>;
|
||||
closeSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
onTrayPanelCloseRequest?(callback: () => void): () => void;
|
||||
onTrayPanelRefresh?(callback: () => void): () => void;
|
||||
|
||||
Reference in new issue
Block a user