mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
453d87c8a6 | ||
|
|
2a3b0df65e | ||
|
|
c26cedf433 | ||
|
|
d3e5c63661 | ||
|
|
a530ac6488 | ||
|
|
c643c1e62c | ||
|
|
240781bc8d | ||
|
|
9b3f2318a7 | ||
|
|
40d1acb4dc | ||
|
|
73920033f5 | ||
|
|
9efa105b26 | ||
|
|
a57161d2fc | ||
|
|
b96eaae42f |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Режим доступности',
|
||||
|
||||
@@ -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': '可用模式',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -3389,6 +3389,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
await runAutomationScript({
|
||||
snippet,
|
||||
sessionId,
|
||||
initiatedBy: 'user',
|
||||
sessionMeta: {
|
||||
connected: true,
|
||||
name: scriptSessionName,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -96,6 +96,7 @@ function evaluatePermissionWithGrants(ctx, grants = []) {
|
||||
capabilityId: base.capability.id,
|
||||
chatSessionId: params.chatSessionId,
|
||||
sessionId: params.sessionId,
|
||||
hostId: ctx?.context?.hostId,
|
||||
args: params,
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user