Compare commits

...
Author SHA1 Message Date
bincxz d379298871 fix(ai): re-arm approval idle on args-only scroll review
Pending approvals without a display command (Codex file-change/permissions,
write tools with JSON args) only show the overflow args block. Wire the same
onScroll/onWheel markReviewing path used by the command pre so continuous
wheel/trackpad review re-arms Catty and MCP/Codex idle timers.
2026-08-06 00:20:11 +08:00
bincxz c808632c0f fix(ai): re-arm Catty approval idle and sync-deny hard deadline
Review interactions kept a one-shot timeoutCancelledRef, so later
cancelApprovalTimeout calls no longer re-armed idle. Remove the one-shot
and deny synchronously when the hard deadline has already elapsed so a
late approve cannot race setTimeout(0).
2026-08-06 00:05:36 +08:00
bincxz 21659cfa03 fix(ai): separate Catty approval idle timer from hard deadline
Review activity re-arms a full idle window from now instead of the original
absolute remainder, capped by a hard deadline (3x idle, max 30m). Stream
tool budgets use the hard deadline so mid-review approvals are not cut off.
2026-08-05 23:52:46 +08:00
netcatty-bot 8518b56c55 fix: address Codex review on PR #2734 2026-08-05 15:43:21 +00:00
bincxz 1c1ee990b9 fix(ai): bound Codex approval cancel and unwrap CLI wrappers
cancelInteractionTimeout now re-arms the absolute creation deadline
(Catty/MCP style) instead of leaving review unbounded. Also treat
netcatty-tool-cli.cjs/.cmd as CLI wrappers so approval Command/Copy
shows the remote command after --.
2026-08-05 22:56:11 +08:00
bincxz 4f4cda1b6a fix(ai): bound Catty approval cancel with absolute deadline
Keep Catty confirm-mode approvals from going unbounded after review:
cancelApprovalTimeout re-arms the creation absolute deadline (MCP-style)
so a late approve cannot outlive the original timeout/stream tool budget.

Also harden pending-card Enter: extract isNestedInteractiveApprovalTarget
so Copy/Expand (and other nested controls) never Approve Once via bubble.
2026-08-05 22:36:41 +08:00
bincxzandCursor 30a55debcc fix(ai): surface Codex reason on pending approval cards
Keep agent justification visible in the execution-context strip and still
show extra invocation args such as commandActions beside the command block.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:34:10 +08:00
bincxzandCursor 5d56226877 fix(ai): keep wrapped CLI target context on approval cards
Parse --session from netcatty-tool-cli wrappers into execution context and
still show invocation args when the pending card displays an unwrapped command.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:28:57 +08:00
bincxz d88c10ce82 Merge branch 'main' into cursor/issue-2697-30974986074 2026-08-05 17:17:27 +08:00
bincxzandCursor 937b9b609e fix(ai): keep MCP absolute approval deadline and Escape reject
Canceling idle review no longer leaves MCP approvals unbounded past the
external agent tool-call window, and Enter-only key stopPropagation lets
Escape still reject from Copy/Expand controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 17:17:24 +08:00
netcatty-bot 355bf3da99 fix: address Codex review on PR #2734 2026-08-05 05:01:31 +00:00
netcatty-bot b5e12c23a6 fix(#2697): automated Cursor CLI fix 2026-08-05 04:39:59 +00:00
21 changed files with 1287 additions and 77 deletions

No files matched your search

+8
View File
@@ -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.',
+8
View File
@@ -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': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
+8
View File
@@ -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': '此命令已被安全策略拦截,无法执行。',
+8
View File
@@ -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': '此指令已被安全策略攔截,無法執行。',
+149
View File
@@ -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/,
);
});
+361 -39
View File
@@ -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>
+4
View File
@@ -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);
});
+52 -9
View File
@@ -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,
+6
View File
@@ -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,
+4 -3
View File
@@ -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),
};
}
+28 -1
View File
@@ -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();
}
});
+85 -17
View File
@@ -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).
+1
View File
@@ -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 }>;