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,
|
||||
current: () => [...queryKeys.agentConfig.all, 'current'] as const,
|
||||
},
|
||||
tenantSdkConfig: {
|
||||
all: ['tenant-sdk-config'] as const,
|
||||
current: () => [...queryKeys.tenantSdkConfig.all, 'current'] as const,
|
||||
},
|
||||
skills: {
|
||||
all: ['skills'] as const,
|
||||
list: (params?: Record<string, string>) => [...queryKeys.skills.all, 'list', params] as const,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const navItems: NavItem[] = [
|
|||
href: '/agent-config',
|
||||
children: [
|
||||
{ label: 'Engine & Prompt', href: '/agent-config' },
|
||||
{ label: 'SDK Config', href: '/agent-config/sdk' },
|
||||
{ label: 'Skills', href: '/agent-config/skills' },
|
||||
{ label: 'Hooks', href: '/agent-config/hooks' },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"@nestjs/platform-socket.io": "^10.3.0",
|
||||
"socket.io": "^4.7.0",
|
||||
"@anthropic-ai/sdk": "^0.32.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.49",
|
||||
"typeorm": "^0.3.20",
|
||||
"pg": "^8.11.0",
|
||||
"glob": "^10.3.0",
|
||||
|
|
|
|||
|
|
@ -5,39 +5,49 @@ import { DatabaseModule } from '@it0/database';
|
|||
import { AgentController } from './interfaces/rest/controllers/agent.controller';
|
||||
import { SessionController } from './interfaces/rest/controllers/session.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 { EngineRegistry } from './infrastructure/engines/engine-registry';
|
||||
import { ClaudeCodeCliEngine } from './infrastructure/engines/claude-code-cli/claude-code-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 { CommandGuardService } from './infrastructure/guards/command-guard.service';
|
||||
import { SkillManagerService } from './domain/services/skill-manager.service';
|
||||
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 { 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 { AgentTask } from './domain/entities/agent-task.entity';
|
||||
import { CommandRecord } from './domain/entities/command-record.entity';
|
||||
import { StandingOrderRef } from './domain/entities/standing-order.entity';
|
||||
import { TenantAgentConfig } from './domain/entities/tenant-agent-config.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
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: [
|
||||
AgentStreamGateway,
|
||||
EngineRegistry,
|
||||
ClaudeCodeCliEngine,
|
||||
ClaudeApiEngine,
|
||||
ClaudeAgentSdkEngine,
|
||||
ToolExecutor,
|
||||
CommandGuardService,
|
||||
SkillManagerService,
|
||||
StandingOrderExtractorService,
|
||||
AllowedToolsResolverService,
|
||||
SessionRepository,
|
||||
TaskRepository,
|
||||
TenantAgentConfigRepository,
|
||||
TenantAgentConfigService,
|
||||
],
|
||||
})
|
||||
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 {
|
||||
CLAUDE_CODE_CLI = 'claude_code_cli',
|
||||
CLAUDE_API = 'claude_api',
|
||||
CLAUDE_AGENT_SDK = 'claude_agent_sdk',
|
||||
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 { ClaudeCodeCliEngine } from './claude-code-cli/claude-code-engine';
|
||||
import { ClaudeApiEngine } from './claude-api/claude-api-engine';
|
||||
import { ClaudeAgentSdkEngine } from './claude-agent-sdk/claude-agent-sdk-engine';
|
||||
|
||||
@Injectable()
|
||||
export class EngineRegistry {
|
||||
|
|
@ -12,10 +13,12 @@ export class EngineRegistry {
|
|||
constructor(
|
||||
@Optional() private readonly cliEngine: ClaudeCodeCliEngine,
|
||||
@Optional() private readonly apiEngine: ClaudeApiEngine,
|
||||
@Optional() private readonly sdkEngine: ClaudeAgentSdkEngine,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
if (cliEngine) this.engines.set(AgentEngineType.CLAUDE_CODE_CLI, cliEngine);
|
||||
if (apiEngine) this.engines.set(AgentEngineType.CLAUDE_API, apiEngine);
|
||||
if (sdkEngine) this.engines.set(AgentEngineType.CLAUDE_AGENT_SDK, sdkEngine);
|
||||
}
|
||||
|
||||
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