Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 ae7b316623 fix(ssh): resolve ET ssh wrapper without a host OpenSSH binary
Unit tests stub execFileSync and only found ssh via /usr/bin/ssh, so the
PATH wrapper was skipped on images without that path. Prefer an absolute
findExecutable result, then command -v, then the well-known locations.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-13 07:49:20 +00:00
87 changed files with 216 additions and 4817 deletions

No files matched your search

+2 -1
View File
@@ -1202,7 +1202,8 @@ jobs:
persist-credentials: false
- name: Freeze trusted helper
run: *freeze_ai_helper
run: cp scripts/ai-automation.cjs "$RUNNER_TEMP/ai-automation.cjs"
chmod 0444 "$RUNNER_TEMP/ai-automation.cjs"
- name: Install Claude Code CLI
run: |
-6
View File
@@ -53,7 +53,6 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
@@ -1433,10 +1432,6 @@ export function AppSideEffects() {
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1479,7 +1474,6 @@ export function AppSideEffects() {
stopTunnel,
stopRuleTunnels,
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
-2
View File
@@ -522,8 +522,6 @@ export const enAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}h ago',
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.undoLastTurn': 'Undo last turn',
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
-2
View File
@@ -513,8 +513,6 @@ export const esAiMessages: Messages = {
'ai.chat.hoursAgo': 'hace {n} h',
'ai.chat.daysAgo': 'hace {n} d',
'ai.chat.newChat': 'Nuevo chat',
'ai.chat.undoLastTurn': 'Deshacer el último turno',
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
'ai.chat.allSessions': 'Todas las sesiones',
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
'ai.chat.jumpNav': 'Ir al mensaje',
-2
View File
@@ -469,8 +469,6 @@ export const ruAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}ч назад',
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.undoLastTurn': 'Отменить последний ход',
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
@@ -1,20 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import en from './en.ts';
import ru from './ru.ts';
import es from './es.ts';
import zhCN from './zh-CN.ts';
import zhTW from './zh-TW.ts';
const UNDO_KEYS = [
'ai.chat.undoLastTurn',
'ai.chat.undoLastTurnNotice',
] as const;
test('Undo-last-turn UI is localized in every supported locale', () => {
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
const missing = UNDO_KEYS.filter(key => !messages[key]);
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
}
});
-2
View File
@@ -522,8 +522,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小时前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.undoLastTurn': '撤销上一轮',
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
-2
View File
@@ -512,8 +512,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.hoursAgo': '{n}小時前',
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新對話',
'ai.chat.undoLastTurn': '復原上一輪',
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
-109
View File
@@ -466,115 +466,6 @@ test('writeSessionsForStorage reduces several small sessions to fit a sub-512 KB
}
});
test('serializeSessionsForStorage truncates slightly-over-cap tool results to strictly smaller sizes', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
// Just over the 8 KiB cap: the truncation marker must not make the degraded
// copy larger than the original, or the ladder could evict a session whose
// unmodified JSON would have fit the budget.
for (const oversize of [8193, 8266, 20000]) {
const session = makeSession('near-cap', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(oversize) }],
}]);
const fullJson = JSON.stringify([session]);
const result = serializeSessionsForStorage([session], fullJson.length - 10);
const content = result.sessions[0].messages[0].toolResults?.[0].content ?? '';
assert.ok(content.length <= 8 * 1024, `cap respected for ${oversize}`);
assert.ok(
JSON.stringify(result.sessions).length < fullJson.length,
`degraded copy smaller than original for ${oversize}`,
);
assert.ok(content.includes('truncated'));
}
});
test('serializeSessionsForStorage sheds oversized tool output before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const newest = makeSession('newest', 2, [{
id: 'new-message',
role: 'assistant',
content: 'still-open chat',
timestamp: 2,
}]);
const toolBulk = makeSession('tool-bulk', 1, [{
id: 'tool-message',
role: 'tool',
content: '',
timestamp: 1,
toolResults: [{ toolCallId: 'call-1', content: 'z'.repeat(400 * 1024) }],
}]);
const result = serializeSessionsForStorage([toolBulk, newest], 100 * 1024);
// The bloated tool result must not evict the other visible chat.
assert.deepEqual(result.sessions.map(session => session.id), ['newest', 'tool-bulk']);
assert.ok(result.json.length <= 100 * 1024);
const persistedToolContent = result.sessions[1].messages[0].toolResults?.[0].content;
assert.ok(persistedToolContent);
assert.ok(persistedToolContent.length <= 9 * 1024);
assert.ok(persistedToolContent.includes('truncated'));
assert.ok(persistedToolContent.startsWith('zzz'));
assert.equal(result.sessions[0].messages[0].content, 'still-open chat');
});
test('writeSessionsForStorage keeps every chat by shedding tool output under quota pressure', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
let stored: string | undefined;
const previousLocalStorage = globalThis.localStorage;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: () => null,
setItem: (_key: string, value: string) => {
if (value.length > 300 * 1024) {
throw new DOMException('quota exceeded', 'QuotaExceededError');
}
stored = value;
},
removeItem: () => {},
},
});
try {
const sessions = Array.from({ length: 10 }, (_, index) => makeSession(
`session-${index}`,
10 - index,
[{
id: `message-${index}`,
role: 'tool' as const,
content: '',
timestamp: index,
toolResults: [{ toolCallId: `call-${index}`, content: 'z'.repeat(40 * 1024) }],
}],
));
assert.equal(writeSessionsForStorage(sessions), true);
assert.ok(stored);
assert.ok(stored.length <= 300 * 1024);
const persisted = JSON.parse(stored) as AISession[];
assert.deepEqual(
persisted.map(session => session.id),
sessions.map(session => session.id),
);
// Degradation sheds only as many messages as the budget demands, so at
// least one chat keeps its full tool output while the persisted set stays
// complete. Assert completion first; truncation second.
const shedContents = persisted.map(
session => session.messages[0].toolResults?.[0].content ?? '',
);
assert.ok(shedContents.some(content => content.includes('truncated')));
assert.ok(shedContents.some(content => content.startsWith('zzz') && !content.includes('truncated')));
} finally {
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: previousLocalStorage,
});
}
});
test('writeSessionsForStorage makes a final attempt with only the newest session', async () => {
const { writeSessionsForStorage } = await import('./aiStateSnapshots');
const writes: string[] = [];
+29 -79
View File
@@ -195,40 +195,12 @@ const RETRY_SESSIONS_JSON_BYTES = [
128 * 1024,
] as const;
/**
* Maximum persisted length of a single tool result payload. Terminal tool
* output can reach hundreds of KB per command, and AI-driven terminal work
* accumulates many of them. When storage pressure forces degradation, shedding
* this replay bulk keeps every visible conversation persisted instead of
* evicting whole chats that happen to carry it.
*/
const MAX_PERSISTED_TOOL_RESULT_CHARS = 8 * 1024;
function truncatePersistedToolResultContent(content: string): string {
if (content.length <= MAX_PERSISTED_TOOL_RESULT_CHARS) return content;
// The marker consumes budget and must stay single-line: JSON-escaped
// newlines would cost 2 characters each, letting a slightly-over-cap
// result serialize at the same (or larger) size than the original — the
// ladder assumes the degraded form is the smallest. Shrink the retained
// head+tail until the whole persisted copy fits the cap.
let half = Math.floor(MAX_PERSISTED_TOOL_RESULT_CHARS / 2);
let omitted = content.length - 2 * half;
let marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
while (2 * half + marker.length > MAX_PERSISTED_TOOL_RESULT_CHARS) {
half -= Math.ceil((2 * half + marker.length - MAX_PERSISTED_TOOL_RESULT_CHARS) / 2);
omitted = content.length - 2 * half;
marker = `[Netcatty] persisted copy truncated; ${omitted} characters of tool output omitted`;
}
return `${content.slice(0, half)}${marker}${content.slice(content.length - half)}`;
}
/**
* Remove `reasoningEncryptedContent` ciphertext from a message's persisted
* continuation and shed bulk tool output. The ciphertext exists so stateless
* Responses turns can replay prior reasoning items; tool output dominates
* persisted size in AI-driven terminal chats. When storage pressure forces
* it, shedding this bulk keeps the visible conversation intact at the cost of
* reasoning replay and full tool output for affected messages.
* continuation. The ciphertext exists so stateless Responses turns can replay
* prior reasoning items; it is also by far the largest per-message payload.
* When storage pressure forces it, dropping the ciphertext keeps the visible
* conversation intact at the cost of reasoning replay for affected messages.
*/
function stripReasoningEncryptedContent(
options: ProviderContinuationOptions,
@@ -246,7 +218,7 @@ function stripReasoningEncryptedContent(
return Object.keys(stripped).length ? stripped : undefined;
}
function stripReasoningCiphertextFromMessage(message: AISession['messages'][number]) {
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const continuation = message.providerContinuation;
if (!continuation?.reasoningParts) return message;
let changed = false;
@@ -267,22 +239,6 @@ function stripReasoningCiphertextFromMessage(message: AISession['messages'][numb
};
}
function stripEncryptedReasoningFromMessage(message: AISession['messages'][number]) {
const strippedContinuationMessage = stripReasoningCiphertextFromMessage(message);
const toolResults = strippedContinuationMessage.toolResults;
if (!toolResults?.length) return strippedContinuationMessage;
let toolResultsChanged = false;
const nextToolResults = toolResults.map((result) => {
const content = truncatePersistedToolResultContent(result.content);
if (content === result.content) return result;
toolResultsChanged = true;
return { ...result, content };
});
return toolResultsChanged
? { ...strippedContinuationMessage, toolResults: nextToolResults }
: strippedContinuationMessage;
}
function stripEncryptedReasoningFromSession(session: AISession): AISession {
let changed = false;
const messages = session.messages.map(message => {
@@ -303,7 +259,7 @@ function stripCompactedEncryptedReasoningFromSession(session: AISession): AISess
let changed = false;
const messages = session.messages.map((message, index) => {
if (index >= compactedMessageCount) return message;
const stripped = stripReasoningCiphertextFromMessage(message);
const stripped = stripEncryptedReasoningFromMessage(message);
if (stripped !== message) changed = true;
return stripped;
});
@@ -354,11 +310,6 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
* sessions that JSON represents.
*
* Degradation ladder, cheapest loss first: strip replay ciphertext, shed bulk
* tool output (kept at a bounded head+tail per result), and only then drop
* oldest whole sessions. Evicting visible chats to preserve tool bulk is what
* emptied history for users under storage pressure.
*/
export function serializeSessionsForStorage(
sessions: AISession[],
@@ -370,13 +321,13 @@ export function serializeSessionsForStorage(
// before deciding whether the newest session deserves full protection;
// otherwise dead metadata can evict other visible conversations.
const session = stripCompactedEncryptedReasoningFromSession(rawSession);
const degradableMessages = session.messages.flatMap((message, index) => {
const degradedMessage = stripEncryptedReasoningFromMessage(message);
if (degradedMessage === message) return [];
const ciphertextMessages = session.messages.flatMap((message, index) => {
const strippedMessage = stripEncryptedReasoningFromMessage(message);
if (strippedMessage === message) return [];
return [{
index,
degradedMessage,
jsonLengthDelta: JSON.stringify(degradedMessage).length - JSON.stringify(message).length,
strippedMessage,
jsonLengthDelta: JSON.stringify(strippedMessage).length - JSON.stringify(message).length,
}];
});
const strippedSession = stripEncryptedReasoningFromSession(session);
@@ -388,7 +339,7 @@ export function serializeSessionsForStorage(
strippedJson: strippedSession === session
? json
: JSON.stringify(strippedSession),
degradableMessages,
ciphertextMessages,
};
});
@@ -413,12 +364,11 @@ export function serializeSessionsForStorage(
minimalLength -= removed.strippedJson.length + 1;
}
// Then keep full continuation data for the retained sessions and shed
// replay-only bulk (ciphertext, oversized tool output) from the oldest
// retained sessions only as needed, never touching the protected newest
// session. Within one session, shed it one message at a time from oldest to
// newest so a long active chat can retain its most recent replayable tool
// exchange when that still fits.
// Then keep full continuation data for the retained sessions and remove
// replay-only ciphertext from the oldest retained sessions only as needed,
// never touching the protected newest session. Within one session, remove
// it one message at a time from oldest to newest so a long active chat can
// retain its most recent replayable tool exchange when that still fits.
let jsonLength = 2 + serialized.reduce((total, entry) => total + entry.json.length, 0)
+ Math.max(0, serialized.length - 1);
const firstStrippableIndex = protectNewestContinuation ? 1 : 0;
@@ -428,27 +378,27 @@ export function serializeSessionsForStorage(
index -= 1
) {
const current = serialized[index];
let degradedMessageCount = 0;
while (jsonLength > budgetBytes && degradedMessageCount < current.degradableMessages.length) {
jsonLength += current.degradableMessages[degradedMessageCount].jsonLengthDelta;
degradedMessageCount += 1;
let strippedMessageCount = 0;
while (jsonLength > budgetBytes && strippedMessageCount < current.ciphertextMessages.length) {
jsonLength += current.ciphertextMessages[strippedMessageCount].jsonLengthDelta;
strippedMessageCount += 1;
}
if (degradedMessageCount > 0) {
const degradedMessagesByIndex = new Map(
current.degradableMessages
.slice(0, degradedMessageCount)
.map(entry => [entry.index, entry.degradedMessage] as const),
if (strippedMessageCount > 0) {
const strippedMessagesByIndex = new Map(
current.ciphertextMessages
.slice(0, strippedMessageCount)
.map(entry => [entry.index, entry.strippedMessage] as const),
);
const nextSession = {
...current.session,
messages: current.session.messages.map((message, messageIndex) => (
degradedMessagesByIndex.get(messageIndex) ?? message
strippedMessagesByIndex.get(messageIndex) ?? message
)),
};
const nextJson = JSON.stringify(nextSession);
const projectedJsonLength = current.json.length
+ current.degradableMessages
.slice(0, degradedMessageCount)
+ current.ciphertextMessages
.slice(0, strippedMessageCount)
.reduce((total, entry) => total + entry.jsonLengthDelta, 0);
jsonLength += nextJson.length - projectedJsonLength;
serialized[index] = {
@@ -8,20 +8,10 @@ import {
shouldRevealTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("backend exited events close the session tab only when auto-close is enabled", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }, true),
{ kind: "closeSession" },
);
});
test("omitted auto-close setting keeps the session tab on a clean exit", () => {
// The fallback for callers that have not loaded the terminal setting yet:
// keep the tab so a jump host / bastion reaping a session (a clean exit)
// never silently deletes the user's scrollback.
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "exited", exitCode: 0 }),
{ kind: "markDisconnected" },
{ kind: "closeSession" },
);
});
@@ -67,19 +57,11 @@ test("backend closed events keep the tab and mark it disconnected", () => {
);
});
test("administrative exit-signal disconnects keep the tab", () => {
assert.deepEqual(
resolveTerminalSessionExitIntent({ reason: "signaled", exitSignal: "SIGHUP" }, true),
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "signaled", exitSignal: "SIGHUP" }), false);
});
test("disabled auto-close keeps command and attached terminal popups open", () => {
@@ -151,17 +133,6 @@ test("exit messages show the reported code for a real remote exit", () => {
);
});
test("an administrative exit-signal renders as a remote signal, not a clean code", () => {
assert.equal(
formatSessionClosedExitMessage({ reason: "signaled", exitSignal: "SIGHUP" }),
"\r\n[session closed by remote signal]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "closed", exitSignal: "SIGTERM" }),
"\r\n[session closed by remote signal]",
);
});
test("a channel closed without exit-status or exit-signal must not render as code 0", () => {
assert.equal(
formatSessionClosedExitMessage({
@@ -179,25 +150,3 @@ test("exit messages without a reported code stay plain instead of showing a sent
);
assert.equal(formatSessionClosedExitMessage({}), "\r\n[session closed]");
});
test("infrastructure teardowns must not render a numeric code as a clean exit (#3631)", () => {
// A terminal-worker exit broadcast carries the worker's own exit status
// (graceful drain = 0). It belongs to the infrastructure failure, not to
// any remote shell, so it must never read as "session closed (code 0)".
assert.equal(
formatSessionClosedExitMessage({
reason: "error",
exitCode: 0,
error: "Terminal worker exited with code 0",
}),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error", exitCode: 1 }),
"\r\n[session closed unexpectedly]",
);
assert.equal(
formatSessionClosedExitMessage({ reason: "error" }),
"\r\n[session closed unexpectedly]",
);
});
@@ -4,10 +4,7 @@ export type TerminalSessionExitEvent = {
exitCode?: number;
signal?: number;
error?: string;
reason?: "exited" | "error" | "timeout" | "closed" | "signaled";
// ssh2 exit-signal name ("SIGHUP", "SIGTERM", ...). Set when the server
// terminated the session process. Distinct from a transport "closed".
exitSignal?: string;
reason?: "exited" | "error" | "timeout" | "closed";
// True when the remote closed the channel without sending exit-status or
// exit-signal (#3591): no numeric exit code exists, and the renderer shows
// a distinct message instead of a misleading "code 0".
@@ -19,13 +16,6 @@ export function formatSessionClosedExitMessage(evt: TerminalSessionExitEvent): s
if (evt?.remoteClosedWithoutExitStatus) {
return "\r\n[session closed by remote (no exit status)]";
}
if (evt?.reason === "signaled" || (typeof evt?.exitSignal === "string" && evt.exitSignal.trim().length > 0)) {
return "\r\n[session closed by remote signal]";
}
// Infrastructure exit codes do not describe the remote shell's status.
if (evt?.reason === "error") {
return "\r\n[session closed unexpectedly]";
}
return `\r\n[session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`;
}
@@ -44,11 +34,7 @@ function isConfirmedCleanExit(evt: TerminalSessionExitEvent): boolean {
export function resolveTerminalSessionExitIntent(
evt: TerminalSessionExitEvent,
// Keep-tab is the safe default: a caller that has not loaded the terminal
// setting yet must not silently delete a session tab. Remote graceful
// channel closes (e.g. a jump host reaping an idle session) are reported
// as a clean exit, so an unavailable setting must not re-enable closing.
autoCloseOnExit = false,
autoCloseOnExit = true,
): TerminalSessionExitIntent {
if (autoCloseOnExit && isConfirmedCleanExit(evt)) {
return { kind: "closeSession" };
@@ -9,14 +9,9 @@ import {
resolveReadableForegroundForHsl,
resolveThemeAccentForeground,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
} from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys.ts";
import { STORAGE_KEY_TERM_FONT_FAMILY } from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
function installMemoryLocalStorage(): Map<string, string> {
@@ -145,72 +140,3 @@ test("app surface theme vars isolate non-terminal pages from active terminal chr
"--ring": "270 70% 45%",
});
});
test("migrateMaterializedAutoCloseOnExitDefault resets the materialized default-on once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.autoCloseOnExit, false);
// Other saved settings pass through untouched.
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault runs once and respects later explicit edits", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
false,
);
// The user re-enables after upgrading: the migration must not undo it again.
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).autoCloseOnExit,
true,
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explicit-off alone", () => {
const store = installMemoryLocalStorage();
try {
// Fresh install: no saved settings at all.
migrateMaterializedAutoCloseOnExitDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
// Explicit-off (or any settings blob without the key) is never rewritten.
delete (globalThis as { localStorage?: unknown }).localStorage;
const store2 = installMemoryLocalStorage();
store2.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 500 }),
);
migrateMaterializedAutoCloseOnExitDefault();
assert.deepEqual(JSON.parse(store2.get(STORAGE_KEY_TERM_SETTINGS)!), {
scrollback: 500,
});
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+1 -29
View File
@@ -1,9 +1,5 @@
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS,
} from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_TERM_FONT_FAMILY } from '../../infrastructure/config/storageKeys';
import {
isDeprecatedPrimaryFontId,
TERMINAL_FONT_AUTO,
@@ -67,30 +63,6 @@ export function migrateIncomingTerminalFontId(raw: string | null | undefined): s
}
return raw;
}
/**
* One-time migration for the autoCloseOnExit default flip (tabs now survive
* session exits by default; the old default-on silently deleted tabs whenever
* a bastion or idle-harvesting server reported a clean exit). Builds before
* the flip shipped autoCloseOnExit: true as the default and materialized it
* into STORAGE_KEY_TERM_SETTINGS on any settings write, so most upgraders
* carry a value they never chose. Leaving it in place would make the new
* default unreachable for them. Reset that materialized `true` exactly once
* and persist the marker; afterwards every stored value is treated as the
* user's own choice and is respected. An inbound cloud or backup payload that
* still carries the pre-migration materialized true (no
* autoCloseOnExitDefaultMigrated flag) is rewritten to false so sync cannot
* resurrect auto-close. The cost: a user who deliberately enabled auto-close
* before this release must re-enable it once.
*/
export function migrateMaterializedAutoCloseOnExitDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored?.autoCloseOnExit === true) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, { ...stored, autoCloseOnExit: false });
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -1,7 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { UploadController, uploadEntriesDirect } from "../../../lib/uploadService";
import { UploadController } from "../../../lib/uploadService";
import {
cancelExternalUploadRuntime,
getExternalUploadController,
@@ -37,64 +37,3 @@ test("external upload controls survive their originating panel unmount", async (
assert.equal(getExternalUploadController("folder-1"), undefined);
assert.equal(getExternalUploadController("child-1"), undefined);
});
for (const selectedFile of ["a.bin", "c.bin"]) {
test(`cancelling independent ${selectedFile} preserves its active and queued upload siblings`, async (t) => {
resetExternalUploadRuntimeForTests();
t.after(resetExternalUploadRuntimeForTests);
const controller = new UploadController();
const cancelled: string[] = [];
const stagedCancelled: string[] = [];
const ids = new Map<string, string>();
const pending = new Map<string, (cancelled?: boolean) => void>();
const started: string[] = [];
const upload = uploadEntriesDirect(
["a.bin", "b.bin", "c.bin"].map((name) => ({
file: null, relativePath: name, isDirectory: false, localPath: `/local/${name}`, size: 8,
})),
{
targetPath: "/remote", sftpId: "fixture", isLocal: false,
fileTransferConcurrency: 2, joinPath: (base, name) => `${base}/${name}`,
callbacks: {
onTaskCreated: (task) => {
ids.set(task.fileName, task.id);
registerExternalUploadController(task.id, controller, "file");
},
},
bridge: {
mkdirSftp: async () => {},
cancelStagedUploadFile: async (id) => { stagedCancelled.push(id); },
cancelTransfer: async (id) => { cancelled.push(id); pending.get(id)?.(true); },
startStreamTransfer: ({ transferId, sourcePath }) => new Promise((resolve) => {
started.push(sourcePath);
pending.set(transferId, (cancelled = false) => resolve({ transferId, cancelled }));
}),
},
},
controller,
);
t.after(async () => {
await controller.cancel();
for (const finish of pending.values()) finish(true);
await upload;
});
await new Promise(setImmediate);
assert.deepEqual(started, ["/local/a.bin", "/local/b.bin"]);
const selectedId = ids.get(selectedFile)!;
await cancelExternalUploadRuntime(selectedId);
assert.deepEqual(cancelled, [selectedId]);
assert.deepEqual(stagedCancelled, [selectedId]);
assert.equal(controller.isCancelled(), false);
await new Promise(setImmediate);
assert.deepEqual(started, selectedFile === "a.bin"
? ["/local/a.bin", "/local/b.bin", "/local/c.bin"]
: ["/local/a.bin", "/local/b.bin"]);
for (const [id, finish] of pending) if (id !== selectedId) finish();
const results = (await upload).sort((a, b) => a.fileName.localeCompare(b.fileName));
assert.deepEqual(results, ["a.bin", "b.bin", "c.bin"].map((fileName) => (
fileName === selectedFile
? { fileName, success: false, cancelled: true }
: { fileName, success: true }
)));
});
}
@@ -5,13 +5,11 @@ import type { UploadController } from "../../../lib/uploadService";
// terminal only removes the view; the global transfer runtime can still cancel
// the upload, and the upload's own finally block releases these entries.
const controllersByTaskId = new Map<string, UploadController>();
const scopesByTaskId = new Map<string, "file" | "batch">();
const taskIdsByController = new Map<UploadController, Set<string>>();
export function registerExternalUploadController(
taskId: string,
controller: UploadController,
scope: "file" | "batch" = "batch",
): void {
if (!taskId) return;
const previous = controllersByTaskId.get(taskId);
@@ -21,7 +19,6 @@ export function registerExternalUploadController(
if (previousIds?.size === 0) taskIdsByController.delete(previous);
}
controllersByTaskId.set(taskId, controller);
scopesByTaskId.set(taskId, scope);
const taskIds = taskIdsByController.get(controller) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByController.set(controller, taskIds);
@@ -33,7 +30,6 @@ export function unregisterExternalUploadController(controller: UploadController)
for (const taskId of taskIds) {
if (controllersByTaskId.get(taskId) === controller) {
controllersByTaskId.delete(taskId);
scopesByTaskId.delete(taskId);
}
}
taskIdsByController.delete(controller);
@@ -44,10 +40,6 @@ export function getExternalUploadController(taskId: string): UploadController |
}
export async function cancelExternalUploadRuntime(taskId?: string): Promise<boolean> {
if (taskId && scopesByTaskId.get(taskId) === "file") {
await controllersByTaskId.get(taskId)?.cancelFile(taskId);
return true;
}
const controllers = taskId
? [controllersByTaskId.get(taskId)].filter((value): value is UploadController => !!value)
: [...taskIdsByController.keys()];
@@ -58,6 +50,5 @@ export async function cancelExternalUploadRuntime(taskId?: string): Promise<bool
export function resetExternalUploadRuntimeForTests(): void {
controllersByTaskId.clear();
scopesByTaskId.clear();
taskIdsByController.clear();
}
@@ -201,8 +201,8 @@ export const useSftpExternalOperations = (
return { sftpId, release: () => {} };
}, [ensureRemoteSftpId, getActivePane, getPaneByConnectionId, getPaneByTabId, getSideByTabId, sftpSessionsRef]);
const registerUploadController = useCallback((taskId: string, controller: UploadController, scope: "file" | "batch" = "batch") => {
registerExternalUploadController(taskId, controller, scope);
const registerUploadController = useCallback((taskId: string, controller: UploadController) => {
registerExternalUploadController(taskId, controller);
}, []);
const unregisterUploadController = useCallback((controller: UploadController) => {
@@ -222,7 +222,7 @@ export const useSftpExternalOperations = (
callbacks.onScanningProgress?.(taskId, progress);
},
onTaskCreated: (task) => {
registerUploadController(task.id, controller, !task.isDirectory && !task.parentTaskId ? "file" : "batch");
registerUploadController(task.id, controller);
if (task.parentTaskId) {
registerUploadController(task.parentTaskId, controller);
}
@@ -58,52 +58,6 @@ test("normal shell exits do not auto reconnect", () => {
);
});
test("administrative idle, timeout, and exit-signal disconnects do not auto reconnect", () => {
for (const evt of [
{ reason: "timeout" as const },
{ reason: "timeout" as const, exitCode: 0 },
{ reason: "timeout" as const, error: "Connection timeout to example.com" },
{ reason: "signaled" as const, exitSignal: "SIGHUP" },
{ reason: "signaled" as const, exitSignal: "SIGTERM" },
{ reason: "signaled" as const },
// A path that still says "closed" but plumbed the exit-signal is the same kick.
{ reason: "closed" as const, exitSignal: "SIGUSR2" },
{ reason: "closed" as const, exitSignal: " SIGHUP " },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
false,
);
}
});
test("transport drops still auto reconnect", () => {
for (const evt of [
{ reason: "closed" as const },
{ reason: "error" as const, error: "connection reset" },
{ reason: "closed" as const, remoteClosedWithoutExitStatus: true },
{ reason: "closed" as const, exitSignal: "" },
{ reason: "closed" as const, exitSignal: " " },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "" },
{ reason: "error" as const, error: "read ECONNRESET", exitSignal: "SIGHUP" },
]) {
assert.equal(
shouldAutoReconnectAfterExit({
evt,
host: sshHost,
terminalSettings: { sshAutoReconnectEnabled: true },
hasEverConnected: true,
}),
true,
);
}
});
test("auto reconnect ignores protocols that already have different lifecycle semantics", () => {
const variants = [
{ protocol: "local" as const, hostname: "localhost" },
+1 -10
View File
@@ -40,16 +40,7 @@ export function shouldAutoReconnectAfterExit({
if (!hasEverConnected) return false;
if (!isTerminalAutoReconnectEnabled(terminalSettings)) return false;
if (!isAutoReconnectableSshHost(host)) return false;
// "exited" is a shell exit. "timeout" is an administrative idle/TMOUT logout
// (or a connect timeout). "signaled" — or a plumbed exit-signal on an
// otherwise "closed" end — is a server kick (idle policy, SIGHUP, SIGTERM).
// Reconnecting would fight that policy. Transport "error" and bare "closed"
// still retry.
if (evt.reason === "error") return true;
if (evt.reason === "exited" || evt.reason === "timeout" || evt.reason === "signaled") {
return false;
}
return typeof evt.exitSignal !== "string" || evt.exitSignal.trim().length === 0;
return evt.reason !== "exited";
}
export function shouldContinueAutoReconnectAfterFailure({
-33
View File
@@ -86,11 +86,6 @@ import {
retargetWorkspaceActiveChatAfterMemberLoss,
seedWorkspaceAIActiveSessionFromMembers,
} from '../../domain/workspaceAiScopeHandoff';
import {
buildUndoLastTurnBranch,
type UndoLastTurnRestoredDraft,
} from '../../domain/aiUndoLastTurn';
function providerPatchIsNoop(
current: ProviderConfig,
@@ -702,32 +697,6 @@ export function useAIState() {
return session;
}, [defaultAgentId, persistSessions, setActiveSessionId]);
// Non-destructive "undo last turn": branch the session at the boundary
// before the latest user message and keep the original in history intact.
// Returns the new branch id plus the undone user message for the composer.
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
sessionId: string;
restored: UndoLastTurnRestoredDraft;
} | null> => {
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const source = sessionsRef.current.find(s => s.id === sessionId);
if (!source) return null;
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
if (!result) return null;
const branched = result.session;
setSessionsRaw(prev => {
// Keep both recently used chats within the existing history limit.
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
setActiveSessionId(scopeKey, branched.id);
return { sessionId: branched.id, restored: result.restored };
}, [persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupDeletedAIChatSessions([sessionId]);
if (persistTimerRef.current) {
@@ -1306,7 +1275,6 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1367,7 +1335,6 @@ export function useAIState() {
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
@@ -1,194 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
import { useAIState } from './useAIState.ts';
import { useAISessionsStore } from './aiSessionsStore.ts';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
const SCOPE_KEY = 'terminal:terminal-a';
const SOURCE_SESSION: AISession = {
id: 'chat-source',
title: 'Undo source',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-a' },
messages: [
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
{
id: 'user-2',
role: 'user',
content: 'bad last prompt',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
],
},
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
],
createdAt: 1,
updatedAt: 4,
};
type HookCapture = {
ai: ReturnType<typeof useAIState> | null;
sessions: AISession[];
activeSessionIdMap: Record<string, string | null>;
};
function UndoHarness({ capture }: { capture: HookCapture }) {
capture.ai = useAIState();
const store = useAISessionsStore();
capture.sessions = store.sessions as AISession[];
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
return null;
}
async function setupAiState(sessions: AISession[]) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
url: 'http://localhost',
});
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
setLatestAISessionsSnapshot(sessions);
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
await act(async () => {
root.render(React.createElement(UndoHarness, { capture }));
});
return { dom, container, root, capture };
}
test('undoLastTurnInSession branches and restores the last user turn', async () => {
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
assert.ok(capture.ai);
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
async function runUndo() {
return capture.ai!.undoLastTurnInSession('chat-source');
}
await act(async () => {
result = await runUndo();
});
assert.ok(result);
assert.notEqual(result!.sessionId, 'chat-source');
const sessions = capture.sessions;
const branch = sessions.find((session) => session.id === result!.sessionId);
assert.ok(branch);
// Branch keeps the conversation up to the boundary before the last turn.
assert.deepEqual(
branch!.messages.map((message) => message.content),
['first prompt', 'first answer'],
);
// Original session stays intact (non-destructive).
const original = sessions.find((session) => session.id === 'chat-source');
assert.equal(original?.messages.length, 4);
// Branch becomes the active session for the scope.
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
const attachments = result!.restored.attachments as UploadedFile[];
assert.equal(result!.restored.text, 'bad last prompt');
assert.equal(attachments.length, 1);
assert.equal(attachments[0].filename, 'note.txt');
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
const emptySession: AISession = {
...SOURCE_SESSION,
id: 'chat-empty',
messages: [],
};
const { dom, container, root, capture } = await setupAiState([emptySession]);
assert.ok(capture.ai);
let result: unknown;
await act(async () => {
result = await capture.ai!.undoLastTurnInSession('chat-empty');
});
assert.equal(result, null);
await act(async () => root.unmount());
container.remove();
dom.window.close();
});
test('undo copies chat only, preserving original outputs and job ownership', async () => {
const runtime = getAgentRuntime();
const outputStore = runtime.getToolOutputStore('chat-source');
const handle = outputStore.store({
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
});
const stateStore = runtime.getSessionStateStore();
stateStore.updateFromToolResult('chat-source', 'terminal_start',
{ sessionId: 'terminal-a', command: 'long-running-command' },
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
const source = structuredClone(SOURCE_SESSION);
source.messages[1].content = `Preview with handleId=${handle.id}`;
const { dom, container, root, capture } = await setupAiState([source]);
const original = JSON.stringify(source);
let result: { sessionId: string } | null = null;
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
assert.ok(result);
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
stateStore.clear(source.id);
outputStore.prune(source.id);
await act(async () => root.unmount());
container.remove(); dom.window.close();
});
for (const newerCount of [49, 50]) {
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
const newer = Array.from({ length: newerCount }, (_, index) => ({
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
}));
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
let branchId: string | undefined;
await act(async () => {
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
});
assert.ok(branchId);
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
assert.equal(saved.length, 50);
const original = saved.find(item => item.id === SOURCE_SESSION.id);
assert.ok(original, 'the original must survive updatedAt-based history pruning');
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
await act(async () => root.unmount());
container.remove(); dom.window.close();
// Remount the actual hook from the persisted records, as on app reload.
const reloaded = await setupAiState(saved);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
await act(async () => reloaded.root.unmount());
reloaded.container.remove(); reloaded.dom.window.close();
});
}
@@ -1,8 +1,5 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
bumpExternalMcpEnableGenerationForTests,
@@ -21,7 +18,6 @@ import {
resetExternalMcpStartupSyncOnceForTests,
shouldStartExternalMcpOnStartup,
shouldWaitForExternalMcpStartupReady,
syncExternalMcpConfig,
syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady,
@@ -67,44 +63,10 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(null), 10);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 30);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
});
for (const savedMinutes of [null, 30]) {
it(`syncs the session idle timeout without replacing saved value ${savedMinutes}`, async () => {
const restore = installMemoryLocalStorage();
const manager = createSessionIdleManager();
try {
assert.equal(manager.getTimeoutMinutes(), 8 * 60, 'main-process default');
if (savedMinutes !== null) {
localStorageAdapter.writeNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, savedMinutes);
}
const expected = savedMinutes ?? manager.getTimeoutMinutes();
const received: number[] = [];
const bridge = {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
},
};
await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal(
localStorageAdapter.readNumber(STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES),
savedMinutes,
'sync does not migrate or overwrite the stored preference',
);
} finally {
manager.clearAll();
restore();
}
});
}
it('only starts on launch for persistent+enabled', () => {
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'persistent' }), true);
assert.equal(shouldStartExternalMcpOnStartup({ enabled: true, mode: 'temporary' }), false);
@@ -18,7 +18,7 @@ export type ExternalMcpMode = 'temporary' | 'persistent';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
const MAX_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 24 * 60;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
/** Keep top-bar / settings switch state aligned with runtime auto-disable. */
export const EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS = 3000;
@@ -1,148 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: effective });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
return {
find: () => find(initial.id),
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
keys = input.keys ?? keys;
identities = input.identities ?? identities;
sessions = input.sessions ?? sessions;
await act(async () => renderer.update(React.createElement(Harness)));
},
close: () => act(async () => renderer.unmount()),
};
}
for (const [field, value] of [
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
['hostChain', { hostIds: ['jump'] }],
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
] as const) {
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
const original = host({ password: 'old-password', authMethod: 'password' });
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
assert.equal((await api.find())?.sessionId, 'tab');
});
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
await api.update({ hosts: [original, changed] });
assert.equal(await api.find(), undefined);
}
});
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
const original = host({ identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
assert.equal(await api.find(), undefined);
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
const keyApi = await harness(keyHost, { keys: [key] });
t.after(keyApi.close);
assert.equal((await keyApi.find())?.sessionId, 'tab');
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
assert.equal(await keyApi.find(), undefined);
});
for (const [field, value] of [
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
] as const) {
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
assert.equal(await api.find(), undefined);
});
}
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
});
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
t.after(api.close);
assert.equal(await api.find(), undefined);
});
for (const changedField of ['configuration', 'credentialId'] as const) {
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness(edited, { sessions: [restored] });
t.after(api.close);
assert.equal(await api.find(), undefined);
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
await api.update({ sessions: reconnecting });
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
});
}
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
t.after(api.close);
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
@@ -1,85 +0,0 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { findReusableHostSession } from '../../domain/hostOpenSession';
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
import { createHostTerminalSession } from './sessionFactories';
type HostOpenSessionReuseInput = {
sessions: TerminalSession[];
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
resolveEffectiveHost: (host: Host) => Host;
};
/** Retain the opening connection identity, never credentials in session storage. */
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, Promise<string | undefined>>());
const connectionKey = useCallback((host: Host) => {
// Reuse the existing credential/chain expansion and transport identity
// comparison. Presentation names and SFTP-only choices do not change a
// terminal's destination or login.
const options = buildSftpHostCredentials({
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
});
delete options.sudo;
delete options.fileProtocol;
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol: host.protocol || 'ssh',
moshEnabled: host.moshEnabled,
moshServerPath: host.moshServerPath,
etEnabled: host.etEnabled,
etPort: host.etPort,
pluginConnection: host.pluginConnection,
serialConfig: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
agentForwarding: host.agentForwarding,
x11Forwarding: host.x11Forwarding,
};
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
}, [hosts, keys, identities, resolveEffectiveHost]);
useLayoutEffect(() => {
const liveIds = new Set(sessions.map((session) => session.id));
for (const id of openingKeys.current.keys()) {
if (!liveIds.has(id)) openingKeys.current.delete(id);
}
for (const session of sessions) {
if (openingKeys.current.has(session.id) || session.restoreState) continue;
const host = hosts.find((candidate) => candidate.id === session.hostId);
if (!host) continue;
try {
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.username,
port: session.port ?? host.port,
protocol: session.protocol ?? host.protocol,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
}).catch(() => undefined));
} catch {
openingKeys.current.set(session.id, Promise.resolve(undefined));
}
}
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
return useCallback(async (hostId: string) => {
const host = hosts.find((candidate) => candidate.id === hostId);
if (!host) return undefined;
try {
const requestedKey = await connectionKey(resolveEffectiveHost(host));
const matches = await Promise.all(sessions.map(async (session) => (
session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined
)));
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
} catch {
// An unresolved/changed credential must never select an old transport.
return undefined;
}
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
}
-4
View File
@@ -151,7 +151,6 @@ import {
isValidUiFontId,
isValidUiThemeId,
migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault,
readStoredString,
serializeTerminalSettings,
type HostClickBehavior,
@@ -356,9 +355,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
passwordVerifier: null,
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored);
});
-2
View File
@@ -53,7 +53,6 @@ export interface UseVaultAgentBridgeInput {
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -272,7 +271,6 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
-44
View File
@@ -1667,50 +1667,6 @@ test("terminal auto-close preference survives sync round-trip", async () => {
assert.equal(restored.autoCloseOnExit, false);
});
test("inbound sync does not resurrect the materialized auto-close default after the marker", async () => {
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, "true");
try {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload({
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { autoCloseOnExit: true } },
}, { importVaultData: () => {} });
const resurrected = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(resurrected.autoCloseOnExit, false);
assert.equal(resurrected.scrollback, 3000);
// An explicit opt-in made after the migration round-trips with a marker.
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: true, scrollback: 3000 }),
);
const optedIn = buildSyncPayload(vault());
assert.equal(optedIn.settings?.autoCloseOnExitDefaultMigrated, true);
assert.equal(optedIn.settings?.terminalSettings?.autoCloseOnExit, true);
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ autoCloseOnExit: false, scrollback: 3000 }),
);
await applySyncPayload(optedIn, { importVaultData: () => {} });
const restoredChoice = JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(restoredChoice.autoCloseOnExit, true);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED), "true");
} finally {
localStorage.removeItem(storageKeys.STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED);
}
});
test("terminal disconnected notice preference survives sync round-trip", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
-22
View File
@@ -25,7 +25,6 @@ import {
SYNC_PAYLOAD_ENTITY_KEYS,
SYNC_STORAGE_KEYS,
hasSyncPayloadEntityData,
reconcileInboundAutoCloseOnExit,
retainLocalHostLastConnectedAt,
sanitizeHostsForSync,
type SyncPayload,
@@ -63,7 +62,6 @@ import {
} from './state/customAccentSync';
import { isValidHslToken } from './state/settingsStateDefaults';
import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
@@ -507,9 +505,6 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
} catch { /* ignore corrupt data */ }
}
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED)) {
settings.autoCloseOnExitDefaultMigrated = true;
}
// Custom terminal themes
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
@@ -800,23 +795,6 @@ async function applySyncableSettings(
? 'disabled'
: 'paste';
}
if ('autoCloseOnExit' in settings.terminalSettings) {
merged.autoCloseOnExit = reconcileInboundAutoCloseOnExit(
settings.terminalSettings.autoCloseOnExit,
settings.autoCloseOnExitDefaultMigrated,
);
}
// A legacy true is the old default. A migrated peer's explicit choice must
// stick, so record the marker before the next local migration pass.
if (
settings.autoCloseOnExitDefaultMigrated === true
|| (
settings.terminalSettings.autoCloseOnExit === true
&& settings.autoCloseOnExitDefaultMigrated !== true
)
) {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
+1 -23
View File
@@ -1,5 +1,5 @@
import React, { type Dispatch, type SetStateAction } from 'react';
import { History, Plus, Undo2 } from 'lucide-react';
import { History, Plus } from 'lucide-react';
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
import type { Host, VaultNote } from '../types';
import type { UserSkillOption } from './ai/userSkillsState';
@@ -55,9 +55,6 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
canCompact?: boolean;
handleSteer: () => void;
handleStop: () => void;
@@ -135,8 +132,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
handleSteer,
handleStop,
@@ -237,23 +232,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
{handleUndoLastTurn && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
aria-label={t('ai.chat.undoLastTurn')}
disabled={sending || isStreaming || !canUndoLastTurn}
onClick={handleUndoLastTurn}
>
<Undo2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
</Tooltip>
)}
<ConversationExport
session={activeSession}
onExport={handleExport}
@@ -120,9 +120,7 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
test('send preflight locks header agent and new-chat controls', () => {
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
// `sending` also covers an in-flight undo-last-turn so the header controls
// (and a quick send) stay locked while the branch materializes.
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
assert.match(panel, /sending=\{isSending\}/);
assert.match(content, /disabled=\{sending\}/);
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
+4 -93
View File
@@ -65,7 +65,6 @@ import {
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
@@ -270,7 +269,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addDraftFiles,
removeDraftFile,
createSession,
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
@@ -321,11 +319,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
// is tracked separately to block sends until the branch settles: a turn
// submitted meanwhile would run against the (soon hidden) source session.
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
// Tracks SDK runtime model catalogs currently loading (per agent scope):
@@ -828,78 +821,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
&& Boolean(effectiveActiveProvider)
&& Boolean(effectiveActiveModelId.trim());
// Undo last turn: offered only at a safe boundary (never inside a compacted
// prefix, never between an assistant tool call and its result).
const canUndoLastTurn = useMemo(() => Boolean(
activeSession
&& !isStreaming
&& resolveUndoLastTurnBoundary(
activeSession.messages,
activeSession.contextCompaction?.compactedMessageCount ?? 0,
) != null,
), [activeSession, isStreaming]);
const undoInFlightRef = useRef(false);
const handleUndoLastTurn = useCallback(async () => {
const sessionId = activeSessionRef.current?.id;
if (!sessionId || !undoLastTurnInSession) return;
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
// click must not start a parallel undo of the same session — that would
// publish duplicate branches and restore the same prompt twice.
if (undoInFlightRef.current) return;
undoInFlightRef.current = true;
setIsUndoingLastTurn(true);
try {
const result = await undoLastTurnInSession(sessionId);
if (!result) return;
ensureScopeDraft(currentAgentId);
// Text typed since the last flush lives only in the live composer buffer
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
// merge from the live buffer so it is not lost, then drop the pending
// buffer so the later flushDraftText() cannot overwrite the merged text.
const liveDraft = currentDraftRef.current;
updateScopeDraft(currentAgentId, (draft) => {
const liveText = liveDraft?.text ?? draft.text;
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
return {
...draft,
// Keep anything the user is still typing; the undone prompt goes first.
text: liveText.trim()
? `${result.restored.text}\n\n${liveText}`
: result.restored.text,
attachments: [
...result.restored.attachments,
...(liveDraft?.attachments ?? draft.attachments),
],
// The undone turn was selected with these pills; keep any pills the
// user picked since the send on top of them.
selectedUserSkillSlugs: [
...result.restored.selectedUserSkillSlugs,
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
],
};
});
discardPendingComposerText();
showScopeSessionView(result.sessionId);
// Tool side effects live outside the conversation and cannot be rolled back.
toast.info(t('ai.chat.undoLastTurnNotice'));
} finally {
undoInFlightRef.current = false;
setIsUndoingLastTurn(false);
}
}, [
discardPendingComposerText,
ensureScopeDraft,
isStreaming,
showScopeSessionView,
t,
undoLastTurnInSession,
updateScopeDraft,
currentAgentId,
]);
const providersRef = useRef(providers);
providersRef.current = providers;
@@ -1256,8 +1177,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
const canSendCurrentAgent = useMemo(
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
[inputAgentId, externalAgents, isSending],
);
const handleAgentModelSelect = useCallback((modelId: string) => {
@@ -1393,10 +1314,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}));
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
// While undo-last-turn is creating the branch, the target is still the source; a
// turn submitted now would run in the soon-hidden source session, so
// block it until the branch settles.
if (isUndoingLastTurn) return;
// Note-only sends (empty text + a mentioned note) still need a usable
// session title: fall back to the first mentioned note's title so these
// conversations don't all show up as "Untitled" in history.
@@ -1521,7 +1438,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
@@ -1536,7 +1452,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
@@ -1550,8 +1465,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
// Kept on the user message so undo last turn can restore the pills.
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
timestamp: Date.now(),
});
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
@@ -1633,7 +1546,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}, [
validateNoteMentions, loadCodexConfigModel,
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope,
@@ -1921,7 +1834,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
>
<AIChatPanelContent
parked={!isVisible}
sending={isSending || isUndoingLastTurn}
sending={isSending}
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
@@ -1951,8 +1864,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
handleStop={handleStop}
canSteer={canSteerCurrentTurn}
-4
View File
@@ -37,10 +37,6 @@ export interface AIChatSidePanelProps {
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
createSession: (scope: AISessionScope, agentId?: string) => AISession;
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
undoLastTurnInSession?: (
sessionId: string,
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
+1 -3
View File
@@ -669,9 +669,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSessionExit = useCallback((sessionId: string, evt: TerminalSessionExitEvent) => {
const intent = resolveTerminalSessionExitIntent(
evt,
// No terminal settings loaded yet -> do not close the tab; auto-close is
// opt-in via Settings -> Terminal -> Behavior (DEFAULT_TERMINAL_SETTINGS).
terminalSettings?.autoCloseOnExit ?? false,
terminalSettings?.autoCloseOnExit ?? true,
);
if (intent.kind === "closeSession") {
handleCloseSession(sessionId);
+1 -1
View File
@@ -162,7 +162,7 @@ test('attach popup close preparation has a bounded timeout', () => {
test('terminal exit auto-close setting reaches tabs, workspaces, and popups', () => {
assert.match(
terminalLayerSource,
/resolveTerminalSessionExitIntent\(\s+evt,[\s\S]*?terminalSettings\?\.autoCloseOnExit \?\? false,\s+\)/,
/resolveTerminalSessionExitIntent\(\s+evt,\s+terminalSettings\?\.autoCloseOnExit \?\? true,\s+\)/,
);
assert.match(
terminalSource,
@@ -1,86 +0,0 @@
import assert from "node:assert/strict";
import test, { type TestContext } from "node:test";
import React from "react";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings";
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from "../../test-support/renderReactDom";
async function setup(t: TestContext, label: string) {
const env = installDomEnvironment();
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
DocumentFragment: env.window.DocumentFragment,
MutationObserver: env.window.MutationObserver,
Element: env.window.Element,
requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0),
cancelAnimationFrame: clearTimeout,
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const { default: SettingsShortcutsTab } = await import("./SettingsShortcutsTab");
const { I18nProvider } = await import("../../../application/i18n/I18nProvider");
const { Tabs } = await import("../../ui/tabs");
const renderer = await createDomRenderer(env.document);
t.after(async () => {
await renderer.unmount();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
env.cleanup();
});
const updates: string[][] = [];
await renderer.render(<I18nProvider locale="en"><Tabs value="shortcuts"><SettingsShortcutsTab
hotkeyScheme="pc" setHotkeyScheme={() => {}}
shellOnlyTabNumberShortcuts={false} setShellOnlyTabNumberShortcuts={() => {}}
showTabNumberBadges={true} setShowTabNumberBadges={() => {}}
disableTerminalFontZoom={false} setDisableTerminalFontZoom={() => {}}
keyBindings={DEFAULT_KEY_BINDINGS} resetAllKeyBindings={() => {}}
updateKeyBinding={(...args) => { updates.push(args); }}
/></Tabs></I18nProvider>);
const row = [...env.document.querySelectorAll("span")].find((span) => span.textContent === label)?.parentElement;
const button = row?.querySelector("button");
assert.ok(button);
await dispatchDomEvent(button, new env.window.MouseEvent("click", { bubbles: true }));
const key = (type: "keydown" | "keyup", init: KeyboardEventInit) => dispatchDomEvent(
env.window, new env.window.KeyboardEvent(type, { bubbles: true, cancelable: true, ...init }),
);
return { updates, key, button };
}
test("the tab modifier editor saves left Alt on release without requiring a digit", async (t) => {
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Alt", code: "AltLeft", altKey: true });
assert.deepEqual(updates, []);
await key("keyup", { key: "Alt", code: "AltLeft" });
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Alt + [1...9]"]]);
});
test("the modifier editor waits for the full modifier chord to be released", async (t) => {
const { updates, key } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Control", ctrlKey: true });
await key("keydown", { key: "Alt", ctrlKey: true, altKey: true });
await key("keyup", { key: "Alt", ctrlKey: true });
assert.deepEqual(updates, []);
await key("keyup", { key: "Control" });
assert.deepEqual(updates, [["switch-tab-1-9", "pc", "Ctrl + Alt + [1...9]"]]);
});
test("ordinary shortcuts still require a non-modifier key", async (t) => {
const { updates, key } = await setup(t, "Next Tab");
await key("keydown", { key: "Alt", altKey: true });
await key("keyup", { key: "Alt" });
assert.deepEqual(updates, []);
await key("keydown", { key: "ArrowRight", altKey: true });
assert.deepEqual(updates, [["next-tab", "pc", "Alt + \u2192"]]);
});
test("Escape cancels an unfinished modifier recording", async (t) => {
const { updates, key, button } = await setup(t, "Switch to Tab [1...9]");
await key("keydown", { key: "Alt", altKey: true });
await key("keydown", { key: "Escape", altKey: true });
await key("keyup", { key: "Alt" });
assert.deepEqual(updates, []);
assert.equal(button.textContent, "Ctrl");
});
@@ -64,7 +64,6 @@ export default function SettingsShortcutsTab(props: {
if (!recordingBindingId || !recordingScheme) return;
const specialSuffix = getSpecialSuffix(recordingBindingId);
let pendingModifierBinding: string | null = null;
const handleKeyDown = (e: KeyboardEvent) => {
// Skip while app lock overlay is up so password keys never bind (Codex P2).
@@ -78,6 +77,8 @@ export default function SettingsShortcutsTab(props: {
}
if (specialSuffix) {
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const parts: string[] = [];
if (recordingScheme === "mac") {
if (e.metaKey) parts.push("⌘");
@@ -94,11 +95,6 @@ export default function SettingsShortcutsTab(props: {
const modifierString = parts.length > 0 ? `${parts.join(" + ")} + ` : "";
const fullKeyString = modifierString + specialSuffix;
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) {
pendingModifierBinding = fullKeyString;
return;
}
updateKeyBinding?.(recordingBindingId, recordingScheme, fullKeyString);
cancelRecording();
return;
@@ -110,17 +106,6 @@ export default function SettingsShortcutsTab(props: {
cancelRecording();
};
const handleKeyUp = (e: KeyboardEvent) => {
if (!specialSuffix || !pendingModifierBinding || isAppLockOverlayActive()) return;
if (!["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
e.preventDefault();
e.stopPropagation();
// Wait for release so a modifier-only editor can capture Ctrl+Alt, etc.
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
updateKeyBinding?.(recordingBindingId, recordingScheme, pendingModifierBinding);
cancelRecording();
};
const handleClick = () => {
cancelRecording();
};
@@ -130,11 +115,9 @@ export default function SettingsShortcutsTab(props: {
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
window.addEventListener("keyup", handleKeyUp, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("click", handleClick, true);
};
}, [recordingBindingId, recordingScheme, updateKeyBinding, cancelRecording, getSpecialSuffix]);
@@ -1,258 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
attachWindowResizeFocusRestore,
WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
type MinimalDocument,
} from "./restoreWindowResizeFocus";
type FakeWindow = {
listeners: Map<string, Array<() => void>>;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
emit: (type: string) => void;
};
const createWindowStub = (): FakeWindow => {
const listeners = new Map<string, Array<() => void>>();
return {
listeners,
addEventListener(type, listener) {
const bucket = listeners.get(type) ?? [];
bucket.push(listener);
listeners.set(type, bucket);
},
removeEventListener(type, listener) {
const bucket = listeners.get(type) ?? [];
const index = bucket.indexOf(listener);
if (index >= 0) bucket.splice(index, 1);
},
emit(type) {
for (const listener of [...(listeners.get(type) ?? [])]) listener();
},
};
};
type SchedulerState = {
schedule: (callback: () => void, delay: number) => number;
cancel: (id: number) => void;
runNext: () => void;
pendingCount: () => number;
};
const createScheduler = (): SchedulerState => {
type Entry = { id: number; callback: () => void; cancelled: boolean };
const queue: Entry[] = [];
let nextId = 1;
return {
schedule(callback, delay) {
assert.equal(delay, WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS);
queue.push({ id: nextId, callback, cancelled: false });
return nextId++;
},
cancel(id) {
for (const entry of queue) {
if (entry.id === id) entry.cancelled = true;
}
},
runNext() {
while (queue.length) {
const entry = queue.shift()!;
if (!entry.cancelled) {
entry.callback();
return;
}
}
},
pendingCount: () => queue.filter((entry) => !entry.cancelled).length,
};
};
test("resize restores terminal focus once the resize settles", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
assert.equal(scheduler.pendingCount(), 1, "restore must be debounced");
assert.equal(refocusCalls, 0);
scheduler.runNext();
assert.equal(refocusCalls, 1);
detach();
});
test("a continuous frame drag only triggers one restore", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
win.emit("resize");
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 1, "debounced resizes must collapse into one restore");
detach();
});
test("resize does not steal focus when the app window is not focused", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => false } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize restore is skipped while a modal dialog is open", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: (selector: string) => (
selector === '[role="dialog"][data-state="open"]' ? {} : null
),
hasFocus: () => true,
} as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize does not steal focus from an active editable field", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: { tagName: "INPUT", matches: () => false },
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0, "restore must not yank focus out of an editable field");
detach();
});
test("resize does not steal focus from a contenteditable field", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: { tagName: "DIV", isContentEditable: true, matches: () => false },
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 0);
detach();
});
test("resize still restores while the xterm helper textarea is focused", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: {
querySelector: () => null,
hasFocus: () => true,
activeElement: {
tagName: "TEXTAREA",
matches: (selector: string) => selector === "textarea.xterm-helper-textarea",
},
} as unknown as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
scheduler.runNext();
assert.equal(refocusCalls, 1, "terminal-owned focus must be restored after resize");
detach();
});
test("detach removes the listener and cancels a pending restore", () => {
const win = createWindowStub();
const scheduler = createScheduler();
let refocusCalls = 0;
const detach = attachWindowResizeFocusRestore({
win,
doc: { querySelector: () => null, hasFocus: () => true } as MinimalDocument,
refocus: () => { refocusCalls += 1; },
scheduleTimeout: scheduler.schedule,
cancelTimeout: scheduler.cancel,
});
win.emit("resize");
detach();
assert.equal((win.listeners.get("resize") ?? []).length, 0, "listener must be removed");
assert.equal(scheduler.pendingCount(), 0, "pending restore must be cancelled");
scheduler.runNext();
assert.equal(refocusCalls, 0);
});
test("a win without listener support degrades to a no-op detach", () => {
const detach = attachWindowResizeFocusRestore({
win: null,
refocus: () => {},
scheduleTimeout: () => 1,
cancelTimeout: () => {},
});
assert.doesNotThrow(() => detach());
});
@@ -1,104 +0,0 @@
import { hasOpenAppDialog } from "./focusTerminalSession";
/** Debounce so a continuous frame drag (or maximize transition) triggers one restore. */
export const WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS = 120;
export type MinimalWindow = {
addEventListener?: (type: string, listener: () => void) => unknown;
removeEventListener?: (type: string, listener: () => void) => unknown;
};
export type MinimalActiveElement = {
tagName?: string | null;
isContentEditable?: boolean;
matches?: (selector: string) => boolean;
};
export type MinimalDocument = Parameters<typeof hasOpenAppDialog>[0] & {
hasFocus?: () => boolean;
activeElement?: MinimalActiveElement | null;
};
/**
* True when keyboard focus sits in an editable app field (search box, chat
* input, SFTP filter, inline rename, ...) that is not an xterm helper
* textarea. Resize-driven restores must not yank focus away from such fields.
* Focus parked on `body`/`html` (or an xterm helper textarea) does not count
* as editing another field.
*/
export const hasActiveEditableElement = (doc: MinimalDocument | null): boolean => {
const element = doc?.activeElement;
if (!element) return false;
if (typeof element.matches === "function" && element.matches("textarea.xterm-helper-textarea")) {
return false;
}
const tagName = typeof element.tagName === "string" ? element.tagName.toLowerCase() : "";
return (
tagName === "input"
|| tagName === "textarea"
|| tagName === "select"
|| Boolean(element.isContentEditable)
);
};
type AttachWindowResizeFocusRestoreOptions = {
win?: MinimalWindow | null;
doc?: MinimalDocument | null;
refocus?: () => void;
delayMs?: number;
scheduleTimeout?: (callback: () => void, delay: number) => unknown;
cancelTimeout?: (id: unknown) => void;
};
/**
* #3647: dragging the OS window frame (or maximizing) leaves the active xterm
* helper textarea unfocused, so keystrokes do nothing until the user clicks
* back into the terminal. Attach a window `resize` listener that re-runs the
* tab-change refocus path after the resize settles.
*
* Restore is skipped when the app window does not hold OS input focus, when a
* modal dialog is open, or when an editable element other than the xterm
* helper textarea already holds focus, so the restore never steals typing
* from elsewhere.
*/
export const attachWindowResizeFocusRestore = (
{
win = typeof window !== "undefined" ? window : null,
doc = typeof document !== "undefined" ? document : null,
refocus,
delayMs = WINDOW_RESIZE_RESTORE_FOCUS_DELAY_MS,
scheduleTimeout = (callback: () => void, delay: number) => setTimeout(callback, delay),
cancelTimeout = (id: unknown) => clearTimeout(id as ReturnType<typeof setTimeout>),
}: AttachWindowResizeFocusRestoreOptions = {},
): (() => void) => {
if (!win?.addEventListener || !win.removeEventListener || typeof refocus !== "function") {
return () => {};
}
let timerId: unknown = null;
const restoreFocus = () => {
timerId = null;
if (doc?.hasFocus?.() === false) return;
if (hasOpenAppDialog(doc)) return;
// The window holds OS focus, but the user may still be typing in a
// non-terminal field (terminal search, AI chat, SFTP filter, rename).
// Never steal focus from an active editable element.
if (hasActiveEditableElement(doc)) return;
refocus();
};
const handleResize = () => {
if (timerId !== null) {
cancelTimeout(timerId);
}
timerId = scheduleTimeout(restoreFocus, delayMs);
};
win.addEventListener("resize", handleResize);
return () => {
win.removeEventListener("resize", handleResize);
if (timerId !== null) {
cancelTimeout(timerId);
timerId = null;
}
};
};
@@ -33,7 +33,7 @@ const createFakeTerm = (cols = 80) => {
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
onDispose: (handler: Handler) => { dispose: () => void };
}> = [];
const markers: Array<{ line: number; disposed: boolean; dispose: () => void }> = [];
const markers: Array<{ line: number; disposed: boolean }> = [];
const term = {
cols,
@@ -477,84 +477,6 @@ test('CursorLineHighlighter restores after the terminal resets its decorations',
highlighter.dispose();
});
test('CursorLineHighlighter self-heals when xterm disposes its decorations without a write', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
// xterm-side disposal (decoration service reset / term.reset): no cursor
// move and no write follow, only the next render frame.
term.resetDecorations();
assert.equal(term.decorations[0]?.disposed, true);
term.render();
assert.equal(term.decorations.at(-1)?.disposed, false);
assert.equal(term.decorations.at(-1)?.options.width, 80);
highlighter.dispose();
});
test('CursorLineHighlighter recreates after a buffer clear wipes markers and decorations', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.disposed, false);
// term.clear() disposes every marker; decorations follow their markers.
for (const marker of term.markers) marker.dispose();
term.resetDecorations();
assert.equal(term.decorations.at(-1)?.disposed, true);
term.render();
assert.equal(term.decorations.at(-1)?.disposed, false);
assert.equal(term.markers.at(-1)?.line, 0);
highlighter.dispose();
});
test('CursorLineHighlighter does not duplicate decorations on redundant renders', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const aliveBefore = term.decorations.filter((decoration) => !decoration.disposed).length;
term.render();
term.render();
assert.equal(
term.decorations.filter((decoration) => !decoration.disposed).length,
aliveBefore,
);
highlighter.dispose();
});
test('CursorLineHighlighter does not churn markers when a fully colored row has no decorations', () => {
const cols = 8;
const term = createFakeTerm(cols);
// Every cell has an explicit ANSI background and the row is full, so both
// highlight ranges and the blank tail are legitimately empty.
term.setColoredBackgrounds(Array.from({ length: cols }, (_, column) => column));
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 0);
assert.equal(term.markers.length, 1);
const marker = term.markers[0];
assert.ok(marker);
assert.equal(marker.disposed, false);
assert.equal(marker.line, 0);
const aliveBefore = term.markers.filter((candidate) => !candidate.disposed).length;
term.render();
term.render();
term.render();
assert.equal(term.markers.length, 1);
assert.equal(term.markers.filter((candidate) => !candidate.disposed).length, aliveBefore);
assert.equal(marker.disposed, false);
assert.equal(marker.line, 0);
assert.equal(term.decorations.length, 0);
highlighter.dispose();
});
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
@@ -37,8 +37,6 @@ export class CursorLineHighlighter implements IDisposable {
private activeColor: string | null = null;
private activeRanges: HighlightRange[] = [];
private activeTailRanges: HighlightRange[] = [];
/** False once any tracked decoration has been disposed out from under us. */
private decorationsAlive = false;
private readonly disposables: IDisposable[] = [];
private pendingRefresh = false;
private disposed = false;
@@ -48,16 +46,10 @@ export class CursorLineHighlighter implements IDisposable {
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
const pending = this.pendingRefresh;
this.pendingRefresh = false;
if (this.disposed || !this.enabled) return;
// Writes and cursor moves defer to render, but xterm can also dispose
// or clear our decorations out from under us (term.clear/reset,
// decoration service resets, marker reindexing) without any further
// cursor move or write. Verify the tracked set on every render so the
// highlight can self-heal at the cursor line instead of staying
// anchored to stale bookkeeping (wrong/missing line).
if (pending || !this.isTrackingIntact()) this.refresh();
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
}),
this.term.onSelectionChange(() => this.refresh({ force: true })),
this.term.onResize(() => this.refresh({ force: true })),
@@ -119,7 +111,6 @@ export class CursorLineHighlighter implements IDisposable {
color === this.activeColor &&
rangesEqual(ranges, this.activeRanges) &&
rangesEqual(tailRanges, this.activeTailRanges) &&
this.decorationsAlive &&
this.marker &&
!this.marker.isDisposed &&
this.marker.line === absoluteLine
@@ -169,18 +160,13 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = marker;
this.decorations = decorations;
// An empty set is valid when the whole row already has ANSI backgrounds or
// inverse cells. Tracking stays intact so later frames do not churn markers.
this.decorationsAlive = true;
this.decorationDisposeListeners = decorations.map((decoration) =>
decoration.onDispose(() => {
if (this.decorations.includes(decoration)) {
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
this.activeTailRanges = [];
}
}),
);
@@ -258,7 +244,6 @@ export class CursorLineHighlighter implements IDisposable {
this.marker = null;
this.decorations = [];
this.decorationDisposeListeners = [];
this.decorationsAlive = false;
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
@@ -266,19 +251,6 @@ export class CursorLineHighlighter implements IDisposable {
this.activeTailRanges = [];
}
/**
* Cheap render-time check that the tracked marker and decorations are still
* owned by xterm. xterm disposes highlight decorations on buffer clears,
* resets, and marker reindexing without emitting cursor-move/write events,
* so a stale tracking state must be recreated before the next frame paints.
*/
private isTrackingIntact(): boolean {
if (this.marker === null || this.marker.isDisposed) {
return this.marker === null && this.decorations.length === 0;
}
return this.decorationsAlive;
}
private clearOwned(
marker: IMarker | null,
decorations: IDecoration[],
@@ -584,7 +584,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
addDraftFiles={aiConfig.addDraftFiles}
removeDraftFile={aiConfig.removeDraftFile}
createSession={aiConfig.createSession}
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
@@ -23,7 +23,6 @@ import {
type SidePanelTabRemap,
} from '../../domain/workspaceSidePanelTabRemap';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { attachWindowResizeFocusRestore } from '../terminal/restoreWindowResizeFocus';
import { toast } from '../ui/toast';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
import type { RendererCwdSource } from '../terminal/sftpCwd';
@@ -748,23 +747,6 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
refocusActiveTerminalSession();
}, [activeTabId, isTerminalLayerVisible, refocusActiveTerminalSession]);
// Restore keyboard focus after the OS window is resized or maximized.
// #3647: dragging the frame (or maximizing) leaves the active xterm helper
// textarea unfocused on Windows, so keystrokes do nothing until a click.
// Reuse the tab-change refocus path; the helper only fires once the resize
// settles and is gated on the app window actually holding input focus.
useEffect(() => {
if (!isTerminalLayerVisible) return undefined;
return attachWindowResizeFocusRestore({
win: window,
doc: document,
refocus: () => {
if (isComposeBarOpen) return;
refocusActiveTerminalSession?.();
},
});
}, [document, isComposeBarOpen, isTerminalLayerVisible, refocusActiveTerminalSession, window]);
// When focusedSessionId changes or terminal layer becomes visible,
// focus the corresponding terminal to restore :focus-within CSS state
useEffect(() => {
-245
View File
@@ -1,245 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts';
import {
buildUndoLastTurnBranch,
hasUnresolvedToolCalls,
resolveUndoLastTurnBoundary,
} from './aiUndoLastTurn.ts';
const user = (content: string): ChatMessage => ({
id: `u_${content}`,
role: 'user',
content,
timestamp: 1,
});
const assistant = (content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
id: `a_${content}`,
role: 'assistant',
content,
timestamp: 2,
...extra,
});
const session = (messages: ChatMessage[], extra: Partial<AISession> = {}): AISession => ({
id: 'chat-1',
title: 'Chat',
agentId: 'catty',
scope: { type: 'terminal', targetId: 't1' },
messages,
createdAt: 1,
updatedAt: 2,
...extra,
});
test('resolveUndoLastTurnBoundary finds the last user message index', () => {
const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')];
assert.equal(resolveUndoLastTurnBoundary(messages), 2);
});
test('resolveUndoLastTurnBoundary returns null without a user message', () => {
assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null);
assert.equal(resolveUndoLastTurnBoundary([]), null);
});
test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => {
const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')];
assert.equal(resolveUndoLastTurnBoundary(messages, 3), null);
assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2);
});
test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => {
// The call sits in the kept prefix while its result would be cut away.
const split = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
}),
user('again?'),
assistant('', {
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
];
assert.equal(resolveUndoLastTurnBoundary(split), null);
assert.ok(hasUnresolvedToolCalls(split.slice(0, 2)));
// Result already inside the kept prefix — the boundary is safe.
const resolved = [
assistant('', {
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
}),
user('again?'),
assistant('done'),
];
assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false);
assert.equal(resolveUndoLastTurnBoundary(resolved), 1);
});
test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => {
const source = session([
user('first'),
assistant('answer'),
user('bad prompt'),
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.equal(result.session.id, 'chat-2');
assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']);
assert.equal(result.restored.text, 'bad prompt');
// Original session untouched (non-destructive).
assert.equal(source.messages.length, 4);
});
test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => {
const source = session(
[user('compacted'), assistant('x'), user('last'), assistant('y')],
{
externalSessionId: 'prov-1',
contextCompaction: { summary: 'summary', compactedMessageCount: 2 },
},
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 });
assert.ok(result);
assert.equal(result.session.externalSessionId, undefined);
assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 });
});
test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => {
const source = session([
user('with file'),
assistant('ok'),
{
id: 'u_last',
role: 'user',
content: 'look at this',
timestamp: 3,
attachments: [
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 },
],
},
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 });
assert.ok(result);
assert.deepEqual(result.restored.attachments, [
{
id: 'undo_99_0',
filename: 'notes.txt',
mediaType: 'text/plain',
base64Data: 'QUJD',
dataUrl: 'data:text/plain;base64,QUJD',
lineCount: 3,
},
]);
});
test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => {
const source = session([
user('first'),
assistant('answer'),
{ ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] },
assistant('bad answer'),
]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
// Original session untouched (non-destructive).
assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
});
test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => {
const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
assert.ok(result);
assert.deepEqual(result.restored.selectedUserSkillSlugs, []);
});
test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => {
assert.equal(
buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }),
null,
);
});
test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => {
// Undoing the first turn leaves the branch with no user message at all, so
// the inherited title was derived from the removed prompt and would stay
// stale forever (auto-titling only runs for untitled/New Chat sessions).
const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' });
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 0);
assert.equal(result?.restored.text, 'first prompt');
assert.equal(result?.session.title, 'New Chat');
});
test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => {
// A note-only send persists empty user text while the mentioned note rides
// on the attachment (its title seeded the conversation title), so a
// blank-content retained user turn must still count as "a user message is
// kept" and must not reset the title to the untitled placeholder.
const noteOnlyUser: ChatMessage = {
id: 'u_note-only',
role: 'user',
content: '',
timestamp: 3,
attachments: [{
base64Data: '',
mediaType: 'text/markdown',
filename: 'plan.md',
vaultNoteId: 'note-1',
vaultNoteTitle: 'Deploy plan',
previewText: '# Deploy plan',
}],
};
const source = session(
[noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'Deploy plan' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.title, 'Deploy plan');
});
test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => {
const source = session(
[user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')],
{ title: 'renamed by user' },
);
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
assert.ok(result);
assert.equal(result?.session.messages.length, 2);
assert.equal(result?.restored.text, 'second prompt');
// The retained prefix still contains a user message, so the source title
// keeps describing the branch's conversation.
assert.equal(result?.session.title, 'renamed by user');
});
test('undo allows a failed last reply while retaining portable replay metadata', () => {
const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
const source = session([
user('first'),
assistant('first answer', { providerContinuation: continuation }),
user('retry this request'),
assistant('request failed', { executionStatus: 'failed' }),
], { externalSessionId: 'original-provider-thread' });
const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 });
assert.ok(result);
assert.deepEqual(result.session.messages[1].providerContinuation, continuation);
assert.equal(result.session.externalSessionId, undefined);
assert.equal(result.restored.text, 'retry this request');
assert.equal(source.messages.length, 4);
});
test('safe boundary does not pair a reused call ID with an earlier result', () => {
const prefix = [
assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }),
assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
];
assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null);
assert.equal(resolveUndoLastTurnBoundary([
...prefix,
assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }),
user('latest'),
]), 4);
});
-154
View File
@@ -1,154 +0,0 @@
import type {
AISession,
ChatMessage,
ChatMessageAttachment,
UploadedFile,
} from '../infrastructure/ai/types';
/**
* Non-destructive "undo last turn" for AI chat sessions.
*
* Undo branches the conversation at the boundary *before* the latest user
* message instead of truncating the original session. The original session
* stays in history untouched; the branch (without the last turn) becomes the
* conversation the user continues, and the undone user message is restored
* into the composer for editing and resending.
*/
export interface UndoLastTurnRestoredDraft {
text: string;
attachments: UploadedFile[];
selectedUserSkillSlugs: string[];
}
export interface UndoLastTurnResult {
/** Branched copy of the source session without the last user turn. */
session: AISession;
/** Undone user message content for the composer. */
restored: UndoLastTurnRestoredDraft;
}
/**
* True when any assistant tool call in `messages` has no matching tool result
* inside the same list. Cutting between a tool call and its result would
* desync agent tool bookkeeping, so such boundaries are rejected.
*/
export function hasUnresolvedToolCalls(messages: readonly ChatMessage[]): boolean {
// Providers may reuse tool-call IDs across a session. Pair each result with
// the nearest preceding unresolved call carrying the same ID (same strategy
// as `repairToolMessageIntegrity`), otherwise a result for an earlier
// occurrence would satisfy a later call that reused the ID.
const pendingCalls = new Map<string, number>();
for (const message of messages) {
for (const call of message.toolCalls ?? []) {
pendingCalls.set(call.id, (pendingCalls.get(call.id) ?? 0) + 1);
}
for (const result of message.toolResults ?? []) {
const pending = pendingCalls.get(result.toolCallId) ?? 0;
if (pending > 0) pendingCalls.set(result.toolCallId, pending - 1);
}
}
for (const pending of pendingCalls.values()) {
if (pending > 0) return true;
}
return false;
}
/**
* Index of the cut point for undoing the last turn (the last user message).
* Returns null when undo is not offered:
* - there is no user message at all,
* - the boundary would fall inside the compacted prefix (its summary would no
* longer match the kept messages),
* - cutting there would split an assistant tool call from its result.
*/
export function resolveUndoLastTurnBoundary(
messages: readonly ChatMessage[],
compactedMessageCount = 0,
): number | null {
let lastUserIndex = -1;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === 'user') {
lastUserIndex = i;
break;
}
}
if (lastUserIndex < 0) return null;
if (lastUserIndex < compactedMessageCount) return null;
const keptPrefix = messages.slice(0, lastUserIndex);
if (hasUnresolvedToolCalls(keptPrefix)) return null;
return lastUserIndex;
}
/** Map a persisted message attachment back to a composer upload (by value). */
export function buildRestoredAttachments(
message: ChatMessage,
now: number,
): UploadedFile[] {
const attachments: ChatMessageAttachment[] = message.attachments ?? message.images ?? [];
return attachments.map((attachment, index) => ({
id: `undo_${now}_${index}`,
filename: attachment.filename ?? '',
mediaType: attachment.mediaType,
base64Data: attachment.base64Data,
dataUrl: attachment.base64Data
? `data:${attachment.mediaType};base64,${attachment.base64Data}`
: '',
...(attachment.filePath !== undefined ? { filePath: attachment.filePath } : {}),
...(attachment.terminalSelection ? { terminalSelection: true } : {}),
...(attachment.vaultNoteId !== undefined ? { vaultNoteId: attachment.vaultNoteId } : {}),
...(attachment.vaultNoteTitle !== undefined ? { vaultNoteTitle: attachment.vaultNoteTitle } : {}),
...(attachment.previewText !== undefined ? { previewText: attachment.previewText } : {}),
...(attachment.lineCount !== undefined ? { lineCount: attachment.lineCount } : {}),
}));
}
/**
* Build the branched "undo last turn" copy of a session.
*
* The branch keeps the compacted prefix intact (its summary still describes
* the kept messages) and drops the external agent session id: external agents
* resume provider-side sessions, so the branch must start a fresh one.
*/
export function buildUndoLastTurnBranch(
source: AISession,
options: { newId: string; now: number },
): UndoLastTurnResult | null {
const compactedMessageCount = source.contextCompaction?.compactedMessageCount ?? 0;
const boundary = resolveUndoLastTurnBoundary(source.messages, compactedMessageCount);
if (boundary == null) return null;
const removedUserMessage = source.messages[boundary];
const retainedPrefix = source.messages.slice(0, boundary);
// When undo removes the first turn, the branch has no user message left and
// still carries the source title, which was auto-titled from the removed
// prompt. Reset it to the untitled placeholder so `autoTitleSession` can
// retitle the branch from the prompt the user sends next; otherwise the
// branch is permanently labeled with text that is no longer its prompt.
// A retained user turn can legitimately carry empty text: a note-only send
// leaves `content` blank while the mentioned note rides on the attachment
// (and its title) — that turn still anchored the conversation, so it must
// keep the note-derived title and suppress the reset.
const hasRetainedUserMessage = retainedPrefix.some(
message => message.role === 'user',
);
const branchedSession: AISession = {
...source,
id: options.newId,
messages: retainedPrefix,
...(hasRetainedUserMessage ? {} : { title: 'New Chat' }),
externalSessionId: undefined,
createdAt: options.now,
updatedAt: options.now,
};
return {
session: branchedSession,
restored: {
text: removedUserMessage.content,
attachments: buildRestoredAttachments(removedUserMessage, options.now),
// The undone turn was selected with these skill pills: they shaped the
// model context, so resending the turn must not silently omit them.
selectedUserSkillSlugs: [...(removedUserMessage.selectedUserSkillSlugs ?? [])],
},
};
}
-31
View File
@@ -1,31 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { TerminalSession } from './models';
import { findReusableHostSession } from './hostOpenSession';
function tab(id: string, status: TerminalSession['status'], extra: Partial<TerminalSession> = {}): TerminalSession {
return { id, hostId: 'host-1', hostLabel: 'Host', hostname: 'example.invalid', username: 'user', status, ...extra };
}
test('host reuse prefers a connected tab over a connecting tab for the same host', () => {
assert.deepEqual(findReusableHostSession([
tab('other-host', 'connected', { hostId: 'host-2' }),
tab('retrying', 'connecting'),
tab('live', 'connected'),
], 'host-1'), { sessionId: 'live', connected: true });
});
test('host reuse keeps the same tab during the reconnect delay and connection attempt', () => {
assert.deepEqual(findReusableHostSession([tab('retrying', 'connecting')], 'host-1'), {
sessionId: 'retrying', connected: false,
});
});
test('host reuse excludes restored placeholders and inactive disconnected tabs', () => {
assert.equal(findReusableHostSession([
tab('manual-disconnect', 'disconnected'),
tab('restored', 'disconnected', { restoreState: 'restored-disconnected' }),
tab('restoring', 'connecting', { restoreState: 'restored-disconnected' }),
], 'host-1'), undefined);
assert.equal(findReusableHostSession([tab('live', 'connected')], ''), undefined);
});
-14
View File
@@ -1,14 +0,0 @@
import type { TerminalSession } from './models';
export function findReusableHostSession(sessions: TerminalSession[], hostId: string) {
if (!hostId) return undefined;
// Auto-reconnect publishes connecting even during the retry delay. A plain
// disconnected/restored tab needs user action and cannot satisfy host_open.
const matches = sessions.filter((session) => (
session.hostId === hostId
&& session.restoreState !== 'restored-disconnected'
&& (session.status === 'connected' || session.status === 'connecting')
));
const picked = matches.find((session) => session.status === 'connected') ?? matches[0];
return picked ? { sessionId: picked.id, connected: picked.status === 'connected' } : undefined;
}
+2 -9
View File
@@ -536,10 +536,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
middleClickPaste: true,
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
linkModifier: 'none',
// Off by default: remote graceful channel closes can be reported as a clean
// exit (code 0), which would auto-close tabs on network drops. Users who
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
autoCloseOnExit: false,
autoCloseOnExit: true,
// Issue #3087: keep terminal history visible after an established session disconnects.
disconnectedNoticeMode: 'terminal',
keywordHighlightEnabled: true,
@@ -555,11 +552,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
verifyHostKeys: true,
keepaliveInterval: 30,
keepaliveCountMax: 10,
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
// in-place reconnect every 5s with the same session id. Clean shell exits
// still close the tab, and administrative idle/timeout exits stay
// disconnected. Users on always-stable links can turn it off.
sshAutoReconnectEnabled: true,
sshAutoReconnectEnabled: false,
x11Display: '', // Empty = use DISPLAY/default local X server
moshClientPath: '', // Legacy mosh-client override; normal UI uses bundled mosh-client
showHostInfoBar: true, // Preserve the existing host information bar by default
-27
View File
@@ -313,12 +313,6 @@ export interface SyncPayload {
terminalFontFamily?: string;
terminalFontSize?: number;
terminalSettings?: Record<string, unknown>;
/**
* Present once the sender has run the autoCloseOnExit default migration.
* Legacy payloads omit it; a materialized `true` in those payloads is the
* old default, not an explicit opt-in.
*/
autoCloseOnExitDefaultMigrated?: boolean;
terminalSidePanelAutoOpen?: boolean;
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
localShellSidePanelAutoOpen?: boolean;
@@ -396,27 +390,6 @@ export interface SyncPayload {
convergentSync?: ConvergentSyncEnvelopeV2;
}
/**
* Pre-migration snapshots stored autoCloseOnExit: true as the default and do
* not carry `autoCloseOnExitDefaultMigrated`. Rewrite that legacy true to
* false. A payload that records the migration keeps an explicit true.
*/
export function reconcileInboundAutoCloseOnExit(
autoCloseOnExit: unknown,
payloadRecordsMigratedChoice: unknown,
): unknown {
if (autoCloseOnExit === true && payloadRecordsMigratedChoice !== true) return false;
return autoCloseOnExit;
}
/** True when this side both migrated and explicitly enabled auto-close. */
export function payloadOptsIntoAutoClose(
settings: SyncPayload['settings'] | undefined,
): boolean {
return settings?.autoCloseOnExitDefaultMigrated === true
&& settings.terminalSettings?.autoCloseOnExit === true;
}
/**
* Strip device-local connection telemetry from hosts before any sync
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
-41
View File
@@ -631,44 +631,3 @@ test("group notes sync edits and clearing without dropping connection settings",
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
});
test("mergeSyncPayloads does not glue a local migration marker onto a legacy auto-close true", () => {
const merged = mergeSyncPayloads(
null,
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: false, scrollback: 3000 },
},
}),
payload({
settings: {
terminalSettings: { autoCloseOnExit: true, scrollback: 3000 },
},
}),
).payload.settings;
assert.equal(merged?.terminalSettings?.autoCloseOnExit, false);
assert.equal(merged?.terminalSettings?.scrollback, 3000);
});
test("mergeSyncPayloads keeps an explicit auto-close opt-in from a migrated peer", () => {
const merged = mergeSyncPayloads(
null,
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: false },
},
}),
payload({
settings: {
autoCloseOnExitDefaultMigrated: true,
terminalSettings: { autoCloseOnExit: true },
},
}),
).payload.settings;
assert.equal(merged?.autoCloseOnExitDefaultMigrated, true);
assert.equal(merged?.terminalSettings?.autoCloseOnExit, true);
});
-19
View File
@@ -21,7 +21,6 @@
import { carryForwardSyncDeletions, getDeletedEntityIds } from './syncReliability';
import {
payloadOptsIntoAutoClose,
sanitizeHostsForSync,
type CloudSyncPayloadEntityKey,
type SyncPayload,
@@ -404,24 +403,6 @@ function mergeSettings(
}
}
// A three-way merge can keep this device's migration flag while adopting a
// legacy remote autoCloseOnExit: true. That true is the old default unless
// one of the sides explicitly opted in after migrating.
const terminalSettings = merged.terminalSettings;
if (
terminalSettings
&& typeof terminalSettings === 'object'
&& !Array.isArray(terminalSettings)
&& (terminalSettings as Record<string, unknown>).autoCloseOnExit === true
&& !payloadOptsIntoAutoClose(local)
&& !payloadOptsIntoAutoClose(remote)
) {
merged.terminalSettings = {
...(terminalSettings as Record<string, unknown>),
autoCloseOnExit: false,
};
}
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
}
+4 -4
View File
@@ -185,16 +185,16 @@ test("normalizeTerminalSettings preserves disabled font ligatures", () => {
assert.equal(normalizeTerminalSettings({ fontLigatures: false }).fontLigatures, false);
});
test("normalizeTerminalSettings disables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, false);
test("normalizeTerminalSettings enables terminal auto-close by default", () => {
assert.equal(normalizeTerminalSettings().autoCloseOnExit, true);
});
test("normalizeTerminalSettings preserves disabled terminal auto-close", () => {
assert.equal(normalizeTerminalSettings({ autoCloseOnExit: false }).autoCloseOnExit, false);
});
test("normalizeTerminalSettings enables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, true);
test("normalizeTerminalSettings disables SSH auto reconnect by default", () => {
assert.equal(normalizeTerminalSettings().sshAutoReconnectEnabled, false);
});
test("normalizeTerminalSettings preserves explicit SSH auto reconnect settings", () => {
+2 -16
View File
@@ -58,25 +58,11 @@ function subscribeToPtyData(ptyStream, onData) {
throw new Error("PTY stream does not support data subscriptions");
}
// Only the end of the output can hold the idle prompt, and this check runs for
// every PTY chunk while the retained output may be hundreds of thousands of
// characters long. Normalising the whole text (ANSI and carriage returns) on
// each chunk stalls the worker during a flood, so look at a tail window that is
// comfortably larger than the prompt including the escape codes it may carry.
const PROMPT_SUFFIX_MIN_WINDOW_CHARS = 2048;
const PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR = 8;
function hasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
if (!normalizedPrompt) return false;
const fullText = String(text || "");
const windowChars = Math.max(
PROMPT_SUFFIX_MIN_WINDOW_CHARS,
String(expectedPrompt).length * PROMPT_SUFFIX_WINDOW_PER_PROMPT_CHAR,
);
const tail = fullText.length > windowChars ? fullText.slice(-windowChars) : fullText;
return stripAnsi(tail).replace(/\r/g, "").endsWith(normalizedPrompt);
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
function escapePosixSingleQuoted(text) {
@@ -1,52 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { hasExpectedPromptSuffix } = require("./ptyExecHelpers.cjs");
const { stripAnsi } = require("./shellUtils.cjs");
// The behaviour before the tail window was introduced: normalise the whole
// text, then compare its end.
function referenceHasExpectedPromptSuffix(text, expectedPrompt) {
if (!expectedPrompt) return false;
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
}
const COLORED_PROMPT = "\x1b[01;32mroot@3160T\x1b[00m:\x1b[01;34m~\x1b[00m# ";
const CASES = [
["plain prompt at the end", "output\r\nroot@3160T:~# ", "root@3160T:~# "],
["prompt not at the end", "root@3160T:~# ls\r\nfile\r\n", "root@3160T:~# "],
["colored prompt in the text, plain expected", `out\r\n${COLORED_PROMPT}`, "root@3160T:~# "],
["colored prompt on both sides", `out\r\n${COLORED_PROMPT}`, COLORED_PROMPT],
["carriage returns inside the prompt", "out\r\nroot@3160T:~\r# ", "root@3160T:~# "],
["empty text", "", "root@3160T:~# "],
["empty expected prompt", "root@3160T:~# ", ""],
["null inputs", null, null],
["text shorter than the prompt", "~# ", "root@3160T:~# "],
];
for (const [name, text, prompt] of CASES) {
test(`prompt suffix matches the full-text result: ${name}`, () => {
assert.equal(hasExpectedPromptSuffix(text, prompt), referenceHasExpectedPromptSuffix(text, prompt));
});
}
test("prompt suffix is found after a very large flood of CRLF output", () => {
const flood = "y\r\n".repeat(700_000);
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# `, "root@3160T:~# "), true);
assert.equal(hasExpectedPromptSuffix(`${flood}root@3160T:~# x`, "root@3160T:~# "), false);
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("prompt suffix check does not scan the whole buffer on every call", () => {
// 2M characters of CRLF output used to cost seconds per call because every
// call stripped ANSI and carriage returns over the entire text.
const flood = "y\r\n".repeat(700_000);
const started = process.hrtime.bigint();
for (let i = 0; i < 50; i += 1) hasExpectedPromptSuffix(flood, "root@3160T:~# ");
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
assert.ok(elapsedMs < 250, `50 checks took ${elapsedMs.toFixed(0)} ms`);
});
+15 -45
View File
@@ -8,27 +8,24 @@ const fs = require("node:fs");
// but no pathname cleanup may remove a concurrent writer's replacement.
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
// verify an open destination handle before later metadata stamping.
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle, options = {}) {
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
assertNotCancelled();
if (!options.copyOnly) {
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
}
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
}
let input;
let output;
let failure;
let publishedIdentity;
let timestampsPreserved = true;
try {
input = preparedHandle ?? await fs.promises.open(source, "r");
const stat = await input.stat();
@@ -52,31 +49,14 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
assertNotCancelled();
// Restore metadata after writes (which may clear special permission bits),
// through the owned handle rather than a potentially replaced pathname.
const mode = stat.mode & 0o7777;
if (((await output.stat()).mode & 0o7777) !== mode) {
try {
await output.chmod(mode);
} catch (error) {
// New files follow the mount's access policy (GVFS can synthesize mode
// bits). Replacements must still preserve the validated protection.
if (!isOptionalMetadataError(error) || options.preserveMode !== false) throw error;
}
if (options.preserveMode !== false && ((await output.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
try {
await output.utimes(stat.atime, stat.mtime);
} catch (error) {
if (!isOptionalMetadataError(error) || options.requireTimestamps !== false) throw error;
timestampsPreserved = false;
}
await output.chmod(stat.mode & 0o7777);
await output.utimes(stat.atime, stat.mtime);
const ownedStat = await output.stat();
const targetStat = await fs.promises.lstat(target);
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
throw new Error("Local download target changed during replacement");
}
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size, timestampsPreserved };
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
} catch (error) {
failure = error;
} finally {
@@ -93,14 +73,4 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
return publishedIdentity;
}
// Only optional chmod/utimes may ignore permission refusal. Never use this
// predicate for creation, data writes, close, or required permission changes.
function isOptionalMetadataError(error) {
return isMetadataUnsupportedError(error) || ["EPERM", "EACCES"].includes(error?.code);
}
function isMetadataUnsupportedError(error) {
return ["ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(error?.code);
}
module.exports = { publishLocalFileExclusive, isMetadataUnsupportedError };
module.exports = { publishLocalFileExclusive };
+6 -27
View File
@@ -1615,17 +1615,6 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
// explicitly closes it while that reply is still in flight, the eventual
// result must not revive ownership or scope metadata for the closed tab.
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
// Reuse adds visibility only: never grant ownership or change the original
// owner's idle lifecycle merely because another chat referenced the tab.
if (result?.reused === true) {
if (generation?.ownershipGeneration?.revoked) {
// A late renderer merge must not recreate a deleted chat's scope.
scopedMetadata.delete(chatSessionId);
return;
}
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
return;
}
if (!openedSessionOwnership.register(
chatSessionId,
sessionId,
@@ -2243,16 +2232,10 @@ async function handleGetContext(params) {
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
const hasSshExec = sshClient && typeof sshClient.exec === "function";
const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function";
const hasTransport = hasCommandablePty || hasSshExec || hasSerialPort;
// Look up metadata scoped to this chat session.
const scopeMeta = getSessionMeta(sessionId, chatSessionId);
// Disconnected/reconnecting tabs keep their session entry (SSH
// auto-reconnect reuses the sessionId), so keep them visible with
// connected:false instead of hiding them — otherwise the agent cannot
// see or wait for a tab it opened and reopens it as a duplicate.
// Without scoped metadata the session is not in this agent's scope.
if (!hasTransport && !scopeMeta) continue;
const meta = scopeMeta || {};
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
hosts.push({
sessionId,
hostname: meta.hostname || session.hostname || "",
@@ -2262,7 +2245,7 @@ async function handleGetContext(params) {
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== undefined ? meta.connected : hasTransport,
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
hostId: meta.hostId || "",
hostChain: meta.hostChain || [],
activePortForwards: meta.activePortForwards || [],
@@ -2274,10 +2257,7 @@ async function handleGetContext(params) {
for (const sessionId of resolvedScopedIds) {
if (addedHostIds.has(sessionId)) continue;
const meta = getSessionMeta(sessionId, chatSessionId);
if (!meta) continue;
// Keep disconnected/reconnecting sessions listed with connected:false so
// the agent can wait on the existing sessionId instead of reopening the
// host as a duplicate tab (SSH auto-reconnect preserves the sessionId).
if (!meta || meta.connected === false) continue;
hosts.push(buildHostFromMetadata(sessionId, meta));
addedHostIds.add(sessionId);
}
@@ -2298,7 +2278,6 @@ async function handleGetContext(params) {
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
buildTerminalToolGuidance(toolHints) +
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
"A session listed with connected false is still in the scope. SSH auto-reconnect keeps the same sessionId: wait and retry that session while it reconnects. Inactive disconnected or restored tabs may require user action; host_open reuses connected or connecting sessions by default and opens a connection when none is usable. " +
"Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " +
"Vault snippets, port forwarding rules/tunnels, and SFTP read/write tools are available when exposed in the tool list. " +
"Always prefer these tools over suggesting the user to do things manually.",
@@ -1,144 +0,0 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { isolateCliDiscoveryFile } = require("./cliDiscoveryTestIsolation.cjs");
isolateCliDiscoveryFile();
function loadFreshBridge() {
const bridgePath = require.resolve("./mcpServerBridge.cjs");
delete require.cache[bridgePath];
return require("./mcpServerBridge.cjs");
}
function setup(t, { sessions } = {}) {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: sessions || new Map(), electronModule: null });
bridge.setPermissionMode("auto");
return bridge;
}
test('reusing a user tab adds visibility without ownership or idle tracking', async (t) => {
const bridge = setup(t);
bridge.setVaultAgentInvoker(async () => ({ ok: true, reused: true, sessionId: 'user-tab', hostId: 'host-b', status: 'connected' }));
await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'chat-b', hostId: 'host-b' });
assert.deepEqual(bridge.getScopedSessionIds('chat-b'), ['user-tab']);
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false);
const close = await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'chat-b', sessionId: 'user-tab' });
assert.equal(close.ok, false);
assert.match(close.error, /not opened by the current AI scope/);
});
test('reuse keeps the original chat owner and its idle tracking', async (t) => {
const bridge = setup(t);
const calls = [];
bridge.setVaultAgentInvoker(async (op, params) => {
calls.push({ op, params });
if (op === 'session.close') return { ok: true };
return { ok: true, reused: params.chatSessionId !== 'owner', sessionId: 'shared-tab', hostId: 'host-b', status: 'connected' };
});
for (const chatSessionId of ['owner', 'borrower']) {
await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId, hostId: 'host-b' });
}
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'shared-tab', phase: 'touch' }), true);
assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'borrower', sessionId: 'shared-tab' })).ok, false);
assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'owner', sessionId: 'shared-tab' })).ok, true);
assert.equal(calls.filter(({ op }) => op === 'session.close').length, 1);
});
test('a deleted chat cannot reclaim a reused tab or revive its own scope', async (t) => {
const bridge = setup(t);
let finishOpen;
const calls = [];
bridge.updateSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'original');
bridge.setVaultAgentInvoker((op) => {
calls.push(op);
return new Promise((resolve) => { finishOpen = resolve; });
});
const opening = bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'deleted', hostId: 'host-b' });
await bridge.cleanupScopedMetadata('deleted');
bridge.mergeSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'deleted');
finishOpen({ ok: true, reused: true, sessionId: 'user-tab', status: 'connected' });
assert.equal((await opening).ok, true);
assert.deepEqual(calls, ['host.open']);
assert.deepEqual(bridge.getScopedSessionIds('deleted'), []);
assert.deepEqual(bridge.getScopedSessionIds('original'), ['user-tab']);
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false);
});
test("getContext keeps a scoped disconnected session visible with connected:false", async (t) => {
const bridge = setup(t);
bridge.updateSessionMetadata([
{
sessionId: "sess-reconnecting",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
// Connection dropped: renderer pushes connected:false while the tab
// auto-reconnects. The scope keeps the same sessionId.
bridge.updateSessionMetadata([
{
sessionId: "sess-reconnecting",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
const host = environment.hosts.find((entry) => entry.sessionId === "sess-reconnecting");
assert.ok(host, "reconnecting session must stay visible to the agent");
assert.equal(host.connected, false);
});
test("getContext keeps a live-map session without transport visible when scoped", async (t) => {
const bridge = setup(t, {
sessions: new Map([["sess-live", { hostname: "10.0.0.3", label: "server-c" }]]),
});
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.3",
label: "server-c",
connected: false,
hostId: "host-c",
protocol: "ssh",
},
], "chat-1");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
const host = environment.hosts.find((entry) => entry.sessionId === "sess-live");
assert.ok(host, "reconnecting live-map session must stay visible");
assert.equal(host.connected, false);
});
test("getContext still hides disconnected sessions outside the agent scope", async (t) => {
const bridge = setup(t);
bridge.updateSessionMetadata([
{
sessionId: "foreign",
hostname: "10.0.0.9",
label: "foreign",
connected: false,
hostId: "host-x",
protocol: "ssh",
},
], "chat-other");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
assert.equal(environment.hosts.length, 0);
});
@@ -1,10 +1,6 @@
"use strict";
// 8h. AI/agent-opened sessions are long-lived, stateful surfaces (prod DB
// operations, deployments); a 30-minute silent close destroyed more in-flight
// work than it saved. 8h covers a workday without sitting on the 24h clamp
// maximum. Users can still set a shorter window in Settings -> AI.
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 480;
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
@@ -560,54 +560,6 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal cancels and clears the stage before fallback creates output", async (t) => {
const controller = new AbortController();
const payload = Buffer.from("complete-download-before-cancel");
const localPath = path.join(tmpDir, "cancel-before-fallback.bin");
fs.writeFileSync(localPath, "original");
let stagedPath;
t.after(() => { if (stagedPath) fs.rmSync(stagedPath, { force: true }); });
const events = [];
sftpClients.set("scp-pre-fallback-cancel", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: {
async stat() { return { type: "file", isDirectory: false, size: payload.length }; },
async downloadFile(_remotePath, stage) {
stagedPath = stage;
await fs.promises.writeFile(stage, payload);
return { fileSize: payload.length, transferred: payload.length };
},
},
});
for (const name of ["rename", "copyFile"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (args[0] === stagedPath && String(args[1]).endsWith(".ready")) {
if (name === "copyFile") controller.abort();
throw Object.assign(new Error("injected cross-device copy refusal"), {
code: name === "rename" ? "EXDEV" : "ENOTSUP",
});
}
return original.apply(fs.promises, args);
};
}
await assert.rejects(() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-pre-fallback-cancel", remotePath: "/remote/file.bin", localPath,
abortSignal: controller.signal,
onTransferEvent: (channel) => events.push(channel),
}), (error) => {
assert.match(error.message, /cancel|abort/i);
assert.doesNotMatch(error.message, /Recovery files preserved/);
return true;
});
assert.ok(stagedPath);
assert.equal(fs.existsSync(stagedPath), false);
assert.equal(fs.readFileSync(localPath, "utf8"), "original");
assert.ok(events.includes("netcatty:transfer:cancelled"));
assert.equal(events.includes("netcatty:transfer:error"), false);
});
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
skip: process.platform === "win32",
}, async (t) => {
@@ -630,11 +582,16 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
interceptPreparedChmod(t, async () => {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
});
const originalChmod = fs.promises.chmod;
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -673,14 +630,18 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
const original = Buffer.from("original-before-late-change");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-late-directory",
@@ -724,14 +685,18 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-same-mode-replacement",
@@ -1021,17 +986,3 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
});
});
function interceptPreparedChmod(t, callback) {
const originalOpen = fs.promises.open;
fs.promises.open = async (filePath, ...args) => {
const handle = await originalOpen.call(fs.promises, filePath, ...args);
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready") && args[0] === "r") {
const chmod = handle.chmod.bind(handle);
handle.chmod = (mode) => callback(chmod, mode);
}
return handle;
};
t.after(() => { fs.promises.open = originalOpen; });
}
@@ -1,193 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { Server } = require("ssh2");
const bridge = require("./sshBridge.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const listen = (server, ...args) => new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(...args, resolve);
});
const close = (server) => new Promise((resolve) => server.close(resolve));
test("normal SSH startup preserves fallback after an unavailable system agent", {
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
timeout: 20_000,
}, async (t) => {
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
const originalHomedir = os.homedir;
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
os.homedir = () => fixture; // Never discover the developer's default keys.
t.after(() => {
os.homedir = originalHomedir;
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
fs.rmSync(fixture, { recursive: true, force: true });
});
const hostKey = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
let sequence = 0;
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
const senderId = 3640 + sequence++;
const socketPath = path.join(fixture, "s");
fs.rmSync(socketPath, { force: true });
const agentClients = new Set();
let agentChecks = 0;
const agent = net.createServer((socket) => {
agentClients.add(socket);
agentChecks += 1;
socket.on("error", () => {});
socket.on("close", () => agentClients.delete(socket));
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
});
await listen(agent, socketPath);
if (stale) {
// Preserve a real socket entry with no listener, without a timing race.
fs.renameSync(socketPath, socketPath + ".stale");
await close(agent);
fs.renameSync(socketPath + ".stale", socketPath);
assert.ok(fs.statSync(socketPath).isSocket());
}
process.env.SSH_AUTH_SOCK = socketPath;
const sshClients = new Set();
const forwardingSockets = new Set();
const methods = [];
let shells = 0;
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
sshClients.add(client);
client.on("error", () => {});
client.on("close", () => sshClients.delete(client));
client.on("tcpip", (accept, reject, info) => {
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
const socket = net.connect(info.destPort, info.destIP, () => {
const stream = accept();
stream.on("error", () => socket.destroy());
stream.pipe(socket).pipe(stream);
});
forwardingSockets.add(socket);
socket.on("close", () => forwardingSockets.delete(socket));
socket.on("error", () => reject());
});
client.on("authentication", (ctx) => {
methods.push(ctx.method);
if (ctx.method === "password" && stall) return;
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
else ctx.reject(["publickey", "password"]);
});
client.on("ready", () => client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty());
session.on("shell", (acceptShell) => {
shells += 1;
acceptShell().write("fixture ready\r\n");
});
}));
});
await listen(ssh, 0, "127.0.0.1");
const handlers = new Map();
const sent = [];
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
const event = { sender: { id: senderId, isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }) } };
let guard;
try {
const start = handlers.get("netcatty:start")(event, {
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
username: "fixture", password, authMethod: "auto", useSshAgent,
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
...(jump ? { jumpHosts: [{
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
password, authMethod: "auto", useSshAgent,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
}] } : {}),
});
let error;
try {
await Promise.race([start, new Promise((_, reject) => {
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
})]);
} catch (err) { error = err; }
return { error, methods, shells, sent: sent.slice(), agentChecks };
} finally {
clearTimeout(guard);
for (const client of sshClients) client.end();
for (const socket of forwardingSockets) socket.destroy();
for (const client of agentClients) client.destroy();
if (agent.listening) await close(agent);
await close(ssh);
}
}
for (const [name, options] of [
["stale agent", {}],
["disabled agent", { useSshAgent: false }],
["healthy empty agent", { stale: false }],
]) {
await t.test(`${name} still opens a real shell using the saved password`, async () => {
const result = await run(options);
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
});
}
for (const [name, password, methods] of [
["agent without other credentials", undefined, ["none"]],
["incorrect password", "wrong-password", ["none", "password"]],
]) {
await t.test(`${name} still reports a real authentication failure`, async () => {
// null deliberately omits a password rather than taking run's default.
const result = await run({ password: password ?? null });
assert.equal(result.error?.level, "client-authentication");
assert.match(result.error.message, /All configured authentication methods failed/);
assert.deepEqual(result.methods, methods);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
});
}
await t.test("stalled password fallback retains its authentication deadline", async () => {
const result = await run({ stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel, payload }) => (
channel === "netcatty:exit" && payload.reason === "timeout"
)));
});
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
const result = await run({ jump: true });
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
});
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
const result = await run({ jump: true, password: "wrong-password" });
assert.equal(result.error?.level, "client-authentication");
assert.equal(result.error?.isJumpHostAuthError, true);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
const result = await run({ jump: true, stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
});
+1 -6
View File
@@ -813,12 +813,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
}
resolve();
});
conn.on('error', (err) => {
// ssh2 continues to the next auth method after agent errors.
if (err.level === 'agent') {
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
conn.once('error', (err) => {
if (settled) return;
settled = true;
clearAuthReadyTimer();
@@ -2415,90 +2415,7 @@ test("an exit-signal with no code does not fabricate an exit code 0", async (t)
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGUSR2");
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined, "the remote did report a signal");
});
test("a connection close after an exit-signal stays an administrative disconnect", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "signal-then-conn-close" });
const session = sessions.get("signal-then-conn-close");
// ssh2 can emit conn "close" before the channel's paced finalize. The
// signal recorded on "exit" must still win over a generic transport close.
session.stream.emit("exit", null, "SIGHUP");
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "signaled");
assert.equal(exits[0].exitSignal, "SIGHUP");
assert.equal(exits[0].exitCode, undefined);
});
test("a connection close without an exit-signal stays a transport close", async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const options = {
hostname: "10.0.0.50",
username: "alice",
port: 22,
authMethod: "password",
password: "secret",
useSshAgent: false,
verifyHostKeys: false,
};
await start({ sender }, { ...options, sessionId: "transport-conn-close" });
sessions.get("transport-conn-close").conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "closed");
assert.equal(exits[0].exitCode, undefined);
assert.equal(exits[0].remoteClosedWithoutExitStatus, true);
assert.equal(exits[0].exitSignal, undefined);
});
for (const exitCode of [0, 7]) {
test(`connection close preserves reported shell status ${exitCode} before channel finalization`, async (t) => {
const { bridge } = loadBridgeWithMockedSsh2(t, { connectReady: true });
const sessions = new Map();
const start = registerStartHandler(bridge, sessions);
const sender = makeSender();
const sessionId = `status-then-conn-close-${exitCode}`;
await start({ sender }, {
hostname: "10.0.0.50", username: "alice", port: 22,
authMethod: "password", password: "secret",
useSshAgent: false, verifyHostKeys: false, sessionId,
});
const session = sessions.get(sessionId);
session.stream.emit("exit", exitCode);
session.conn.emit("close");
await waitForExitDeliveries();
const exits = getExitPayloads(sender);
assert.equal(exits.length, 1);
assert.equal(exits[0].reason, "exited");
assert.equal(exits[0].exitCode, exitCode);
assert.equal(exits[0].remoteClosedWithoutExitStatus, undefined);
});
}
@@ -1,57 +0,0 @@
/**
* Classify how a remote SSH shell channel ended.
*
* An exit-signal (ssh2: code null, signal set) is an administrative
* termination — idle kick, SIGHUP, SIGTERM — not a VPN/transport flap.
* Those stay "signaled" so auto-reconnect can leave them alone.
* A channel that closes with neither exit-status nor exit-signal stays
* "closed" and is still eligible to reconnect.
*/
function normalizeRemoteExitSignal(signal) {
return typeof signal === "string" && signal.length > 0 ? signal : null;
}
function classifyRemoteShellClose({ idleTimedOut, exitSignal, shellExited }) {
if (idleTimedOut) return "timeout";
const signal = normalizeRemoteExitSignal(exitSignal);
if (signal) return "signaled";
if (shellExited) return "exited";
return "closed";
}
function buildRemoteShellExitPayload({
sessionId,
generation,
exitCode,
exitSignal,
shellExited,
idleTimedOut,
}) {
const signal = normalizeRemoteExitSignal(exitSignal);
const reason = classifyRemoteShellClose({
idleTimedOut: idleTimedOut === true,
exitSignal: signal,
shellExited: shellExited === true,
});
const payload = {
sessionId,
reason,
_terminalSessionGeneration: generation,
};
if (typeof exitCode === "number") {
payload.exitCode = exitCode;
} else if (!signal) {
payload.remoteClosedWithoutExitStatus = true;
}
if (signal) {
payload.exitSignal = signal;
}
return payload;
}
module.exports = {
normalizeRemoteExitSignal,
classifyRemoteShellClose,
buildRemoteShellExitPayload,
};
@@ -1,73 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildRemoteShellExitPayload,
classifyRemoteShellClose,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
test("normalizeRemoteExitSignal keeps a reported signal and drops empty values", () => {
assert.equal(normalizeRemoteExitSignal("SIGHUP"), "SIGHUP");
assert.equal(normalizeRemoteExitSignal("SIGUSR2"), "SIGUSR2");
assert.equal(normalizeRemoteExitSignal(""), null);
assert.equal(normalizeRemoteExitSignal(null), null);
assert.equal(normalizeRemoteExitSignal(undefined), null);
});
test("an exit-signal is an administrative signaled close, not a transport close", () => {
for (const exitSignal of ["SIGHUP", "SIGTERM", "SIGUSR2"]) {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal, shellExited: false }),
"signaled",
);
}
});
test("a channel close without exit-status or exit-signal stays closed", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: false }),
"closed",
);
});
test("a numeric shell exit stays exited, and a TMOUT banner stays timeout", () => {
assert.equal(
classifyRemoteShellClose({ idleTimedOut: false, exitSignal: null, shellExited: true }),
"exited",
);
assert.equal(
classifyRemoteShellClose({ idleTimedOut: true, exitSignal: null, shellExited: true }),
"timeout",
);
});
test("an exit-signal payload reports the signal and does not invent an exit code", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "signal-exit",
generation: 3,
exitSignal: "SIGUSR2",
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "signaled");
assert.equal(payload.exitSignal, "SIGUSR2");
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, undefined);
assert.equal(payload._terminalSessionGeneration, 3);
});
test("a bare channel-close payload stays a reconnectable transport close", () => {
const payload = buildRemoteShellExitPayload({
sessionId: "transport-close",
exitSignal: null,
shellExited: false,
idleTimedOut: false,
});
assert.equal(payload.reason, "closed");
assert.equal(payload.exitSignal, undefined);
assert.equal(payload.exitCode, undefined);
assert.equal(payload.remoteClosedWithoutExitStatus, true);
});
+1 -1
View File
@@ -1734,7 +1734,7 @@ function createSessionOpsApi(ctx) {
disks, // Array of all mounted disks
netRxSpeed, // Total network receive speed (bytes/sec)
netTxSpeed, // Total network transmit speed (bytes/sec)
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
latencyMs, // TCP connection establishment latency to the SSH endpoint (ms)
netInterfaces, // Per-interface network stats
hostname,
osName,
@@ -746,46 +746,6 @@ test("getServerStats measures latency by pinging the stats connection", async ()
assert.deepEqual(pingedConns, [session.moshStatsConn]);
});
test("getServerStats does not open an unauthenticated SSH handshake (#3630)", async (t) => {
const net = require("node:net");
const { createSshPingLatencyProbe } = require("./sshPingLatency.cjs");
let accepted = 0;
const server = net.createServer((socket) => {
accepted += 1;
socket.destroy();
});
t.after(() => new Promise((resolve) => server.close(() => resolve())));
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
const conn = Object.assign(fakeConn(LINUX_STATS), {
config: { keepaliveInterval: 30000 },
});
const sessions = new Map([["sid", {
type: "ssh",
conn,
hostname: "127.0.0.1",
port,
_reuseEndpoint: { hostname: "127.0.0.1", port },
}]]);
let pingTarget = null;
const measureSshPingLatency = createSshPingLatencyProbe();
const api = makeSessionOps(sessions, {
measureSshPingLatency: (target, timeoutMs) => {
pingTarget = target;
return measureSshPingLatency(target, timeoutMs);
},
});
const result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
await new Promise((resolve) => setTimeout(resolve, 50));
assert.equal(result.success, true);
assert.equal(pingTarget, conn);
assert.equal(result.stats.latencyMs, null);
assert.equal(accepted, 0);
});
test("getServerStats pings the companion connection for jump-host sessions", async () => {
const sessions = new Map([["sid", {
type: "mosh",
+26 -68
View File
@@ -27,10 +27,6 @@ const {
annotateMacLocalNetworkErrorMessage,
resolveFirstTcpEndpoint,
} = require("../macLocalNetworkAccess.cjs");
const {
buildRemoteShellExitPayload,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
@@ -548,15 +544,6 @@ function createStartSessionApi(ctx) {
),
});
},
// Upload progress tracks bytes the transport actually accepted instead
// of bytes queued (issue #3663): ssh2 keeps writableLength unchanged
// until one queued write fully completes but shrinks _chunk as the
// channel window opens, so summing both approximates the outstanding
// backlog while it drains. Without this, an rz progress bar freezes
// mid-file for as long as the channel window takes to flush.
getTransportPendingBytes() {
return (Number(stream.writableLength) || 0) + (stream._chunk?.length || 0);
},
interruptRemote() {
try { stream.signal?.("INT"); } catch { /* ignore */ }
},
@@ -678,8 +665,7 @@ function createStartSessionApi(ctx) {
// "close" fires whenever the channel closes (could be network drop).
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits
// and are reported as "signaled", not the reconnectable "closed".
// timeout) have code=null and signal set — those are not user exits.
// A third case exists: a remote that closes the channel without sending
// exit-status or exit-signal never fires "exit" at all (#3591). The
// initial values below stay null in that case and must not be reported
@@ -690,17 +676,8 @@ function createStartSessionApi(ctx) {
stream.on("exit", (code, signal) => {
log("shell exit", { sessionId, hostname: options.hostname, code, signal, reused: !!isReused });
streamExitCode = typeof code === "number" ? code : null;
streamExitSignal = normalizeRemoteExitSignal(signal);
streamExitSignal = typeof signal === "string" && signal ? signal : null;
streamExited = typeof code === "number" && !streamExitSignal;
// conn "close" can win the race against this channel's paced
// finalize. Stamp the signal so that path can still tell an
// administrative exit-signal from a transport drop.
if (streamExitSignal && sessions.get(sessionId) === session) {
session._remoteExitSignal = streamExitSignal;
}
if (streamExited && sessions.get(sessionId) === session) {
session._remoteExitCode = streamExitCode;
}
});
let closeFinalized = false;
@@ -709,7 +686,6 @@ function createStartSessionApi(ctx) {
sessionId,
hostname: options.hostname,
streamExitCode,
streamExitSignal,
streamExited,
reused: !!isReused,
transportError: sessions.get(sessionId)?._transportError,
@@ -741,20 +717,27 @@ function createStartSessionApi(ctx) {
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
});
} else {
// TMOUT auto-logout is a clean exit (numeric code, no signal) and
// is reported as "timeout" via the shell banner (#1062 / #977).
// An exit-signal is a separate administrative kick ("signaled").
// A channel close with neither exit-status nor exit-signal stays
// "closed" and must not be reported as code 0 (#3591).
// A shell TMOUT auto-logout is a clean exit (numeric code, no
// signal) — identical to a user-typed `exit` by code/signal —
// so detect it via the banner the shell prints just before
// exiting and report it as a timeout. That keeps the tab open
// for reconnect instead of auto-closing it (#1062 / #977).
const idleTimedOut = streamExited && looksLikeIdleAutoLogout(liveSession?._promptTrackTail);
const exitPayload = buildRemoteShellExitPayload({
const reason = idleTimedOut ? "timeout" : (streamExited ? "exited" : "closed");
const exitPayload = {
sessionId,
generation: liveSession?._terminalSessionGeneration,
exitCode: streamExitCode,
exitSignal: streamExitSignal,
shellExited: streamExited,
idleTimedOut,
});
reason,
_terminalSessionGeneration: liveSession?._terminalSessionGeneration,
};
// Report only exit facts the remote actually sent (#3591). When
// the peer closed the channel without exit-status/exit-signal,
// there is no code to show — flag it instead so the renderer can
// distinguish an abnormal teardown from a user-typed `exit 0`.
if (typeof streamExitCode === "number") {
exitPayload.exitCode = streamExitCode;
} else if (streamExitSignal === null) {
exitPayload.remoteClosedWithoutExitStatus = true;
}
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, exitPayload);
}
liveSession?.zmodemSentry?.cancel();
@@ -2481,16 +2464,6 @@ function createStartSessionApi(ctx) {
});
conn.on("error", (err) => {
// ssh2 reports agent failures before trying the next auth method.
// Keep the connection and its authentication deadline alive.
if (err.level === "agent") {
log("non-fatal agent auth error (will try next method)", {
sessionId,
hostname: options.hostname,
error: err.message,
});
return;
}
clearAuthReadyTimer();
// After the promise is settled, we can't reject again. But if the
// session was already established (resolved), we still need to notify
@@ -2665,28 +2638,13 @@ function createStartSessionApi(ctx) {
reason: "error",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
} else if (normalizeRemoteExitSignal(session?._remoteExitSignal)) {
// The shell already reported an exit-signal. This connection
// close is the server finishing that administrative kick, not
// a transport flap — keep it out of the reconnect loop.
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
sessionId,
generation: session?._terminalSessionGeneration,
exitSignal: session._remoteExitSignal,
shellExited: false,
idleTimedOut: false,
}));
} else {
// Preserve a shell status already received before transport
// close; otherwise do not invent a successful exit code.
const shellExited = typeof session?._remoteExitCode === "number";
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, buildRemoteShellExitPayload({
safeSendSessionExit({ safeSend, electronModule, sessions }, contents, sessionId, {
sessionId,
generation: session?._terminalSessionGeneration,
exitCode: session?._remoteExitCode,
shellExited,
idleTimedOut: shellExited && looksLikeIdleAutoLogout(session?._promptTrackTail),
}));
exitCode: 0,
reason: "closed",
_terminalSessionGeneration: session?._terminalSessionGeneration,
});
}
// Use this connection's captured token so a late close from an
// old transport can't stop a newer same-sessionId stream (#916).
+6 -41
View File
@@ -1040,7 +1040,7 @@ function stableLocalFileIdentity(statLike) {
}
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
const { publishLocalFileExclusive, isMetadataUnsupportedError } = require("./localFilePublish.cjs");
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
const assertNotCancelled = options.assertNotCancelled || (() => {});
const token = crypto.randomUUID().replace(/-/g, "");
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
@@ -1050,7 +1050,6 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
let backedUp = false;
let committed = false;
let keepRecoveryFiles = false;
let skipReadyCleanup = false;
let preparedHandle;
let originalHandle;
let restoreProbeCreated = false;
@@ -1061,28 +1060,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
await fs.promises.rename(stagedPath, readyPath);
} catch (error) {
if (error?.code !== "EXDEV") throw error;
try {
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
} catch (copyError) {
if (!["ENOTSUP", "EOPNOTSUPP"].includes(copyError?.code)) throw copyError;
// copyFile includes metadata operations that writable GVFS mounts may
// reject. Reuse the exclusive bounded read/write publication path.
try {
await publishLocalFileExclusive(stagedPath, readyPath, assertNotCancelled, undefined, {
copyOnly: true, preserveMode: false, requireTimestamps: false,
});
} catch (fallbackError) {
// Do not remove a name this fallback never owned: copyFile may
// have left a partial, or an exclusive open may have lost a race.
skipReadyCleanup = true;
const cancelledBeforeOutput = !fallbackError.localPublicationIncomplete
&& (fallbackError.code === "ABORT_ERR" || fallbackError.message === "Transfer cancelled");
// An early cancellation needs ordinary stage cleanup, not recovery.
// Once output exists, retain the complete stage as before.
keepRecoveryFiles = !cancelledBeforeOutput;
throw fallbackError;
}
}
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
}
// Stamp the private prepared file before applying possibly unreadable
// destination permissions. Publication carries these times to the target.
@@ -1107,17 +1085,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
? validatedTarget.existingMode & 0o7777
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
if (mode !== null && mode !== appliedMode) {
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
try {
await preparedHandle.chmod(mode);
} catch (error) {
if (!isMetadataUnsupportedError(error)
|| ((await preparedHandle.stat()).mode & 0o7777) !== mode) throw error;
}
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
await fs.promises.chmod(readyPath, mode);
appliedMode = mode;
continue;
}
@@ -1163,9 +1131,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
assertNotCancelled();
let publishedIdentity = null;
try {
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle, {
preserveMode: appliedMode !== null, requireTimestamps: false,
}) ?? null;
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
} catch (error) {
if (error?.code === "EEXIST") {
throw new Error("Local download target changed during replacement", { cause: error });
@@ -1178,13 +1144,12 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
committed = true;
// Hand the published inode identity to the caller for descriptor-based
// metadata stamping after publication.
options.onCommit?.(publishedIdentity, localMtimePrepared && publishedIdentity?.timestampsPreserved !== false);
options.onCommit?.(publishedIdentity, localMtimePrepared);
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
await fs.promises.unlink(readyPath).catch(() => {});
await fs.promises.unlink(stagedPath).catch(() => {});
} catch (error) {
if (committed) throw error;
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
if (backedUp && !keepRecoveryFiles) {
try {
// The pathname may have changed between open and rename. Never copy
@@ -1214,7 +1179,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
if (backedUp) failure.remoteBackupPath = backupPath;
throw failure;
}
if (!skipReadyCleanup) await fs.promises.unlink(readyPath).catch(() => {});
await fs.promises.unlink(readyPath).catch(() => {});
throw error;
} finally {
await preparedHandle?.close().catch(() => {});
@@ -1,189 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const bridge = require("./transferBridge.cjs");
const temp = require("./tempDirBridge.cjs");
const unsupported = (code = "ENOTSUP") => Object.assign(new Error(`Injected ${code}`), { code });
// Real files and handles; only unavailable mount operations and virtual mode
// reporting are simulated. This is not a real Linux/GVFS acceptance test.
async function mountedDestination(t, options = {}) {
const root = fs.mkdtempSync(`${temp.getTempFilePath("gvfs-publish")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const stage = path.join(root, "stage.part");
const target = path.join(root, "target");
const payload = Buffer.alloc(2 * 1024 * 1024 + 17, 173);
fs.writeFileSync(stage, payload, { mode: 0o644 });
if (options.existingMode !== undefined) {
fs.writeFileSync(target, "original");
fs.chmodSync(target, options.existingMode);
}
const calls = [];
let cancelled = false;
for (const name of ["rename", "copyFile", "link", "open"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (name === "rename" && args[0] === stage) throw unsupported("EXDEV");
if (name === "copyFile") {
calls.push("copyFile");
if (options.readyCollision) fs.writeFileSync(args[1], "other writer");
if (options.cancelBeforeFallback) cancelled = true;
if (options.copyError !== false) throw unsupported(options.copyError || "ENOTSUP");
}
if (name === "link") throw unsupported();
const result = await original.apply(fs.promises, args);
if (name !== "open" || args[0] === stage) return result;
const created = args[1] === "wx";
const virtualMode = args[0] === target ? options.finalVirtualMode ?? options.virtualMode : options.virtualMode;
if (created && virtualMode !== undefined) fs.chmodSync(args[0], virtualMode);
result.chmod = async () => { calls.push("chmod"); throw unsupported(options.metadataError); };
result.utimes = async () => { calls.push("utimes"); throw unsupported(options.metadataError); };
if (created) {
const final = args[0] === target;
calls.push(final ? "final-open" : "ready-open");
const write = result.write.bind(result);
result.write = async (buffer, offset, length, position) => {
if (options.writeError && final) throw unsupported(options.writeError);
const written = await write(buffer, offset, Math.min(length, 32768), position);
if (options.cancel && !final) cancelled = true;
return written;
};
const close = result.close.bind(result);
result.close = async () => {
await close();
if (options.closeError === (final ? "final" : "ready")) throw unsupported("EIO");
};
}
return result;
};
}
const run = () => bridge._promoteLocalTransferForTests(stage, target, {
sourceSoftIdentity: { mtimeMs: 1700000000000 },
validateTarget: async () => ({
targetIdentity: options.existingMode === undefined ? "missing" : undefined,
existingMode: options.existingMode,
}),
assertNotCancelled: () => {
if (cancelled || (options.cancelBeforeFinal && !fs.existsSync(target))) throw unsupported("ABORT_ERR");
},
onCommit: (_identity, timestampsPreserved) => { calls.push(["commit", timestampsPreserved]); },
});
return { root, stage, target, payload, calls, run };
}
for (const [virtualMode, copyError, metadataError] of [[0o644, "ENOTSUP"], [0o700, "EOPNOTSUPP"], [0o700, "ENOTSUP", "EPERM"], [0o700, "ENOTSUP", "EACCES"]]) {
test(`new mounted destination accepts creation policy ${virtualMode.toString(8)} with ${metadataError || "ENOTSUP"} through both copy stages`, async (t) => {
const setup = await mountedDestination(t, { virtualMode, copyError, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
assert.ok(setup.calls.includes("ready-open"));
assert.ok(setup.calls.includes("final-open"));
assert.deepEqual(setup.calls.at(-1), ["commit", false]);
});
}
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`existing mounted file keeps matching virtual permissions: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o700, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o700);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
});
test(`refused chmod cannot broaden an existing destination: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o600, metadataError });
await assert.rejects(setup.run, { code: metadataError });
assert.equal(fs.readFileSync(setup.target, "utf8"), "original");
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o600);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const copyError of ["EACCES", "EPERM", "ENOSPC", "EIO", "EEXIST", "EINVAL"]) {
test(`copyFile ${copyError} remains a failure`, async (t) => {
const setup = await mountedDestination(t, { copyError });
await assert.rejects(setup.run, { code: copyError });
assert.equal(fs.existsSync(setup.target), false);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.includes("ready-open"), false);
});
}
for (const failure of [{ cancel: true }, { closeError: "ready" }, { closeError: "final" }, { writeError: "ENOSPC" }, { writeError: "EPERM" }, { writeError: "EACCES" }]) {
test(`copy failure preserves original and complete stage: ${JSON.stringify(failure)}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, ...failure });
await assert.rejects(setup.run, /Recovery files preserved/);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.equal(fs.readFileSync(backup ? path.join(setup.root, backup) : setup.target, "utf8"), "original");
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
test("failed copy followed by exclusive-open collision leaves that entry intact", async (t) => {
const setup = await mountedDestination(t, { readyCollision: true });
await assert.rejects(setup.run, /Recovery files preserved/);
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(fs.existsSync(setup.target), false);
});
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const metadataError of ["EPERM", "EACCES"]) {
test(`final copy cannot broaden an existing destination after chmod ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, {
existingMode: 0o644, virtualMode: 0o644, finalVirtualMode: 0o700, metadataError,
});
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
for (const readyCollision of [false, true]) {
test(`cancellation before fallback output remains cancellation, existing ready=${readyCollision}`, async (t) => {
const setup = await mountedDestination(t, { cancelBeforeFallback: true, readyCollision });
const originalLstat = fs.promises.lstat;
const originalUnlink = fs.promises.unlink;
t.after(() => { fs.promises.lstat = originalLstat; fs.promises.unlink = originalUnlink; });
for (const [name, original] of [["lstat", originalLstat], ["unlink", originalUnlink]]) {
fs.promises[name] = async (...args) => {
assert.equal(String(args[0]).endsWith(".ready"), false, "no destination probe/cleanup after early cancellation");
return original.apply(fs.promises, args);
};
}
await assert.rejects(setup.run, (error) => {
assert.equal(error.code, "ABORT_ERR");
assert.equal(Boolean(error.recoveryFailed), false);
return true;
});
assert.equal(setup.calls.includes("ready-open"), false);
assert.equal(fs.existsSync(setup.target), false);
if (readyCollision) {
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
} else assert.deepEqual(fs.readdirSync(setup.root), ["stage.part"]);
});
}
-3
View File
@@ -76,9 +76,6 @@ const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
// Minimum window size: enough to render the expanded sidebar + a usable
// host list + the 420px host details / new-host aside panel without overflow.
// Small displays shrink this fit to their work area at window-creation time
// (see resolveWindowMinimumSize in windowManager/mainWindow.cjs, #3647);
// the minimum tracks the display the window currently occupies.
const MIN_WINDOW_WIDTH = 1100;
const MIN_WINDOW_HEIGHT = 640;
+4 -136
View File
@@ -6,104 +6,6 @@ const { attachDisplayRecovery } = require("./displayRecovery.cjs");
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
// #3647: the 1100×640 layout minimum is larger than the usable workspace on
// small laptops (e.g. 1366×768 at 125% Windows scaling leaves only ~1093×574
// logical px), which made those windows impossible to fit — let alone shrink.
// Keep the layout minimum on capable screens, but shrink it to the work area
// of the display the window targets so the window can always fit with a small
// margin.
const MIN_SIZE_FIT_FACTOR = 0.9;
const MIN_WINDOW_FLOOR_WIDTH = 320;
const MIN_WINDOW_FLOOR_HEIGHT = 240;
function pickTargetDisplay(screenModule, bounds) {
if (!screenModule || typeof screenModule.getDisplayMatching !== "function") {
return screenModule?.getPrimaryDisplay?.() || null;
}
try {
return screenModule.getDisplayMatching(bounds) || screenModule.getPrimaryDisplay?.() || null;
} catch {
try {
return screenModule.getPrimaryDisplay?.() || null;
} catch {
return null;
}
}
}
function resolveWindowMinimumSize(screenModule, minWidth, minHeight, bounds) {
let effectiveWidth = minWidth;
let effectiveHeight = minHeight;
try {
// Size for the display the window actually lives on (saved position or
// current placement), falling back to the primary display.
const display = bounds
? pickTargetDisplay(screenModule, bounds)
: screenModule?.getPrimaryDisplay?.();
const workArea = display?.workArea;
if (Number.isFinite(workArea?.width) && workArea.width > 0) {
effectiveWidth = Math.min(effectiveWidth, Math.floor(workArea.width * MIN_SIZE_FIT_FACTOR));
}
if (Number.isFinite(workArea?.height) && workArea.height > 0) {
effectiveHeight = Math.min(effectiveHeight, Math.floor(workArea.height * MIN_SIZE_FIT_FACTOR));
}
} catch {
// screen unavailable (e.g. before app ready): keep the static minimums
}
return {
width: Math.max(MIN_WINDOW_FLOOR_WIDTH, effectiveWidth),
height: Math.max(MIN_WINDOW_FLOOR_HEIGHT, effectiveHeight),
};
}
// Keep the BrowserWindow minimum in sync with the display it currently lives
// on: docking, unplugging a screen, or display-scaling changes can move the
// window onto a display with a different work area.
function attachDisplayMinimumSync({ win, screen: screenModule, minWidth, minHeight }) {
if (!win || !screenModule || typeof screenModule.on !== "function") return () => {};
const applyMinimumForCurrentDisplay = () => {
try {
if (win.isDestroyed?.()) return;
const next = resolveWindowMinimumSize(screenModule, minWidth, minHeight, win.getBounds());
const [currentWidth, currentHeight] = win.getMinimumSize?.() || [];
if (next.width === currentWidth && next.height === currentHeight) return;
win.setMinimumSize(next.width, next.height);
} catch {
// Best-effort only.
}
};
const onDisplayMetricsChanged = () => applyMinimumForCurrentDisplay();
const onDisplayRemoved = () => applyMinimumForCurrentDisplay();
const onWindowMoved = () => applyMinimumForCurrentDisplay();
try {
screenModule.on("display-metrics-changed", onDisplayMetricsChanged);
screenModule.on("display-removed", onDisplayRemoved);
win.on("move", onWindowMoved);
} catch {
try {
win.removeListener?.("move", onWindowMoved);
} catch {
// ignore
}
return () => {};
}
return function detachDisplayMinimumSync() {
try {
screenModule.removeListener?.("display-metrics-changed", onDisplayMetricsChanged);
screenModule.removeListener?.("display-removed", onDisplayRemoved);
} catch {
// ignore
}
try {
win.removeListener?.("move", onWindowMoved);
} catch {
// ignore
}
};
}
function setTerminalKeyboardFocusForWindow(win, focused) {
if (!win || win.isDestroyed?.() || !win.webContents) return false;
const isFocused = focused === true;
@@ -211,24 +113,11 @@ function createMainWindowApi(ctx) {
height: DEFAULT_WINDOW_HEIGHT,
};
// Size the minimum to the display the restored window will live on
// (a saved position may target a smaller secondary display), falling
// back to the primary display when there is no usable saved position.
const savedTargetBounds = savedState && Number.isFinite(savedState.x) && Number.isFinite(savedState.y)
? { x: savedState.x, y: savedState.y, width: savedState.width, height: savedState.height }
: null;
const windowMinimumSize = resolveWindowMinimumSize(
screen,
MIN_WINDOW_WIDTH,
MIN_WINDOW_HEIGHT,
savedTargetBounds,
);
if (savedState) {
// Use saved dimensions, but clamp to the minimum so a previously
// shrunk window from an older build cannot start below the minimum.
windowBounds.width = Math.max(savedState.width, windowMinimumSize.width);
windowBounds.height = Math.max(savedState.height, windowMinimumSize.height);
windowBounds.width = Math.max(savedState.width, MIN_WINDOW_WIDTH);
windowBounds.height = Math.max(savedState.height, MIN_WINDOW_HEIGHT);
// Only use saved position if the screen is available at that location
if (typeof savedState.x === "number" && typeof savedState.y === "number") {
@@ -260,8 +149,8 @@ function createMainWindowApi(ctx) {
const win = new BrowserWindow({
...windowBounds,
minWidth: windowMinimumSize.width,
minHeight: windowMinimumSize.height,
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
backgroundColor,
icon: appIcon,
show: false,
@@ -301,20 +190,6 @@ function createMainWindowApi(ctx) {
detachDisplayRecovery = null;
}
// #3647: docking / display-scaling changes can move the window onto a
// display with a different work area; keep the minimum in sync so the
// window can always fit its current display.
const detachDisplayMinimumSync = attachDisplayMinimumSync({
win,
screen,
// Pass the configured (layout) minimum as the baseline, not the
// display-fitted value resolved above: otherwise a reduced minimum
// would become a permanent ceiling and could never grow back to
// MIN_WINDOW_WIDTH × MIN_WINDOW_HEIGHT on a larger display.
minWidth: MIN_WINDOW_WIDTH,
minHeight: MIN_WINDOW_HEIGHT,
});
// Clear reference when the main window is destroyed
win.on('closed', () => {
try {
@@ -322,11 +197,6 @@ function createMainWindowApi(ctx) {
} catch {
// ignore
}
try {
detachDisplayMinimumSync?.();
} catch {
// ignore
}
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
@@ -725,8 +595,6 @@ function createMainWindowApi(ctx) {
}
module.exports = {
attachDisplayMinimumSync,
createMainWindowApi,
resolveWindowMinimumSize,
setTerminalKeyboardFocusForWindow,
};
@@ -1,194 +0,0 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { resolveWindowMinimumSize, attachDisplayMinimumSync } = require("./mainWindow.cjs");
test("uses the static layout minimums when they fit the display", () => {
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 1920, height: 1080 } }) },
1100,
640,
);
assert.deepEqual(size, { width: 1100, height: 640 });
});
test("sizes the minimum for the display containing the target bounds", () => {
const primary = { workArea: { width: 1920, height: 1080 } };
const secondary = { workArea: { width: 1093, height: 585 } };
const size = resolveWindowMinimumSize(
{
getPrimaryDisplay: () => primary,
getDisplayMatching: (bounds) => (bounds.x > 1920 ? secondary : primary),
},
1100,
640,
{ x: 2000, y: 0, width: 1000, height: 700 },
);
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
});
test("falls back to the primary display when the target cannot be matched", () => {
const size = resolveWindowMinimumSize(
{
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
getDisplayMatching: () => null,
},
1100,
640,
{ x: 0, y: 0, width: 1000, height: 700 },
);
assert.deepEqual(size, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
});
test("refreshes the window minimum when display metrics change", () => {
const { EventEmitter } = require("node:events");
const screenModule = new EventEmitter();
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1920, height: 1080 } });
const win = new EventEmitter();
win.isDestroyed = () => false;
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
let minimum = { width: 1100, height: 640 };
win.getMinimumSize = () => [minimum.width, minimum.height];
win.setMinimumSize = (width, height) => { minimum = { width, height }; };
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
try {
// Docking change shrinks the primary work area under the window.
screenModule.getPrimaryDisplay = () => ({ workArea: { width: 1093, height: 585 } });
screenModule.emit("display-metrics-changed");
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
// No-op emission keeps the same minimum.
screenModule.emit("display-metrics-changed");
assert.deepEqual(minimum, { width: Math.floor(1093 * 0.9), height: Math.floor(585 * 0.9) });
} finally {
detach();
}
});
test("ignores display events after detach", () => {
const { EventEmitter } = require("node:events");
const screenModule = new EventEmitter();
const win = new EventEmitter();
win.isDestroyed = () => false;
win.getBounds = () => ({ x: 0, y: 0, width: 1100, height: 640 });
win.getMinimumSize = () => [1100, 640];
let called = 0;
win.setMinimumSize = () => { called += 1; };
const detach = attachDisplayMinimumSync({ win, screen: screenModule, minWidth: 1100, minHeight: 640 });
detach();
screenModule.emit("display-metrics-changed");
screenModule.emit("display-removed");
assert.equal(called, 0);
});
test("shrinks the minimum to fit a small primary work area (#3647)", () => {
// 1366×768 at 125% Windows scaling leaves ~1093×585 logical px of work area.
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }) },
1100,
640,
);
assert.equal(size.width, Math.floor(1093 * 0.9));
assert.equal(size.height, Math.floor(585 * 0.9));
assert.ok(size.width < 1100 && size.height < 640);
});
test("never shrinks below a small usable floor", () => {
const size = resolveWindowMinimumSize(
{ getPrimaryDisplay: () => ({ workArea: { width: 300, height: 200 } }) },
1100,
640,
);
assert.deepEqual(size, { width: 320, height: 240 });
});
test("keeps the static minimums when screen is unavailable", () => {
assert.deepEqual(resolveWindowMinimumSize(null, 1100, 640), { width: 1100, height: 640 });
assert.deepEqual(resolveWindowMinimumSize({}, 1100, 640), { width: 1100, height: 640 });
assert.deepEqual(
resolveWindowMinimumSize(
{ getPrimaryDisplay: () => { throw new Error("screen not ready"); } },
1100,
640,
),
{ width: 1100, height: 640 },
);
});
test("createWindow clamps saved bounds and BrowserWindow minima to the fitted size", async () => {
const { EventEmitter } = require("node:events");
const { createMainWindowApi } = require("./mainWindow.cjs");
let constructedOptions = null;
class Window extends EventEmitter {
constructor(options) {
super();
constructedOptions = options;
this.destroyed = false;
this.webContents = Object.assign(new EventEmitter(), {
id: 71,
isDestroyed: () => this.destroyed,
setWindowOpenHandler() {},
openDevTools() {},
send() {},
});
}
close() {}
isDestroyed() { return this.destroyed; }
isMaximized() { return false; }
isFullScreen() { return false; }
getBounds() { return { width: 1000, height: 700 }; }
setBackgroundColor() {}
loadURL() { return Promise.resolve(); }
}
const api = createMainWindowApi({
mainWindow: null, electronApp: null, currentTheme: "light", isQuitting: false,
pendingWindowStateWrite: null, queuedWindowState: null, windowStateCloseRequested: false,
DEFAULT_WINDOW_WIDTH: 1400, DEFAULT_WINDOW_HEIGHT: 900, MIN_WINDOW_WIDTH: 1100, MIN_WINDOW_HEIGHT: 640,
V8_CACHE_OPTIONS: "code", THEME_COLORS: { light: {} },
unhealthyWebContentsIds: new Set(), rendererReadySeenByWebContentsId: new Set(),
__dirname, URL, require, console, setTimeout, clearTimeout,
getGlobalShortcutBridge: () => ({ handleWindowClose: () => false }),
getMainWindowCount: () => 1,
debugLog() {}, resolveFrontendBackgroundColor() { return null; },
loadWindowState() { return { width: 980, height: 560, x: 0, y: 0 }; },
getDevRendererBaseUrl: (url) => url, getWindowBoundsState() { return null; },
queueWindowStateSave() {}, saveWindowStateSync() {}, setupDeferredShow() {},
createExternalOnlyWindowOpenHandler() { return {}; }, createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {}, registerWindowHandlers() {}, requestWindowCommandClose() {},
shouldCloseWindowFromInput() { return false; },
registerMainWindow() {}, unregisterMainWindow() {}, registerAppContentWindow() {}, unregisterAppContentWindow() {},
closeSettingsWindow() {}, hideSettingsWindow() {}, applyWindowOpacityToWindow() {},
});
await api.createWindow(
{
BrowserWindow: Window,
nativeTheme: {},
app: {},
screen: {
getPrimaryDisplay: () => ({ workArea: { width: 1093, height: 585 } }),
getAllDisplays: () => [],
},
shell: {},
ipcMain: {},
},
{
preload: "/tmp/preload.cjs",
devServerUrl: "http://localhost:5173",
isDev: true,
appIcon: null,
isMac: true,
electronDir: __dirname,
},
);
assert.ok(constructedOptions);
assert.equal(constructedOptions.minWidth, Math.floor(1093 * 0.9));
assert.equal(constructedOptions.minHeight, Math.floor(585 * 0.9));
assert.equal(constructedOptions.width, Math.max(980, Math.floor(1093 * 0.9)));
assert.equal(constructedOptions.height, Math.max(560, Math.floor(585 * 0.9)));
});
+30 -272
View File
@@ -107,11 +107,6 @@ function buildModeRestores(dir, names, removeIndices, modes) {
* Optional. When writeToRemote returns false, the upload loop awaits this
* before sending more file data (typically waitForWritableDrain(stream)).
* Callers should forward `opts.signal` so cancel can abort a blocked drain.
* @param {() => number} [opts.getTransportPendingBytes]
* Optional. Return the bytes the transport has queued but not delivered yet
* (e.g. the SSH channel backlog). Enables delivered-byte upload progress:
* while parked on backpressure the loop reports queued-minus-outstanding
* bytes so the progress indicator tracks actual delivery (issue #3663).
* @param {() => import('electron').WebContents | null} opts.getWebContents
* Returns the Electron WebContents for sending progress IPC events.
* @param {string} [opts.label]
@@ -136,11 +131,6 @@ function createZmodemSentry(opts) {
const pendingEchoes = [];
let pendingTerminalSuppression = null;
let pendingTerminalSuppressionAt = 0;
let pendingHexHeaderFragment = null;
let pendingHexHeaderFragmentTimer = null;
// Set when a stripped hex header's trailing XON (0x11) may arrive in the
// next terminal-output chunk; at most one byte is then dropped.
let suppressNextTerminalXon = false;
function setPendingTerminalSuppression(buf) {
pendingTerminalSuppression = buf;
@@ -162,16 +152,6 @@ function createZmodemSentry(opts) {
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
// "**" + ZDLE + 'B': the start of every ZMODEM hex header.
const ZMODEM_HEX_HEADER_PREFIX = Buffer.from([0x2a, 0x2a, 0x18, 0x42]);
// 4-byte pattern + 14 hex digits + CR is the most a still-incomplete
// header can look like before the final LF (plus optional XON) arrives.
const ZMODEM_HEX_HEADER_MAX_PARTIAL = 19;
// How long a withheld partial-header fragment may sit unresolved before
// it is flushed through to the terminal anyway.
const HEX_HEADER_FRAGMENT_TTL_MS = Number.isFinite(opts.hexHeaderFragmentTtlMs)
? Math.max(1, opts.hexHeaderFragmentTtlMs)
: 1000;
/**
* Upper bound on how long a drag-drop echo suppression may stay armed.
* The suppression hides the terminal echo of the upload command, whose echo
@@ -260,14 +240,6 @@ function createZmodemSentry(opts) {
return buf;
}
function isHexDigitByte(byte) {
return (
(byte >= 0x30 && byte <= 0x39)
|| (byte >= 0x41 && byte <= 0x46)
|| (byte >= 0x61 && byte <= 0x66)
);
}
function stripVisibleZmodemHeaders(data) {
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let searchFrom = 0;
@@ -281,7 +253,12 @@ function createZmodemSentry(opts) {
let isHexHeader = true;
for (let i = 0; i < 14; i += 1) {
if (!isHexDigitByte(buf[prefixAt + 4 + i])) {
const byte = buf[prefixAt + 4 + i];
const isHexDigit =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66);
if (!isHexDigit) {
isHexHeader = false;
break;
}
@@ -297,10 +274,6 @@ function createZmodemSentry(opts) {
headerLength = 20;
if (buf[prefixAt + 20] === 0x11) {
headerLength = 21;
} else if (prefixAt + 20 === buf.length) {
// The optional XON that follows the CR/LF may arrive in the next
// chunk; strip at most one leading XON byte from it (#3651).
suppressNextTerminalXon = true;
}
}
@@ -314,119 +287,6 @@ function createZmodemSentry(opts) {
return buf;
}
function flushPendingHexHeaderFragment() {
pendingHexHeaderFragmentTimer = null;
const buf = pendingHexHeaderFragment;
pendingHexHeaderFragment = null;
if (buf?.length) onData(buf);
}
/**
* A ZMODEM hex header (the ZRQINIT/ZRINIT preamble of `sz`/`rz`) can be
* split across transport chunks. zmodem.js hands each original chunk to
* to_terminal verbatim and only complete headers can be stripped, so a
* chunk that ends in the middle of the preamble painted it as terminal
* garble (#3651). Detect the trailing bytes that may still form one and
* hold them back; the next chunk either completes the header (stripped
* above) or proves the buffer invalid. A TTL guard flushes held bytes so
* deferrals can never lose legitimate terminal output.
*
* Returns the index from which the buffer should be withheld, or -1.
*/
function findTrailingHexHeaderFragment(buf) {
if (!buf.length) return -1;
// Longest still-incomplete header tail: the 4-byte pattern, 14 hex
// digits, and a CR — i.e. everything up to the final LF (+ optional XON).
const windowStart = Math.max(0, buf.length - ZMODEM_HEX_HEADER_MAX_PARTIAL);
for (let i = windowStart; i <= buf.length - 4; i += 1) {
let matched = true;
for (let j = 0; j < 4; j += 1) {
if (buf[i + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
matched = false;
break;
}
}
if (!matched) continue;
let digits = 0;
while (i + 4 + digits < buf.length && isHexDigitByte(buf[i + 4 + digits])) {
digits += 1;
}
// Chunk ends inside the hex digits — the header may still complete.
if (i + 4 + digits === buf.length) return i;
// Digit run ended before the 14 required digits, or overshoots them:
// not a hex header.
if (digits !== 14) continue;
// A CR (but not yet the trailing LF) may still complete the header.
if (buf.length - i <= 19) return i;
break;
}
// The "**" + ZDLE + 'B' pattern itself may be split: withhold any
// trailing bytes that are a prefix of it until the next chunk resolves.
for (let k = Math.min(3, buf.length); k >= 1; k -= 1) {
let matches = true;
for (let j = 0; j < k; j += 1) {
if (buf[buf.length - k + j] !== ZMODEM_HEX_HEADER_PREFIX[j]) {
matches = false;
break;
}
}
if (matches) return buf.length - k;
}
return -1;
}
/**
* Join any previously withheld partial-header bytes onto the new chunk so
* the suppression/stripping stages can see a complete header again. This
* must run BEFORE the stripping stages, or a header split across two
* chunks would be judged complete too late and reach the terminal.
*/
function assembleTerminalChunk(octets) {
const buf = Buffer.isBuffer(octets) ? octets : Buffer.from(octets);
if (!pendingHexHeaderFragment?.length) {
return buf;
}
if (pendingHexHeaderFragmentTimer) {
clearTimeout(pendingHexHeaderFragmentTimer);
pendingHexHeaderFragmentTimer = null;
}
const merged = Buffer.concat([pendingHexHeaderFragment, buf]);
pendingHexHeaderFragment = null;
return merged;
}
/**
* Withhold a trailing partial hex header (if any) and arm a timer that
* flushes it when no more bytes arrive to resolve it.
*/
function deferIncompleteHexHeader(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fragmentAt = findTrailingHexHeaderFragment(buf);
if (fragmentAt === -1) return buf;
if (fragmentAt < buf.length) {
pendingHexHeaderFragment = Buffer.from(buf.subarray(fragmentAt));
if (!pendingHexHeaderFragmentTimer) {
pendingHexHeaderFragmentTimer = setTimeout(
flushPendingHexHeaderFragment,
HEX_HEADER_FRAGMENT_TTL_MS,
);
}
}
return fragmentAt ? buf.subarray(0, fragmentAt) : Buffer.alloc(0);
}
function looksLikeResidualZmodemData(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!buf.length) return true;
@@ -593,19 +453,8 @@ function createZmodemSentry(opts) {
const sentry = new Zmodem.Sentry({
to_terminal(octets) {
// Normal data – pass raw bytes to the caller for charset-aware decoding.
// Bytes that may be the start of a ZMODEM hex header split across
// chunks are withheld first (and later stripped or flushed) so the
// preamble of sz/rz never reaches the terminal as garble (#3651).
let sanitizedOctets = assembleTerminalChunk(octets);
if (suppressNextTerminalXon) {
suppressNextTerminalXon = false;
if (sanitizedOctets[0] === 0x11) {
sanitizedOctets = sanitizedOctets.subarray(1);
}
}
sanitizedOctets = stripPendingTerminalSuppression(sanitizedOctets);
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
sanitizedOctets = deferIncompleteHexHeader(sanitizedOctets);
if (!sanitizedOctets.length) return;
onData(sanitizedOctets);
},
@@ -917,8 +766,6 @@ const UPLOAD_DRAIN_TIMEOUT_MS = 60000;
const UPLOAD_CHUNK_SIZE = 64 * 1024;
/** Default interval between non-final upload progress IPC events. */
const DEFAULT_UPLOAD_PROGRESS_THROTTLE_MS = 100;
/** Interval between delivered-byte progress reports while parked on drain. */
const UPLOAD_DRAIN_PROGRESS_INTERVAL_MS = 100;
function resolveTimeoutMs(value, fallback) {
return Number.isFinite(value) && value >= 0 ? value : fallback;
@@ -1134,76 +981,6 @@ function createZmodemUploadDrainWaiter(opts) {
};
}
/**
* Park the upload loop on transport backpressure while keeping progress
* updates alive (issue #3663).
*
* Once a large write backlog fills the transport, the upload loop otherwise
* sits silent on the "drain" event while the transport keeps delivering the
* queued backlog at full link speed: on SSH the entire channel window drains
* between two upload-loop steps, so the progress indicator freezes mid-file
* and jumps when the loop resumes. While the drain wait stays pending, poll
* it and let the caller emit progress events showing bytes the transport has
* actually accepted instead.
*
* Falls back to a plain drain wait when the transport cannot report its
* outstanding byte count. Rejections (timeout, cancellation) propagate.
*
* @param {{
* waitForDrain?: () => Promise<void>,
* getTransportPendingBytes?: () => unknown,
* progressThrottleMs?: number,
* }} opts
* @param {() => void} emitProgress Called at most once per throttle interval
* while the drain wait stays pending.
* @returns {Promise<void>}
*/
async function awaitUploadDrainWithProgress(opts, emitProgress) {
if (!opts.waitForDrain) return;
if (typeof opts.getTransportPendingBytes !== "function") {
await opts.waitForDrain();
return;
}
let settled = false;
const drain = opts.waitForDrain().finally(() => {
settled = true;
});
drain.catch(() => {}); // observed at the final await; never surface early
// A drain wait without pending backpressure resolves after one event-loop
// turn; yield once so backpressure-free uploads do not gain polling delay.
await new Promise((resolve) => setImmediate(resolve));
if (settled) {
await drain;
return;
}
const intervalMs = Math.max(
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
resolveProgressThrottleMs(opts.progressThrottleMs),
);
// Parked: report accepted bytes right away, then once per interval while
// the transport drains its backlog. Progress emissions must never break
// (or slow) the transfer itself.
try { emitProgress(); } catch { /* ignore */ }
// Race each poll delay against drain. A wait that settles mid-interval
// must resume the upload immediately; sleeping out the rest of the
// interval throttles every backpressure cycle by up to ~100ms.
while (!settled) {
let timer;
await Promise.race([
drain.then(() => {}, () => {}),
new Promise((resolve) => {
timer = setTimeout(resolve, intervalMs);
}),
]);
clearTimeout(timer);
if (settled) break;
try { emitProgress(); } catch { /* ignore */ }
}
await drain;
}
/**
* Resolve when `promise` settles, or reject with NETCATTY_ZMODEM_CANCELLED
* if `signal` aborts first. Used so cancel unblocks upload drain waits even
@@ -1406,42 +1183,19 @@ async function handleUpload(zsession, opts) {
const { originalIndex, filePath, stat, name } = offers[i];
opts.resetUploadBackpressure?.();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
let bytesRemaining = 0;
for (let j = i; j < offers.length; j++) bytesRemaining += offers[j].stat.size;
// Progress should track bytes the transport accepted, not just bytes
// queued (#3663): on SSH the channel can buffer its whole window before
// write() returns false, so queued-only reporting freezes the progress
// bar while the backlog drains at link speed. When the transport can
// report outstanding bytes, show `sent - outstanding` and never move
// the bar backwards.
const getOutstandingBytes = typeof opts.getTransportPendingBytes === "function"
? opts.getTransportPendingBytes
: null;
let sent = 0;
let displayedTransferred = 0;
const emitFileProgress = () => {
const outstanding = getOutstandingBytes ? Number(getOutstandingBytes()) : NaN;
const delivered = Number.isFinite(outstanding)
? sent - Math.max(outstanding, 0)
: sent;
displayedTransferred = Math.min(
Math.max(delivered, displayedTransferred),
stat.size,
);
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: displayedTransferred,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
};
emitFileProgress();
// The offer handshake is the only upload step without a built-in
// deadline: if rz dies before answering ZFILE (crash, YMODEM fallback),
// send_offer() would park this loop forever and block all future
@@ -1469,6 +1223,7 @@ async function handleUpload(zsession, opts) {
// Read and send in chunks
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(UPLOAD_CHUNK_SIZE);
let sent = 0;
// Progress IPC is throttled by default for every transport. Pass 0 only
// when a caller explicitly needs one event per chunk.
const progressThrottleMs = resolveProgressThrottleMs(opts.progressThrottleMs);
@@ -1488,16 +1243,21 @@ async function handleUpload(zsession, opts) {
const now = Date.now();
if (progressThrottleMs === 0 || now - lastProgressEmitAt >= progressThrottleMs) {
emitFileProgress();
safeSend(getWebContents(), "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: offers.length,
transferType: "upload",
});
lastProgressEmitAt = now;
}
// Wait for transport to drain if its buffer is full (emitting
// delivered-byte progress while parked, #3663), then yield so
// inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) {
await awaitUploadDrainWithProgress(opts, emitFileProgress);
}
// Wait for transport to drain if its buffer is full, then yield
// so inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) await opts.waitForDrain();
await yieldToIO();
}
// All data written to Node.js buffer — but TCP may still be
@@ -1782,8 +1542,6 @@ module.exports = {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
UPLOAD_DRAIN_PROGRESS_INTERVAL_MS,
};
-406
View File
@@ -11,7 +11,6 @@ const {
handleDownload,
waitForWritableDrain,
createZmodemUploadDrainWaiter,
awaitUploadDrainWithProgress,
UPLOAD_CHUNK_SIZE,
UPLOAD_DRAIN_TIMEOUT_MS,
} = require("./zmodemHelper.cjs");
@@ -423,282 +422,6 @@ test("handleUpload does not read the next chunk until transport backpressure cle
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload reports transport-accepted bytes instead of the queued backlog", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-delivered-"));
const filePath = path.join(tempDir, "upload.bin");
const size = UPLOAD_CHUNK_SIZE * 2;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
const zsession = {
async send_offer() {
return {
send() {},
async end() {},
};
},
async close() {},
};
await handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.bin"],
}),
progressThrottleMs: 0,
// 4096 bytes of every queued amount are still outstanding in the transport.
getTransportPendingBytes: () => 4096,
});
assert.deepEqual(
progress.map((event) => [event.transferred, Boolean(event.finalizing)]),
[
[0, false],
[UPLOAD_CHUNK_SIZE - 4096, false],
[size - 4096, false],
[size, true],
],
);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("handleUpload emits delivered-byte progress while parked on transport drain", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-park-"));
const filePath = path.join(tempDir, "upload.bin");
const size = UPLOAD_CHUNK_SIZE * 2 + 1;
fs.writeFileSync(filePath, Buffer.alloc(size, 0x5a));
const progress = [];
// Bytes queued into the transport (never consumed; delivery is simulated
// below on the pending-byte readout).
let outstanding = 0;
let drainStartedAt = null;
let releaseFirstDrain;
const firstDrain = new Promise((resolve) => {
releaseFirstDrain = resolve;
});
let drainCalls = 0;
const zsession = {
async send_offer() {
return {
send(chunk) {
outstanding += chunk.byteLength;
},
async end() {},
};
},
async close() {},
};
const upload = handleUpload(zsession, {
sessionId: "session-1",
getWebContents: () => ({
isDestroyed: () => false,
send(channel, data) {
if (channel === "netcatty:zmodem:progress") progress.push(data);
},
}),
takeDragDropUpload: () => ({
filePaths: [filePath],
remoteNames: ["upload.bin"],
}),
progressThrottleMs: 0,
getTransportPendingBytes: () => {
if (drainStartedAt === null) return outstanding;
// The backlog drains at 512 B/ms once the parked wait begins.
const delivered = Math.min(outstanding, (Date.now() - drainStartedAt) * 512);
return outstanding - delivered;
},
async waitForDrain() {
drainCalls += 1;
if (drainCalls === 1) {
drainStartedAt = Date.now();
await firstDrain;
}
},
});
while (drainCalls === 0) {
await new Promise((resolve) => setImmediate(resolve));
}
const progressedBefore = progress.length;
await new Promise((resolve) => setTimeout(resolve, 250));
const parkedEvents = progress.slice(progressedBefore);
releaseFirstDrain();
await upload;
// While parked, delivered-byte progress must keep flowing (previously the
// indicator stayed frozen until the whole backlog drained).
assert.ok(
parkedEvents.some((event) => event.transferred > 0 && !event.finalizing),
`expected delivered-byte emissions while parked, got ${JSON.stringify(parkedEvents.map((e) => [e.transferred, Boolean(e.finalizing)]))}`,
);
const values = progress.map((event) => event.transferred);
for (let k = 1; k < values.length; k++) {
assert.ok(
values[k] >= values[k - 1],
`progress must never move backwards: ${values.join(",")}`,
);
assert.ok(values[k] <= size, `progress must not exceed the file size: ${values.join(",")}`);
}
assert.equal(progress.at(-1).finalizing, true);
fs.rmSync(tempDir, { recursive: true, force: true });
});
test("awaitUploadDrainWithProgress falls back to a plain drain wait without pending-byte accounting", async () => {
let drainCalls = 0;
await awaitUploadDrainWithProgress(
{
waitForDrain: () => {
drainCalls += 1;
return Promise.resolve();
},
},
() => {
throw new Error("no progress emissions without pending-byte accounting");
},
);
assert.equal(drainCalls, 1);
});
test("awaitUploadDrainWithProgress adds no polling delay when drain resolves immediately", async () => {
const startedAt = Date.now();
await awaitUploadDrainWithProgress(
{
waitForDrain: () => Promise.resolve(),
getTransportPendingBytes: () => 0,
},
() => {
throw new Error("no emissions without parked backpressure");
},
);
assert.ok(Date.now() - startedAt < 50);
});
test("awaitUploadDrainWithProgress returns promptly when drain settles during the poll interval", async () => {
let releaseDrain;
const drainGate = new Promise((resolve) => {
releaseDrain = resolve;
});
let parked = false;
const done = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 2048,
},
() => {
parked = true;
},
);
const waitStarted = Date.now();
while (!parked) {
if (Date.now() - waitStarted > 1000) {
throw new Error("upload drain wait never parked");
}
await new Promise((resolve) => setImmediate(resolve));
}
// The immediate park emission has run and the poll timer (~100ms) is armed.
// Settle on the next turn, still inside that interval.
await new Promise((resolve) => setImmediate(resolve));
const releasedAt = Date.now();
releaseDrain();
await done;
const elapsed = Date.now() - releasedAt;
assert.ok(
elapsed < 50,
`drain settlement during a poll interval resumed in ${elapsed}ms`,
);
});
test("awaitUploadDrainWithProgress propagates a rejection that arrives during the poll interval", async () => {
let rejectDrain;
const drainGate = new Promise((_, reject) => {
rejectDrain = reject;
});
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
let parked = false;
const done = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 2048,
},
() => {
parked = true;
},
);
const waitStarted = Date.now();
while (!parked) {
if (Date.now() - waitStarted > 1000) {
throw new Error("upload drain wait never parked");
}
await new Promise((resolve) => setImmediate(resolve));
}
await new Promise((resolve) => setImmediate(resolve));
const releasedAt = Date.now();
rejectDrain(err);
await assert.rejects(done, (caught) => caught === err);
const elapsed = Date.now() - releasedAt;
assert.ok(
elapsed < 50,
`drain rejection during a poll interval resumed in ${elapsed}ms`,
);
});
test("awaitUploadDrainWithProgress emits progress while parked until drain settles", async () => {
let releaseDrain;
const drainGate = new Promise((resolve) => {
releaseDrain = resolve;
});
let displayed = 0;
const emitted = [];
const deliverTimer = setInterval(() => {
displayed += 512;
}, 40);
try {
const parked = awaitUploadDrainWithProgress(
{
waitForDrain: () => drainGate,
getTransportPendingBytes: () => 1024,
},
() => emitted.push(displayed),
);
await new Promise((resolve) => setTimeout(resolve, 280));
assert.ok(emitted.length >= 2, `expected poll emissions, got ${emitted.length}`);
assert.ok(emitted.every((v) => v === 0 || v % 512 === 0));
releaseDrain();
await parked;
} finally {
clearInterval(deliverTimer);
}
});
test("awaitUploadDrainWithProgress propagates drain-wait rejections", async () => {
const err = new Error("Transport drain timeout");
err.code = "NETCATTY_ZMODEM_TIMEOUT";
await assert.rejects(
awaitUploadDrainWithProgress(
{
waitForDrain: () => Promise.reject(err),
getTransportPendingBytes: () => 0,
},
() => {},
),
(caught) => caught === err,
);
});
test("handleUpload throttles progress IPC by default for every transport", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-progress-"));
const filePath = path.join(tempDir, "large-upload.bin");
@@ -1766,132 +1489,3 @@ test("createZmodemUploadDrainWaiter falls back to a single yield without transpo
await waitForDrain();
assert.equal(needsDrain, false);
});
// ---------------------------------------------------------------------------
// Split hex-header preamble (#3651): the ZRQINIT/ZRINIT hex header that opens
// every sz/rz session can be fragmented across transport chunks; it must
// never reach the terminal as garble, and withheld bytes must never be lost.
// ---------------------------------------------------------------------------
const ZmodemLib = require("zmodem.js");
const DOWNLOAD_PREAMBLE_TEXT = "sz: Sending test.bin\r\nrz\r";
function buildHexHeaderBytes(name, args) {
return Buffer.from(ZmodemLib.Header.build(name, ...(args || [])).to_hex());
}
function createProbeSentry(extra) {
const terminal = [];
const sentry = createZmodemSentry({
sessionId: "probe",
onData: (buf) => terminal.push(Buffer.from(buf)),
writeToRemote: () => true,
getWebContents: () => null,
selectDownloadDirectory: async () => ({ canceled: true }),
selectUploadFiles: async () => null,
hexHeaderFragmentTtlMs: 30,
...extra,
});
return { sentry, terminal };
}
function joinedText(terminal) {
return Buffer.concat(terminal).toString("latin1");
}
function assertNoZmodemHeaderBytes(terminal, context) {
const joined = Buffer.concat(terminal);
assert.equal(
joined.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42])),
-1,
`${context}: ZRQINIT/ZRINIT preamble leaked to the terminal`,
);
assert.equal(
joined.includes(Buffer.from([0x18])),
false,
`${context}: ZDLE/CAN byte leaked to the terminal`,
);
}
test("fragmented sz ZRQINIT preamble never reaches the terminal", () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.concat([
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
buildHexHeaderBytes("ZRQINIT"),
Buffer.from("$ "),
]);
for (const byte of stream) sentry.consume(Buffer.from([byte]));
assert.equal(
joinedText(terminal),
`${DOWNLOAD_PREAMBLE_TEXT}$ `,
"only the human-readable sz preamble text should be shown",
);
assertNoZmodemHeaderBytes(terminal, "byte-at-a-time");
});
test("split sz preamble leaks nothing at any chunk boundary", () => {
const header = buildHexHeaderBytes("ZRQINIT");
// Header.build().to_hex() already includes the trailing XON byte.
const stream = Buffer.concat([
Buffer.from(DOWNLOAD_PREAMBLE_TEXT),
header,
Buffer.from("$ "),
]);
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(joinedText(terminal), `${DOWNLOAD_PREAMBLE_TEXT}$ `);
assertNoZmodemHeaderBytes(terminal, `chunkSize=${chunkSize}`);
}
});
test("fragmented rz ZRINIT preamble never reaches the terminal", () => {
const header = buildHexHeaderBytes("ZRINIT", [["CANFDX", "CANOVIO", "CANFC32"]]);
const stream = Buffer.concat([Buffer.from("root@host:~$ "), header]);
for (let chunkSize = 1; chunkSize <= 24; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(
joinedText(terminal),
"root@host:~$ ",
`split ZRINIT preamble leaked for chunkSize=${chunkSize}`,
);
assertNoZmodemHeaderBytes(terminal, `rz chunkSize=${chunkSize}`);
}
});
test("bytes withheld as a header fragment are flushed after the TTL", async () => {
const { sentry, terminal } = createProbeSentry();
const stream = Buffer.from("hello *");
for (const byte of stream) sentry.consume(Buffer.from([byte]));
// The trailing "*" may be the start of a hex header, so it is withheld.
assert.equal(joinedText(terminal), "hello ");
await new Promise((resolve) => setTimeout(resolve, 80));
assert.equal(joinedText(terminal), "hello *", "TTL flush must not lose terminal output");
});
test("output that disproves a header fragment is emitted without delay", () => {
const stream = Buffer.from("text **\x18Bxy more");
for (let chunkSize = 1; chunkSize <= 6; chunkSize += 1) {
const { sentry, terminal } = createProbeSentry();
for (let i = 0; i < stream.length; i += chunkSize) {
sentry.consume(stream.subarray(i, i + chunkSize));
}
assert.equal(
joinedText(terminal),
"text **\x18Bxy more",
"a non-hex byte after the pattern stops the deferral",
);
}
});
+1 -1
View File
@@ -48,7 +48,7 @@ const VAULT_CAPABILITIES = [
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description:
"Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
"Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
// Sidebar Catty is scoped to already-open terminals/workspaces and must not
// expand that scope mid-turn. Keep host_open for MCP / CLI / global agent.
agentKinds: [AGENT_KINDS.GLOBAL],
+1 -2
View File
@@ -83,7 +83,6 @@ const TOOL_INPUT_FIELDS = Object.freeze({
"vault.host.list": {},
"vault.host.open": {
hostId: { type: "string", description: "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname." },
newTab: { type: "string", optional: true, description: "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session." },
},
"vault.hosts.create": {
hosts: {
@@ -421,7 +420,7 @@ const MODEL_DESCRIPTION_HINTS = Object.freeze({
"vault.host.notes.set":
"Host metadata notes on a saved host — not Vault → Notes sidebar entries. Prefer vault_notes_create/update when the user wants vault notes they can open in the Notes sidebar.",
"vault.host.open":
"Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"vault.host.list":
"Use when the user names a saved Netcatty host but get_environment has no matching live session. Resolve the hostId here, call host_open, then call get_environment to obtain the new sessionId.",
"vault.host.import":
@@ -47,7 +47,6 @@ function createVaultService(ctx = {}) {
return invokeVaultAgent("host.open", {
hostId: params.hostId,
chatSessionId: params.chatSessionId,
newTab: params.newTab,
});
},
createHosts: async (params = {}) => {
@@ -36,11 +36,10 @@ test("vault service delegates host open to vault agent bridge", async () => {
return { ok: true, sessionId: "sess-1", hostId: params.hostId, status: "connecting" };
},
});
const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1", newTab: "true" });
const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1" });
assert.equal(invokedOp, "host.open");
assert.equal(invokedParams.hostId, "host-1");
assert.equal(invokedParams.chatSessionId, "chat-1");
assert.equal(invokedParams.newTab, "true");
assert.equal(result.ok, true);
assert.equal(result.sessionId, "sess-1");
});
@@ -585,17 +585,12 @@
"toolName": "host_open",
"rpcMethod": "vault/hosts/open",
"localExecution": false,
"description": "Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname. Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"description": "Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname. Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"inputShape": {
"hostId": {
"type": "string",
"optional": false,
"description": "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname."
},
"newTab": {
"type": "string",
"optional": true,
"description": "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session."
}
},
"policy": {
@@ -1,120 +0,0 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { test } from 'node:test';
import type { Host, TerminalSession } from '../../domain/models';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { updateRestoredSessionStatusState } from '../../application/state/sessionRestoreState';
import { createHostTerminalSession } from '../../application/state/sessionFactories';
import { useHostOpenSessionReuse } from '../../application/state/useHostOpenSessionReuse';
import { handleVaultAgentOp, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
const require = createRequire(import.meta.url);
const { isolateCliDiscoveryFile } = require('../../electron/bridges/cliDiscoveryTestIsolation.cjs');
isolateCliDiscoveryFile();
const bridge = require('../../electron/bridges/mcpServerBridge.cjs');
const { registerMcpTools } = require('../../electron/capabilities/codegen/mcpToolRegistry.cjs');
test('MCP host_open forwards the schema input through RPC and vault service to the real renderer handler', async (t) => {
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode('auto');
t.after(() => bridge.cleanup());
const host: Host = { id: 'host-1', label: 'Host', hostname: 'example.invalid', username: 'user', protocol: 'ssh', os: 'linux', tags: [] };
const tabs: TerminalSession[] = [{ id: 'existing', hostId: host.id, hostLabel: host.label, hostname: host.hostname, username: host.username, status: 'connecting' }];
let finder: ReturnType<typeof useHostOpenSessionReuse>;
let renderer: ReactTestRenderer;
function Harness() {
finder = useHostOpenSessionReuse({ sessions: [...tabs], hosts: [{ ...host }], keys: [], identities: [], resolveEffectiveHost: (value) => value });
return null;
}
const render = async () => act(async () => {
if (renderer) renderer.update(React.createElement(Harness));
else renderer = create(React.createElement(Harness));
});
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
t.after(async () => act(async () => renderer.unmount()));
await render();
const deps = {
getHosts: () => [host],
resolveEffectiveHost: (value: Host) => value,
findExistingSessionForHost: (hostId: string) => finder(hostId),
openHost: (openedHost: Host) => {
const sessionId = `new-${tabs.length}`;
tabs.push(createHostTerminalSession(sessionId, openedHost));
return { ok: true as const, sessionId, host };
},
} as unknown as VaultAgentApiDeps;
bridge.setVaultAgentInvoker((op: string, params: Record<string, unknown>) => handleVaultAgentOp(op, params, deps));
let openTool: ((args: Record<string, unknown>) => Promise<unknown>) | undefined;
let schema: { parse: (args: Record<string, unknown>) => Record<string, unknown> };
registerMcpTools({
tool(name: string, _description: string, shape: Record<string, unknown>, handler: typeof openTool) {
if (name !== 'host_open') return;
schema = require('zod').z.object(shape);
openTool = handler;
},
}, {
rpcCall: (method: string, params: Record<string, unknown>) => bridge.dispatchBuiltinRpc(method, params),
scopeParams: { chatSessionId: 'integration-chat' },
guardWriteOperation: () => null,
catalogDescription: (_name: string, fallback: string) => fallback,
});
assert.ok(openTool);
const run = async (args: Record<string, unknown>) => {
await render();
const response = await openTool!(schema.parse(args)) as { content: { text: string }[] };
await render();
return JSON.parse(response.content[0].text);
};
for (let index = 0; index < 3; index++) {
const reused = await run({ hostId: host.id });
assert.equal(reused.sessionId, 'existing');
assert.equal(reused.reused, true);
}
assert.equal(tabs.length, 1);
const fresh = await run({ hostId: host.id, newTab: 'true' });
assert.equal(fresh.sessionId, 'new-1');
assert.equal(tabs.length, 2);
// An inactive tab requires a real open instead of an endless wait.
for (const tab of tabs) tab.status = 'disconnected';
const recovered = await run({ hostId: host.id, newTab: 'false' });
assert.equal(recovered.sessionId, 'new-2');
assert.equal(tabs.length, 3);
tabs[2].status = 'connected';
host.label = 'Renamed only';
assert.equal((await run({ hostId: host.id })).sessionId, recovered.sessionId);
host.hostname = 'replacement.invalid';
const editedTarget = await run({ hostId: host.id });
assert.notEqual(editedTarget.sessionId, recovered.sessionId, 'a saved endpoint edit must not reuse the old transport');
assert.equal(editedTarget.reused, undefined);
// Saved serial hosts use the same first-party host_open path and factory.
tabs.length = 0;
host.protocol = 'serial';
host.hostname = '/dev/tty.test';
host.username = '';
host.serialConfig = { path: host.hostname, baudRate: 9600, dataBits: 8, parity: 'none', flowControl: 'none' };
const serial = await run({ hostId: host.id });
assert.equal(serial.protocol, 'serial');
assert.equal(tabs[0].serialConfig?.baudRate, 9600);
tabs[0].status = 'connected';
assert.equal((await run({ hostId: host.id })).sessionId, serial.sessionId);
host.serialConfig = { ...host.serialConfig, baudRate: 19200 };
const changedSerial = await run({ hostId: host.id });
assert.notEqual(changedSerial.sessionId, serial.sessionId, 'an edited serial configuration must not reuse the old parameters');
assert.equal(changedSerial.reused, undefined);
assert.equal(tabs[1].serialConfig?.baudRate, 19200);
assert.equal(tabs[0].serialConfig?.baudRate, 9600);
tabs.length = 0;
host.protocol = 'plugin:com.example.transport.connection';
host.hostname = 'plugin-host';
host.pluginConnection = { providerId: 'com.example.transport.connection', configuration: { endpoint: 'original.invalid' } };
tabs.push({ ...createHostTerminalSession('restored-plugin', host), status: 'disconnected', restoreState: 'restored-disconnected' });
await render();
host.pluginConnection = { ...host.pluginConnection, configuration: { endpoint: 'replacement.invalid' } };
tabs.splice(0, tabs.length, ...updateRestoredSessionStatusState(tabs, 'restored-plugin', 'connecting'));
const changedPlugin = await run({ hostId: host.id });
assert.notEqual(changedPlugin.sessionId, 'restored-plugin', 'restored plugin transport must not mask the edited provider configuration');
assert.equal(changedPlugin.reused, undefined);
assert.deepEqual(tabs[0].pluginConnection?.configuration, { endpoint: 'original.invalid' });
assert.deepEqual(tabs[1].pluginConnection?.configuration, { endpoint: 'replacement.invalid' });
});
-2
View File
@@ -162,8 +162,6 @@ export interface ChatMessage {
providerContinuation?: ProviderContinuation;
toolCalls?: ToolCall[];
toolResults?: ToolResult[];
/** User-skill pills selected for this turn (user messages); restored on undo. */
selectedUserSkillSlugs?: string[];
agentActivities?: AgentActivity[];
usage?: AgentUsage;
timestamp: number;
@@ -496,110 +496,6 @@ describe('handleVaultAgentOp vault hosts', () => {
]);
});
it('host.open reuses an existing session for the same host instead of opening a new tab', async (t) => {
const { netcattyBridge } = await import('../services/netcattyBridge');
const merged: Array<{ sessions: Array<{ sessionId: string; connected?: boolean }>; scope: string }> = [];
t.mock.method(netcattyBridge, 'get', () => ({
aiMcpMergeSessions: async (
sessions: Array<{ sessionId: string; connected?: boolean }>,
scope: string,
) => {
merged.push({ sessions, scope });
},
} as unknown as NetcattyBridge));
const host: Host = {
id: 'host-reuse-1',
label: 'edge',
hostname: 'edge.example.com',
username: 'ops',
port: 22,
};
let openHostCalls = 0;
const deps = createDeps({
hosts: [host],
openHost: (hostToOpen: Host) => {
openHostCalls += 1;
return { ok: true, sessionId: `session-${hostToOpen.id}`, host: hostToOpen };
},
findExistingSessionForHost: (hostId: string) => hostId === host.id
? { sessionId: 'session-existing-1', connected: false }
: undefined,
});
const result = await handleVaultAgentOp(
'host.open',
{ hostId: host.id, chatSessionId: '__external_mcp__' },
deps,
);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-existing-1');
assert.equal((result as { reused?: boolean }).reused, true);
assert.equal((result as { status?: string }).status, 'reconnecting');
assert.equal(openHostCalls, 0, 'must not open a duplicate tab');
// The reused session is re-registered in the MCP scope with its live
// reconnecting state so terminal_execute can target it immediately.
assert.deepEqual(merged.map(({ sessions, scope }) => ({ scope, connected: sessions[0]?.connected })), [
{ scope: '__external_mcp__', connected: false },
]);
});
it('host.open reuse reports a connected session as connected', async () => {
const host: Host = { id: 'host-reuse-2', label: 'edge', hostname: 'edge.example.com' };
let openHostCalls = 0;
const deps = createDeps({
hosts: [host],
openHost: (hostToOpen: Host) => {
openHostCalls += 1;
return { ok: true, sessionId: `session-${hostToOpen.id}`, host: hostToOpen };
},
findExistingSessionForHost: (hostId: string) => hostId === host.id
? { sessionId: 'session-existing-2', connected: true }
: undefined,
});
const result = await handleVaultAgentOp('host.open', { hostId: host.id }, deps);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-existing-2');
assert.equal((result as { status?: string }).status, 'connected');
assert.equal((result as { connected?: boolean }).connected, true);
assert.equal(openHostCalls, 0);
});
it('host.open newTab forces a fresh session even when one exists', async () => {
const host: Host = { id: 'host-reuse-3', label: 'edge', hostname: 'edge.example.com' };
const deps = createDeps({
hosts: [host],
findExistingSessionForHost: () => ({ sessionId: 'session-existing-3', connected: true }),
});
const result = await handleVaultAgentOp(
'host.open',
{ hostId: host.id, newTab: 'true' },
deps,
);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-host-reuse-3');
assert.equal((result as { status?: string }).status, 'connecting');
});
it('host.open still opens a new tab when no session exists for the host', async () => {
const host: Host = { id: 'host-reuse-4', label: 'edge', hostname: 'edge.example.com' };
const deps = createDeps({
hosts: [host],
findExistingSessionForHost: () => undefined,
});
const result = await handleVaultAgentOp('host.open', { hostId: host.id }, deps);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-host-reuse-4');
assert.equal((result as { reused?: boolean }).reused, undefined);
assert.equal((result as { status?: string }).status, 'connecting');
});
it('host.open does not treat a missing chatSessionId as an external MCP call', async () => {
const host: Host = {
id: 'host-open-4',
+10 -54
View File
@@ -556,15 +556,6 @@ export interface VaultAgentApiDeps {
ok: false;
error: string;
};
/**
* Find an existing terminal session for a vault host so `host.open` can
* reuse it instead of always opening a new tab. Reconnecting SSH tabs keep
* the same sessionId. Check the opening connection identity before reuse.
*/
findExistingSessionForHost?: (hostId: string) => {
sessionId: string;
connected: boolean;
} | undefined | Promise<{ sessionId: string; connected: boolean } | undefined>;
closeSession?: (sessionId: string) => {
ok: true;
} | {
@@ -593,7 +584,6 @@ async function registerOpenedSessionInMcpScope(
sessionId: string,
host: Host,
chatSessionId?: string,
connected = false,
): Promise<void> {
let bridge: ReturnType<typeof netcattyBridge.get> | undefined;
try {
@@ -622,7 +612,7 @@ async function registerOpenedSessionInMcpScope(
username: host.username || '',
protocol,
deviceType: host.deviceType || '',
connected,
connected: false,
hostChain: [],
activePortForwards: [],
};
@@ -681,6 +671,9 @@ export async function handleVaultAgentOp(
if (!hostId) return { ok: false, error: 'hostId is required.' };
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
if (typeof deps.openHost !== 'function') {
return { ok: false, error: 'Host open is not available in this window.' };
}
const chatSessionId = typeof params.chatSessionId === 'string'
? params.chatSessionId
@@ -690,49 +683,6 @@ export async function handleVaultAgentOp(
// not a reliable signal — compare against the constant instead.
const isExternalMcpCall = chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID;
const effectiveHost = deps.resolveEffectiveHost(host);
const protocol = effectiveHost.etEnabled
? 'et'
: effectiveHost.moshEnabled
? 'mosh'
: (effectiveHost.protocol || 'ssh');
// Reuse-first: a terminal tab keeps its sessionId across SSH
// auto-reconnect, so blindly opening a new tab stacks duplicate tabs
// (and steals focus) after every network blip. Reuse the live or
// reconnecting session for this host unless the caller forces a new tab.
const wantsNewTab = parseOptionalBoolean(params.newTab) ?? false;
const existing = !wantsNewTab
? await deps.findExistingSessionForHost?.(effectiveHost.id)
: undefined;
if (existing) {
await registerOpenedSessionInMcpScope(
existing.sessionId,
effectiveHost,
chatSessionId,
existing.connected,
);
return {
ok: true,
sessionId: existing.sessionId,
hostId: effectiveHost.id,
...(isSavedVaultHost(effectiveHost) && protocol !== 'serial' && protocol !== 'local'
? { savedHostId: effectiveHost.id }
: {}),
reused: true,
connected: existing.connected,
status: existing.connected ? 'connected' : 'reconnecting',
protocol,
host: summarizeHostForList(effectiveHost),
message:
'Reused the existing session for this host (same sessionId). If it is not connected yet, the tab is connecting or reconnecting — wait briefly and use get_environment or terminal_execute against this sessionId. Complete any authentication prompt in the Netcatty UI if needed. Do not call session_close on it unless the user asks; pass newTab true to force a separate tab instead.',
};
}
if (typeof deps.openHost !== 'function') {
return { ok: false, error: 'Host open is not available in this window.' };
}
const opened = deps.openHost(effectiveHost, isExternalMcpCall);
if (!opened.ok) {
return { ok: false, error: opened.error };
@@ -740,6 +690,12 @@ export async function handleVaultAgentOp(
await registerOpenedSessionInMcpScope(opened.sessionId, effectiveHost, chatSessionId);
const protocol = effectiveHost.etEnabled
? 'et'
: effectiveHost.moshEnabled
? 'mosh'
: (effectiveHost.protocol || 'ssh');
return {
ok: true,
sessionId: opened.sessionId,
-2
View File
@@ -24,8 +24,6 @@ export const STORAGE_KEY_TERM_THEME_LIGHT = 'netcatty_term_theme_light_v1';
export const STORAGE_KEY_TERM_FONT_FAMILY = 'netcatty_term_font_family_v1';
export const STORAGE_KEY_TERM_FONT_SIZE = 'netcatty_term_font_size_v1';
export const STORAGE_KEY_TERM_SETTINGS = 'netcatty_term_settings_v1';
/** One-time marker: pre-#3619 builds materialized autoCloseOnExit: true into saved settings. */
export const STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED = 'netcatty_term_auto_close_exit_migrated_v1';
export const STORAGE_KEY_HOTKEY_SCHEME = 'netcatty_hotkey_scheme_v1';
export const STORAGE_KEY_CUSTOM_KEY_BINDINGS = 'netcatty_custom_key_bindings_v1';
export const STORAGE_KEY_HOTKEY_RECORDING = 'netcatty_hotkey_recording_v1';
+12 -16
View File
@@ -2,7 +2,6 @@ import type { UploadBridge } from "./uploadService.types";
export class UploadController {
private cancelled = false;
private cancelledFileIds = new Set<string>();
private activeFileTransferIds = new Set<string>();
private activeCompressionIds = new Set<string>();
private currentTransferId = "";
@@ -48,7 +47,16 @@ export class UploadController {
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
await this.cancelFile(transferId);
try {
if (this.bridge?.cancelStagedUploadFile) {
await this.bridge.cancelStagedUploadFile(transferId);
}
if (this.bridge?.cancelTransfer) {
await this.bridge.cancelTransfer(transferId);
}
} catch {
// Ignore cancel errors
}
}
// Also cancel current one if not in the set
@@ -63,22 +71,11 @@ export class UploadController {
}
}
/** Cancel one standalone file without stopping the shared upload queue. */
async cancelFile(transferId: string): Promise<void> {
this.cancelledFileIds.add(transferId);
try {
await this.bridge?.cancelStagedUploadFile?.(transferId);
await this.bridge?.cancelTransfer?.(transferId);
} catch {
// The transfer runtime reconciles backend cancellation failures.
}
}
/**
* Check if upload was cancelled
*/
isCancelled(fileId?: string): boolean {
return this.cancelled || (!!fileId && this.cancelledFileIds.has(fileId));
isCancelled(): boolean {
return this.cancelled;
}
/**
@@ -101,7 +98,6 @@ export class UploadController {
*/
reset(): void {
this.cancelled = false;
this.cancelledFileIds.clear();
this.cancelListeners.clear();
this.activeFileTransferIds.clear();
this.activeCompressionIds.clear();
+2 -8
View File
@@ -769,7 +769,6 @@ async function uploadEntries(
standaloneTransferId: string,
fileTotalBytes: number,
): Promise<{ cancelled?: boolean; error?: string }> => {
if (controller?.isCancelled(standaloneTransferId)) return { cancelled: true };
let localFilePath = getDropEntryLocalPath(entry);
let ownedTempPath: string | undefined;
@@ -785,13 +784,13 @@ async function uploadEntries(
ownedTempPath = await bridge.stageUploadFile(entry.file, standaloneTransferId);
} catch (error) {
controller?.removeActiveTransfer(standaloneTransferId);
if (controller?.isCancelled(standaloneTransferId) || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
if (controller?.isCancelled() || /cancel/i.test(error instanceof Error ? error.message : String(error))) {
return { cancelled: true };
}
throw error;
}
localFilePath = ownedTempPath;
if (controller?.isCancelled(standaloneTransferId)) {
if (controller?.isCancelled()) {
await bridge.deleteTempFile(ownedTempPath).catch(() => {});
return { cancelled: true };
}
@@ -957,11 +956,6 @@ async function uploadEntries(
);
if (uploadResult.cancelled) {
if (!bundleTaskId && !controller?.isCancelled()) {
settleFileTask(standaloneTransferId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
results.push({ fileName: entry.relativePath, success: false, cancelled: true });
continue;
}
wasCancelled = true;
settleFileTask(bundledChildTaskId, lifecycleManaged, (taskId) => callbacks?.onTaskCancelled?.(taskId));
settleTask(bundleTaskId ?? "", (taskId) => callbacks?.onTaskCancelled?.(taskId));
+2 -2
View File
@@ -318,7 +318,7 @@ declare global {
}>;
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
latencyMs: number | null; // SSH transport ping on the existing session, not a second TCP dial
latencyMs: number | null; // TCP connection establishment latency to the SSH endpoint
netInterfaces: Array<{ // Per-interface network stats
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
@@ -531,7 +531,7 @@ declare global {
): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number; exitSignal?: string; error?: string; reason?: "exited" | "error" | "timeout" | "closed" | "signaled"; remoteClosedWithoutExitStatus?: boolean }) => void
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
): () => void;
onTelnetAutoLoginComplete?(
sessionId: string,