Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes
- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas, login rate limiting, audit log and backups - Ghidra headless decompilation (ARM64 natives built from source), binary_info tool, clean room / deep workflow per session - Recreate skill and commands for analysing and re-building old software - File checkpoints with rewind/restore, background shells (bash_output, kill_shell) - web_search tool (DuckDuckGo, SearXNG, Brave, Tavily) - In-app dialogs and toasts instead of browser dialogs - 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One, Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fab9add5d5
commit
5b569b6835
108 files changed
+6785
-279
No files matched your search
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { AgentInfo } from "../../types";
|
||||
import { ModelSelect } from "../ModelSelect";
|
||||
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
type Patch = { path: string[]; value: unknown };
|
||||
|
||||
@@ -45,7 +46,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
|
||||
};
|
||||
|
||||
const reset = async () => {
|
||||
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) return;
|
||||
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
|
||||
try {
|
||||
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
|
||||
onSaved(true, t("settings.saved"));
|
||||
@@ -135,7 +136,7 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
|
||||
|
||||
const add = async (template: "writer" | "blank") => {
|
||||
const n = name.trim() || (template === "writer" ? "writer" : "");
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
|
||||
const value =
|
||||
template === "writer"
|
||||
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
|
||||
interface Entry {
|
||||
ts: number;
|
||||
user: string;
|
||||
action: string;
|
||||
detail: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
/** Admin: audit log and backup download. */
|
||||
export function Audit() {
|
||||
const t = useT();
|
||||
const [filter, setFilter] = useState("");
|
||||
const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`);
|
||||
return (
|
||||
<>
|
||||
<div className="panel">
|
||||
<h3>{t("audit.backup")}</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 8 }}>{t("audit.backupHint")}</div>
|
||||
<a className="button primary" href="/api/backup">
|
||||
{t("audit.download")}
|
||||
</a>
|
||||
</div>
|
||||
<div className="panel">
|
||||
<h3>
|
||||
{t("audit.title")}
|
||||
<span className="spacer" />
|
||||
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
|
||||
<option value="">{t("audit.all")}</option>
|
||||
{["login", "permission", "config", "user", "folder", "session", "git", "backup"].map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="small" onClick={reload}>
|
||||
↻
|
||||
</button>
|
||||
</h3>
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table className="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("audit.time")}</th>
|
||||
<th>{t("stats.user")}</th>
|
||||
<th>{t("audit.action")}</th>
|
||||
<th>{t("audit.detail")}</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.map((e, i) => (
|
||||
<tr key={i} className={e.action.includes("failed") || e.action.includes("blocked") || e.action.includes("reject") ? "audit-warn" : ""}>
|
||||
<td style={{ whiteSpace: "nowrap" }}>{fmtDate(e.ts)}</td>
|
||||
<td>{e.user || "–"}</td>
|
||||
<td>
|
||||
<code>{e.action}</code>
|
||||
</td>
|
||||
<td style={{ wordBreak: "break-word" }}>{e.detail}</td>
|
||||
<td>{e.ip}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{data?.length === 0 && <div className="empty">–</div>}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { del, post, put } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import type { User } from "../../types";
|
||||
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
interface Folder {
|
||||
id: string;
|
||||
@@ -44,7 +45,7 @@ export function Folders() {
|
||||
<h3>
|
||||
📁 <code>{f.path.slice(ws.length) || "/"}</code>
|
||||
<span className="spacer" />
|
||||
<button className="small danger" onClick={() => confirm(t("folders.removeConfirm")) && run(() => del(`/folders/${f.id}`))}>
|
||||
<button className="small danger" onClick={() => void confirmDialog({ message: t("folders.removeConfirm"), danger: true, confirmLabel: t("folders.remove") }).then((ok): unknown => ok && run(() => del(`/folders/${f.id}`)))}>
|
||||
{t("folders.remove")}
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from "react";
|
||||
import { post } from "../../api";
|
||||
import { useFetch } from "../../hooks";
|
||||
import { useT } from "../../i18n";
|
||||
import { Spinner } from "../Activity";
|
||||
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
interface ToolsStatus {
|
||||
ghidra: { installed: boolean; version?: string | null; running: boolean; log: string };
|
||||
}
|
||||
|
||||
/** Admin: optional tools installed into the data volume. */
|
||||
export function Tools() {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<ToolsStatus>("/tools");
|
||||
// Poll while an installation runs.
|
||||
useEffect(() => {
|
||||
if (!data?.ghidra.running) return;
|
||||
const h = setInterval(reload, 3000);
|
||||
return () => clearInterval(h);
|
||||
}, [data?.ghidra.running, reload]);
|
||||
const g = data?.ghidra;
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
Ghidra
|
||||
{g?.installed && <span className="chip">{g.version ?? t("tools.installed")}</span>}
|
||||
</h3>
|
||||
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 10 }}>{t("tools.ghidraHint")}</div>
|
||||
{g?.running ? (
|
||||
<div className="row">
|
||||
<Spinner /> {t("tools.installing")}
|
||||
</div>
|
||||
) : (
|
||||
<button className="primary" onClick={() => post("/tools/ghidra/install").then(reload).catch((e) => toast(e.message))}>
|
||||
{g?.installed ? t("tools.reinstall") : t("tools.install")}
|
||||
</button>
|
||||
)}
|
||||
{g?.log && <pre className="git-output" style={{ marginTop: 10 }}>{g.log}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
|
||||
import { fmtDate, useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import type { User } from "../../types";
|
||||
import { confirmDialog, promptDialog, toast } from "../../dialogs";
|
||||
|
||||
export function Users() {
|
||||
const t = useT();
|
||||
@@ -22,8 +23,8 @@ export function Users() {
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = (u: User) => {
|
||||
const pw = prompt(t("users.newPasswordFor", { name: u.username }));
|
||||
const resetPassword = async (u: User) => {
|
||||
const pw = await promptDialog({ message: t("users.newPasswordFor", { name: u.username }), password: true });
|
||||
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
|
||||
};
|
||||
|
||||
@@ -39,6 +40,7 @@ export function Users() {
|
||||
<th>{t("users.displayName")}</th>
|
||||
<th>{t("users.role")}</th>
|
||||
<th>{t("users.login")}</th>
|
||||
<th>{t("users.quota")}</th>
|
||||
<th>{t("users.created")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
@@ -60,6 +62,19 @@ export function Users() {
|
||||
<td>
|
||||
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="small ghost"
|
||||
title={t("users.quotaHint")}
|
||||
onClick={async () => {
|
||||
const v = await promptDialog({ message: t("users.quotaPrompt", { name: u.username }), defaultValue: u.monthly_quota != null ? String(u.monthly_quota) : "" });
|
||||
if (v === null) return;
|
||||
void run(() => patch(`/users/${u.id}`, { monthly_quota: v.trim() === "" ? -1 : Number(v.replace(",", ".")) }));
|
||||
}}
|
||||
>
|
||||
{u.monthly_quota != null ? u.monthly_quota : "∞"}
|
||||
</button>
|
||||
</td>
|
||||
<td>{fmtDate(u.created_at, false)}</td>
|
||||
<td>
|
||||
<div className="row" style={{ flexWrap: "nowrap" }}>
|
||||
@@ -70,7 +85,7 @@ export function Users() {
|
||||
{u.disabled ? t("users.enable") : t("users.disable")}
|
||||
</button>
|
||||
{u.id !== me?.id && (
|
||||
<button className="small danger" onClick={() => confirm(t("users.deleteConfirm", { name: u.username })) && run(() => del(`/users/${u.id}`))}>
|
||||
<button className="small danger" onClick={() => void confirmDialog({ message: t("users.deleteConfirm", { name: u.username }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => del(`/users/${u.id}`)))}>
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user