mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-24 09:37:38 +00:00
feat: setup canUseTool for claude messages
This commit is contained in:
@@ -13,6 +13,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||||
|
// Used to mint unique approval request IDs when randomUUID is not available.
|
||||||
|
// This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
|
||||||
|
import crypto from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
@@ -20,6 +23,123 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
|
|||||||
|
|
||||||
// Session tracking: Map of session IDs to active query instances
|
// Session tracking: Map of session IDs to active query instances
|
||||||
const activeSessions = new Map();
|
const activeSessions = new Map();
|
||||||
|
// In-memory registry of pending tool approvals keyed by requestId.
|
||||||
|
// This does not persist approvals or share across processes; it exists so the
|
||||||
|
// SDK can pause tool execution while the UI decides what to do.
|
||||||
|
const pendingToolApprovals = new Map();
|
||||||
|
|
||||||
|
// Default approval timeout kept under the SDK's 60s control timeout.
|
||||||
|
// This does not change SDK limits; it only defines how long we wait for the UI,
|
||||||
|
// introduced to avoid hanging the run when no decision arrives.
|
||||||
|
const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
|
||||||
|
|
||||||
|
// Generate a stable request ID for UI approval flows.
|
||||||
|
// This does not encode tool details or get shown to users; it exists so the UI
|
||||||
|
// can respond to the correct pending request without collisions.
|
||||||
|
function createRequestId() {
|
||||||
|
if (typeof crypto.randomUUID === 'function') {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return crypto.randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for a UI approval decision, honoring SDK cancellation.
|
||||||
|
// This does not auto-approve or auto-deny; it only resolves with UI input,
|
||||||
|
// and it cleans up the pending map to avoid leaks, introduced to prevent
|
||||||
|
// replying after the SDK cancels the control request.
|
||||||
|
function waitForToolApproval(requestId, options = {}) {
|
||||||
|
const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finalize = (decision) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(decision);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
pendingToolApprovals.delete(requestId);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (signal && abortHandler) {
|
||||||
|
signal.removeEventListener('abort', abortHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timeout is local to this process; it does not override SDK timing.
|
||||||
|
// It exists to prevent the UI prompt from lingering indefinitely.
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
onCancel?.('timeout');
|
||||||
|
finalize(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const abortHandler = () => {
|
||||||
|
// If the SDK cancels the control request, stop waiting to avoid
|
||||||
|
// replying after the process is no longer ready for writes.
|
||||||
|
onCancel?.('cancelled');
|
||||||
|
finalize({ cancelled: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
onCancel?.('cancelled');
|
||||||
|
finalize({ cancelled: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingToolApprovals.set(requestId, (decision) => {
|
||||||
|
finalize(decision);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a pending approval. This does not validate the decision payload;
|
||||||
|
// validation and tool matching remain in canUseTool, which keeps this as a
|
||||||
|
// lightweight WebSocket -> SDK relay.
|
||||||
|
function resolveToolApproval(requestId, decision) {
|
||||||
|
const resolver = pendingToolApprovals.get(requestId);
|
||||||
|
if (resolver) {
|
||||||
|
resolver(decision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match stored permission entries against a tool + input combo.
|
||||||
|
// This only supports exact tool names and the Bash(command:*) shorthand
|
||||||
|
// used by the UI; it intentionally does not implement full glob semantics,
|
||||||
|
// introduced to stay consistent with the UI's "Allow rule" format.
|
||||||
|
function matchesToolPermission(entry, toolName, input) {
|
||||||
|
if (!entry || !toolName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry === toolName) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
|
||||||
|
if (toolName === 'Bash' && bashMatch) {
|
||||||
|
const allowedPrefix = bashMatch[1];
|
||||||
|
let command = '';
|
||||||
|
|
||||||
|
if (typeof input === 'string') {
|
||||||
|
command = input.trim();
|
||||||
|
} else if (input && typeof input === 'object' && typeof input.command === 'string') {
|
||||||
|
command = input.command.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return command.startsWith(allowedPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps CLI options to SDK-compatible options format
|
* Maps CLI options to SDK-compatible options format
|
||||||
@@ -52,30 +172,29 @@ function mapCliOptionsToSDK(options = {}) {
|
|||||||
if (settings.skipPermissions && permissionMode !== 'plan') {
|
if (settings.skipPermissions && permissionMode !== 'plan') {
|
||||||
// When skipping permissions, use bypassPermissions mode
|
// When skipping permissions, use bypassPermissions mode
|
||||||
sdkOptions.permissionMode = 'bypassPermissions';
|
sdkOptions.permissionMode = 'bypassPermissions';
|
||||||
} else {
|
}
|
||||||
// Map allowed tools
|
|
||||||
let allowedTools = [...(settings.allowedTools || [])];
|
|
||||||
|
|
||||||
// Add plan mode default tools
|
// Map allowed tools (always set to avoid implicit "allow all" defaults).
|
||||||
if (permissionMode === 'plan') {
|
// This does not grant permissions by itself; it just configures the SDK,
|
||||||
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
|
// introduced because leaving it undefined made the SDK treat it as "all tools allowed."
|
||||||
for (const tool of planModeTools) {
|
let allowedTools = [...(settings.allowedTools || [])];
|
||||||
if (!allowedTools.includes(tool)) {
|
|
||||||
allowedTools.push(tool);
|
// Add plan mode default tools
|
||||||
}
|
if (permissionMode === 'plan') {
|
||||||
|
const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
|
||||||
|
for (const tool of planModeTools) {
|
||||||
|
if (!allowedTools.includes(tool)) {
|
||||||
|
allowedTools.push(tool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedTools.length > 0) {
|
|
||||||
sdkOptions.allowedTools = allowedTools;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map disallowed tools
|
|
||||||
if (settings.disallowedTools && settings.disallowedTools.length > 0) {
|
|
||||||
sdkOptions.disallowedTools = settings.disallowedTools;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sdkOptions.allowedTools = allowedTools;
|
||||||
|
|
||||||
|
// Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
|
||||||
|
// This does not override allowlists; it only feeds the canUseTool gate.
|
||||||
|
sdkOptions.disallowedTools = settings.disallowedTools || [];
|
||||||
|
|
||||||
// Map model (default to sonnet)
|
// Map model (default to sonnet)
|
||||||
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
// Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
|
||||||
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
|
||||||
@@ -370,6 +489,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
|
|||||||
tempImagePaths = imageResult.tempImagePaths;
|
tempImagePaths = imageResult.tempImagePaths;
|
||||||
tempDir = imageResult.tempDir;
|
tempDir = imageResult.tempDir;
|
||||||
|
|
||||||
|
// Gate tool usage with explicit UI approval when not auto-approved.
|
||||||
|
// This does not render UI or persist permissions; it only bridges to the UI
|
||||||
|
// via WebSocket and waits for the response, introduced so tool calls pause
|
||||||
|
// instead of auto-running when the allowlist is empty.
|
||||||
|
sdkOptions.canUseTool = async (toolName, input, context) => {
|
||||||
|
if (sdkOptions.permissionMode === 'bypassPermissions') {
|
||||||
|
return { behavior: 'allow', updatedInput: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
|
||||||
|
matchesToolPermission(entry, toolName, input)
|
||||||
|
);
|
||||||
|
if (isDisallowed) {
|
||||||
|
return { behavior: 'deny', message: 'Tool disallowed by settings' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
|
||||||
|
matchesToolPermission(entry, toolName, input)
|
||||||
|
);
|
||||||
|
if (isAllowed) {
|
||||||
|
return { behavior: 'allow', updatedInput: input };
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = createRequestId();
|
||||||
|
ws.send({
|
||||||
|
type: 'claude-permission-request',
|
||||||
|
requestId,
|
||||||
|
toolName,
|
||||||
|
input,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
|
||||||
|
// This does not retry or resurface the prompt; it just reflects the cancellation.
|
||||||
|
const decision = await waitForToolApproval(requestId, {
|
||||||
|
signal: context?.signal,
|
||||||
|
onCancel: (reason) => {
|
||||||
|
ws.send({
|
||||||
|
type: 'claude-permission-cancelled',
|
||||||
|
requestId,
|
||||||
|
reason,
|
||||||
|
sessionId: capturedSessionId || sessionId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!decision) {
|
||||||
|
return { behavior: 'deny', message: 'Permission request timed out' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.cancelled) {
|
||||||
|
return { behavior: 'deny', message: 'Permission request cancelled' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decision.allow) {
|
||||||
|
// rememberEntry only updates this run's in-memory allowlist to prevent
|
||||||
|
// repeated prompts in the same session; persistence is handled by the UI.
|
||||||
|
if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
|
||||||
|
if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
|
||||||
|
sdkOptions.allowedTools.push(decision.rememberEntry);
|
||||||
|
}
|
||||||
|
if (Array.isArray(sdkOptions.disallowedTools)) {
|
||||||
|
sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
|
||||||
|
};
|
||||||
|
|
||||||
// Create SDK query instance
|
// Create SDK query instance
|
||||||
const queryInstance = query({
|
const queryInstance = query({
|
||||||
prompt: finalCommand,
|
prompt: finalCommand,
|
||||||
@@ -526,5 +715,6 @@ export {
|
|||||||
queryClaudeSDK,
|
queryClaudeSDK,
|
||||||
abortClaudeSDKSession,
|
abortClaudeSDKSession,
|
||||||
isClaudeSDKSessionActive,
|
isClaudeSDKSessionActive,
|
||||||
getActiveClaudeSDKSessions
|
getActiveClaudeSDKSessions,
|
||||||
|
resolveToolApproval
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
|
|||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
|
||||||
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
|
||||||
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
|
import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
|
||||||
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
|
||||||
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
|
||||||
import gitRoutes from './routes/git.js';
|
import gitRoutes from './routes/git.js';
|
||||||
@@ -804,6 +804,18 @@ function handleChatConnection(ws) {
|
|||||||
provider,
|
provider,
|
||||||
success
|
success
|
||||||
});
|
});
|
||||||
|
} else if (data.type === 'claude-permission-response') {
|
||||||
|
// Relay UI approval decisions back into the SDK control flow.
|
||||||
|
// This does not persist permissions; it only resolves the in-flight request,
|
||||||
|
// introduced so the SDK can resume once the user clicks Allow/Deny.
|
||||||
|
if (data.requestId) {
|
||||||
|
resolveToolApproval(data.requestId, {
|
||||||
|
allow: Boolean(data.allow),
|
||||||
|
updatedInput: data.updatedInput,
|
||||||
|
message: data.message,
|
||||||
|
rememberEntry: data.rememberEntry
|
||||||
|
});
|
||||||
|
}
|
||||||
} else if (data.type === 'cursor-abort') {
|
} else if (data.type === 'cursor-abort') {
|
||||||
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
console.log('[DEBUG] Abort Cursor session:', data.sessionId);
|
||||||
const success = abortCursorSession(data.sessionId);
|
const success = abortCursorSession(data.sessionId);
|
||||||
|
|||||||
@@ -295,6 +295,19 @@ function buildClaudeToolPermissionEntry(toolName, toolInput) {
|
|||||||
return `Bash(${tokens[0]}:*)`;
|
return `Bash(${tokens[0]}:*)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize tool inputs for display in the permission banner.
|
||||||
|
// This does not sanitize/redact secrets; it is strictly formatting so users
|
||||||
|
// can see the raw input that triggered the permission prompt.
|
||||||
|
function formatToolInputForDisplay(input) {
|
||||||
|
if (input === undefined || input === null) return '';
|
||||||
|
if (typeof input === 'string') return input;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(input, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getClaudePermissionSuggestion(message, provider) {
|
function getClaudePermissionSuggestion(message, provider) {
|
||||||
if (provider !== 'claude') return null;
|
if (provider !== 'claude') return null;
|
||||||
if (!message?.toolResult?.isError) return null;
|
if (!message?.toolResult?.isError) return null;
|
||||||
@@ -1839,6 +1852,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const MESSAGES_PER_PAGE = 20;
|
const MESSAGES_PER_PAGE = 20;
|
||||||
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
const [isSystemSessionChange, setIsSystemSessionChange] = useState(false);
|
||||||
const [permissionMode, setPermissionMode] = useState('default');
|
const [permissionMode, setPermissionMode] = useState('default');
|
||||||
|
// In-memory queue of tool permission prompts for the current UI view.
|
||||||
|
// These are not persisted and do not survive a page refresh; introduced so
|
||||||
|
// the UI can present pending approvals while the SDK waits.
|
||||||
|
const [pendingPermissionRequests, setPendingPermissionRequests] = useState([]);
|
||||||
const [attachedImages, setAttachedImages] = useState([]);
|
const [attachedImages, setAttachedImages] = useState([]);
|
||||||
const [uploadingImages, setUploadingImages] = useState(new Map());
|
const [uploadingImages, setUploadingImages] = useState(new Map());
|
||||||
const [imageErrors, setImageErrors] = useState(new Map());
|
const [imageErrors, setImageErrors] = useState(new Map());
|
||||||
@@ -1886,6 +1903,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
const [codexModel, setCodexModel] = useState(() => {
|
const [codexModel, setCodexModel] = useState(() => {
|
||||||
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
return localStorage.getItem('codex-model') || CODEX_MODELS.DEFAULT;
|
||||||
});
|
});
|
||||||
|
// Track provider transitions so we only clear approvals when provider truly changes.
|
||||||
|
// This does not sync with the backend; it just prevents UI prompts from disappearing.
|
||||||
|
const lastProviderRef = useRef(provider);
|
||||||
// Load permission mode for the current session
|
// Load permission mode for the current session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSession?.id) {
|
if (selectedSession?.id) {
|
||||||
@@ -1906,6 +1926,23 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}, [selectedSession]);
|
}, [selectedSession]);
|
||||||
|
|
||||||
|
// Clear pending permission prompts when switching providers; filter when switching sessions.
|
||||||
|
// This does not preserve prompts across provider changes; it exists to keep the
|
||||||
|
// Claude approval flow intact while preventing prompts from a different provider.
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastProviderRef.current !== provider) {
|
||||||
|
setPendingPermissionRequests([]);
|
||||||
|
lastProviderRef.current = provider;
|
||||||
|
}
|
||||||
|
}, [provider]);
|
||||||
|
|
||||||
|
// When the selected session changes, drop prompts that belong to other sessions.
|
||||||
|
// This does not attempt to migrate prompts across sessions; it only filters,
|
||||||
|
// introduced so the UI does not show approvals for a session the user is no longer viewing.
|
||||||
|
useEffect(() => {
|
||||||
|
setPendingPermissionRequests(prev => prev.filter(req => !req.sessionId || req.sessionId === selectedSession?.id));
|
||||||
|
}, [selectedSession?.id]);
|
||||||
|
|
||||||
// Load Cursor default model from config
|
// Load Cursor default model from config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (provider === 'cursor') {
|
if (provider === 'cursor') {
|
||||||
@@ -3165,6 +3202,13 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
if (onReplaceTemporarySession) {
|
if (onReplaceTemporarySession) {
|
||||||
onReplaceTemporarySession(latestMessage.sessionId);
|
onReplaceTemporarySession(latestMessage.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach the real session ID to any pending permission requests so they
|
||||||
|
// do not disappear during the "new-session -> real-session" transition.
|
||||||
|
// This does not create or auto-approve requests; it only keeps UI state aligned.
|
||||||
|
setPendingPermissionRequests(prev => prev.map(req => (
|
||||||
|
req.sessionId ? req : { ...req, sessionId: latestMessage.sessionId }
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -3395,6 +3439,55 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}]);
|
}]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'claude-permission-request': {
|
||||||
|
// Receive a tool approval request from the backend and surface it in the UI.
|
||||||
|
// This does not approve anything automatically; it only queues a prompt,
|
||||||
|
// introduced so the user can decide before the SDK continues.
|
||||||
|
if (provider !== 'claude' || !latestMessage.requestId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingPermissionRequests(prev => {
|
||||||
|
if (prev.some(req => req.requestId === latestMessage.requestId)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
requestId: latestMessage.requestId,
|
||||||
|
toolName: latestMessage.toolName || 'UnknownTool',
|
||||||
|
input: latestMessage.input,
|
||||||
|
context: latestMessage.context,
|
||||||
|
sessionId: latestMessage.sessionId || null,
|
||||||
|
receivedAt: new Date()
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep the session in a "waiting" state while approval is pending.
|
||||||
|
// This does not resume the run; it only updates the UI status so the
|
||||||
|
// user knows Claude is blocked on a decision.
|
||||||
|
setIsLoading(true);
|
||||||
|
setCanAbortSession(true);
|
||||||
|
setClaudeStatus({
|
||||||
|
text: 'Waiting for permission',
|
||||||
|
tokens: 0,
|
||||||
|
can_interrupt: true
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'claude-permission-cancelled': {
|
||||||
|
// Backend cancelled the approval (timeout or SDK cancel); remove the banner.
|
||||||
|
// We currently do not show a user-facing warning here; this is intentional
|
||||||
|
// to avoid noisy alerts when the SDK cancels in the background.
|
||||||
|
if (!latestMessage.requestId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
setPendingPermissionRequests(prev => prev.filter(req => req.requestId !== latestMessage.requestId));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'claude-error':
|
case 'claude-error':
|
||||||
setChatMessages(prev => [...prev, {
|
setChatMessages(prev => [...prev, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -3591,6 +3684,9 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
if (selectedProject && latestMessage.exitCode === 0) {
|
if (selectedProject && latestMessage.exitCode === 0) {
|
||||||
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
safeLocalStorage.removeItem(`chat_messages_${selectedProject.name}`);
|
||||||
}
|
}
|
||||||
|
// Conversation finished; clear any stale permission prompts.
|
||||||
|
// This does not remove saved permissions; it only resets transient UI state.
|
||||||
|
setPendingPermissionRequests([]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'codex-response':
|
case 'codex-response':
|
||||||
@@ -3766,6 +3862,10 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abort ends the run; clear permission prompts to avoid dangling UI state.
|
||||||
|
// This does not change allowlists; it only clears the current banner.
|
||||||
|
setPendingPermissionRequests([]);
|
||||||
|
|
||||||
setChatMessages(prev => [...prev, {
|
setChatMessages(prev => [...prev, {
|
||||||
type: 'assistant',
|
type: 'assistant',
|
||||||
content: 'Session interrupted by user.',
|
content: 'Session interrupted by user.',
|
||||||
@@ -4291,6 +4391,36 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
return grantClaudeToolPermission(suggestion.entry);
|
return grantClaudeToolPermission(suggestion.entry);
|
||||||
}, [provider]);
|
}, [provider]);
|
||||||
|
|
||||||
|
// Send a UI decision back to the server (single or batched request IDs).
|
||||||
|
// This does not validate tool inputs or permissions; the backend enforces rules.
|
||||||
|
// It exists so "Allow & remember" can resolve multiple queued prompts at once.
|
||||||
|
const handlePermissionDecision = useCallback((requestIds, decision) => {
|
||||||
|
const ids = Array.isArray(requestIds) ? requestIds : [requestIds];
|
||||||
|
const validIds = ids.filter(Boolean);
|
||||||
|
if (validIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
validIds.forEach((requestId) => {
|
||||||
|
sendMessage({
|
||||||
|
type: 'claude-permission-response',
|
||||||
|
requestId,
|
||||||
|
allow: Boolean(decision?.allow),
|
||||||
|
updatedInput: decision?.updatedInput,
|
||||||
|
message: decision?.message,
|
||||||
|
rememberEntry: decision?.rememberEntry
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setPendingPermissionRequests(prev => {
|
||||||
|
const next = prev.filter(req => !validIds.includes(req.requestId));
|
||||||
|
if (next.length === 0) {
|
||||||
|
setClaudeStatus(null);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [sendMessage]);
|
||||||
|
|
||||||
// Store handleSubmit in ref so handleCustomCommand can access it
|
// Store handleSubmit in ref so handleCustomCommand can access it
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleSubmitRef.current = handleSubmit;
|
handleSubmitRef.current = handleSubmit;
|
||||||
@@ -4929,6 +5059,101 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
|||||||
</div>
|
</div>
|
||||||
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
{/* Permission Mode Selector with scroll to bottom button - Above input, clickable for mobile */}
|
||||||
<div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3">
|
<div ref={inputContainerRef} className="max-w-4xl mx-auto mb-3">
|
||||||
|
{pendingPermissionRequests.length > 0 && (
|
||||||
|
// Permission banner for tool approvals. This renders the input, allows
|
||||||
|
// "allow once" or "allow & remember", and supports batching similar requests.
|
||||||
|
// It does not persist permissions by itself; persistence is handled by
|
||||||
|
// the existing localStorage-based settings helpers, introduced to surface
|
||||||
|
// approvals before tool execution resumes.
|
||||||
|
<div className="mb-3 space-y-2">
|
||||||
|
{pendingPermissionRequests.map((request) => {
|
||||||
|
const rawInput = formatToolInputForDisplay(request.input);
|
||||||
|
const permissionEntry = buildClaudeToolPermissionEntry(request.toolName, rawInput);
|
||||||
|
const settings = getClaudeSettings();
|
||||||
|
const alreadyAllowed = permissionEntry
|
||||||
|
? settings.allowedTools.includes(permissionEntry)
|
||||||
|
: false;
|
||||||
|
const rememberLabel = alreadyAllowed ? 'Allow (saved)' : 'Allow & remember';
|
||||||
|
// Group pending prompts that resolve to the same allow rule so
|
||||||
|
// a single "Allow & remember" can clear them in one click.
|
||||||
|
// This does not attempt fuzzy matching; it only batches identical rules.
|
||||||
|
const matchingRequestIds = permissionEntry
|
||||||
|
? pendingPermissionRequests
|
||||||
|
.filter(item => buildClaudeToolPermissionEntry(item.toolName, formatToolInputForDisplay(item.input)) === permissionEntry)
|
||||||
|
.map(item => item.requestId)
|
||||||
|
: [request.requestId];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={request.requestId}
|
||||||
|
className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-3 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||||
|
Permission required
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
Tool: <span className="font-mono">{request.toolName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{permissionEntry && (
|
||||||
|
<div className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
Allow rule: <span className="font-mono">{permissionEntry}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rawInput && (
|
||||||
|
<details className="mt-2">
|
||||||
|
<summary className="cursor-pointer text-xs text-amber-800 dark:text-amber-200 hover:text-amber-900 dark:hover:text-amber-100">
|
||||||
|
View tool input
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 max-h-40 overflow-auto rounded-md bg-white/80 dark:bg-gray-900/60 border border-amber-200/60 dark:border-amber-800/60 p-2 text-xs text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
|
||||||
|
{rawInput}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePermissionDecision(request.requestId, { allow: true })}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-amber-600 text-white text-xs font-medium px-3 py-1.5 hover:bg-amber-700 transition-colors"
|
||||||
|
>
|
||||||
|
Allow once
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (permissionEntry && !alreadyAllowed) {
|
||||||
|
handleGrantToolPermission({ entry: permissionEntry, toolName: request.toolName });
|
||||||
|
}
|
||||||
|
handlePermissionDecision(matchingRequestIds, { allow: true, rememberEntry: permissionEntry });
|
||||||
|
}}
|
||||||
|
className={`inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border transition-colors ${
|
||||||
|
permissionEntry
|
||||||
|
? 'border-amber-300 text-amber-800 hover:bg-amber-100 dark:border-amber-700 dark:text-amber-100 dark:hover:bg-amber-900/30'
|
||||||
|
: 'border-gray-300 text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
disabled={!permissionEntry}
|
||||||
|
>
|
||||||
|
{rememberLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePermissionDecision(request.requestId, { allow: false, message: 'User denied tool use' })}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md text-xs font-medium px-3 py-1.5 border border-red-300 text-red-700 hover:bg-red-50 dark:border-red-800 dark:text-red-200 dark:hover:bg-red-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
Deny
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs) {
|
export function cn(...inputs) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Add safeJsonParse here
|
||||||
|
//
|
||||||
Reference in New Issue
Block a user