Compare commits

..
36 changed files with 75 additions and 1881 deletions

No files matched your search

-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': '跳轉到訊息',
-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,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]);
}
-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,
+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;
@@ -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}
-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;
}
+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);
});
@@ -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; });
}
+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"]);
});
}
+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,