Exec Approval Modal - Feature Implementation Guide
Comprehensive guide for implementing a Control UI modal dialog to replace the friction-heavy /approve code copy-paste workflow for exec command approvals.
π Symptoms
Current Friction Points in Exec Approval Workflow
The existing exec approval mechanism exhibits several usability issues that degrade the operator experience:
1. Cross-Context Code Copy Users must manually copy approval codes from the Control UI or notification and paste into a separate terminal or chat interface:
# Current flow requires this manual step:
[Control UI] Copy: /approve abc123-def456-ghi789
[Terminal] Paste and execute the approve command
2. No Visual Command Preview Operators cannot see the actual command that will execute before approving. The current interface shows only:
- The approval code
- A generic “command requires approval” message
- No syntax highlighting or formatting
3. Execution Context Ambiguity Users receive no clear indication of whether a command will run in:
- Elevated mode (sudo/admin privileges)
- Sandboxed mode (restricted environment)
- The specific workspace or container context
4. Approval Timeout Opacity The countdown mechanism exists server-side but is not surfaced to users, leading to confusion when approvals expire silently.
5. Windows-Specific Quirks On Windows environments, the copy-paste workflow conflicts with:
- PowerShell’s clipboard handling
- Command Prompt encoding differences
- Enterprise restriction policies that block clipboard operations
π§ Root Cause
Architectural Analysis
The current exec approval system was designed with a chat-first paradigm where approvals were expected to happen via the same interface that sent the approval request. This design decision created inherent friction for Control UI users.
1. Protocol Layer (Already Functional)
The gateway’s approval WebSocket protocol already supports all required semantics:
// Existing WebSocket message types (already implemented)
{
"type": "exec.approval.required",
"payload": {
"requestId": "req_abc123",
"command": "kubectl delete pod nginx --namespace production",
"executionContext": {
"mode": "elevated", // or "sandboxed"
"workspaceId": "ws_xyz789",
"userId": "user_123",
"sudo": true
},
"timeoutSeconds": 120,
"createdAt": "2024-01-15T10:30:00Z"
}
}
// Approval actions (already implemented)
{
"type": "exec.approval.response",
"payload": {
"requestId": "req_abc123",
"action": "allow_once" | "allow_always" | "deny",
"approvedBy": "user_123"
}
}
2. Frontend Gap Analysis
The Control UI currently:
- β
Receives the
exec.approval.requiredWebSocket message - β Does not render a modal component
- β Does not parse or syntax-highlight the command string
- β Does not display execution context metadata
- β Does not implement the countdown timer
- β Does not send
exec.approval.responsemessages
3. Missing Components
| Component | Status | Location |
|---|---|---|
ApprovalModal component | Not implemented | ui/src/components/Approval/ |
| Command parser/highlighter | Not implemented | ui/src/lib/command-parser.ts |
| Execution context badge | Not implemented | ui/src/components/ExecutionBadge/ |
| Countdown timer hook | Not implemented | ui/src/hooks/useApprovalTimeout.ts |
| WebSocket action dispatch | Partial | ui/src/store/approvalSlice.ts |
4. State Management Gap
The approval state is not persisted in the UI state management layer:
// Current missing state shape
interface ApprovalState {
pendingApprovals: Map;
activeModalId: string | null;
countdownTimers: Map;
}
// Required but missing selectors
selectPendingApprovals
selectActiveApproval
selectApprovalTimeRemaining
π οΈ Step-by-Step Fix
Implementation Guide for Approval Modal
Phase 1: State Management Foundation
Step 1.1: Define TypeScript interfaces
typescript // src/types/approval.ts export type ApprovalAction = ‘allow_once’ | ‘allow_always’ | ‘deny’; export type ExecutionMode = ’elevated’ | ‘sandboxed’;
export interface ExecutionContext { mode: ExecutionMode; workspaceId: string; userId: string; sudo: boolean; containerId?: string; environment?: Record<string, string>; }
export interface PendingApproval { requestId: string; command: string; context: ExecutionContext; timeoutSeconds: number; createdAt: string; expiresAt: string; }
export interface ApprovalResponse { requestId: string; action: ApprovalAction; approvedBy: string; timestamp: string; }
Step 1.2: Create Redux slice
typescript // src/store/approvalSlice.ts import { createSlice, PayloadAction } from ‘@reduxjs/toolkit’; import type { PendingApproval } from ‘../types/approval’;
interface ApprovalState { pendingApprovals: Record<string, PendingApproval>; activeModalRequestId: string | null; }
const initialState: ApprovalState = { pendingApprovals: {}, activeModalRequestId: null, };
const approvalSlice = createSlice({
name: ‘approval’,
initialState,
reducers: {
addPendingApproval: (state, action: PayloadAction
export const { addPendingApproval, removePendingApproval, setActiveModal } = approvalSlice.actions; export default approvalSlice.reducer;
Phase 2: WebSocket Integration
Step 2.1: Handle incoming approval messages
typescript // src/services/websocket/handlers.ts import { addPendingApproval, removePendingApproval } from ‘../../store/approvalSlice’;
export const handleExecApprovalRequired = (payload: PendingApproval, dispatch: AppDispatch) => { const expiresAt = new Date(); expiresAt.setSeconds(expiresAt.getSeconds() + payload.timeoutSeconds);
dispatch(addPendingApproval({ …payload, expiresAt: expiresAt.toISOString(), })); };
export const handleExecApprovalComplete = (requestId: string, dispatch: AppDispatch) => { dispatch(removePendingApproval(requestId)); };
export const handleExecApprovalTimeout = (requestId: string, dispatch: AppDispatch) => { dispatch(removePendingApproval(requestId)); // Emit timeout event to gateway websocketService.send({ type: ’exec.approval.timeout’, payload: { requestId }, }); };
Phase 3: Modal Component Implementation
Step 3.1: Create ApprovalModal component
tsx // src/components/Approval/ApprovalModal.tsx import React, { useEffect, useState } from ‘react’; import { useDispatch, useSelector } from ‘react-redux’; import { selectActiveApproval, selectApprovalTimeRemaining } from ‘../../store/selectors’; import { removePendingApproval, setActiveModal } from ‘../../store/approvalSlice’; import { sendApprovalResponse } from ‘../../services/websocket’; import CommandHighlighter from ‘../CommandHighlighter’; import ExecutionBadge from ‘../ExecutionBadge’; import type { ApprovalAction } from ‘../../types/approval’;
const ApprovalModal: React.FC = () => {
const dispatch = useDispatch();
const activeApproval = useSelector(selectActiveApproval);
const [timeRemaining, setTimeRemaining] = useState
useEffect(() => { if (!activeApproval) return;
const interval = setInterval(() => {
const remaining = Math.max(
0,
Math.floor((new Date(activeApproval.expiresAt).getTime() - Date.now()) / 1000)
);
setTimeRemaining(remaining);
if (remaining === 0) {
handleResponse('deny');
}
}, 1000);
return () => clearInterval(interval);
}, [activeApproval]);
const handleResponse = async (action: ApprovalAction) => { if (!activeApproval) return;
await sendApprovalResponse({
requestId: activeApproval.requestId,
action,
approvedBy: currentUserId,
timestamp: new Date().toISOString(),
});
dispatch(removePendingApproval(activeApproval.requestId));
};
if (!activeApproval) return null;
return (
Command Approval Required
<div className="approval-modal__command">
<CommandHighlighter command={activeApproval.command} />
</div>
<div className="approval-modal__context">
<dl>
<dt>Workspace</dt>
<dd>{activeApproval.context.workspaceId}</dd>
<dt>Timeout</dt>
<dd>{timeRemaining}s remaining</dd>
</dl>
</div>
<footer className="approval-modal__actions">
<button
className="btn btn--danger"
onClick={() => handleResponse('deny')}
>
Deny
</button>
<button
className="btn btn--secondary"
onClick={() => handleResponse('allow_once')}
>
Allow Once
</button>
<button
className="btn btn--primary"
onClick={() => handleResponse('allow_always')}
>
Allow Always
</button>
</footer>
</div>
</div>
); };
export default ApprovalModal;
Step 3.2: Implement CommandHighlighter
tsx // src/components/CommandHighlighter/CommandHighlighter.tsx import React from ‘react’; import { tokenize } from ‘../../lib/command-parser’;
interface Props { command: string; }
const CommandHighlighter: React.FC
return (
{tokens.map((token, index) => (
<span key={index} className={token token--${token.type}}>
{token.value}
))}
);
};// Token types: ‘command’, ‘flag’, ‘option’, ‘string’, ‘path’, ‘argument’, ‘punctuation’
Phase 4: CSS Styling
css /* src/components/Approval/ApprovalModal.css */
.approval-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.approval-modal { background: var(–surface-primary); border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); max-width: 640px; width: 90%; }
.approval-modal__header { display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; border-bottom: 1px solid var(–border-color); }
.approval-modal__command { padding: 24px; background: var(–surface-secondary); }
.command-highlight { font-family: ‘JetBrains Mono’, ‘Fira Code’, monospace; font-size: 14px; line-height: 1.6; margin: 0; }
.token–command { color: var(–color-command); } .token–flag { color: var(–color-flag); } .token–option { color: var(–color-option); } .token–string { color: var(–color-string); } .token–path { color: var(–color-path); } .token–argument { color: var(–color-argument); }
.approval-modal__actions { display: flex; gap: 12px; justify-content: flex-end; padding: 16px 24px; border-top: 1px solid var(–border-color); }
/* Execution badges */ .execution-badge–elevated { background: var(–color-danger-subtle); color: var(–color-danger); border: 1px solid var(–color-danger); }
.execution-badge–sandboxed { background: var(–color-success-subtle); color: var(–color-success); border: 1px solid var(–color-success); }
Phase 5: Integration
Step 5.1: Mount modal in app root
tsx // src/App.tsx import ApprovalModal from ‘./components/Approval/ApprovalModal’;
const App: React.FC = () => {
return (
π§ͺ Verification
Testing Checklist
After implementation, verify the following scenarios:
1. Modal Rendering
bash
Start the gateway with a test exec approval
curl -X POST http://localhost:8080/api/v1/exec/test-approval
-H “Content-Type: application/json”
-d ‘{“command”: “kubectl delete pod nginx”, “timeoutSeconds”: 60}’
Expected: Modal appears within 500ms of WebSocket message receipt
2. Syntax Highlighting Verification
The modal should correctly tokenize:
# Test commands to verify highlighting:
kubectl get pods -n production --watch
docker exec -it abc123 /bin/bash
find /var/log -name "*.log" -mtime +7 -exec rm {} \;
ssh user@host "sudo systemctl restart nginx"
3. Execution Context Display
| Context Type | Badge Color | Icon |
|---|---|---|
| Elevated (sudo) | Red/Orange | Shield with checkmark |
| Sandboxed | Green | Container icon |
| Standard | Gray | Terminal icon |
4. Timeout Behavior
javascript // In browser console: const modal = document.querySelector(’.approval-modal__context dd:last-child’); // Should show countdown: “45s remaining” // Should auto-dismiss at 0s with deny action
5. Approval Actions
bash
Verify WebSocket messages are sent correctly:
Allow Once
{ “type”: “exec.approval.response”, “payload”: { “requestId”: “req_abc123”, “action”: “allow_once”, “approvedBy”: “user_123”, “timestamp”: “2024-01-15T10:32:00Z” } }
6. Integration with Existing Systems
- β Modal does not block other UI interactions (click outside dismisses without action)
- β Multiple pending approvals queue correctly
- β User can switch between pending approvals
- β Mobile view renders correctly (< 768px)
β οΈ Common Pitfalls
Implementation Traps
1. WebSocket Reconnection Handling
The modal must gracefully handle WebSocket disconnections:
typescript // β Wrong: Modal remains stuck on reconnection useEffect(() => { // direct listener without cleanup websocket.on(’exec.approval.required’, handleApproval); }, []);
// β Correct: Cleanup and reconnect handling useEffect(() => { const unsubscribe = websocket.on(’exec.approval.required’, handleApproval); const handleReconnect = () => { // Refetch pending approvals from server dispatch(fetchPendingApprovals()); }; websocket.on(‘reconnect’, handleReconnect);
return () => { unsubscribe(); websocket.off(‘reconnect’, handleReconnect); }; }, [dispatch]);
2. Timeout Synchronization
Never rely solely on client-side timers:
typescript // β Wrong: Server time drift causes issues const localExpiry = createdAt + timeoutSeconds * 1000;
// β Correct: Server is source of truth // Compare against server’s expiresAt timestamp const serverExpiry = new Date(approval.expiresAt).getTime(); const remaining = Math.max(0, serverExpiry - Date.now());
3. Large Command Handling
Commands may exceed viewport width:
css /* β Required: Horizontal scroll for long commands */ .approval-modal__command { overflow-x: auto; max-height: 200px; }
/* β Required: Word-break for pathological cases */ .command-highlight { white-space: pre-wrap; word-break: break-all; }
4. Security Considerations
typescript // β Wrong: InnerHTML for command display (XSS vector) const CommandHighlighter: React.FC<{ command: string }> = ({ command }) => ( <span dangerouslySetInnerHTML={{ __html: highlight(command) }} /> );
// β
Correct: Token-based rendering
const CommandHighlighter: React.FC<{ command: string }> = ({ command }) => {
const tokens = tokenize(command);
return (
{tokens.map((token, i) => (
<span key={i} className={token token--${token.type}}>
{escapeHtml(token.value)}
))}
);
};
5. Multiple Monitor Scenarios
Modal may open on non-primary monitor:
typescript // Ensure modal appears in viewport const useModalPosition = () => { const [position, setPosition] = useState({ top: ‘50%’, left: ‘50%’ });
useEffect(() => { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const modalWidth = 640; const modalHeight = 400;
setPosition({
top: Math.min(viewportHeight / 2 - modalHeight / 2, 20) + 'px',
left: Math.min(viewportWidth / 2 - modalWidth / 2, 20) + 'px',
});
}, []);
return position; };
6. Accessibility (a11y)
The modal must be keyboard-navigable and screen-reader friendly:
- Focus trap within modal
- Escape key denies approval
- Tab navigation between buttons
aria-modal="true"androle="alertdialog"- Live region announcement for timeout warnings
π Related Errors
Contextual Error References
| Error Code | Description | Related Component |
|---|---|---|
EXEC_001 | Approval request timeout | Gateway timeout handler |
EXEC_002 | Invalid approval token | WebSocket validation |
EXEC_003 | Approval already processed | State deduplication |
EXEC_004 | User lacks approval permissions | RBAC enforcement |
WS_101 | WebSocket connection lost | Connection manager |
WS_102 | Message queue overflow | WebSocket buffer |
UI_301 | Modal render failure | ApprovalModal component |
UI_302 | Command parse error | CommandHighlighter |
AUTH_401 | Session expired mid-approval | Auth interceptor |
Historical Design Context
The current /approve code system originated from OpenClaw v1.2 as a security measure to separate the approval context from the requesting session. While cryptographically sound, the implementation prioritized security over UX, creating the friction this feature addresses.
Future Considerations
- Mobile companion app: Push notification integration for approvals on-the-go
- Biometric authentication: Face ID / fingerprint for high-privilege approvals
- Approval policies: Server-side rules that auto-approve known-safe commands
- Audit dashboard: Historical view of all approval decisions with search/filter