feat: add Claude Agent SDK engine with multi-tenant support
Add @anthropic-ai/claude-agent-sdk as a third engine (pure additive, no changes to existing CLI/API engines). Includes full frontend admin page. Backend (agent-service): - ClaudeAgentSdkEngine: implements AgentEnginePort using SDK's query() API - ApprovalGate: L2 tool approval with configurable auto-approve timeout (default 120s) - TenantAgentConfig entity: per-tenant billing mode, encrypted API key, timeout, tool lists - AllowedToolsResolverService: RBAC-based tool whitelist (admin/operator/viewer) - TenantAgentConfigController: REST endpoints for admin config management - Default subscription billing (operator's Claude login, no API key needed) - Optional per-tenant API key with AES-256-GCM encryption Frontend (web-admin): - SDK Config page at /agent-config/sdk with billing, timeout, tool permissions - Sidebar navigation entry under Agent Config - React Query key for tenant SDK config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
67d5a13c0c
commit
c75ad27771
|
|
@ -0,0 +1,437 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
|
import { queryKeys } from '@/infrastructure/api/query-keys';
|
||||||
|
import { Save, Loader2, RotateCcw, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
|
/* ---------- types ---------- */
|
||||||
|
|
||||||
|
interface TenantAgentSdkConfig {
|
||||||
|
tenantId: string;
|
||||||
|
billingMode: 'subscription' | 'api_key';
|
||||||
|
approvalTimeoutSeconds: number;
|
||||||
|
toolWhitelist: string[];
|
||||||
|
toolBlacklist: string[];
|
||||||
|
hasApiKey: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateConfigPayload {
|
||||||
|
billingMode?: 'subscription' | 'api_key';
|
||||||
|
apiKey?: string;
|
||||||
|
approvalTimeoutSeconds?: number;
|
||||||
|
toolWhitelist?: string[];
|
||||||
|
toolBlacklist?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_TOOLS = [
|
||||||
|
{ name: 'Bash', description: 'Execute shell commands' },
|
||||||
|
{ name: 'Read', description: 'Read file contents' },
|
||||||
|
{ name: 'Write', description: 'Write / create files' },
|
||||||
|
{ name: 'Edit', description: 'Edit existing files' },
|
||||||
|
{ name: 'Glob', description: 'Search files by pattern' },
|
||||||
|
{ name: 'Grep', description: 'Search file contents' },
|
||||||
|
{ name: 'WebFetch', description: 'Fetch web content' },
|
||||||
|
{ name: 'WebSearch', description: 'Search the web' },
|
||||||
|
{ name: 'NotebookEdit', description: 'Edit Jupyter notebooks' },
|
||||||
|
{ name: 'Task', description: 'Launch sub-agent tasks' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ---------- page ---------- */
|
||||||
|
|
||||||
|
export default function AgentSdkConfigPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
/* ---- load existing config ---- */
|
||||||
|
const {
|
||||||
|
data: config,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: queryKeys.tenantSdkConfig.current(),
|
||||||
|
queryFn: () => apiClient<TenantAgentSdkConfig>('/api/v1/agent/tenant-config'),
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- local form state ---- */
|
||||||
|
const [billingMode, setBillingMode] = useState<'subscription' | 'api_key'>('subscription');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [showApiKey, setShowApiKey] = useState(false);
|
||||||
|
const [approvalTimeout, setApprovalTimeout] = useState(120);
|
||||||
|
const [toolWhitelist, setToolWhitelist] = useState<string[]>([]);
|
||||||
|
const [toolBlacklist, setToolBlacklist] = useState<string[]>([]);
|
||||||
|
|
||||||
|
/* ---- seed form when config loads ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) {
|
||||||
|
setBillingMode(config.billingMode);
|
||||||
|
setApprovalTimeout(config.approvalTimeoutSeconds);
|
||||||
|
setToolWhitelist(config.toolWhitelist);
|
||||||
|
setToolBlacklist(config.toolBlacklist);
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
/* ---- save mutation ---- */
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
|
const { mutate: saveConfig, isPending: isSaving } = useMutation({
|
||||||
|
mutationFn: (payload: UpdateConfigPayload) =>
|
||||||
|
apiClient<TenantAgentSdkConfig>('/api/v1/agent/tenant-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: payload,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenantSdkConfig.all });
|
||||||
|
setSaveSuccess(true);
|
||||||
|
setApiKey(''); // Clear API key input after save
|
||||||
|
setTimeout(() => setSaveSuccess(false), 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- remove API key mutation ---- */
|
||||||
|
const { mutate: removeApiKey, isPending: isRemoving } = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiClient<{ message: string }>('/api/v1/agent/tenant-config/api-key', {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.tenantSdkConfig.all });
|
||||||
|
setBillingMode('subscription');
|
||||||
|
setApiKey('');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- handlers ---- */
|
||||||
|
|
||||||
|
function handleWhitelistToggle(toolName: string) {
|
||||||
|
setToolWhitelist((prev) =>
|
||||||
|
prev.includes(toolName)
|
||||||
|
? prev.filter((t) => t !== toolName)
|
||||||
|
: [...prev, toolName],
|
||||||
|
);
|
||||||
|
// If adding to whitelist, remove from blacklist
|
||||||
|
setToolBlacklist((prev) => prev.filter((t) => t !== toolName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlacklistToggle(toolName: string) {
|
||||||
|
setToolBlacklist((prev) =>
|
||||||
|
prev.includes(toolName)
|
||||||
|
? prev.filter((t) => t !== toolName)
|
||||||
|
: [...prev, toolName],
|
||||||
|
);
|
||||||
|
// If adding to blacklist, remove from whitelist
|
||||||
|
setToolWhitelist((prev) => prev.filter((t) => t !== toolName));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setBillingMode('subscription');
|
||||||
|
setApiKey('');
|
||||||
|
setApprovalTimeout(120);
|
||||||
|
setToolWhitelist([]);
|
||||||
|
setToolBlacklist([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const payload: UpdateConfigPayload = {
|
||||||
|
billingMode,
|
||||||
|
approvalTimeoutSeconds: approvalTimeout,
|
||||||
|
toolWhitelist,
|
||||||
|
toolBlacklist,
|
||||||
|
};
|
||||||
|
// Only send API key if it was entered (not empty)
|
||||||
|
if (apiKey.trim()) {
|
||||||
|
payload.apiKey = apiKey.trim();
|
||||||
|
}
|
||||||
|
saveConfig(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- loading / error states ---- */
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Loading SDK configuration...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold mb-4">Agent SDK Configuration</h1>
|
||||||
|
<p className="text-sm text-destructive mb-4">
|
||||||
|
Failed to load configuration. Using defaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- render ---- */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">Agent SDK Configuration</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Configure Claude Agent SDK billing, approval flow, and tool permissions per tenant.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* ---- Billing Mode ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Billing Mode</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Choose how Claude Agent SDK usage is billed for this tenant.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="billingMode"
|
||||||
|
value="subscription"
|
||||||
|
checked={billingMode === 'subscription'}
|
||||||
|
onChange={() => setBillingMode('subscription')}
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">Subscription</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use operator's Claude login (no API key needed)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="billingMode"
|
||||||
|
value="api_key"
|
||||||
|
checked={billingMode === 'api_key'}
|
||||||
|
onChange={() => setBillingMode('api_key')}
|
||||||
|
className="h-4 w-4 accent-primary"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium">API Key</span>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Tenant's own Anthropic API key (token-based billing)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- API Key Input (shown when api_key mode is selected) ---- */}
|
||||||
|
{billingMode === 'api_key' && (
|
||||||
|
<div className="mt-4 p-4 rounded-md border bg-muted/30">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-sm font-medium">Anthropic API Key</label>
|
||||||
|
{config?.hasApiKey && (
|
||||||
|
<span className="text-xs text-green-600 dark:text-green-400">
|
||||||
|
Key configured
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<input
|
||||||
|
type={showApiKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={config?.hasApiKey ? 'Enter new key to replace existing' : 'sk-ant-...'}
|
||||||
|
className="w-full rounded-md border bg-background px-3 py-2 pr-10 text-sm font-mono
|
||||||
|
placeholder:text-muted-foreground focus:outline-none focus:ring-2
|
||||||
|
focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowApiKey(!showApiKey)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground
|
||||||
|
hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{config?.hasApiKey && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeApiKey()}
|
||||||
|
disabled={isRemoving}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-2 rounded-md border
|
||||||
|
text-sm text-destructive hover:bg-destructive/10
|
||||||
|
disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
API key is encrypted (AES-256-GCM) before storage. Never stored in plaintext.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- L2 Approval Timeout ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">L2 Approval Timeout</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
For high-risk commands (L2), how long to wait for manual approval before auto-approving.
|
||||||
|
Set to 0 to disable auto-approve (wait indefinitely).
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={600}
|
||||||
|
step={10}
|
||||||
|
value={approvalTimeout}
|
||||||
|
onChange={(e) => setApprovalTimeout(Number(e.target.value))}
|
||||||
|
className="flex-1 accent-primary"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={3600}
|
||||||
|
value={approvalTimeout}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = Math.max(0, Math.min(3600, Number(e.target.value) || 0));
|
||||||
|
setApprovalTimeout(v);
|
||||||
|
}}
|
||||||
|
className="w-20 rounded-md border bg-background px-3 py-1.5 text-sm text-center
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">sec</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{approvalTimeout === 0
|
||||||
|
? 'Auto-approve disabled — commands will wait for manual approval indefinitely.'
|
||||||
|
: `Commands auto-approved after ${approvalTimeout} seconds without response.`}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Tool Whitelist / Blacklist ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Tool Permissions</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Override tool access per tenant. Empty whitelist means use RBAC defaults.
|
||||||
|
Blacklisted tools are always denied regardless of role.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/50">
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Tool</th>
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Description</th>
|
||||||
|
<th className="text-center px-4 py-2 font-medium w-24">Whitelist</th>
|
||||||
|
<th className="text-center px-4 py-2 font-medium w-24">Blacklist</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{AVAILABLE_TOOLS.map((tool) => (
|
||||||
|
<tr
|
||||||
|
key={tool.name}
|
||||||
|
className="border-t hover:bg-accent/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{tool.name}</td>
|
||||||
|
<td className="px-4 py-2 text-muted-foreground text-xs">{tool.description}</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolWhitelist.includes(tool.name)}
|
||||||
|
onChange={() => handleWhitelistToggle(tool.name)}
|
||||||
|
className="h-4 w-4 accent-primary cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={toolBlacklist.includes(tool.name)}
|
||||||
|
onChange={() => handleBlacklistToggle(tool.name)}
|
||||||
|
className="h-4 w-4 accent-destructive cursor-pointer"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mt-3 text-xs text-muted-foreground">
|
||||||
|
<span>Whitelist: {toolWhitelist.length || 'none (use RBAC defaults)'}</span>
|
||||||
|
<span>Blacklist: {toolBlacklist.length || 'none'}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- RBAC Info ---- */}
|
||||||
|
<section className="bg-card rounded-lg border p-5">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">RBAC Tool Access (Reference)</h2>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Default tool access per role (applied when whitelist is empty).
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="font-medium w-20">Admin</span>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
All tools (Bash, Read, Write, Edit, Glob, Grep, WebFetch, WebSearch, NotebookEdit, Task)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="font-medium w-20">Operator</span>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
Bash, Read, Write, Edit, Glob, Grep
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="font-medium w-20">Viewer</span>
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
Read, Glob, Grep (read-only)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- Action buttons ---- */}
|
||||||
|
<div className="flex items-center gap-3 pb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-md
|
||||||
|
bg-primary text-primary-foreground text-sm font-medium
|
||||||
|
hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
transition-colors"
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{isSaving ? 'Saving...' : 'Save Configuration'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-md border
|
||||||
|
text-sm font-medium hover:bg-accent transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
Reset to Defaults
|
||||||
|
</button>
|
||||||
|
{saveSuccess && (
|
||||||
|
<span className="text-sm text-green-600 dark:text-green-400">
|
||||||
|
Configuration saved successfully.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -75,6 +75,10 @@ export const queryKeys = {
|
||||||
all: ['agent-config'] as const,
|
all: ['agent-config'] as const,
|
||||||
current: () => [...queryKeys.agentConfig.all, 'current'] as const,
|
current: () => [...queryKeys.agentConfig.all, 'current'] as const,
|
||||||
},
|
},
|
||||||
|
tenantSdkConfig: {
|
||||||
|
all: ['tenant-sdk-config'] as const,
|
||||||
|
current: () => [...queryKeys.tenantSdkConfig.all, 'current'] as const,
|
||||||
|
},
|
||||||
skills: {
|
skills: {
|
||||||
all: ['skills'] as const,
|
all: ['skills'] as const,
|
||||||
list: (params?: Record<string, string>) => [...queryKeys.skills.all, 'list', params] as const,
|
list: (params?: Record<string, string>) => [...queryKeys.skills.all, 'list', params] as const,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ const navItems: NavItem[] = [
|
||||||
href: '/agent-config',
|
href: '/agent-config',
|
||||||
children: [
|
children: [
|
||||||
{ label: 'Engine & Prompt', href: '/agent-config' },
|
{ label: 'Engine & Prompt', href: '/agent-config' },
|
||||||
|
{ label: 'SDK Config', href: '/agent-config/sdk' },
|
||||||
{ label: 'Skills', href: '/agent-config/skills' },
|
{ label: 'Skills', href: '/agent-config/skills' },
|
||||||
{ label: 'Hooks', href: '/agent-config/hooks' },
|
{ label: 'Hooks', href: '/agent-config/hooks' },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@nestjs/platform-socket.io": "^10.3.0",
|
"@nestjs/platform-socket.io": "^10.3.0",
|
||||||
"socket.io": "^4.7.0",
|
"socket.io": "^4.7.0",
|
||||||
"@anthropic-ai/sdk": "^0.32.0",
|
"@anthropic-ai/sdk": "^0.32.0",
|
||||||
|
"@anthropic-ai/claude-agent-sdk": "^0.2.49",
|
||||||
"typeorm": "^0.3.20",
|
"typeorm": "^0.3.20",
|
||||||
"pg": "^8.11.0",
|
"pg": "^8.11.0",
|
||||||
"glob": "^10.3.0",
|
"glob": "^10.3.0",
|
||||||
|
|
|
||||||
|
|
@ -5,39 +5,49 @@ import { DatabaseModule } from '@it0/database';
|
||||||
import { AgentController } from './interfaces/rest/controllers/agent.controller';
|
import { AgentController } from './interfaces/rest/controllers/agent.controller';
|
||||||
import { SessionController } from './interfaces/rest/controllers/session.controller';
|
import { SessionController } from './interfaces/rest/controllers/session.controller';
|
||||||
import { RiskRulesController } from './interfaces/rest/controllers/risk-rules.controller';
|
import { RiskRulesController } from './interfaces/rest/controllers/risk-rules.controller';
|
||||||
|
import { TenantAgentConfigController } from './interfaces/rest/controllers/tenant-agent-config.controller';
|
||||||
import { AgentStreamGateway } from './interfaces/ws/agent-stream.gateway';
|
import { AgentStreamGateway } from './interfaces/ws/agent-stream.gateway';
|
||||||
import { EngineRegistry } from './infrastructure/engines/engine-registry';
|
import { EngineRegistry } from './infrastructure/engines/engine-registry';
|
||||||
import { ClaudeCodeCliEngine } from './infrastructure/engines/claude-code-cli/claude-code-engine';
|
import { ClaudeCodeCliEngine } from './infrastructure/engines/claude-code-cli/claude-code-engine';
|
||||||
import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api-engine';
|
import { ClaudeApiEngine } from './infrastructure/engines/claude-api/claude-api-engine';
|
||||||
|
import { ClaudeAgentSdkEngine } from './infrastructure/engines/claude-agent-sdk';
|
||||||
import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor';
|
import { ToolExecutor } from './infrastructure/engines/claude-api/tool-executor';
|
||||||
import { CommandGuardService } from './infrastructure/guards/command-guard.service';
|
import { CommandGuardService } from './infrastructure/guards/command-guard.service';
|
||||||
import { SkillManagerService } from './domain/services/skill-manager.service';
|
import { SkillManagerService } from './domain/services/skill-manager.service';
|
||||||
import { StandingOrderExtractorService } from './domain/services/standing-order-extractor';
|
import { StandingOrderExtractorService } from './domain/services/standing-order-extractor';
|
||||||
|
import { AllowedToolsResolverService } from './domain/services/allowed-tools-resolver.service';
|
||||||
import { SessionRepository } from './infrastructure/repositories/session.repository';
|
import { SessionRepository } from './infrastructure/repositories/session.repository';
|
||||||
import { TaskRepository } from './infrastructure/repositories/task.repository';
|
import { TaskRepository } from './infrastructure/repositories/task.repository';
|
||||||
|
import { TenantAgentConfigRepository } from './infrastructure/repositories/tenant-agent-config.repository';
|
||||||
|
import { TenantAgentConfigService } from './infrastructure/services/tenant-agent-config.service';
|
||||||
import { AgentSession } from './domain/entities/agent-session.entity';
|
import { AgentSession } from './domain/entities/agent-session.entity';
|
||||||
import { AgentTask } from './domain/entities/agent-task.entity';
|
import { AgentTask } from './domain/entities/agent-task.entity';
|
||||||
import { CommandRecord } from './domain/entities/command-record.entity';
|
import { CommandRecord } from './domain/entities/command-record.entity';
|
||||||
import { StandingOrderRef } from './domain/entities/standing-order.entity';
|
import { StandingOrderRef } from './domain/entities/standing-order.entity';
|
||||||
|
import { TenantAgentConfig } from './domain/entities/tenant-agent-config.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({ isGlobal: true }),
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
DatabaseModule.forRoot(),
|
DatabaseModule.forRoot(),
|
||||||
TypeOrmModule.forFeature([AgentSession, AgentTask, CommandRecord, StandingOrderRef]),
|
TypeOrmModule.forFeature([AgentSession, AgentTask, CommandRecord, StandingOrderRef, TenantAgentConfig]),
|
||||||
],
|
],
|
||||||
controllers: [AgentController, SessionController, RiskRulesController],
|
controllers: [AgentController, SessionController, RiskRulesController, TenantAgentConfigController],
|
||||||
providers: [
|
providers: [
|
||||||
AgentStreamGateway,
|
AgentStreamGateway,
|
||||||
EngineRegistry,
|
EngineRegistry,
|
||||||
ClaudeCodeCliEngine,
|
ClaudeCodeCliEngine,
|
||||||
ClaudeApiEngine,
|
ClaudeApiEngine,
|
||||||
|
ClaudeAgentSdkEngine,
|
||||||
ToolExecutor,
|
ToolExecutor,
|
||||||
CommandGuardService,
|
CommandGuardService,
|
||||||
SkillManagerService,
|
SkillManagerService,
|
||||||
StandingOrderExtractorService,
|
StandingOrderExtractorService,
|
||||||
|
AllowedToolsResolverService,
|
||||||
SessionRepository,
|
SessionRepository,
|
||||||
TaskRepository,
|
TaskRepository,
|
||||||
|
TenantAgentConfigRepository,
|
||||||
|
TenantAgentConfigService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Per-tenant Agent SDK configuration entity.
|
||||||
|
*
|
||||||
|
* Stores tenant-specific settings for the Claude Agent SDK engine:
|
||||||
|
* - billingMode: 'subscription' (default, operator's Claude login) or 'api_key' (tenant's own Anthropic key)
|
||||||
|
* - encryptedApiKey/apiKeyIv: AES-256-GCM encrypted Anthropic API key (only for api_key mode)
|
||||||
|
* - approvalTimeoutSeconds: L2 high-risk command auto-approve timeout (default 120s, 0 = wait forever)
|
||||||
|
* - toolWhitelist: tenant-level tool override (empty = use RBAC defaults)
|
||||||
|
* - toolBlacklist: tenant-level always-denied tools
|
||||||
|
*/
|
||||||
|
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('tenant_agent_configs')
|
||||||
|
export class TenantAgentConfig {
|
||||||
|
@PrimaryGeneratedColumn('uuid')
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, unique: true })
|
||||||
|
tenantId!: string;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 20, default: 'subscription' })
|
||||||
|
billingMode!: 'subscription' | 'api_key';
|
||||||
|
|
||||||
|
@Column({ type: 'bytea', nullable: true })
|
||||||
|
encryptedApiKey!: Buffer | null;
|
||||||
|
|
||||||
|
@Column({ type: 'bytea', nullable: true })
|
||||||
|
apiKeyIv!: Buffer | null;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 120 })
|
||||||
|
approvalTimeoutSeconds!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', array: true, default: '{}' })
|
||||||
|
toolWhitelist!: string[];
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', array: true, default: '{}' })
|
||||||
|
toolBlacklist!: string[];
|
||||||
|
|
||||||
|
@CreateDateColumn({ type: 'timestamptz' })
|
||||||
|
createdAt!: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz' })
|
||||||
|
updatedAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* RBAC-based tool whitelist resolver for Claude Agent SDK.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1. Start with role-based default tool set (admin > operator > viewer)
|
||||||
|
* 2. If tenant has toolWhitelist set → intersect (only keep tools in both sets)
|
||||||
|
* 3. If tenant has toolBlacklist set → subtract (always remove these tools)
|
||||||
|
* 4. Return final allowedTools[] for SDK's query() call
|
||||||
|
*
|
||||||
|
* When toolWhitelist is empty, RBAC defaults apply.
|
||||||
|
* toolBlacklist always takes precedence (denied regardless of role).
|
||||||
|
*/
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { TenantAgentConfig } from '../entities/tenant-agent-config.entity';
|
||||||
|
|
||||||
|
const ALL_SDK_TOOLS = [
|
||||||
|
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||||
|
'WebFetch', 'WebSearch', 'NotebookEdit', 'Task',
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLE_TOOL_MAP: Record<string, string[]> = {
|
||||||
|
admin: [...ALL_SDK_TOOLS],
|
||||||
|
operator: ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep'],
|
||||||
|
viewer: ['Read', 'Glob', 'Grep'],
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AllowedToolsResolverService {
|
||||||
|
resolve(roleType: string, tenantConfig: TenantAgentConfig | null): string[] {
|
||||||
|
let tools = [...(ROLE_TOOL_MAP[roleType] ?? ROLE_TOOL_MAP['viewer'])];
|
||||||
|
|
||||||
|
if (tenantConfig?.toolWhitelist?.length) {
|
||||||
|
tools = tools.filter(t => tenantConfig.toolWhitelist.includes(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenantConfig?.toolBlacklist?.length) {
|
||||||
|
tools = tools.filter(t => !tenantConfig.toolBlacklist.includes(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export enum AgentEngineType {
|
export enum AgentEngineType {
|
||||||
CLAUDE_CODE_CLI = 'claude_code_cli',
|
CLAUDE_CODE_CLI = 'claude_code_cli',
|
||||||
CLAUDE_API = 'claude_api',
|
CLAUDE_API = 'claude_api',
|
||||||
|
CLAUDE_AGENT_SDK = 'claude_agent_sdk',
|
||||||
CUSTOM = 'custom',
|
CUSTOM = 'custom',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* ApprovalGate — Promise-based L2 tool approval with configurable timeout.
|
||||||
|
*
|
||||||
|
* Used inside the Agent SDK's `canUseTool` callback to block execution
|
||||||
|
* on high-risk (L2) commands until the user approves or the timeout expires.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. SDK calls canUseTool → classifyToolRisk returns HIGH_RISK (L2)
|
||||||
|
* 2. Engine emits 'approval_required' event via WebSocket to client
|
||||||
|
* 3. waitForApproval() blocks on a Promise
|
||||||
|
* 4. Either:
|
||||||
|
* a. User calls POST /tasks/:id/approve → resolveApproval(true/false)
|
||||||
|
* b. Timeout expires → auto-approve (resolve(true))
|
||||||
|
* 5. canUseTool returns allow/deny based on result
|
||||||
|
*
|
||||||
|
* Timeout of 0 means wait indefinitely (no auto-approve).
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
interface PendingApproval {
|
||||||
|
resolve: (approved: boolean) => void;
|
||||||
|
timer: NodeJS.Timeout;
|
||||||
|
toolName: string;
|
||||||
|
toolInput: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApprovalGate {
|
||||||
|
private pendingApprovals = new Map<string, PendingApproval>();
|
||||||
|
|
||||||
|
constructor(private readonly timeoutSeconds: number) {}
|
||||||
|
|
||||||
|
async waitForApproval(
|
||||||
|
toolName: string,
|
||||||
|
toolInput: Record<string, unknown>,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingApprovals.delete(id);
|
||||||
|
resolve(true); // Auto-approve on timeout
|
||||||
|
}, this.timeoutSeconds * 1000);
|
||||||
|
|
||||||
|
this.pendingApprovals.set(id, { resolve, timer, toolName, toolInput });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveApproval(approved: boolean): void {
|
||||||
|
// Resolve the most recent pending approval (LIFO)
|
||||||
|
const entries = [...this.pendingApprovals.entries()];
|
||||||
|
const entry = entries[entries.length - 1];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const [id, { resolve, timer }] = entry;
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pendingApprovals.delete(id);
|
||||||
|
resolve(approved);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPending(): boolean {
|
||||||
|
return this.pendingApprovals.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
for (const [id, { timer, resolve }] of this.pendingApprovals) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
this.pendingApprovals.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
/**
|
||||||
|
* Claude Agent SDK Engine
|
||||||
|
*
|
||||||
|
* Third engine implementation using @anthropic-ai/claude-agent-sdk.
|
||||||
|
* Integrates with the existing EngineRegistry via the AgentEnginePort interface.
|
||||||
|
*
|
||||||
|
* Key features:
|
||||||
|
* - Default subscription auth (operator's Claude Code login, no API key needed)
|
||||||
|
* - Optional per-tenant API key billing (AES-256-GCM encrypted)
|
||||||
|
* - L2 approval flow via canUseTool callback with configurable auto-approve timeout
|
||||||
|
* - SDK session ID persistence for resume support
|
||||||
|
* - RBAC-based tool whitelist resolution per tenant/role
|
||||||
|
*
|
||||||
|
* Billing modes:
|
||||||
|
* - 'subscription': Uses inherited CLI auth from `claude login` (default)
|
||||||
|
* - 'api_key': Tenant provides own Anthropic API key, passed via env.ANTHROPIC_API_KEY
|
||||||
|
*/
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AgentEnginePort, EngineTaskParams, EngineStreamEvent } from '../../../domain/ports/outbound/agent-engine.port';
|
||||||
|
import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type.vo';
|
||||||
|
import { CommandRiskLevel } from '../../../domain/value-objects/command-risk-level.vo';
|
||||||
|
import { CommandRiskClassifier } from '../../../domain/services/command-risk-classifier';
|
||||||
|
import { TenantAgentConfigService } from '../../services/tenant-agent-config.service';
|
||||||
|
import { AllowedToolsResolverService } from '../../../domain/services/allowed-tools-resolver.service';
|
||||||
|
import { TenantContextService } from '@it0/common';
|
||||||
|
import { ApprovalGate } from './approval-gate';
|
||||||
|
|
||||||
|
/** Tracks an active SDK session for cancellation and approval resolution. */
|
||||||
|
interface ActiveSession {
|
||||||
|
abort: AbortController;
|
||||||
|
gate: ApprovalGate;
|
||||||
|
sdkSessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ClaudeAgentSdkEngine implements AgentEnginePort {
|
||||||
|
readonly engineType = AgentEngineType.CLAUDE_AGENT_SDK;
|
||||||
|
private readonly logger = new Logger(ClaudeAgentSdkEngine.name);
|
||||||
|
private readonly activeSessions = new Map<string, ActiveSession>();
|
||||||
|
private readonly classifier = new CommandRiskClassifier();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly tenantConfigService: TenantAgentConfigService,
|
||||||
|
private readonly allowedToolsResolver: AllowedToolsResolverService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async *executeTask(params: EngineTaskParams): AsyncGenerator<EngineStreamEvent> {
|
||||||
|
const tenantId = TenantContextService.getTenantId();
|
||||||
|
const tenantConfig = await this.tenantConfigService.findByTenantId(tenantId);
|
||||||
|
|
||||||
|
// Build environment — subscription mode uses inherited CLI auth, api_key mode overrides
|
||||||
|
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||||
|
if (tenantConfig?.billingMode === 'api_key') {
|
||||||
|
try {
|
||||||
|
env.ANTHROPIC_API_KEY = this.tenantConfigService.decryptApiKey(tenantConfig);
|
||||||
|
} catch (err) {
|
||||||
|
yield { type: 'error', message: 'Tenant API key not configured or invalid', code: 'API_KEY_ERROR' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create approval gate with tenant-configurable timeout
|
||||||
|
const timeoutSec = tenantConfig?.approvalTimeoutSeconds ?? 120;
|
||||||
|
const gate = new ApprovalGate(timeoutSec);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
this.activeSessions.set(params.sessionId, { abort: abortController, gate });
|
||||||
|
|
||||||
|
// Event queue for merging SDK stream events and approval events
|
||||||
|
const eventQueue: (EngineStreamEvent | null)[] = [];
|
||||||
|
let resolveWait: (() => void) | null = null;
|
||||||
|
|
||||||
|
const pushEvent = (event: EngineStreamEvent | null) => {
|
||||||
|
eventQueue.push(event);
|
||||||
|
if (resolveWait) {
|
||||||
|
const resolve = resolveWait;
|
||||||
|
resolveWait = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
||||||
|
|
||||||
|
const sdkQuery = query({
|
||||||
|
prompt: params.prompt,
|
||||||
|
options: {
|
||||||
|
systemPrompt: params.systemPrompt || undefined,
|
||||||
|
allowedTools: params.allowedTools?.length ? params.allowedTools : undefined,
|
||||||
|
maxTurns: params.maxTurns,
|
||||||
|
maxBudgetUsd: params.maxBudgetUsd,
|
||||||
|
env,
|
||||||
|
abortController,
|
||||||
|
permissionMode: 'default',
|
||||||
|
canUseTool: async (toolName, toolInput, { signal }) => {
|
||||||
|
const riskLevel = this.classifyToolRisk(toolName, toolInput);
|
||||||
|
|
||||||
|
// L0-L1: auto-approve
|
||||||
|
if (riskLevel <= CommandRiskLevel.LOW_RISK_WRITE) {
|
||||||
|
return { behavior: 'allow' as const, updatedInput: toolInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
// L3: always block
|
||||||
|
if (riskLevel >= CommandRiskLevel.FORBIDDEN) {
|
||||||
|
return {
|
||||||
|
behavior: 'deny' as const,
|
||||||
|
message: `Command blocked: risk level FORBIDDEN`,
|
||||||
|
interrupt: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// L2: emit approval_required and wait for gate
|
||||||
|
pushEvent({
|
||||||
|
type: 'approval_required',
|
||||||
|
command: `${toolName}: ${JSON.stringify(toolInput).slice(0, 200)}`,
|
||||||
|
riskLevel: riskLevel,
|
||||||
|
taskId: params.sessionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const approved = await gate.waitForApproval(toolName, toolInput as Record<string, unknown>);
|
||||||
|
|
||||||
|
if (approved) {
|
||||||
|
return { behavior: 'allow' as const, updatedInput: toolInput };
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
behavior: 'deny' as const,
|
||||||
|
message: 'User rejected the command',
|
||||||
|
interrupt: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Consume SDK messages in the background and push to event queue
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
for await (const message of sdkQuery) {
|
||||||
|
// Capture SDK session ID for resume support
|
||||||
|
if ('session_id' in message && message.session_id) {
|
||||||
|
const session = this.activeSessions.get(params.sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.sdkSessionId = message.session_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = this.mapSdkMessage(message);
|
||||||
|
for (const event of events) {
|
||||||
|
pushEvent(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name !== 'AbortError') {
|
||||||
|
pushEvent({
|
||||||
|
type: 'error',
|
||||||
|
message: err?.message ?? 'SDK execution error',
|
||||||
|
code: 'SDK_ERROR',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pushEvent(null); // Signal end of stream
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Yield events from the merged queue
|
||||||
|
while (true) {
|
||||||
|
if (eventQueue.length === 0) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveWait = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while (eventQueue.length > 0) {
|
||||||
|
const event = eventQueue.shift();
|
||||||
|
if (event === null) return;
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `Failed to initialize Agent SDK: ${err?.message}`,
|
||||||
|
code: 'SDK_INIT_ERROR',
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
const session = this.activeSessions.get(params.sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.gate.dispose();
|
||||||
|
}
|
||||||
|
this.activeSessions.delete(params.sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancelTask(sessionId: string): Promise<void> {
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
if (session) {
|
||||||
|
session.gate.dispose();
|
||||||
|
session.abort.abort();
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async *continueSession(sessionId: string, message: string): AsyncGenerator<EngineStreamEvent> {
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
|
||||||
|
// If there's a pending approval, resolve it
|
||||||
|
if (session?.gate.hasPending()) {
|
||||||
|
session.gate.resolveApproval(message === 'approved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, resume the SDK session
|
||||||
|
const sdkSessionId = session?.sdkSessionId;
|
||||||
|
if (!sdkSessionId) {
|
||||||
|
yield {
|
||||||
|
type: 'error',
|
||||||
|
message: `No SDK session found for session ${sessionId}`,
|
||||||
|
code: 'SESSION_NOT_FOUND',
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantId = TenantContextService.getTenantId();
|
||||||
|
const tenantConfig = await this.tenantConfigService.findByTenantId(tenantId);
|
||||||
|
|
||||||
|
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
||||||
|
if (tenantConfig?.billingMode === 'api_key') {
|
||||||
|
try {
|
||||||
|
env.ANTHROPIC_API_KEY = this.tenantConfigService.decryptApiKey(tenantConfig);
|
||||||
|
} catch {
|
||||||
|
yield { type: 'error', message: 'Tenant API key invalid', code: 'API_KEY_ERROR' };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutSec = tenantConfig?.approvalTimeoutSeconds ?? 120;
|
||||||
|
const gate = new ApprovalGate(timeoutSec);
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
this.activeSessions.set(sessionId, { abort: abortController, gate, sdkSessionId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { query } = await import('@anthropic-ai/claude-agent-sdk');
|
||||||
|
|
||||||
|
const sdkQuery = query({
|
||||||
|
prompt: message,
|
||||||
|
options: {
|
||||||
|
resume: sdkSessionId,
|
||||||
|
env,
|
||||||
|
abortController,
|
||||||
|
permissionMode: 'default',
|
||||||
|
canUseTool: async (toolName, toolInput) => {
|
||||||
|
const riskLevel = this.classifyToolRisk(toolName, toolInput);
|
||||||
|
if (riskLevel <= CommandRiskLevel.LOW_RISK_WRITE) {
|
||||||
|
return { behavior: 'allow' as const, updatedInput: toolInput };
|
||||||
|
}
|
||||||
|
if (riskLevel >= CommandRiskLevel.FORBIDDEN) {
|
||||||
|
return { behavior: 'deny' as const, message: 'Command blocked: FORBIDDEN', interrupt: false };
|
||||||
|
}
|
||||||
|
const approved = await gate.waitForApproval(toolName, toolInput as Record<string, unknown>);
|
||||||
|
return approved
|
||||||
|
? { behavior: 'allow' as const, updatedInput: toolInput }
|
||||||
|
: { behavior: 'deny' as const, message: 'User rejected', interrupt: false };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const msg of sdkQuery) {
|
||||||
|
const events = this.mapSdkMessage(msg);
|
||||||
|
for (const event of events) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name !== 'AbortError') {
|
||||||
|
yield { type: 'error', message: err?.message ?? 'SDK resume error', code: 'SDK_ERROR' };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const s = this.activeSessions.get(sessionId);
|
||||||
|
if (s) s.gate.dispose();
|
||||||
|
this.activeSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await import('@anthropic-ai/claude-agent-sdk');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the approval for a pending session (called from controller).
|
||||||
|
*/
|
||||||
|
resolveApproval(sessionId: string, approved: boolean): boolean {
|
||||||
|
const session = this.activeSessions.get(sessionId);
|
||||||
|
if (!session?.gate.hasPending()) return false;
|
||||||
|
session.gate.resolveApproval(approved);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SDK session ID for persistence in AgentSession.metadata.
|
||||||
|
*/
|
||||||
|
getSdkSessionId(sessionId: string): string | undefined {
|
||||||
|
return this.activeSessions.get(sessionId)?.sdkSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private classifyToolRisk(toolName: string, toolInput: any): CommandRiskLevel {
|
||||||
|
// Only classify Bash commands for risk; other tools are auto-allowed
|
||||||
|
if (toolName === 'Bash' && typeof toolInput?.command === 'string') {
|
||||||
|
return this.classifier.classify(toolInput.command);
|
||||||
|
}
|
||||||
|
// Write/Edit operations are low-risk write
|
||||||
|
if (toolName === 'Write' || toolName === 'Edit' || toolName === 'NotebookEdit') {
|
||||||
|
return CommandRiskLevel.LOW_RISK_WRITE;
|
||||||
|
}
|
||||||
|
// Read-only tools
|
||||||
|
return CommandRiskLevel.READ_ONLY;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapSdkMessage(message: any): EngineStreamEvent[] {
|
||||||
|
const events: EngineStreamEvent[] = [];
|
||||||
|
|
||||||
|
if (message.type === 'assistant') {
|
||||||
|
const content = message.message?.content;
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
for (const block of content) {
|
||||||
|
if (block.type === 'thinking') {
|
||||||
|
events.push({ type: 'thinking', content: block.thinking ?? '' });
|
||||||
|
} else if (block.type === 'text') {
|
||||||
|
events.push({ type: 'text', content: block.text ?? '' });
|
||||||
|
} else if (block.type === 'tool_use') {
|
||||||
|
events.push({
|
||||||
|
type: 'tool_use',
|
||||||
|
toolName: block.name ?? 'unknown',
|
||||||
|
input: block.input ?? {},
|
||||||
|
});
|
||||||
|
} else if (block.type === 'tool_result') {
|
||||||
|
events.push({
|
||||||
|
type: 'tool_result',
|
||||||
|
toolName: block.tool_use_id ?? 'unknown',
|
||||||
|
output: typeof block.content === 'string'
|
||||||
|
? block.content
|
||||||
|
: JSON.stringify(block.content ?? ''),
|
||||||
|
isError: block.is_error ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (message.type === 'result') {
|
||||||
|
events.push({
|
||||||
|
type: 'completed',
|
||||||
|
summary: message.result ?? 'Task completed',
|
||||||
|
tokensUsed: message.usage
|
||||||
|
? (message.usage.input_tokens ?? 0) + (message.usage.output_tokens ?? 0)
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} else if (message.type === 'system' && message.subtype === 'init') {
|
||||||
|
this.logger.log(`SDK session initialized: ${message.session_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { ClaudeAgentSdkEngine } from './claude-agent-sdk-engine';
|
||||||
|
export { ApprovalGate } from './approval-gate';
|
||||||
|
|
@ -4,6 +4,7 @@ import { AgentEnginePort } from '../../domain/ports/outbound/agent-engine.port';
|
||||||
import { AgentEngineType } from '../../domain/value-objects/agent-engine-type.vo';
|
import { AgentEngineType } from '../../domain/value-objects/agent-engine-type.vo';
|
||||||
import { ClaudeCodeCliEngine } from './claude-code-cli/claude-code-engine';
|
import { ClaudeCodeCliEngine } from './claude-code-cli/claude-code-engine';
|
||||||
import { ClaudeApiEngine } from './claude-api/claude-api-engine';
|
import { ClaudeApiEngine } from './claude-api/claude-api-engine';
|
||||||
|
import { ClaudeAgentSdkEngine } from './claude-agent-sdk/claude-agent-sdk-engine';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EngineRegistry {
|
export class EngineRegistry {
|
||||||
|
|
@ -12,10 +13,12 @@ export class EngineRegistry {
|
||||||
constructor(
|
constructor(
|
||||||
@Optional() private readonly cliEngine: ClaudeCodeCliEngine,
|
@Optional() private readonly cliEngine: ClaudeCodeCliEngine,
|
||||||
@Optional() private readonly apiEngine: ClaudeApiEngine,
|
@Optional() private readonly apiEngine: ClaudeApiEngine,
|
||||||
|
@Optional() private readonly sdkEngine: ClaudeAgentSdkEngine,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
if (cliEngine) this.engines.set(AgentEngineType.CLAUDE_CODE_CLI, cliEngine);
|
if (cliEngine) this.engines.set(AgentEngineType.CLAUDE_CODE_CLI, cliEngine);
|
||||||
if (apiEngine) this.engines.set(AgentEngineType.CLAUDE_API, apiEngine);
|
if (apiEngine) this.engines.set(AgentEngineType.CLAUDE_API, apiEngine);
|
||||||
|
if (sdkEngine) this.engines.set(AgentEngineType.CLAUDE_AGENT_SDK, sdkEngine);
|
||||||
}
|
}
|
||||||
|
|
||||||
getActiveEngine(): AgentEnginePort {
|
getActiveEngine(): AgentEnginePort {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/**
|
||||||
|
* Repository for TenantAgentConfig.
|
||||||
|
* Extends TenantAwareRepository for schema-per-tenant isolation (SET search_path).
|
||||||
|
*/
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { TenantAwareRepository } from '@it0/database';
|
||||||
|
import { TenantAgentConfig } from '../../domain/entities/tenant-agent-config.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantAgentConfigRepository extends TenantAwareRepository<TenantAgentConfig> {
|
||||||
|
constructor(dataSource: DataSource) {
|
||||||
|
super(dataSource, TenantAgentConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTenantId(tenantId: string): Promise<TenantAgentConfig | null> {
|
||||||
|
const repo = await this.getRepository();
|
||||||
|
return repo.findOneBy({ tenantId } as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* Service for managing per-tenant Agent SDK configuration.
|
||||||
|
*
|
||||||
|
* Handles CRUD operations for TenantAgentConfig with:
|
||||||
|
* - API key encryption/decryption using CryptoUtil (AES-256-GCM, same pattern as CredentialVaultService)
|
||||||
|
* - Upsert semantics (create if not exists, update if exists)
|
||||||
|
* - API key removal with automatic revert to subscription billing
|
||||||
|
*/
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { CryptoUtil } from '@it0/common';
|
||||||
|
import { TenantAgentConfigRepository } from '../repositories/tenant-agent-config.repository';
|
||||||
|
import { TenantAgentConfig } from '../../domain/entities/tenant-agent-config.entity';
|
||||||
|
|
||||||
|
export interface UpdateTenantAgentConfigDto {
|
||||||
|
billingMode?: 'subscription' | 'api_key';
|
||||||
|
apiKey?: string;
|
||||||
|
approvalTimeoutSeconds?: number;
|
||||||
|
toolWhitelist?: string[];
|
||||||
|
toolBlacklist?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TenantAgentConfigService {
|
||||||
|
private readonly logger = new Logger(TenantAgentConfigService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repo: TenantAgentConfigRepository,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get masterKey(): string {
|
||||||
|
return this.configService.get<string>('VAULT_MASTER_KEY', 'dev-vault-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByTenantId(tenantId: string): Promise<TenantAgentConfig | null> {
|
||||||
|
return this.repo.findByTenantId(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert(tenantId: string, dto: UpdateTenantAgentConfigDto): Promise<TenantAgentConfig> {
|
||||||
|
let config = await this.repo.findByTenantId(tenantId);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
config = new TenantAgentConfig();
|
||||||
|
config.tenantId = tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.billingMode !== undefined) {
|
||||||
|
config.billingMode = dto.billingMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.apiKey !== undefined) {
|
||||||
|
const { encrypted, iv } = CryptoUtil.encrypt(dto.apiKey, this.masterKey);
|
||||||
|
config.encryptedApiKey = encrypted;
|
||||||
|
config.apiKeyIv = iv;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.approvalTimeoutSeconds !== undefined) {
|
||||||
|
config.approvalTimeoutSeconds = dto.approvalTimeoutSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.toolWhitelist !== undefined) {
|
||||||
|
config.toolWhitelist = dto.toolWhitelist;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.toolBlacklist !== undefined) {
|
||||||
|
config.toolBlacklist = dto.toolBlacklist;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.repo.save(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeApiKey(tenantId: string): Promise<TenantAgentConfig | null> {
|
||||||
|
const config = await this.repo.findByTenantId(tenantId);
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
config.billingMode = 'subscription';
|
||||||
|
config.encryptedApiKey = null;
|
||||||
|
config.apiKeyIv = null;
|
||||||
|
return this.repo.save(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptApiKey(config: TenantAgentConfig): string {
|
||||||
|
if (!config.encryptedApiKey || !config.apiKeyIv) {
|
||||||
|
throw new Error('No API key configured for this tenant');
|
||||||
|
}
|
||||||
|
return CryptoUtil.decrypt(config.encryptedApiKey, config.apiKeyIv, this.masterKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
/**
|
||||||
|
* Admin REST controller for per-tenant Agent SDK configuration.
|
||||||
|
*
|
||||||
|
* Endpoints (all JWT-protected):
|
||||||
|
* GET /api/v1/agent/tenant-config → Get current tenant's SDK config (returns defaults if none set)
|
||||||
|
* PUT /api/v1/agent/tenant-config → Create/update config (billingMode, apiKey, timeout, tools)
|
||||||
|
* DELETE /api/v1/agent/tenant-config/api-key → Remove API key, revert to subscription billing
|
||||||
|
*
|
||||||
|
* Note: API key is never returned in responses — only `hasApiKey: boolean` is exposed.
|
||||||
|
*/
|
||||||
|
import { Controller, Get, Put, Delete, Body, UseGuards, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { TenantId } from '@it0/common';
|
||||||
|
import { TenantAgentConfigService, UpdateTenantAgentConfigDto } from '../../../infrastructure/services/tenant-agent-config.service';
|
||||||
|
|
||||||
|
@Controller('api/v1/agent/tenant-config')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
export class TenantAgentConfigController {
|
||||||
|
constructor(
|
||||||
|
private readonly tenantConfigService: TenantAgentConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getConfig(@TenantId() tenantId: string) {
|
||||||
|
const config = await this.tenantConfigService.findByTenantId(tenantId);
|
||||||
|
if (!config) {
|
||||||
|
return {
|
||||||
|
tenantId,
|
||||||
|
billingMode: 'subscription',
|
||||||
|
approvalTimeoutSeconds: 120,
|
||||||
|
toolWhitelist: [],
|
||||||
|
toolBlacklist: [],
|
||||||
|
hasApiKey: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
billingMode: config.billingMode,
|
||||||
|
approvalTimeoutSeconds: config.approvalTimeoutSeconds,
|
||||||
|
toolWhitelist: config.toolWhitelist,
|
||||||
|
toolBlacklist: config.toolBlacklist,
|
||||||
|
hasApiKey: config.encryptedApiKey !== null,
|
||||||
|
createdAt: config.createdAt,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
async upsertConfig(
|
||||||
|
@TenantId() tenantId: string,
|
||||||
|
@Body() dto: UpdateTenantAgentConfigDto,
|
||||||
|
) {
|
||||||
|
const config = await this.tenantConfigService.upsert(tenantId, dto);
|
||||||
|
return {
|
||||||
|
tenantId: config.tenantId,
|
||||||
|
billingMode: config.billingMode,
|
||||||
|
approvalTimeoutSeconds: config.approvalTimeoutSeconds,
|
||||||
|
toolWhitelist: config.toolWhitelist,
|
||||||
|
toolBlacklist: config.toolBlacklist,
|
||||||
|
hasApiKey: config.encryptedApiKey !== null,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('api-key')
|
||||||
|
async removeApiKey(@TenantId() tenantId: string) {
|
||||||
|
const config = await this.tenantConfigService.removeApiKey(tenantId);
|
||||||
|
if (!config) {
|
||||||
|
throw new NotFoundException('No agent config found for this tenant');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: 'API key removed, reverted to subscription billing',
|
||||||
|
billingMode: config.billingMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue