fix(phase17): 401 auth, provider config from server, remove hardcoded PROVIDERS

Problems fixed:
1. 401 unauthorized on chat — OLLAMA_API_KEY was not set in containers
   - Created docker/.env with real API key
   - Added OLLAMA_BASE_URL + OLLAMA_API_KEY to control-center in docker-compose.yml

2. AgentDetailModal/AgentCreateModal showed hardcoded providers list
   (Ollama, OpenAI, Anthropic, Mistral, Groq) regardless of what is configured
   - Removed const PROVIDERS = [...] from both modals
   - Now loads providers via trpc.config.providers (server-side)
   - Only shows providers that are actually configured in env

3. Settings.tsx had API key hardcoded in frontend source code (security issue)
   - API key removed from frontend
   - New trpc.config.providers endpoint returns masked key (first 8 chars + ***)
   - Shows red warning badge 'NO KEY — chat will fail' if key is missing
   - Base URL read from server env, not hardcoded

New tRPC endpoint: config.providers
   - Returns list of configured providers with name, baseUrl, hasKey, maskedKey
   - Provider name auto-detected from URL (ollama.com → 'Ollama Cloud', etc.)
This commit is contained in:
bboxwtf
2026-03-21 02:55:05 +00:00
parent 62cedcdba5
commit 91684956bb
6 changed files with 102 additions and 28 deletions

1
.git-credentials Normal file
View File

@@ -0,0 +1 @@
https://x-access-token:ghs_b4NOitjlosRPPypJr3KupAZqrOXlxr4fq5Z9@github.com

View File

