Compare commits

...

3 Commits

Author SHA1 Message Date
陈大猫
ecadc1fc2d [codex] Enable sudo fallback for Docker panel (#1466)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* Enable sudo fallback for Docker panel

* Prefer sudo for Docker panel commands

* Use pending saved sudo password immediately

* Try plain Docker before sudo fallback

* Detect Docker before sudo fallback

* Add sudo fallback for Docker popup commands

* Harden Docker popup sudo fallback
2026-06-14 10:47:21 +08:00
陈大猫
79ccf47655 fix editor tab theme toggle (#1467) 2026-06-14 09:54:13 +08:00
陈大猫
6ef0a4ad6b Fix settings localization gaps (#1465) 2026-06-14 09:00:49 +08:00
30 changed files with 789 additions and 86 deletions

View File

@@ -990,7 +990,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}

View File

@@ -8,6 +8,7 @@ import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } fro
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
interface AppHostTreeLayerProps {
@@ -20,7 +21,12 @@ interface AppHostTreeLayerProps {
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
resolvedPreviewTheme: TerminalTheme;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
@@ -43,7 +49,12 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
editorTabs,
logViews,
orderedTabs,
resolvedPreviewTheme,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
onConnect,
onCreateLocalTerminal,
}) => {
@@ -67,6 +78,24 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}), [
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
]);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
@@ -79,7 +108,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={resolvedPreviewTheme}
resolvedPreviewTheme={hostTreeTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}

View File

@@ -48,7 +48,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
@@ -153,7 +153,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
resolvedPreviewTheme={currentTerminalTheme}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>

View File

