Compare commits

...
46 changed files with 906 additions and 83 deletions

No files matched your search

+7
View File
@@ -336,6 +336,13 @@ export const enAiMessages: Messages = {
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'External MCP',
'ai.externalMcp.allowCommandOnHost': 'Always',
'ai.externalMcp.allowCommandOnHostHint': 'Always allow this action on this server.',
'ai.externalMcp.allowAll': 'Allow all actions',
'ai.externalMcp.allowSession': 'This connection',
'ai.externalMcp.allowSessionHint': 'Allow all actions from this client until it disconnects.',
'ai.externalMcp.allowHost': 'This server',
'ai.externalMcp.allowHostHint': 'Always allow actions on this server.',
'ai.externalMcp.description': 'Expose Netcatty as an MCP server for external clients such as Codex, Claude Code, Cursor, and Grok. Uses the same catalog tools as in-app agents (terminal, SFTP, Vault, port forwarding). Keep Netcatty running while clients are connected.',
'ai.externalMcp.sessionsExposed': 'Sessions in scope: {count}',
'ai.externalMcp.mode': 'Availability mode',
+7
View File
@@ -327,6 +327,13 @@ export const esAiMessages: Messages = {
// External MCP (productized catalog MCP for Codex / Claude Code / Cursor)
'ai.externalMcp.title': 'MCP externo',
'ai.externalMcp.allowCommandOnHost': 'Siempre',
'ai.externalMcp.allowCommandOnHostHint': 'Permitir siempre esta acción en este servidor.',
'ai.externalMcp.allowAll': 'Permitir todas las acciones',
'ai.externalMcp.allowSession': 'Esta conexión',
'ai.externalMcp.allowSessionHint': 'Permitir todas las acciones de este cliente hasta que se desconecte.',
'ai.externalMcp.allowHost': 'Este servidor',
'ai.externalMcp.allowHostHint': 'Permitir siempre acciones en este servidor.',
'ai.externalMcp.description': 'Expón Netcatty como un servidor MCP para clientes externos como Codex, Claude Code, Cursor y Grok. Usa las mismas herramientas del catálogo que los agentes de la aplicación (terminal, SFTP, Vault, reenvío de puertos). Mantén Netcatty en ejecución mientras los clientes estén conectados.',
'ai.externalMcp.sessionsExposed': 'Sesiones en alcance: {count}',
'ai.externalMcp.mode': 'Modo de disponibilidad',
+7
View File
@@ -283,6 +283,13 @@ export const ruAiMessages: Messages = {
'ai.toolAccess.skills.unavailable': 'Путь к skill-файлу недоступен',
'ai.externalMcp.title': 'Внешний MCP',
'ai.externalMcp.allowCommandOnHost': 'Всегда',
'ai.externalMcp.allowCommandOnHostHint': 'Всегда разрешать это действие на этом сервере.',
'ai.externalMcp.allowAll': 'Разрешить все действия',
'ai.externalMcp.allowSession': 'Это подключение',
'ai.externalMcp.allowSessionHint': 'Разрешать все действия клиента до отключения.',
'ai.externalMcp.allowHost': 'Этот сервер',
'ai.externalMcp.allowHostHint': 'Всегда разрешать действия на этом сервере.',
'ai.externalMcp.description': 'Откройте Netcatty как MCP-сервер для Codex, Claude Code, Cursor и Grok. Набор инструментов тот же, что у встроенных агентов. Держите Netcatty запущенным, пока клиенты подключены.',
'ai.externalMcp.sessionsExposed': 'Сессий в области: {count}',
'ai.externalMcp.mode': 'Режим доступности',
+7
View File
@@ -336,6 +336,13 @@ export const zhCNAiMessages: Messages = {
// External MCP
'ai.externalMcp.title': '对外 MCP',
'ai.externalMcp.allowCommandOnHost': '始终',
'ai.externalMcp.allowCommandOnHostHint': '始终允许在此服务器执行同类操作。',
'ai.externalMcp.allowAll': '允许所有操作',
'ai.externalMcp.allowSession': '此次连接',
'ai.externalMcp.allowSessionHint': '在客户端断开前,允许此次连接的所有操作。',
'ai.externalMcp.allowHost': '此服务器',
'ai.externalMcp.allowHostHint': '始终允许操作此服务器。',
'ai.externalMcp.description': '把 Netcatty 作为 MCP 服务器暴露给 Codex、Claude Code、Cursor、Grok 等外部客户端。工具面与应用内 Agent 相同(终端、SFTP、Vault、端口转发)。客户端连接期间请保持 Netcatty 运行。',
'ai.externalMcp.sessionsExposed': '作用域内会话:{count}',
'ai.externalMcp.mode': '可用模式',
+7
View File
@@ -326,6 +326,13 @@ export const zhTWAiMessages: Messages = {
'ai.toolAccess.skills.unavailable': '暫時無法取得 Skill 檔案路徑',
'ai.externalMcp.title': '對外 MCP',
'ai.externalMcp.allowCommandOnHost': '始終',
'ai.externalMcp.allowCommandOnHostHint': '始終允許在此伺服器執行同類操作。',
'ai.externalMcp.allowAll': '允許所有操作',
'ai.externalMcp.allowSession': '此次連線',
'ai.externalMcp.allowSessionHint': '在用戶端中斷前,允許此次連線的所有操作。',
'ai.externalMcp.allowHost': '此伺服器',
'ai.externalMcp.allowHostHint': '始終允許操作此伺服器。',
'ai.externalMcp.description': '將 Netcatty 作為 MCP 伺服器暴露給 Codex、Claude Code、Cursor、Grok 等外部用戶端。工具面與應用內 Agent 相同。用戶端連線期間請保持 Netcatty 執行。',
'ai.externalMcp.sessionsExposed': '作用域內工作階段:{count}',
'ai.externalMcp.mode': '可用模式',
@@ -284,6 +284,7 @@ test('runAutomationScript executes a user write script while AI permission mode
const result = await runAutomationScript({
snippet: writeScript,
sessionId: 'sess-user',
initiatedBy: 'user',
});
assert.equal(result.runId, 'user-run');
assert.equal(seenMode, 'auto');
@@ -293,6 +294,29 @@ test('runAutomationScript executes a user write script while AI permission mode
}
});
test('runAutomationScript blocks automatic write scripts in observer mode', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
let called = false;
netcattyBridge.get = () => ({
scriptRun: async () => {
called = true;
return { runId: 'automatic-run', runIds: ['automatic-run'] };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runAutomationScript({ snippet: writeScript, sessionId: 'sess-automatic' }),
/Observer mode blocks scripts that write to the terminal/,
);
assert.equal(called, false);
} finally {
netcattyBridge.get = originalGet;
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runAutomationScript blocks an AI write script in observer mode', async () => {
const originalGet = netcattyBridge.get;
installPermissionMode('observer');
@@ -95,10 +95,10 @@ export async function runAutomationScript(params: {
hostname?: string;
username?: string;
};
/** AI-initiated runs honor Observer. User runs are an explicit action and always execute. */
/** Only a direct user action bypasses Observer; AI and automatic runs honor it. */
initiatedBy?: 'user' | 'ai';
}): Promise<{ runId: string; runIds: string[] }> {
const permissionMode = params.initiatedBy === 'ai' ? readPermissionMode() : 'auto';
const permissionMode = params.initiatedBy === 'user' ? 'auto' : readPermissionMode();
if (permissionMode === 'observer' && scriptContainsWriteOperations(params.snippet.command)) {
throw new Error('Observer mode blocks scripts that write to the terminal.');
}
@@ -479,7 +479,7 @@ test("stat-less preflight results are not statted again before transfer", async
}
});
test("files past the preflight bound are not statted again and keep an unknown plan", async () => {
test("files past the preflight bound stay un-statted after conflict resolution", async () => {
const extra = 2;
const names = Array.from({ length: PREFLIGHT_STAT_MAX_FILES + extra }, (_, index) => `bulk-${index}.bin`);
let statCalls = 0;
@@ -495,7 +495,9 @@ test("files past the preflight bound are not statted again and keep an unknown p
lastModified: 5,
};
},
statLocal: async () => null,
statLocal: async (target: string) => names.slice(-extra).some((name) => target.endsWith(`/${name}`))
? { name: target.split("/").pop() || "", type: "file" as const, size: 1, lastModified: 1 }
: null,
startStreamTransfer: async (options: StartOptions) => {
started.push(options);
sftpTransferCenterStore.ingestBackgroundEvent({
@@ -541,7 +543,17 @@ test("files past the preflight bound are not statted again and keep an unknown p
);
});
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES);
assert.equal(started.length, names.length - extra);
assert.equal(ops!.conflicts.length, extra);
await act(async () => { renderer?.unmount(); });
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(ops!.conflicts.length, extra, "conflicts must survive panel remount");
await act(async () => {
await ops!.resolveConflict(ops!.conflicts[0].transferId, "replace", true);
await new Promise((resolve) => setTimeout(resolve, 300));
});
assert.equal(started.length, names.length);
assert.equal(statCalls, PREFLIGHT_STAT_MAX_FILES, "conflict resolution must keep the preflight cap");
const omitted = started.filter((options) => options.totalBytes === undefined);
assert.equal(omitted.length, extra);
} finally {
+2 -8
View File
@@ -222,10 +222,6 @@ export const useSftpTransfers = ({
const transfersRef = useRef(transfers);
const conflictsRef = useRef(conflicts);
conflictsRef.current = conflicts;
// Remote files past the preflight cap keep an unknown plan. Mark them so
// deferred discovery does not stat the whole tail at once.
const preflightSkippedTaskIdsRef = useRef(new Set<string>());
// When the retained panel is re-opened, catch up React state from the ref that
// kept receiving progress while the surface was hidden.
useEffect(() => {
@@ -673,7 +669,7 @@ export const useSftpTransfers = ({
}
if (sourceSftpId) {
if (preflightSkippedTaskIdsRef.current.has(task.id)) {
if (task.preflightStatSkipped) {
return cachedListingStatForDialog();
}
// This stat runs deferred (started before the conflict check) and
@@ -1354,9 +1350,6 @@ export const useSftpTransfers = ({
continue;
}
const taskId = crypto.randomUUID();
if (preflightSkippedNames.has(file.name)) {
preflightSkippedTaskIdsRef.current.add(taskId);
}
newTasks.push({
id: taskId,
batchId,
@@ -1379,6 +1372,7 @@ export const useSftpTransfers = ({
isDirectory: file.isDirectory,
progressMode: file.isDirectory ? "files" : "bytes",
sourceLastModified,
preflightStatSkipped: preflightSkippedNames.has(file.name),
origin: "manual",
resumable: !usesLegacyScp,
pauseUnavailableReason: usesLegacyScp ? "This server uses legacy SCP; cancel and retry from the beginning instead" : undefined,
+1 -1
View File
@@ -76,7 +76,7 @@ import {
resolveLocateSftpPathSessionId,
} from "../domain/sftpLocatePathInTerminal";
import { classifyDistroId } from "../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.cjs";
import { remoteSoftwareRequiresSingleChannel } from "../domain/singleChannelSshBanner.shared.mjs";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { isTerminalSensitiveInputActive } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { isTerminalReadyForCommandInjection } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
+1
View File
@@ -3389,6 +3389,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: {
connected: true,
name: scriptSessionName,
+4
View File
@@ -2135,6 +2135,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
});
} catch (err) {
@@ -2160,6 +2161,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
});
} catch (err) {
@@ -2245,6 +2247,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
await runAutomationScript({
snippet,
sessionId,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sessionId, sessionsRef.current, hosts),
});
} catch (err) {
@@ -2314,6 +2317,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const runOnSession = (sid: string) => runAutomationScript({
snippet,
sessionId: sid,
initiatedBy: 'user',
sessionMeta: buildScriptSessionMeta(sid, sessionsRef.current, hosts),
});
if (mode === 'sequential') {
+29 -10
View File
@@ -249,7 +249,7 @@ function formatToolResult(result: unknown): string {
const obj = JSON.parse(parsed);
if (obj && typeof obj === 'object') parsed = obj;
} catch {
return parsed;
return result as string;
}
}
@@ -272,6 +272,7 @@ function formatToolResult(result: unknown): string {
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
approvalTarget?: { label: string; hostname: string };
className?: string;
args?: Record<string, unknown>;
result?: unknown;
@@ -292,6 +293,7 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
onAlwaysAllow?: () => void;
/** Optional source-specific label for the persistent/session approval action. */
alwaysAllowLabel?: string;
alwaysAllowTitle?: string;
}
async function copyTextToClipboard(text: string): Promise<boolean> {
@@ -321,8 +323,8 @@ async function copyTextToClipboard(text: string): Promise<boolean> {
}
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
name, args, result, isError, isLoading, isInterrupted, approvalTarget,
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel, alwaysAllowTitle,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
@@ -520,31 +522,47 @@ export const ToolCall = ({
{statusIcon}
</button>
{isPendingApproval && approvalTarget && !showApprovalCommand && (
<div className="min-w-0 break-words border-t border-border/20 px-3 py-1.5 text-xs font-medium">
{t('ai.chat.targetLabel')}: <span className="break-all">{approvalTarget.label}</span>
{approvalTarget.hostname && approvalTarget.hostname !== approvalTarget.label && (
<span className="ml-1 break-all text-muted-foreground">({approvalTarget.hostname})</span>
)}
</div>
)}
{expanded && (
<div className="border-t border-border/20">
{showApprovalCommand && reviewCommand && (
<div className="px-3 py-2 space-y-1.5">
{executionContext && (
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
{(approvalTarget || executionContext) && (
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
<span className="font-medium uppercase tracking-wider text-muted-foreground/60">
{t('ai.chat.targetLabel')}
</span>
{executionContext.sessionId && (
{approvalTarget ? (
<span className="min-w-0 break-all font-medium text-foreground/90">
{approvalTarget.label}
{approvalTarget.hostname && approvalTarget.hostname !== approvalTarget.label && (
<span className="ml-1 font-normal text-muted-foreground">({approvalTarget.hostname})</span>
)}
</span>
) : executionContext?.sessionId && (
<span className="font-mono truncate" title={executionContext.sessionId}>
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
</span>
)}
{executionContext.shell && (
{executionContext?.shell && (
<span className="font-mono">
{t('ai.chat.approvalShell')}: {executionContext.shell}
</span>
)}
{executionContext.cwd && (
{executionContext?.cwd && (
<span className="font-mono truncate" title={executionContext.cwd}>
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
</span>
)}
{executionContext.reason && (
{executionContext?.reason && (
<span className="truncate" title={executionContext.reason}>
{t('ai.chat.approvalReason')}: {executionContext.reason}
</span>
@@ -656,6 +674,7 @@ export const ToolCall = ({
size="sm"
className="h-7 min-w-0 flex-1 gap-1 px-1.5 text-[11px] font-normal border-green-500/35 text-green-300/95 hover:bg-green-500/10 hover:text-green-300 hover:border-green-500/50"
onClick={handleAlwaysAllow}
title={alwaysAllowTitle}
>
<Check size={12} className="shrink-0" />
<span className="truncate">{alwaysAllowLabel || t('ai.chat.alwaysAllow')}</span>
+1
View File
@@ -843,6 +843,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
<ToolCall
name={req.toolName}
args={req.args}
approvalTarget={req.target}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
+95 -14
View File
@@ -6,6 +6,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ToolCall } from '../ai-elements/tool-call';
import { Button } from '../ui/button';
import {
onApprovalCleared,
onApprovalRequest,
@@ -15,6 +16,7 @@ import {
} from '../../infrastructure/ai/shared/approvalGate';
import {
buildGrantsFromApproval,
createPermissionGrantId,
resolveCapabilityId,
} from '../../infrastructure/ai/harness/permissionGrants';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -61,7 +63,10 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
const handleAlwaysAllow = useCallback((toolCallId: string, request: ApprovalRequest) => {
const capabilityId = request.capabilityId ?? resolveCapabilityId(request.toolName);
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId);
const persistGrants = buildGrantsFromApproval(capabilityId, request.args, request.chatSessionId)
.map((grant) => request.target?.hostId
? { ...grant, sessionPattern: `host:${request.target.hostId}` }
: grant);
resolveApproval(toolCallId, { approved: true, persistGrants });
setPendingApprovals((prev) => {
const next = new Map(prev);
@@ -70,6 +75,35 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
});
}, []);
const handleAllowSession = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, { approved: true, scope: 'session' });
setPendingApprovals((prev) => {
const next = new Map(prev);
next.delete(toolCallId);
return next;
});
}, []);
const handleAllowHost = useCallback((toolCallId: string, request: ApprovalRequest) => {
const hostId = request.target?.hostId;
if (!hostId) return;
resolveApproval(toolCallId, {
approved: true,
persistGrant: {
id: createPermissionGrantId(),
capabilityId: '*',
sessionPattern: `host:${hostId}`,
createdAt: Date.now(),
note: request.target?.label,
},
});
setPendingApprovals((prev) => {
const next = new Map(prev);
next.delete(toolCallId);
return next;
});
}, []);
const handleReject = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, false);
setPendingApprovals((prev) => {
@@ -84,7 +118,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
return (
<div
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex w-[min(420px,calc(100vw-2rem))] flex-col gap-2"
className="pointer-events-auto fixed bottom-4 right-4 z-[80] flex max-h-[calc(100vh-2rem)] w-[min(420px,calc(100vw-2rem))] flex-col gap-2 overflow-y-auto overscroll-contain"
data-testid="external-mcp-approvals-host"
>
<div className="rounded-lg border border-border/60 bg-background/95 p-3 shadow-lg backdrop-blur-sm">
@@ -93,18 +127,65 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
</div>
<div className="space-y-2">
{entries.map(([id, req]) => (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus="pending"
approvalId={id}
onApproveOnce={() => handleApproveOnce(id)}
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
onReject={() => handleReject(id)}
/>
<div key={id} className="space-y-1.5">
<ToolCall
name={req.toolName}
args={req.args}
approvalTarget={req.target}
isLoading={false}
isInterrupted={false}
approvalStatus="pending"
approvalId={id}
onApproveOnce={() => handleApproveOnce(id)}
onAlwaysAllow={req.target && !req.target.hostId ? undefined : () => handleAlwaysAllow(id, req)}
onReject={() => handleReject(id)}
alwaysAllowLabel={req.target?.hostId ? t('ai.externalMcp.allowCommandOnHost') : undefined}
alwaysAllowTitle={req.target?.hostId ? t('ai.externalMcp.allowCommandOnHostHint') : undefined}
/>
{(req.allowSession || req.target?.hostId) && (
<div className="space-y-1.5">
<div className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60">
{t('ai.externalMcp.allowAll')}
</div>
<div className="grid grid-cols-2 gap-1.5">
{req.allowSession && (
<Button
variant="outline"
size="sm"
className={`h-7 min-w-0 px-2 text-[11px] ${req.target?.hostId ? '' : 'col-span-2'}`}
title={t('ai.externalMcp.allowSessionHint')}
onClick={() => handleAllowSession(id)}
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
handleReject(id);
}}
>
{t('ai.externalMcp.allowSession')}
</Button>
)}
{req.target?.hostId && (
<Button
variant="outline"
size="sm"
className={`h-7 min-w-0 px-2 text-[11px] ${req.allowSession ? '' : 'col-span-2'}`}
title={t('ai.externalMcp.allowHostHint')}
onClick={() => handleAllowHost(id, req)}
onKeyDown={(event) => {
if (event.key !== 'Escape') return;
event.preventDefault();
event.stopPropagation();
handleReject(id);
}}
>
{t('ai.externalMcp.allowHost')}
</Button>
)}
</div>
</div>
)}
</div>
))}
</div>
</div>
@@ -38,6 +38,7 @@ const GrantCapabilitySelect: React.FC<{
options: readonly string[];
onChange: (value: string) => void;
}> = ({ value, options, onChange }) => {
const { t } = useI18n();
const selectOptions = useMemo(() => {
if (options.includes(value)) return options;
return [value, ...options];
@@ -52,7 +53,7 @@ const GrantCapabilitySelect: React.FC<{
>
{selectOptions.map((capabilityId) => (
<option key={capabilityId} value={capabilityId}>
{capabilityId}
{capabilityId === '*' ? t('ai.externalMcp.allowHost') : capabilityId}
</option>
))}
</select>
@@ -204,6 +205,11 @@ export const PermissionGrantsSettings: React.FC<{
updateGrant(grant.id, updates);
}}
/>
{grant.sessionPattern.startsWith('host:') && (
<div className="mt-1 truncate text-[10px] text-muted-foreground" title={grant.sessionPattern}>
{t('ai.chat.targetLabel')}: {grant.note || grant.sessionPattern.slice(5)}
</div>
)}
</td>
<td className="px-2 py-2 align-middle max-w-0">
{supportsCommandPattern ? (
@@ -5,7 +5,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { SYSTEM_MANAGER_TAB_LAYOUT_DEFAULTS } from '../../application/state/systemManagerTabLayout';
import { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
import { useTerminalBackend } from '../../application/state/useTerminalBackend';
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.cjs';
import { remoteSoftwareRequiresSingleChannel } from '../../domain/singleChannelSshBanner.shared.mjs';
import { useToolbarItemLayout } from '../../application/state/useToolbarItemLayout';
import type { TerminalSettings } from '../../domain/models';
import type { Host } from '../../domain/models/connection';
@@ -4,7 +4,7 @@ import {
hostRestrictsExtraSshChannels,
normalizeDistroId,
} from "../../../domain/host";
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.cjs";
import { remoteSoftwareRequiresSingleChannel } from "../../../domain/singleChannelSshBanner.shared.mjs";
import { logger } from "../../../lib/logger";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
+21 -1
View File
@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildAITerminalSessionInfo } from "./buildAITerminalSessionInfo.ts";
import type { Host, TerminalSession } from "../../types";
import type { Host, TerminalSession } from "../types";
const baseHost = (overrides: Partial<Host> = {}): Host =>
({
@@ -93,3 +93,23 @@ test("prefers a session custom name over the shared host label", () => {
);
assert.equal(info.label, "Bastion - production");
});
test("only saved remote hosts can receive persistent server approvals", () => {
const saved = baseHost();
assert.equal(buildAITerminalSessionInfo(baseSession(), saved, "linux", {
allHosts: [saved],
}).savedHostId, "h1");
assert.equal(buildAITerminalSessionInfo(baseSession(), saved, "linux", {
allHosts: [],
}).savedHostId, undefined);
const temporary = baseHost({ ephemeral: true });
assert.equal(buildAITerminalSessionInfo(baseSession(), temporary, "linux", {
allHosts: [temporary],
}).savedHostId, undefined);
assert.equal(buildAITerminalSessionInfo(baseSession({ protocol: "local" }), saved, "linux", {
allHosts: [saved],
}).savedHostId, undefined);
assert.equal(buildAITerminalSessionInfo(baseSession({ protocol: "serial" }), saved, "linux", {
allHosts: [saved],
}).savedHostId, undefined);
});
+6
View File
@@ -1,10 +1,12 @@
import { classifyDistroId, resolveHostOs } from './host';
import { isSavedVaultHost } from './ephemeralHosts';
import type { PortForwardingRule } from './models';
import type { Host, TerminalSession } from '../types';
export type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
savedHostId?: string;
hostname: string;
label: string;
os?: string;
@@ -50,6 +52,9 @@ export const buildAITerminalSessionInfo = (
const protocol = session?.protocol || host?.protocol;
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
const allHosts = options?.allHosts ?? (host ? [host] : []);
const savedHostId = !isLocalSession && protocol !== 'serial'
? allHosts.find((entry) => entry.id === session?.hostId && isSavedVaultHost(entry))?.id
: undefined;
const hostChain = summarizeHostChain(host, allHosts);
const activePortForwards = host?.id && options?.portForwardingRules
? options.portForwardingRules
@@ -81,6 +86,7 @@ export const buildAITerminalSessionInfo = (
return {
sessionId: session?.id || '',
hostId: session?.hostId || '',
...(savedHostId ? { savedHostId } : {}),
hostname: host?.hostname || session?.hostname || '',
// A session rename is scoped to the individual terminal tab. Prefer it
// over the saved Host label so duplicate connections to the same host can
+1 -1
View File
@@ -1,4 +1,4 @@
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.cjs';
import { remoteSoftwareRequiresSingleChannel } from './singleChannelSshBanner.shared.mjs';
import { Host, Snippet, TerminalSettings } from './models';
import type { HostOperatingSystem, HostOsSelection } from './models/connection';
import { sanitizeHostIconFields } from './hostIcon';
+2
View File
@@ -86,6 +86,8 @@ export interface TransferTask {
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
sourceLastModified?: number; // Cached from file list to avoid redundant stat
/** Bounded remote preflight already skipped this file; keep the cap after panel remount. */
preflightStatSkipped?: boolean;
skipConflictCheck?: boolean; // Skip conflict check for replace operations
replaceExistingTarget?: boolean; // Delete the existing target before transferring
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
+7
View File
@@ -156,6 +156,13 @@ test("restoring terminal tasks strips stale conflict payloads (skip-without-clea
assert.equal(restored.tasks[1]?.conflict?.fileName, "css");
});
test("a preflight-skipped conflict keeps its stat limit after restart", () => {
const pending = { ...task("late-file", "attention", 1), preflightStatSkipped: true };
const restored = deserializeSftpTransferCenter(serializeSftpTransferCenter([pending]));
assert.equal(restored.tasks[0]?.status, "attention");
assert.equal(restored.tasks[0]?.preflightStatSkipped, true);
});
test("history keeps unfinished tasks and caps terminal tasks by age and count", () => {
const now = Date.UTC(2026, 6, 23);
const old = now - 31 * 24 * 60 * 60 * 1000;
+2 -1
View File
@@ -34,7 +34,7 @@ const SAFE_TASK_KEYS: ReadonlySet<keyof TransferTask> = new Set([
"sourceConnectionId", "targetConnectionId", "targetHostId", "targetConnectionKey",
"direction", "status", "totalBytes", "transferredBytes", "speed", "error",
"startTime", "endTime", "isDirectory", "progressMode", "childTasks", "parentTaskId",
"sourceLastModified", "skipConflictCheck", "replaceExistingTarget", "retryable",
"sourceLastModified", "preflightStatSkipped", "skipConflictCheck", "replaceExistingTarget", "retryable",
"ownerId", "sourceHostId", "sourceHostLabel", "targetHostLabel", "origin", "background",
"phase", "controlKind", "resumable", "checkpointBytes", "priority", "updatedAt", "pauseUnavailableReason",
"resumeStage", "downloadCheckpointBytes", "uploadCheckpointBytes",
@@ -58,6 +58,7 @@ export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
if (source[key] !== undefined) sanitized[key] = source[key];
}
const task = sanitized as unknown as TransferTask;
if (task.preflightStatSkipped !== true) task.preflightStatSkipped = undefined;
if (!Number.isSafeInteger(task.directoryEntryIndex) || (task.directoryEntryIndex ?? -1) < 0) {
task.directoryEntryIndex = undefined;
}
@@ -0,0 +1,7 @@
export const SINGLE_CHANNEL_SOFTWARE_TOKENS: readonly string[];
export function remoteSoftwareRequiresSingleChannel(remoteSshVersion?: string): boolean;
export function remoteDisallowsChunkedChannelWrite(remoteSshVersion?: string): boolean;
export function optionsForPeerSingleChannel<T extends { singleChannelSsh?: boolean }>(
options: T,
conn?: { _remoteVer?: string } | null,
): T;
+70
View File
@@ -0,0 +1,70 @@
"use strict";
// SSH software tokens that allow one session channel per TCP connection.
// Match the identification software token only. Do not match product names,
// OpenSSH, JumpServer/KoKo, or a "term-sshd" substring inside another token.
//
// BHostSSH_7.0 — issue #3146 log field remoteSshVersion. PR #3313: a second
// exec channel tears down the first interactive session. Multi-character
// channel writes are also dropped on this banner.
// TERM-SSHD — issue #2923 comment 5314292157 logs remoteSshVersion
// "TERM-SSHD". PR #3030: a later session channel on that TCP is accepted
// and the shell exits 0.
// CLOUDBILITY-4.14 — PR #3458 reproduction note: SSH-2.0-CLOUDBILITY-4.14
// drops the interactive shell when another session channel opens. No other
// public packet was found; do not add aliases.
// JumpServer KoKo advertises Version "JumpServer" and multiplexes. Omit it.
const SINGLE_CHANNEL_SOFTWARE_TOKENS = Object.freeze([
"BHostSSH",
"TERM-SSHD",
"CLOUDBILITY",
]);
// #3146 also drops multi-character writes. That evidence is only for these
// two tokens, not for CLOUDBILITY.
const CHUNKED_WRITE_SOFTWARE_TOKENS = Object.freeze([
"BHostSSH",
"TERM-SSHD",
]);
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function softwareTokenMatches(remoteSshVersion, token) {
const software = String(remoteSshVersion || "").trim();
if (!software) return false;
const pattern = new RegExp(
`(?:^|[^A-Za-z0-9])${escapeRegExp(token)}(?:[^A-Za-z0-9]|$)`,
"i",
);
return pattern.test(software);
}
function remoteSoftwareMatchesAny(remoteSshVersion, tokens) {
return tokens.some((token) => softwareTokenMatches(remoteSshVersion, token));
}
function remoteSoftwareRequiresSingleChannel(remoteSshVersion) {
return remoteSoftwareMatchesAny(remoteSshVersion, SINGLE_CHANNEL_SOFTWARE_TOKENS);
}
function remoteDisallowsChunkedChannelWrite(remoteSshVersion) {
return remoteSoftwareMatchesAny(remoteSshVersion, CHUNKED_WRITE_SOFTWARE_TOKENS);
}
function optionsForPeerSingleChannel(options, conn) {
const remoteSshVersion = conn && typeof conn._remoteVer === "string" ? conn._remoteVer : "";
if (!options || options.singleChannelSsh === true || !remoteSoftwareRequiresSingleChannel(remoteSshVersion)) {
return options;
}
return { ...options, singleChannelSsh: true };
}
export {
SINGLE_CHANNEL_SOFTWARE_TOKENS,
remoteSoftwareRequiresSingleChannel,
remoteDisallowsChunkedChannelWrite,
optionsForPeerSingleChannel,
};
@@ -69,3 +69,18 @@ test("optionsForPeerSingleChannel stamps the runtime flag from the peer banner",
true,
);
});
test("CommonJS and renderer modules agree on SSH banner behavior", async () => {
const browserModule = await import("./singleChannelSshBanner.shared.mjs");
const nodeModule = require("./singleChannelSshBanner.shared.cjs");
for (const version of [
"BHostSSH_7.0", "TERM-SSHD", "CLOUDBILITY-4.14", "OpenSSH_9.6", "JumpServer", "",
]) {
assert.equal(browserModule.remoteSoftwareRequiresSingleChannel(version), nodeModule.remoteSoftwareRequiresSingleChannel(version));
assert.equal(browserModule.remoteDisallowsChunkedChannelWrite(version), nodeModule.remoteDisallowsChunkedChannelWrite(version));
assert.deepEqual(
browserModule.optionsForPeerSingleChannel({ singleChannelSsh: false }, { _remoteVer: version }),
nodeModule.optionsForPeerSingleChannel({ singleChannelSsh: false }, { _remoteVer: version }),
);
}
});
+1
View File
@@ -71,6 +71,7 @@ module.exports = {
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/singleChannelSshBanner.shared.cjs',
'domain/singleChannelSshBanner.shared.mjs',
'lib/**/*.cjs',
'lib/**/*.json',
'skills/**/*',
@@ -135,11 +135,11 @@ function registerAgentProcessHandlers(ctx) {
});
// ── MCP Approval response (renderer → main) ──
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved, scope }) => {
// Settings window also hosts External MCP approval cards.
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
return { ok: true };
const accepted = mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved, scope);
return { ok: true, accepted };
});
// Cancel MCP approval auto-deny after the user starts reviewing the card.
+84 -12
View File
@@ -63,6 +63,7 @@ const DISCOVERY_SELF_HEAL_INTERVAL_MS = 15000;
const authenticatedSockets = new WeakSet();
// Sockets authenticated with the External MCP token (or that used the reserved scope).
const externalMcpSockets = new Set();
const sessionApprovedExternalSockets = new WeakSet();
function markExternalMcpSocket(socket) {
if (!socket || socket.destroyed) return;
@@ -71,6 +72,8 @@ function markExternalMcpSocket(socket) {
socket.__netcattyExternalMcpCleanupBound = true;
const cleanup = () => {
externalMcpSockets.delete(socket);
sessionApprovedExternalSockets.delete(socket);
clearPendingApprovalsForSocket(socket);
};
socket.once("close", cleanup);
socket.once("end", cleanup);
@@ -85,6 +88,9 @@ function issueExternalMcpAuthToken() {
function revokeExternalMcpAuthToken() {
externalAuthToken = null;
for (const socket of externalMcpSockets) {
sessionApprovedExternalSockets.delete(socket);
}
}
function getExternalMcpAuthToken() {
@@ -97,6 +103,7 @@ function disconnectExternalMcpClients() {
// is reserved for process shutdown paths that call this intentionally.
for (const socket of Array.from(externalMcpSockets)) {
externalMcpSockets.delete(socket);
sessionApprovedExternalSockets.delete(socket);
try {
if (!socket.destroyed) socket.destroy();
} catch {
@@ -207,7 +214,7 @@ function broadcastApprovalEvent(channel, payload) {
}
}
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
function requestApprovalFromRenderer(toolName, args, chatSessionId, approvalContext = {}) {
return new Promise((resolve) => {
debugLog("requestApprovalFromRenderer", { toolName, args, chatSessionId });
const targets = listApprovalTargetWindows();
@@ -261,25 +268,59 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId) {
absoluteExpiresAt,
idleCancelled: false,
chatSessionId: chatSessionId || null,
externalSocket: approvalContext.externalSocket || null,
});
broadcastApprovalEvent('netcatty:ai:mcp:approval-request', {
approvalId,
toolName,
args,
chatSessionId: chatSessionId || undefined,
target: approvalContext.target || undefined,
allowSession: chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
&& externalMcpSockets.has(approvalContext.externalSocket),
});
});
}
function resolveApprovalFromRenderer(approvalId, approved) {
function resolveApprovalFromRenderer(approvalId, approved, scope = 'once') {
debugLog("resolveApprovalFromRenderer", { approvalId, approved });
const entry = pendingApprovals.get(approvalId);
if (entry) {
pendingApprovals.delete(approvalId);
entry.resolve(approved);
// Main + settings both receive approval requests; clear the sibling card.
notifyRendererApprovalCleared([approvalId]);
if (!entry) return false;
pendingApprovals.delete(approvalId);
const externalSocket = entry.chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID
? entry.externalSocket
: null;
const socketValid = entry.chatSessionId !== EXTERNAL_MCP_CHAT_SESSION_ID
|| Boolean(externalSocket
&& !externalSocket.destroyed
&& externalMcpSockets.has(externalSocket)
&& externalMcpActivityHook?.isEnabled?.());
const accepted = approved === true && socketValid;
const clearedIds = [approvalId];
if (accepted && scope === 'session' && externalSocket) {
sessionApprovedExternalSockets.add(externalSocket);
for (const [id, pending] of pendingApprovals) {
if (pending.externalSocket !== externalSocket) continue;
pendingApprovals.delete(id);
pending.resolve(true);
clearedIds.push(id);
}
}
entry.resolve(accepted);
// Main + settings both receive approval requests; clear sibling cards.
notifyRendererApprovalCleared(clearedIds);
return socketValid;
}
function clearPendingApprovalsForSocket(socket) {
const clearedIds = [];
for (const [id, entry] of pendingApprovals) {
if (entry.externalSocket !== socket) continue;
pendingApprovals.delete(id);
entry.resolve(false);
clearedIds.push(id);
}
notifyRendererApprovalCleared(clearedIds);
}
/**
@@ -642,6 +683,7 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
sessionList.push({
sessionId,
hostId: session.hostId || previous.hostId || "",
savedHostId: session.savedHostId || previous.savedHostId || "",
hostname: session.hostname || session.host || previous.hostname || "",
label: session.label || session.hostname || previous.label || sessionId,
os: session.os || previous.os || "",
@@ -726,6 +768,7 @@ function updateLiveSessionMetadata(sessionList) {
deviceType: entry.deviceType || "",
connected: entry.connected !== false,
hostId: entry.hostId || "",
savedHostId: entry.savedHostId || "",
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
_revision: updateRevision,
@@ -850,6 +893,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
deviceType: s.deviceType || "",
connected: s.connected !== false,
hostId: s.hostId || "",
savedHostId: s.savedHostId || "",
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
@@ -890,6 +934,9 @@ function mergeSessionMetadata(sessionList, chatSessionId) {
deviceType: entry.deviceType || previous.deviceType || "",
connected: entry.connected !== undefined ? entry.connected !== false : previous.connected !== false,
hostId: entry.hostId || previous.hostId || "",
savedHostId: Object.prototype.hasOwnProperty.call(entry, "savedHostId")
? entry.savedHostId || ""
: previous.savedHostId || "",
hostChain: Array.isArray(entry.hostChain)
? entry.hostChain
: (Array.isArray(previous.hostChain) ? previous.hostChain : []),
@@ -1130,6 +1177,18 @@ function toPublicSessionMeta(meta) {
return publicMeta;
}
function getApprovalTarget(params) {
if (typeof params?.sessionId !== 'string' || !params.sessionId) return null;
const meta = getSessionMeta(params.sessionId, params.chatSessionId);
if (!meta) return null;
return {
sessionId: params.sessionId,
hostId: meta.savedHostId || '',
label: meta.label || meta.hostname || params.sessionId,
hostname: meta.hostname || '',
};
}
function buildOpenedSessionMeta(result, sessionId) {
const host = result?.host && typeof result.host === "object" ? result.host : {};
return {
@@ -1143,6 +1202,9 @@ function buildOpenedSessionMeta(result, sessionId) {
deviceType: host.deviceType || "",
connected: result?.status === "connected",
hostId: result?.hostId || host.id || "",
savedHostId: result?.savedHostId && result.savedHostId === (result?.hostId || host.id)
? result.savedHostId
: "",
hostChain: [],
activePortForwards: [],
};
@@ -1410,7 +1472,9 @@ async function handleMessage(socket, line) {
);
}
notifyExternalMcpActivity(method, callParams);
const result = await dispatch(method, callParams);
const result = await dispatch(method, callParams, {
externalSocket: externalMcpSockets.has(socket) ? socket : null,
});
const response = JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n";
if (!socket.destroyed) socket.write(response);
} catch (err) {
@@ -1537,6 +1601,8 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
},
isChatSessionCancelled,
requestApprovalFromRenderer,
getApprovalTarget,
hasSessionApproval: (socket) => Boolean(socket && sessionApprovedExternalSockets.has(socket)),
USER_DENIED_MESSAGE,
listPortForwards: () => listActivePortForwards(),
sessionService,
@@ -1973,11 +2039,11 @@ function getBuiltinRpcHandlerRegistry() {
return builtinRpcHandlerRegistry;
}
async function dispatch(method, params) {
async function dispatch(method, params, approvalContext = {}) {
debugLog("dispatch", { method, params, permissionMode });
if (!method.startsWith("netcatty/")) {
const capabilityResult = await dispatchCapabilityRpc(method, params || {});
const capabilityResult = await dispatchCapabilityRpc(method, params || {}, approvalContext);
if (capabilityResult !== UNROUTED) {
return capabilityResult;
}
@@ -1989,6 +2055,7 @@ async function dispatch(method, params) {
: null;
pruneCompletedBackgroundJobs();
const approvalTarget = getApprovalTarget(params);
const permission = evaluatePermissionWithGrants({
rpcMethod: method,
surface: CAPABILITY_SURFACES.BUILTIN,
@@ -1996,6 +2063,7 @@ async function dispatch(method, params) {
params,
context: {
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
hostId: approvalTarget?.hostId,
},
}, permissionGrantsSnapshot);
if (!permission.allowed) {
@@ -2051,9 +2119,13 @@ async function dispatch(method, params) {
// netcatty/jobStop bypasses approval — it's a stop/cancel action that
// must remain available even if the renderer is unavailable; otherwise
// a runaway terminal_start job could not be interrupted at all.
if (permission.requiresApproval) {
if (permission.requiresApproval
&& !sessionApprovedExternalSockets.has(approvalContext.externalSocket)) {
const { chatSessionId, ...toolArgs } = params || {};
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId, {
...approvalContext,
target: approvalTarget,
});
if (!approved) {
return { ok: false, error: USER_DENIED_MESSAGE };
}
@@ -0,0 +1,150 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const net = require("node:net");
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const bridge = require("./mcpServerBridge.cjs");
test("external MCP approval names the host and isolates connection and host grants", async (t) => {
const dir = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("host-approval")}-`);
const clients = [];
t.after(() => {
for (const client of clients) client.close();
bridge.disconnectExternalMcpClients();
bridge.setMainWindowGetter(() => null);
bridge.setVaultAgentInvoker(null);
bridge.setExternalMcpHooks(null);
bridge.setPermissionGrants([]);
bridge.cleanup();
fs.rmSync(dir, { recursive: true, force: true });
});
const approvals = [];
const waiters = [];
const nextApproval = () => new Promise((resolve) => waiters.push(resolve));
bridge.init({
sessions: new Map(),
cliDiscoveryFilePath: path.join(dir, "discovery.json"),
terminalWorkerManager: {
request: async () => ({ ok: true, stdout: "done", stderr: "", exitCode: 0 }),
},
});
bridge.setMainWindowGetter(() => ({
isDestroyed: () => false,
webContents: {
id: 42,
send(channel, payload) {
if (channel !== "netcatty:ai:mcp:approval-request") return;
approvals.push(payload);
waiters.shift()?.(payload);
},
},
}));
let externalEnabled = true;
bridge.setExternalMcpHooks({ isEnabled: () => externalEnabled, recordActivity: () => {} });
bridge.setPermissionMode("confirm");
bridge.setCommandBlocklist([]);
bridge.setVaultAgentInvoker(async () => ({ ok: true }));
const token = bridge.issueExternalMcpAuthToken();
const port = await bridge.getOrCreateHost();
bridge.updateSessionMetadata([
{ sessionId: "ssh-test", hostId: "host-test", savedHostId: "host-test", label: "Test server", hostname: "test.local", protocol: "ssh", connected: true },
{ sessionId: "ssh-prod", hostId: "host-prod", savedHostId: "host-prod", label: "Production", hostname: "prod.local", protocol: "ssh", connected: true },
], bridge.EXTERNAL_MCP_CHAT_SESSION_ID);
async function connect() {
const socket = net.createConnection({ port, host: "127.0.0.1" });
await new Promise((resolve, reject) => {
socket.once("connect", resolve);
socket.once("error", reject);
});
const client = createNdjsonRpcClient({ socket });
clients.push(client);
assert.equal((await client.call("auth/verify", { token })).ok, true);
return client;
}
const clientA = await connect();
const clientB = await connect();
const exec = (client, sessionId) => client.call("netcatty/exec", {
sessionId,
chatSessionId: bridge.EXTERNAL_MCP_CHAT_SESSION_ID,
command: "date",
});
const firstApproval = nextApproval();
const firstCall = exec(clientA, "ssh-test");
const request = await firstApproval;
assert.deepEqual(request.target, {
sessionId: "ssh-test", hostId: "host-test", label: "Test server", hostname: "test.local",
});
assert.equal(request.allowSession, true);
bridge.resolveApprovalFromRenderer(request.approvalId, true, "session");
assert.equal((await firstCall).ok, true);
assert.equal((await exec(clientA, "ssh-prod")).ok, true);
assert.equal(approvals.length, 1, "session approval stays on the first connection");
const secondApproval = nextApproval();
const secondCall = exec(clientB, "ssh-prod");
const prodRequest = await secondApproval;
assert.equal(prodRequest.target.label, "Production");
bridge.resolveApprovalFromRenderer(prodRequest.approvalId, false);
assert.equal((await secondCall).ok, false);
bridge.setPermissionGrants([{
id: "allow-test-host", capabilityId: "*", sessionPattern: "host:host-test", createdAt: Date.now(),
}]);
assert.equal((await exec(clientB, "ssh-test")).ok, true);
assert.equal(approvals.length, 2, "host grant applies to the second connection without approving production");
bridge.setPermissionGrants([]);
clientA.close();
const clientC = await connect();
const thirdApproval = nextApproval();
const thirdCall = exec(clientC, "ssh-test");
const reconnectRequest = await thirdApproval;
assert.equal(reconnectRequest.target.label, "Test server");
bridge.resolveApprovalFromRenderer(reconnectRequest.approvalId, false);
assert.equal((await thirdCall).ok, false, "a new connection does not inherit temporary approval");
const pendingA = nextApproval();
const pendingB = nextApproval();
const callA = clientB.call("public/vault/hosts/delete", { hostId: "missing-a" });
const callB = clientB.call("public/vault/hosts/update", { hostId: "missing-b", label: "test" });
const [approvalA, approvalB] = await Promise.all([pendingA, pendingB]);
assert.equal(bridge.resolveApprovalFromRenderer(approvalA.approvalId, true, "session"), true);
assert.equal((await callA).ok, true);
assert.equal((await callB).ok, true, "connection approval clears other pending requests on that socket");
assert.equal(bridge.resolveApprovalFromRenderer(approvalB.approvalId, true), false);
bridge.revokeExternalMcpAuthToken();
const afterRevoke = nextApproval();
const afterRevokeCall = exec(clientB, "ssh-test");
const revokeRequest = await afterRevoke;
bridge.resolveApprovalFromRenderer(revokeRequest.approvalId, false);
assert.equal((await afterRevokeCall).ok, false, "re-enabling requires fresh approval");
bridge.updateSessionMetadata([
{ sessionId: "local-test", hostId: "local-1", savedHostId: "", label: "Local Terminal", hostname: "localhost", protocol: "local", connected: true },
], bridge.EXTERNAL_MCP_CHAT_SESSION_ID);
const localApproval = nextApproval();
const localCall = exec(clientB, "local-test");
const localRequest = await localApproval;
assert.equal(localRequest.target.label, "Local Terminal");
assert.equal(localRequest.target.hostname, "localhost");
assert.equal(localRequest.target.hostId, "", "local sessions cannot get a persistent host grant");
bridge.resolveApprovalFromRenderer(localRequest.approvalId, false);
assert.equal((await localCall).ok, false);
const staleApproval = nextApproval();
const staleCall = exec(clientC, "local-test");
const staleRequest = await staleApproval;
externalEnabled = false;
assert.equal(bridge.resolveApprovalFromRenderer(staleRequest.approvalId, true), false);
assert.equal((await staleCall).ok, false);
});
@@ -120,6 +120,7 @@ test("MCP/Catty terminal_execute proxies to worker when terminal sessions live i
deviceType: "",
connected: true,
hostId: "",
savedHostId: "",
hostChain: [],
activePortForwards: [],
},
@@ -431,6 +432,7 @@ test("MCP/Catty terminal_start, poll, and stop proxy worker background jobs", as
deviceType: "",
connected: true,
hostId: "",
savedHostId: "",
hostChain: [],
activePortForwards: [],
},
@@ -1,6 +1,6 @@
"use strict";
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../../capabilities/constants.cjs");
const { CAPABILITY_STATUS, CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
const { getCapabilityByRpcMethod } = require("../../capabilities/registry.cjs");
const { getMcpToolNameForRpcMethod } = require("../../capabilities/adapters/mcpAdapter.cjs");
const { createVaultService } = require("../../capabilities/services/vaultService.cjs");
@@ -106,7 +106,7 @@ function createCapabilityRpcDispatcher(deps) {
session: sessionService,
};
return async function dispatchCapabilityRpc(rpcMethod, params = {}) {
return async function dispatchCapabilityRpc(rpcMethod, params = {}, approvalContext = {}) {
if (typeof rpcMethod !== "string" || rpcMethod.startsWith("netcatty/")) {
return UNROUTED;
}
@@ -130,6 +130,33 @@ function createCapabilityRpcDispatcher(deps) {
return UNROUTED;
}
// Only session.close executes against params.sessionId. host.open instead
// targets its requested vault host; other vault/script operations must not
// borrow an unrelated session's host grant.
let approvalTarget = capability.id === "session.close"
? (deps.getApprovalTarget?.(params) || null)
: null;
if (capability.id === "vault.host.open" && deps.permissionMode === PERMISSION_MODES.CONFIRM) {
const requestedHostId = typeof params?.hostId === "string" ? params.hostId.trim() : "";
let lookup = null;
if (requestedHostId) {
try {
lookup = await vaultService.getHost({ hostId: requestedHostId });
} catch {
// The metadata lookup is best-effort; without it, approval cannot
// offer a persistent host grant.
}
}
const host = lookup?.host?.id === requestedHostId ? lookup.host : null;
approvalTarget = {
sessionId: "",
hostId: host && host.ephemeral !== true && host.protocol !== "serial" && host.protocol !== "local"
? requestedHostId
: "",
label: host?.label || host?.hostname || requestedHostId,
hostname: host?.hostname || "",
};
}
const permission = evaluatePermissionWithGrants({
rpcMethod,
surface,
@@ -137,6 +164,7 @@ function createCapabilityRpcDispatcher(deps) {
params,
context: {
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
hostId: approvalTarget?.hostId,
},
}, deps.permissionGrantsSnapshot);
@@ -144,12 +172,15 @@ function createCapabilityRpcDispatcher(deps) {
return { ok: false, error: permission.error };
}
if (permission.requiresApproval) {
if (permission.requiresApproval && !deps.hasSessionApproval?.(approvalContext.externalSocket)) {
const { chatSessionId, ...toolArgs } = params || {};
const toolName = getMcpToolNameForRpcMethod(rpcMethod, surface)
|| capability.surfaces?.[CAPABILITY_SURFACES.PUBLIC]?.mcpTool
|| capability.id;
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId);
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId, {
...approvalContext,
target: approvalTarget,
});
if (!approved) {
return { ok: false, error: USER_DENIED_MESSAGE };
}
@@ -5,6 +5,7 @@ const assert = require("node:assert/strict");
const { createCapabilityRpcDispatcher, UNROUTED } = require("./capabilityRpcDispatch.cjs");
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
const { evaluatePermissionWithGrants } = require("../../capabilities/policy.cjs");
function createTestDispatcher(overrides = {}) {
const invokeVaultAgent = overrides.invokeVaultAgent || (async (op, params) => ({
@@ -84,6 +85,75 @@ test("dispatchCapabilityRpc routes public vault host notes set through approval"
assert.equal(result.notes, "updated");
});
test("vault host deletion cannot borrow a different session's host grant", async () => {
let approvalTarget;
let deleted = false;
const dispatch = createTestDispatcher({
evaluatePermissionWithGrants,
permissionGrantsSnapshot: [{
id: "trusted-host", capabilityId: "*", sessionPattern: "host:trusted", createdAt: 1,
}],
getApprovalTarget: () => ({ sessionId: "trusted-session", hostId: "trusted", label: "Trusted" }),
requestApprovalFromRenderer: async (_name, _args, _chatSessionId, context) => {
approvalTarget = context.target;
return false;
},
invokeVaultAgent: async () => {
deleted = true;
return { ok: true };
},
});
const result = await dispatch("public/vault/hosts/delete", {
chatSessionId: "__external_mcp__",
sessionId: "trusted-session",
hostId: "other-host",
});
assert.equal(result.ok, false);
assert.equal(deleted, false);
assert.equal(approvalTarget, null);
});
test("host_open names its vault host and cannot borrow another host's grant", async () => {
const approvals = [];
const opened = [];
const hosts = {
test: { id: "test", label: "Test server", hostname: "test.example" },
prod: { id: "prod", label: "Production", hostname: "prod.example" },
temporary: { id: "temporary", label: "Temporary", hostname: "temp.example", ephemeral: true },
serial: { id: "serial", label: "Serial device", hostname: "ttyUSB0", protocol: "serial" },
};
const dispatch = createTestDispatcher({
evaluatePermissionWithGrants,
permissionGrantsSnapshot: [{
id: "test-only", capabilityId: "vault.host.open", sessionPattern: "host:test", createdAt: 1,
}],
requestApprovalFromRenderer: async (_name, _args, _chat, context) => {
approvals.push(context.target);
return false;
},
invokeVaultAgent: async (op, params) => {
if (op === "host.get") return { ok: true, host: hosts[params.hostId] };
if (op === "host.open") {
opened.push(params.hostId);
return { ok: true, sessionId: `session-${params.hostId}` };
}
return { ok: false };
},
});
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "test" })).ok, true);
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "prod" })).ok, false);
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "temporary" })).ok, false);
assert.equal((await dispatch("public/vault/hosts/open", { hostId: "serial" })).ok, false);
assert.deepEqual(opened, ["test"]);
assert.deepEqual(approvals, [
{ sessionId: "", hostId: "prod", label: "Production", hostname: "prod.example" },
{ sessionId: "", hostId: "", label: "Temporary", hostname: "temp.example" },
{ sessionId: "", hostId: "", label: "Serial device", hostname: "ttyUSB0" },
]);
});
test("dispatchCapabilityRpc asks for approval before importing vault notes from the CLI rpc", async () => {
const approvalCalls = [];
let invokedOp = null;
@@ -35,6 +35,9 @@ function mergeRetentionMeta(previous, fallback) {
shellType: newer.shellType || older.shellType,
deviceType: newer.deviceType || older.deviceType,
hostId: newer.hostId || older.hostId,
savedHostId: Object.prototype.hasOwnProperty.call(newer, "savedHostId")
? newer.savedHostId || ""
: older.savedHostId,
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
// Explicit empty arrays in the newer snapshot clear stopped forwards.
activePortForwards: Array.isArray(newer.activePortForwards)
@@ -105,6 +108,7 @@ function retainOwnedSessions({
deviceType: meta.deviceType || "",
connected: meta.connected !== false,
hostId: meta.hostId || "",
savedHostId: meta.savedHostId || "",
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
+1
View File
@@ -96,6 +96,7 @@ function evaluatePermissionWithGrants(ctx, grants = []) {
capabilityId: base.capability.id,
chatSessionId: params.chatSessionId,
sessionId: params.sessionId,
hostId: ctx?.context?.hostId,
args: params,
});
+21
View File
@@ -236,6 +236,27 @@ test("evaluatePermissionWithGrants skips approval when a grant matches", () => {
assert.equal(decision.requiresApproval, false);
});
test("a server grant approves only that server's terminal and SFTP writes", () => {
const grants = [{
id: "test-server",
capabilityId: "*",
sessionPattern: "host:host-test",
createdAt: Date.now(),
}];
const decide = (rpcMethod, hostId, sessionId = "ssh-1") => evaluatePermissionWithGrants({
rpcMethod,
permissionMode: PERMISSION_MODES.CONFIRM,
params: { chatSessionId: "chat-1", sessionId, command: "rm -rf /tmp/build", path: "/tmp/build" },
context: { hostId },
}, grants);
assert.equal(decide("netcatty/exec", "host-test").requiresApproval, false);
assert.equal(decide("netcatty/sftp/delete", "host-test").requiresApproval, false);
assert.equal(decide("netcatty/exec", "host-prod").requiresApproval, true);
assert.equal(decide("netcatty/exec", undefined).requiresApproval, true);
assert.equal(decide("netcatty/exec", "host-test", "").requiresApproval, true);
});
test("evaluatePermissionWithGrants does not let a comment grant approve a multiline command", () => {
const decision = evaluatePermissionWithGrants({
rpcMethod: "netcatty/exec",
+2 -2
View File
@@ -1887,8 +1887,8 @@ function createPreloadApi(ctx) {
ipcRenderer.on("netcatty:ai:mcp:approval-request", handler);
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-request", handler);
},
respondMcpApproval: async (approvalId, approved) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
respondMcpApproval: async (approvalId, approved, scope) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved, scope });
},
cancelMcpApprovalTimeout: async (approvalId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-cancel-timeout", { approvalId });
+16 -2
View File
@@ -57,6 +57,20 @@ function argsPatternMatches(argsPattern, args) {
return true;
}
function ruleMatchesTarget(rule, ctx) {
if (rule.sessionPattern?.startsWith("host:")) {
const hostId = rule.sessionPattern.slice("host:".length);
const opensThisHost = ctx?.capabilityId === "vault.host.open" && ctx?.args?.hostId === hostId;
return Boolean(hostId && ctx?.hostId === hostId && (ctx?.sessionId || opensThisHost));
}
return rule.capabilityId !== "*" && rule.sessionPattern !== "host:";
}
function ruleMatchesCapability(rule, ctx) {
return (rule.capabilityId === ctx?.capabilityId || rule.capabilityId === "*")
&& ruleMatchesTarget(rule, ctx);
}
const CWD_COMMANDS = new Set([
"cd",
"chdir",
@@ -518,7 +532,7 @@ function matchCommandPatternGrants(rules, ctx, command, args) {
const eligibleRules = rules.filter((rule) => (
rule
&& rule.capabilityId === ctx?.capabilityId
&& ruleMatchesCapability(rule, ctx)
&& rule.commandPattern
&& argsPatternMatches(rule.argsPattern, args)
));
@@ -545,7 +559,7 @@ function matchPermissionGrant(rules, ctx) {
for (const rule of rules) {
if (!rule || typeof rule.capabilityId !== "string") continue;
if (rule.capabilityId !== ctx?.capabilityId) continue;
if (!ruleMatchesCapability(rule, ctx)) continue;
if (rule.commandPattern) continue;
if (!argsPatternMatches(rule.argsPattern, args)) continue;
@@ -38,6 +38,48 @@ describe('permissionGrants', () => {
assert.ok(matched);
});
it('matches host grants only for sessions on the selected saved host', () => {
const rules = [baseRule({ capabilityId: '*', sessionPattern: 'host:test-host' })];
assert.ok(matchPermissionGrant(rules, {
capabilityId: 'terminal.execute', sessionId: 'ssh-test', hostId: 'test-host', args: { command: 'date' },
}));
assert.equal(matchPermissionGrant(rules, {
capabilityId: 'terminal.execute', sessionId: 'ssh-prod', hostId: 'prod-host', args: { command: 'date' },
}), null);
assert.equal(matchPermissionGrant(rules, {
capabilityId: 'terminal.execute', sessionId: 'ssh-test', args: { command: 'date' },
}), null);
});
it('matches host-open grants only for the verified requested host', () => {
const rules = [baseRule({ capabilityId: 'vault.host.open', sessionPattern: 'host:test-host' })];
assert.ok(matchPermissionGrant(rules, {
capabilityId: 'vault.host.open', hostId: 'test-host', args: { hostId: 'test-host' },
}));
assert.equal(matchPermissionGrant(rules, {
capabilityId: 'vault.host.open', hostId: 'prod-host', args: { hostId: 'prod-host' },
}), null);
assert.equal(matchPermissionGrant(rules, {
capabilityId: 'vault.host.open', args: { hostId: 'test-host' },
}), null);
});
it('keeps a saved command allowance on the approved host', () => {
const rules = [baseRule({ sessionPattern: 'host:prod-host', commandPattern: 'systemctl status *' })];
assert.ok(matchPermissionGrant(rules, {
capabilityId: 'terminal.execute', sessionId: 'ssh-prod', hostId: 'prod-host',
args: { command: 'systemctl status nginx' },
}));
assert.equal(matchPermissionGrant(rules, {
capabilityId: 'terminal.execute', sessionId: 'ssh-test', hostId: 'test-host',
args: { command: 'systemctl status nginx' },
}), null);
assert.equal(matchPermissionGrant(rules, {
capabilityId: 'terminal.execute', sessionId: 'ssh-prod', hostId: 'prod-host',
args: { command: 'systemctl restart nginx' },
}), null);
});
it('does not match a different capability', () => {
const rules = [baseRule({ sessionPattern: '*' })];
const matched = matchPermissionGrant(rules, {
+17 -2
View File
@@ -17,11 +17,26 @@ export interface PermissionGrantRule {
export interface PermissionGrantMatchContext {
capabilityId: string;
sessionId?: string;
hostId?: string;
chatSessionId?: string;
hostname?: string;
args?: Record<string, unknown>;
}
function ruleMatchesTarget(rule: PermissionGrantRule, ctx: PermissionGrantMatchContext): boolean {
if (rule.sessionPattern.startsWith('host:')) {
const hostId = rule.sessionPattern.slice('host:'.length);
const opensThisHost = ctx.capabilityId === 'vault.host.open' && ctx.args?.hostId === hostId;
return Boolean(hostId && ctx.hostId === hostId && (ctx.sessionId || opensThisHost));
}
return rule.capabilityId !== '*' && rule.sessionPattern !== 'host:';
}
function ruleMatchesCapability(rule: PermissionGrantRule, ctx: PermissionGrantMatchContext): boolean {
return (rule.capabilityId === ctx.capabilityId || rule.capabilityId === '*')
&& ruleMatchesTarget(rule, ctx);
}
type CattyToolSpecRef = {
capabilityId: string;
toolName: string;
@@ -114,7 +129,7 @@ export function matchPermissionGrant(
if (commandGrantMatch) return commandGrantMatch;
for (const rule of rules) {
if (rule.capabilityId !== ctx.capabilityId) continue;
if (!ruleMatchesCapability(rule, ctx)) continue;
if (rule.commandPattern) continue;
if (!argsPatternMatches(rule.argsPattern, args)) continue;
@@ -135,7 +150,7 @@ function matchCommandPatternGrants(
if (commandSegments.length === 0) return null;
const eligibleRules = rules.filter((rule) => (
rule.capabilityId === ctx.capabilityId
ruleMatchesCapability(rule, ctx)
&& Boolean(rule.commandPattern)
&& argsPatternMatches(rule.argsPattern, args)
));
@@ -7,7 +7,9 @@ import {
clearAllPendingApprovals,
onApprovalCleared,
requestApproval,
registerGrantPersister,
resolveApproval,
setupMcpApprovalBridge,
} from './approvalGate';
import { resolveCattyApprovalDeadlines } from './approvalConstants';
@@ -301,3 +303,40 @@ test('resolveApproval skips MCP IPC when the pending entry is already gone', ()
clearAllPendingApprovals();
}
});
test('MCP grants persist only after main confirms an active approval', async () => {
clearAllPendingApprovals();
const previous = (globalThis as { window?: unknown }).window;
const saved: string[] = [];
let receiveRequest: ((payload: unknown) => void) | undefined;
let accepted = false;
(globalThis as { window?: unknown }).window = {
netcatty: {
onMcpApprovalRequest: (listener: (payload: unknown) => void) => {
receiveRequest = listener;
return () => {};
},
respondMcpApproval: async () => ({ ok: true, accepted }),
},
};
const removePersister = registerGrantPersister((grant) => saved.push(grant.id));
const stopBridge = setupMcpApprovalBridge();
try {
for (const shouldAccept of [false, true]) {
accepted = shouldAccept;
const id = `mcp_approval_ack_${shouldAccept}`;
receiveRequest?.({ approvalId: id, toolName: 'terminal_execute', args: {}, chatSessionId: '__external_mcp__' });
resolveApproval(id, {
approved: true,
persistGrant: { id, capabilityId: 'terminal.execute', sessionPattern: 'host:h1', createdAt: 1 },
});
await delay(0);
assert.equal(saved.includes(id), shouldAccept);
}
} finally {
stopBridge();
removePersister();
(globalThis as { window?: unknown }).window = previous;
clearAllPendingApprovals();
}
});
+35 -15
View File
@@ -44,6 +44,7 @@ export interface ApprovalRequest {
approvalType?: 'command' | 'file-change' | 'permissions';
itemId?: string;
allowSession?: boolean;
target?: { sessionId: string; hostId: string; label: string; hostname: string };
}
export interface ResolveApprovalOptions {
@@ -328,25 +329,40 @@ export function resolveApproval(
pendingApprovals.delete(toolCallId);
entry.resolve(resolution);
let persistedGrantId: string | undefined;
if (approved && request?.source !== 'codex-app-server' && persistGrants?.length) {
for (const grant of persistGrants) {
grantPersister?.(grant);
persistedGrantId = grant.id;
const finish = (accepted: boolean) => {
let persistedGrantId: string | undefined;
if (approved && accepted && request?.source !== 'codex-app-server' && persistGrants?.length) {
for (const grant of persistGrants) {
grantPersister?.(grant);
persistedGrantId = grant.id;
}
}
}
if (request) {
emitApprovalEvent('approval_resolved', request, {
outcome: approved ? 'approved' : 'denied',
persistedGrantId,
});
}
if (request) {
emitApprovalEvent('approval_resolved', request, {
outcome: approved && accepted ? 'approved' : 'denied',
persistedGrantId,
});
}
};
// MCP tool call: also forward response to main process via IPC
// The main process owns the MCP timeout and socket lifecycle. Persist a
// grant only after it confirms that this approval was still active.
if (toolCallId.startsWith('mcp_approval_')) {
const bridge = (window as unknown as { netcatty?: { respondMcpApproval?: (id: string, approved: boolean) => Promise<unknown> } }).netcatty;
bridge?.respondMcpApproval?.(toolCallId, approved);
const bridge = (window as unknown as {
netcatty?: {
respondMcpApproval?: (id: string, approved: boolean, scope: 'once' | 'session') => Promise<{ ok?: boolean; accepted?: boolean }>;
};
}).netcatty;
if (!bridge?.respondMcpApproval) {
finish(false);
return;
}
void bridge.respondMcpApproval(toolCallId, approved, resolution.scope)
.then((result) => finish(result?.ok === true && result?.accepted === true))
.catch(() => finish(false));
return;
}
finish(true);
}
/**
@@ -470,6 +486,8 @@ export function setupMcpApprovalBridge(): () => void {
toolName: string;
args: Record<string, unknown>;
chatSessionId?: string;
target?: ApprovalRequest['target'];
allowSession?: boolean;
}) => void) => () => void;
onMcpApprovalCleared?: (cb: (payload: {
approvalIds: string[];
@@ -486,6 +504,8 @@ export function setupMcpApprovalBridge(): () => void {
chatSessionId: payload.chatSessionId,
capabilityId: resolveCapabilityId(payload.toolName),
source: 'mcp',
target: payload.target,
allowSession: payload.allowSession,
};
// Store in pendingApprovals so it survives unmount/remount
@@ -472,6 +472,30 @@ describe('handleVaultAgentOp vault hosts', () => {
assert.equal(receivedIsExternalMcpCall, true);
});
it('host.open registers saved-host identity immediately but not for a temporary host', async (t) => {
const { netcattyBridge } = await import('../services/netcattyBridge');
const merged: Array<{ sessions: Array<{ savedHostId?: string }>; scope: string }> = [];
t.mock.method(netcattyBridge, 'get', () => ({
aiMcpMergeSessions: async (sessions: Array<{ savedHostId?: string }>, scope: string) => {
merged.push({ sessions, scope });
},
} as unknown as NetcattyBridge));
const saved = { id: 'saved-host', label: 'Saved', hostname: 'saved.example', port: 22 } as Host;
const temporary = { id: 'temporary-host', label: 'Temporary', hostname: 'temp.example', port: 22, ephemeral: true } as Host;
const serial = { id: 'serial-host', label: 'Serial', hostname: 'ttyUSB0', protocol: 'serial' } as Host;
const deps = createDeps({ hosts: [saved, temporary, serial] });
await handleVaultAgentOp('host.open', { hostId: saved.id, chatSessionId: '__external_mcp__' }, deps);
await handleVaultAgentOp('host.open', { hostId: temporary.id, chatSessionId: '__external_mcp__' }, deps);
await handleVaultAgentOp('host.open', { hostId: serial.id, chatSessionId: '__external_mcp__' }, deps);
assert.deepEqual(merged.map(({ sessions, scope }) => ({ savedHostId: sessions[0]?.savedHostId, scope })), [
{ savedHostId: saved.id, scope: '__external_mcp__' },
{ savedHostId: undefined, scope: '__external_mcp__' },
{ savedHostId: undefined, scope: '__external_mcp__' },
]);
});
it('host.open does not treat a missing chatSessionId as an external MCP call', async () => {
const host: Host = {
id: 'host-open-4',
@@ -1,6 +1,7 @@
import { readScreenContext } from "../scripts/screenSnapshotRegistry";
import { normalizeTerminalContextRange } from "../../domain/terminalContextRead";
import { resolveHostOs } from '../../domain/host';
import { isSavedVaultHost } from '../../domain/ephemeralHosts';
import type { GroupConfig, Host, Identity, KnownHost, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, SSHKey, TerminalSettings, VaultNote } from '../../domain/models';
import type { RememberImportedKeyPassphraseResult } from '../../application/defaultKeyPassphrases';
import {
@@ -598,9 +599,13 @@ async function registerOpenedSessionInMcpScope(
: host.moshEnabled
? 'mosh'
: (host.protocol || 'ssh');
const savedHostId = isSavedVaultHost(host) && protocol !== 'serial' && protocol !== 'local'
? host.id
: undefined;
const sessionInfo = {
sessionId,
hostId: host.id,
...(savedHostId ? { savedHostId } : {}),
hostname: host.hostname || '',
label: host.label || host.hostname || sessionId,
os: resolveHostOs(host),
@@ -695,6 +700,9 @@ export async function handleVaultAgentOp(
ok: true,
sessionId: opened.sessionId,
hostId: effectiveHost.id,
...(isSavedVaultHost(effectiveHost) && protocol !== 'serial' && protocol !== 'local'
? { savedHostId: effectiveHost.id }
: {}),
status: 'connecting',
protocol,
host: summarizeHostForList(effectiveHost),
+4
View File
@@ -45,6 +45,10 @@ test("build.files includes the single-channel SSH banner matcher", () => {
config.files.includes("domain/singleChannelSshBanner.shared.cjs"),
"packaged SSH and SFTP bridges require the shared banner matcher",
);
assert.ok(
config.files.includes("domain/singleChannelSshBanner.shared.mjs"),
"the shared banner matcher requires its source module",
);
const banner = require("../domain/singleChannelSshBanner.shared.cjs");
assert.equal(banner.remoteSoftwareRequiresSingleChannel("BHostSSH_7.0"), true);
assert.equal(banner.remoteSoftwareRequiresSingleChannel("JumpServer"), false);