/ src / components / agents / exec-config-panel.tsx
exec-config-panel.tsx
  1  'use client'
  2  
  3  import { useCallback, useEffect, useState } from 'react'
  4  import { api } from '@/lib/app/api-client'
  5  import type { ExecApprovalConfig, ExecApprovalSnapshot } from '@/types'
  6  
  7  interface Props {
  8    agentId: string
  9  }
 10  
 11  export function ExecConfigPanel({ agentId }: Props) {
 12    const [config, setConfig] = useState<ExecApprovalConfig>({ security: 'deny', askMode: 'off', patterns: [] })
 13    const [hash, setHash] = useState('')
 14    const [loading, setLoading] = useState(true)
 15    const [saving, setSaving] = useState(false)
 16    const [error, setError] = useState('')
 17    const [newPattern, setNewPattern] = useState('')
 18  
 19    const load = useCallback(async () => {
 20      setLoading(true)
 21      setError('')
 22      try {
 23        const snap = await api<ExecApprovalSnapshot>('GET', `/openclaw/exec-config?agentId=${agentId}`)
 24        setConfig(snap.file)
 25        setHash(snap.hash)
 26      } catch (err: unknown) {
 27        setError(err instanceof Error ? err.message : 'Failed to load')
 28      } finally {
 29        setLoading(false)
 30      }
 31    }, [agentId])
 32  
 33    useEffect(() => { load() }, [load])
 34  
 35    const save = async (patch: Partial<ExecApprovalConfig>) => {
 36      const updated = { ...config, ...patch }
 37      setConfig(updated)
 38      setSaving(true)
 39      setError('')
 40      try {
 41        const result = await api<{ ok: boolean; hash: string }>('PUT', '/openclaw/exec-config', {
 42          agentId,
 43          config: updated,
 44          baseHash: hash,
 45        })
 46        setHash(result.hash)
 47      } catch (err: unknown) {
 48        setError(err instanceof Error ? err.message : 'Save failed')
 49      } finally {
 50        setSaving(false)
 51      }
 52    }
 53  
 54    const addPattern = () => {
 55      const p = newPattern.trim()
 56      if (!p || config.patterns.includes(p)) return
 57      save({ patterns: [...config.patterns, p] })
 58      setNewPattern('')
 59    }
 60  
 61    const removePattern = (idx: number) => {
 62      save({ patterns: config.patterns.filter((_, i) => i !== idx) })
 63    }
 64  
 65    if (loading) return <div className="p-4 text-[13px] text-text-3/50">Loading exec config...</div>
 66  
 67    return (
 68      <div className="flex flex-col gap-4">
 69        {/* Security Level */}
 70        <div>
 71          <label className="block text-[11px] font-600 uppercase tracking-wider text-text-3/50 mb-2">Security Level</label>
 72          <select
 73            value={config.security}
 74            onChange={(e) => save({ security: e.target.value as ExecApprovalConfig['security'] })}
 75            disabled={saving}
 76            className="w-full px-3 py-2 rounded-[10px] border border-white/[0.06] bg-black/20 text-[13px] text-text outline-none"
 77          >
 78            <option value="deny">Deny (block all)</option>
 79            <option value="allowlist">Allowlist (matched patterns only)</option>
 80            <option value="full">Full (allow all)</option>
 81          </select>
 82        </div>
 83  
 84        {/* Ask Mode */}
 85        <div>
 86          <label className="block text-[11px] font-600 uppercase tracking-wider text-text-3/50 mb-2">Ask Mode</label>
 87          <select
 88            value={config.askMode}
 89            onChange={(e) => save({ askMode: e.target.value as ExecApprovalConfig['askMode'] })}
 90            disabled={saving}
 91            className="w-full px-3 py-2 rounded-[10px] border border-white/[0.06] bg-black/20 text-[13px] text-text outline-none"
 92          >
 93            <option value="off">Off</option>
 94            <option value="on-miss">On miss (ask when no pattern matches)</option>
 95            <option value="always">Always ask</option>
 96          </select>
 97        </div>
 98  
 99        {/* Patterns */}
100        {config.security === 'allowlist' && (
101          <div>
102            <label className="block text-[11px] font-600 uppercase tracking-wider text-text-3/50 mb-2">
103              Allowed Patterns
104            </label>
105            <div className="flex flex-col gap-1 mb-2">
106              {config.patterns.map((p, i) => (
107                <div key={i} className="flex items-center gap-2 py-1 px-2 rounded-[8px] bg-white/[0.02] border border-white/[0.04]">
108                  <span className="text-[12px] text-text font-mono truncate flex-1">{p}</span>
109                  <button
110                    onClick={() => removePattern(i)}
111                    disabled={saving}
112                    className="text-red-400/60 hover:text-red-400 text-[10px] bg-transparent border-none cursor-pointer"
113                  >
114                    Remove
115                  </button>
116                </div>
117              ))}
118              {!config.patterns.length && (
119                <span className="text-[12px] text-text-3/40">No patterns configured</span>
120              )}
121            </div>
122            <div className="flex gap-2">
123              <input
124                type="text"
125                value={newPattern}
126                onChange={(e) => setNewPattern(e.target.value)}
127                onKeyDown={(e) => e.key === 'Enter' && addPattern()}
128                placeholder="e.g. npm run *"
129                className="flex-1 px-3 py-1.5 rounded-[8px] border border-white/[0.06] bg-black/20 text-[12px] text-text font-mono outline-none placeholder:text-text-3/40"
130              />
131              <button
132                onClick={addPattern}
133                disabled={saving || !newPattern.trim()}
134                className="px-3 py-1.5 rounded-[8px] border-none bg-accent-bright text-white text-[11px] font-600 cursor-pointer disabled:opacity-30 transition-all"
135                style={{ fontFamily: 'inherit' }}
136              >
137                Add
138              </button>
139            </div>
140          </div>
141        )}
142  
143        {error && <p className="text-[12px] text-red-400">{error}</p>}
144        {saving && <p className="text-[11px] text-text-3/50">Saving...</p>}
145      </div>
146    )
147  }