Merge remote-tracking branch 'public-gh/master' into paperclip-company-import-export

* public-gh/master:
  fix: address greptile follow-up feedback
  docs: clarify quickstart npx usage
  Add guarded dev restart handling
  Fix PAP-576 settings toggles and transcript default
  Add username log censor setting
  fix: use standard toggle component for permission controls

# Conflicts:
#	server/src/routes/agents.ts
#	ui/src/pages/AgentDetail.tsx
This commit is contained in:
dotta
2026-03-20 13:28:05 -05:00
44 changed files with 11673 additions and 208 deletions

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

@@ -721,6 +721,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);
@@ -1320,8 +1323,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,
@@ -2259,8 +2267,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();
@@ -2510,6 +2519,7 @@ export function heartbeatService(db: Db) {
? null
: redactCurrentUserText(
adapterResult.errorMessage ?? (outcome === "timed_out" ? "Timed out" : "Adapter failed"),
currentUserRedactionOptions,
),
errorCode:
outcome === "timed_out"
@@ -2577,7 +2587,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;
@@ -3615,7 +3628,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,21 +13,36 @@ 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) {
return {
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
};
}
return {
enableIsolatedWorkspaces: false,
autoRestartDevServerWhenIdle: false,
};
}
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 +63,7 @@ export function instanceSettingsService(db: Db) {
.insert(instanceSettings)
.values({
singletonKey: DEFAULT_SINGLETON_KEY,
general: {},
experimental: {},
createdAt: now,
updatedAt: now,
@@ -63,11 +82,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,
}),
};
},
};