April 26, 2026

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.required WebSocket 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.response messages

3. Missing Components

ComponentStatusLocation
ApprovalModal componentNot implementedui/src/components/Approval/
Command parser/highlighterNot implementedui/src/lib/command-parser.ts
Execution context badgeNot implementedui/src/components/ExecutionBadge/
Countdown timer hookNot implementedui/src/hooks/useApprovalTimeout.ts
WebSocket action dispatchPartialui/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) => { state.pendingApprovals[action.payload.requestId] = action.payload; if (!state.activeModalRequestId) { state.activeModalRequestId = action.payload.requestId; } }, removePendingApproval: (state, action: PayloadAction) => { delete state.pendingApprovals[action.payload]; if (state.activeModalRequestId === action.payload) { const remaining = Object.keys(state.pendingApprovals); state.activeModalRequestId = remaining[0] || null; } }, setActiveModal: (state, action: PayloadAction<string | null>) => { state.activeModalRequestId = action.payload; }, }, });

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(0);

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 = ({ command }) => { const tokens = tokenize(command);

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 ( {/* existing routes */} ); };

πŸ§ͺ 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 TypeBadge ColorIcon
Elevated (sudo)Red/OrangeShield with checkmark
SandboxedGreenContainer icon
StandardGrayTerminal 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" and role="alertdialog"
  • Live region announcement for timeout warnings

Contextual Error References

Error CodeDescriptionRelated Component
EXEC_001Approval request timeoutGateway timeout handler
EXEC_002Invalid approval tokenWebSocket validation
EXEC_003Approval already processedState deduplication
EXEC_004User lacks approval permissionsRBAC enforcement
WS_101WebSocket connection lostConnection manager
WS_102Message queue overflowWebSocket buffer
UI_301Modal render failureApprovalModal component
UI_302Command parse errorCommandHighlighter
AUTH_401Session expired mid-approvalAuth 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

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.