Compare commits

...

5 Commits

Author SHA1 Message Date
陈大猫
51abe7da63 fix: send SSH keepalive on idle SFTP sessions to prevent NAT drop (#669) (#671)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The main openSftp() connection path was building ssh2 connect options
without setting keepaliveInterval at all, so no SSH-level keepalive
packets were sent on the SFTP channel. When the SFTP panel sits idle
(the common case while a user browses files), NAT/firewall state
tables reap the idle TCP connection after ~30-60s, causing the panel
to disconnect while the SSH terminal next to it — which has its own
keepalive config via sshBridge — stays connected. That matches the
exact symptom reported in #669.

Default to a 10s keepalive interval, matching the existing SFTP jump
host path (sftpBridge.cjs:466-467). Honor an explicitly configured
positive options.keepaliveInterval (in seconds) if one is passed in,
so the frontend can thread the user setting through later without
another bridge change.

Closes #669

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:45:51 +08:00
yuzifu
9667c03ddc fix: pin toolbar above content on KeychainManager page (#666)
* fix: pin toolbar above content on KeychainManager page

* fix: apply panel offset to outer wrapper so toolbar is not covered

The aside panel is rendered as an absolute overlay (right-0, w-[380px]),
so any container covered by the overlay needs mr-[380px] to avoid
having its right-side controls obscured. Previously only the inner
scroll area had the offset, which left the toolbar at full width —
its right-side controls (view-mode dropdown, etc.) would be covered
by the panel and become unclickable when it opened.

Move both the margin and the transition to the outer flex wrapper so
the toolbar and the scroll area shift together when the panel opens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:14:41 +08:00
陈大猫
9935eb2ed1 fix: preserve file permissions when saving edited file via SFTP (#667)
* fix: preserve file permissions when saving edited file via SFTP (#665)

ssh2-sftp-client's put() overwrites existing files with the server's
default mode (typically 0o666 after umask), so a 0o755 file edited
through the built-in text editor would silently become 0o666 after
save.

Stat the file before writing to capture its existing mode, then
chmod it back to that mode after put() completes. For new files,
stat fails and we fall through to let the server apply defaults,
preserving existing behavior for file creation.

Closes #665

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: also preserve setuid/setgid/sticky bits when restoring mode

Use 0o7777 mask instead of 0o777 so special permission bits are
preserved alongside the regular rwx bits — otherwise a 4755
executable would still be restored as 0755 after editing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 17:04:10 +08:00
Eric Chan
268b698a39 Follow up #640 for the Snippets page (#662)
* Update snippets page to use inline aside panels

* Fix nested host editor overflow in selector panel
2026-04-09 15:21:55 +08:00
Eric Chan
2491d1a177 Shorten MCP approval timeout (#659) 2026-04-09 09:56:19 +08:00
5 changed files with 75 additions and 41 deletions

View File

@@ -515,12 +515,12 @@ echo $3 >> "$FILE"`);
{/* Main Content */}
<div
className={cn(
"flex-1 overflow-y-auto transition-all duration-200",
"flex-1 flex flex-col min-h-0 transition-all duration-200",
panel.type !== "closed" && "mr-[380px]",
)}
>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border-b border-border/70 px-3 py-1.5 shrink-0">
{/* Filter Tabs */}
<div className="flex items-center gap-1">
{/* KEY button with split interaction: left=switch view, right=dropdown */}
@@ -684,8 +684,10 @@ echo $3 >> "$FILE"`);
</div>
</div>
{/* Keys Section */}
<div className="space-y-3 p-3">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
{t("keychain.section.keys")}
@@ -817,6 +819,7 @@ echo $3 >> "$FILE"`);
</div>
</div>
)}
</div>
</div>
{/* Slide-out Panel */}

View File

@@ -1,11 +1,9 @@
import {
ArrowLeft,
Check,
ChevronRight,
LayoutGrid,
Plus,
Search,
X,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
@@ -14,6 +12,7 @@ import { Host, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import { AsidePanel, type AsidePanelLayout } from "./ui/aside-panel";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
@@ -44,6 +43,7 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
layout?: AsidePanelLayout;
}
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
@@ -63,6 +63,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
layout = "overlay",
}) => {
const { t } = useI18n();
const panelTitle = title ?? t("selectHost.title");
@@ -205,35 +206,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
return (
<TooltipProvider delayDuration={300}>
<div
<AsidePanel
open={true}
onClose={onBack}
title={panelTitle}
subtitle={subtitle}
showBackButton={true}
onBack={onBack}
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
layout === "overlay" && "z-40",
showNewHostPanel && "overflow-visible",
className,
)}
layout={layout}
>
{/* Header */}
<div className="px-4 py-3 border-b border-border/60 flex items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onBack}
className="p-1 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<ArrowLeft size={18} />
</button>
<div className="min-w-0">
<h3 className="text-sm font-semibold">{panelTitle}</h3>
{subtitle && (
<p className="text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
</div>
<button
onClick={onBack}
className="p-1.5 hover:bg-muted rounded-md transition-colors cursor-pointer shrink-0"
>
<X size={18} />
</button>
</div>
{/* Toolbar */}
<div className="px-4 py-3 flex items-center gap-2 border-b border-border/60 shrink-0">
@@ -277,7 +263,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
</div>
{/* Content */}
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 min-w-0">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
@@ -398,7 +384,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
</ScrollArea>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60">
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<Button
className="w-full"
disabled={selectedHostIds.length === 0}
@@ -436,7 +422,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onCreateGroup={onCreateGroup}
/>
)}
</div>
</AsidePanel>
</TooltipProvider>
);
};

View File

@@ -8,7 +8,7 @@ import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { Combobox, ComboboxOption } from './ui/combobox';
@@ -721,6 +721,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
title={t('snippets.targets.add')}
layout="inline"
/>
);
}
@@ -731,6 +732,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
open={true}
onClose={handleClosePanel}
title={editingSnippet.id ? t('snippets.panel.editTitle') : t('snippets.panel.newTitle')}
layout="inline"
actions={
<Button
variant="ghost"
@@ -884,7 +886,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</AsidePanelContent>
{/* Footer */}
<div className="px-4 py-3 border-t border-border/60 shrink-0">
<AsidePanelFooter>
<Button
className="w-full"
onClick={handleSubmit}
@@ -892,7 +894,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
>
{editingSnippet.targets?.length ? t('action.run') : t('common.save')}
</Button>
</div>
</AsidePanelFooter>
</AsidePanel>
);
}
@@ -906,6 +908,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
subtitle={t('snippets.history.subtitle', { count: shellHistory.length })}
showBackButton={true}
onBack={handleClosePanel}
layout="inline"
>
{/* History List */}
<div
@@ -953,7 +956,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return (
<TooltipProvider delayDuration={300}>
<div className="h-full flex gap-3 relative">
<div className="h-full min-h-0 flex relative">
<div className="flex-1 flex flex-col min-h-0 min-w-0 overflow-hidden">
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur">
<div className="h-14 px-4 py-2 flex items-center gap-2">

View File

@@ -70,7 +70,12 @@ function setMainWindowGetter(fn) {
* Sends an IPC event and returns a Promise<boolean> that resolves
* when the user approves/rejects in the UI, or auto-denies after timeout.
*/
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
// External ACP agents (for example Codex) may give up on MCP tool calls after
// about 120 seconds; see openai/codex#6127 ("timed out awaiting tools/call
// after 120s"). Keep the Netcatty-side approval window below that with a small
// buffer so a stale approval cannot still be accepted after the agent has
// already timed out and abandoned the call.
const APPROVAL_TIMEOUT_MS = 110 * 1000; // 110 seconds
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
return new Promise((resolve) => {
@@ -1206,7 +1211,7 @@ function cleanupScopedMetadata(chatSessionId) {
// Resolve any in-flight approval requests so dispatch()'s finally block
// releases its pendingSessionWriteApprovals entry. Without this, a chat
// deleted while an approval was pending would leave the per-session
// write lock held until the 5-minute approval timeout.
// write lock held until the approval timeout expires.
clearPendingApprovals(chatSessionId);
}
}

View File

@@ -954,6 +954,14 @@ async function openSftp(event, options) {
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
// Keep SFTP sessions alive while the panel is idle. Without SSH-level
// keepalive packets the connection sits with zero data flow while the
// user is just browsing files, and NAT/firewall state tables drop the
// idle TCP connection after ~30-60s (the exact symptom of #669).
// Honor an explicitly configured positive keepaliveInterval (seconds);
// otherwise default to 10s, matching the SFTP jump host path below.
keepaliveInterval: options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
keepaliveCountMax: 3,
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
@@ -1388,7 +1396,12 @@ async function readSftpBinary(event, payload) {
}
/**
* Write file content
* Write file content.
*
* If the target file already exists, its mode is preserved — ssh2-sftp-client's
* `put()` otherwise overwrites existing files with the server's default mode
* (typically 0o666 after umask), which would silently change permissions on
* files edited through the built-in text editor.
*/
async function writeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
@@ -1397,7 +1410,31 @@ async function writeSftp(event, payload) {
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
let existingMode = null;
try {
const stat = await client.stat(encodedPath);
if (typeof stat.mode === "number") {
// Mask with 0o7777 so special bits (setuid/setgid/sticky) are preserved too.
existingMode = stat.mode & 0o7777;
}
} catch (_err) {
// File does not exist — treat as a new file and let the server apply defaults.
}
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
if (existingMode !== null) {
try {
await client.chmod(encodedPath, existingMode);
} catch (err) {
console.warn(
`[sftp] Failed to restore permissions on ${payload.path}:`,
err && err.message ? err.message : err,
);
}
}
return true;
}