@@ -39,7 +39,7 @@ const baseInput = {
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the theme from their owning host", () => {
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
@@ -58,6 +58,26 @@ test("editor tabs use the theme from their owning host", () => {
assert.equal(resolved?.id, hostTheme.id);
});
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,

View File

@@ -54,22 +54,21 @@ export function resolveActiveChromeTheme({
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(session.hostId) ?? null;
const host = hostById.get(hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
const host = hostById.get(editorTab.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
return resolveHostTheme(editorTab.hostId);
}
const logView = logViews.find((item) => item.id === activeTabId);

View File

@@ -8,9 +8,38 @@ import {
isTerminalContentTabSurface,
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
id,
name: id,
type,
colors: {
background,
foreground: type === 'dark' ? '#ffffff' : '#000000',
cursor: '#888888',
selection: '#555555',
black: '#000000',
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00',
blue: '#0000ff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#ffffff',
brightBlack: '#444444',
brightRed: '#ff5555',
brightGreen: '#55ff55',
brightYellow: '#ffff55',
brightBlue: '#5555ff',
brightMagenta: '#ff55ff',
brightCyan: '#55ffff',
brightWhite: '#ffffff',
},
});
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
@@ -104,3 +133,73 @@ test('shared host tree resolves active host ids across work tab types', () => {
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, hostTheme.id);
});
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
test('shared host tree falls back to the current terminal theme without an active host', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: null,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map(),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});

View File

@@ -2,8 +2,9 @@ import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
function uniqueTabIds(tabIds: readonly string[]): string[] {
const seen = new Set<string>();
@@ -125,3 +126,28 @@ export function resolveWorkTabActiveHostId({
return null;
}
export function resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}: {
activeHostId: string | null;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
}): TerminalTheme {
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(activeHostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}

View File

@@ -312,6 +312,15 @@ export const enCoreMessages: Messages = {
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
'settings.terminal.font.weight.thin': 'Thin',
'settings.terminal.font.weight.extraLight': 'Extra Light',
'settings.terminal.font.weight.light': 'Light',
'settings.terminal.font.weight.normal': 'Normal',
'settings.terminal.font.weight.medium': 'Medium',
'settings.terminal.font.weight.semiBold': 'Semi Bold',
'settings.terminal.font.weight.bold': 'Bold',
'settings.terminal.font.weight.extraBold': 'Extra Bold',
'settings.terminal.font.weight.black': 'Black',
'settings.terminal.font.weightBold': 'Bold font weight',
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
'settings.terminal.font.linePadding': 'Line padding',

View File

@@ -312,6 +312,15 @@ export const ruCoreMessages: Messages = {
'settings.terminal.font.size.desc': 'Размер текста терминала',
'settings.terminal.font.weight': 'Толщина шрифта',
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
'settings.terminal.font.weight.thin': 'Тонкий',
'settings.terminal.font.weight.extraLight': 'Очень светлый',
'settings.terminal.font.weight.light': 'Светлый',
'settings.terminal.font.weight.normal': 'Обычный',
'settings.terminal.font.weight.medium': 'Средний',
'settings.terminal.font.weight.semiBold': 'Полужирный',
'settings.terminal.font.weight.bold': 'Жирный',
'settings.terminal.font.weight.extraBold': 'Очень жирный',
'settings.terminal.font.weight.black': 'Максимально жирный',
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
'settings.terminal.font.linePadding': 'Межстрочный отступ',
@@ -500,6 +509,7 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
'settings.shortcuts.binding.copy': 'Копировать из терминала',
'settings.shortcuts.binding.paste': 'Вставить в терминал',
@@ -507,9 +517,13 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
}
});
test("localized settings include workspace focus indicator labels", () => {
const keys = [
"settings.terminal.section.workspaceFocus",
"settings.terminal.workspaceFocus.style",
"settings.terminal.workspaceFocus.style.desc",
"settings.terminal.workspaceFocus.dim",
"settings.terminal.workspaceFocus.border",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
"settings.terminal.font.weight.extraLight",
"settings.terminal.font.weight.light",
"settings.terminal.font.weight.normal",
"settings.terminal.font.weight.medium",
"settings.terminal.font.weight.semiBold",
"settings.terminal.font.weight.bold",
"settings.terminal.font.weight.extraBold",
"settings.terminal.font.weight.black",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
}
});

View File

@@ -190,6 +190,15 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
'settings.terminal.font.weight.thin': '极细',
'settings.terminal.font.weight.extraLight': '特细',
'settings.terminal.font.weight.light': '细',
'settings.terminal.font.weight.normal': '常规',
'settings.terminal.font.weight.medium': '中等',
'settings.terminal.font.weight.semiBold': '半粗',
'settings.terminal.font.weight.bold': '粗',
'settings.terminal.font.weight.extraBold': '特粗',
'settings.terminal.font.weight.black': '黑体',
'settings.terminal.font.weightBold': '粗体字重',
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
'settings.terminal.font.linePadding': '行间距',
@@ -328,6 +337,13 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
'settings.terminal.workspaceFocus.style': '焦点提示样式',
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
'settings.terminal.autocomplete.enabled': '启用自动补全',
@@ -362,18 +378,25 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.next-tab': '下一个标签页',
'settings.shortcuts.binding.prev-tab': '上一个标签页',
'settings.shortcuts.binding.close-tab': '关闭标签页',
'settings.shortcuts.binding.close-session': '关闭会话窗格',
'settings.shortcuts.binding.new-tab': '新建本地标签页',
'settings.shortcuts.binding.copy': '从终端复制',
'settings.shortcuts.binding.paste': '粘贴到终端',
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
'settings.shortcuts.binding.select-all': '全选终端内容',
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
'settings.shortcuts.binding.split-horizontal': '水平分屏',
'settings.shortcuts.binding.split-vertical': '垂直分屏',
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
'settings.shortcuts.binding.open-hosts': '打开主机列表',
'settings.shortcuts.binding.open-local': '打开本地终端',
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
'settings.shortcuts.binding.open-settings': '打开设置',
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
'settings.shortcuts.binding.command-palette': '打开命令面板',
'settings.shortcuts.binding.quick-switch': '快速切换',
@@ -389,6 +412,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.sftp-delete': '删除文件',
'settings.shortcuts.binding.sftp-refresh': '刷新',
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',

View File

@@ -71,7 +71,7 @@ export const Select: React.FC<SelectProps> = ({
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 w-max max-w-[var(--radix-select-content-available-width)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
className="z-[200000] max-h-80 w-max max-w-[min(24rem,var(--radix-select-content-available-width))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
@@ -84,7 +84,7 @@ export const Select: React.FC<SelectProps> = ({
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className="relative flex w-full min-w-max cursor-default select-none items-center whitespace-nowrap rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
className="relative flex w-full min-w-0 cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
@@ -92,7 +92,7 @@ export const Select: React.FC<SelectProps> = ({
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="flex min-w-0 items-center gap-2 whitespace-normal break-words leading-snug">
{opt.icon}
{opt.label}
</span>

View File

@@ -27,6 +27,19 @@ import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../do
import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls";
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings";
const FONT_WEIGHT_OPTIONS = [
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
{ value: "300", labelKey: "settings.terminal.font.weight.light" },
{ value: "400", labelKey: "settings.terminal.font.weight.normal" },
{ value: "500", labelKey: "settings.terminal.font.weight.medium" },
{ value: "600", labelKey: "settings.terminal.font.weight.semiBold" },
{ value: "700", labelKey: "settings.terminal.font.weight.bold" },
{ value: "800", labelKey: "settings.terminal.font.weight.extraBold" },
{ value: "900", labelKey: "settings.terminal.font.weight.black" },
];
function SettingsTerminalTab(props: {
terminalThemeId: string;
setTerminalThemeId: (id: string) => void;
@@ -146,6 +159,13 @@ function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const fontWeightOptions = useMemo(() => (
FONT_WEIGHT_OPTIONS.map((option) => ({
value: option.value,
label: `${option.value} - ${t(option.labelKey)}`,
}))
), [t]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
@@ -516,17 +536,7 @@ function SettingsTerminalTab(props: {
>
<Select
value={String(terminalSettings.fontWeight)}
options={[
{ value: "100", label: "100 - Thin" },
{ value: "200", label: "200 - Extra Light" },
{ value: "300", label: "300 - Light" },
{ value: "400", label: "400 - Normal" },
{ value: "500", label: "500 - Medium" },
{ value: "600", label: "600 - Semi Bold" },
{ value: "700", label: "700 - Bold" },
{ value: "800", label: "800 - Extra Bold" },
{ value: "900", label: "900 - Black" },
]}
options={fontWeightOptions}
onChange={(v) => updateTerminalSetting("fontWeight", parseInt(v))}
className="w-40"
/>
@@ -538,17 +548,7 @@ function SettingsTerminalTab(props: {
>
<Select
value={String(terminalSettings.fontWeightBold)}
options={[
{ value: "100", label: "100 - Thin" },
{ value: "200", label: "200 - Extra Light" },
{ value: "300", label: "300 - Light" },
{ value: "400", label: "400 - Normal" },
{ value: "500", label: "500 - Medium" },
{ value: "600", label: "600 - Semi Bold" },
{ value: "700", label: "700 - Bold" },
{ value: "800", label: "800 - Extra Bold" },
{ value: "900", label: "900 - Black" },
]}
options={fontWeightOptions}
onChange={(v) => updateTerminalSetting("fontWeightBold", parseInt(v))}
className="w-40"
/>

View File

@@ -161,6 +161,47 @@ test("startSSH forwards custom ProxyCommand to the SSH bridge", async () => {
});
});
test("startSSH forwards the saved sudo autofill password to the SSH bridge", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "ssh-session";
},
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "login-secret",
},
terminalBackend,
sudoAutofillPassword: "sudo-secret",
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(capturedOptions?.sudoAutofillPassword, "sudo-secret");
});
test("startSSH enables sudo autofill only with the host saved password", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
@@ -255,7 +296,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
assert.deepEqual(sent, []);
});
test("startSSH prefers latest sudo autofill password state over pending saved auth", async () => {
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const terminalBackend = {
@@ -296,14 +337,15 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
},
},
terminalBackend,
sudoAutofillPasswordRef: { current: undefined },
sudoAutofillPasswordRef: { current: "stale-secret" },
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("[sudo] password for alice: ");
ctx.sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, []);
assert.deepEqual(sent, ["pending-secret\n"]);
});
test("startSSH does not use merged group default passwords for sudo autofill", async () => {

View File

@@ -53,13 +53,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const resolveSavedSudoAutofillPassword = (): string | undefined => {
if (ctx.sudoAutofillPasswordRef) {
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
}
const pendingAuth = ctx.pendingAuthRef.current;
if (pendingAuth?.savedToHost && pendingAuth.password) {
return sanitizeCredentialValue(pendingAuth.password);
}
if (ctx.sudoAutofillPasswordRef) {
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
}
return sanitizeCredentialValue(ctx.sudoAutofillPassword);
};
@@ -401,6 +401,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
knownHosts: ctx.knownHosts,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
// Ask the bridge to reuse the source tab's authenticated connection
// (issue #1204). Only honored on the very first connect attempt; the
// bridge silently falls back to a fresh connection if the source is
@@ -764,6 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Lets the stats companion verify the host key before sending a saved
// password (#1198), so it never discloses it to an unvetted host.
knownHosts: ctx.knownHosts,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
@@ -1002,6 +1004,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
knownHosts: ctx.knownHosts,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
agentForwarding: ctx.host.agentForwarding,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,

View File

@@ -9,7 +9,7 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
}
const NETCATTY_MANAGED_STARTUP_COMMAND =
/^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/;
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
/** True when a shell history line came from a Netcatty-managed terminal launch. */
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {

View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './dockerShell.ts';
test('buildDockerExecShellCommand probes plain Docker before sudo fallback', () => {
const command = buildDockerExecShellCommand('587abcdef123');
assert.match(command, /^sh -c /);
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
assert.match(command, /docker inspect 587abcdef123/);
assert.match(command, /exec docker exec -it 587abcdef123/);
assert.match(command, /exec sudo docker exec -it 587abcdef123/);
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
assert.doesNotMatch(command, /sudo -S/);
assert.equal(command.includes('\n'), false);
});
test('buildDockerLogsCommand probes plain Docker before sudo fallback', () => {
const command = buildDockerLogsCommand('587abcdef123');
assert.match(command, /^sh -c /);
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
assert.match(command, /docker inspect 587abcdef123/);
assert.match(command, /exec docker logs -f --tail 200 587abcdef123/);
assert.match(command, /exec sudo docker logs -f --tail 200 587abcdef123/);
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
assert.doesNotMatch(command, /sudo -S/);
assert.equal(command.includes('\n'), false);
});

View File

@@ -5,15 +5,48 @@ export function sanitizeDockerContainerId(id: string): string {
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
function shQuote(value: string): string {
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
const plainCommand = `docker ${dockerArgs}`;
const sudoCommand = `sudo ${plainCommand}`;
const script = [
CLEAR_STARTUP_OUTPUT,
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
'_nc_docker_status=$?;',
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
'case "$_nc_docker_lc" in',
[
'*permission\\ denied*docker\\ daemon*',
'*docker\\ daemon*permission\\ denied*',
'*permission\\ denied*docker.sock*',
'*docker.sock*permission\\ denied*',
'*permission\\ denied*/var/run/docker.sock*',
'*/var/run/docker.sock*permission\\ denied*',
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
].join('|') + `) exec ${sudoCommand} ;;`,
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
'esac',
].join(' ');
return `sh -c ${shQuote(script)}`;
}
/** Interactive shell into a container — prefer bash, fall back to sh. */
export function buildDockerExecShellCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return `${CLEAR_STARTUP_OUTPUT} exec docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
return buildDockerCommandWithSudoFallback(
safeId,
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
);
}
export function buildDockerLogsCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return `${CLEAR_STARTUP_OUTPUT} exec docker logs -f --tail 200 ${safeId}`;
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
}

View File

@@ -38,6 +38,9 @@ function createStartSessionApi(ctx) {
hostname: options.host || options.hostname || '',
username: options.username || '',
label: options.label || '',
systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined,
lastIdlePrompt: '',
lastIdlePromptAt: 0,
_promptTrackTail: '',

View File

@@ -19,6 +19,37 @@ function sanitizeImageRef(ref) {
return trimmed || null;
}
function isSuccessfulCommandResult(result) {
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
}
function dockerCommandError(result, fallback) {
return (result?.stderr || result?.error || "").trim() || fallback;
}
function isDockerSocketPermissionError(result) {
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
if (!text.includes("permission denied")) return false;
return text.includes("docker daemon")
|| text.includes("docker.sock")
|| text.includes("/var/run/docker.sock")
|| text.includes("connect to the docker daemon");
}
function getSessionSudoPassword(session) {
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
? session.systemManagerSudoPassword
: null;
}
function buildDockerCommand(args) {
return `docker ${args}`.trim();
}
function buildSudoDockerCommand(args) {
return `sudo -S -p '' ${buildDockerCommand(args)}`;
}
function parseDockerContainers(stdout) {
const containers = [];
for (const line of (stdout || "").split("\n")) {
@@ -132,15 +163,35 @@ function summarizeContainerInspect(info) {
};
}
function createDockerOpsApi({ execOnSession }) {
function createDockerOpsApi({ execOnSession, getSession }) {
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
const cmd = `docker ${args}`;
const cmd = buildDockerCommand(args);
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
if (isSuccessfulCommandResult(result)) return result;
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
if (sudoPassword && isDockerSocketPermissionError(result)) {
const sudoResult = await execOnSession(
event,
sessionId,
buildSudoDockerCommand(args),
timeoutMs,
{ stdin: `${sudoPassword}\n` },
);
if (isSuccessfulCommandResult(sudoResult)) return sudoResult;
return {
success: false,
error: dockerCommandError(sudoResult, `sudo docker exited with code ${sudoResult?.code}`),
stderr: sudoResult?.stderr,
};
}
if (!result.success) return result;
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
return {
success: false,
error: (result.stderr || "").trim() || `docker exited with code ${result.code}`,
error: dockerCommandError(result, `docker exited with code ${result.code}`),
stderr: result.stderr,
};
}
@@ -148,23 +199,13 @@ function createDockerOpsApi({ execOnSession }) {
}
async function listContainers(event, sessionId) {
const result = await execOnSession(
event,
sessionId,
"docker ps -a --format '{{json .}}'",
12000,
);
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000);
if (!result.success) return { success: false, error: result.error };
return { success: true, containers: parseDockerContainers(result.stdout) };
}
async function listImages(event, sessionId) {
const result = await execOnSession(
event,
sessionId,
"docker images --format '{{json .}}'",
12000,
);
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000);
if (!result.success) return { success: false, error: result.error };
return { success: true, images: parseDockerImages(result.stdout) };
}
@@ -174,10 +215,10 @@ function createDockerOpsApi({ execOnSession }) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
const result = await execOnSession(
const result = await runDocker(
event,
sessionId,
`docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
`stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
15000,
);
if (!result.success) return { success: false, error: result.error };
@@ -188,7 +229,7 @@ function createDockerOpsApi({ execOnSession }) {
const { sessionId, containerId } = payload || {};
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(containerId);
const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000);
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout || "[]");
@@ -203,7 +244,7 @@ function createDockerOpsApi({ execOnSession }) {
const { sessionId, imageId } = payload || {};
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(imageId);
const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000);
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout || "[]");

View File

@@ -0,0 +1,186 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createDockerOpsApi } = require("./dockerOps.cjs");
test("listContainers uses plain docker first even when a saved session password exists", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
return {
success: true,
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, true);
assert.equal(result.containers.length, 1);
assert.equal(calls.length, 1);
assert.equal(
calls[0].command,
"docker ps -a --format '{{json .}}'",
);
assert.equal(calls[0].execOptions, undefined);
});
test("listContainers falls back to sudo when plain docker hits socket permission denial", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "permission denied while trying to connect to the Docker daemon socket",
code: 1,
};
}
return {
success: true,
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, true);
assert.equal(result.containers.length, 1);
assert.equal(calls.length, 2);
assert.equal(calls[0].command, "docker ps -a --format '{{json .}}'");
assert.equal(calls[0].execOptions, undefined);
assert.equal(
calls[1].command,
"sudo -S -p '' docker ps -a --format '{{json .}}'",
);
assert.deepEqual(calls[1].execOptions, { stdin: "host-secret\n" });
});
test("listContainers uses plain docker when no saved password exists", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({}),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
return {
success: true,
stdout: "",
stderr: "Got permission denied while trying to connect to the Docker daemon socket",
code: 1,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, false);
assert.match(result.error, /permission denied/i);
assert.equal(calls.length, 1);
});
test("listContainers does not retry with transport auth passwords that were not saved for sudo autofill", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({
moshStatsAuth: { password: "interactive-mosh-password" },
etStatsAuth: { password: "interactive-et-password" },
}),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
return {
success: true,
stdout: "",
stderr: "permission denied while trying to connect to the Docker daemon socket",
code: 1,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, false);
assert.match(result.error, /permission denied/i);
assert.equal(calls.length, 1);
});
test("listContainers retries with explicit sudo autofill password on mosh or et sessions", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({
systemManagerSudoPassword: "saved-secret",
moshStatsAuth: { password: "transport-secret" },
}),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
code: 1,
};
}
return {
success: true,
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, true);
assert.equal(calls.length, 2);
assert.equal(
calls[1].command,
"sudo -S -p '' docker ps -a --format '{{json .}}'",
);
assert.deepEqual(calls[1].execOptions, { stdin: "saved-secret\n" });
});
test("docker image actions retry with sudo and send saved passwords through stdin", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "pa'ss" }),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
code: 1,
};
}
return { success: true, stdout: "deleted\n", stderr: "", code: 0 };
},
});
const result = await dockerOps.imageAction(null, {
sessionId: "s1",
action: "rm",
imageId: "sha256:abc123",
});
assert.equal(result.success, true);
assert.equal(calls.length, 2);
assert.equal(
calls[1].command,
"sudo -S -p '' docker rmi sha256abc123",
);
assert.deepEqual(calls[1].execOptions, { stdin: "pa'ss\n" });
});

View File

@@ -78,7 +78,7 @@ function createExecOnSessionApi(ctx) {
return conn;
}
function execOnConnection(conn, command, timeoutMs) {
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
return new Promise((resolve) => {
let settled = false;
let activeStream = null;
@@ -106,6 +106,10 @@ function createExecOnSessionApi(ctx) {
if (stream.stderr) {
stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
}
if (typeof execOptions.stdin === "string") {
stream.write(execOptions.stdin);
stream.end();
}
stream.on("close", (code) => {
settle({ success: true, stdout, stderr, code: code ?? 0 });
});
@@ -116,7 +120,7 @@ function createExecOnSessionApi(ctx) {
});
}
async function execOnSshSession(session, sessionId, command, timeoutMs, event, allowCompanionRetry = true) {
async function execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions = {}, allowCompanionRetry = true) {
if (session?.type === "et") {
if (typeof execOnEtSession !== "function") {
return { success: false, error: "ET command executor unavailable" };
@@ -124,6 +128,7 @@ function createExecOnSessionApi(ctx) {
return execOnEtSession(session, command, timeoutMs, {
requireTrustedHost: true,
knownHosts: session.etStatsAuth?.knownHosts,
stdin: execOptions.stdin,
});
}
@@ -135,7 +140,7 @@ function createExecOnSessionApi(ctx) {
return { success: false, error: "Session not found or not connected" };
}
const result = await execOnConnection(conn, command, timeoutMs);
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
if (
allowCompanionRetry
&& !result.success
@@ -143,18 +148,18 @@ function createExecOnSessionApi(ctx) {
&& isTransportExecError(result.error)
) {
session.moshStatsConn = null;
return execOnSshSession(session, sessionId, command, timeoutMs, event, false);
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions, false);
}
return result;
}
async function execOnLocalMachine(command, timeoutMs) {
async function execOnLocalMachine(command, timeoutMs, execOptions = {}) {
const { execFile } = require("node:child_process");
const platform = process.platform;
if (platform === "win32") {
return new Promise((resolve) => {
execFile(
const child = execFile(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", command],
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
@@ -166,11 +171,14 @@ function createExecOnSessionApi(ctx) {
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
},
);
if (typeof execOptions.stdin === "string") {
child.stdin?.end(execOptions.stdin);
}
});
}
return new Promise((resolve) => {
execFile(
const child = execFile(
"sh",
["-c", command],
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
@@ -182,10 +190,13 @@ function createExecOnSessionApi(ctx) {
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
},
);
if (typeof execOptions.stdin === "string") {
child.stdin?.end(execOptions.stdin);
}
});
}
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000) {
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
const session = getSession(sessionId);
if (!session) {
execQueues.delete(sessionId);
@@ -193,18 +204,18 @@ function createExecOnSessionApi(ctx) {
}
if (session.protocol === "local" || session.type === "local") {
return execOnLocalMachine(command, timeoutMs);
return execOnLocalMachine(command, timeoutMs, execOptions);
}
if (session.conn || session.type === "mosh" || session.type === "et") {
return execOnSshSession(session, sessionId, command, timeoutMs, event);
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions);
}
return { success: false, error: "Session not supported for system management" };
}
async function execOnSession(event, sessionId, command, timeoutMs = 8000) {
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs));
async function execOnSession(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs, execOptions));
}
function isLocalSession(sessionId) {

View File

@@ -0,0 +1,38 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createExecOnSessionApi } = require("./execOnSession.cjs");
test("execOnSession closes ssh exec stdin after writing provided input", async () => {
const writes = [];
let ended = false;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = (data) => {
writes.push(data);
return true;
};
stream.end = () => {
ended = true;
};
const conn = {
exec(_command, callback) {
callback(null, stream);
process.nextTick(() => stream.emit("close", 0));
},
};
const execApi = createExecOnSessionApi({
sessions: { get: () => ({ conn, type: "ssh" }) },
});
const result = await execApi.execOnSession(null, "s1", "sudo -S -p '' docker ps", 1000, {
stdin: "secret\n",
});
assert.equal(result.success, true);
assert.deepEqual(writes, ["secret\n"]);
assert.equal(ended, true);
});

View File

@@ -9,7 +9,7 @@ const CAPABILITY_SCRIPT_POSIX = [
"'",
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
'(docker info >/dev/null 2>&1 || (command -v docker >/dev/null 2>&1 && [ -r /var/run/docker.sock ])) && printf "%s\\n" __NC_DOCKER__=1',
'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
"'",
].join("");
@@ -111,10 +111,10 @@ function createSystemManagerBridge(deps) {
ensureMoshStatsConnection,
});
const { execOnSession, execOnLocalMachine, isLocalSession } = execApi;
const { execOnSession, execOnLocalMachine, isLocalSession, getSession } = execApi;
const tmuxOps = createTmuxOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession, getSession });
async function probeCapabilities(event, payload) {
const sessionId = payload?.sessionId;
@@ -136,7 +136,7 @@ function createSystemManagerBridge(deps) {
8000,
);
if (!result.success) {
const fallback = await execOnLocalMachine("uname -s; command -v tmux; (docker info >/dev/null 2>&1 || (command -v docker >/dev/null 2>&1 && [ -r /var/run/docker.sock ])) && echo docker_ok", 8000);
const fallback = await execOnLocalMachine("uname -s; command -v tmux; command -v docker >/dev/null 2>&1 && echo docker_ok", 8000);
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
const text = fallback.stdout || "";
return {

View File

@@ -46,3 +46,24 @@ test("listProcesses uses a ps format that works on CentOS 7 procps", async () =>
assert.equal(result.processes[0].pid, 1);
assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21");
});
test("probeCapabilities reports Docker when docker is installed even if plain docker access is denied", async () => {
const conn = {
exec(command, callback) {
assert.match(command, /command -v docker/);
assert.doesNotMatch(command, /docker info/);
assert.doesNotMatch(command, /docker\.sock/);
callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n"));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process,
});
const result = await bridge.probeCapabilities(null, { sessionId: "s1" });
assert.equal(result.success, true);
assert.equal(result.capabilities.hasDocker, true);
});

View File

@@ -654,7 +654,7 @@ main();
args.push(session.sshUserHost, command);
return new Promise((resolve) => {
execFile(sshCmd, args, {
const child = execFile(sshCmd, args, {
env: { ...process.env, ...session.sshEnv },
timeout: timeoutMs,
encoding: "utf8",
@@ -672,6 +672,9 @@ main();
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
}
});
if (typeof execOpts.stdin === "string") {
child.stdin?.end(execOpts.stdin);
}
});
}
@@ -791,6 +794,9 @@ main();
knownHosts: options.knownHosts,
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
},
systemManagerSudoPassword: typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined,
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,

View File

@@ -572,6 +572,9 @@ function createMoshSessionApi(ctx) {
// does not depend on this.
knownHosts: options.knownHosts,
};
session.systemManagerSudoPassword = typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined;
if (process.platform !== "win32") {
const decoder = new StringDecoder("utf8");

2
global.d.ts vendored
View File

@@ -119,6 +119,8 @@ declare global {
algorithmOverrides?: import("./domain/models").HostAlgorithmOverrides;
// Use sudo for SFTP server
sudo?: boolean;
// Saved host password used by background system tools when they need sudo.
sudoAutofillPassword?: string;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
// SSH connection diagnostics. Does not capture terminal output.

View File

@@ -35,7 +35,7 @@
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

View File

@@ -29,6 +29,7 @@ declare global {
moshServerPath?: string;
moshClientPath?: string;
agentForwarding?: boolean;
sudoAutofillPassword?: string;
// Algorithm settings, forwarded so the host-info stats companion SSH
// connection (issue #1198) negotiates the same KEX / cipher / host-key
// set the interactive session would.
@@ -63,6 +64,7 @@ declare global {
knownHosts?: import("../../domain/models").KnownHost[];
jumpHosts?: NetcattyJumpHost[];
agentForwarding?: boolean;
sudoAutofillPassword?: string;
cols?: number;
rows?: number;
charset?: string;