@@ -28,11 +28,7 @@ const AGENT_ROLES = [
{ value: "monitor", label: "Monitor - System monitoring" },
];
const PROVIDERS = [
{ value: "Ollama", label: "Ollama (Local/Cloud)" },
{ value: "OpenAI", label: "OpenAI (GPT)" },
{ value: "Anthropic", label: "Anthropic (Claude)" },
];
// Providers are loaded dynamically from server config — no hardcoded list
export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateModalProps) {
const [formData, setFormData] = useState({
@@ -51,6 +47,11 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
const { data: modelsData, isLoading: modelsLoading } = trpc.ollama.models.useQuery(undefined, {
staleTime: 60_000,
});
const { data: configData } = trpc.config.providers.useQuery(undefined, {
staleTime: 300_000,
});
// Only providers configured on server
const connectedProviders = configData?.providers ?? [];
const createMutation = trpc.agents.create.useMutation({
onSuccess: () => {
@@ -157,11 +158,17 @@ export function AgentCreateModal({ open, onOpenChange, onSuccess }: AgentCreateM
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map((provider) => (
<SelectItem key={provider.value} value={provider.value}>
{provider.label}
</SelectItem>
))}
{connectedProviders.length > 0
? connectedProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
<span className="flex items-center gap-2">
{p.name}
<span className="text-[10px] text-neon-green font-mono"> connected</span>
</span>
</SelectItem>
))
: <SelectItem value={formData.provider}>{formData.provider}</SelectItem>
}
</SelectContent>
</Select>
</div>

View File

@@ -48,7 +48,6 @@ interface AgentDetailModalProps {
onSave?: () => void;
}
const PROVIDERS = ["Ollama", "OpenAI", "Anthropic", "Mistral", "Groq"];
const TOOL_OPTIONS = ["http_get", "http_post", "shell_exec", "file_read", "file_write", "docker_list", "docker_exec", "docker_logs", "browser_navigate"];
function toNum(v: string | number | null | undefined, fallback = 0): number {
@@ -70,6 +69,11 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
const { data: modelsData, isLoading: modelsLoading } = trpc.ollama.models.useQuery(undefined, {
staleTime: 60_000,
});
const { data: configData } = trpc.config.providers.useQuery(undefined, {
staleTime: 300_000,
});
// Only show providers that are actually configured on the server
const connectedProviders = configData?.providers ?? [];
const { data: history = [] } = trpc.agents.history.useQuery(
{ id: agent?.id ?? 0, limit: 20 },
{ enabled: !!agent && open }
@@ -194,7 +198,17 @@ export function AgentDetailModal({ agent, open, onOpenChange, onSave }: AgentDet
<Select value={form.provider ?? ""} onValueChange={(v) => setForm({ ...form, provider: v, model: "" })}>
<SelectTrigger className="font-mono"><SelectValue /></SelectTrigger>
<SelectContent>
{PROVIDERS.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
{connectedProviders.length > 0
? connectedProviders.map((p) => (
<SelectItem key={p.id} value={p.name}>
<span className="flex items-center gap-2">
{p.name}
<span className="text-[10px] text-neon-green font-mono"> connected</span>
</span>
</SelectItem>
))
: <SelectItem value={form.provider ?? "ollama"}>{form.provider ?? "Ollama"}</SelectItem>
}
</SelectContent>
</Select>
</div>

View File

@@ -53,11 +53,16 @@ export default function Settings() {
// Реальные данные из Ollama API
const healthQuery = trpc.ollama.health.useQuery(undefined, {
refetchInterval: 30_000, // Обновлять каждые 30 секунд
refetchInterval: 30_000,
});
const modelsQuery = trpc.ollama.models.useQuery(undefined, {
refetchInterval: 60_000, // Обновлять каждые 60 секунд
refetchInterval: 60_000,
});
// Server-side provider configuration (API key masked)
const configQuery = trpc.config.providers.useQuery(undefined, {
staleTime: 300_000,
});
const primaryProvider = configQuery.data?.providers?.[0];
const ollamaStatus = healthQuery.data?.connected ? "connected" : healthQuery.isLoading ? "unchecked" : "error";
const ollamaLatency = healthQuery.data?.latencyMs ?? 0;
@@ -133,7 +138,7 @@ export default function Settings() {
)}
<div>
<CardTitle className="text-sm font-semibold flex items-center gap-2">
Ollama Cloud
{primaryProvider?.name ?? "Ollama Cloud"}
<Badge variant="outline" className="text-[9px] font-mono bg-primary/10 text-primary border-primary/20">
LIVE
</Badge>
@@ -156,12 +161,12 @@ export default function Settings() {
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Base URL */}
{/* Base URL — from server config */}
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">BASE URL</Label>
<div className="flex items-center gap-2">
<Input
value="https://ollama.com/v1"
value={primaryProvider?.baseUrl ?? "https://ollama.com/v1"}
className="bg-secondary/30 border-border/30 font-mono text-xs h-8"
readOnly
/>
@@ -182,15 +187,17 @@ export default function Settings() {
</div>
</div>
{/* API Key */}
{/* API Key — masked, read from server env */}
<div className="space-y-1.5">
<Label className="text-[11px] font-mono text-muted-foreground">API KEY</Label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Input
type={showKeys["ollama"] ? "text" : "password"}
value="feaa56e2dff045af989346ca74cb33a6.xzJ-plOVSgTL1FbmL8PZZ3Wx"
className="bg-secondary/30 border-border/30 font-mono text-xs h-8 pr-10"
value={primaryProvider?.maskedKey ?? (primaryProvider?.hasKey ? "(configured)" : "(not set)")}
className={`bg-secondary/30 border-border/30 font-mono text-xs h-8 pr-10 ${
primaryProvider?.hasKey ? "" : "text-neon-amber"
}`}
readOnly
/>
<button
@@ -200,15 +207,17 @@ export default function Settings() {
{showKeys["ollama"] ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
</button>
</div>
<Button
size="sm"
variant="outline"
className="h-8 text-[11px] border-border/50 text-muted-foreground hover:text-foreground"
onClick={() => toast("Feature coming soon")}
>
<Key className="w-3 h-3 mr-1" /> Edit
</Button>
{!primaryProvider?.hasKey && (
<Badge variant="outline" className="text-[10px] font-mono bg-neon-red/15 text-neon-red border-neon-red/30 whitespace-nowrap">
NO KEY chat will fail
</Badge>
)}
</div>
{!primaryProvider?.hasKey && (
<p className="text-[10px] text-neon-amber font-mono">
Set OLLAMA_API_KEY in docker/.env and restart containers
</p>
)}
</div>
<Separator className="bg-border/30" />

View File

@@ -145,6 +145,9 @@ services:
DATABASE_URL: "mysql://${MYSQL_USER:-goclaw}:${MYSQL_PASSWORD:-goClawPass123}@db:3306/${MYSQL_DATABASE:-goclaw}"
GATEWAY_URL: "http://gateway:18789"
JWT_SECRET: "${JWT_SECRET:-change-me-in-production}"
# ── LLM Provider (same as gateway, used by Node.js tRPC proxy) ──────
OLLAMA_BASE_URL: "${LLM_BASE_URL:-${OLLAMA_BASE_URL:-https://ollama.com/v1}}"
OLLAMA_API_KEY: "${LLM_API_KEY:-${OLLAMA_API_KEY:-}}"
VITE_APP_ID: "${VITE_APP_ID:-}"
OAUTH_SERVER_URL: "${OAUTH_SERVER_URL:-}"
VITE_OAUTH_PORTAL_URL: "${VITE_OAUTH_PORTAL_URL:-}"

View File

@@ -31,6 +31,46 @@ export const appRouter = router({
}),
}),
/**
* System config — returns server-side LLM provider configuration.
* API keys are masked (never sent to frontend in full).
* Used by Settings page and AgentDetailModal to show real connected providers.
*/
config: router({
providers: publicProcedure.query(async () => {
const { ENV } = await import("./_core/env");
const baseUrl = ENV.ollamaBaseUrl || "https://ollama.com/v1";
const apiKey = ENV.ollamaApiKey || "";
const hasKey = apiKey.length > 0;
// Mask key: show first 8 chars + ****
const maskedKey = hasKey
? `${apiKey.slice(0, 8)}${"*".repeat(Math.max(0, apiKey.length - 8))}`
: "";
// Determine provider name from base URL
let providerName = "Ollama Cloud";
if (baseUrl.includes("openai.com")) providerName = "OpenAI";
else if (baseUrl.includes("anthropic.com")) providerName = "Anthropic";
else if (baseUrl.includes("groq.com")) providerName = "Groq";
else if (baseUrl.includes("mistral.ai")) providerName = "Mistral";
else if (baseUrl.includes("ollama.com")) providerName = "Ollama Cloud";
else providerName = "Custom";
return {
providers: [
{
id: "primary",
name: providerName,
baseUrl,
hasKey,
maskedKey,
isActive: true,
},
],
};
}),
}),
/**
* Ollama API — серверный прокси для безопасного доступа
* Приоритет: Go Gateway → прямой Ollama