Add username log censor setting

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-20 08:00:39 -05:00
parent 3de7d63ea9
commit 39878fcdfe
33 changed files with 10841 additions and 146 deletions

View File

@@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceGeneralSettings } from "./pages/InstanceGeneralSettings";
import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
@@ -171,7 +172,7 @@ function InboxRootRedirect() {
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings/heartbeats${location.search}${location.hash}`} replace />;
return <Navigate to={`/instance/settings/general${location.search}${location.hash}`} replace />;
}
function OnboardingRoutePage() {
@@ -296,9 +297,10 @@ export function App() {
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />
<Route path="instance" element={<Navigate to="/instance/settings/heartbeats" replace />} />
<Route path="instance" element={<Navigate to="/instance/settings/general" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<Navigate to="heartbeats" replace />} />
<Route index element={<Navigate to="general" replace />} />
<Route path="general" element={<InstanceGeneralSettings />} />
<Route path="heartbeats" element={<InstanceSettings />} />
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />

View File

@@ -2,6 +2,7 @@ import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@papercl
import type { TranscriptEntry, StdoutLineParser } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
@@ -21,17 +22,22 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
}
}
export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser): TranscriptEntry[] {
export function buildTranscript(
chunks: RunLogChunk[],
parser: StdoutLineParser,
opts?: TranscriptBuildOptions,
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? true };
for (const chunk of chunks) {
if (chunk.stream === "stderr") {
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
entries.push({ kind: "stderr", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
if (chunk.stream === "system") {
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk) });
entries.push({ kind: "system", ts: chunk.ts, text: redactHomePathUserSegments(chunk.chunk, redactionOptions) });
continue;
}
@@ -41,14 +47,14 @@ export function buildTranscript(chunks: RunLogChunk[], parser: StdoutLineParser)
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map(redactTranscriptEntryPaths));
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts).map(redactTranscriptEntryPaths));
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
return entries;

View File

@@ -1,10 +1,16 @@
import type {
InstanceExperimentalSettings,
InstanceGeneralSettings,
PatchInstanceGeneralSettings,
PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
import { api } from "./client";
export const instanceSettingsApi = {
getGeneral: () =>
api.get<InstanceGeneralSettings>("/instance/settings/general"),
updateGeneral: (patch: PatchInstanceGeneralSettings) =>
api.patch<InstanceGeneralSettings>("/instance/settings/general", patch),
getExperimental: () =>
api.get<InstanceExperimentalSettings>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>

View File

@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, FlaskConical, Puzzle, Settings } from "lucide-react";
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@@ -22,6 +22,7 @@ export function InstanceSidebar() {
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings/general" label="General" icon={SlidersHorizontal} end />
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />

View File

@@ -1,7 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000;
const LOG_READ_LIMIT_BYTES = 256_000;
@@ -65,6 +68,10 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const runById = useMemo(() => new Map(runs.map((run) => [run.id, run])), [runs]);
const activeRunIds = useMemo(
@@ -267,12 +274,18 @@ export function useLiveRunTranscripts({
const transcriptByRun = useMemo(() => {
const next = new Map<string, TranscriptEntry[]>();
const censorUsernameInLogs = generalSettings?.censorUsernameInLogs === true;
for (const run of runs) {
const adapter = getUIAdapter(run.adapterType);
next.set(run.id, buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine));
next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
censorUsernameInLogs,
}),
);
}
return next;
}, [chunksByRun, runs]);
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
return {
transcriptByRun,

View File

@@ -6,6 +6,9 @@ import {
describe("normalizeRememberedInstanceSettingsPath", () => {
it("keeps known instance settings pages", () => {
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/general")).toBe(
"/instance/settings/general",
);
expect(normalizeRememberedInstanceSettingsPath("/instance/settings/experimental")).toBe(
"/instance/settings/experimental",
);

View File

@@ -1,4 +1,4 @@
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/heartbeats";
export const DEFAULT_INSTANCE_SETTINGS_PATH = "/instance/settings/general";
export function normalizeRememberedInstanceSettingsPath(rawPath: string | null): string {
if (!rawPath) return DEFAULT_INSTANCE_SETTINGS_PATH;
@@ -9,6 +9,7 @@ export function normalizeRememberedInstanceSettingsPath(rawPath: string | null):
const hash = match?.[3] ?? "";
if (
pathname === "/instance/settings/general" ||
pathname === "/instance/settings/heartbeats" ||
pathname === "/instance/settings/plugins" ||
pathname === "/instance/settings/experimental"

View File

@@ -68,6 +68,7 @@ export const queryKeys = {
session: ["auth", "session"] as const,
},
instance: {
generalSettings: ["instance", "general-settings"] as const,
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
experimentalSettings: ["instance", "experimental-settings"] as const,
},

View File

@@ -10,6 +10,7 @@ import {
} from "../api/agents";
import { budgetsApi } from "../api/budgets";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { ApiError } from "../api/client";
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
import { activityApi } from "../api/activity";
@@ -95,13 +96,21 @@ const SECRET_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const JWT_VALUE_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)?$/;
function redactPathText(value: string, censorUsernameInLogs: boolean) {
return redactHomePathUserSegments(value, { enabled: censorUsernameInLogs });
}
function redactPathValue<T>(value: T, censorUsernameInLogs: boolean): T {
return redactHomePathUserSegmentsInValue(value, { enabled: censorUsernameInLogs });
}
function shouldRedactSecretValue(key: string, value: unknown): boolean {
if (SECRET_ENV_KEY_RE.test(key)) return true;
if (typeof value !== "string") return false;
return JWT_VALUE_RE.test(value);
}
function redactEnvValue(key: string, value: unknown): string {
function redactEnvValue(key: string, value: unknown, censorUsernameInLogs: boolean): string {
if (
typeof value === "object" &&
value !== null &&
@@ -112,15 +121,15 @@ function redactEnvValue(key: string, value: unknown): string {
}
if (shouldRedactSecretValue(key, value)) return REDACTED_ENV_VALUE;
if (value === null || value === undefined) return "";
if (typeof value === "string") return redactHomePathUserSegments(value);
if (typeof value === "string") return redactPathText(value, censorUsernameInLogs);
try {
return JSON.stringify(redactHomePathUserSegmentsInValue(value));
return JSON.stringify(redactPathValue(value, censorUsernameInLogs));
} catch {
return redactHomePathUserSegments(String(value));
return redactPathText(String(value), censorUsernameInLogs);
}
}
function formatEnvForDisplay(envValue: unknown): string {
function formatEnvForDisplay(envValue: unknown, censorUsernameInLogs: boolean): string {
const env = asRecord(envValue);
if (!env) return "<unable-to-parse>";
@@ -129,7 +138,7 @@ function formatEnvForDisplay(envValue: unknown): string {
return keys
.sort()
.map((key) => `${key}=${redactEnvValue(key, env[key])}`)
.map((key) => `${key}=${redactEnvValue(key, env[key], censorUsernameInLogs)}`)
.join("\n");
}
@@ -311,7 +320,13 @@ function WorkspaceOperationStatusBadge({ status }: { status: WorkspaceOperation[
);
}
function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperation }) {
function WorkspaceOperationLogViewer({
operation,
censorUsernameInLogs,
}: {
operation: WorkspaceOperation;
censorUsernameInLogs: boolean;
}) {
const [open, setOpen] = useState(false);
const { data: logData, isLoading, error } = useQuery({
queryKey: ["workspace-operation-log", operation.id],
@@ -364,7 +379,7 @@ function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperat
>
[{chunk.stream}]
</span>
<span className="whitespace-pre-wrap break-all">{redactHomePathUserSegments(chunk.chunk)}</span>
<span className="whitespace-pre-wrap break-all">{redactPathText(chunk.chunk, censorUsernameInLogs)}</span>
</div>
))}
</div>
@@ -375,7 +390,13 @@ function WorkspaceOperationLogViewer({ operation }: { operation: WorkspaceOperat
);
}
function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOperation[] }) {
function WorkspaceOperationsSection({
operations,
censorUsernameInLogs,
}: {
operations: WorkspaceOperation[];
censorUsernameInLogs: boolean;
}) {
if (operations.length === 0) return null;
return (
@@ -440,7 +461,7 @@ function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOpera
<div>
<div className="mb-1 text-xs text-red-700 dark:text-red-300">stderr excerpt</div>
<pre className="rounded-md bg-red-50 p-2 text-xs whitespace-pre-wrap break-all text-red-800 dark:bg-neutral-950 dark:text-red-100">
{redactHomePathUserSegments(operation.stderrExcerpt)}
{redactPathText(operation.stderrExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -448,11 +469,16 @@ function WorkspaceOperationsSection({ operations }: { operations: WorkspaceOpera
<div>
<div className="mb-1 text-xs text-muted-foreground">stdout excerpt</div>
<pre className="rounded-md bg-neutral-100 p-2 text-xs whitespace-pre-wrap break-all dark:bg-neutral-950">
{redactHomePathUserSegments(operation.stdoutExcerpt)}
{redactPathText(operation.stdoutExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
{operation.logRef && <WorkspaceOperationLogViewer operation={operation} />}
{operation.logRef && (
<WorkspaceOperationLogViewer
operation={operation}
censorUsernameInLogs={censorUsernameInLogs}
/>
)}
</div>
);
})}
@@ -2472,13 +2498,21 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
};
}, [isLive, run.companyId, run.id, run.agentId]);
const censorUsernameInLogs = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
}).data?.censorUsernameInLogs === true;
const adapterInvokePayload = useMemo(() => {
const evt = events.find((e) => e.eventType === "adapter.invoke");
return redactHomePathUserSegmentsInValue(asRecord(evt?.payload ?? null));
}, [events]);
return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
}, [censorUsernameInLogs, events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
const transcript = useMemo(() => buildTranscript(logLines, adapter.parseStdoutLine), [logLines, adapter]);
const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines],
);
useEffect(() => {
setTranscriptMode("nice");
@@ -2506,7 +2540,10 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return (
<div className="space-y-3">
<WorkspaceOperationsSection operations={workspaceOperations} />
<WorkspaceOperationsSection
operations={workspaceOperations}
censorUsernameInLogs={censorUsernameInLogs}
/>
{adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
@@ -2548,8 +2585,8 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? redactHomePathUserSegments(adapterInvokePayload.prompt)
: JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.prompt), null, 2)}
? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
: JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
@@ -2557,7 +2594,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactHomePathUserSegmentsInValue(adapterInvokePayload.context), null, 2)}
{JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
@@ -2565,7 +2602,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env)}
{formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -2641,14 +2678,14 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
{run.error && (
<div className="text-xs text-red-600 dark:text-red-200">
<span className="text-red-700 dark:text-red-300">Error: </span>
{redactHomePathUserSegments(run.error)}
{redactPathText(run.error, censorUsernameInLogs)}
</div>
)}
{run.stderrExcerpt && run.stderrExcerpt.trim() && (
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stderr excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{redactHomePathUserSegments(run.stderrExcerpt)}
{redactPathText(run.stderrExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -2656,7 +2693,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">adapter result JSON</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{JSON.stringify(redactHomePathUserSegmentsInValue(run.resultJson), null, 2)}
{JSON.stringify(redactPathValue(run.resultJson, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
@@ -2664,7 +2701,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
<div>
<div className="text-xs text-red-700 dark:text-red-300 mb-1">stdout excerpt</div>
<pre className="bg-red-50 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap text-red-800 dark:text-red-100">
{redactHomePathUserSegments(run.stdoutExcerpt)}
{redactPathText(run.stdoutExcerpt, censorUsernameInLogs)}
</pre>
</div>
)}
@@ -2691,9 +2728,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
</span>
<span className={cn("break-all", color)}>
{evt.message
? redactHomePathUserSegments(evt.message)
? redactPathText(evt.message, censorUsernameInLogs)
: evt.payload
? JSON.stringify(redactHomePathUserSegmentsInValue(evt.payload))
? JSON.stringify(redactPathValue(evt.payload, censorUsernameInLogs))
: ""}
</span>
</div>

View File

@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
export function InstanceGeneralSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
{ label: "General" },
]);
}, [setBreadcrumbs]);
const generalQuery = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
});
const toggleMutation = useMutation({
mutationFn: async (enabled: boolean) =>
instanceSettingsApi.updateGeneral({ censorUsernameInLogs: enabled }),
onSuccess: async () => {
setActionError(null);
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to update general settings.");
},
});
if (generalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading general settings...</div>;
}
if (generalQuery.error) {
return (
<div className="text-sm text-destructive">
{generalQuery.error instanceof Error
? generalQuery.error.message
: "Failed to load general settings."}
</div>
);
}
const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true;
return (
<div className="max-w-4xl space-y-6">
<div className="space-y-2">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">General</h1>
</div>
<p className="text-sm text-muted-foreground">
Configure instance-wide defaults that affect how operator-visible logs are displayed.
</p>
</div>
{actionError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
)}
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Censor username in logs</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Hide the username segment in home-directory paths and similar log output. This is off by default.
</p>
</div>
<button
type="button"
aria-label="Toggle username log censoring"
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate(!censorUsernameInLogs)}
>
<span
className={cn(
"inline-block h-4.5 w-4.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-6" : "translate-x-0.5",
)}
/>
</button>
</div>
</section>
</div>
);
}