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

@@ -117,7 +117,7 @@ describe("codex_local ui stdout parser", () => {
{
kind: "system",
ts,
text: "file changes: update /Users/[]/project/ui/src/pages/AgentDetail.tsx",
text: "file changes: update /Users/paperclipuser/project/ui/src/pages/AgentDetail.tsx",
},
]);
});

View File

@@ -5,7 +5,9 @@ import { errorHandler } from "../middleware/index.js";
import { instanceSettingsRoutes } from "../routes/instance-settings.js";
const mockInstanceSettingsService = vi.hoisted(() => ({
getGeneral: vi.fn(),
getExperimental: vi.fn(),
updateGeneral: vi.fn(),
updateExperimental: vi.fn(),
listCompanyIds: vi.fn(),
}));
@@ -31,9 +33,18 @@ function createApp(actor: any) {
describe("instance settings routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
});
mockInstanceSettingsService.getExperimental.mockResolvedValue({
enableIsolatedWorkspaces: false,
});
mockInstanceSettingsService.updateGeneral.mockResolvedValue({
id: "instance-settings-1",
general: {
censorUsernameInLogs: true,
},
});
mockInstanceSettingsService.updateExperimental.mockResolvedValue({
id: "instance-settings-1",
experimental: {
@@ -66,6 +77,29 @@ describe("instance settings routes", () => {
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("allows local board users to read and update general settings", async () => {
const app = createApp({
type: "board",
userId: "local-board",
source: "local_implicit",
isInstanceAdmin: true,
});
const getRes = await request(app).get("/api/instance/settings/general");
expect(getRes.status).toBe(200);
expect(getRes.body).toEqual({ censorUsernameInLogs: false });
const patchRes = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(patchRes.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: true,
});
expect(mockLogActivity).toHaveBeenCalledTimes(2);
});
it("rejects non-admin board users", async () => {
const app = createApp({
type: "board",
@@ -75,10 +109,10 @@ describe("instance settings routes", () => {
companyIds: ["company-1"],
});
const res = await request(app).get("/api/instance/settings/experimental");
const res = await request(app).get("/api/instance/settings/general");
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.getExperimental).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled();
});
it("rejects agent callers", async () => {
@@ -90,10 +124,10 @@ describe("instance settings routes", () => {
});
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(res.status).toBe(403);
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import {
CURRENT_USER_REDACTION_TOKEN,
maskUserNameForLogs,
redactCurrentUserText,
redactCurrentUserValue,
} from "../log-redaction.js";
@@ -8,6 +8,7 @@ import {
describe("log redaction", () => {
it("redacts the active username inside home-directory paths", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const input = [
`cwd=/Users/${userName}/paperclip`,
`home=/home/${userName}/workspace`,
@@ -19,14 +20,15 @@ describe("log redaction", () => {
homeDirs: [`/Users/${userName}`, `/home/${userName}`, `C:\\Users\\${userName}`],
});
expect(result).toContain(`cwd=/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`);
expect(result).toContain(`home=/home/${CURRENT_USER_REDACTION_TOKEN}/workspace`);
expect(result).toContain(`win=C:\\Users\\${CURRENT_USER_REDACTION_TOKEN}\\paperclip`);
expect(result).toContain(`cwd=/Users/${maskedUserName}/paperclip`);
expect(result).toContain(`home=/home/${maskedUserName}/workspace`);
expect(result).toContain(`win=C:\\Users\\${maskedUserName}\\paperclip`);
expect(result).not.toContain(userName);
});
it("redacts standalone username mentions without mangling larger tokens", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserText(
`user ${userName} said ${userName}/project should stay but apaperclipuserz should not change`,
{
@@ -36,12 +38,13 @@ describe("log redaction", () => {
);
expect(result).toBe(
`user ${CURRENT_USER_REDACTION_TOKEN} said ${CURRENT_USER_REDACTION_TOKEN}/project should stay but apaperclipuserz should not change`,
`user ${maskedUserName} said ${maskedUserName}/project should stay but apaperclipuserz should not change`,
);
});
it("recursively redacts nested event payloads", () => {
const userName = "paperclipuser";
const maskedUserName = maskUserNameForLogs(userName);
const result = redactCurrentUserValue({
cwd: `/Users/${userName}/paperclip`,
prompt: `open /Users/${userName}/paperclip/ui`,
@@ -55,12 +58,17 @@ describe("log redaction", () => {
});
expect(result).toEqual({
cwd: `/Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip`,
prompt: `open /Users/${CURRENT_USER_REDACTION_TOKEN}/paperclip/ui`,
cwd: `/Users/${maskedUserName}/paperclip`,
prompt: `open /Users/${maskedUserName}/paperclip/ui`,
nested: {
author: CURRENT_USER_REDACTION_TOKEN,
author: maskedUserName,
},
values: [CURRENT_USER_REDACTION_TOKEN, `/home/${CURRENT_USER_REDACTION_TOKEN}/project`],
values: [maskedUserName, `/home/${maskedUserName}/project`],
});
});
it("skips redaction when disabled", () => {
const input = "cwd=/Users/paperclipuser/paperclip";
expect(redactCurrentUserText(input, { enabled: false })).toBe(input);
});
});

View File

@@ -1,8 +1,9 @@
import os from "node:os";
export const CURRENT_USER_REDACTION_TOKEN = "[]";
export const CURRENT_USER_REDACTION_TOKEN = "*";
interface CurrentUserRedactionOptions {
export interface CurrentUserRedactionOptions {
enabled?: boolean;
replacement?: string;
userNames?: string[];
homeDirs?: string[];
@@ -39,6 +40,12 @@ function replaceLastPathSegment(pathValue: string, replacement: string) {
return `${normalized.slice(0, lastSeparator + 1)}${replacement}`;
}
export function maskUserNameForLogs(value: string, fallback = CURRENT_USER_REDACTION_TOKEN) {
const trimmed = value.trim();
if (!trimmed) return fallback;
return `${trimmed[0]}${"*".repeat(Math.max(1, Array.from(trimmed).length - 1))}`;
}
function defaultUserNames() {
const candidates = [
process.env.USER,
@@ -99,21 +106,22 @@ function resolveCurrentUserCandidates(opts?: CurrentUserRedactionOptions) {
export function redactCurrentUserText(input: string, opts?: CurrentUserRedactionOptions) {
if (!input) return input;
if (opts?.enabled === false) return input;
const { userNames, homeDirs, replacement } = resolveCurrentUserCandidates(opts);
let result = input;
for (const homeDir of [...homeDirs].sort((a, b) => b.length - a.length)) {
const lastSegment = splitPathSegments(homeDir).pop() ?? "";
const replacementDir = userNames.includes(lastSegment)
? replaceLastPathSegment(homeDir, replacement)
const replacementDir = lastSegment
? replaceLastPathSegment(homeDir, maskUserNameForLogs(lastSegment, replacement))
: replacement;
result = result.split(homeDir).join(replacementDir);
}
for (const userName of [...userNames].sort((a, b) => b.length - a.length)) {
const pattern = new RegExp(`(?<![A-Za-z0-9._-])${escapeRegExp(userName)}(?![A-Za-z0-9._-])`, "g");
result = result.replace(pattern, replacement);
result = result.replace(pattern, maskUserNameForLogs(userName, replacement));
}
return result;

View File

@@ -36,6 +36,7 @@ import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
import { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@@ -64,8 +65,15 @@ export function agentRoutes(db: Db) {
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const instanceSettings = instanceSettingsService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
async function getCurrentUserRedactionOptions() {
return {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
}
function canCreateAgents(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
@@ -1597,7 +1605,7 @@ export function agentRoutes(db: Db) {
return;
}
assertCompanyAccess(req, run.companyId);
res.json(redactCurrentUserValue(run));
res.json(redactCurrentUserValue(run, await getCurrentUserRedactionOptions()));
});
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
@@ -1632,11 +1640,12 @@ export function agentRoutes(db: Db) {
const afterSeq = Number(req.query.afterSeq ?? 0);
const limit = Number(req.query.limit ?? 200);
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const redactedEvents = events.map((event) =>
redactCurrentUserValue({
...event,
payload: redactEventPayload(event.payload),
}),
}, currentUserRedactionOptions),
);
res.json(redactedEvents);
});
@@ -1672,7 +1681,7 @@ export function agentRoutes(db: Db) {
const context = asRecord(run.contextSnapshot);
const executionWorkspaceId = asNonEmptyString(context?.executionWorkspaceId);
const operations = await workspaceOperations.listForRun(runId, executionWorkspaceId);
res.json(redactCurrentUserValue(operations));
res.json(redactCurrentUserValue(operations, await getCurrentUserRedactionOptions()));
});
router.get("/workspace-operations/:operationId/log", async (req, res) => {
@@ -1768,7 +1777,7 @@ export function agentRoutes(db: Db) {
}
res.json({
...redactCurrentUserValue(run),
...redactCurrentUserValue(run, await getCurrentUserRedactionOptions()),
agentId: agent.id,
agentName: agent.name,
adapterType: agent.adapterType,

View File

@@ -1,6 +1,6 @@
import { Router, type Request } from "express";
import type { Db } from "@paperclipai/db";
import { patchInstanceExperimentalSettingsSchema } from "@paperclipai/shared";
import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSchema } from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { validate } from "../middleware/validate.js";
import { instanceSettingsService, logActivity } from "../services/index.js";
@@ -20,6 +20,41 @@ export function instanceSettingsRoutes(db: Db) {
const router = Router();
const svc = instanceSettingsService(db);
router.get("/instance/settings/general", async (req, res) => {
assertCanManageInstanceSettings(req);
res.json(await svc.getGeneral());
});
router.patch(
"/instance/settings/general",
validate(patchInstanceGeneralSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
await Promise.all(
companyIds.map((companyId) =>
logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "instance.settings.general_updated",
entityType: "instance_settings",
entityId: updated.id,
details: {
general: updated.general,
changedKeys: Object.keys(req.body).sort(),
},
}),
),
);
res.json(updated.general);
},
);
router.get("/instance/settings/experimental", async (req, res) => {
assertCanManageInstanceSettings(req);
res.json(await svc.getExperimental());

View File

@@ -8,6 +8,7 @@ import { redactCurrentUserValue } from "../log-redaction.js";
import { sanitizeRecord } from "../redaction.js";
import { logger } from "../middleware/logger.js";
import type { PluginEventBus } from "./plugin-event-bus.js";
import { instanceSettingsService } from "./instance-settings.js";
const PLUGIN_EVENT_SET: ReadonlySet<string> = new Set(PLUGIN_EVENT_TYPES);
@@ -34,8 +35,13 @@ export interface LogActivityInput {
}
export async function logActivity(db: Db, input: LogActivityInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettingsService(db).getGeneral()).censorUsernameInLogs,
};
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
const redactedDetails = sanitizedDetails ? redactCurrentUserValue(sanitizedDetails) : null;
const redactedDetails = sanitizedDetails
? redactCurrentUserValue(sanitizedDetails, currentUserRedactionOptions)
: null;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,

View File

@@ -6,22 +6,24 @@ import { redactCurrentUserText } from "../log-redaction.js";
import { agentService } from "./agents.js";
import { budgetService } from "./budgets.js";
import { notifyHireApproved } from "./hire-hook.js";
function redactApprovalComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
import { instanceSettingsService } from "./instance-settings.js";
export function approvalService(db: Db) {
const agentsSvc = agentService(db);
const budgets = budgetService(db);
const instanceSettings = instanceSettingsService(db);
const canResolveStatuses = new Set(["pending", "revision_requested"]);
const resolvableStatuses = Array.from(canResolveStatuses);
type ApprovalRecord = typeof approvals.$inferSelect;
type ResolutionResult = { approval: ApprovalRecord; applied: boolean };
function redactApprovalComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function getExistingApproval(id: string) {
const existing = await db
.select()
@@ -230,6 +232,7 @@ export function approvalService(db: Db) {
listComments: async (approvalId: string) => {
const existing = await getExistingApproval(approvalId);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return db
.select()
.from(approvalComments)
@@ -240,7 +243,7 @@ export function approvalService(db: Db) {
),
)
.orderBy(asc(approvalComments.createdAt))
.then((comments) => comments.map(redactApprovalComment));
.then((comments) => comments.map((comment) => redactApprovalComment(comment, censorUsernameInLogs)));
},
addComment: async (
@@ -249,7 +252,10 @@ export function approvalService(db: Db) {
actor: { agentId?: string; userId?: string },
) => {
const existing = await getExistingApproval(approvalId);
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
return db
.insert(approvalComments)
.values({
@@ -260,7 +266,7 @@ export function approvalService(db: Db) {
body: redactedBody,
})
.returning()
.then((rows) => redactApprovalComment(rows[0]));
.then((rows) => redactApprovalComment(rows[0], currentUserRedactionOptions.enabled));
},
};
}

View File

@@ -720,6 +720,9 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const getCurrentUserRedactionOptions = async () => ({
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
});
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
@@ -1318,8 +1321,13 @@ export function heartbeatService(db: Db) {
payload?: Record<string, unknown>;
},
) {
const sanitizedMessage = event.message ? redactCurrentUserText(event.message) : event.message;
const sanitizedPayload = event.payload ? redactCurrentUserValue(event.payload) : event.payload;
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const sanitizedMessage = event.message
? redactCurrentUserText(event.message, currentUserRedactionOptions)
: event.message;
const sanitizedPayload = event.payload
? redactCurrentUserValue(event.payload, currentUserRedactionOptions)
: event.payload;
await db.insert(heartbeatRunEvents).values({
companyId: run.companyId,
@@ -2252,8 +2260,9 @@ export function heartbeatService(db: Db) {
})
.where(eq(heartbeatRuns.id, runId));
const currentUserRedactionOptions = await getCurrentUserRedactionOptions();
const onLog = async (stream: "stdout" | "stderr", chunk: string) => {
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
const ts = new Date().toISOString();
@@ -2503,6 +2512,7 @@ export function heartbeatService(db: Db) {
? null
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
),
errorCode:
outcome === "timed_out"
@@ -2570,7 +2580,10 @@ export function heartbeatService(db: Db) {
}
await finalizeAgentStatus(agent.id, outcome);
} catch (err) {
const message = redactCurrentUserText(err instanceof Error ? err.message : "Unknown adapter failure");
const message = redactCurrentUserText(
err instanceof Error ? err.message : "Unknown adapter failure",
await getCurrentUserRedactionOptions(),
);
logger.error({ err, runId }, "heartbeat execution failed");
let logSummary: { bytes: number; sha256?: string; compressed: boolean } | null = null;
@@ -3608,7 +3621,7 @@ export function heartbeatService(db: Db) {
store: run.logStore,
logRef: run.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, await getCurrentUserRedactionOptions()),
};
},

View File

@@ -1,8 +1,11 @@
import type { Db } from "@paperclipai/db";
import { companies, instanceSettings } from "@paperclipai/db";
import {
instanceGeneralSettingsSchema,
type InstanceGeneralSettings,
instanceExperimentalSettingsSchema,
type InstanceExperimentalSettings,
type PatchInstanceGeneralSettings,
type InstanceSettings,
type PatchInstanceExperimentalSettings,
} from "@paperclipai/shared";
@@ -10,6 +13,18 @@ import { eq } from "drizzle-orm";
const DEFAULT_SINGLETON_KEY = "default";
function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings {
const parsed = instanceGeneralSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
return {
censorUsernameInLogs: parsed.data.censorUsernameInLogs ?? false,
};
}
return {
censorUsernameInLogs: false,
};
}
function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettings {
const parsed = instanceExperimentalSettingsSchema.safeParse(raw ?? {});
if (parsed.success) {
@@ -25,6 +40,7 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
function toInstanceSettings(row: typeof instanceSettings.$inferSelect): InstanceSettings {
return {
id: row.id,
general: normalizeGeneralSettings(row.general),
experimental: normalizeExperimentalSettings(row.experimental),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
@@ -45,6 +61,7 @@ export function instanceSettingsService(db: Db) {
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
@@ -63,11 +80,34 @@ export function instanceSettingsService(db: Db) {
return {
get: async (): Promise<InstanceSettings> => toInstanceSettings(await getOrCreateRow()),
getGeneral: async (): Promise<InstanceGeneralSettings> => {
const row = await getOrCreateRow();
return normalizeGeneralSettings(row.general);
},
getExperimental: async (): Promise<InstanceExperimentalSettings> => {
const row = await getOrCreateRow();
return normalizeExperimentalSettings(row.experimental);
},
updateGeneral: async (patch: PatchInstanceGeneralSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextGeneral = normalizeGeneralSettings({
...normalizeGeneralSettings(current.general),
...patch,
});
const now = new Date();
const [updated] = await db
.update(instanceSettings)
.set({
general: { ...nextGeneral },
updatedAt: now,
})
.where(eq(instanceSettings.id, current.id))
.returning();
return toInstanceSettings(updated ?? current);
},
updateExperimental: async (patch: PatchInstanceExperimentalSettings): Promise<InstanceSettings> => {
const current = await getOrCreateRow();
const nextExperimental = normalizeExperimentalSettings({

View File

@@ -97,13 +97,6 @@ type IssueUserContextInput = {
updatedAt: Date | string;
};
function redactIssueComment<T extends { body: string }>(comment: T): T {
return {
...comment,
body: redactCurrentUserText(comment.body),
};
}
function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) {
if (actorRunId) return checkoutRunId === actorRunId;
return checkoutRunId == null;
@@ -320,6 +313,13 @@ function withActiveRuns(
export function issueService(db: Db) {
const instanceSettings = instanceSettingsService(db);
function redactIssueComment<T extends { body: string }>(comment: T, censorUsernameInLogs: boolean): T {
return {
...comment,
body: redactCurrentUserText(comment.body, { enabled: censorUsernameInLogs }),
};
}
async function assertAssignableAgent(companyId: string, agentId: string) {
const assignee = await db
.select({
@@ -1215,7 +1215,8 @@ export function issueService(db: Db) {
);
const comments = limit ? await query.limit(limit) : await query;
return comments.map(redactIssueComment);
const { censorUsernameInLogs } = await instanceSettings.getGeneral();
return comments.map((comment) => redactIssueComment(comment, censorUsernameInLogs));
},
getCommentCursor: async (issueId: string) => {
@@ -1247,14 +1248,15 @@ export function issueService(db: Db) {
},
getComment: (commentId: string) =>
db
instanceSettings.getGeneral().then(({ censorUsernameInLogs }) =>
db
.select()
.from(issueComments)
.where(eq(issueComments.id, commentId))
.then((rows) => {
const comment = rows[0] ?? null;
return comment ? redactIssueComment(comment) : null;
}),
return comment ? redactIssueComment(comment, censorUsernameInLogs) : null;
})),
addComment: async (issueId: string, body: string, actor: { agentId?: string; userId?: string }) => {
const issue = await db
@@ -1265,7 +1267,10 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
const redactedBody = redactCurrentUserText(body);
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
const [comment] = await db
.insert(issueComments)
.values({
@@ -1283,7 +1288,7 @@ export function issueService(db: Db) {
.set({ updatedAt: new Date() })
.where(eq(issues.id, issueId));
return redactIssueComment(comment);
return redactIssueComment(comment, currentUserRedactionOptions.enabled);
},
createAttachment: async (input: {

View File

@@ -5,6 +5,7 @@ import type { WorkspaceOperation, WorkspaceOperationPhase, WorkspaceOperationSta
import { asc, desc, eq, inArray, isNull, or, and } from "drizzle-orm";
import { notFound } from "../errors.js";
import { redactCurrentUserText, redactCurrentUserValue } from "../log-redaction.js";
import { instanceSettingsService } from "./instance-settings.js";
import { getWorkspaceOperationLogStore } from "./workspace-operation-log-store.js";
type WorkspaceOperationRow = typeof workspaceOperations.$inferSelect;
@@ -69,6 +70,7 @@ export interface WorkspaceOperationRecorder {
}
export function workspaceOperationService(db: Db) {
const instanceSettings = instanceSettingsService(db);
const logStore = getWorkspaceOperationLogStore();
async function getById(id: string) {
@@ -105,6 +107,9 @@ export function workspaceOperationService(db: Db) {
},
async recordOperation(recordInput) {
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const startedAt = new Date();
const id = randomUUID();
const handle = await logStore.begin({
@@ -116,7 +121,7 @@ export function workspaceOperationService(db: Db) {
let stderrExcerpt = "";
const append = async (stream: "stdout" | "stderr" | "system", chunk: string | null | undefined) => {
if (!chunk) return;
const sanitizedChunk = redactCurrentUserText(chunk);
const sanitizedChunk = redactCurrentUserText(chunk, currentUserRedactionOptions);
if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk);
if (stream === "stderr") stderrExcerpt = appendExcerpt(stderrExcerpt, sanitizedChunk);
await logStore.append(handle, {
@@ -137,7 +142,10 @@ export function workspaceOperationService(db: Db) {
status: "running",
logStore: handle.store,
logRef: handle.logRef,
metadata: redactCurrentUserValue(recordInput.metadata ?? null) as Record<string, unknown> | null,
metadata: redactCurrentUserValue(
recordInput.metadata ?? null,
currentUserRedactionOptions,
) as Record<string, unknown> | null,
startedAt,
});
createdIds.push(id);
@@ -162,6 +170,7 @@ export function workspaceOperationService(db: Db) {
logCompressed: finalized.compressed,
metadata: redactCurrentUserValue(
combineMetadata(recordInput.metadata, result.metadata),
currentUserRedactionOptions,
) as Record<string, unknown> | null,
finishedAt,
updatedAt: finishedAt,
@@ -241,7 +250,9 @@ export function workspaceOperationService(db: Db) {
store: operation.logStore,
logRef: operation.logRef,
...result,
content: redactCurrentUserText(result.content),
content: redactCurrentUserText(result.content, {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
}),
};
},
};