mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-04 21:12:35 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d379298871 | ||
|
|
c808632c0f | ||
|
|
21659cfa03 | ||
|
|
8518b56c55 | ||
|
|
1c1ee990b9 | ||
|
|
4f4cda1b6a | ||
|
|
30a55debcc | ||
|
|
5d56226877 | ||
|
|
d88c10ce82 | ||
|
|
937b9b609e | ||
|
|
355bf3da99 | ||
|
|
b5e12c23a6 |
No files matched your search
@@ -404,6 +404,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.rawCommand': 'Command',
|
||||
'ai.chat.copyCommand': 'Copy',
|
||||
'ai.chat.commandCopied': 'Copied',
|
||||
'ai.chat.approvalSession': 'Session',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Reason',
|
||||
'ai.chat.approvalInvocation': 'Invocation',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
|
||||
@@ -356,6 +356,14 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.rawCommand': 'Команда',
|
||||
'ai.chat.copyCommand': 'Копировать',
|
||||
'ai.chat.commandCopied': 'Скопировано',
|
||||
'ai.chat.approvalSession': 'Сессия',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Каталог',
|
||||
'ai.chat.approvalReason': 'Причина',
|
||||
'ai.chat.approvalInvocation': 'Вызов',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
|
||||
@@ -404,6 +404,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.rawCommand': '命令',
|
||||
'ai.chat.copyCommand': '复制',
|
||||
'ai.chat.commandCopied': '已复制',
|
||||
'ai.chat.approvalSession': '会话',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目录',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '调用详情',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
|
||||
@@ -403,6 +403,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
'ai.chat.rawCommand': '指令',
|
||||
'ai.chat.copyCommand': '複製',
|
||||
'ai.chat.commandCopied': '已複製',
|
||||
'ai.chat.approvalSession': '工作階段',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目錄',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
approvalArgsHaveExtraContext,
|
||||
approvalCommandWasUnwrapped,
|
||||
extractApprovalExecutionContext,
|
||||
extractDisplayCommand,
|
||||
isNestedInteractiveApprovalTarget,
|
||||
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
|
||||
truncateToolCommandTooltip,
|
||||
} from './tool-call';
|
||||
|
||||
const toolCallSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'tool-call.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
|
||||
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
|
||||
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
|
||||
@@ -44,6 +56,30 @@ test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cjs wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli.cjs" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"/Resources/netcatty-tool-cli.cjs" exec --session X -- "df -h"`,
|
||||
}),
|
||||
'df -h',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cmd wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"C:\\\\App\\\\netcatty-tool-cli.cmd" exec --session X -- "whoami"`,
|
||||
}),
|
||||
'whoami',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli env -> list sessions', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
|
||||
});
|
||||
@@ -70,3 +106,116 @@ test('empty / missing args -> null', () => {
|
||||
assert.equal(extractDisplayCommand(undefined), null);
|
||||
assert.equal(extractDisplayCommand({ command: '' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces session/cwd/shell without rewriting command', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
sessionId: 'term-1',
|
||||
cwd: '/var/log',
|
||||
command: ['zsh', '-lc', 'df -h | sort'],
|
||||
}),
|
||||
{ sessionId: 'term-1', cwd: '/var/log', shell: 'zsh', reason: undefined },
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/bash -lc 'uptime'`,
|
||||
}),
|
||||
{ sessionId: undefined, cwd: undefined, shell: 'bash', reason: undefined },
|
||||
);
|
||||
assert.equal(extractApprovalExecutionContext({ path: '/tmp' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext reads --session from netcatty-tool-cli wrappers', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session term-9 --chat-session chat-1 -- "uptime"'`,
|
||||
}),
|
||||
{ sessionId: 'term-9', cwd: undefined, shell: 'zsh', reason: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces Codex reason for approval review', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: 'rm -rf /tmp/x',
|
||||
cwd: '/tmp',
|
||||
reason: 'Clean stale build artifacts',
|
||||
commandActions: [{ type: 'delete' }],
|
||||
}),
|
||||
{
|
||||
sessionId: undefined,
|
||||
cwd: '/tmp',
|
||||
shell: undefined,
|
||||
reason: 'Clean stale build artifacts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('approvalCommandWasUnwrapped detects Skills+CLI display unwrap', () => {
|
||||
const args = {
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
};
|
||||
const display = extractDisplayCommand(args);
|
||||
assert.equal(display, 'uptime');
|
||||
assert.equal(approvalCommandWasUnwrapped(args, display), true);
|
||||
assert.equal(approvalCommandWasUnwrapped({ command: 'uptime' }, 'uptime'), false);
|
||||
});
|
||||
|
||||
test('approvalArgsHaveExtraContext keeps commandActions visible beside the command block', () => {
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({
|
||||
command: 'echo hi',
|
||||
reason: 'demo',
|
||||
commandActions: [{ type: 'read' }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({ command: 'echo hi', cwd: '/tmp', reason: 'demo' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isNestedInteractiveApprovalTarget ignores Enter on Copy/Expand review controls', () => {
|
||||
const card = { id: 'card' };
|
||||
const copyBtn = {
|
||||
closest: (selector: string) => (selector.includes('button') ? copyBtn : null),
|
||||
};
|
||||
const plainSpan = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
assert.equal(isNestedInteractiveApprovalTarget(copyBtn, card), true);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(plainSpan, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(card, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(null, card), false);
|
||||
});
|
||||
|
||||
// Pending approvals with no display command (Codex file-change/permissions,
|
||||
// write tools with JSON-only args) only render the overflow args <pre>. Wheel /
|
||||
// trackpad scroll must re-arm idle the same way the command block does.
|
||||
test('args overflow block re-arms review timers on scroll/wheel while pending', () => {
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/JSON\.stringify\(args,\s*null,\s*2\)[\s\S]{0,200}?<\/pre>/,
|
||||
);
|
||||
// Both review surfaces (command + args) wire markReviewing for continuous scroll.
|
||||
const markReviewingScrollBindings = toolCallSource.match(
|
||||
/onScroll=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
const markReviewingWheelBindings = toolCallSource.match(
|
||||
/onWheel=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingScrollBindings && markReviewingScrollBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onScroll',
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingWheelBindings && markReviewingWheelBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onWheel',
|
||||
);
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/onScroll=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}onWheel=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}JSON\.stringify\(args/,
|
||||
);
|
||||
});
|
||||
@@ -1,13 +1,36 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
|
||||
/** Collapsed approval command block max height (px). Full text remains scrollable. */
|
||||
export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144;
|
||||
/** Expanded approval command block max height (px). */
|
||||
export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384;
|
||||
/** Prefer expand control when the raw command exceeds this many characters. */
|
||||
export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180;
|
||||
|
||||
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
|
||||
|
||||
/**
|
||||
* Enter on the pending-card root means Approve Once. Enter on nested review
|
||||
* controls (Copy / Expand / action buttons) must not approve — those controls
|
||||
* also stopPropagation on Enter so the card handler is a second line of defense.
|
||||
*/
|
||||
export function isNestedInteractiveApprovalTarget(
|
||||
target: { closest?: (selector: string) => unknown } | null,
|
||||
currentTarget: unknown,
|
||||
): boolean {
|
||||
if (!target || target === currentTarget) return false;
|
||||
if (typeof target.closest !== 'function') return false;
|
||||
return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function truncateToolCommandTooltip(
|
||||
command: string,
|
||||
@@ -72,10 +95,14 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
if (strWrap) cmdString = strWrap[2];
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
|
||||
// Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the
|
||||
// optional extension so the subcommand after the binary is still found.
|
||||
const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
if (cliIdx >= 0) {
|
||||
const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length;
|
||||
const afterCli = cmdString
|
||||
.slice(cliIdx + 'netcatty-tool-cli'.length)
|
||||
.slice(cliIdx + cliTokenLen)
|
||||
.replace(/^["']?\s*/, '');
|
||||
const subMatch = afterCli.match(/^(\S+)/);
|
||||
const sub = subMatch ? subMatch[1] : '';
|
||||
@@ -106,6 +133,110 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
return cmdString;
|
||||
}
|
||||
|
||||
export interface ApprovalExecutionContext {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rawCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' ');
|
||||
return null;
|
||||
}
|
||||
|
||||
const APPROVAL_CONTEXT_ARG_KEYS = new Set([
|
||||
'command',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workdir',
|
||||
'workingDirectory',
|
||||
'sessionId',
|
||||
'shell',
|
||||
'reason',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when pending args still carry review-relevant fields beyond the
|
||||
* command block / execution-context strip (e.g. commandActions).
|
||||
*/
|
||||
export function approvalArgsHaveExtraContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!args) return false;
|
||||
return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reviewable display command was unwrapped from a Skills+CLI /
|
||||
* shell wrapper — the pending card should still surface target flags.
|
||||
*/
|
||||
export function approvalCommandWasUnwrapped(
|
||||
args: Record<string, unknown> | undefined,
|
||||
displayCommand: string | null,
|
||||
): boolean {
|
||||
if (!displayCommand) return false;
|
||||
const raw = rawCommandString(args);
|
||||
if (!raw || raw === displayCommand) return false;
|
||||
return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw)
|
||||
|| (Array.isArray(args?.command) && args.command.length >= 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort execution context for approval review (session / cwd / shell).
|
||||
* Never invents host names; only surfaces fields already present on tool args
|
||||
* or explicit netcatty-tool-cli flags in the command string.
|
||||
*/
|
||||
export function extractApprovalExecutionContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): ApprovalExecutionContext | null {
|
||||
if (!args) return null;
|
||||
|
||||
let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined;
|
||||
|
||||
let shell = typeof args.shell === 'string' && args.shell.trim()
|
||||
? args.shell.trim()
|
||||
: undefined;
|
||||
|
||||
const reason = typeof args.reason === 'string' && args.reason.trim()
|
||||
? args.reason.trim()
|
||||
: undefined;
|
||||
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (!shell) {
|
||||
if (Array.isArray(raw) && raw.length >= 2) {
|
||||
const first = String(raw[0] ?? '');
|
||||
const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
} else if (typeof raw === 'string') {
|
||||
const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap.
|
||||
if (!sessionId) {
|
||||
const cmd = rawCommandString(args);
|
||||
if (cmd && cmd.includes('netcatty-tool-cli')) {
|
||||
const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
||||
const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3];
|
||||
if (fromFlag) sessionId = fromFlag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId && !cwd && !shell && !reason) return null;
|
||||
return { sessionId, cwd, shell, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
@@ -149,6 +280,8 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Pending approval id used to cancel the auto-deny timer on review. */
|
||||
approvalId?: string;
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
@@ -161,18 +294,72 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
alwaysAllowLabel?: string;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commandExpanded, setCommandExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [frozenCommand, setFrozenCommand] = useState<string | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
const liveDisplayCommand = extractDisplayCommand(args);
|
||||
const reviewCommand = isPendingApproval
|
||||
? (frozenCommand ?? liveDisplayCommand)
|
||||
: liveDisplayCommand;
|
||||
const executionContext = extractApprovalExecutionContext(args);
|
||||
const showApprovalCommand = Boolean(isPendingApproval && reviewCommand);
|
||||
const showArgsAlongsideCommand = Boolean(
|
||||
showApprovalCommand
|
||||
&& args
|
||||
&& Object.keys(args).length > 0
|
||||
&& (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)),
|
||||
);
|
||||
const commandNeedsExpand = Boolean(
|
||||
reviewCommand
|
||||
&& (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD
|
||||
|| reviewCommand.includes('\n')),
|
||||
);
|
||||
|
||||
// Each review interaction re-arms the Catty idle window (capped by hard
|
||||
// deadline). Do not one-shot cancel — subsequent focus/scroll/key events
|
||||
// must keep extending idle while the user is still deciding.
|
||||
const markReviewing = useCallback(() => {
|
||||
if (!isPendingApproval || !approvalId) return;
|
||||
cancelApprovalTimeout(approvalId);
|
||||
}, [approvalId, isPendingApproval]);
|
||||
|
||||
const handleApproveOnce = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
@@ -192,28 +379,68 @@ export const ToolCall = ({
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending)
|
||||
const handleCopyCommand = useCallback(async () => {
|
||||
if (!reviewCommand) return;
|
||||
markReviewing();
|
||||
const ok = await copyTextToClipboard(reviewCommand);
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}, [markReviewing, reviewCommand]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending).
|
||||
// Ignore Enter from nested controls (Copy / Expand / action buttons) so it
|
||||
// activates that control instead of approving the pending command.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleApproveOnce(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject]);
|
||||
if (e.key === 'Enter') {
|
||||
if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleApproveOnce();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
} else {
|
||||
// Typing / navigation while reviewing cancels the idle auto-deny timer.
|
||||
markReviewing();
|
||||
}
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
// Auto-focus and auto-scroll when approval is pending.
|
||||
// Do not treat this programmatic expand/focus as user review (timeout stays armed).
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || !cardRef.current) return;
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
// Freeze the reviewable command for the life of this pending approval.
|
||||
// Do not reset review/timeout state when args identity churns while still pending.
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') setResponded(false);
|
||||
if (approvalStatus === 'pending') {
|
||||
setResponded(false);
|
||||
setCommandExpanded(false);
|
||||
setFrozenCommand(extractDisplayCommand(args));
|
||||
return;
|
||||
}
|
||||
setFrozenCommand(null);
|
||||
setCommandExpanded(false);
|
||||
// Intentionally depend only on approvalStatus so late arg patches cannot
|
||||
// replace the command the user is already reviewing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter
|
||||
}, [approvalStatus]);
|
||||
|
||||
// If the first pending paint had no command yet, accept the first non-empty one.
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || frozenCommand) return;
|
||||
const next = extractDisplayCommand(args);
|
||||
if (next) setFrozenCommand(next);
|
||||
}, [args, frozenCommand, isPendingApproval]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
@@ -236,46 +463,48 @@ export const ToolCall = ({
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
const headerCommand = reviewCommand ?? liveDisplayCommand;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
onPointerDownCapture={isPendingApproval ? markReviewing : undefined}
|
||||
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
onClick={() => {
|
||||
if (isPendingApproval) markReviewing();
|
||||
setExpanded((e) => !e);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{(() => {
|
||||
const displayCmd = extractDisplayCommand(args);
|
||||
if (displayCmd) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{displayCmd}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(displayCmd)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
|
||||
})()}
|
||||
{headerCommand ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{headerCommand}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(headerCommand)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
@@ -293,10 +522,103 @@ export const ToolCall = ({
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
{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">
|
||||
{t('ai.chat.targetLabel')}
|
||||
</span>
|
||||
{executionContext.sessionId && (
|
||||
<span className="font-mono truncate" title={executionContext.sessionId}>
|
||||
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.shell && (
|
||||
<span className="font-mono">
|
||||
{t('ai.chat.approvalShell')}: {executionContext.shell}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.cwd && (
|
||||
<span className="font-mono truncate" title={executionContext.cwd}>
|
||||
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.reason && (
|
||||
<span className="truncate" title={executionContext.reason}>
|
||||
{t('ai.chat.approvalReason')}: {executionContext.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.rawCommand')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{commandNeedsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
markReviewing();
|
||||
setCommandExpanded((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Keep Enter on this control; let Escape bubble to card reject.
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{commandExpanded ? t('ai.chat.collapse') : t('ai.chat.expand')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => { void handleCopyCommand(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Copy size={10} className="shrink-0" />
|
||||
{copied ? t('ai.chat.commandCopied') : t('ai.chat.copyCommand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-auto rounded-md border border-border/25 bg-muted/20 px-2.5 py-2',
|
||||
'text-[11px] font-mono leading-relaxed text-foreground/80',
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere]',
|
||||
)}
|
||||
style={{
|
||||
maxHeight: commandExpanded
|
||||
? APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX
|
||||
: APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX,
|
||||
}}
|
||||
onScroll={markReviewing}
|
||||
onWheel={markReviewing}
|
||||
>
|
||||
{reviewCommand}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">
|
||||
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
|
||||
</div>
|
||||
{/*
|
||||
Args-only approvals (Codex file-change/permissions, write tools with
|
||||
JSON args) have no command pre — wheel/trackpad scroll must re-arm
|
||||
idle the same way the command overflow block does.
|
||||
*/}
|
||||
<pre
|
||||
className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]"
|
||||
onScroll={isPendingApproval ? markReviewing : undefined}
|
||||
onWheel={isPendingApproval ? markReviewing : undefined}
|
||||
>
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -432,6 +432,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
args={request.args}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={approvalId}
|
||||
onApproveOnce={() => handleApproveOnce(approvalId)}
|
||||
onAlwaysAllow={request.allowSession === false
|
||||
? undefined
|
||||
@@ -463,6 +464,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isInterrupted={options.historical ? !isPending : undefined}
|
||||
isLoading={options.historical ? undefined : Boolean(options.isToolRunning && !isPending)}
|
||||
approvalStatus={approvalStatus}
|
||||
approvalId={isPending ? toolCall.id : undefined}
|
||||
onApproveOnce={() => handleApproveOnce(toolCall.id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(toolCall.id, pendingRequest ?? {
|
||||
toolCallId: toolCall.id,
|
||||
@@ -762,6 +764,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
@@ -781,6 +784,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={approvalId}
|
||||
onApproveOnce={() => handleApproveOnce(approvalId)}
|
||||
onAlwaysAllow={request.allowSession === false
|
||||
? undefined
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
|
||||
@@ -131,6 +131,13 @@ function registerAgentProcessHandlers(ctx) {
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Cancel MCP approval auto-deny after the user starts reviewing the card.
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-cancel-timeout", async (event, { approvalId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const cancelled = mcpServerBridge.cancelApprovalTimeoutFromRenderer?.(approvalId) === true;
|
||||
return { ok: true, cancelled };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -740,9 +740,30 @@ class CodexAppServerRuntime {
|
||||
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
|
||||
? Number(params.autoResolutionMs)
|
||||
: INTERACTION_TIMEOUT_MS;
|
||||
const timer = setTimeout(() => {
|
||||
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
|
||||
}, timeoutMs);
|
||||
// Hard ceiling from creation — review can cancel the idle timer but must
|
||||
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
|
||||
const absoluteExpiresAt = Date.now() + timeoutMs;
|
||||
const armTimer = (ms) => {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending) return;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
if (ms <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, ms);
|
||||
};
|
||||
this.pendingInteractions.set(interactionId, {
|
||||
interactionId,
|
||||
connection,
|
||||
@@ -750,8 +771,11 @@ class CodexAppServerRuntime {
|
||||
kind,
|
||||
params,
|
||||
context,
|
||||
timer,
|
||||
timer: null,
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
});
|
||||
armTimer(timeoutMs);
|
||||
|
||||
const payload = {
|
||||
interactionId,
|
||||
@@ -840,6 +864,37 @@ class CodexAppServerRuntime {
|
||||
return this.#resolveInteraction(interactionId, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
|
||||
* Re-arms the absolute creation deadline so a late approve cannot outlive the
|
||||
* original timeout window (matches Catty/MCP approval cancel semantics).
|
||||
*/
|
||||
cancelInteractionTimeout(interactionId, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending || pending.idleCancelled) return false;
|
||||
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
|
||||
pending.idleCancelled = true;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, remainingMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
#clearInteractionsForContext(context, decision) {
|
||||
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
|
||||
if (pending.context === context) {
|
||||
|
||||
@@ -311,6 +311,116 @@ test("runtime routes native approvals and request_user_input responses", async (
|
||||
await run;
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const realNow = Date.now;
|
||||
let now = 5_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
try {
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-cancel",
|
||||
chatSessionId: "chat-timeout-cancel",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 90,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "question-timeout",
|
||||
questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }],
|
||||
autoResolutionMs: 100,
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
|
||||
// Jump close to the absolute ceiling, then cancel idle. Remaining absolute ~30ms.
|
||||
now += 70;
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
false,
|
||||
"must stay pending before absolute expiry",
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
true,
|
||||
"absolute deadline must auto-reject after remaining time elapses",
|
||||
);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 90,
|
||||
result: { answers: {} },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout still allows explicit approve before absolute expiry", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-approve",
|
||||
chatSessionId: "chat-timeout-approve",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 91,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-approve",
|
||||
command: "echo ok",
|
||||
cwd: "/tmp",
|
||||
reason: "demo",
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.respondInteraction(interaction.interactionId, { decision: "once" }), true);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 91,
|
||||
result: { decision: "accept" },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime steers the active turn with text, local images, and a stable user message id", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
|
||||
@@ -1054,6 +1054,15 @@ function registerSdkStreamHandlers(ctx) {
|
||||
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex-app-server:interaction-cancel-timeout", async (event, payload) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const cancelled = codexAppServerRuntime.cancelInteractionTimeout(
|
||||
payload?.interactionId,
|
||||
event.sender,
|
||||
) === true;
|
||||
return { ok: true, cancelled };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex-app-server:status", async (event, payload) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, available: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
function createFakeWindow() {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = Math.floor(Math.random() * 1e6);
|
||||
contents.send = (channel, payload) => {
|
||||
contents.emit("send", channel, payload);
|
||||
};
|
||||
contents.isDestroyed = () => false;
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
webContents: contents,
|
||||
};
|
||||
}
|
||||
|
||||
function stubNow(t, startMs) {
|
||||
const realNow = Date.now;
|
||||
let now = startMs;
|
||||
Date.now = () => now;
|
||||
t.after(() => {
|
||||
Date.now = realNow;
|
||||
});
|
||||
return {
|
||||
advance: (deltaMs) => {
|
||||
now += deltaMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("cancelApprovalTimeoutFromRenderer keeps the absolute MCP deadline armed", async (t) => {
|
||||
const win = createFakeWindow();
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
const cleared = [];
|
||||
win.webContents.on("send", (channel, payload) => {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
|
||||
if (channel === "netcatty:ai:mcp:approval-cleared") {
|
||||
cleared.push(...(payload?.approvalIds || []));
|
||||
}
|
||||
});
|
||||
|
||||
const clock = stubNow(t, 1_000_000);
|
||||
const approvalPromise = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"terminal_execute",
|
||||
{ command: "echo hi" },
|
||||
"chat-mcp-absolute",
|
||||
);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
const approvalId = requests[0].approvalId;
|
||||
|
||||
// Jump close to the 110s absolute ceiling, then cancel idle. Remaining absolute ~40ms.
|
||||
clock.advance(109_960);
|
||||
assert.equal(mcpServerBridge.cancelApprovalTimeoutFromRenderer(approvalId), true);
|
||||
assert.equal(mcpServerBridge.cancelApprovalTimeoutFromRenderer(approvalId), false);
|
||||
|
||||
await delay(20);
|
||||
assert.equal(cleared.includes(approvalId), false, "must stay pending before absolute expiry");
|
||||
|
||||
const outcome = await Promise.race([
|
||||
approvalPromise.then((approved) => ({ approved })),
|
||||
delay(120).then(() => ({ approved: "timeout-wait" })),
|
||||
]);
|
||||
assert.deepEqual(outcome, { approved: false });
|
||||
assert.ok(cleared.includes(approvalId));
|
||||
});
|
||||
|
||||
test("cancelApprovalTimeoutFromRenderer still allows explicit approve before absolute expiry", async (t) => {
|
||||
const win = createFakeWindow();
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
win.webContents.on("send", (channel, payload) => {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
|
||||
});
|
||||
|
||||
stubNow(t, 2_000_000);
|
||||
const approvalPromise = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"sftp_write",
|
||||
{ path: "/tmp/x" },
|
||||
"chat-mcp-approve",
|
||||
);
|
||||
const approvalId = requests[0].approvalId;
|
||||
|
||||
assert.equal(mcpServerBridge.cancelApprovalTimeoutFromRenderer(approvalId), true);
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
});
|
||||
@@ -199,22 +199,49 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId) {
|
||||
return;
|
||||
}
|
||||
const approvalId = `mcp_approval_${++approvalIdCounter}_${Date.now()}`;
|
||||
// Hard ceiling from creation — must stay below external SDK tool-call
|
||||
// timeouts (~120s). Review can cancel the idle timer but not this bound.
|
||||
const absoluteExpiresAt = Date.now() + APPROVAL_TIMEOUT_MS;
|
||||
let timerId = null;
|
||||
|
||||
// Auto-deny after timeout so SDK/MCP tool calls don't hang indefinitely
|
||||
const timerId = setTimeout(() => {
|
||||
if (pendingApprovals.has(approvalId)) {
|
||||
pendingApprovals.delete(approvalId);
|
||||
resolve(false);
|
||||
// Notify renderer(s) to remove the stale approval card
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
const denyTimedOut = () => {
|
||||
if (!pendingApprovals.has(approvalId)) return;
|
||||
pendingApprovals.delete(approvalId);
|
||||
resolve(false);
|
||||
// Notify renderer(s) to remove the stale approval card
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerId != null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}, APPROVAL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const armTimer = (ms) => {
|
||||
clearTimer();
|
||||
if (ms <= 0) {
|
||||
denyTimedOut();
|
||||
return;
|
||||
}
|
||||
timerId = setTimeout(denyTimedOut, ms);
|
||||
};
|
||||
|
||||
// Auto-deny after timeout so SDK/MCP tool calls don't hang indefinitely.
|
||||
// Idle engagement cancels this arm and re-arms the absolute remainder
|
||||
// (never auto-approves; never extends past absoluteExpiresAt).
|
||||
armTimer(APPROVAL_TIMEOUT_MS);
|
||||
|
||||
pendingApprovals.set(approvalId, {
|
||||
resolve: (approved) => {
|
||||
clearTimeout(timerId);
|
||||
clearTimer();
|
||||
resolve(approved);
|
||||
},
|
||||
clearTimer,
|
||||
armAbsoluteTimeout: () => armTimer(absoluteExpiresAt - Date.now()),
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
chatSessionId: chatSessionId || null,
|
||||
});
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-request', {
|
||||
@@ -237,6 +264,20 @@ function resolveApprovalFromRenderer(approvalId, approved) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the idle auto-deny timer after the user starts reviewing an approval card.
|
||||
* Re-arms the absolute creation deadline so a late approve cannot outlive the
|
||||
* external agent's tool-call timeout window.
|
||||
*/
|
||||
function cancelApprovalTimeoutFromRenderer(approvalId) {
|
||||
const entry = pendingApprovals.get(approvalId);
|
||||
if (!entry || entry.idleCancelled) return false;
|
||||
entry.idleCancelled = true;
|
||||
entry.clearTimer?.();
|
||||
entry.armAbsoluteTimeout?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function notifyRendererApprovalCleared(approvalIds) {
|
||||
if (!Array.isArray(approvalIds) || approvalIds.length === 0) return;
|
||||
broadcastApprovalEvent("netcatty:ai:mcp:approval-cleared", { approvalIds });
|
||||
@@ -1999,7 +2040,9 @@ module.exports = {
|
||||
revokeExternalMcpAuthToken,
|
||||
getExternalMcpAuthToken,
|
||||
syncLiveSessionsToExternalScope,
|
||||
requestApprovalFromRenderer,
|
||||
resolveApprovalFromRenderer,
|
||||
cancelApprovalTimeoutFromRenderer,
|
||||
clearPendingApprovals,
|
||||
reserveSessionExecution,
|
||||
releaseSessionExecution,
|
||||
|
||||
@@ -1558,6 +1558,9 @@ function createPreloadApi(ctx) {
|
||||
respondMcpApproval: async (approvalId, approved) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
|
||||
},
|
||||
cancelMcpApprovalTimeout: async (approvalId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-cancel-timeout", { approvalId });
|
||||
},
|
||||
// MCP approval cleared: main process timed out or cancelled an approval
|
||||
onMcpApprovalCleared: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
@@ -1633,6 +1636,9 @@ function createPreloadApi(ctx) {
|
||||
respondCodexAppServerInteraction: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex-app-server:interaction-response", payload);
|
||||
},
|
||||
cancelCodexAppServerInteractionTimeout: async (interactionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex-app-server:interaction-cancel-timeout", { interactionId });
|
||||
},
|
||||
aiSdkAgentCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sdk-agent:cancel", { requestId, chatSessionId });
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildCattyStreamTimeouts } from './streamTimeouts';
|
||||
import { CATTY_APPROVAL_TIMEOUT_MS } from '../shared/approvalConstants';
|
||||
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
|
||||
|
||||
describe('buildCattyStreamTimeouts', () => {
|
||||
it('keeps stream budgets from undercutting the configured command timeout', () => {
|
||||
@@ -16,9 +16,9 @@ describe('buildCattyStreamTimeouts', () => {
|
||||
assert.ok(timeouts.totalMs > oneDayMs);
|
||||
});
|
||||
|
||||
it('includes confirm-mode approval time in long command stream budgets', () => {
|
||||
it('includes confirm-mode hard approval deadline in long command stream budgets', () => {
|
||||
const commandTimeoutMs = 60 * 1000;
|
||||
const expectedMinimum = commandTimeoutMs + CATTY_APPROVAL_TIMEOUT_MS + (90 * 1000);
|
||||
const expectedMinimum = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
permissionMode: 'confirm',
|
||||
commandTimeoutMs,
|
||||
@@ -32,7 +32,7 @@ describe('buildCattyStreamTimeouts', () => {
|
||||
|
||||
it('scales the total stream budget for multi-step long command turns', () => {
|
||||
const commandTimeoutMs = 10 * 60 * 1000;
|
||||
const singleStepBudgetMs = commandTimeoutMs + CATTY_APPROVAL_TIMEOUT_MS + (90 * 1000);
|
||||
const singleStepBudgetMs = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
permissionMode: 'confirm',
|
||||
commandTimeoutMs,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import { CATTY_APPROVAL_TIMEOUT_MS } from '../shared/approvalConstants';
|
||||
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
|
||||
|
||||
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
@@ -18,7 +18,8 @@ export interface BuildCattyStreamTimeoutsInput {
|
||||
export function buildCattyStreamTimeouts(
|
||||
input: BuildCattyStreamTimeoutsInput = {},
|
||||
) {
|
||||
const approvalBudgetMs = input.permissionMode === 'confirm' ? CATTY_APPROVAL_TIMEOUT_MS : 0;
|
||||
// Budget the hard approval deadline so a mid-review re-arm is not cut off by toolMs.
|
||||
const approvalBudgetMs = input.permissionMode === 'confirm' ? CATTY_APPROVAL_HARD_DEADLINE_MS : 0;
|
||||
const stepCount =
|
||||
Number.isFinite(input.maxIterations) && input.maxIterations != null && input.maxIterations > 0
|
||||
? Math.max(1, Math.floor(input.maxIterations))
|
||||
@@ -33,7 +34,7 @@ export function buildCattyStreamTimeouts(
|
||||
totalMs,
|
||||
stepMs: Math.max(TEN_MINUTES_MS, commandTimeoutBudgetMs),
|
||||
chunkMs: Math.max(TWO_MINUTES_MS, commandTimeoutBudgetMs),
|
||||
toolMs: Math.max(CATTY_APPROVAL_TIMEOUT_MS + NINETY_SECONDS_MS, commandTimeoutBudgetMs),
|
||||
toolMs: Math.max(CATTY_APPROVAL_HARD_DEADLINE_MS + NINETY_SECONDS_MS, commandTimeoutBudgetMs),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
/** Renderer-side Catty tool approval timeout (5 minutes). */
|
||||
/** Renderer-side Catty tool approval idle timeout (5 minutes). */
|
||||
export const CATTY_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Hard ceiling from approval creation. Review activity re-arms the idle timer
|
||||
* but never past this bound (3× idle for the default 5m → 15m).
|
||||
*/
|
||||
export const CATTY_APPROVAL_HARD_DEADLINE_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Absolute upper bound for any Catty approval hard deadline (30 minutes). */
|
||||
export const CATTY_APPROVAL_HARD_DEADLINE_MAX_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* MCP / external SDK approval timeout aligned with Codex MCP limits (~110s).
|
||||
* Kept separate from Catty because external agents block on main-process IPC.
|
||||
*/
|
||||
export const MCP_APPROVAL_TIMEOUT_MS = 110 * 1000;
|
||||
|
||||
/**
|
||||
* Resolve idle vs hard deadline for a Catty approval request.
|
||||
* hardDeadlineMs is always >= idleMs and capped by the 30m global max when
|
||||
* the 3× multiplier would exceed it (unless idle itself is larger).
|
||||
*/
|
||||
export function resolveCattyApprovalDeadlines(timeoutMs: number = CATTY_APPROVAL_TIMEOUT_MS): {
|
||||
idleMs: number;
|
||||
hardDeadlineMs: number;
|
||||
} {
|
||||
const idleMs = Math.max(0, Number.isFinite(timeoutMs) ? timeoutMs : CATTY_APPROVAL_TIMEOUT_MS);
|
||||
const scaled = idleMs * 3;
|
||||
const hardDeadlineMs = Math.max(
|
||||
idleMs,
|
||||
Math.min(scaled, CATTY_APPROVAL_HARD_DEADLINE_MAX_MS),
|
||||
);
|
||||
return { idleMs, hardDeadlineMs };
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import {
|
||||
cancelApprovalTimeout,
|
||||
clearAllPendingApprovals,
|
||||
onApprovalCleared,
|
||||
requestApproval,
|
||||
resolveApproval,
|
||||
} from './approvalGate';
|
||||
import { resolveCattyApprovalDeadlines } from './approvalConstants';
|
||||
|
||||
function stubNow(startMs: number): { advance: (deltaMs: number) => void; restore: () => void } {
|
||||
const realNow = Date.now;
|
||||
let now = startMs;
|
||||
Date.now = () => now;
|
||||
return {
|
||||
advance: (deltaMs: number) => {
|
||||
now += deltaMs;
|
||||
},
|
||||
restore: () => {
|
||||
Date.now = realNow;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveCattyApprovalDeadlines keeps hard deadline >= idle and 3x by default', () => {
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(100), { idleMs: 100, hardDeadlineMs: 300 });
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(5 * 60 * 1000), {
|
||||
idleMs: 5 * 60 * 1000,
|
||||
hardDeadlineMs: 15 * 60 * 1000,
|
||||
});
|
||||
// Cap at 30m when 3× would exceed, unless idle itself is larger.
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(20 * 60 * 1000), {
|
||||
idleMs: 20 * 60 * 1000,
|
||||
hardDeadlineMs: 30 * 60 * 1000,
|
||||
});
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(40 * 60 * 1000), {
|
||||
idleMs: 40 * 60 * 1000,
|
||||
hardDeadlineMs: 40 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout re-arms a fresh idle window, not the original absolute remainder', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(1_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-idle-rearm-${Date.now()}`;
|
||||
// idle 100ms, hard 300ms
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
100,
|
||||
);
|
||||
|
||||
// Review near end of first idle window — previously this left ~30ms absolute
|
||||
// remainder and denied while the user was still deciding.
|
||||
clock.advance(70);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Fresh idle (100ms) should keep the approval pending well past the old
|
||||
// absolute mark at t=100.
|
||||
await delay(50);
|
||||
assert.equal(cleared.includes(toolCallId), false, 'must stay pending through re-armed idle');
|
||||
|
||||
// Still before hard deadline (300): jump clock so remaining hard is short.
|
||||
clock.advance(220); // now = start+290; remaining hard = 10ms; re-arm idle capped to 10
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
const outcome = await Promise.race([
|
||||
approvalPromise.then((approved) => ({ approved })),
|
||||
delay(120).then(() => ({ approved: 'timeout-wait' as const })),
|
||||
]);
|
||||
assert.deepEqual(outcome, { approved: false });
|
||||
assert.ok(cleared.includes(toolCallId));
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout survives past the idle deadline while reviewing', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(3_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-past-idle-${Date.now()}`;
|
||||
const idleMs = 80;
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
idleMs,
|
||||
);
|
||||
|
||||
// Review near end of first idle — re-arms a full idleMs from now (hard = 3x).
|
||||
clock.advance(idleMs - 20);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Wall-clock past the original idle mark; re-armed idle still has ~idleMs left.
|
||||
await delay(40);
|
||||
assert.equal(cleared.includes(toolCallId), false, 'active review must outlive original idle');
|
||||
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout still allows explicit approve before hard Catty deadline', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const clock = stubNow(2_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-approve-${Date.now()}`;
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'sftp_write',
|
||||
{ path: '/tmp/x' },
|
||||
'chat-1',
|
||||
200,
|
||||
);
|
||||
|
||||
clock.advance(150);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
} finally {
|
||||
clock.restore();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout rejects expired Catty approvals synchronously after hard deadline', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(4_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-hard-sync-${Date.now()}`;
|
||||
// idle 50ms → hard 150ms
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
50,
|
||||
);
|
||||
|
||||
clock.advance(160); // past hard deadline
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Must already be denied — no setTimeout(0) race window for approve.
|
||||
assert.ok(cleared.includes(toolCallId), 'must clear synchronously when hard deadline elapsed');
|
||||
assert.equal(await approvalPromise, false);
|
||||
|
||||
// Late approve after hard-deadline deny must not resurrect the request.
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, false);
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('repeated cancelApprovalTimeout re-arms idle on each review interaction', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(5_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-multi-rearm-${Date.now()}`;
|
||||
const idleMs = 100;
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
idleMs,
|
||||
);
|
||||
|
||||
// Simulate successive review events (focus, scroll, key) — each re-arms.
|
||||
clock.advance(60);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
clock.advance(60);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
clock.advance(60);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Still before hard deadline (300ms); original idle marks have long passed.
|
||||
await delay(20);
|
||||
assert.equal(cleared.includes(toolCallId), false, 'each re-arm must keep approval pending');
|
||||
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('idle approval timeout still auto-denies when the user never reviews', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
|
||||
const toolCallId = `timeout-fire-${Date.now()}`;
|
||||
const approved = await requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
30,
|
||||
);
|
||||
|
||||
assert.equal(approved, false);
|
||||
assert.ok(cleared.includes(toolCallId));
|
||||
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout asks main to drop Codex App Server interaction timers', () => {
|
||||
clearAllPendingApprovals();
|
||||
const calls: string[] = [];
|
||||
const previous = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
cancelCodexAppServerInteractionTimeout: async (id: string) => {
|
||||
calls.push(id);
|
||||
return { ok: true, cancelled: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const toolCallId = `codex_interaction_1_${Date.now()}`;
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
assert.deepEqual(calls, [toolCallId]);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previous;
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,10 @@
|
||||
* interference when stopping or cancelling sessions.
|
||||
*/
|
||||
|
||||
import { CATTY_APPROVAL_TIMEOUT_MS } from './approvalConstants';
|
||||
import {
|
||||
CATTY_APPROVAL_TIMEOUT_MS,
|
||||
resolveCattyApprovalDeadlines,
|
||||
} from './approvalConstants';
|
||||
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_PERMISSION_GRANTS } from '../../config/storageKeys';
|
||||
import { globalTraceStore } from '../harness/traceStore';
|
||||
@@ -96,6 +99,8 @@ export function registerGrantPersister(persister: GrantPersister): () => void {
|
||||
const pendingApprovals = new Map<string, {
|
||||
resolve: (resolution: ApprovalResolution) => void;
|
||||
request: ApprovalRequest;
|
||||
/** Clears the auto-deny timer without resolving the approval. */
|
||||
cancelTimeout?: () => void;
|
||||
}>();
|
||||
|
||||
// Subscribers for approval request events (UI listens here)
|
||||
@@ -163,8 +168,10 @@ function isGrantedByRules(request: ApprovalRequest): boolean {
|
||||
* Returns a Promise<boolean> that resolves to `true` (approved) or `false` (denied).
|
||||
* The UI is notified via the listener system to render approval buttons.
|
||||
*
|
||||
* If the user does not respond within `timeoutMs` (default 5 minutes), the
|
||||
* approval is auto-denied to prevent the session from hanging indefinitely.
|
||||
* Idle auto-deny uses `timeoutMs` (default 5 minutes). Review activity re-arms
|
||||
* a fresh idle window from *now*, but never past a hard deadline from creation
|
||||
* (default 3× idle, capped at 30 minutes) so late review is not cut off at the
|
||||
* original idle mark while still staying bounded.
|
||||
*/
|
||||
export function requestApproval(
|
||||
toolCallId: string,
|
||||
@@ -190,26 +197,61 @@ export function requestApproval(
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
const { idleMs, hardDeadlineMs } = resolveCattyApprovalDeadlines(timeoutMs);
|
||||
// Hard ceiling from creation — review re-arms idle from now for idleMs
|
||||
// but never past this bound.
|
||||
const hardDeadlineAt = Date.now() + hardDeadlineMs;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const wrappedResolve = (resolution: ApprovalResolution) => {
|
||||
if (timerId) { clearTimeout(timerId); timerId = null; }
|
||||
clearTimer();
|
||||
resolve(resolution.approved);
|
||||
};
|
||||
|
||||
pendingApprovals.set(toolCallId, { resolve: wrappedResolve, request });
|
||||
|
||||
// Auto-deny after timeout so the session doesn't hang indefinitely
|
||||
timerId = setTimeout(() => {
|
||||
if (pendingApprovals.has(toolCallId)) {
|
||||
pendingApprovals.delete(toolCallId);
|
||||
wrappedResolve({ approved: false, scope: 'once', cancelled: false });
|
||||
emitApprovalEvent('approval_resolved', request, { outcome: 'timeout' });
|
||||
// Notify UI to remove the stale card
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl([toolCallId]); } catch { /* ignore */ }
|
||||
}
|
||||
const denyTimedOut = () => {
|
||||
const entry = pendingApprovals.get(toolCallId);
|
||||
if (!entry) return;
|
||||
pendingApprovals.delete(toolCallId);
|
||||
wrappedResolve({ approved: false, scope: 'once', cancelled: false });
|
||||
emitApprovalEvent('approval_resolved', request, { outcome: 'timeout' });
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl([toolCallId]); } catch { /* ignore */ }
|
||||
}
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
const armTimer = (ms: number) => {
|
||||
clearTimer();
|
||||
if (ms <= 0) {
|
||||
// Hard deadline already elapsed — reject synchronously so a late
|
||||
// approve cannot race a deferred setTimeout(0) deny.
|
||||
denyTimedOut();
|
||||
return;
|
||||
}
|
||||
timerId = setTimeout(denyTimedOut, ms);
|
||||
};
|
||||
|
||||
// Initial arm is the idle window. Review re-arms idle (capped by hard deadline).
|
||||
armTimer(idleMs);
|
||||
|
||||
pendingApprovals.set(toolCallId, {
|
||||
resolve: wrappedResolve,
|
||||
request,
|
||||
cancelTimeout: () => {
|
||||
const remainingHard = hardDeadlineAt - Date.now();
|
||||
if (remainingHard <= 0) {
|
||||
armTimer(0);
|
||||
return;
|
||||
}
|
||||
// Re-arm a full idle window from now, never past the hard deadline.
|
||||
armTimer(Math.min(idleMs, remainingHard));
|
||||
},
|
||||
});
|
||||
|
||||
// Notify all UI listeners
|
||||
for (const listener of listeners) {
|
||||
@@ -218,6 +260,32 @@ export function requestApproval(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel / reset the idle auto-deny timer after the user starts reviewing or
|
||||
* interacting with the card. Catty local approvals re-arm idle from *now* for
|
||||
* a fresh idleMs window, never past the hard creation deadline.
|
||||
* Timeout never auto-approves.
|
||||
*/
|
||||
export function cancelApprovalTimeout(toolCallId: string): void {
|
||||
const entry = pendingApprovals.get(toolCallId);
|
||||
// Keep cancelTimeout registered so further review activity can re-arm idle.
|
||||
entry?.cancelTimeout?.();
|
||||
|
||||
// MCP / Codex App Server approvals are timed in the main process.
|
||||
// MCP cancel drops idle but keeps the absolute creation deadline.
|
||||
if (toolCallId.startsWith('mcp_approval_')) {
|
||||
const bridge = (window as unknown as {
|
||||
netcatty?: { cancelMcpApprovalTimeout?: (id: string) => Promise<unknown> };
|
||||
}).netcatty;
|
||||
void bridge?.cancelMcpApprovalTimeout?.(toolCallId);
|
||||
} else if (toolCallId.startsWith('codex_interaction_')) {
|
||||
const bridge = (window as unknown as {
|
||||
netcatty?: { cancelCodexAppServerInteractionTimeout?: (id: string) => Promise<unknown> };
|
||||
}).netcatty;
|
||||
void bridge?.cancelCodexAppServerInteractionTimeout?.(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the UI when the user approves or rejects a tool execution.
|
||||
* Handles both SDK tool calls (local Promise) and MCP tool calls (IPC to main process).
|
||||
|
||||
Vendored
+1
@@ -179,6 +179,7 @@ declare global {
|
||||
onCodexAppServerInteractionRequest?(cb: (payload: Record<string, unknown>) => void): () => void;
|
||||
onCodexAppServerInteractionCleared?(cb: (payload: { interactionIds: string[]; chatSessionId?: string }) => void): () => void;
|
||||
respondCodexAppServerInteraction?(payload: Record<string, unknown>): Promise<{ ok: boolean; error?: string }>;
|
||||
cancelCodexAppServerInteractionTimeout?(interactionId: string): Promise<{ ok: boolean; cancelled?: boolean; error?: string }>;
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
|
||||
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
|
||||
aiMcpSyncPermissionGrants?(grants: Array<Record<string, unknown>>): Promise<{ ok: boolean; count?: number; error?: string }>;
|
||||
|
||||
Reference in new issue
Block a user