+
{t('fileName')}
{hasChanges && {t('unsavedChanges')}}
@@ -252,6 +239,7 @@ export default function ConfigPage() {
)}
+ {config && 'plugins' in config &&
diff --git a/client-next/src/app/profiles/page.test.tsx b/client-next/src/app/profiles/page.test.tsx
new file mode 100644
index 00000000..99f9a7bb
--- /dev/null
+++ b/client-next/src/app/profiles/page.test.tsx
@@ -0,0 +1,56 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import ProfilesPage from './page';
+
+const mocks = vi.hoisted(() => ({
+ translate: (key: string) => key,
+ getProfiles: vi.fn(), createProfile: vi.fn(), deleteProfile: vi.fn(), activateProfile: vi.fn(), refreshData: vi.fn(),
+}));
+vi.mock('next-intl', () => ({ useTranslations: () => mocks.translate }));
+vi.mock('@/lib/api', () => mocks);
+vi.mock('@/lib/context', () => ({ useApp: () => ({ refreshData: mocks.refreshData }) }));
+vi.mock('@/components/page-help', () => ({ PageHelp: () => null }));
+vi.mock('@nsmr/pixelart-react', () => ({ Plus: () => null, Trash: () => null, Check: () => null, User: () => null, CardStack: () => null, Play: () => null, Close: () => null }));
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ mocks.getProfiles.mockResolvedValue({ active: 'default', profiles: ['default', 'one', 'two'] });
+ mocks.refreshData.mockResolvedValue(undefined);
+});
+afterEach(cleanup);
+
+it('serializes profile switching and refreshes shared data before unlocking controls', async () => {
+ let finish!: () => void;
+ mocks.activateProfile.mockReturnValue(new Promise
(resolve => { finish = resolve; }));
+ render();
+ const switches = await screen.findAllByRole('button', { name: /^switch$/ });
+ fireEvent.click(switches[0]);
+ expect(switches.every(button => (button as HTMLButtonElement).disabled)).toBe(true);
+ fireEvent.click(switches[1]);
+ expect(mocks.activateProfile).toHaveBeenCalledTimes(1);
+ await act(async () => finish());
+ expect(mocks.refreshData).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect((switches[1] as HTMLButtonElement).disabled).toBe(false));
+});
+
+it('cancelling profile creation does not mutate profiles', async () => {
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'newProfile' }));
+ fireEvent.change(screen.getByPlaceholderText('namePlaceholder'), { target: { value: 'draft' } });
+ fireEvent.click(screen.getByRole('button', { name: /^cancel$/ }));
+ expect(mocks.createProfile).not.toHaveBeenCalled();
+ expect(screen.queryByRole('dialog')).toBeNull();
+});
+
+it('repeated Enter submits a new profile only once', async () => {
+ let finish!: () => void;
+ mocks.createProfile.mockReturnValue(new Promise(resolve => { finish = resolve; }));
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'newProfile' }));
+ const input = screen.getByPlaceholderText('namePlaceholder');
+ fireEvent.change(input, { target: { value: 'new-profile' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(mocks.createProfile).toHaveBeenCalledTimes(1);
+ await act(async () => finish());
+});
diff --git a/client-next/src/app/profiles/page.tsx b/client-next/src/app/profiles/page.tsx
index 88124186..40be4abc 100644
--- a/client-next/src/app/profiles/page.tsx
+++ b/client-next/src/app/profiles/page.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef, useCallback } from "react";
+import { useApp } from "@/lib/context";
import { useTranslations } from "next-intl";
import axios from "axios";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -31,8 +32,15 @@ import { Plus, Trash, Check, User, CardStack, Play } from "@nsmr/pixelart-react"
import { getProfiles, createProfile, deleteProfile, activateProfile, type ProfileList } from "@/lib/api";
import { PageHelp } from "@/components/page-help";
+const getErrorMessage = (err: unknown) => {
+ if (axios.isAxiosError(err)) return err.response?.data?.error || err.message;
+ if (err instanceof Error) return err.message;
+ return null;
+ };
+
export default function ProfilesPage() {
const t = useTranslations('profiles');
+ const { refreshData } = useApp();
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -40,70 +48,73 @@ export default function ProfilesPage() {
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [activating, setActivating] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+ const mutationInFlight = useRef(false);
+ const busy = creating || deleting || activating !== null;
- const getErrorMessage = (err: unknown) => {
- if (axios.isAxiosError(err)) return err.response?.data?.error || err.message;
- if (err instanceof Error) return err.message;
- return null;
- };
-
- const loadProfiles = async () => {
- try {
- const res = await getProfiles();
- setData(res);
- } catch (e) {
- const msg = getErrorMessage(e);
+ const loadProfiles = useCallback(() => getProfiles()
+ .then(setData)
+ .catch((error: unknown) => {
+ const msg = getErrorMessage(error);
toast.error(msg ? t('loadFailedWithError', { error: msg }) : t('loadFailed'));
- } finally {
- setLoading(false);
- }
- };
+ })
+ .finally(() => setLoading(false)), [t]);
useEffect(() => {
- loadProfiles();
- }, []);
+ void loadProfiles();
+ }, [loadProfiles]);
const handleCreate = async () => {
- if (!newProfileName.trim()) return;
+ if (!newProfileName.trim() || mutationInFlight.current) return;
+ mutationInFlight.current = true;
try {
setCreating(true);
await createProfile(newProfileName);
toast.success(t('createSuccess', { name: newProfileName }));
setCreateOpen(false);
setNewProfileName("");
- loadProfiles();
+ await loadProfiles();
} catch (e) {
const msg = getErrorMessage(e);
toast.error(msg ? t('createFailedWithError', { error: msg }) : t('createFailed'));
} finally {
+ mutationInFlight.current = false;
setCreating(false);
}
};
const handleDelete = async () => {
- if (!deleteTarget) return;
+ if (!deleteTarget || mutationInFlight.current) return;
+ mutationInFlight.current = true;
+ setDeleting(true);
try {
await deleteProfile(deleteTarget);
toast.success(t('deleted'));
- loadProfiles();
+ await loadProfiles();
} catch (e) {
const msg = getErrorMessage(e);
toast.error(msg ? t('deleteFailedWithError', { error: msg }) : t('deleteFailed'));
} finally {
+ mutationInFlight.current = false;
+ setDeleting(false);
setDeleteTarget(null);
}
};
const handleActivate = async (name: string) => {
+ if (mutationInFlight.current) return;
+ mutationInFlight.current = true;
try {
setActivating(name);
await activateProfile(name);
+ await refreshData();
toast.success(t('switchSuccess', { name }));
- loadProfiles();
+ await loadProfiles();
} catch (e) {
const msg = getErrorMessage(e);
toast.error(msg ? t('switchFailedWithError', { error: msg }) : t('switchFailed'));
} finally {
+ mutationInFlight.current = false;
setActivating(null);
}
};
@@ -114,7 +125,7 @@ export default function ProfilesPage() {
return (
-
+
@@ -190,7 +203,7 @@ export default function ProfilesPage() {
className="w-full"
variant="outline"
onClick={() => handleActivate(profile)}
- disabled={activating === profile}
+ disabled={busy}
>
{t('switch')}
@@ -203,6 +216,8 @@ export default function ProfilesPage() {
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => setDeleteTarget(profile)}
+ aria-label={`${t('deleteTitle')}: ${profile}`}
+ disabled={busy}
>
diff --git a/client-next/src/app/quickstart/page.tsx b/client-next/src/app/quickstart/page.tsx
index 8eb2c1b5..9b412f1a 100644
--- a/client-next/src/app/quickstart/page.tsx
+++ b/client-next/src/app/quickstart/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useMemo } from "react";
+import { useState, useEffect, useMemo, useSyncExternalStore } from "react";
import { useTranslations } from "next-intl";
import {
Card,
@@ -100,9 +100,13 @@ function ExternalLinkButton({ href, children }: { href: string; children: React.
// --- Main Page Component ---
-export default function QuickstartPage() {
- const [mounted, setMounted] = useState(false);
- const [completedSteps, setCompletedSteps] = useState([]);
+function QuickstartContent() {
+ const [completedSteps, setCompletedSteps] = useState(() => {
+ try {
+ const saved: unknown = JSON.parse(localStorage.getItem("opencode-quickstart-progress") || "[]");
+ return Array.isArray(saved) ? saved.filter((item): item is string => typeof item === 'string') : [];
+ } catch { return []; }
+ });
const [openSections, setOpenSections] = useState>({
ohmyopencode: true,
superpowers: false,
@@ -157,25 +161,10 @@ export default function QuickstartPage() {
providerChoices.gemini === "google" ||
providerChoices.grok === "opencode";
- // Load from localStorage on mount
- useEffect(() => {
- setMounted(true);
- const saved = localStorage.getItem("opencode-quickstart-progress");
- if (saved) {
- try {
- setCompletedSteps(JSON.parse(saved));
- } catch {
- // ignore
- }
- }
- }, []);
-
- // Save to localStorage
useEffect(() => {
- if (mounted) {
- localStorage.setItem("opencode-quickstart-progress", JSON.stringify(completedSteps));
- }
- }, [completedSteps, mounted]);
+ try { localStorage.setItem("opencode-quickstart-progress", JSON.stringify(completedSteps)); }
+ catch { /* Progress remains usable when storage is blocked. */ }
+ }, [completedSteps]);
const toggleSection = (id: string) => {
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
@@ -389,10 +378,6 @@ export default function QuickstartPage() {
const progressPercentage = Math.round((completedSteps.length / steps.length) * 100);
- if (!mounted) {
- return {t('loading')}
;
- }
-
return (
{/* Hero */}
@@ -557,3 +542,10 @@ export default function QuickstartPage() {
);
}
+
+const subscribeHydration = () => () => {};
+export default function QuickstartPage() {
+ const mounted = useSyncExternalStore(subscribeHydration, () => true, () => false);
+ const t = useTranslations('quickstart');
+ return mounted ? : {t('loading')}
;
+}
diff --git a/client-next/src/app/rules/page.tsx b/client-next/src/app/rules/page.tsx
index 67f24324..245c93bd 100644
--- a/client-next/src/app/rules/page.tsx
+++ b/client-next/src/app/rules/page.tsx
@@ -1,6 +1,8 @@
"use client";
-import { useEffect, useState } from "react";
+import { errorMessage } from "@/lib/error-message.mjs";
+
+import { useEffect, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { useTheme } from "next-themes";
import { Editor } from "@monaco-editor/react";
@@ -37,20 +39,13 @@ export default function RulesPage() {
const [path, setPath] = useState(null);
const [saving, setSaving] = useState(false);
- const loadRules = async () => {
- try {
- const data = await getProjectRules();
- setContent(data.content || "");
- setSource(data.source || "none");
- setPath(data.path || null);
- } catch (err: any) {
- toast.error(err?.message || t('loadFailed'));
- }
- };
+ const loadRules = useCallback(() => getProjectRules().then(data => {
+ setContent(data.content || "");
+ setSource(data.source || "none");
+ setPath(data.path || null);
+ }).catch((error: unknown) => { toast.error(errorMessage(error, t('loadFailed'))); }), [t]);
- useEffect(() => {
- loadRules();
- }, []);
+ useEffect(() => { void loadRules(); }, [loadRules]);
const handleSave = async () => {
const target = source === "none" ? "AGENTS.md" : source;
@@ -60,8 +55,8 @@ export default function RulesPage() {
toast.success(t('saved'));
setSource(target);
loadRules();
- } catch (err: any) {
- toast.error(err?.message || t('saveFailed'));
+ } catch (err: unknown) {
+ toast.error(errorMessage(err, t('saveFailed')));
} finally {
setSaving(false);
}
diff --git a/client-next/src/app/settings/code/page.tsx b/client-next/src/app/settings/code/page.tsx
index 6ef291a2..67dcfeb0 100644
--- a/client-next/src/app/settings/code/page.tsx
+++ b/client-next/src/app/settings/code/page.tsx
@@ -1,11 +1,13 @@
"use client";
+import { errorMessage } from "@/lib/error-message.mjs";
+
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useApp } from "@/lib/context";
import { getApiBaseUrl, getPaths, getProjectRules, getSystemTools } from "@/lib/api";
-import type { PathsInfo, RulesResponse, SystemToolInfo } from "@/types";
+import type { PathsInfo, RulesResponse, SystemToolInfo, LSPConfig, FormatterConfig } from "@/types";
import { PageHelp } from "@/components/page-help";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -63,8 +65,8 @@ export default function CodeSettingsPage() {
setPaths(pathsData);
setRules(rulesData);
setTools(toolsData);
- } catch (err: any) {
- toast.error(err?.message || t('code.toast.failedToLoadMetadata'));
+ } catch (err: unknown) {
+ toast.error(errorMessage(err, t('code.toast.failedToLoadMetadata')));
} finally {
setLoading(false);
}
@@ -78,7 +80,7 @@ export default function CodeSettingsPage() {
return map;
}, [tools]);
- const updateLsp = (id: string, next: any) => {
+ const updateLsp = (id: string, next: LSPConfig[string]) => {
if (!config) return;
saveConfig({
...config,
@@ -89,7 +91,7 @@ export default function CodeSettingsPage() {
});
};
- const updateFormatter = (id: string, next: any) => {
+ const updateFormatter = (id: string, next: FormatterConfig[string]) => {
if (!config) return;
saveConfig({
...config,
@@ -148,7 +150,7 @@ export default function CodeSettingsPage() {
{LSP_LIST.map((item) => {
- const cfg = config?.lsp?.[item.id] as any;
+ const cfg = config?.lsp?.[item.id];
const tool = toolMap.get(item.tool);
return (
@@ -193,7 +195,7 @@ export default function CodeSettingsPage() {
{FORMATTER_LIST.map((item) => {
- const cfg = config?.formatter?.[item.id] as any;
+ const cfg = config?.formatter?.[item.id];
const tool = toolMap.get(item.tool);
return (
diff --git a/client-next/src/app/settings/page.tsx b/client-next/src/app/settings/page.tsx
index 894d5aa0..2fd60e1c 100644
--- a/client-next/src/app/settings/page.tsx
+++ b/client-next/src/app/settings/page.tsx
@@ -28,6 +28,7 @@ import { CustomProviderModelEditor } from "@/components/custom-provider-model-ed
import { Sliders as Settings, Android, Download, Upload, Save, ChevronDown, Loader, Code, Github } from "@nsmr/pixelart-react";
import { PageHelp } from "@/components/page-help";
import { toast } from "sonner";
+import { errorMessage } from "@/lib/error-message.mjs";
import Editor from "@monaco-editor/react";
import { useTheme } from "next-themes";
import { SincronizadoCard } from "@/components/sincronizado-card";
@@ -67,7 +68,7 @@ export default function SettingsPage() {
});
const [systemPrompt, setSystemPrompt] = useState("");
- const [loadingPrompt, setLoadingPrompt] = useState(false);
+ const [loadingPrompt, setLoadingPrompt] = useState(true);
const [savingPrompt, setSavingPrompt] = useState(false);
const { theme } = useTheme();
@@ -85,7 +86,9 @@ const [systemPrompt, setSystemPrompt] = useState("");
useEffect(() => {
getPaths().then(setPathsInfo).catch(console.error);
- loadSystemPrompt();
+ api.get('/prompts/global').then(res => setSystemPrompt(res.data.content))
+ .catch(error => console.error("Error loading prompt:", error))
+ .finally(() => setLoadingPrompt(false));
getGitHubBackupStatus().then(status => {
setGhBackupStatus(status);
@@ -98,18 +101,6 @@ const [systemPrompt, setSystemPrompt] = useState("");
}).catch(console.error);
}, []);
- const loadSystemPrompt = async () => {
- try {
- setLoadingPrompt(true);
- const res = await api.get('/prompts/global');
- setSystemPrompt(res.data.content);
- } catch (error) {
- console.error("Error loading prompt:", error);
- } finally {
- setLoadingPrompt(false);
- }
- };
-
const handleSaveSystemPrompt = async () => {
try {
setSavingPrompt(true);
@@ -127,8 +118,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
try {
await saveConfig({ ...config, ...updates });
toast.success(t('toast.settingsSaved'));
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('toast.failedToSaveSettings', { msg }));
}
};
@@ -141,8 +132,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
await refreshData();
toast.success(manualPath ? t('toast.configPathUpdated') : t('toast.resetToAutoDetect'));
setManualPath("");
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('toast.failedToSetConfigPath', { msg }));
}
};
@@ -154,8 +145,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
setPathsInfo(newPaths);
await refreshData();
toast.success(t('toast.resetToAutoDetect'));
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('toast.failedToResetPath', { msg }));
}
};
@@ -173,8 +164,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t('toast.backupDownloaded'));
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('toast.failedToCreateBackup', { msg }));
}
};
@@ -195,8 +186,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
await restoreBackup(backup);
await refreshData();
toast.success(t('toast.backupRestored'));
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('toast.failedToRestoreBackup', { msg }));
}
@@ -220,8 +211,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
} else {
toast.error(result.error || t('toast.backupFailed'));
}
- } catch (err: any) {
- toast.error(err.response?.data?.error || err.message);
+ } catch (err: unknown) {
+ toast.error(errorMessage(err, t('unknownError')));
} finally {
setBackingUp(false);
}
@@ -241,8 +232,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
} else {
toast.error(t('toast.restoreFailed'));
}
- } catch (err: any) {
- toast.error(err.response?.data?.error || err.message);
+ } catch (err: unknown) {
+ toast.error(errorMessage(err, t('unknownError')));
} finally {
setRestoring(false);
}
@@ -254,8 +245,8 @@ const [systemPrompt, setSystemPrompt] = useState("");
const status = await getGitHubBackupStatus();
setGhBackupStatus(status);
toast.success(enabled ? t('toast.autoSyncEnabled') : t('toast.autoSyncDisabled'));
- } catch (err: any) {
- toast.error(err.response?.data?.error || err.message);
+ } catch (err: unknown) {
+ toast.error(errorMessage(err, t('unknownError')));
}
};
diff --git a/client-next/src/app/skills/page.tsx b/client-next/src/app/skills/page.tsx
index 32e87cfa..d799c173 100644
--- a/client-next/src/app/skills/page.tsx
+++ b/client-next/src/app/skills/page.tsx
@@ -21,6 +21,7 @@ import {
import { useRouter } from "next/navigation";
import { deleteSkill } from "@/lib/api";
import { toast } from "sonner";
+import { errorMessage } from "@/lib/error-message.mjs";
import { Search } from "@nsmr/pixelart-react";
import { PageHelp } from "@/components/page-help";
import { PresetsManager } from "@/components/presets-manager";
@@ -51,8 +52,8 @@ export default function SkillsPage() {
await toggleSkill(name);
const skill = skills.find(s => s.name === name);
toast.success(skill?.enabled ? t('toggleDisabled', { name }) : t('toggleEnabled', { name }));
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('toggleFailed', { error: msg }));
}
};
@@ -69,8 +70,8 @@ export default function SkillsPage() {
toast.success(t('deleted', { name: deleteTarget.name }));
setDeleteDialogOpen(false);
refreshData();
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('unknownError');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('unknownError'));
toast.error(t('deleteFailed', { error: msg }));
}
};
diff --git a/client-next/src/app/usage/page.tsx b/client-next/src/app/usage/page.tsx
index 4f2cd40d..d51e0595 100644
--- a/client-next/src/app/usage/page.tsx
+++ b/client-next/src/app/usage/page.tsx
@@ -1,5 +1,7 @@
"use client";
+import { errorMessage } from "@/lib/error-message.mjs";
+
import { useEffect, useState, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -73,6 +75,7 @@ export default function UsagePage() {
const [pieConfig, setPieConfig] = useState({ cx: "45%", cy: "45%", legendRight: 35 });
const [timeRange, setTimeRange] = useState("30d");
const [customRange, setCustomRange] = useState({ start: "", end: "" });
+ const [appliedCustomRange, setAppliedCustomRange] = useState({ start: "", end: "" });
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const [customError, setCustomError] = useState
(null);
const hoveredModelRef = useRef(null);
@@ -104,38 +107,37 @@ export default function UsagePage() {
return;
}
setCustomError(null);
+ setAppliedCustomRange({ ...customRange });
setCustomDialogOpen(false);
setTimeRange("custom");
};
- const fetchStats = async () => {
- setLoading(true);
- try {
+ useEffect(() => {
+ let cancelled = false;
const selectedRange = TIME_RANGES.find(r => r.value === timeRange) || TIME_RANGES[2];
const isCustom = timeRange === "custom";
let from: number | undefined;
let to: number | undefined;
if (isCustom) {
- if (!customRange.start || !customRange.end) {
- setLoading(false);
+ if (!appliedCustomRange.start || !appliedCustomRange.end) {
return;
}
- from = Date.parse(`${customRange.start}T00:00:00.000Z`);
- to = Date.parse(`${customRange.end}T23:59:59.999Z`);
+ from = Date.parse(`${appliedCustomRange.start}T00:00:00.000Z`);
+ to = Date.parse(`${appliedCustomRange.end}T23:59:59.999Z`);
if (Number.isNaN(from) || Number.isNaN(to)) {
- setLoading(false);
return;
}
}
- const data = await getUsageStats(
+ void getUsageStats(
projectId,
selectedRange.granularity,
isCustom ? "custom" : selectedRange.value,
from,
to
- );
+ ).then(data => {
+ if (cancelled) return;
const enrichedStats: UsageStats = {
totalCost: 0,
@@ -154,12 +156,12 @@ export default function UsagePage() {
enrichedStats.totalCost = enrichedStats.byModel.reduce((acc, m) => acc + m.cost, 0);
const modelNames = enrichedStats.byModel.map(m => m.name);
- const processedByDay = (data.byDay || []).map((d: any) => {
+ const processedByDay = (data.byDay || []).map((d) => {
const modelCosts: Record = {};
modelNames.forEach(mid => {
modelCosts[mid] = 0;
- const it = d[`${mid}_input`] || 0;
- const ot = d[`${mid}_output`] || 0;
+ const it = Number(d[`${mid}_input`]) || 0;
+ const ot = Number(d[`${mid}_output`]) || 0;
if (it > 0 || ot > 0) {
modelCosts[mid] = calculateCost(mid, it, ot);
}
@@ -173,18 +175,14 @@ export default function UsagePage() {
enrichedStats.byDay = processedByDay;
setStats(enrichedStats);
- } catch (e: any) {
- const msg = e.response?.data?.error || e.message || "Unknown error";
+ }).catch((e: unknown) => {
+ if (cancelled) return;
+ const msg = errorMessage(e, "Unknown error");
toast.error(t('fetchFailed', { error: msg }));
console.error(e);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchStats();
- }, [projectId, timeRange, customRange.start, customRange.end]);
+ }).finally(() => { if (!cancelled) setLoading(false); });
+ return () => { cancelled = true; };
+ }, [projectId, timeRange, appliedCustomRange.start, appliedCustomRange.end, t]);
const pieData = useMemo(() => {
if (!stats) return [];
@@ -526,7 +524,7 @@ export default function UsagePage() {
content={({ active, payload, label }) => {
const hovered = hoveredModelRef.current;
if (barTooltipRef.current && active && payload && payload.length && hovered) {
- const entry = payload.find((p: any) => p.name === hovered);
+ const entry = payload.find((p) => p.name === hovered);
if (entry) {
const inputTokens = entry.payload[`${hovered}_input`] || 0;
const outputTokens = entry.payload[`${hovered}_output`] || 0;
@@ -647,7 +645,7 @@ export default function UsagePage() {
align="right"
wrapperStyle={{ fontSize: "11px", right: pieConfig.legendRight }}
content={({ payload }) => {
- const sortedPayload = [...(payload || [])].sort((a: any, b: any) => {
+ const sortedPayload = [...(payload || [])].sort((a, b) => {
if (a.value === "Others") return 1;
if (b.value === "Others") return -1;
const aCost = pieData.find(p => p.name === a.value)?.cost || 0;
@@ -656,7 +654,7 @@ export default function UsagePage() {
});
return (
- {sortedPayload.map((entry: any, index: number) => (
+ {sortedPayload.map((entry, index: number) => (
-
{entry.value}
diff --git a/client-next/src/components/add-mcp-dialog.tsx b/client-next/src/components/add-mcp-dialog.tsx
index a898b2cb..bdd2576b 100644
--- a/client-next/src/components/add-mcp-dialog.tsx
+++ b/client-next/src/components/add-mcp-dialog.tsx
@@ -1,6 +1,8 @@
"use client";
-import { useState, useEffect } from "react";
+import { errorMessage } from "@/lib/error-message.mjs";
+
+import { useState } from "react";
import {
Dialog,
DialogContent,
@@ -141,15 +143,15 @@ export function AddMCPDialog({ onAdd }: AddMCPDialogProps) {
setError("");
};
- useEffect(() => {
- if (!pasteInput.trim()) return;
-
- const parsed = parseInput(pasteInput);
+ const handlePasteInput = (value: string) => {
+ setPasteInput(value);
+ if (!value.trim()) return;
+ const parsed = parseInput(value);
if (parsed) {
setName(parsed.name);
setConfigJson(JSON.stringify(parsed.config, null, 2));
}
- }, [pasteInput]);
+ };
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -182,8 +184,8 @@ export function AddMCPDialog({ onAdd }: AddMCPDialogProps) {
await onAdd(name, config);
resetForm();
setOpen(false);
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('addMcp.errors.failedToAdd');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('addMcp.errors.failedToAdd'));
setError(msg);
} finally {
setLoading(false);
@@ -218,7 +220,7 @@ export function AddMCPDialog({ onAdd }: AddMCPDialogProps) {
-
setOpen(false)}>
+ { setOpen(false); resetForm(); }}>
{t('cancel')}
diff --git a/client-next/src/components/add-plugin-dialog.tsx b/client-next/src/components/add-plugin-dialog.tsx
index ee97b667..d369d510 100644
--- a/client-next/src/components/add-plugin-dialog.tsx
+++ b/client-next/src/components/add-plugin-dialog.tsx
@@ -1,5 +1,7 @@
"use client";
+import { errorMessage } from "@/lib/error-message.mjs";
+
import { useState } from "react";
import {
Dialog,
@@ -482,8 +484,8 @@ export function AddPluginDialog({ onSuccess }: AddPluginDialogProps) {
resetForm();
setOpen(false);
onSuccess();
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('addPlugin.errors.createFailed');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('addPlugin.errors.createFailed'));
setError(msg);
} finally {
setLoading(false);
diff --git a/client-next/src/components/add-skill-dialog.tsx b/client-next/src/components/add-skill-dialog.tsx
index 8573798e..2a45ca2a 100644
--- a/client-next/src/components/add-skill-dialog.tsx
+++ b/client-next/src/components/add-skill-dialog.tsx
@@ -1,5 +1,7 @@
"use client";
+import { errorMessage } from "@/lib/error-message.mjs";
+
import { useState } from "react";
import {
Dialog,
@@ -216,8 +218,8 @@ export function AddSkillDialog({ onSuccess }: AddSkillDialogProps) {
resetForm();
setOpen(false);
onSuccess();
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || t('addSkill.errors.createFailed');
+ } catch (err: unknown) {
+ const msg = errorMessage(err, t('addSkill.errors.createFailed'));
setError(msg);
} finally {
setLoading(false);
diff --git a/client-next/src/components/agent-card.tsx b/client-next/src/components/agent-card.tsx
index 66764bb1..b8729863 100644
--- a/client-next/src/components/agent-card.tsx
+++ b/client-next/src/components/agent-card.tsx
@@ -17,8 +17,8 @@ interface AgentCardProps {
export function AgentCard({ agent, onEdit, onDelete, onToggle }: AgentCardProps) {
const t = useTranslations("agents");
- const sourceLabelKey = agent.source === "json" ? "sourceJson" : agent.source === "markdown" ? "sourceMarkdown" : "sourceBuiltin";
- const modeLabel = agent.mode || t("defaultMode");
+ const sourceLabelKey = ["json", "json-config"].includes(agent.source) ? "sourceJson" : agent.source === "markdown" ? "sourceMarkdown" : "sourceBuiltin";
+ const modeLabel = agent.mode || (agent.configurationFormat === "v2" ? "primary" : t("defaultMode"));
return (
diff --git a/client-next/src/components/app-shell.tsx b/client-next/src/components/app-shell.tsx
index ab80a0b6..bbb29345 100644
--- a/client-next/src/components/app-shell.tsx
+++ b/client-next/src/components/app-shell.tsx
@@ -183,9 +183,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
}
return (
-
+
-
+
{children}
diff --git a/client-next/src/components/bulk-import-dialog.tsx b/client-next/src/components/bulk-import-dialog.tsx
index 2038359d..d037beb5 100644
--- a/client-next/src/components/bulk-import-dialog.tsx
+++ b/client-next/src/components/bulk-import-dialog.tsx
@@ -1,5 +1,7 @@
"use client";
+import { errorMessage } from "@/lib/error-message.mjs";
+
import { useState, useCallback } from "react";
import {
Dialog,
@@ -188,8 +190,8 @@ export function BulkImportDialog({ type, existingNames, onSuccess }: BulkImportD
await savePlugin(pluginName, item.result!.content || "");
}
successCount++;
- } catch (err: any) {
- const msg = err.response?.data?.error || err.message || "Unknown error";
+ } catch (err: unknown) {
+ const msg = errorMessage(err, "Unknown error");
console.error(`Failed to import ${item.result?.name}:`, msg);
failCount++;
}
diff --git a/client-next/src/components/cli-config-editor.test.tsx b/client-next/src/components/cli-config-editor.test.tsx
new file mode 100644
index 00000000..8cf74572
--- /dev/null
+++ b/client-next/src/components/cli-config-editor.test.tsx
@@ -0,0 +1,18 @@
+import { afterEach, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { CliConfigEditor } from './cli-config-editor';
+const api = vi.hoisted(() => ({ getCliConfig: vi.fn(), saveCliConfig: vi.fn() }));
+vi.mock('@/lib/api', () => api);
+afterEach(() => { cleanup(); vi.resetAllMocks(); });
+
+it('shows a stale-write error without discarding the draft', async () => {
+ api.getCliConfig.mockResolvedValue({ content: '{}', exists: true, revision: 'original' });
+ api.saveCliConfig.mockRejectedValue(new Error('CLI configuration changed on disk. Reload before saving.'));
+ render();
+ const editor = await screen.findByRole('textbox', { name: 'cli.json' });
+ fireEvent.change(editor, { target: { value: '{"theme":"custom"}' } });
+ fireEvent.click(screen.getByText('Save cli.json'));
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('changed on disk'));
+ expect(api.saveCliConfig).toHaveBeenCalledWith({ content: '{"theme":"custom"}', revision: 'original' });
+ expect((editor as HTMLTextAreaElement).value).toBe('{"theme":"custom"}');
+});
diff --git a/client-next/src/components/cli-config-editor.tsx b/client-next/src/components/cli-config-editor.tsx
new file mode 100644
index 00000000..2e5e9485
--- /dev/null
+++ b/client-next/src/components/cli-config-editor.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { useEffect, useState } from 'react';
+import { getCliConfig, saveCliConfig, type CliConfigDocument } from '@/lib/api';
+import { errorMessage } from '@/lib/error-message.mjs';
+import { Button } from '@/components/ui/button';
+import { Textarea } from '@/components/ui/textarea';
+
+export function CliConfigEditor() {
+ const [document, setDocument] = useState(null);
+ const [draft, setDraft] = useState(null);
+ const [error, setError] = useState('');
+ const [saving, setSaving] = useState(false);
+ useEffect(() => {
+ let cancelled = false;
+ getCliConfig().then(value => { if (!cancelled) setDocument(value); })
+ .catch(reason => { if (!cancelled) setError(errorMessage(reason, 'Could not load cli.json')); });
+ return () => { cancelled = true; };
+ }, []);
+ const save = async () => {
+ if (!document || draft === null || saving) return;
+ const submitted = draft;
+ setSaving(true); setError('');
+ try {
+ const result = await saveCliConfig({ content: submitted, revision: document.revision });
+ setDocument(result);
+ setDraft(current => current === submitted ? null : current);
+ } catch (reason) { setError(errorMessage(reason, 'Could not save cli.json')); }
+ finally { setSaving(false); }
+ };
+ return
+ OpenCode v2 terminal settings
+ Global cli.json controls the v2 terminal client. This editor does not migrate or overwrite tui.json, and these settings do not apply to OpenCode v1.
+ Official migration guide
+ {error && {error}
}
+ {!document ? Loading cli.json…
: <>
+ {!document.exists && No cli.json exists yet. Saving creates it; allow OpenCode’s first launch to migrate your old settings first if needed.
}
+ ;
+}
diff --git a/client-next/src/components/debug-menu.tsx b/client-next/src/components/debug-menu.tsx
index 87d203ec..444005ee 100644
--- a/client-next/src/components/debug-menu.tsx
+++ b/client-next/src/components/debug-menu.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useState, useCallback } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -11,12 +11,20 @@ import { cn } from "@/lib/utils";
export function DebugMenu() {
const t = useTranslations("common");
const [isOpen, setIsOpen] = useState(false);
- const [debugInfo, setDebugInfo] = useState(null);
- const [loading, setLoading] = useState(false);
+ const [debugInfo, setDebugInfo] = useState | null>(null);
+ const [loading, setLoading] = useState(true);
+
+ const fetchDebugInfo = useCallback(() => getDebugInfo().then(info => {
+ setDebugInfo({ ...info, userAgent: navigator.userAgent, timestamp: new Date().toISOString(), windowSize: `${window.innerWidth}x${window.innerHeight}` });
+ }).catch(error => {
+ console.error("Failed to fetch debug info", error);
+ setDebugInfo({ error: t("debug.failedToFetch") });
+ }).finally(() => setLoading(false)), [t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "F2") {
+ setLoading(true);
setIsOpen((prev) => !prev);
}
};
@@ -29,25 +37,7 @@ export function DebugMenu() {
if (isOpen) {
fetchDebugInfo();
}
- }, [isOpen]);
-
- const fetchDebugInfo = async () => {
- setLoading(true);
- try {
- const info = await getDebugInfo();
- setDebugInfo({
- ...info,
- userAgent: navigator.userAgent,
- timestamp: new Date().toISOString(),
- windowSize: `${window.innerWidth}x${window.innerHeight}`,
- });
- } catch (err) {
- console.error("Failed to fetch debug info", err);
- setDebugInfo({ error: t("debug.failedToFetch") });
- } finally {
- setLoading(false);
- }
- };
+ }, [isOpen, fetchDebugInfo]);
const copyToClipboard = () => {
if (debugInfo) {
@@ -75,18 +65,18 @@ export function DebugMenu() {
{t("debug.systemInformation")}
{new Date().toLocaleTimeString()}
-
+
{loading ? t("debug.fetchingData") : JSON.stringify(debugInfo, null, 2)}
-
+
-
{ setLoading(true); void fetchDebugInfo(); }}
+ variant="outline"
+ size="sm"
className="gap-2"
disabled={loading}
>
diff --git a/client-next/src/components/isometric-chart.tsx b/client-next/src/components/isometric-chart.tsx
index b0881f19..f72df99e 100644
--- a/client-next/src/components/isometric-chart.tsx
+++ b/client-next/src/components/isometric-chart.tsx
@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef } from 'react';
-// @ts-ignore - obelisk.js doesn't have types
import obelisk from 'obelisk.js';
interface IsometricChartProps {
diff --git a/client-next/src/components/isometric-heatmap.tsx b/client-next/src/components/isometric-heatmap.tsx
index 2d30fdb8..cc1ec35a 100644
--- a/client-next/src/components/isometric-heatmap.tsx
+++ b/client-next/src/components/isometric-heatmap.tsx
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react';
-// @ts-ignore - obelisk.js doesn't have types
import obelisk from 'obelisk.js';
interface IsometricHeatmapProps {
diff --git a/client-next/src/components/language-switcher.tsx b/client-next/src/components/language-switcher.tsx
index f3c1f245..4f5a06f3 100644
--- a/client-next/src/components/language-switcher.tsx
+++ b/client-next/src/components/language-switcher.tsx
@@ -12,16 +12,20 @@ import {
const locales = [
{ code: "en", label: "English", triggerLabel: "EN" },
- { code: "zh-CN", label: "중文", triggerLabel: "中" },
+ { code: "zh-CN", label: "中文", triggerLabel: "中" },
{ code: "ko", label: "한국어", triggerLabel: "한" },
] as const;
+function persistLocale(locale: string) {
+ document.cookie = `locale=${encodeURIComponent(locale)};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`;
+}
+
export function LanguageSwitcher() {
const currentLocale = useLocale();
const router = useRouter();
- const handleChange = async (locale: string) => {
- document.cookie = `locale=${locale};path=/;max-age=${60 * 60 * 24 * 365}`;
+ const handleChange = (locale: string) => {
+ persistLocale(locale);
router.refresh();
};
diff --git a/client-next/src/components/mcp-card.tsx b/client-next/src/components/mcp-card.tsx
index 2240d308..e530a0c5 100644
--- a/client-next/src/components/mcp-card.tsx
+++ b/client-next/src/components/mcp-card.tsx
@@ -1,5 +1,6 @@
"use client";
+import { mcpEnabled } from "@/lib/mcp-config";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Card, CardContent } from "@/components/ui/card";
@@ -50,7 +51,7 @@ export function MCPCard({ name, config, onToggle, onDelete, onEdit }: MCPCardPro
let newConfig: MCPConfig;
try {
newConfig = JSON.parse(configJson);
- if (typeof newConfig !== "object" || Array.isArray(newConfig)) {
+ if (!newConfig || typeof newConfig !== "object" || Array.isArray(newConfig)) {
setError(t("configMustBeObject"));
return;
}
@@ -72,7 +73,7 @@ export function MCPCard({ name, config, onToggle, onDelete, onEdit }: MCPCardPro
return (
<>
-
+
@@ -95,7 +96,7 @@ export function MCPCard({ name, config, onToggle, onDelete, onEdit }: MCPCardPro
e.stopPropagation()}
aria-label={`Toggle ${name}`}
diff --git a/client-next/src/components/permission-editor.tsx b/client-next/src/components/permission-editor.tsx
index 16438ce4..404334ad 100644
--- a/client-next/src/components/permission-editor.tsx
+++ b/client-next/src/components/permission-editor.tsx
@@ -71,7 +71,7 @@ export function PermissionEditor({ value, onChange, className }: PermissionEdito
});
}, [search, onlyEnabled, value]);
- const updateTool = (tool: keyof PermissionConfig, next: any) => {
+ const updateTool = (tool: keyof PermissionConfig, next: PermissionConfig[keyof PermissionConfig] | Record) => {
onChange({
...value,
[tool]: next,
@@ -120,7 +120,7 @@ export function PermissionEditor({ value, onChange, className }: PermissionEdito
value={mode}
onValueChange={(next) => {
if (next === "simple") updateTool(tool as keyof PermissionConfig, "ask");
- if (next === "map") updateTool(tool as keyof PermissionConfig, { "*": "ask" } as PermissionConfig);
+ if (next === "map") updateTool(tool as keyof PermissionConfig, { "*": "ask" });
if (next === "list")
updateTool(
tool as keyof PermissionConfig,
@@ -168,7 +168,7 @@ export function PermissionEditor({ value, onChange, className }: PermissionEdito
- setCreateOpen(true)}>
+
{t("presets.newPreset")}
@@ -327,26 +322,26 @@ export function PresetsManager() {
- {selectedMcps.length}/{config?.mcp ? Object.keys(config.mcp).length : 0}
+ {selectedMcps.length}/{config?.mcp ? Object.keys(mcpServers(config)).length : 0}
{
- const allLocks = config?.mcp ? Object.keys(config.mcp) : [];
+ const allLocks = config?.mcp ? Object.keys(mcpServers(config)) : [];
if (selectedMcps.length === allLocks.length) setSelectedMcps([]);
else setSelectedMcps(allLocks);
}}
>
- {selectedMcps.length === (config?.mcp ? Object.keys(config.mcp).length : 0) ? t("presets.none") : t("presets.all")}
+ {selectedMcps.length === (config?.mcp ? Object.keys(mcpServers(config)).length : 0) ? t("presets.none") : t("presets.all")}
- {!config?.mcp || Object.keys(config.mcp).length === 0 ? (
+ {!config?.mcp || Object.keys(mcpServers(config)).length === 0 ? (
{t("presets.noMcpServersFound")}
) : (
- Object.keys(config.mcp).map(key => (
+ Object.keys(mcpServers(config)).map(key => (
void;
}
-export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderDetailPanelProps) {
+function ProviderDetailContent({ providerId, onBack, onRefresh }: ProviderDetailPanelProps) {
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
@@ -130,27 +130,18 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD
setSelectedProfilePath(activeProfile?.path ?? result.profiles?.[0]?.path ?? "");
}, [isOpenAgent, providerId]);
- const loadDetail = useCallback(async () => {
- try {
- setLoading(true);
- setError("");
- setStaleWriteWarning(false);
- const result = await getConfigProvider(providerId);
- setDetail(result);
- setRawText(result.raw ?? "");
- setDiagnostics(result.diagnostics ?? []);
- setHasChanges(false);
- await loadProfiles();
- } catch {
- setError("Failed to load provider detail");
- } finally {
- setLoading(false);
- }
- }, [loadProfiles, providerId]);
+ const loadDetail = useCallback(() => getConfigProvider(providerId).then(async result => {
+ setError("");
+ setStaleWriteWarning(false);
+ setDetail(result);
+ setRawText(result.raw ?? "");
+ setDiagnostics(result.diagnostics ?? []);
+ setHasChanges(false);
+ await loadProfiles();
+ }).catch(() => setError("Failed to load provider detail"))
+ .finally(() => setLoading(false)), [loadProfiles, providerId]);
- useEffect(() => {
- loadDetail();
- }, [loadDetail]);
+ useEffect(() => { void loadDetail(); }, [loadDetail]);
const handleRawChange = (value: string) => {
setRawText(value);
@@ -758,3 +749,7 @@ export function ProviderDetailPanel({ providerId, onBack, onRefresh }: ProviderD
>
);
}
+
+export function ProviderDetailPanel(props: ProviderDetailPanelProps) {
+ return ;
+}
diff --git a/client-next/src/components/sidebar.tsx b/client-next/src/components/sidebar.tsx
index 3971e80a..b142f2c5 100644
--- a/client-next/src/components/sidebar.tsx
+++ b/client-next/src/components/sidebar.tsx
@@ -66,13 +66,13 @@ export function Sidebar() {
return (
-
-
+
+
-

+
-
-
+
-
+
{connected ? t('status.connected') : t('status.disconnected')}
-
+
{!connected && (
-
+
@@ -179,7 +179,7 @@ export function Sidebar() {
{connected && (
- setShowDisconnectDialog(true)}>
+ setShowDisconnectDialog(true)} aria-label={t('tooltips.disconnectBackend')}>
@@ -191,7 +191,7 @@ export function Sidebar() {
-
+
diff --git a/client-next/src/lib/api.test.tsx b/client-next/src/lib/api.test.tsx
new file mode 100644
index 00000000..113d62b3
--- /dev/null
+++ b/client-next/src/lib/api.test.tsx
@@ -0,0 +1,35 @@
+import { afterEach, expect, it, vi } from 'vitest';
+import api, { getUsageStats, getCommand, saveCommand, deleteCommand } from './api';
+
+afterEach(() => vi.restoreAllMocks());
+
+it('edits native v2 commands without losing metadata or unrelated config', async () => {
+ const config = { commands: { review: { template: 'old', description: 'keep', agent: 'reviewer' } }, policies: [{ action: 'provider.use', resource: '*', effect: 'deny' }] };
+ vi.spyOn(api, 'get').mockResolvedValue({ data: config });
+ const post = vi.spyOn(api, 'post').mockResolvedValue({ data: { success: true } });
+ expect(await getCommand('review')).toEqual(config.commands.review);
+ await saveCommand('review', 'new');
+ expect(post).toHaveBeenCalledWith('/config', { ...config, commands: { review: { ...config.commands.review, template: 'new' } } });
+});
+
+it('deletes native and shadowed legacy command entries without resurrecting one', async () => {
+ const config = { command: { review: { template: 'legacy' }, keep: { template: 'keep' } }, commands: { review: { template: 'native' } } };
+ vi.spyOn(api, 'get').mockResolvedValue({ data: config });
+ const post = vi.spyOn(api, 'post').mockResolvedValue({ data: { success: true } });
+ await deleteCommand('review');
+ expect(post).toHaveBeenCalledWith('/config', { command: { keep: { template: 'keep' } }, commands: {} });
+ expect(config.command.review.template).toBe('legacy');
+});
+
+it('does not report zero usage when the backend request fails', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ vi.spyOn(api, 'get').mockRejectedValueOnce(new Error('backend unavailable'));
+ await expect(getUsageStats()).rejects.toThrow('backend unavailable');
+});
+
+it('returns genuine zero usage and preserves epoch range boundaries', async () => {
+ const data = { totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] };
+ const get = vi.spyOn(api, 'get').mockResolvedValueOnce({ data });
+ expect(await getUsageStats(null, 'daily', 'custom', 0, 0)).toBe(data);
+ expect(get).toHaveBeenCalledWith('/usage?granularity=daily&range=custom&from=0&to=0');
+});
diff --git a/client-next/src/lib/api.ts b/client-next/src/lib/api.ts
index 1ce0a98d..557a96a0 100644
--- a/client-next/src/lib/api.ts
+++ b/client-next/src/lib/api.ts
@@ -434,18 +434,21 @@ export async function getModels(): Promise<{ providers: unknown[]; models: unkno
export async function getCommand(name: string): Promise<{ template: string }> {
const config = await getConfig();
- const cmd = config.command?.[name];
+ const cmd = config.commands?.[name] ?? config.command?.[name];
if (!cmd) throw new Error('Command not found');
return cmd;
}
export async function saveCommand(name: string, template: string): Promise {
const config = await getConfig();
+ const key = config.commands !== undefined || (config.command === undefined && (config.agents || config.providers || config.permissions))
+ ? 'commands' : 'command';
+ const commands = config[key];
const updated = {
...config,
- command: {
- ...config.command,
- [name]: { template }
+ [key]: {
+ ...commands,
+ [name]: { ...(config.commands?.[name] ?? config.command?.[name]), template }
}
};
await saveConfig(updated);
@@ -453,11 +456,16 @@ export async function saveCommand(name: string, template: string): Promise
export async function deleteCommand(name: string): Promise {
const config = await getConfig();
- if (config.command) {
- const rest = { ...config.command };
- delete rest[name];
- await saveConfig({ ...config, command: rest });
+ const updated = { ...config };
+ let changed = false;
+ for (const key of ['command', 'commands'] as const) {
+ if (config[key] && Object.hasOwn(config[key], name)) {
+ updated[key] = { ...config[key] };
+ delete updated[key]![name];
+ changed = true;
+ }
}
+ if (changed) await saveConfig(updated);
}
export interface BackupData {
@@ -466,7 +474,7 @@ export interface BackupData {
studioConfig: Record;
opencodeConfig: OpencodeConfig | null;
skills: { name: string; content: string }[];
- plugins: { name: string; content: string }[];
+ plugins: { name: string; content: string; extension?: ".js" | ".ts" }[];
}
export async function getBackup(): Promise {
@@ -656,7 +664,7 @@ export interface UsageStats {
totalCost: number;
totalTokens: number;
byModel: { name: string; cost: number; tokens: number; inputTokens: number; outputTokens: number }[];
- byDay: { date: string; cost: number; tokens: number; inputTokens: number; outputTokens: number }[];
+ byDay: { date: string; cost: number; tokens: number; inputTokens: number; outputTokens: number; [key: string]: string | number }[];
byProject: { id: string; name: string; cost: number; tokens: number; inputTokens: number; outputTokens: number }[];
}
@@ -672,14 +680,14 @@ export const getUsageStats = async (
if (projectId) params.set('projectId', projectId);
if (granularity) params.set('granularity', granularity);
if (range) params.set('range', range);
- if (from) params.set('from', String(from));
- if (to) params.set('to', String(to));
+ if (from !== undefined) params.set('from', String(from));
+ if (to !== undefined) params.set('to', String(to));
const res = await api.get(`/usage?${params.toString()}`);
return res.data;
} catch (error) {
console.error("Failed to fetch usage stats:", error);
- return { totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] };
+ throw error;
}
};
@@ -898,3 +906,12 @@ export async function setGitHubAutoSync(enabled: boolean): Promise<{ success: bo
}
export default api;
+
+
+export interface CliConfigDocument { content: string; exists: boolean; revision: string | null }
+export async function getCliConfig(): Promise {
+ return (await api.get('/cli-config')).data;
+}
+export async function saveCliConfig(document: Pick): Promise {
+ return (await api.post('/cli-config', document)).data;
+}
diff --git a/client-next/src/lib/context.test.tsx b/client-next/src/lib/context.test.tsx
new file mode 100644
index 00000000..6b4b2d3e
--- /dev/null
+++ b/client-next/src/lib/context.test.tsx
@@ -0,0 +1,81 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { AppProvider, useApp } from './context';
+
+const api = vi.hoisted(() => ({
+ getConfig: vi.fn(), getSkills: vi.fn(), getPlugins: vi.fn(),
+ saveConfig: vi.fn(), toggleSkill: vi.fn(), togglePlugin: vi.fn(),
+ checkHealth: vi.fn(), checkVersion: vi.fn(), getPendingAction: vi.fn(),
+ clearPendingAction: vi.fn(), syncAuto: vi.fn(), syncPush: vi.fn(), getSyncStatus: vi.fn(),
+}));
+vi.mock('@/lib/api', () => api);
+vi.mock('@/components/update-required-modal', () => ({ UpdateRequiredModal: () => null }));
+
+function Probe() {
+ const { config, skills, plugins, loading, error, refreshData, toggleMCP } = useApp();
+ return <>
+ void refreshData()}>refresh
+ void toggleMCP("fixture")}>toggle MCP
+
+ >;
+}
+const state = () => JSON.parse(screen.getByTestId('state').textContent || '{}');
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise(done => { resolve = done; });
+ return { promise, resolve };
+}
+
+beforeEach(() => {
+ vi.resetAllMocks();
+ api.getConfig.mockResolvedValue({ theme: 'original' });
+ api.getSkills.mockResolvedValue([{ name: 'old-skill' }]);
+ api.getPlugins.mockResolvedValue([{ name: 'old-plugin' }]);
+ api.checkHealth.mockResolvedValue(true);
+ api.checkVersion.mockResolvedValue({ connected: true, isCompatible: true });
+ api.getPendingAction.mockResolvedValue(null);
+ api.syncAuto.mockResolvedValue({ action: 'none' });
+});
+afterEach(cleanup);
+
+describe('shared configuration refresh', () => {
+ it('does not let a slower previous profile response overwrite the latest refresh', async () => {
+ render();
+ await waitFor(() => expect(state().loading).toBe(false));
+ const old = deferred<{ theme: string }>();
+ const latest = deferred<{ theme: string }>();
+ api.getConfig.mockImplementationOnce(() => old.promise).mockImplementationOnce(() => latest.promise);
+ fireEvent.click(screen.getByText('refresh'));
+ fireEvent.click(screen.getByText('refresh'));
+ await act(async () => latest.resolve({ theme: 'latest' }));
+ expect(state().config.theme).toBe('latest');
+ await act(async () => old.resolve({ theme: 'stale' }));
+ expect(state().config.theme).toBe('latest');
+ expect(state().loading).toBe(false);
+ });
+
+ it('clears previous profile resources when the new profile has no configuration', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ render();
+ await waitFor(() => expect(state().loading).toBe(false));
+ expect(state().skills).toHaveLength(1);
+ api.getConfig.mockRejectedValueOnce({ isAxiosError: true, response: { status: 404, data: {} } });
+ fireEvent.click(screen.getByText('refresh'));
+ await waitFor(() => expect(state().config).toBeNull());
+ expect(state().skills).toEqual([]);
+ expect(state().plugins).toEqual([]);
+ expect(state().error).toContain('configuration not found');
+ });
+});
+
+
+it('MCP changes read the latest config instead of overwriting edits from other pages', async () => {
+ api.getConfig.mockResolvedValue({ agents: { reviewer: { system: 'old' } }, mcp: { servers: { fixture: { type: 'local', command: ['fixture'], disabled: true } } } });
+ render();
+ await waitFor(() => expect(state().loading).toBe(false));
+ const latest = { agents: { reviewer: { system: 'changed elsewhere', permissions: [{ action: 'shell', resource: '*', effect: 'deny' }] } }, mcp: { timeout: { startup: 1234 }, servers: { fixture: { type: 'local', command: ['fixture'], disabled: true } } } };
+ api.getConfig.mockResolvedValue(latest);
+ fireEvent.click(screen.getByText('toggle MCP'));
+ await waitFor(() => expect(api.saveConfig).toHaveBeenCalledOnce());
+ expect(api.saveConfig.mock.calls[0][0]).toEqual({ ...latest, mcp: { ...latest.mcp, servers: { fixture: { ...latest.mcp.servers.fixture, disabled: false } } } });
+});
diff --git a/client-next/src/lib/context.tsx b/client-next/src/lib/context.tsx
index 22be90fc..d8f0d80e 100644
--- a/client-next/src/lib/context.tsx
+++ b/client-next/src/lib/context.tsx
@@ -1,5 +1,7 @@
"use client";
+import axios from "axios";
+import { changeMcp, mcpServers, mcpEnabled, nativeMcp } from "@/lib/mcp-config";
import { createContext, useContext, useState, useEffect, useCallback, useRef, type ReactNode } from 'react';
import { getConfig, saveConfig as apiSaveConfig, getSkills, getPlugins, toggleSkill as apiToggleSkill, togglePlugin as apiTogglePlugin, checkHealth, checkVersion, getPendingAction, clearPendingAction, syncAuto, syncPush, getSyncStatus, type PendingAction, type VersionCheck } from '@/lib/api';
import type { OpencodeConfig, MCPConfig, SkillInfo, PluginInfo } from '@/types';
@@ -57,43 +59,48 @@ export function AppProvider({ children }: { children: ReactNode }) {
const [connected, setConnected] = useState(false);
const [pendingAction, setPendingAction] = useState(null);
const [versionInfo, setVersionInfo] = useState(null);
- const healthCheckRef = useRef(null);
- const loadingRef = useRef(false);
+ const connectedRef = useRef(false);
+ const updateConnection = useCallback((value: boolean) => {
+ connectedRef.current = value;
+ setConnected(value);
+ }, []);
+ const refreshGenerationRef = useRef(0);
const checkedPendingRef = useRef(false);
const versionCheckedRef = useRef(false);
const autoSyncCheckedRef = useRef(false);
- const refreshData = useCallback(async () => {
- // Prevent multiple simultaneous refreshes
- if (loadingRef.current) return;
-
- try {
- loadingRef.current = true;
- setLoading(true);
- setError(null);
- const [configData, skillsData, pluginsData] = await Promise.all([
+ const fetchData = useCallback(() => {
+ // Newer refreshes (especially profile switches) own the resulting state.
+ const generation = ++refreshGenerationRef.current;
+ return Promise.all([
getConfig(),
getSkills(),
getPlugins(),
- ]);
+ ]).then(([configData, skillsData, pluginsData]) => {
+ if (generation !== refreshGenerationRef.current) return;
+ setError(null);
setConfig(configData);
setSkills(skillsData);
setPlugins(pluginsData);
// We are connected because the requests succeeded
- setConnected(true);
- } catch (err: any) {
+ updateConnection(true);
+ }).catch((err: unknown) => {
+ if (generation !== refreshGenerationRef.current) return;
let errorMessage = 'Failed to load data from backend';
- if (err.code === 'ERR_NETWORK') {
+ const httpError = axios.isAxiosError<{ error?: string; message?: string }>(err) ? err : null;
+ if (httpError?.code === 'ERR_NETWORK') {
errorMessage = 'Backend server is unreachable. Ensure "npm start" is running.';
- } else if (err.response) {
- const status = err.response.status;
- const data = err.response.data;
+ } else if (httpError?.response) {
+ const status = httpError.response.status;
+ const data = httpError.response.data;
if (status === 404) {
errorMessage = 'OpenCode configuration not found. Please run "opencode --version" in your terminal to initialize it.';
setConfig(null);
- setConnected(true);
+ setSkills([]);
+ setPlugins([]);
+ updateConnection(true);
} else if (status === 500) {
errorMessage = `Server Error (500): ${data?.error || data?.message || 'Check backend logs'}`;
} else if (status === 403) {
@@ -101,18 +108,23 @@ export function AppProvider({ children }: { children: ReactNode }) {
} else {
errorMessage = `HTTP Error ${status}: ${data?.error || data?.message || 'Unknown server error'}`;
}
- } else if (err.message) {
+ } else if (err instanceof Error) {
errorMessage = `Error: ${err.message}`;
}
setError(errorMessage);
- setConnected(err.response ? true : false);
+ updateConnection(Boolean(httpError?.response));
console.error(err);
- } finally {
- setLoading(false);
- loadingRef.current = false;
- }
- }, []);
+ }).finally(() => {
+ if (generation === refreshGenerationRef.current) setLoading(false);
+ });
+ }, [updateConnection]);
+
+ const refreshData = useCallback(() => {
+ setLoading(true);
+ setError(null);
+ return fetchData();
+ }, [fetchData]);
const checkForPendingAction = useCallback(async () => {
if (checkedPendingRef.current) return;
@@ -154,23 +166,23 @@ export function AppProvider({ children }: { children: ReactNode }) {
try {
const isHealthy = await checkHealth();
if (isHealthy) {
- if (!connected) {
- setConnected(true);
+ if (!connectedRef.current) {
+ updateConnection(true);
checkServerVersion();
checkAutoSync();
refreshData();
checkForPendingAction();
}
} else {
- if (connected) {
- setConnected(false);
+ if (connectedRef.current) {
+ updateConnection(false);
setError('Backend disconnected. Attempting to reconnect...');
checkedPendingRef.current = false;
}
}
} catch {
- if (connected) {
- setConnected(false);
+ if (connectedRef.current) {
+ updateConnection(false);
setError('Backend connection lost. Check if the server is still running.');
}
}
@@ -178,11 +190,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
checkServerVersion();
checkAutoSync();
- refreshData();
+ void fetchData();
const interval = setInterval(pollHealth, 3000);
return () => clearInterval(interval);
- }, [refreshData, connected, checkForPendingAction]);
+ }, [fetchData, refreshData, updateConnection, checkForPendingAction]);
const dismissPendingAction = useCallback(async () => {
await clearPendingAction();
@@ -205,53 +217,26 @@ export function AppProvider({ children }: { children: ReactNode }) {
}, [triggerAutoSync]);
const toggleMCP = useCallback(async (key: string) => {
- if (!config?.mcp?.[key]) return;
- const newConfig = {
- ...config,
- mcp: {
- ...config.mcp,
- [key]: {
- ...config.mcp[key],
- enabled: !config.mcp[key].enabled,
- },
- },
- };
- await saveConfigHandler(newConfig);
- }, [config, saveConfigHandler]);
+ const latest = await getConfig();
+ const server = mcpServers(latest)[key];
+ if (!server) return;
+ const updated = nativeMcp(latest)
+ ? { ...server, disabled: mcpEnabled(server) }
+ : { ...server, enabled: !mcpEnabled(server) };
+ await saveConfigHandler(changeMcp(latest, key, updated));
+ }, [saveConfigHandler]);
const deleteMCP = useCallback(async (key: string) => {
- if (!config) return;
- const rest = { ...config.mcp };
- delete rest[key];
- const newConfig = { ...config, mcp: rest };
- await saveConfigHandler(newConfig);
- }, [config, saveConfigHandler]);
+ const latest = await getConfig();
+ await saveConfigHandler(changeMcp(latest, key, null));
+ }, [saveConfigHandler]);
const addMCP = useCallback(async (key: string, mcpConfig: MCPConfig) => {
- if (!config) return;
- const sanitizedConfig = sanitizeMCPConfig(mcpConfig);
- const newConfig = {
- ...config,
- mcp: {
- ...config.mcp,
- [key]: sanitizedConfig,
- },
- };
- await saveConfigHandler(newConfig);
- }, [config, saveConfigHandler]);
+ const latest = await getConfig();
+ await saveConfigHandler(changeMcp(latest, key, sanitizeMCPConfig(mcpConfig)));
+ }, [saveConfigHandler]);
- const updateMCP = useCallback(async (key: string, mcpConfig: MCPConfig) => {
- if (!config) return;
- const sanitizedConfig = sanitizeMCPConfig(mcpConfig);
- const newConfig = {
- ...config,
- mcp: {
- ...config.mcp,
- [key]: sanitizedConfig,
- },
- };
- await saveConfigHandler(newConfig);
- }, [config, saveConfigHandler]);
+ const updateMCP = addMCP;
const toggleSkill = useCallback(async (name: string) => {
const result = await apiToggleSkill(name);
diff --git a/client-next/src/lib/error-message.mjs b/client-next/src/lib/error-message.mjs
new file mode 100644
index 00000000..3c372260
--- /dev/null
+++ b/client-next/src/lib/error-message.mjs
@@ -0,0 +1,11 @@
+/** @param {unknown} error @param {string} fallback */
+export function errorMessage(error, fallback) {
+ if (error && typeof error === 'object') {
+ if ('response' in error && error.response && typeof error.response === 'object'
+ && 'data' in error.response && error.response.data && typeof error.response.data === 'object'
+ && 'error' in error.response.data && typeof error.response.data.error === 'string'
+ && error.response.data.error) return error.response.data.error;
+ if ('message' in error && typeof error.message === 'string' && error.message) return error.message;
+ }
+ return fallback;
+}
diff --git a/client-next/src/lib/mcp-config.test.tsx b/client-next/src/lib/mcp-config.test.tsx
new file mode 100644
index 00000000..0bce775e
--- /dev/null
+++ b/client-next/src/lib/mcp-config.test.tsx
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import { changeMcp, mcpServers, mcpEnabled } from './mcp-config';
+
+describe('MCP source-preserving edits', () => {
+ it('keeps v2 servers, timeout policy and unknown server fields intact', () => {
+ const config = { mcp: { timeout: { startup: 1234 }, servers: {
+ local: { type: 'local' as const, command: ['fixture'], disabled: true, codemode: false },
+ } } };
+ expect(Object.keys(mcpServers(config))).toEqual(['local']);
+ expect(mcpEnabled(mcpServers(config).local)).toBe(false);
+ const edited = changeMcp(config, 'local', { ...mcpServers(config).local, disabled: false });
+ expect(edited.mcp).toEqual({ ...config.mcp, servers: { local: { ...config.mcp.servers.local, disabled: false } } });
+ expect(changeMcp(config, 'local', null).mcp).toEqual({ timeout: { startup: 1234 }, servers: {} });
+ expect(config.mcp.servers.local.disabled).toBe(true);
+ });
+ it('translates new legacy-style dialog enabled flag only at the native boundary', () => {
+ const edited = changeMcp({ mcp: { servers: {} } }, 'new', { type: 'local', command: ['fixture'], enabled: false });
+ expect(mcpServers(edited).new).toEqual({ type: 'local', command: ['fixture'], disabled: true });
+ });
+ it('preserves legacy servers named servers or timeout', () => {
+ const config = { mcp: { servers: { type: 'local' as const, command: ['fixture'], enabled: true } } };
+ expect(mcpServers(config)).toEqual(config.mcp);
+ expect(changeMcp(config, 'servers', null).mcp).toEqual({});
+ });
+});
diff --git a/client-next/src/lib/mcp-config.ts b/client-next/src/lib/mcp-config.ts
new file mode 100644
index 00000000..a88e16c1
--- /dev/null
+++ b/client-next/src/lib/mcp-config.ts
@@ -0,0 +1,34 @@
+import type { MCPConfig, OpencodeConfig } from '@/types';
+
+const object = (value: unknown): value is Record =>
+ value !== null && typeof value === 'object' && !Array.isArray(value);
+
+export function nativeMcp(config: OpencodeConfig): boolean {
+ const mcp = config.mcp;
+ return !!mcp && ((object(mcp.servers) && !('type' in mcp.servers))
+ || (object(mcp.timeout) && !('type' in mcp.timeout)));
+}
+
+export function mcpServers(config: OpencodeConfig | null): Record {
+ if (!config?.mcp) return {};
+ if (!nativeMcp(config)) return config.mcp as Record;
+ return (config.mcp.servers || {}) as Record;
+}
+
+export function mcpEnabled(server: MCPConfig): boolean {
+ return Object.hasOwn(server, 'disabled') ? !server.disabled : server.enabled !== false;
+}
+
+export function changeMcp(config: OpencodeConfig, key: string, value: MCPConfig | null): OpencodeConfig {
+ const servers = { ...mcpServers(config) };
+ if (value === null) delete servers[key];
+ else {
+ const updated = { ...value };
+ if (nativeMcp(config)) {
+ if (!Object.hasOwn(updated, 'disabled') && Object.hasOwn(updated, 'enabled')) updated.disabled = !updated.enabled;
+ delete updated.enabled;
+ }
+ servers[key] = updated;
+ }
+ return { ...config, mcp: nativeMcp(config) ? { ...config.mcp, servers } : servers };
+}
diff --git a/client-next/src/lib/save-renamed-resource.mjs b/client-next/src/lib/save-renamed-resource.mjs
new file mode 100644
index 00000000..25e06b10
--- /dev/null
+++ b/client-next/src/lib/save-renamed-resource.mjs
@@ -0,0 +1,14 @@
+/**
+ * Save before removing the old name. A failed save must never destroy the source.
+ * If removal fails, retain both copies and let the caller report the failure.
+ * @param {string} originalName
+ * @param {string} requestedName
+ * @param {(name: string) => Promise} save
+ * @param {(name: string) => Promise} remove
+ */
+export async function saveRenamedResource(originalName, requestedName, save, remove) {
+ const name = requestedName.trim();
+ if (!name) throw new Error('Resource name is required');
+ await save(name);
+ if (name !== originalName) await remove(originalName);
+}
diff --git a/client-next/src/types/index.ts b/client-next/src/types/index.ts
index 0a0b7927..ad294584 100644
--- a/client-next/src/types/index.ts
+++ b/client-next/src/types/index.ts
@@ -3,7 +3,8 @@ export interface MCPConfig {
args?: string[];
url?: string;
env?: Record;
- enabled: boolean;
+ enabled?: boolean;
+ disabled?: boolean;
type: 'local' | 'sse' | 'remote';
timeout?: number;
environment?: Record;
@@ -44,6 +45,7 @@ export interface PermissionConfig {
export type PermissionToolKey = keyof PermissionConfig;
export interface AgentConfig {
+ nativePermissions?: Array<{ action: string; resource: string; effect: PermissionValue; [key: string]: unknown }>;
model?: string;
temperature?: number;
top_p?: number;
@@ -62,6 +64,7 @@ export interface AgentConfig {
export type AgentSource = 'json' | 'markdown' | 'builtin';
export interface AgentInfo extends AgentConfig {
+ configurationFormat?: 'v1' | 'v2';
name: string;
source: AgentSource;
disabled?: boolean;
@@ -252,7 +255,7 @@ export interface ConfigProviderContentPayload {
path?: string | null;
}
-export interface ConfigProviderValidationPayload extends ConfigProviderContentPayload {}
+export type ConfigProviderValidationPayload = ConfigProviderContentPayload;
export interface ConfigProviderValidationResult {
valid: boolean;
@@ -367,7 +370,7 @@ export interface LSPConfig {
args?: string[];
extensions?: string[];
env?: Record;
- initialization?: Record;
+ initialization?: Record;
};
}
@@ -413,7 +416,7 @@ export interface OpencodeConfig {
share?: 'manual' | 'auto' | 'disabled';
default_agent?: string;
snapshot?: boolean;
- mcp?: Record;
+ mcp?: Record | Record>;
permission?: PermissionConfig;
agent?: AgentsConfig;
tools?: Record;
@@ -424,6 +427,10 @@ export interface OpencodeConfig {
lsp?: LSPConfig;
formatter?: FormatterConfig;
command?: Record;
+ commands?: Record;
+ agents?: Record;
+ providers?: Record;
+ permissions?: Array<{ action: string; resource: string; effect: PermissionValue; [key: string]: unknown }>;
plugin?: string[];
experimental?: ExperimentalConfig;
provider?: Record;
@@ -448,6 +455,7 @@ export interface PluginFile {
}
export interface PluginInfo {
+ configurationFormat?: 'v1' | 'v2';
name: string;
type: 'file' | 'npm';
enabled: boolean;
@@ -472,6 +480,8 @@ export interface AuthCredential {
}
export interface AuthInfo {
+ readOnly?: boolean;
+ nativeCredentials?: Array<{ id: string; integrationID: string | null; label: string; active: boolean }>;
credentials: AuthCredential[];
authFile: string | null;
message?: string;
diff --git a/client-next/vitest.config.mts b/client-next/vitest.config.mts
new file mode 100644
index 00000000..21e4e38f
--- /dev/null
+++ b/client-next/vitest.config.mts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+import { fileURLToPath } from 'node:url';
+
+export default defineConfig({
+ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
+ test: { environment: 'jsdom', include: ['src/**/*.test.tsx'], restoreMocks: true },
+});
diff --git a/docs/EFFECT-MIGRATION.md b/docs/EFFECT-MIGRATION.md
new file mode 100644
index 00000000..9aee22ba
--- /dev/null
+++ b/docs/EFFECT-MIGRATION.md
@@ -0,0 +1,42 @@
+# TypeScript + Effect migration plan
+
+Start after the corrected JavaScript baseline has been tested and released. The
+Next.js frontend is already TypeScript; the backend and runtime boundaries are the
+primary migration target. Do not treat a renamed JavaScript file or a wrapper
+around the existing monolith as a completed rewrite.
+
+## Compatibility baseline
+
+Preserve existing API routes, configuration formats, unknown JSONC fields,
+profile data, plugin/skill formats, CLI/deep-link behavior, local-only binding and
+explicit trusted origins. Keep login and spending separate from offline tests.
+Existing user data must not require a destructive migration.
+
+## Implementation slices
+
+1. Strict TypeScript shared request/response schemas and tagged errors. Validate
+ inbound payloads and preserve useful structured error responses.
+2. Effect services/layers for filesystem access, profile transactions, configuration
+ discovery, atomic writes, backups and process execution. Inject synthetic paths
+ in tests rather than inspecting the developer's real home.
+3. Typed HTTP routing and lifecycle management. Scope server sockets, file watchers,
+ streams, polling and child processes; cancellation must clean them up.
+4. Authentication and synchronization services with redacted errors and no automatic
+ credential creation. Keep secret data outside logs and test fixtures.
+5. Typed frontend API boundaries and cancellable state flows. Retain the existing
+ visual design; cover profile switches, interrupted dialogs and stale responses.
+6. Remove the old backend implementation only after contract/regression parity,
+ cross-platform CI, packaging and a clean install prove the replacement.
+
+## Version selection
+
+At research time, npm reports Effect 4.0.6 and @effect/platform-node 4.0.6.
+Effect v4 and v3 APIs differ; use the v4 official documentation and matching
+packages, not legacy snippets or the old @effect/platform package by accident.
+Recheck the chosen exact versions before implementation.
+
+Primary references:
+- https://effect.website/docs/v4/getting-started
+- https://effect.website/docs/v4/schema/introduction
+- https://effect.website/docs/v4/runtime
+- https://github.com/Effect-TS/effect/blob/main/LLMS.md
diff --git a/docs/OPENCODE-V2-COMPATIBILITY.md b/docs/OPENCODE-V2-COMPATIBILITY.md
new file mode 100644
index 00000000..621ee2d6
--- /dev/null
+++ b/docs/OPENCODE-V2-COMPATIBILITY.md
@@ -0,0 +1,103 @@
+# OpenCode v2 compatibility investigation
+
+Work in progress, not a compatibility claim.
+
+Verified reference: official npm `@opencode/cli`, `@opencode/client`, and
+`@opencode/schema` 2.1.4. An isolated CLI reports `opencode v2.1.4`.
+No provider credentials, model requests or user configuration were used.
+
+Primary sources:
+- https://opencode.ai/v2/docs/migrate-v1/
+- https://opencode.ai/v2/docs/config/
+- https://opencode.ai/v2/docs/agents/
+- https://opencode.ai/v2/docs/commands/
+- https://opencode.ai/v2/docs/mcp-servers/
+- https://opencode.ai/v2/docs/plugins/
+
+## Compatibility boundaries
+
+V2 retains supported V1 configuration locations and normalizes supported legacy
+fields in memory. Native V2 fields win conflicts. Individual nested agents,
+providers, commands and models must not be reconstructed as mixed-format entries.
+A configuration manager must preserve unknown fields and source format, not
+silently migrate a user's entire setup.
+
+Explicit migration surfaces include the plugin API, server/client contracts,
+and terminal settings moving to a global `cli.json`. V1 plugins cannot be assumed
+to run under V2. Existing plugins must remain untouched until a deliberate port.
+
+Native fields to cover include `agents`, `commands`, `providers`, `plugins`,
+`permissions`, and `mcp.servers`. Agents use `system`, `disabled`, `steps` and
+ordered permission rules; MCP uses `disabled` rather than `enabled`. Ordered
+permissions cannot safely be flattened into the current legacy object editor.
+
+## Runtime observations
+
+With synthetic HOME and XDG paths, `opencode debug paths` reports data under
+`$XDG_DATA_HOME/opencode`, including `opencode.db`; config remains under
+`$XDG_CONFIG_HOME/opencode`. The v2 client exposes `/api/credential` contracts.
+This does not establish compatibility with Studio's legacy auth-file operations
+or usage-database queries; those require explicit fixtures and native checks.
+
+## Next verification
+
+- Version/capability detection without inference or global installation changes.
+- V1/V2 read/edit/write round trips preserving source shape and unknown fields.
+- Native agents, commands, MCP, providers and ordered permission editing.
+- Global CLI settings and plugin compatibility warnings.
+- Credential metadata and usage schema compatibility without exposing secrets.
+- Real v2.1.4 config/agent discovery against synthetic fixtures.
+
+## Implemented regression coverage (not yet complete)
+
+- Native JSON agents: read, POST/PUT edit, disable/enable and delete, without
+ introducing legacy `agent` entries. Preserve ordered permissions and unknown
+ fields; keep explicit object model selections intact when unchanged.
+- Native commands: read, edit and delete, preserving metadata and removing a
+ same-name legacy shadow on explicit deletion.
+- MCP: native container read/edit/delete and disabled semantics; preserve global
+ timeout policies and unknown server fields. Do not mistake legitimate legacy
+ servers called `servers` or `timeout` for containers. Preset toggles target
+ actual servers rather than container metadata.
+- Native agent editors do not offer legacy tool switches or temperature controls.
+
+Runtime limitations remain for native credential mutations and plugin-ID controls;
+see the latest checks below. Full native CLI agent discovery is still unresolved.
+No blanket v2 compatibility claim.
+
+Native Markdown agent fields now round-trip in place (including unknown fields,
+ordered permissions, steps and disabled); HTTP fixtures cover this. Provider model
+maps and plugin `{package, options}` entries now appear in inventories. Usage
+supports native `session_message`/`session_v2` and legacy table families, including
+coexisting histories, with explicit failures for unreadable/unknown schemas.
+
+The isolated official CLI's config inspection finds the synthetic global config,
+but both `debug agents` and standalone `GET /api/agent` returned empty inventories,
+including with a project Markdown fixture. This remains unresolved; do not claim
+native CLI agent-discovery verification passed.
+
+
+## Latest checks and explicit limits
+
+- 55 backend tests and 29 frontend tests (9 native Node, 20 Vitest) pass.
+- TypeScript, production build and lint pass (44 existing/non-blocking warnings).
+- Actual 500px browser run: native agent editing preserves explicit model objects
+ and permission order. The cross-page MCP overwrite was reproduced, fixed, and
+ repeated successfully with both permission rules and MCP timeout/codemode intact.
+- Global CLI JSON editor rejects stale revisions and invalid/non-object JSON;
+ unit tests verify v1 `tui.json` and server config are untouched.
+- Native credential storage is read-only metadata. SQL never selects credential
+ values. Legacy auth mutation routes are blocked when native config/storage is
+ detected, instead of claiming they change v2 credentials.
+- Native plugin entries/options can be listed, added and removed. Runtime toggles
+ deliberately do not guess plugin IDs from npm package names; use Raw Config.
+- Native default agent mode is primary, not the legacy UI's subagent default.
+
+No model inference, external login or real credential change was performed.
+Publication follows the release runbook. The owner authorized the specific
+contribution-policy exception on 2026-10-24; all listed checks were automated,
+and no human testing or unavailable exact model identity is claimed.
+
+Official `@opencode/schema` 2.1.4 successfully decoded the edited native fixture
+covering agents, commands, provider model maps, MCP and plugin options. This
+validates source shape but does not resolve the empty runtime agent inventory.
diff --git a/docs/QA-2026-10.md b/docs/QA-2026-10.md
new file mode 100644
index 00000000..ec80a253
--- /dev/null
+++ b/docs/QA-2026-10.md
@@ -0,0 +1,117 @@
+# QA baseline (October 2026)
+
+Work in progress. All checks below were performed by an automated assistant in
+isolated synthetic homes, not by a human. No real credentials, provider logins,
+model calls, account changes or existing user configuration were used.
+
+## Reproduced and fixed locally
+
+- Profile activation deleted an unmanaged configuration when an existing default
+ profile prevented migration. Activation now refuses that collision, validates
+ directory targets, and rolls back failed link replacement. Migration also
+ restores the original directory if link creation fails.
+- Untrusted-origin form POSTs could change configuration even when CORS did not
+ expose the response. Request-origin and loopback Host enforcement now run before
+ body parsing and routes. Unrelated Vercel tenants and deceptive LAN hostnames
+ are no longer trusted. Exact hosted Studio origins remain supported.
+- A newly created `.ts` plugin could not be read back by its explicit filename.
+ Lookup now includes the exact supported filename.
+- Backups discarded plugin extensions and restored TypeScript as JavaScript.
+ Local and cloud backup payloads now retain an optional `.js`/`.ts` extension;
+ legacy entries without an extension still restore as `.js`. Malformed resource
+ content or extensions are rejected before any restore writes.
+- The server could select ports beyond the ten ports the client searches, and
+ could retry permission errors indefinitely. Startup binds the actual loopback
+ socket within 1920–1929 and only retries occupied ports.
+- Profile switching did not refresh shared client configuration. A switch now
+ refreshes shared data, and stale earlier refresh results cannot overwrite it.
+- The provider test file depended on undeclared Vitest while the package was
+ CommonJS. It now uses the existing native Node test runner with isolated cleanup.
+- Stale DOMPurify/PostCSS overrides produced dependency audit findings. Updated
+ overrides resolve to 3.4.17 and 8.5.31, with reproducible lockfiles.
+
+- Command rename previously deleted the original before saving the new name.
+ It now saves first, preserving the source on write failure and retaining both
+ copies on removal failure. Whitespace-only renames no longer delete the file.
+- Frontend development port discovery is bounded to 1080–1089, binds loopback,
+ and reports non-occupancy errors instead of retrying indefinitely.
+- Editor navigation ignores stale file responses and stale load errors.
+- Error presentation validates unknown backend error values before passing them
+ to translated UI messages.
+
+- Profile mutations are serialized so rapid Enter presses and overlapping switches
+ cannot start concurrent writes.
+- Non-object main configurations and malformed backup objects are rejected before
+ writes; an HTTP regression reproduced the original array-config corruption.
+
+- Restored the missing agent-delete confirmation dialog; cancellation never calls
+ the delete API and confirmation targets the selected agent.
+- Raw config drafts survive background refresh and edits made while saving.
+- Connection-state changes no longer restart initial refreshes and race user work.
+
+## Checks performed
+
+- `cd server && npm test`: 47 tests pass, including a real child-process HTTP
+ backend, trusted and untrusted requests, and TypeScript plugin backup/restore.
+- Real synthetic HTTP reads: health, config, MCP, agents, commands, skills, plugins,
+ profiles, presets, project rules and empty usage all returned 200.
+- Synthetic skill create/read and TypeScript plugin create/read/backup passed.
+- Client production build passes; dependency audits report zero findings for the
+ installed server and client graphs. An audit is not proof of exploitability or
+ absence of security defects.
+- Client lint baseline was 70 errors and 66 warnings. The current pass has
+ reduced this to 0 errors and 44 warnings without disabling lint rules. This is not a clean full-QA
+ sign-off; types, effects and interaction coverage remain to be completed.
+
+- New frontend suite: 9 pure Node regression tests and 13 React/jsdom interaction
+ tests pass, covering stale profile responses, missing-profile cleanup, editor
+ navigation and save targeting, create cancellation, repeated Enter and
+ overlapping profile switches, agent deletion confirmation/cancellation, and
+ preserving drafts during refreshes and pending saves. These are not rendered-browser visual checks.
+- TypeScript no-emit check passes. Updated dependency audit remains at zero.
+
+## Outstanding verification
+
+- Native rendered UI interactions at desktop/mobile sizes, including Cancel,
+ repeated actions, navigation and profile-switch races. The coding executor cannot host the browser directly. A separate cloud-browser
+ runtime now runs the built app through a shared, synthetic test workspace.
+ Real browser checks passed command creation/rename/delete cancellation, profile
+ creation/switch/return with original data preserved, and agent creation/delete.
+ Further browser flows and narrow-width layout remain in progress. These are
+ assistant-driven checks, not human verification.
+- Cross-platform profile link behavior; the HTTP fixture currently skips Windows
+ to avoid probing real WSL configuration. Profile unit fixtures isolate HOME.
+- Provider OAuth, protocol registration and cloud synchronization with real accounts
+ remain operator tests requiring separate credentials/permissions.
+- Package publication, release installation and deployed-client verification.
+
+The repository's contributor policy requires human testing and a model identifier.
+Do not fabricate these. The owner authorized the exception on 2026-10-24 after the automated-only
+testing and unavailable exact model identifier were disclosed. No human testing
+is claimed. The exception is specific to this owner-directed contribution.
+
+### Additional v2 QA findings
+
+- Actual browser reproduction: edit native agent ordered permissions, save, then
+ toggle MCP on another page. The old shared config snapshot overwrote the new
+ permission rule. MCP mutation handlers now fetch the current configuration
+ before applying their edit; a regression test verifies unrelated edits survive.
+- Native agent JSON entries were mislabeled Built-in; treat `json-config` as JSON.
+- V2 uses `session_message` and `session_v2`. Usage now reads either or both table
+ generations, avoids duplicate message IDs, and reports database failures rather
+ than silently replacing failures with a zero-spending report. Synthetic SQL
+ fixtures verify native/legacy totals and the failure response.
+
+### Final browser retest of changed flows
+
+- Repeated native agent permission edit → MCP toggle → Raw Config on the fixed
+ production build. Both ordered rules, explicit model object, MCP global timeout
+ and `codemode` survived. The previous build lost the second permission rule.
+- Separate v2 CLI tab: invalid JSON was rejected with the draft retained; valid
+ synthetic CLI settings saved. Native auth page showed its read-only explanation
+ and no misleading legacy login/pool controls.
+- Audits report zero known vulnerabilities for backend production dependencies
+ and frontend dependency tree at the time of this check.
+- Legacy `/api/auth` also returned the raw stored auth object alongside status.
+ It now returns status metadata only; a synthetic-key regression asserts that
+ secrets are absent while provider login status remains available.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 00000000..0fbb3b84
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,21 @@
+# Release runbook
+
+1. Update root/server package versions together and the client version for client changes.
+ Update both lockfile root package versions and CHANGELOG.md.
+2. Run server `npm ci --ignore-scripts && npm test`; run client
+ `npm ci --ignore-scripts && npm test && npm run lint && npx tsc --noEmit && npm run build`.
+ The protocol-handler install script must not run in CI.
+3. Exercise the production UI against a synthetic home. Preserve v1/v2 source
+ formats and verify cancelled, stale-response and failed-save paths.
+4. Review the PR and its exact head's checks. Do not claim unsupported native auth,
+ plugin API porting or unresolved native runtime behavior as verified.
+5. Merge only after checks pass and actionable review findings are addressed.
+6. The master regression workflow packages the server and creates the versioned
+ GitHub release after the regression job succeeds. It does not overwrite releases.
+7. npm publication uses an existing `NPM_TOKEN` only if configured. Without it, the
+ GitHub release still provides the package archive, and npm remains explicitly
+ unpublished. Never invent publication success or create credentials silently.
+8. Verify the GitHub release tag targets the tested merge SHA and download/check
+ the package artifact. Verify npm's version separately if publication ran.
+9. Verify the hosted client deployment separately; a green build is not deployment
+ proof. Record unresolved deployment/authentication limits in the completion report.
diff --git a/package.json b/package.json
index e6e6a797..eb61cc1c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "opencode-studio-server",
- "version": "2.4.5",
+ "version": "2.5.0",
"private": false,
"scripts": {
"start": "concurrently \"npm run server\" \"npm run client\"",
diff --git a/server/index.js b/server/index.js
index 03b21322..60505a2c 100644
--- a/server/index.js
+++ b/server/index.js
@@ -10,7 +10,10 @@ try { ({ DatabaseSync } = require('node:sqlite')); } catch {}
const { spawn, exec, execSync } = require('child_process');
const yaml = require('js-yaml');
const configProviders = require('./lib/config-providers');
-const { corsOptions, setLocalNetworkAccessHeaders } = require('./lib/cors-policy');
+const { readCliConfig, writeCliConfig } = require('./lib/cli-config');
+const { readNativeCredentialMetadata } = require('./lib/native-credentials');
+const { pluginName, isNativeAgent, hasNativeMcp, readConfigMaps, agentView, mergeNativeAgentUpdate } = require('./lib/opencode-compat');
+const { corsOptions, setLocalNetworkAccessHeaders, rejectUntrustedOrigin } = require('./lib/cors-policy');
const {
assertSafeBackupResourceNames,
isSafeAgentName,
@@ -21,6 +24,7 @@ const {
const pkg = require('./package.json');
const profileManager = require('./profile-manager');
+const { listenOnAvailablePort } = require('./lib/listen');
const SERVER_VERSION = pkg.version;
const MIN_CLIENT_VERSION = '1.16.0';
@@ -120,19 +124,6 @@ const app = express();
const DEFAULT_PORT = 1920;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
-function findAvailablePort(startPort) {
- const net = require('net');
- return new Promise((resolve) => {
- const server = net.createServer();
- server.listen(startPort, () => {
- server.once('close', () => resolve(startPort));
- server.close();
- });
- server.on('error', () => {
- findAvailablePort(startPort + 1).then(resolve);
- });
- });
-}
let idleTimer = null;
@@ -151,6 +142,7 @@ app.use((req, res, next) => {
next();
});
+app.use(rejectUntrustedOrigin);
app.use(setLocalNetworkAccessHeaders);
app.use(cors(corsOptions));
@@ -1041,6 +1033,23 @@ const buildAgentMarkdown = (frontmatter, body) => {
return `---\n${yamlText}---\n\n${content}`;
};
+const findNativeMarkdownAgent = (name) => {
+ for (const directory of getAgentDirs()) {
+ const file = path.join(directory, `${name}.md`);
+ if (!fs.existsSync(file)) continue;
+ const parsed = parseAgentMarkdown(fs.readFileSync(file, 'utf8'));
+ if (isNativeAgent(parsed.data)) return { file, ...parsed };
+ return null;
+ }
+ return null;
+};
+
+const saveNativeMarkdownAgent = (existing, update) => {
+ const merged = mergeNativeAgentUpdate({ ...existing.data, system: existing.body }, update);
+ const { system, ...frontmatter } = merged;
+ atomicWriteFileSync(existing.file, buildAgentMarkdown(frontmatter, system));
+};
+
const validatePermissionValue = (value) => {
const allowed = ['ask', 'allow', 'deny'];
if (value === undefined || value === null) return true;
@@ -1116,6 +1125,12 @@ const saveConfig = (config) => {
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2));
};
+const configFileInRoot = (root) => {
+ const active = getConfigPath();
+ if (active && path.resolve(path.dirname(active)) === path.resolve(root) && fs.existsSync(active)) return active;
+ return ['opencode.json', 'opencode.jsonc'].map(name => path.join(root, name)).find(file => fs.existsSync(file));
+};
+
const aggregateModels = () => {
const roots = getSearchRoots();
const activeConfigPath = getConfigPath();
@@ -1124,21 +1139,15 @@ const aggregateModels = () => {
const providerMap = new Map();
for (const root of roots) {
- const configPath = path.join(root, 'opencode.json');
+ const configPath = configFileInRoot(root);
- if (!fs.existsSync(configPath)) continue;
+ if (!configPath || !fs.existsSync(configPath)) continue;
try {
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
-
- let providers = null;
-
- if (config.model && config.model.providers) {
- providers = config.model.providers;
- } else if (config.providers) {
- providers = config.providers;
- }
+ const config = configProviders.loadConfigFileSync(configPath);
+ const providers = readConfigMaps(config).providers;
+
if (providers && typeof providers === 'object') {
for (const [providerName, providerConfig] of Object.entries(providers)) {
if (!providerMap.has(providerName)) {
@@ -1173,10 +1182,10 @@ const loadAggregatedConfig = () => {
const configs = [];
for (const root of roots) {
- const configPath = path.join(root, 'opencode.json');
- if (fs.existsSync(configPath)) {
+ const configPath = configFileInRoot(root);
+ if (configPath && fs.existsSync(configPath)) {
try {
- const content = JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ const content = configProviders.loadConfigFileSync(configPath);
configs.push({
root,
isHighestPriority: activeDir ? path.resolve(root) === path.resolve(activeDir) : false,
@@ -1196,36 +1205,44 @@ const loadAggregatedConfig = () => {
[...configs].reverse().forEach(({ config }) => {
if (config.mcp && typeof config.mcp === 'object') {
- for (const [key, value] of Object.entries(config.mcp)) {
+ for (const [key, value] of Object.entries(readConfigMaps(config).mcp)) {
aggregated.mcp[key] = value;
}
}
- if (config.command && typeof config.command === 'object') {
- Object.assign(aggregated.command, config.command);
- }
+ Object.assign(aggregated.command, readConfigMaps(config).commands);
if (config.env && typeof config.env === 'object') {
Object.assign(aggregated.env, config.env);
}
- if (config.plugins && Array.isArray(config.plugins)) {
- for (const plugin of config.plugins) {
- const name = typeof plugin === 'string' ? plugin : plugin.name || plugin.npm;
- if (name && !aggregated.plugins.find((p) => p.name === name)) {
- aggregated.plugins.push({
- name,
- source: 'json-config',
- type: typeof plugin === 'object' && plugin.npm ? 'npm' : 'file'
- });
- }
+ for (const [key, native] of [['plugin', false], ['plugins', true]]) {
+ if (!Array.isArray(config[key])) continue;
+ for (const plugin of config[key]) {
+ const name = pluginName(plugin);
+ if (!name || name === '*' || name.startsWith('-')) continue;
+ if (!aggregated.plugins.find(p => p.name === name)) aggregated.plugins.push({
+ name, source: 'json-config', type: 'npm',
+ configurationFormat: native ? 'v2' : 'v1',
+ });
}
}
+
});
return aggregated;
};
+const cliConfigPath = () => path.join(process.env.XDG_CONFIG_HOME || path.join(HOME_DIR, '.config'), 'opencode', 'cli.json');
+app.get('/api/cli-config', (req, res) => {
+ try { res.json(readCliConfig(cliConfigPath())); }
+ catch (error) { res.status(500).json({ error: error.message }); }
+});
+app.post('/api/cli-config', (req, res) => {
+ try { res.json(writeCliConfig(cliConfigPath(), req.body, atomicWriteFileSync)); }
+ catch (error) { res.status(error.statusCode || 500).json({ error: error.message }); }
+});
+
app.get('/api/health', (req, res) => res.json({ status: 'ok', version: SERVER_VERSION }));
app.post('/api/shutdown', (req, res) => {
@@ -1308,6 +1325,9 @@ app.get('/api/config', (req, res) => {
app.post('/api/config', (req, res) => {
try {
+ if (!configProviders.isPlainObject(req.body)) {
+ return res.status(400).json({ error: 'Configuration must be an object', code: 'INVALID_CONFIG' });
+ }
if (!validatePermissionValue(req.body?.permission)) {
return res.status(400).json({ error: ERROR_CODES.INVALID_PERMISSION, code: 'INVALID_PERMISSION' });
}
@@ -1405,20 +1425,22 @@ const aggregateAgents = () => {
for (const root of roots) {
// Read from opencode.json (agent field - singular)
- const configPath = path.join(root, 'opencode.json');
- if (fs.existsSync(configPath)) {
+ const configPath = configFileInRoot(root);
+ if (configPath && fs.existsSync(configPath)) {
try {
- const content = JSON.parse(fs.readFileSync(configPath, 'utf8'));
- const configAgents = content.agent || {};
+ const content = configProviders.loadConfigFileSync(configPath);
+ const configAgents = readConfigMaps(content).agents;
for (const [name, agentConfig] of Object.entries(configAgents)) {
if (!agentMap.has(name)) {
agentMap.set(name, {
name,
source: 'json-config',
configPath: root,
- ...agentConfig,
- permission: agentConfig.permission || agentConfig.permissions,
- permissions: agentConfig.permission || agentConfig.permissions
+ ...agentView(agentConfig, Object.hasOwn(content.agents || {}, name)),
+ ...(Object.hasOwn(content.agents || {}, name) ? {} : {
+ permission: agentConfig.permission || agentConfig.permissions,
+ permissions: agentConfig.permission || agentConfig.permissions,
+ })
});
}
}
@@ -1466,17 +1488,7 @@ const aggregateAgents = () => {
source: 'markdown',
path: fp,
disabled: false,
- description: data.description,
- mode: data.mode,
- model: data.model,
- temperature: data.temperature,
- tools: data.tools,
- permission: data.permission,
- permissions: data.permission,
- maxSteps: data.maxSteps,
- disable: data.disable,
- hidden: data.hidden,
- prompt: body
+ ...agentView(isNativeAgent(data) ? { ...data, system: body } : { ...data, prompt: body }, isNativeAgent(data))
});
}
});
@@ -1500,7 +1512,7 @@ app.get('/api/agents', (req, res) => {
const agentsWithStatus = agents.map(agent => ({
...agent,
- disabled: disabledAgents.includes(agent.name)
+ disabled: agent.configurationFormat === 'v2' ? !!agent.disable : disabledAgents.includes(agent.name) || !!agent.disable
}));
res.json({ agents: agentsWithStatus });
@@ -1516,6 +1528,18 @@ app.post('/api/agents', (req, res) => {
if (!isSafeAgentName(name)) return res.status(400).json({ error: ERROR_CODES.INVALID_AGENT_NAME, code: 'INVALID_AGENT_NAME' });
const config = loadConfig() || {};
+ if (config.agents && Object.hasOwn(config.agents, name)) {
+ config.agents[name] = mergeNativeAgentUpdate(config.agents[name], agentConfig || {});
+ saveConfig(config);
+ triggerGitHubAutoSync();
+ return res.json({ success: true });
+ }
+ const nativeMarkdown = findNativeMarkdownAgent(name);
+ if (nativeMarkdown) {
+ saveNativeMarkdownAgent(nativeMarkdown, agentConfig || {});
+ triggerGitHubAutoSync();
+ return res.json({ success: true });
+ }
if (!config.agent) config.agent = {};
const normalizedConfig = { ...(agentConfig || {}) };
@@ -1524,7 +1548,7 @@ app.post('/api/agents', (req, res) => {
delete normalizedConfig.permissions;
}
- const shouldWriteMarkdown = source === 'markdown' || !!normalizedConfig?.mode;
+ const shouldWriteMarkdown = source === 'markdown' || (source === undefined && !!normalizedConfig?.mode);
if (shouldWriteMarkdown) {
const configPath = getConfigPath();
const baseDir = configPath ? path.dirname(configPath) : HOME_DIR;
@@ -1560,7 +1584,7 @@ app.post('/api/agents', (req, res) => {
triggerGitHubAutoSync();
res.json({ success: true });
} catch (err) {
- res.status(500).json({ error: err.message });
+ res.status(err.statusCode || 500).json({ error: err.message });
}
});
@@ -1579,6 +1603,18 @@ app.put('/api/agents/:name', (req, res) => {
}
const config = loadConfig() || {};
+ if (config.agents && Object.hasOwn(config.agents, name)) {
+ config.agents[name] = mergeNativeAgentUpdate(config.agents[name], agentConfig || {});
+ saveConfig(config);
+ triggerGitHubAutoSync();
+ return res.json({ success: true });
+ }
+ const nativeMarkdown = findNativeMarkdownAgent(name);
+ if (nativeMarkdown) {
+ saveNativeMarkdownAgent(nativeMarkdown, agentConfig || {});
+ triggerGitHubAutoSync();
+ return res.json({ success: true });
+ }
if (!config.agent) config.agent = {};
const markdownDirs = getAgentDirs().filter((d) => fs.existsSync(d));
@@ -1608,7 +1644,7 @@ app.put('/api/agents/:name', (req, res) => {
triggerGitHubAutoSync();
res.json({ success: true });
} catch (err) {
- res.status(500).json({ error: err.message });
+ res.status(err.statusCode || 500).json({ error: err.message });
}
});
@@ -1621,6 +1657,10 @@ app.delete('/api/agents/:name', (req, res) => {
const config = loadConfig() || {};
+ if (config.agents && Object.hasOwn(config.agents, name)) {
+ delete config.agents[name];
+ saveConfig(config);
+ }
if (config.agent && config.agent[name]) {
delete config.agent[name];
saveConfig(config);
@@ -1645,6 +1685,23 @@ app.post('/api/agents/:name/toggle', (req, res) => {
return res.status(400).json({ error: ERROR_CODES.INVALID_AGENT_NAME, code: 'INVALID_AGENT_NAME' });
}
+ const config = loadConfig() || {};
+ if (config.agents && Object.hasOwn(config.agents, name)) {
+ const disabled = !config.agents[name].disabled;
+ config.agents[name] = { ...config.agents[name], disabled };
+ saveConfig(config);
+ triggerGitHubAutoSync();
+ return res.json({ success: true, disabled });
+ }
+
+ const nativeMarkdown = findNativeMarkdownAgent(name);
+ if (nativeMarkdown) {
+ const disabled = !nativeMarkdown.data.disabled;
+ saveNativeMarkdownAgent(nativeMarkdown, { disable: disabled });
+ triggerGitHubAutoSync();
+ return res.json({ success: true, disabled });
+ }
+
const studio = loadStudioConfig();
const disabled = new Set(studio.disabledAgents || []);
if (disabled.has(name)) disabled.delete(name); else disabled.add(name);
@@ -1764,7 +1821,7 @@ app.get('/api/backup', (req, res) => {
fs.readdirSync(pd, { withFileTypes: true }).forEach(e => {
const fp = path.join(pd, e.name);
if (e.isFile() && /\.(js|ts)$/.test(e.name)) {
- plugins.push({ name: e.name.replace(/\.(js|ts)$/, ''), content: fs.readFileSync(fp, 'utf8') });
+ plugins.push({ name: e.name.replace(/\.(js|ts)$/, ''), extension: path.extname(e.name), content: fs.readFileSync(fp, 'utf8') });
}
});
}
@@ -1784,8 +1841,8 @@ app.get('/api/backup', (req, res) => {
app.post('/api/restore', (req, res) => {
try {
+ assertSafeBackupResourceNames(req.body);
const { studioConfig, opencodeConfig, skills, plugins } = req.body;
- assertSafeBackupResourceNames(req.body || {});
if (studioConfig) saveStudioConfig(studioConfig);
if (opencodeConfig) saveConfig(opencodeConfig);
@@ -1804,7 +1861,7 @@ app.post('/api/restore', (req, res) => {
if (pd && plugins && Array.isArray(plugins)) {
if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
plugins.forEach(p => {
- atomicWriteFileSync(path.join(pd, `${p.name}.js`), p.content);
+ atomicWriteFileSync(path.join(pd, `${p.name}${p.extension || '.js'}`), p.content);
});
}
@@ -1865,7 +1922,7 @@ function buildBackupData() {
if (pd && fs.existsSync(pd)) {
fs.readdirSync(pd, { withFileTypes: true }).forEach(e => {
if (e.isFile() && /\.(js|ts)$/.test(e.name)) {
- plugins.push({ name: e.name.replace(/\.(js|ts)$/, ''), content: fs.readFileSync(path.join(pd, e.name), 'utf8') });
+ plugins.push({ name: e.name.replace(/\.(js|ts)$/, ''), extension: path.extname(e.name), content: fs.readFileSync(path.join(pd, e.name), 'utf8') });
}
});
}
@@ -1911,7 +1968,7 @@ function restoreFromBackup(backup, studio) {
if (pd && backup.plugins && Array.isArray(backup.plugins)) {
if (!fs.existsSync(pd)) fs.mkdirSync(pd, { recursive: true });
backup.plugins.forEach(p => {
- atomicWriteFileSync(path.join(pd, `${p.name}.js`), p.content);
+ atomicWriteFileSync(path.join(pd, `${p.name}${p.extension || '.js'}`), p.content);
});
}
}
@@ -3383,6 +3440,7 @@ app.get('/api/plugins/:name', (req, res) => {
for (const dirInfo of getPluginDirs()) {
const possiblePaths = [
+ ...(/\.(js|ts)$/.test(name) ? [path.join(dirInfo.path, name)] : []),
path.join(dirInfo.path, name + '.js'),
path.join(dirInfo.path, name + '.ts'),
path.join(dirInfo.path, name, 'index.js'),
@@ -3408,6 +3466,7 @@ app.post('/api/plugins/:name', (req, res) => {
for (const dirInfo of getPluginDirs()) {
const possiblePaths = [
+ ...(/\.(js|ts)$/.test(name) ? [path.join(dirInfo.path, name)] : []),
path.join(dirInfo.path, name + '.js'),
path.join(dirInfo.path, name + '.ts'),
path.join(dirInfo.path, name, 'index.js'),
@@ -3475,6 +3534,11 @@ app.delete('/api/plugins/:name', (req, res) => {
app.post('/api/plugins/:name/toggle', (req, res) => {
const { name } = req.params;
+ const config = loadConfig() || {};
+ if (Array.isArray(config.plugins)) return res.status(409).json({
+ code: 'NATIVE_PLUGIN_CONTROL_REQUIRES_ID',
+ error: 'V2 plugin runtime controls use plugin IDs, not package names. Edit the plugins control list in Raw Config; Studio will not guess an ID.',
+ });
const studio = loadStudioConfig();
studio.disabledPlugins = studio.disabledPlugins || [];
@@ -3504,6 +3568,16 @@ app.get('/api/models', (req, res) => {
...((typeof model === 'object' ? model : {}))
});
}
+ } else if (provider.config.models && typeof provider.config.models === 'object') {
+ for (const [modelId, model] of Object.entries(provider.config.models)) {
+ if (!model || typeof model !== 'object' || model.disabled) continue;
+ allModels.push({
+ ...model,
+ id: `${provider.name}/${modelId}`,
+ provider: provider.name,
+ providerConfig: provider.config
+ });
+ }
} else {
allModels.push({
id: `${provider.name}/default`,
@@ -3616,6 +3690,28 @@ function rejectInvalidAuthProfileName(res, name) {
return true;
}
+// V2 credentials are not legacy auth.json pools. Do not claim a legacy write
+// changes V2 credentials, or import legacy secrets as a side effect of viewing.
+app.use('/api/auth', (req, res, next) => {
+ try {
+ const config = loadConfig() || {};
+ const nativeConfig = ['agents', 'commands', 'providers', 'permissions', 'plugins'].some(key => Object.hasOwn(config, key)) || hasNativeMcp(config);
+ const cp = getConfigPath() || path.join(HOME_DIR, '.config', 'opencode', 'opencode.json');
+ const metadata = readNativeCredentialMetadata(getUsageDataCandidates(cp), DatabaseSync);
+ if (!nativeConfig && !metadata) return next();
+ const message = 'OpenCode v2 credentials use a separate store. This view is read-only; manage login and account selection with opencode auth. Legacy Studio auth pools are not applied to v2.';
+ if (req.method === 'GET' && (req.path === '/' || req.path === '')) return res.json({
+ credentials: [], nativeCredentials: metadata?.credentials || [], readOnly: true,
+ authFile: null, installedGooglePlugins: [], activeGooglePlugin: null, message,
+ });
+ if (req.method === 'GET' && req.path === '/profiles') return res.json({});
+ if (req.method === 'GET' && req.path === '/providers') return res.json([]);
+ return res.status(409).json({ code: 'OPENCODE_V2_AUTH_READ_ONLY', error: message });
+ } catch (error) {
+ return res.status(503).json({ code: 'AUTH_METADATA_UNAVAILABLE', error: 'Credential metadata could not be read. No credentials were changed.' });
+ }
+});
+
app.get('/api/auth/providers', (req, res) => {
const providers = [
{ id: 'google', name: 'Google', type: 'oauth', description: 'Google Gemini API' },
@@ -3696,7 +3792,6 @@ app.get('/api/auth', (req, res) => {
credentials.push({ ...p, active: ac[p.id] || (curr ? 'current' : null), profiles: saved, hasCurrentAuth: curr });
});
res.json({
- ...authCfg,
credentials,
installedGooglePlugins: studio.availableGooglePlugins || [],
activeGooglePlugin: activePlugin
@@ -4859,6 +4954,7 @@ function getUsageDataCandidates(cp) {
const home = os.homedir();
return [
path.dirname(cp),
+ process.env.XDG_DATA_HOME ? path.join(process.env.XDG_DATA_HOME, 'opencode') : null,
path.join(home, '.local', 'share', 'opencode'),
path.join(home, '.opencode'),
process.env.APPDATA ? path.join(process.env.APPDATA, 'opencode') : null,
@@ -4898,11 +4994,11 @@ function createStatsAccumulator() {
}
function appendUsage(stats, projectInfo, msg, granularity) {
- if (msg.role !== 'assistant' || !msg.tokens) return;
+ if (!['assistant', 'compaction'].includes(msg.role) || !msg.tokens) return;
const c = msg.cost || 0;
const it = msg.tokens.input || 0;
const ot = msg.tokens.output || 0;
- const t = it + ot;
+ const t = it + ot + (msg.tokens.reasoning || 0) + (msg.tokens.cache?.read || 0) + (msg.tokens.cache?.write || 0);
const createdAt = msg.time?.created;
if (!createdAt) return;
@@ -4952,12 +5048,25 @@ function finalizeUsageStats(stats) {
}
function tryReadUsageFromSqlite(opts) {
- if (!DatabaseSync) return null;
+ if (!DatabaseSync) throw new Error('SQLite usage requires a Node runtime with node:sqlite');
const { dbPath, projectIdFilter, min, max, granularity } = opts;
let db;
try {
db = new DatabaseSync(dbPath, { readonly: true });
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map(row => row.name));
+ const native = tables.has('session_message') && tables.has('session_v2');
+ const legacy = tables.has('message') && tables.has('session');
+ if (!native && !legacy) throw new Error('Unsupported OpenCode usage database schema');
+ const messageColumns = 'id, session_id, time_created, data';
+ const sessionColumns = 'id, project_id';
+ const messageSource = native
+ ? `SELECT ${messageColumns} FROM session_message${legacy ? ` UNION ALL SELECT ${messageColumns} FROM message WHERE id NOT IN (SELECT id FROM session_message)` : ''}`
+ : `SELECT ${messageColumns} FROM message`;
+ const sessionSource = native
+ ? `SELECT ${sessionColumns} FROM session_v2${legacy ? ` UNION ALL SELECT ${sessionColumns} FROM session WHERE id NOT IN (SELECT id FROM session_v2)` : ''}`
+ : `SELECT ${sessionColumns} FROM session`;
+
// Extract only the usage fields in SQL instead of loading full message
// payloads into the V8 heap. opencode DBs can hold hundreds of
// thousands of rows and messages up to hundreds of MB each; JSON.parsing
@@ -4980,21 +5089,25 @@ function tryReadUsageFromSqlite(opts) {
params.push('');
const stmt = db.prepare(`
+ WITH usage_messages AS (${messageSource}), usage_sessions AS (${sessionSource})
SELECT m.id AS message_id,
m.time_created AS message_time_created,
- json_extract(m.data, '$.role') AS msg_role,
+ COALESCE(json_extract(m.data, '$.role'), json_extract(m.data, '$.type')) AS msg_role,
json_extract(m.data, '$.cost') AS msg_cost,
json_type(m.data, '$.tokens') AS msg_tokens_type,
json_extract(m.data, '$.tokens.input') AS msg_tokens_input,
json_extract(m.data, '$.tokens.output') AS msg_tokens_output,
+ json_extract(m.data, '$.tokens.reasoning') AS msg_tokens_reasoning,
+ json_extract(m.data, '$.tokens.cache.read') AS msg_tokens_cache_read,
+ json_extract(m.data, '$.tokens.cache.write') AS msg_tokens_cache_write,
json_extract(m.data, '$.time.created') AS msg_time_created,
json_extract(m.data, '$.modelID') AS msg_model_id,
json_extract(m.data, '$.model.modelID') AS msg_model_model_id,
json_extract(m.data, '$.model.id') AS msg_model_nested_id,
s.project_id AS session_project_id,
p.id AS project_id, p.name AS project_name, p.worktree AS project_worktree
- FROM message m
- LEFT JOIN session s ON s.id = m.session_id
+ FROM usage_messages m
+ LEFT JOIN usage_sessions s ON s.id = m.session_id
LEFT JOIN project p ON p.id = s.project_id
WHERE ${conditions.join(' AND ')}
ORDER BY m.id
@@ -5013,7 +5126,7 @@ function tryReadUsageFromSqlite(opts) {
const msg = {
role: row.msg_role,
cost: row.msg_cost,
- tokens: (row.msg_tokens_type === 'object' || row.msg_tokens_type === 'array') ? { input: row.msg_tokens_input, output: row.msg_tokens_output } : null,
+ tokens: (row.msg_tokens_type === 'object' || row.msg_tokens_type === 'array') ? { input: row.msg_tokens_input, output: row.msg_tokens_output, reasoning: row.msg_tokens_reasoning, cache: { read: row.msg_tokens_cache_read, write: row.msg_tokens_cache_write } } : null,
time: { created: row.msg_time_created },
modelID: row.msg_model_id,
model: (row.msg_model_model_id !== null || row.msg_model_nested_id !== null)
@@ -5136,8 +5249,8 @@ app.get('/api/usage', async (req, res) => {
try {
return res.json(tryReadUsageFromSqlite({ dbPath, projectIdFilter, min, max, granularity }));
} catch (error) {
- console.warn('[Usage] Failed reading SQLite usage DB, falling back to legacy JSON:', error?.message || error);
- break;
+ console.warn('[Usage] Failed reading SQLite usage DB:', error?.message || error);
+ throw error;
}
}
@@ -5464,19 +5577,22 @@ app.delete('/api/pending-action', (req, res) => {
});
app.post('/api/plugins/config/add', (req, res) => {
- const { plugins } = req.body;
+ const { plugins } = req.body || {};
+ if (!Array.isArray(plugins) || plugins.some(p => typeof p !== 'string' || !p.trim())) return res.status(400).json({ error: 'Expected an array of plugin package names' });
const opencode = loadConfig();
if (!opencode) return res.status(404).json({ error: ERROR_CODES.CONFIG_NOT_FOUND, code: 'CONFIG_NOT_FOUND' });
- if (!opencode.plugin) opencode.plugin = [];
+ const key = Array.isArray(opencode.plugins) ? 'plugins' : 'plugin';
+ if (!opencode[key]) opencode[key] = [];
const added = [];
const skipped = [];
plugins.forEach(p => {
- if (!opencode.plugin.includes(p)) {
- opencode.plugin.push(p);
+ if (!opencode[key].some(entry => pluginName(entry) === p)) {
+ opencode[key].push(p);
added.push(p);
+ if (key === 'plugins') return;
const studio = loadStudioConfig();
if (p.includes('gemini-auth') && !studio.availableGooglePlugins.includes('gemini')) {
studio.availableGooglePlugins.push('gemini');
@@ -5495,6 +5611,23 @@ app.post('/api/plugins/config/add', (req, res) => {
res.json({ added, skipped });
});
+app.delete('/api/plugins/config/:name', (req, res) => {
+ const config = loadConfig();
+ if (!config) return res.status(404).json({ error: ERROR_CODES.CONFIG_NOT_FOUND });
+ const name = req.params.name;
+ let removed = false;
+ for (const key of ['plugin', 'plugins']) {
+ if (!Array.isArray(config[key])) continue;
+ const remaining = config[key].filter(entry => pluginName(entry) !== name);
+ removed ||= remaining.length !== config[key].length;
+ config[key] = remaining;
+ }
+ if (!removed) return res.status(404).json({ error: ERROR_CODES.PLUGIN_NOT_FOUND });
+ saveConfig(config);
+ triggerGitHubAutoSync();
+ res.json({ success: true });
+});
+
// Presets
app.get('/api/presets', (req, res) => {
const studio = loadStudioConfig();
@@ -5588,13 +5721,12 @@ app.post('/api/presets/:id/apply', (req, res) => {
// MCPs
if (preset.config.mcps !== undefined && preset.config.mcps !== null) {
const targetMcps = new Set(preset.config.mcps);
- if (config.mcp) {
- for (const key in config.mcp) {
- if (mode === 'exclusive') {
- config.mcp[key].enabled = targetMcps.has(key);
- } else { // additive
- if (targetMcps.has(key)) config.mcp[key].enabled = true;
- }
+ const nativeMcp = hasNativeMcp(config);
+ const servers = nativeMcp ? config.mcp.servers || {} : config.mcp || {};
+ for (const [key, server] of Object.entries(servers)) {
+ if (mode === 'exclusive' || targetMcps.has(key)) {
+ if (nativeMcp) server.disabled = !targetMcps.has(key);
+ else server.enabled = targetMcps.has(key);
}
}
}
@@ -5612,8 +5744,9 @@ async function startServer() {
['google', 'anthropic', 'openai', 'xai', 'openrouter', 'together', 'mistral', 'deepseek', 'amazon-bedrock', 'azure', 'github-copilot'].forEach(p => importCurrentAuthToPool(p));
- const port = await findAvailablePort(DEFAULT_PORT);
- app.listen(port, '127.0.0.1', () => {
+ const server = await listenOnAvailablePort(app, DEFAULT_PORT, 10);
+ const port = server.address().port;
+ {
console.log(`Server running at http://127.0.0.1:${port}`);
// Initial sync on startup if enabled
setTimeout(() => {
@@ -5623,7 +5756,8 @@ async function startServer() {
triggerGitHubAutoSync();
}
}, 5000);
- });
+ }
+ return server;
}
if (require.main === module) {
diff --git a/server/lib/cli-config.js b/server/lib/cli-config.js
new file mode 100644
index 00000000..3d97f2f7
--- /dev/null
+++ b/server/lib/cli-config.js
@@ -0,0 +1,28 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+
+function readCliConfig(file) {
+ const exists = fs.existsSync(file);
+ const content = exists ? fs.readFileSync(file, 'utf8') : '{}\n';
+ return { content, exists, revision: exists ? crypto.createHash('sha256').update(content).digest('hex') : null };
+}
+function writeCliConfig(file, payload, atomicWrite) {
+ if (!payload || typeof payload.content !== 'string' || !Object.hasOwn(payload, 'revision')) {
+ throw Object.assign(new Error('Content and revision are required'), { statusCode: 400 });
+ }
+ let parsed;
+ try { parsed = JSON.parse(payload.content); } catch {
+ throw Object.assign(new Error('Invalid JSON'), { statusCode: 400 });
+ }
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw Object.assign(new Error('CLI configuration must be a JSON object'), { statusCode: 400 });
+ }
+ if (readCliConfig(file).revision !== payload.revision) {
+ throw Object.assign(new Error('CLI configuration changed on disk. Reload before saving.'), { statusCode: 409 });
+ }
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ atomicWrite(file, payload.content);
+ return readCliConfig(file);
+}
+module.exports = { readCliConfig, writeCliConfig };
diff --git a/server/lib/cli-config.test.js b/server/lib/cli-config.test.js
new file mode 100644
index 00000000..0ab7f283
--- /dev/null
+++ b/server/lib/cli-config.test.js
@@ -0,0 +1,24 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { readCliConfig, writeCliConfig } = require('./cli-config');
+
+test('v2 CLI settings preserve other files and reject stale or invalid writes', () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-cli-config-'));
+ const file = path.join(directory, 'cli.json');
+ try {
+ fs.writeFileSync(path.join(directory, 'tui.json'), '{"theme":"old"}');
+ fs.writeFileSync(path.join(directory, 'opencode.json'), '{"agents":{}}');
+ assert.deepEqual(readCliConfig(file), { content: '{}\n', exists: false, revision: null });
+ assert.equal(fs.existsSync(file), false);
+ const saved = writeCliConfig(file, { content: '{"theme":"new","custom":{"keep":true}}', revision: null }, fs.writeFileSync);
+ assert.equal(saved.exists, true);
+ assert.throws(() => writeCliConfig(file, { content: '{}', revision: null }, fs.writeFileSync), { statusCode: 409 });
+ for (const content of ['null', '[]', '{bad']) assert.throws(() => writeCliConfig(file, { content, revision: saved.revision }, fs.writeFileSync), { statusCode: 400 });
+ assert.equal(readCliConfig(file).content, saved.content);
+ assert.equal(fs.readFileSync(path.join(directory, 'tui.json'), 'utf8'), '{"theme":"old"}');
+ assert.equal(fs.readFileSync(path.join(directory, 'opencode.json'), 'utf8'), '{"agents":{}}');
+ } finally { fs.rmSync(directory, { recursive: true, force: true }); }
+});
diff --git a/server/lib/config-providers.test.js b/server/lib/config-providers.test.js
index 3b7a0fd7..f209fbad 100644
--- a/server/lib/config-providers.test.js
+++ b/server/lib/config-providers.test.js
@@ -1,12 +1,19 @@
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-import { describe, it, expect } from 'vitest';
-import * as jsoncParser from 'jsonc-parser';
-
-import providers from './config-providers.js';
-
-const makeTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-server-test-'));
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { describe, it, after } = require('node:test');
+const assert = require('node:assert/strict');
+const jsoncParser = require('jsonc-parser');
+
+const providers = require('./config-providers.js');
+
+const temporaryDirectories = [];
+const makeTempDir = () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-server-test-'));
+ temporaryDirectories.push(dir);
+ return dir;
+};
+after(() => temporaryDirectories.forEach(dir => fs.rmSync(dir, { recursive: true, force: true })));
describe('config provider seams', () => {
it('normalizes path inventories and keeps precedence order', () => {
@@ -22,16 +29,16 @@ describe('config provider seams', () => {
current: path.join(tempDir, '.', 'current.json')
});
- expect(inventory.candidates).toEqual([detected, manual]);
- expect(inventory.detected).toBe(detected);
- expect(inventory.manual).toBe(manual);
- expect(inventory.current).toBe(current);
+ assert.deepEqual(inventory.candidates, [detected, manual]);
+ assert.equal(inventory.detected, detected);
+ assert.equal(inventory.manual, manual);
+ assert.equal(inventory.current, current);
});
it('parses jsonc comments and trailing commas by default', () => {
const text = ['{', ' // comment', ' "name": "demo",', ' "items": [1, 2,],', '}'].join('\n');
- expect(providers.parseJsonText(text)).toEqual({ name: 'demo', items: [1, 2] });
+ assert.deepEqual(providers.parseJsonText(text), { name: 'demo', items: [1, 2] });
});
it('loads jsonc config files with comments and trailing commas', () => {
@@ -48,13 +55,13 @@ describe('config provider seams', () => {
'}',
].join('\n'));
- expect(providers.loadConfigFileSync(configPath)).toEqual({
+ assert.deepEqual(providers.loadConfigFileSync(configPath), {
provider: { ascend: { reasoning: true } },
});
});
it('parses malformed json with injected jsonc parser', () => {
- expect(() => providers.parseJsonText('{"name":', {})).toThrow(SyntaxError);
+ assert.throws(() => providers.parseJsonText('{"name":', {}), SyntaxError);
const text = ['{', ' // comment', ' "name": "demo",', ' "items": [1, 2,],', '}'].join('\n');
@@ -71,8 +78,8 @@ describe('config provider seams', () => {
}
});
- expect(parsed).toEqual({ name: 'demo', items: [1, 2] });
- expect(errors).toEqual([]);
+ assert.deepEqual(parsed, { name: 'demo', items: [1, 2] });
+ assert.deepEqual(errors, []);
});
it('preserves unknown keys as raw text through atomic writes', () => {
@@ -82,8 +89,8 @@ describe('config provider seams', () => {
providers.writeConfigTextAtomicSync(targetPath, content);
- expect(fs.readFileSync(targetPath, 'utf8')).toBe(content);
- expect(fs.readdirSync(tempDir).sort()).toEqual(['config.json']);
+ assert.equal(fs.readFileSync(targetPath, 'utf8'), content);
+ assert.deepEqual(fs.readdirSync(tempDir).sort(), ['config.json']);
});
it('writes atomically into a temp directory and leaves no temp files behind', () => {
@@ -92,15 +99,15 @@ describe('config provider seams', () => {
providers.atomicWriteTextSync(targetPath, '{"ok":true}');
- expect(fs.readFileSync(targetPath, 'utf8')).toBe('{"ok":true}');
- expect(fs.readdirSync(tempDir)).toEqual(['atomic.json']);
+ assert.equal(fs.readFileSync(targetPath, 'utf8'), '{"ok":true}');
+ assert.deepEqual(fs.readdirSync(tempDir), ['atomic.json']);
});
it('creates contracts for supported providers and rejects unsupported ids', () => {
const contract = providers.createProviderContract(providers.PROVIDER_IDS.OPENCODE);
- expect(contract.id).toBe('opencode');
- expect(contract.capabilities).toEqual({
+ assert.equal(contract.id, 'opencode');
+ assert.deepEqual(contract.capabilities, {
canDetect: true,
canLoad: true,
canValidate: true,
@@ -109,14 +116,14 @@ describe('config provider seams', () => {
canImportConfig: true,
canExportConfig: true
});
- expect(() => providers.createProviderContract('unknown-provider')).toThrow('Unsupported provider id: unknown-provider');
+ assert.throws(() => providers.createProviderContract('unknown-provider'), { message: 'Unsupported provider id: unknown-provider' });
});
});
describe('provider rules detection', () => {
it('extracts basename across slash styles for cross-platform suffix checks', () => {
- expect(providers.getPathBasenameAnySeparator('/tmp/a/slim.jsonc')).toBe('slim.jsonc');
- expect(providers.getPathBasenameAnySeparator('C:\\temp\\a\\slim.json')).toBe('slim.json');
+ assert.equal(providers.getPathBasenameAnySeparator('/tmp/a/slim.jsonc'), 'slim.jsonc');
+ assert.equal(providers.getPathBasenameAnySeparator('C:\\temp\\a\\slim.json'), 'slim.json');
});
it('returns all provider records with expected metadata fields', () => {
@@ -128,19 +135,19 @@ describe('provider rules detection', () => {
const detected = providers.detectProviders({ roots: [tempDir] });
- expect(detected.map((provider) => provider.id)).toEqual([
+ assert.deepEqual(detected.map((provider) => provider.id), [
providers.PROVIDER_IDS.OPENCODE,
providers.PROVIDER_IDS.OH_MY_OPENAGENT,
providers.PROVIDER_IDS.OH_MY_OPENCODE_SLIM
]);
for (const provider of detected) {
- expect(provider).toHaveProperty('id');
- expect(provider).toHaveProperty('displayName');
- expect(provider).toHaveProperty('paths');
- expect(provider).toHaveProperty('exists');
- expect(provider).toHaveProperty('activePath');
- expect(provider).toHaveProperty('capabilities');
- expect(provider).toHaveProperty('diagnostics');
+ assert.ok(Object.hasOwn(provider, 'id'));
+ assert.ok(Object.hasOwn(provider, 'displayName'));
+ assert.ok(Object.hasOwn(provider, 'paths'));
+ assert.ok(Object.hasOwn(provider, 'exists'));
+ assert.ok(Object.hasOwn(provider, 'activePath'));
+ assert.ok(Object.hasOwn(provider, 'capabilities'));
+ assert.ok(Object.hasOwn(provider, 'diagnostics'));
}
});
@@ -154,10 +161,10 @@ describe('provider rules detection', () => {
const openAgent = providers.detectProviders({ roots: [tempDir] })
.find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENAGENT);
- expect(openAgent.activePath).toBe(primary);
- expect(openAgent.diagnostics.some((d) => d.code === 'OPENAGENT_ALIAS_DUPLICATE')).toBe(true);
+ assert.equal(openAgent.activePath, primary);
+ assert.equal(openAgent.diagnostics.some((d) => d.code === 'OPENAGENT_ALIAS_DUPLICATE'), true);
const duplicateDiagnostic = openAgent.diagnostics.find((d) => d.code === 'OPENAGENT_ALIAS_DUPLICATE');
- expect(duplicateDiagnostic.details.paths).toEqual([primary, legacy]);
+ assert.deepEqual(duplicateDiagnostic.details.paths, [primary, legacy]);
});
it('creates safe openagent profile paths next to active config', () => {
@@ -168,14 +175,14 @@ describe('provider rules detection', () => {
const openAgent = providers.detectProviders({ roots: [tempDir] })
.find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENAGENT);
- expect(providers.getOpenAgentProfileDir(openAgent)).toBe(path.join(tempDir, providers.OPENAGENT_PROFILE_DIRNAME));
- expect(providers.getOpenAgentDefaultActivePath(openAgent)).toBe(active);
- expect(providers.sanitizeConfigProfileName('Work Config!.json')).toBe('Work-Config');
+ assert.equal(providers.getOpenAgentProfileDir(openAgent), path.join(tempDir, providers.OPENAGENT_PROFILE_DIRNAME));
+ assert.equal(providers.getOpenAgentDefaultActivePath(openAgent), active);
+ assert.equal(providers.sanitizeConfigProfileName('Work Config!.json'), 'Work-Config');
const profilePath = providers.getOpenAgentProfilePath(openAgent, 'Work Config!');
- expect(profilePath).toBe(path.join(tempDir, providers.OPENAGENT_PROFILE_DIRNAME, 'Work-Config.json'));
- expect(providers.isOpenAgentProfilePath(openAgent, profilePath)).toBe(true);
- expect(providers.isOpenAgentProfilePath(openAgent, path.join(tempDir, 'other.json'))).toBe(false);
+ assert.equal(profilePath, path.join(tempDir, providers.OPENAGENT_PROFILE_DIRNAME, 'Work-Config.json'));
+ assert.equal(providers.isOpenAgentProfilePath(openAgent, profilePath), true);
+ assert.equal(providers.isOpenAgentProfilePath(openAgent, path.join(tempDir, 'other.json')), false);
});
it('lists only json openagent profile files', () => {
@@ -191,7 +198,7 @@ describe('provider rules detection', () => {
fs.writeFileSync(path.join(profileDir, 'b.jsonc'), '{}');
fs.writeFileSync(path.join(profileDir, 'notes.txt'), '{}');
- expect(providers.listOpenAgentProfilePaths(openAgent)).toEqual([
+ assert.deepEqual(providers.listOpenAgentProfilePaths(openAgent), [
path.join(profileDir, 'a.json'),
path.join(profileDir, 'b.jsonc')
]);
@@ -209,8 +216,8 @@ describe('provider rules detection', () => {
.find((provider) => provider.id === providers.PROVIDER_IDS.OPENCODE);
const duplicateDiagnostic = opencode.diagnostics.find((d) => d.code === 'DUPLICATE_PROVIDER_CONFIG');
- expect(duplicateDiagnostic).toBeTruthy();
- expect(duplicateDiagnostic.details.paths).toEqual([pathA, pathB]);
+ assert.ok(duplicateDiagnostic);
+ assert.deepEqual(duplicateDiagnostic.details.paths, [pathA, pathB]);
});
it('reports malformed provider json using jsonc parser diagnostics', () => {
@@ -222,8 +229,8 @@ describe('provider rules detection', () => {
.find((provider) => provider.id === providers.PROVIDER_IDS.OPENCODE);
const malformedDiagnostic = opencode.diagnostics.find((d) => d.code === 'MALFORMED_PROVIDER_CONFIG');
- expect(malformedDiagnostic).toBeTruthy();
- expect(malformedDiagnostic.details.path).toBe(malformedPath);
+ assert.ok(malformedDiagnostic);
+ assert.equal(malformedDiagnostic.details.path, malformedPath);
});
it('reports missing slim companion tui config', () => {
@@ -235,8 +242,8 @@ describe('provider rules detection', () => {
.find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENCODE_SLIM);
const missingDiagnostic = slim.diagnostics.find((d) => d.code === 'SLIM_TUI_CONFIG_MISSING');
- expect(missingDiagnostic).toBeTruthy();
- expect(missingDiagnostic.details.path).toBe(slimPath);
+ assert.ok(missingDiagnostic);
+ assert.equal(missingDiagnostic.details.path, slimPath);
});
it('reports malformed slim companion tui config', () => {
@@ -249,8 +256,8 @@ describe('provider rules detection', () => {
.find((provider) => provider.id === providers.PROVIDER_IDS.OH_MY_OPENCODE_SLIM);
const malformedDiagnostic = slim.diagnostics.find((d) => d.code === 'SLIM_TUI_CONFIG_MALFORMED');
- expect(malformedDiagnostic).toBeTruthy();
- expect(malformedDiagnostic.details.path).toBe(malformedTuiPath);
+ assert.ok(malformedDiagnostic);
+ assert.equal(malformedDiagnostic.details.path, malformedTuiPath);
});
});
@@ -268,7 +275,7 @@ describe('provider write/import helper semantics', () => {
const merged = providers.deepMergePreservingUnknown(existing, incoming);
- expect(merged).toEqual({
+ assert.deepEqual(merged, {
name: 'demo',
plugins: { keep: true, nested: { original: 2, added: true } },
unknownTopLevel: { keep: true },
@@ -277,17 +284,17 @@ describe('provider write/import helper semantics', () => {
});
it('extracts expected revision hash from string or object payloads', () => {
- expect(providers.getExpectedRevisionHash({ expectedRevision: 'abc' })).toBe('abc');
- expect(providers.getExpectedRevisionHash({ expectedRevision: { hash: 'def' } })).toBe('def');
- expect(providers.getExpectedRevisionHash({ revision: { hash: 'ghi' } })).toBe('ghi');
- expect(providers.getExpectedRevisionHash({})).toBeNull();
+ assert.equal(providers.getExpectedRevisionHash({ expectedRevision: 'abc' }), 'abc');
+ assert.equal(providers.getExpectedRevisionHash({ expectedRevision: { hash: 'def' } }), 'def');
+ assert.equal(providers.getExpectedRevisionHash({ revision: { hash: 'ghi' } }), 'ghi');
+ assert.equal(providers.getExpectedRevisionHash({}), null);
});
it('flags stale revisions when expected hash does not match current hash', () => {
- expect(providers.isStaleRevision({ expectedHash: 'abc', currentRevision: { hash: 'abc' } })).toBe(false);
- expect(providers.isStaleRevision({ expectedHash: 'abc', currentRevision: { hash: 'zzz' } })).toBe(true);
- expect(providers.isStaleRevision({ expectedHash: 'abc', currentRevision: null })).toBe(true);
- expect(providers.isStaleRevision({ expectedHash: null, currentRevision: null })).toBe(false);
+ assert.equal(providers.isStaleRevision({ expectedHash: 'abc', currentRevision: { hash: 'abc' } }), false);
+ assert.equal(providers.isStaleRevision({ expectedHash: 'abc', currentRevision: { hash: 'zzz' } }), true);
+ assert.equal(providers.isStaleRevision({ expectedHash: 'abc', currentRevision: null }), true);
+ assert.equal(providers.isStaleRevision({ expectedHash: null, currentRevision: null }), false);
});
it('accepts matching provider import payload and rejects mismatch', () => {
@@ -295,14 +302,14 @@ describe('provider write/import helper semantics', () => {
routeProviderId: providers.PROVIDER_IDS.OPENCODE,
payload: { providerId: providers.PROVIDER_IDS.OPENCODE }
});
- expect(okMatch.ok).toBe(true);
+ assert.equal(okMatch.ok, true);
const mismatch = providers.validateImportProviderMatch({
routeProviderId: providers.PROVIDER_IDS.OPENCODE,
payload: { id: providers.PROVIDER_IDS.OH_MY_OPENAGENT }
});
- expect(mismatch.ok).toBe(false);
- expect(mismatch.diagnostic.code).toBe('PROVIDER_MISMATCH');
+ assert.equal(mismatch.ok, false);
+ assert.equal(mismatch.diagnostic.code, 'PROVIDER_MISMATCH');
});
it('creates only missing file and never overwrites existing file', () => {
@@ -312,12 +319,12 @@ describe('provider write/import helper semantics', () => {
const secondRaw = '{"a":2}';
const created = providers.createFileIfMissingSync(targetPath, initialRaw, 'utf8');
- expect(created).toEqual({ created: true, path: targetPath });
- expect(fs.readFileSync(targetPath, 'utf8')).toBe(initialRaw);
+ assert.deepEqual(created, { created: true, path: targetPath });
+ assert.equal(fs.readFileSync(targetPath, 'utf8'), initialRaw);
const existing = providers.createFileIfMissingSync(targetPath, secondRaw, 'utf8');
- expect(existing).toEqual({ created: false, path: targetPath });
- expect(fs.readFileSync(targetPath, 'utf8')).toBe(initialRaw);
+ assert.deepEqual(existing, { created: false, path: targetPath });
+ assert.equal(fs.readFileSync(targetPath, 'utf8'), initialRaw);
});
it('requires explicit write path when provider has no active path', () => {
@@ -330,10 +337,10 @@ describe('provider write/import helper semantics', () => {
};
const noPath = providers.resolveProviderWritePath({ provider, requestedPath: undefined });
- expect(noPath.ok).toBe(false);
- expect(noPath.diagnostics[0].code).toBe('PROVIDER_PATH_REQUIRED');
+ assert.equal(noPath.ok, false);
+ assert.equal(noPath.diagnostics[0].code, 'PROVIDER_PATH_REQUIRED');
const withAllowedPath = providers.resolveProviderWritePath({ provider, requestedPath: candidatePath });
- expect(withAllowedPath).toEqual({ ok: true, path: candidatePath });
+ assert.deepEqual(withAllowedPath, { ok: true, path: candidatePath });
});
});
diff --git a/server/lib/cors-policy.js b/server/lib/cors-policy.js
index 26886346..67699c78 100644
--- a/server/lib/cors-policy.js
+++ b/server/lib/cors-policy.js
@@ -1,23 +1,41 @@
const ALLOWED_ORIGINS = [
- 'http://192.168.10.100:1080',
- /^http:\/\/192\.168\..+:108\d$/,
- /^http:\/\/192\.168\.10\.\d{1,3}:108\d$/,
- 'http://localhost:1080',
- 'http://127.0.0.1:1080',
- /^http:\/\/localhost:108\d$/,
- /^http:\/\/127\.0\.0\.1:108\d$/,
'https://opencode-studio.vercel.app',
'https://opencode.micr.dev',
'https://opencode-studio.micr.dev',
- /\.vercel\.app$/,
- /\.micr\.dev$/,
];
function isAllowedOrigin(origin) {
- if (!origin) return true;
- return ALLOWED_ORIGINS.some((allowedOrigin) =>
- allowedOrigin instanceof RegExp ? allowedOrigin.test(origin) : allowedOrigin === origin
- );
+ if (origin === undefined) return true; // Native CLI requests have no Origin.
+ if (typeof origin !== 'string' || origin === 'null') return false;
+ let url;
+ try { url = new URL(origin); } catch { return false; }
+ // An Origin is exactly a serialized origin, never credentials, paths or lists.
+ if (url.origin !== origin || url.username || url.password) return false;
+ if (ALLOWED_ORIGINS.includes(origin)) return true;
+ if (url.protocol !== 'http:' || !/^108\d$/.test(url.port)) return false;
+ if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]') return true;
+ const octets = url.hostname.split('.');
+ return octets.length === 4 && octets[0] === '192' && octets[1] === '168' &&
+ octets.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
+}
+
+function isAllowedHost(host) {
+ if (typeof host !== 'string') return false;
+ try {
+ const url = new URL('http://' + host);
+ return url.host === host.toLowerCase() &&
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
+ } catch { return false; }
+}
+
+// CORS alone only controls whether JavaScript may READ the response. Reject
+// untrusted browser requests before parsing bodies or running mutating routes.
+function rejectUntrustedOrigin(req, res, next) {
+ if (!isAllowedHost(req.headers.host) || !isAllowedOrigin(req.headers.origin) ||
+ (req.headers.origin === undefined && req.headers['sec-fetch-site'] === 'cross-site')) {
+ return res.status(403).json({ error: 'Untrusted request origin', code: 'UNTRUSTED_ORIGIN' });
+ }
+ next();
}
function setLocalNetworkAccessHeaders(req, res, next) {
@@ -25,27 +43,16 @@ function setLocalNetworkAccessHeaders(req, res, next) {
if (origin && isAllowedOrigin(origin)) {
const asksForPrivateNetwork = req.headers['access-control-request-private-network'] === 'true';
const asksForLocalNetwork = req.headers['access-control-request-local-network'] === 'true';
-
if (asksForPrivateNetwork || asksForLocalNetwork) {
- // Chrome's older Private Network Access preflight path requires this
- // explicit opt-in from local HTTP services.
res.setHeader('Access-Control-Allow-Private-Network', 'true');
}
}
-
next();
}
const corsOptions = {
- origin: (origin, callback) => {
- callback(null, isAllowedOrigin(origin));
- },
+ origin: (origin, callback) => callback(null, isAllowedOrigin(origin)),
credentials: true,
};
-module.exports = {
- ALLOWED_ORIGINS,
- corsOptions,
- isAllowedOrigin,
- setLocalNetworkAccessHeaders,
-};
+module.exports = { ALLOWED_ORIGINS, corsOptions, isAllowedOrigin, isAllowedHost, rejectUntrustedOrigin, setLocalNetworkAccessHeaders };
diff --git a/server/lib/cors-policy.test.js b/server/lib/cors-policy.test.js
index 8624ae13..d5298a9f 100644
--- a/server/lib/cors-policy.test.js
+++ b/server/lib/cors-policy.test.js
@@ -68,3 +68,32 @@ describe('cors policy', () => {
assert.deepEqual(appliedHeaders, {});
});
});
+
+describe('request origin enforcement', () => {
+ const { rejectUntrustedOrigin, isAllowedHost } = require('./cors-policy');
+ function request(headers) {
+ let code=200, proceeded=false;
+ rejectUntrustedOrigin({headers:{host:'127.0.0.1:1920',...headers}}, {
+ status(value){code=value;return this;},json(){return this;},
+ },()=>{proceeded=true;});
+ return {code,proceeded};
+ }
+ it('rejects simple cross-origin writes before any route runs', () => {
+ for(const origin of ['https://untrusted.example','https://unrelated.vercel.app','http://192.168.attacker.example:1080','null','', 'https://opencode.micr.dev/']) {
+ assert.deepEqual(request({origin}),{code:403,proceeded:false});
+ }
+ });
+ it('allows exact trusted origins and originless native requests', () => {
+ for(const headers of [{},{origin:'https://opencode.micr.dev'},{origin:'http://localhost:1080'},{origin:'http://192.168.1.2:1089'}]) {
+ assert.deepEqual(request(headers),{code:200,proceeded:true});
+ }
+ });
+ it('rejects originless cross-site requests and DNS-rebinding hosts', () => {
+ assert.deepEqual(request({'sec-fetch-site':'cross-site'}),{code:403,proceeded:false});
+ assert.deepEqual(request({host:'untrusted.example:1920'}),{code:403,proceeded:false});
+ assert.equal(isAllowedHost('127.0.0.1:1920'),true);
+ assert.equal(isAllowedHost('[::1]:1920'),true);
+ assert.equal(isAllowedHost('evil@127.0.0.1:1920'),false);
+ assert.equal(isAllowedHost('127.0.0.1:1920/path'),false);
+ });
+});
diff --git a/server/lib/http-security.test.js b/server/lib/http-security.test.js
new file mode 100644
index 00000000..4dd2ae28
--- /dev/null
+++ b/server/lib/http-security.test.js
@@ -0,0 +1,150 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { spawn } = require('node:child_process');
+const { once } = require('node:events');
+
+test('real backend rejects foreign-origin writes without changing config', {timeout:15000, skip:process.platform==='win32'?'HTTP fixture currently isolates Linux/macOS homes only':false}, async () => {
+ const home=fs.mkdtempSync(path.join(os.tmpdir(),'studio-http-test-'));
+ const directory=path.join(home,'.config/opencode');fs.mkdirSync(directory,{recursive:true});
+ const config=path.join(directory,'opencode.json');fs.writeFileSync(config,'{}');
+ const child=spawn(process.execPath,[path.join(__dirname,'../index.js')], {
+ cwd:path.join(__dirname,'..'),
+ env:{PATH:process.env.PATH,HOME:home,USERPROFILE:home,XDG_CONFIG_HOME:path.join(home,'.config'),XDG_DATA_HOME:path.join(home,'.local/share'),GH_CONFIG_DIR:path.join(home,'.gh'),APPDATA:path.join(home,'AppData/Roaming'),LOCALAPPDATA:path.join(home,'AppData/Local')},
+ stdio:['ignore','pipe','pipe'],
+ });
+ const exited=once(child,'exit');let output='';
+ child.stdout.on('data',chunk=>{output=(output+chunk).slice(-16000);});
+ child.stderr.on('data',chunk=>{output=(output+chunk).slice(-16000);});
+ try {
+ let base;
+ for(let i=0;i<100;i++) {
+ const match=output.match(/Server running at (http:\/\/127\.0\.0\.1:\d+)/);
+ if(match){base=match[1];break;}
+ if(child.exitCode!==null)break;
+ await new Promise(resolve=>setTimeout(resolve,50));
+ }
+ assert.ok(base,'backend starts in synthetic home: '+output);
+ for(const origin of ['https://untrusted.example','https://unrelated.vercel.app','http://192.168.attacker.example:1080']) {
+ const response=await fetch(base+'/api/config',{method:'POST',headers:{Origin:origin,'Content-Type':'application/x-www-form-urlencoded'},body:'permission=allow'});
+ assert.equal(response.status,403);assert.equal(fs.readFileSync(config,'utf8'),'{}');
+ }
+ const preflight=await fetch(base+'/api/config',{method:'OPTIONS',headers:{Origin:'https://opencode.micr.dev','Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'content-type'}});
+ assert.equal(preflight.status,204);assert.equal(preflight.headers.get('access-control-allow-origin'),'https://opencode.micr.dev');
+ const trusted=await fetch(base+'/api/config',{method:'POST',headers:{Origin:'http://localhost:1080','Content-Type':'application/json'},body:JSON.stringify({permission:'ask',mcp:{}})});
+ assert.equal(trusted.status,200);assert.equal(JSON.parse(fs.readFileSync(config,'utf8')).permission,'ask');
+ const read=await fetch(base+'/api/config');assert.equal(read.status,200);assert.equal((await read.json()).permission,'ask');
+ const beforeInvalidConfig=fs.readFileSync(config,'utf8');
+ const malformedConfig=await fetch(base+'/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:'[]'});
+ assert.equal(malformedConfig.status,400);
+ assert.equal(fs.readFileSync(config,'utf8'),beforeInvalidConfig);
+ const pluginContent='export const value: string = "synthetic";';
+ const create=await fetch(base+'/api/plugins/qa.ts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:pluginContent})});
+ assert.equal(create.status,200);
+ assert.equal((await (await fetch(base+'/api/plugins/qa.ts')).json()).content,pluginContent);
+ const backup=await (await fetch(base+'/api/backup')).json();
+ assert.equal(backup.plugins.find(p=>p.name==='qa').extension,'.ts');
+ assert.equal((await fetch(base+'/api/plugins/qa.ts',{method:'DELETE'})).status,200);
+ const restore=await fetch(base+'/api/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(backup)});
+ assert.equal(restore.status,200);
+ assert.equal((await (await fetch(base+'/api/plugins/qa.ts')).json()).content,pluginContent);
+ const before=fs.readFileSync(config,'utf8');
+ const invalid=await fetch(base+'/api/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({opencodeConfig:{permission:'allow'},plugins:[{name:'bad',content:42}]})});
+ assert.equal(invalid.status,400);assert.equal(fs.readFileSync(config,'utf8'),before);
+ const native={providers:{fixture:{models:{model:{name:'Fixture model'},hidden:{disabled:true}}}},plugins:[{package:'fixture-plugin',options:{custom:'keep'}}],mcp:{timeout:{startup:1234},servers:{fixture:{type:'local',command:['fixture'],disabled:true}}},commands:{review:{template:'native v2',description:'preserved'}},agents:{reviewer:{system:'review'}},permissions:[{action:'shell',resource:'*',effect:'deny'}]};
+ assert.equal((await fetch(base+'/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(native)})).status,200);
+ const nativeCommands=await (await fetch(base+'/api/commands')).json();
+ assert.equal(nativeCommands.review.template,'native v2');
+ const nativeMcp=await (await fetch(base+'/api/mcp')).json();
+ assert.deepEqual(Object.keys(nativeMcp),['fixture']);
+ assert.equal(nativeMcp.fixture.disabled,true);
+ const models=await (await fetch(base+'/api/models')).json();
+ assert.ok(models.models.some(m=>m.id==='fixture/model'));
+ assert.ok(!models.models.some(m=>m.id==='fixture/hidden'));
+ const plugins=await (await fetch(base+'/api/plugins')).json();
+ assert.ok(plugins.some(p=>p.name==='fixture-plugin' && p.type==='npm'));
+ assert.equal((await fetch(base+'/api/plugins/config/add',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({plugins:['fixture-new']})})).status,200);
+ const addedConfig=JSON.parse(fs.readFileSync(config,'utf8'));
+ assert.deepEqual(addedConfig.plugins[0],{package:'fixture-plugin',options:{custom:'keep'}});
+ assert.equal(addedConfig.plugins[1],'fixture-new');assert.equal(addedConfig.plugin,undefined);
+ assert.equal((await fetch(base+'/api/plugins/config/fixture-new',{method:'DELETE'})).status,200);
+ assert.deepEqual(JSON.parse(fs.readFileSync(config,'utf8')).plugins,[{package:'fixture-plugin',options:{custom:'keep'}}]);
+ assert.equal((await fetch(base+'/api/plugins/fixture-plugin/toggle',{method:'POST'})).status,409);
+
+
+ assert.equal(nativeCommands.review.description,'preserved');
+ const authInfo=await (await fetch(base+'/api/auth')).json();assert.equal(authInfo.readOnly,true);
+ const beforeAuthAttempt=fs.readFileSync(config,'utf8');
+ assert.equal((await fetch(base+'/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider:'fixture'})})).status,409);
+ assert.equal(fs.readFileSync(config,'utf8'),beforeAuthAttempt);
+ assert.equal(fs.existsSync(path.join(home,'.config/opencode-studio/auth-profiles')),false);
+
+ const rules=[{action:'shell',resource:'*',effect:'deny'}];
+ for (const method of ['POST','PUT']) {
+ const route=method==='POST'?'/api/agents':'/api/agents/reviewer';
+ assert.equal((await fetch(base+route,{method,headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'reviewer',config:{prompt:'changed',maxSteps:7,nativePermissions:rules}})})).status,200);
+ const stored=JSON.parse(fs.readFileSync(config,'utf8'));
+ assert.equal(stored.agents.reviewer.system,'changed');
+ assert.equal(stored.agents.reviewer.steps,7);
+ assert.deepEqual(stored.agents.reviewer.permissions,rules);
+ assert.equal(stored.agent,undefined);
+ }
+ const nativeAgents=await (await fetch(base+'/api/agents')).json();
+ assert.equal(nativeAgents.agents.find(a=>a.name==='reviewer').configurationFormat,'v2');
+ assert.equal((await (await fetch(base+'/api/agents/reviewer/toggle',{method:'POST'})).json()).disabled,true);
+ assert.equal(JSON.parse(fs.readFileSync(config,'utf8')).agents.reviewer.disabled,true);
+ assert.equal((await (await fetch(base+'/api/agents/reviewer/toggle',{method:'POST'})).json()).disabled,false);
+ assert.equal((await fetch(base+'/api/agents/reviewer',{method:'DELETE'})).status,200);
+ assert.equal(JSON.parse(fs.readFileSync(config,'utf8')).agents.reviewer,undefined);
+ const agentsDir=path.join(directory,'agents');fs.mkdirSync(agentsDir,{recursive:true});
+ const markdown=path.join(agentsDir,'native-md.md');
+ fs.writeFileSync(markdown,'---\nsteps: 4\ncolor: "#123456"\npermissions:\n - action: shell\n resource: "*"\n effect: deny\n---\nOriginal body');
+ const markdownAgents=await (await fetch(base+'/api/agents')).json();
+ assert.equal(markdownAgents.agents.find(a=>a.name==='native-md').configurationFormat,'v2');
+ assert.equal((await fetch(base+'/api/agents',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'native-md',source:'markdown',config:{prompt:'Updated body',maxSteps:9,nativePermissions:rules}})})).status,200);
+ const updatedMarkdown=fs.readFileSync(markdown,'utf8');
+ assert.match(updatedMarkdown,/steps: 9/);assert.match(updatedMarkdown,/#123456/);assert.match(updatedMarkdown,/Updated body/);assert.doesNotMatch(updatedMarkdown,/maxSteps:|prompt:/);
+ assert.equal((await (await fetch(base+'/api/agents/native-md/toggle',{method:'POST'})).json()).disabled,true);
+ assert.match(fs.readFileSync(markdown,'utf8'),/disabled: true/);
+ const { DatabaseSync }=require('node:sqlite');
+ const dataDirectory=path.join(home,'.local/share/opencode');fs.mkdirSync(dataDirectory,{recursive:true});
+ const db=new DatabaseSync(path.join(dataDirectory,'opencode.db'));
+ db.exec(`CREATE TABLE project (id TEXT PRIMARY KEY, name TEXT, worktree TEXT);
+ CREATE TABLE session_v2 (id TEXT PRIMARY KEY, project_id TEXT);
+ CREATE TABLE session_message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
+ CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT);
+ CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT);
+ INSERT INTO project VALUES ('project', 'Synthetic project', '/synthetic');
+ INSERT INTO session_v2 VALUES ('native-session','project');
+ INSERT INTO session VALUES ('legacy-session','project');`);
+ const now=Date.now();
+ const nativeMessage=JSON.stringify({type:'assistant',cost:0.5,tokens:{input:10,output:20,reasoning:3,cache:{read:4,write:5}},time:{created:now},model:{providerID:'fixture',id:'native-model'}});
+ const oldMessage=JSON.stringify({role:'assistant',cost:0.25,tokens:{input:2,output:3},time:{created:now},modelID:'legacy-model'});
+ db.prepare('INSERT INTO session_message VALUES (?,?,?,?)').run('native-message','native-session',now,nativeMessage);
+ db.prepare('INSERT INTO message VALUES (?,?,?,?)').run('legacy-message','legacy-session',now,oldMessage);
+ db.prepare('INSERT INTO message VALUES (?,?,?,?)').run('native-message','legacy-session',now,nativeMessage);
+ db.close();
+ const usageResponse=await fetch(base+'/api/usage?range=all');assert.equal(usageResponse.status,200);
+ const usage=await usageResponse.json();assert.equal(usage.totalCost,0.75);assert.equal(usage.totalTokens,47);
+ assert.equal(usage.byModel.length,2);assert.ok(usage.byModel.some(m=>m.id==='native-model'));
+ const brokenDb=new DatabaseSync(path.join(dataDirectory,'opencode.db'));brokenDb.exec('DROP TABLE project');brokenDb.close();
+ assert.equal((await fetch(base+'/api/usage?range=all')).status,500,'database failures must not become a false zero-spending report');
+ fs.unlinkSync(path.join(dataDirectory,'opencode.db'));
+ fs.writeFileSync(config,'{}');
+ fs.writeFileSync(path.join(directory,'auth.json'),JSON.stringify({openai:{type:'api',key:'synthetic-not-a-real-secret'}}));
+ const legacyAuth=await (await fetch(base+'/api/auth')).json();
+ assert.equal(JSON.stringify(legacyAuth).includes('synthetic-not-a-real-secret'),false,'auth status must not return stored keys');
+ assert.equal(legacyAuth.openai,undefined);
+ assert.ok(legacyAuth.credentials.some(c=>c.id==='openai' && c.hasCurrentAuth));
+
+
+
+
+
+ } finally {
+ if(child.exitCode===null)child.kill('SIGTERM');
+ await exited;fs.rmSync(home,{recursive:true,force:true});
+ }
+});
diff --git a/server/lib/listen.js b/server/lib/listen.js
new file mode 100644
index 00000000..4f268539
--- /dev/null
+++ b/server/lib/listen.js
@@ -0,0 +1,19 @@
+// Bind the actual application socket, not a probe socket that can be raced.
+async function listenOnAvailablePort(app, startPort = 1920, attempts = 10) {
+ for (let offset = 0; offset < attempts; offset++) {
+ try {
+ return await new Promise((resolve, reject) => {
+ const server = app.listen(startPort + offset, '127.0.0.1');
+ server.once('error', reject);
+ server.once('listening', () => {
+ server.removeListener('error', reject);
+ resolve(server);
+ });
+ });
+ } catch (error) {
+ if (error.code !== 'EADDRINUSE') throw error;
+ }
+ }
+ throw new Error(`No backend port available in ${startPort}-${startPort + attempts - 1}`);
+}
+module.exports = { listenOnAvailablePort };
diff --git a/server/lib/listen.test.js b/server/lib/listen.test.js
new file mode 100644
index 00000000..2949c473
--- /dev/null
+++ b/server/lib/listen.test.js
@@ -0,0 +1,21 @@
+const {test}=require('node:test');
+const assert=require('node:assert/strict');
+const {EventEmitter}=require('node:events');
+const {listenOnAvailablePort}=require('./listen');
+function fakeApp(failures) {
+ const calls=[];
+ return {calls,listen(port,host){calls.push({port,host});const server=new EventEmitter();queueMicrotask(()=>{
+ const code=failures.shift();if(code)server.emit('error',Object.assign(new Error(code),{code}));else server.emit('listening');
+ });return server;}};
+}
+test('binds loopback and retries only occupied ports within discovery range',async()=>{
+ const app=fakeApp(['EADDRINUSE','EADDRINUSE']);await listenOnAvailablePort(app);
+ assert.deepEqual(app.calls,[1920,1921,1922].map(port=>({port,host:'127.0.0.1'})));
+});
+test('fails clearly when all ten discoverable ports are occupied',async()=>{
+ const app=fakeApp(Array(10).fill('EADDRINUSE'));
+ await assert.rejects(listenOnAvailablePort(app),/1920-1929/);assert.equal(app.calls.length,10);
+});
+test('permission errors fail immediately instead of scanning indefinitely',async()=>{
+ const app=fakeApp(['EACCES']);await assert.rejects(listenOnAvailablePort(app),{code:'EACCES'});assert.equal(app.calls.length,1);
+});
diff --git a/server/lib/native-credentials.js b/server/lib/native-credentials.js
new file mode 100644
index 00000000..8ffd53ac
--- /dev/null
+++ b/server/lib/native-credentials.js
@@ -0,0 +1,23 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+// Metadata only. Never select credential.value or rewrite OpenCode's database.
+function readNativeCredentialMetadata(dataDirectories, DatabaseSync) {
+ if (!DatabaseSync) return null;
+ for (const directory of dataDirectories) {
+ const file = path.join(directory, 'opencode.db');
+ if (!fs.existsSync(file)) continue;
+ let db;
+ try {
+ db = new DatabaseSync(file, { readonly: true });
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='credential'").get()) continue;
+ const credentials = db.prepare('SELECT id, integration_id, label, active FROM credential ORDER BY label, id').all()
+ .map(row => ({ id: row.id, integrationID: row.integration_id, label: row.label, active: !!row.active }));
+ return { credentials, storage: 'opencode-v2-database' };
+ } finally {
+ if (db) db.close();
+ }
+ }
+ return null;
+}
+module.exports = { readNativeCredentialMetadata };
diff --git a/server/lib/native-credentials.test.js b/server/lib/native-credentials.test.js
new file mode 100644
index 00000000..3a6c30b6
--- /dev/null
+++ b/server/lib/native-credentials.test.js
@@ -0,0 +1,29 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { DatabaseSync } = require('node:sqlite');
+const { readNativeCredentialMetadata } = require('./native-credentials');
+
+test('v2 credential metadata excludes secret values and does not change storage', () => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-credential-test-'));
+ const file = path.join(directory, 'opencode.db');
+ try {
+ const db = new DatabaseSync(file);
+ db.exec('CREATE TABLE credential (id TEXT PRIMARY KEY, integration_id TEXT, label TEXT, active INTEGER, value TEXT)');
+ db.prepare('INSERT INTO credential VALUES (?,?,?,?,?)').run('fixture', 'fixture-provider', 'Synthetic account', 1, '{"key":"synthetic-never-a-real-secret"}');
+ db.close();
+ const before = fs.readFileSync(file);
+ const result = readNativeCredentialMetadata([directory], DatabaseSync);
+ assert.deepEqual(result, { storage: 'opencode-v2-database', credentials: [{ id: 'fixture', integrationID: 'fixture-provider', label: 'Synthetic account', active: true }] });
+ assert.equal(JSON.stringify(result).includes('synthetic-never-a-real-secret'), false);
+ assert.deepEqual(fs.readFileSync(file), before);
+ } finally { fs.rmSync(directory, { recursive: true, force: true }); }
+});
+
+test('missing native credential store does not create directories or databases', () => {
+ const directory = path.join(os.tmpdir(), 'studio-missing-credentials-' + Date.now());
+ assert.equal(readNativeCredentialMetadata([directory], DatabaseSync), null);
+ assert.equal(fs.existsSync(directory), false);
+});
diff --git a/server/lib/opencode-compat.js b/server/lib/opencode-compat.js
new file mode 100644
index 00000000..94690a05
--- /dev/null
+++ b/server/lib/opencode-compat.js
@@ -0,0 +1,74 @@
+// Read aliases without rewriting the source. Native v2 entries win conflicts.
+const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value);
+const map = value => isObject(value) ? value : {};
+
+function hasNativeMcp(config = {}) {
+ const mcp = map(config.mcp);
+ return (isObject(mcp.servers) && !('type' in mcp.servers))
+ || (isObject(mcp.timeout) && !('type' in mcp.timeout));
+}
+
+function readConfigMaps(config = {}) {
+ const mcp = map(config.mcp);
+ const nativeMcp = hasNativeMcp(config);
+ const legacyMcp = nativeMcp
+ ? Object.fromEntries(Object.entries(mcp).filter(([key]) => key !== 'servers' && key !== 'timeout'))
+ : mcp;
+ return {
+ agents: { ...map(config.agent), ...map(config.agents) },
+ commands: { ...map(config.command), ...map(config.commands) },
+ providers: { ...map(config.provider), ...map(config.model?.providers), ...map(config.providers) },
+ mcp: { ...legacyMcp, ...(nativeMcp ? map(mcp.servers) : {}) },
+ };
+}
+
+function pluginName(entry) {
+ if (typeof entry === 'string') return entry;
+ if (Array.isArray(entry)) return typeof entry[0] === 'string' ? entry[0] : null;
+ return isObject(entry) ? entry.package || entry.name || entry.npm || null : null;
+}
+
+function isNativeAgent(config) {
+ return isObject(config) && (Array.isArray(config.permissions) || ['system', 'steps', 'disabled', 'request'].some(key => Object.hasOwn(config, key)));
+}
+
+function modelLabel(model) {
+ return isObject(model) && typeof model.providerID === 'string' && typeof model.model === 'string'
+ ? `${model.providerID}/${model.model}${model.variant ? `#${model.variant}` : ''}`
+ : model;
+}
+
+function agentView(config, native = false) {
+ return {
+ ...config,
+ model: native ? modelLabel(config.model) : config.model,
+ prompt: native ? config.system ?? '' : config.prompt ?? '',
+ maxSteps: native ? config.steps : config.maxSteps,
+ disable: native ? config.disabled : config.disable,
+ configurationFormat: native ? 'v2' : 'v1',
+ // Ordered v2 rules must not be flattened into the legacy permission map.
+ ...(native ? { nativePermissions: config.permissions, permission: undefined, permissions: undefined } : {}),
+ };
+}
+
+function mergeNativeAgentUpdate(existing, update) {
+ const result = { ...existing };
+ for (const key of ['description', 'mode', 'model', 'hidden']) {
+ if (Object.hasOwn(update, key) && !(key === 'model' && update.model === modelLabel(existing.model))) result[key] = update[key];
+ }
+ for (const [legacy, native] of [['prompt', 'system'], ['maxSteps', 'steps'], ['disable', 'disabled']]) {
+ if (Object.hasOwn(update, legacy)) result[native] = update[legacy];
+ }
+ if (Object.hasOwn(update, 'nativePermissions')) {
+ const rules = update.nativePermissions;
+ if (!Array.isArray(rules) || rules.some(rule => !isObject(rule)
+ || typeof rule.action !== 'string' || typeof rule.resource !== 'string'
+ || !['ask', 'allow', 'deny'].includes(rule.effect))) {
+ throw Object.assign(new Error('Invalid ordered v2 permission rules'), { statusCode: 400 });
+ }
+ result.permissions = rules.map(rule => ({ ...rule }));
+ }
+ return result;
+}
+
+module.exports = { pluginName, isNativeAgent, hasNativeMcp, readConfigMaps, agentView, mergeNativeAgentUpdate };
diff --git a/server/lib/opencode-compat.test.js b/server/lib/opencode-compat.test.js
new file mode 100644
index 00000000..6872ace1
--- /dev/null
+++ b/server/lib/opencode-compat.test.js
@@ -0,0 +1,53 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { readConfigMaps, agentView, mergeNativeAgentUpdate } = require('./opencode-compat');
+
+test('preserves legacy servers named servers or timeout', () => {
+ const mcp = { servers: { type: 'local', command: ['fixture'] }, timeout: { type: 'remote', url: 'https://example.invalid' } };
+ assert.deepEqual(readConfigMaps({ mcp }).mcp, mcp);
+});
+
+test('reads native v2 collections without mutating or discarding legacy siblings', () => {
+ const config = {
+ command: { old: { template: 'old' }, both: { template: 'v1' } },
+ commands: { both: { template: 'v2' } },
+ agents: { reviewer: { system: 'review', permissions: [{ action: 'shell', resource: '*', effect: 'deny' }] } },
+ mcp: { timeout: { connect: 1000 }, servers: { local: { type: 'local', command: ['fixture'], disabled: true } } },
+ provider: { legacy: { name: 'old' } }, providers: { native: { name: 'new' } },
+ };
+ const before = JSON.stringify(config);
+ const result = readConfigMaps(config);
+ assert.equal(result.commands.both.template, 'v2');
+ assert.equal(result.commands.old.template, 'old');
+ assert.deepEqual(Object.keys(result.mcp), ['local']);
+ assert.equal(result.mcp.local.disabled, true);
+ assert.deepEqual(Object.keys(result.providers), ['legacy', 'native']);
+ assert.equal(JSON.stringify(config), before);
+});
+
+test('native agent view preserves ordered permissions and native settings', () => {
+ const rules = [{ action: 'shell', resource: '*', effect: 'deny' }, { action: 'shell', resource: 'git status', effect: 'allow' }];
+ const view = agentView({ system: 'review', steps: 7, disabled: true, permissions: rules, custom: 'preserved' }, true);
+ assert.equal(view.prompt, 'review');
+ assert.equal(view.maxSteps, 7);
+ assert.equal(view.disable, true);
+ assert.deepEqual(view.nativePermissions, rules);
+ assert.equal(view.custom, 'preserved');
+});
+
+test('native agent updates preserve unknown fields and ordered rules', () => {
+ const original = { system: 'old', steps: 3, permissions: [{ action: 'shell', resource: '*', effect: 'deny' }], request: { custom: 'keep' } };
+ const edited = mergeNativeAgentUpdate(original, { prompt: 'new', maxSteps: 5, permission: { '*': 'allow' }, temperature: 1 });
+ assert.deepEqual(edited, { ...original, system: 'new', steps: 5 });
+ assert.equal(original.system, 'old');
+ assert.throws(() => mergeNativeAgentUpdate(original, { nativePermissions: { '*': 'allow' } }), { statusCode: 400 });
+});
+
+
+test('native explicit model selections display without losing their source representation', () => {
+ const original = { model: { providerID: 'fixture', model: 'model', variant: 'high' }, system: 'old' };
+ const view = agentView(original, true);
+ assert.equal(view.model, 'fixture/model#high');
+ assert.deepEqual(mergeNativeAgentUpdate(original, { model: view.model, prompt: 'new' }).model, original.model);
+ assert.equal(mergeNativeAgentUpdate(original, { model: 'fixture/other' }).model, 'fixture/other');
+});
diff --git a/server/lib/resource-names.js b/server/lib/resource-names.js
index 85bbd0f5..1c8fc601 100644
--- a/server/lib/resource-names.js
+++ b/server/lib/resource-names.js
@@ -76,6 +76,16 @@ function getNamedEntries(entries, type) {
}
function assertSafeBackupResourceNames(backup = {}) {
+ const invalid = (message) => Object.assign(new Error(message), { statusCode: 400 });
+ if (!backup || typeof backup !== 'object' || Array.isArray(backup)) {
+ throw invalid('Invalid backup object');
+ }
+ for (const field of ['studioConfig', 'opencodeConfig']) {
+ const value = backup[field];
+ if (value !== undefined && value !== null && (typeof value !== 'object' || Array.isArray(value))) {
+ throw invalid(`Invalid ${field} object`);
+ }
+ }
const skills = getNamedEntries(backup.skills, 'skills');
const invalidSkill = findInvalidNamedEntry(skills, isSafeSkillName);
if (invalidSkill) {
@@ -87,6 +97,22 @@ function assertSafeBackupResourceNames(backup = {}) {
if (invalidPlugin) {
throw createInvalidResourceNameError('plugin', invalidPlugin.name);
}
+ for (const [type, entries] of [['skill', skills], ['plugin', plugins]]) {
+ for (const entry of entries) {
+ if (!entry || typeof entry !== 'object') throw invalid(`Invalid ${type} entry`);
+ if (typeof entry.content !== 'string') {
+ const error = new Error(`Invalid ${type} content`);
+ error.statusCode = 400;
+ throw error;
+ }
+ if (type === 'plugin' && entry.extension !== undefined && !['.js', '.ts'].includes(entry.extension)) {
+ const error = new Error('Invalid plugin extension');
+ error.statusCode = 400;
+ throw error;
+ }
+ }
+ }
+
}
module.exports = {
diff --git a/server/lib/resource-names.test.js b/server/lib/resource-names.test.js
index a1dda352..f61dae65 100644
--- a/server/lib/resource-names.test.js
+++ b/server/lib/resource-names.test.js
@@ -10,6 +10,11 @@ const {
} = require('./resource-names');
describe('resource name policy', () => {
+ it('rejects malformed backup objects and null entries with client errors', () => {
+ for (const payload of [null, [], 'bad', { opencodeConfig: [] }, { studioConfig: 'bad' }, { skills: [null] }, { plugins: [null] }]) {
+ assert.throws(() => assertSafeBackupResourceNames(payload), { statusCode: 400 });
+ }
+ });
it('accepts normal skill, plugin, and agent names', () => {
assert.equal(isSafeSkillName('code-review'), true);
assert.equal(isSafePluginName('watcher.plugin'), true);
diff --git a/server/package-lock.json b/server/package-lock.json
new file mode 100644
index 00000000..ada79688
--- /dev/null
+++ b/server/package-lock.json
@@ -0,0 +1,927 @@
+{
+ "name": "opencode-studio-server",
+ "version": "2.5.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "opencode-studio-server",
+ "version": "2.5.0",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "body-parser": "^2.2.1",
+ "cors": "^2.8.5",
+ "express": "^5.2.1",
+ "js-yaml": "^4.1.1",
+ "jsonc-parser": "^3.3.1"
+ },
+ "bin": {
+ "opencode-studio-server": "cli.js"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/body-parser": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz",
+ "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.1.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.16.1",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz",
+ "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
+ "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz",
+ "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.3.0",
+ "content-disposition": "^2.0.1",
+ "content-type": "^2.0.0",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.8",
+ "qs": "^6.16.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.1.0",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsonc-parser": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+ "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
+ "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
+ "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz",
+ "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/server/package.json b/server/package.json
index c1e5527b..95e4f702 100644
--- a/server/package.json
+++ b/server/package.json
@@ -1,6 +1,6 @@
{
"name": "opencode-studio-server",
- "version": "2.4.5",
+ "version": "2.5.0",
"description": "Backend server for OpenCode Studio - manages opencode configurations",
"main": "index.js",
"bin": {
@@ -9,7 +9,8 @@
"scripts": {
"start": "node index.js",
"register": "node register-protocol.js",
- "postinstall": "node register-protocol.js"
+ "postinstall": "node register-protocol.js",
+ "test": "node --test profile-manager.test.js lib/*.test.js"
},
"keywords": [
"opencode",
diff --git a/server/profile-manager.js b/server/profile-manager.js
index 883e50f6..25cacadf 100644
--- a/server/profile-manager.js
+++ b/server/profile-manager.js
@@ -7,7 +7,7 @@ const OPENCODE_DIR = path.join(HOME_DIR, '.config', 'opencode');
const PROFILES_DIR = path.join(HOME_DIR, '.config', 'opencode-profiles');
function safeName(name) {
- if (!name || typeof name !== 'string' || name.includes('/') || name.includes('\\')) {
+ if (!name || typeof name !== 'string' || name.includes('/') || name.includes('\\') || /[\x00-\x1f]/.test(name)) {
throw new Error('Invalid profile name');
}
const target = path.resolve(PROFILES_DIR, name);
@@ -17,7 +17,7 @@ function safeName(name) {
return name;
}
-if (!fs.existsSync(PROFILES_DIR)) {
+function ensureProfilesDirectory() {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
}
@@ -30,13 +30,19 @@ function isSymlink(filepath) {
}
function init() {
+ ensureProfilesDirectory();
const defaultProfilePath = path.join(PROFILES_DIR, 'default');
if (fs.existsSync(OPENCODE_DIR) && !isSymlink(OPENCODE_DIR)) {
if (!fs.existsSync(defaultProfilePath)) {
console.log('[Profiles] Migrating existing config to "default" profile');
fs.renameSync(OPENCODE_DIR, defaultProfilePath);
- fs.symlinkSync(defaultProfilePath, OPENCODE_DIR, 'junction');
+ try {
+ fs.symlinkSync(defaultProfilePath, OPENCODE_DIR, 'junction');
+ } catch (error) {
+ fs.renameSync(defaultProfilePath, OPENCODE_DIR);
+ throw error;
+ }
}
} else if (!fs.existsSync(OPENCODE_DIR) && !isSymlink(OPENCODE_DIR)) {
fs.mkdirSync(defaultProfilePath, { recursive: true });
@@ -47,7 +53,7 @@ function init() {
function listProfiles() {
init();
const profiles = fs.readdirSync(PROFILES_DIR).filter(f => {
- return fs.statSync(path.join(PROFILES_DIR, f)).isDirectory();
+ return fs.lstatSync(path.join(PROFILES_DIR, f)).isDirectory();
});
let active = null;
@@ -63,6 +69,7 @@ function listProfiles() {
function createProfile(name) {
safeName(name);
+ ensureProfilesDirectory();
const dir = path.join(PROFILES_DIR, name);
if (fs.existsSync(dir)) throw new Error('Profile already exists');
fs.mkdirSync(dir, { recursive: true });
@@ -84,14 +91,35 @@ function deleteProfile(name) {
function activateProfile(name) {
safeName(name);
+ init();
const target = path.join(PROFILES_DIR, name);
- if (!fs.existsSync(target)) throw new Error('Profile not found');
-
- if (fs.existsSync(OPENCODE_DIR)) {
- fs.rmSync(OPENCODE_DIR, { recursive: true, force: true });
+ let targetInfo;
+ try { targetInfo = fs.lstatSync(target); } catch { throw new Error('Profile not found'); }
+ if (!targetInfo.isDirectory() || targetInfo.isSymbolicLink()) {
+ throw new Error('Profile must be a local directory');
}
-
- fs.symlinkSync(target, OPENCODE_DIR, 'junction');
+ // Never remove a real config directory, even when a default profile already
+ // exists and automatic migration cannot safely choose which copy to keep.
+ if (!isSymlink(OPENCODE_DIR)) {
+ throw new Error('Existing unmanaged config must be backed up or migrated before switching profiles');
+ }
+ const nonce = require('crypto').randomBytes(8).toString('hex');
+ const pending = OPENCODE_DIR + '.next-' + nonce;
+ const previous = OPENCODE_DIR + '.previous-' + nonce;
+ fs.symlinkSync(target, pending, 'junction');
+ let moved = false;
+ try {
+ fs.renameSync(OPENCODE_DIR, previous);
+ moved = true;
+ fs.renameSync(pending, OPENCODE_DIR);
+ } catch (error) {
+ if (moved) fs.renameSync(previous, OPENCODE_DIR);
+ throw error;
+ } finally {
+ if (isSymlink(pending)) fs.unlinkSync(pending);
+ }
+ // Only unlink our old link; never recursively delete its target directory.
+ fs.unlinkSync(previous);
return { success: true };
}
diff --git a/server/profile-manager.test.js b/server/profile-manager.test.js
index 3112fabc..7b577197 100644
--- a/server/profile-manager.test.js
+++ b/server/profile-manager.test.js
@@ -13,3 +13,67 @@ test('safeName accepts direct-child names', () => {
assert.strictEqual(safeName(ok), ok);
}
});
+
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+
+function isolated(script) {
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-profile-test-'));
+ try {
+ execFileSync(process.execPath, ['-e', `
+ const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict');
+ const home=process.env.HOME, config=path.join(home,'.config/opencode'), profiles=path.join(home,'.config/opencode-profiles');
+ const manager=require(${JSON.stringify(require.resolve('./profile-manager'))});
+ ${script}
+ `], { env: { ...process.env, HOME: home, USERPROFILE: home }, stdio: 'pipe' });
+ } finally { fs.rmSync(home, { recursive: true, force: true }); }
+}
+
+test('import does not modify the home directory', () => isolated(`
+ assert.equal(fs.existsSync(path.join(home,'.config')),false);
+`));
+
+test('activation preserves an unmanaged config when default already exists', () => isolated(`
+ fs.mkdirSync(config,{recursive:true});fs.writeFileSync(path.join(config,'opencode.json'),'keep me');
+ manager.createProfile('default');manager.createProfile('work');
+ assert.throws(()=>manager.activateProfile('work'),/unmanaged config/);
+ assert.equal(fs.readFileSync(path.join(config,'opencode.json'),'utf8'),'keep me');
+ assert.equal(fs.lstatSync(config).isDirectory(),true);
+`));
+
+test('first activation safely migrates and preserves an existing config', () => isolated(`
+ fs.mkdirSync(config,{recursive:true});fs.writeFileSync(path.join(config,'opencode.json'),'keep me');
+ manager.createProfile('work');manager.activateProfile('work');
+ assert.equal(fs.readFileSync(path.join(profiles,'default/opencode.json'),'utf8'),'keep me');
+ assert.equal(manager.listProfiles().active,'work');
+ manager.activateProfile('default');assert.equal(fs.readFileSync(path.join(config,'opencode.json'),'utf8'),'keep me');
+`));
+
+test('failed activation rolls back the old link and preserves both profiles', () => isolated(`
+ manager.listProfiles();manager.createProfile('work');
+ fs.writeFileSync(path.join(profiles,'default/keep'),'original');
+ const rename=fs.renameSync;
+ fs.renameSync=(from,to)=>{if(from.includes('.next-'))throw new Error('simulated rename failure');return rename(from,to);};
+ assert.throws(()=>manager.activateProfile('work'),/simulated/);fs.renameSync=rename;
+ assert.equal(manager.listProfiles().active,'default');
+ assert.equal(fs.readFileSync(path.join(config,'keep'),'utf8'),'original');
+ assert.equal(fs.existsSync(path.join(profiles,'work')),true);
+`));
+
+test('activation rejects files and external symlink targets', () => isolated(`
+ manager.listProfiles();fs.writeFileSync(path.join(profiles,'file'),'not a directory');
+ assert.throws(()=>manager.activateProfile('file'),/local directory/);
+ const outside=path.join(home,'outside');fs.mkdirSync(outside);fs.symlinkSync(outside,path.join(profiles,'external'),'junction');
+ assert.throws(()=>manager.activateProfile('external'),/local directory/);
+ assert.equal(manager.listProfiles().active,'default');
+`));
+
+test('migration restores unmanaged config if creating its link fails', () => isolated(`
+ fs.mkdirSync(config,{recursive:true});fs.writeFileSync(path.join(config,'opencode.json'),'keep me');
+ const link=fs.symlinkSync;fs.symlinkSync=()=>{throw new Error('simulated link failure');};
+ assert.throws(()=>manager.listProfiles(),/simulated/);fs.symlinkSync=link;
+ assert.equal(fs.readFileSync(path.join(config,'opencode.json'),'utf8'),'keep me');
+ assert.equal(fs.lstatSync(config).isDirectory(),true);
+`));