diff --git a/cli/src/commands/db-backup.ts b/cli/src/commands/db-backup.ts index bdbf739f44b..7e1adce546a 100644 --- a/cli/src/commands/db-backup.ts +++ b/cli/src/commands/db-backup.ts @@ -73,7 +73,7 @@ export async function dbBackupCommand(opts: DbBackupOptions): Promise { const result = await runDatabaseBackup({ connectionString: connection.value, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix, }); spinner.stop(`Backup saved: ${formatDatabaseBackupResult(result)}`); diff --git a/cli/src/commands/worktree.ts b/cli/src/commands/worktree.ts index 252d0af5f01..be7b1ab7997 100644 --- a/cli/src/commands/worktree.ts +++ b/cli/src/commands/worktree.ts @@ -991,7 +991,7 @@ async function seedWorktreeDatabase(input: { const backup = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir: path.resolve(input.targetPaths.backupDir, "seed"), - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: `${input.instanceId}-seed`, includeMigrationJournal: true, excludeTables: seedPlan.excludedTables, diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index fac395239c5..fc14b75ea54 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -309,11 +309,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise desiredSkillNames.has(entry.key)), - instructionsContents: combinedInstructionsContents, - onLog, - }); - const effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined; - commandNotes.push(`Using stable Claude prompt bundle ${promptBundle.bundleKey}.`); - const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); const runtimePromptBundleKey = asString(runtimeSessionParams.promptBundleKey, ""); + + let promptBundle = await prepareClaudePromptBundle({ + companyId: agent.companyId, + skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)), + instructionsContents: combinedInstructionsContents, + onLog, + materializeInstructions: runtimeSessionId.length === 0, + }); + let effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined; const hasMatchingPromptBundle = runtimePromptBundleKey.length === 0 || runtimePromptBundleKey === promptBundle.bundleKey; const canResumeSession = @@ -397,6 +396,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + if (!combinedInstructionsContents) return undefined; + if (effectiveInstructionsFilePath) return effectiveInstructionsFilePath; + promptBundle = await prepareClaudePromptBundle({ + companyId: agent.companyId, + skills: claudeSkillEntries.filter((entry) => desiredSkillNames.has(entry.key)), + instructionsContents: combinedInstructionsContents, + onLog, + materializeInstructions: true, + }); + effectiveInstructionsFilePath = promptBundle.instructionsFilePath ?? undefined; + return effectiveInstructionsFilePath; + }; const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); const templateData = { agentId: agent.id, @@ -429,7 +442,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + const buildClaudeArgs = (resumeSessionId: string | null, instructionsPath?: string) => { const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; if (resumeSessionId) args.push("--resume", resumeSessionId); if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions"); @@ -442,8 +455,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push("--max-turns", String(maxTurns)); - if (effectiveInstructionsFilePath) { - args.push("--append-system-prompt-file", effectiveInstructionsFilePath); + if (!resumeSessionId && instructionsPath) { + args.push("--append-system-prompt-file", instructionsPath); } args.push("--add-dir", promptBundle.addDir); if (extraArgs.length > 0) args.push(...extraArgs); @@ -467,7 +480,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = buildClaudeArgs(resumeSessionId); + const instructionsPath = resumeSessionId ? undefined : await ensureMaterializedInstructionsFilePath(); + const args = buildClaudeArgs(resumeSessionId, instructionsPath); + const commandNotes = + !resumeSessionId && instructionsPath + ? [ + `Injected agent instructions via --append-system-prompt-file ${instructionsPath} (with path directive appended)`, + ] + : []; if (onMeta) { await onMeta({ adapterType: "claude_local", diff --git a/packages/adapters/claude-local/src/server/prompt-cache.ts b/packages/adapters/claude-local/src/server/prompt-cache.ts index ce719799979..f6891972ebb 100644 --- a/packages/adapters/claude-local/src/server/prompt-cache.ts +++ b/packages/adapters/claude-local/src/server/prompt-cache.ts @@ -134,8 +134,9 @@ export async function prepareClaudePromptBundle(input: { skills: SkillEntry[]; instructionsContents: string | null; onLog: AdapterExecutionContext["onLog"]; + materializeInstructions?: boolean; }): Promise { - const { companyId, skills, instructionsContents, onLog } = input; + const { companyId, skills, instructionsContents, onLog, materializeInstructions = true } = input; const bundleKey = await buildClaudePromptBundleKey({ skills, instructionsContents, @@ -159,7 +160,7 @@ export async function prepareClaudePromptBundle(input: { const instructionsFilePath = instructionsContents ? path.join(rootDir, "agent-instructions.md") : null; - if (instructionsFilePath && instructionsContents) { + if (materializeInstructions && instructionsFilePath && instructionsContents) { await ensureReadableFile(instructionsFilePath, instructionsContents); } @@ -167,6 +168,6 @@ export async function prepareClaudePromptBundle(input: { bundleKey, rootDir, addDir: rootDir, - instructionsFilePath, + instructionsFilePath: materializeInstructions ? instructionsFilePath : null, }; } diff --git a/packages/db/src/backup-lib.test.ts b/packages/db/src/backup-lib.test.ts index dcdc87c50e4..2ea9b07001c 100644 --- a/packages/db/src/backup-lib.test.ts +++ b/packages/db/src/backup-lib.test.ts @@ -125,12 +125,12 @@ describeEmbeddedPostgres("runDatabaseBackup", () => { const result = await runDatabaseBackup({ connectionString: sourceConnectionString, backupDir, - retentionDays: 7, + retention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip-test", }); - expect(result.backupFile).toMatch(/paperclip-test-.*\.sql$/); - expect(result.sizeBytes).toBeGreaterThan(1024 * 1024); + expect(result.backupFile).toMatch(/paperclip-test-.*\.sql\.gz$/); + expect(result.sizeBytes).toBeGreaterThan(0); expect(fs.existsSync(result.backupFile)).toBe(true); await runDatabaseRestore({ diff --git a/packages/db/src/backup-lib.ts b/packages/db/src/backup-lib.ts index ea76a2b6d3c..e1e88724f8a 100644 --- a/packages/db/src/backup-lib.ts +++ b/packages/db/src/backup-lib.ts @@ -1,12 +1,20 @@ import { createReadStream, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from "node:fs"; import { basename, resolve } from "node:path"; import { createInterface } from "node:readline"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip } from "node:zlib"; import postgres from "postgres"; +export type BackupRetentionPolicy = { + dailyDays: number; + weeklyWeeks: number; + monthlyMonths: number; +}; + export type RunDatabaseBackupOptions = { connectionString: string; backupDir: string; - retentionDays: number; + retention: BackupRetentionPolicy; filenamePrefix?: string; connectTimeoutSeconds?: number; includeMigrationJournal?: boolean; @@ -75,23 +83,91 @@ function timestamp(date: Date = new Date()): string { return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; } -function pruneOldBackups(backupDir: string, retentionDays: number, filenamePrefix: string): number { +/** + * ISO week key for grouping backups by calendar week (ISO 8601). + */ +function isoWeekKey(date: Date): string { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, "0")}`; +} + +function monthKey(date: Date): string { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; +} + +/** + * Tiered backup pruning: + * - Daily tier: keep ALL backups from the last `dailyDays` days + * - Weekly tier: keep the NEWEST backup per calendar week for `weeklyWeeks` weeks + * - Monthly tier: keep the NEWEST backup per calendar month for `monthlyMonths` months + * - Everything else is deleted + */ +function pruneOldBackups(backupDir: string, retention: BackupRetentionPolicy, filenamePrefix: string): number { if (!existsSync(backupDir)) return 0; - const safeRetention = Math.max(1, Math.trunc(retentionDays)); - const cutoff = Date.now() - safeRetention * 24 * 60 * 60 * 1000; - let pruned = 0; + + const now = Date.now(); + const dailyCutoff = now - Math.max(1, retention.dailyDays) * 24 * 60 * 60 * 1000; + const weeklyCutoff = now - Math.max(1, retention.weeklyWeeks) * 7 * 24 * 60 * 60 * 1000; + const monthlyCutoff = now - Math.max(1, retention.monthlyMonths) * 30 * 24 * 60 * 60 * 1000; + + type BackupEntry = { name: string; fullPath: string; mtimeMs: number }; + const entries: BackupEntry[] = []; for (const name of readdirSync(backupDir)) { - if (!name.startsWith(`${filenamePrefix}-`) || !name.endsWith(".sql")) continue; + if (!name.startsWith(`${filenamePrefix}-`)) continue; + if (!name.endsWith(".sql") && !name.endsWith(".sql.gz")) continue; const fullPath = resolve(backupDir, name); const stat = statSync(fullPath); - if (stat.mtimeMs < cutoff) { - unlinkSync(fullPath); - pruned++; - } + entries.push({ name, fullPath, mtimeMs: stat.mtimeMs }); } - return pruned; + // Sort newest first so the first entry per week/month bucket is the one we keep + entries.sort((a, b) => b.mtimeMs - a.mtimeMs); + + const keepWeekBuckets = new Set(); + const keepMonthBuckets = new Set(); + const toDelete: string[] = []; + + for (const entry of entries) { + // Daily tier — keep everything within dailyDays + if (entry.mtimeMs >= dailyCutoff) continue; + + const date = new Date(entry.mtimeMs); + const week = isoWeekKey(date); + const month = monthKey(date); + + // Weekly tier — keep newest per calendar week + if (entry.mtimeMs >= weeklyCutoff) { + if (keepWeekBuckets.has(week)) { + toDelete.push(entry.fullPath); + } else { + keepWeekBuckets.add(week); + } + continue; + } + + // Monthly tier — keep newest per calendar month + if (entry.mtimeMs >= monthlyCutoff) { + if (keepMonthBuckets.has(month)) { + toDelete.push(entry.fullPath); + } else { + keepMonthBuckets.add(month); + } + continue; + } + + // Beyond all retention tiers — delete + toDelete.push(entry.fullPath); + } + + for (const filePath of toDelete) { + unlinkSync(filePath); + } + + return toDelete.length; } function formatBackupSize(sizeBytes: number): string { @@ -148,7 +224,9 @@ function tableKey(schemaName: string, tableName: string): string { } async function* readRestoreStatements(backupFile: string): AsyncGenerator { - const stream = createReadStream(backupFile, { encoding: "utf8" }); + const raw = createReadStream(backupFile); + const stream = backupFile.endsWith(".gz") ? raw.pipe(createGunzip()) : raw; + stream.setEncoding("utf8"); const reader = createInterface({ input: stream, crlfDelay: Infinity, @@ -180,6 +258,7 @@ async function* readRestoreStatements(backupFile: string): AsyncGenerator { const filenamePrefix = opts.filenamePrefix ?? "paperclip"; - const retentionDays = Math.max(1, Math.trunc(opts.retentionDays)); + const retention = opts.retention; const connectTimeout = Math.max(1, Math.trunc(opts.connectTimeoutSeconds ?? 5)); const includeMigrationJournal = opts.includeMigrationJournal === true; const excludedTableNames = normalizeTableNameSet(opts.excludeTables); const nullifiedColumnsByTable = normalizeNullifyColumnMap(opts.nullifyColumns); const sql = postgres(opts.connectionString, { max: 1, connect_timeout: connectTimeout }); mkdirSync(opts.backupDir, { recursive: true }); - const backupFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); - const writer = createBufferedTextFileWriter(backupFile); + const sqlFile = resolve(opts.backupDir, `${filenamePrefix}-${timestamp()}.sql`); + const backupFile = `${sqlFile}.gz`; + const writer = createBufferedTextFileWriter(sqlFile); try { await sql`SELECT 1`; @@ -664,8 +744,14 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise await writer.close(); + // Compress the SQL file with gzip + const sqlReadStream = createReadStream(sqlFile); + const gzWriteStream = createWriteStream(backupFile); + await pipeline(sqlReadStream, createGzip(), gzWriteStream); + unlinkSync(sqlFile); + const sizeBytes = statSync(backupFile).size; - const prunedCount = pruneOldBackups(opts.backupDir, retentionDays, filenamePrefix); + const prunedCount = pruneOldBackups(opts.backupDir, retention, filenamePrefix); return { backupFile, @@ -674,6 +760,12 @@ export async function runDatabaseBackup(opts: RunDatabaseBackupOptions): Promise }; } catch (error) { await writer.abort(); + if (existsSync(backupFile)) { + try { unlinkSync(backupFile); } catch { /* ignore */ } + } + if (existsSync(sqlFile)) { + try { unlinkSync(sqlFile); } catch { /* ignore */ } + } throw error; } finally { await sql.end(); diff --git a/packages/db/src/backup.ts b/packages/db/src/backup.ts index f07dc646f36..c11822c9503 100644 --- a/packages/db/src/backup.ts +++ b/packages/db/src/backup.ts @@ -85,7 +85,7 @@ function resolveBackupDir(config: PartialConfig | null): string { } function resolveRetentionDays(config: PartialConfig | null): number { - return asPositiveInt(config?.database?.backup?.retentionDays) ?? 30; + return asPositiveInt(config?.database?.backup?.retentionDays) ?? 7; } async function main() { @@ -103,7 +103,7 @@ async function main() { const result = await runDatabaseBackup({ connectionString, backupDir, - retentionDays, + retention: { dailyDays: retentionDays, weeklyWeeks: 4, monthlyMonths: 1 }, filenamePrefix: "paperclip", }); diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index cf4a26335fd..200e29813d1 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -21,6 +21,7 @@ export { runDatabaseBackup, runDatabaseRestore, formatDatabaseBackupResult, + type BackupRetentionPolicy, type RunDatabaseBackupOptions, type RunDatabaseBackupResult, type RunDatabaseRestoreOptions, diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index 48687878c9d..2399ceaec25 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -21,7 +21,7 @@ export const llmConfigSchema = z.object({ export const databaseBackupConfigSchema = z.object({ enabled: z.boolean().default(true), intervalMinutes: z.number().int().min(1).max(7 * 24 * 60).default(60), - retentionDays: z.number().int().min(1).max(3650).default(30), + retentionDays: z.number().int().min(1).max(3650).default(7), dir: z.string().default("~/.paperclip/instances/default/data/backups"), }); @@ -33,7 +33,7 @@ export const databaseConfigSchema = z.object({ backup: databaseBackupConfigSchema.default({ enabled: true, intervalMinutes: 60, - retentionDays: 30, + retentionDays: 7, dir: "~/.paperclip/instances/default/data/backups", }), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9b125165459..36505ece76c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -189,6 +189,7 @@ export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, + BackupRetentionPolicy, Agent, AgentAccessState, AgentChainOfCommandEntry, @@ -370,6 +371,13 @@ export { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, } from "./types/feedback.js"; +export { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "./types/instance.js"; + export { getClosedIsolatedExecutionWorkspaceMessage, isClosedIsolatedExecutionWorkspace, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 888740d3d99..13625a68c03 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -11,7 +11,8 @@ export type { FeedbackTraceBundleFile, FeedbackTraceBundle, } from "./feedback.js"; -export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings } from "./instance.js"; +export type { InstanceExperimentalSettings, InstanceGeneralSettings, InstanceSettings, BackupRetentionPolicy } from "./instance.js"; +export { DAILY_RETENTION_PRESETS, WEEKLY_RETENTION_PRESETS, MONTHLY_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION } from "./instance.js"; export type { CompanySkillSourceType, CompanySkillTrustLevel, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index 70599868f7e..4e83c92529d 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -1,9 +1,26 @@ import type { FeedbackDataSharingPreference } from "./feedback.js"; +export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const; +export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const; +export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const; + +export interface BackupRetentionPolicy { + dailyDays: (typeof DAILY_RETENTION_PRESETS)[number]; + weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number]; + monthlyMonths: (typeof MONTHLY_RETENTION_PRESETS)[number]; +} + +export const DEFAULT_BACKUP_RETENTION: BackupRetentionPolicy = { + dailyDays: 7, + weeklyWeeks: 4, + monthlyMonths: 1, +}; + export interface InstanceGeneralSettings { censorUsernameInLogs: boolean; keyboardShortcuts: boolean; feedbackDataSharingPreference: FeedbackDataSharingPreference; + backupRetention: BackupRetentionPolicy; } export interface InstanceExperimentalSettings { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 5634c8f6a71..930e183f9bd 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -1,13 +1,33 @@ import { z } from "zod"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE } from "../types/feedback.js"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "../types/instance.js"; import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; +function presetSchema(presets: T, label: string) { + return z.number().refine( + (v): v is T[number] => (presets as readonly number[]).includes(v), + { message: `${label} must be one of: ${presets.join(", ")}` }, + ); +} + +export const backupRetentionPolicySchema = z.object({ + dailyDays: presetSchema(DAILY_RETENTION_PRESETS, "dailyDays").default(DEFAULT_BACKUP_RETENTION.dailyDays), + weeklyWeeks: presetSchema(WEEKLY_RETENTION_PRESETS, "weeklyWeeks").default(DEFAULT_BACKUP_RETENTION.weeklyWeeks), + monthlyMonths: presetSchema(MONTHLY_RETENTION_PRESETS, "monthlyMonths").default(DEFAULT_BACKUP_RETENTION.monthlyMonths), +}); + export const instanceGeneralSettingsSchema = z.object({ censorUsernameInLogs: z.boolean().default(false), keyboardShortcuts: z.boolean().default(false), feedbackDataSharingPreference: feedbackDataSharingPreferenceSchema.default( DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, ), + backupRetention: backupRetentionPolicySchema.default(DEFAULT_BACKUP_RETENTION), }).strict(); export const patchInstanceGeneralSettingsSchema = instanceGeneralSettingsSchema.partial(); diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index 86ee374dbdd..ffd343dffe4 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -10,6 +10,10 @@ const mockActivityService = vi.hoisted(() => ({ create: vi.fn(), })); +const mockHeartbeatService = vi.hoisted(() => ({ + getRun: vi.fn(), +})); + const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn(), @@ -22,6 +26,7 @@ function registerRouteMocks() { vi.doMock("../services/index.js", () => ({ issueService: () => mockIssueService, + heartbeatService: () => mockHeartbeatService, })); } @@ -75,4 +80,32 @@ describe("activity routes", () => { expect(mockActivityService.runsForIssue).toHaveBeenCalledWith("company-1", "issue-uuid-1"); expect(res.body).toEqual([{ runId: "run-1", adapterType: "codex_local" }]); }); + + it("requires company access before creating activity events", async () => { + const app = await createApp(); + const res = await request(app) + .post("/api/companies/company-2/activity") + .send({ + actorId: "user-1", + action: "test.event", + entityType: "issue", + entityId: "issue-1", + }); + + expect(res.status).toBe(403); + expect(mockActivityService.create).not.toHaveBeenCalled(); + }); + + it("requires company access before listing issues for another company's run", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-2", + companyId: "company-2", + }); + + const app = await createApp(); + const res = await request(app).get("/api/heartbeat-runs/run-2/issues"); + + expect(res.status).toBe(403); + expect(mockActivityService.issuesForRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index 6ef45db164f..c5593f035fd 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -59,6 +59,8 @@ const mockBudgetService = vi.hoisted(() => ({ const mockHeartbeatService = vi.hoisted(() => ({ listTaskSessions: vi.fn(), resetRuntimeSession: vi.fn(), + getRun: vi.fn(), + cancelRun: vi.fn(), })); const mockIssueApprovalService = vi.hoisted(() => ({ @@ -397,4 +399,26 @@ describe("agent permission routes", () => { }, ]); }); + + it("rejects heartbeat cancellation outside the caller company scope", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", + companyId: "33333333-3333-4333-8333-333333333333", + agentId, + status: "running", + }); + + const app = await createApp({ + type: "board", + userId: "board-user", + source: "session", + isInstanceAdmin: false, + companyIds: [companyId], + }); + + const res = await request(app).post("/api/heartbeat-runs/run-1/cancel").send({}); + + expect(res.status).toBe(403); + expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/__tests__/approval-routes-idempotency.test.ts b/server/src/__tests__/approval-routes-idempotency.test.ts index 83d34cf1f93..cecee0e8ba9 100644 --- a/server/src/__tests__/approval-routes-idempotency.test.ts +++ b/server/src/__tests__/approval-routes-idempotency.test.ts @@ -39,7 +39,7 @@ vi.mock("../services/index.js", () => ({ secretService: () => mockSecretService, })); -function createApp() { +function createApp(actorOverrides: Record = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -49,6 +49,7 @@ function createApp() { companyIds: ["company-1"], source: "session", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -84,6 +85,14 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate approval side effects when approve is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "approved", + payload: {}, + requestedByAgentId: "agent-1", + }); mockApprovalService.approve.mockResolvedValue({ approval: { id: "approval-1", @@ -107,6 +116,13 @@ describe("approval routes idempotent retries", () => { }); it("does not emit duplicate rejection logs when reject is already resolved", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-1", + companyId: "company-1", + type: "hire_agent", + status: "rejected", + payload: {}, + }); mockApprovalService.reject.mockResolvedValue({ approval: { id: "approval-1", @@ -126,6 +142,40 @@ describe("approval routes idempotent retries", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it("rejects approval decisions for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-2", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-2/approve") + .send({}); + + expect(res.status).toBe(403); + expect(mockApprovalService.approve).not.toHaveBeenCalled(); + }); + + it("rejects approval revision requests for companies outside the caller scope", async () => { + mockApprovalService.getById.mockResolvedValue({ + id: "approval-3", + companyId: "company-2", + type: "hire_agent", + status: "pending", + payload: {}, + }); + + const res = await request(createApp()) + .post("/api/approvals/approval-3/request-revision") + .send({ decisionNote: "Need changes" }); + + expect(res.status).toBe(403); + expect(mockApprovalService.requestRevision).not.toHaveBeenCalled(); + }); + it("lets agents create generic issue-linked board approval requests", async () => { mockApprovalService.create.mockResolvedValue({ id: "approval-1", diff --git a/server/src/__tests__/claude-local-execute.test.ts b/server/src/__tests__/claude-local-execute.test.ts index 9e3af9ad34d..e73036c1536 100644 --- a/server/src/__tests__/claude-local-execute.test.ts +++ b/server/src/__tests__/claude-local-execute.test.ts @@ -21,6 +21,8 @@ const payload = { addDir, instructionsFilePath, instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, + appendedSystemPromptFilePath: instructionsFilePath, + appendedSystemPromptFileContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, }; @@ -45,7 +47,323 @@ type CapturePayload = { claudeConfigDir: string | null; }; +async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise { + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); + +const argv = process.argv.slice(2); +const addDirIndex = argv.indexOf("--add-dir"); +const addDir = addDirIndex >= 0 ? argv[addDirIndex + 1] : null; +const instructionsIndex = argv.indexOf("--append-system-prompt-file"); +const instructionsFilePath = instructionsIndex >= 0 ? argv[instructionsIndex + 1] : null; +const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH; +const statePath = process.env.PAPERCLIP_TEST_STATE_PATH; +const payload = { + argv, + prompt: fs.readFileSync(0, "utf8"), + addDir, + instructionsFilePath, + instructionsContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, + appendedSystemPromptFilePath: instructionsFilePath, + appendedSystemPromptFileContents: instructionsFilePath ? fs.readFileSync(instructionsFilePath, "utf8") : null, + skillEntries: addDir ? fs.readdirSync(path.join(addDir, ".claude", "skills")).sort() : [], + claudeConfigDir: process.env.CLAUDE_CONFIG_DIR || null, +}; +if (capturePath) { + const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : []; + entries.push(payload); + fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8"); +} +const resumed = argv.includes("--resume"); +const shouldFailResume = resumed && statePath && !fs.existsSync(statePath); +if (shouldFailResume) { + fs.writeFileSync(statePath, "retried", "utf8"); + console.log(JSON.stringify({ + type: "result", + subtype: "error", + session_id: "claude-session-1", + result: "No conversation found with session id claude-session-1", + errors: ["No conversation found with session id claude-session-1"], + })); + process.exit(1); +} +console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-2", model: "claude-sonnet" })); +console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-2", message: { content: [{ type: "text", text: "hello" }] } })); +console.log(JSON.stringify({ type: "result", session_id: "claude-session-2", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } })); +`; + await fs.writeFile(commandPath, script, "utf8"); + await fs.chmod(commandPath, 0o755); +} + +async function setupExecuteEnv( + root: string, + options?: { commandWriter?: (commandPath: string) => Promise }, +) { + const workspace = path.join(root, "workspace"); + const binDir = path.join(root, "bin"); + const commandPath = path.join(binDir, "claude"); + const capturePath = path.join(root, "capture.json"); + const statePath = path.join(root, "state.txt"); + await fs.mkdir(workspace, { recursive: true }); + await fs.mkdir(binDir, { recursive: true }); + await (options?.commandWriter ?? writeFakeClaudeCommand)(commandPath); + const previousHome = process.env.HOME; + const previousPath = process.env.PATH; + process.env.HOME = root; + process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`; + return { + workspace, + commandPath, + capturePath, + statePath, + restore: () => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; + else process.env.PATH = previousPath; + }, + }; +} + describe("claude execute", () => { + /** + * Regression tests for https://github.com/paperclipai/paperclip/issues/2848 + * + * --append-system-prompt-file should only be passed on fresh sessions. + * On resumed sessions the instructions are already in the session cache; + * re-injecting them wastes tokens and may be rejected by the CLI. + */ + it("passes --append-system-prompt-file on a fresh session when instructionsFile is set", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-fresh-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + try { + await execute({ + runId: "run-fresh", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async () => {}, + }); + const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")); + expect(captured.argv).toContain("--append-system-prompt-file"); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("omits --append-system-prompt-file on a resumed session even when instructionsFile is set", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-")); + const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + try { + await execute({ + runId: "run-resume", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath }, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async () => {}, + }); + const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")); + expect(captured.argv).not.toContain("--append-system-prompt-file"); + expect(captured.argv).toContain("--resume"); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + /** + * Regression tests for commandNotes accuracy (Greptile P2). + * + * commandNotes should only claim instructions were injected when the flag + * was actually passed — i.e. on fresh sessions, not resumed ones. + */ + it("commandNotes reports injection on a fresh session with instructionsFile", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-fresh-")); + const { workspace, commandPath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + let capturedNotes: string[] = []; + try { + await execute({ + runId: "run-notes-fresh", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: {}, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; }, + }); + expect(capturedNotes.some((n) => n.includes("--append-system-prompt-file"))).toBe(true); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("commandNotes is empty on a resumed session even when instructionsFile is set", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-notes-resume-")); + const { workspace, commandPath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + let capturedNotes: string[] = ["sentinel"]; + try { + await execute({ + runId: "run-notes-resume", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: {}, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async (meta) => { capturedNotes = (meta.commandNotes as string[]) ?? []; }, + }); + expect(capturedNotes).toHaveLength(0); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + /** + * Regression test for unnecessary file I/O on resumed sessions (Greptile P2). + * + * The combined agent-instructions.md temp file must NOT be written when + * resuming, since the instructions are already baked into the session cache. + */ + it("does not write agent-instructions temp file on a resumed session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-io-resume-")); + const { workspace, commandPath, restore } = await setupExecuteEnv(root); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + try { + await execute({ + runId: "run-io-resume", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: {}, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async () => {}, + }); + // The skills dir lives under HOME/.paperclip/skills — verify no combined + // agent-instructions.md was written anywhere under root on a resume. + const allFiles = await fs.readdir(root, { recursive: true }); + const tempInstructionsWritten = (allFiles as string[]).some((f) => + f.includes("agent-instructions.md"), + ); + expect(tempInstructionsWritten).toBe(false); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("rebuilds the combined instructions file when an unknown resumed session falls back to fresh", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-resume-fallback-")); + const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, { + commandWriter: writeRetryThenSucceedClaudeCommand, + }); + const instructionsFile = path.join(root, "instructions.md"); + await fs.writeFile(instructionsFile, "# Agent instructions", "utf-8"); + const metaEvents: Array<{ commandArgs: string[]; commandNotes: string[] }> = []; + try { + const result = await execute({ + runId: "run-resume-fallback", + agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} }, + runtime: { sessionId: "claude-session-1", sessionParams: null, sessionDisplayId: null, taskKey: null }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + PAPERCLIP_TEST_STATE_PATH: statePath, + }, + promptTemplate: "Do work.", + instructionsFilePath: instructionsFile, + }, + context: {}, + authToken: "tok", + onLog: async () => {}, + onMeta: async (meta) => { + metaEvents.push({ + commandArgs: ((meta.commandArgs as string[]) ?? []).slice(), + commandNotes: ((meta.commandNotes as string[]) ?? []).slice(), + }); + }, + }); + const captured = JSON.parse(await fs.readFile(capturePath, "utf-8")) as Array<{ + argv: string[]; + appendedSystemPromptFilePath: string | null; + appendedSystemPromptFileContents: string | null; + }>; + expect(captured).toHaveLength(2); + expect(captured[0]?.argv).toContain("--resume"); + expect(captured[0]?.argv).not.toContain("--append-system-prompt-file"); + expect(captured[1]?.argv).not.toContain("--resume"); + expect(captured[1]?.argv).toContain("--append-system-prompt-file"); + expect(captured[1]?.appendedSystemPromptFilePath).toContain("agent-instructions.md"); + expect(captured[1]?.appendedSystemPromptFilePath).not.toBe(instructionsFile); + expect(captured[1]?.appendedSystemPromptFileContents).toContain("# Agent instructions"); + expect(captured[1]?.appendedSystemPromptFileContents).toContain( + `The above agent instructions were loaded from ${instructionsFile}. ` + + `Resolve any relative file references from ${path.dirname(instructionsFile)}/. ` + + `This base directory is authoritative for sibling instruction files such as ` + + `./HEARTBEAT.md, ./SOUL.md, and ./TOOLS.md; do not resolve those from the parent agent directory.`, + ); + expect(metaEvents).toHaveLength(2); + expect(metaEvents[0]?.commandNotes).toHaveLength(0); + expect(metaEvents[1]?.commandNotes.some((note) => note.includes("--append-system-prompt-file"))).toBe(true); + expect(result.sessionId).toBe("claude-session-2"); + expect(result.clearSession).toBe(false); + } finally { + restore(); + await fs.rm(root, { recursive: true, force: true }); + } + }); it("logs HOME, CLAUDE_CONFIG_DIR, and the resolved executable path in invocation metadata", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-execute-meta-")); const workspace = path.join(root, "workspace"); @@ -252,7 +570,7 @@ describe("claude execute", () => { expect(capture1.addDir).toBeTruthy(); expect(capture1.addDir).toBe(capture2.addDir); expect(capture1.instructionsFilePath).toBeTruthy(); - expect(capture1.instructionsFilePath).toBe(capture2.instructionsFilePath); + expect(capture2.instructionsFilePath).toBeNull(); expect(capture1.addDir?.startsWith(expectedRoot)).toBe(true); expect(capture1.instructionsFilePath?.startsWith(expectedRoot)).toBe(true); expect(capture1.instructionsContents).toContain("You are managed instructions."); @@ -260,6 +578,7 @@ describe("claude execute", () => { expect(capture1.skillEntries).toContain("paperclip"); expect(capture2.argv).toContain("--resume"); expect(capture2.argv).toContain("claude-session-1"); + expect(capture2.argv).not.toContain("--append-system-prompt-file"); expect(capture2.prompt).toContain("## Paperclip Resume Delta"); expect(capture2.prompt).not.toContain("Follow the paperclip heartbeat."); } finally { diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 3ba590b28c7..5dca3a977aa 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -177,4 +177,50 @@ describe("company portability routes", () => { expect(res.status).toBe(403); expect(res.body.error).toContain("Board access required"); }); + + it("requires instance admin for new-company import preview", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.previewImport).not.toHaveBeenCalled(); + }); + + it("requires instance admin for new-company import apply", async () => { + const app = await createApp({ + type: "board", + userId: "user-1", + companyIds: ["11111111-1111-4111-8111-111111111111"], + source: "session", + isInstanceAdmin: false, + }); + + const res = await request(app) + .post("/api/companies/import") + .send({ + source: { type: "inline", files: { "COMPANY.md": "---\nname: Test\n---\n" } }, + include: { company: true, agents: true, projects: false, issues: false }, + target: { mode: "new_company", newCompanyName: "Imported Test" }, + collisionStrategy: "rename", + }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain("Instance admin"); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + }); }); diff --git a/server/src/config.ts b/server/src/config.ts index 71084cc097d..5ae50400efc 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -216,7 +216,7 @@ export function loadConfig(): Config { 1, Number(process.env.PAPERCLIP_DB_BACKUP_RETENTION_DAYS) || fileDatabaseBackup?.retentionDays || - 30, + 7, ); const databaseBackupDir = resolveHomeAwarePath( process.env.PAPERCLIP_DB_BACKUP_DIR ?? diff --git a/server/src/index.ts b/server/src/index.ts index 6fb078fcc46..839d9fc61f9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -31,6 +31,7 @@ import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { feedbackService, heartbeatService, + instanceSettingsService, reconcilePersistedRuntimeServicesOnStartup, routineService, } from "./services/index.js"; @@ -623,20 +624,25 @@ export async function startServer(): Promise { if (config.databaseBackupEnabled) { const backupIntervalMs = config.databaseBackupIntervalMinutes * 60 * 1000; + const settingsSvc = instanceSettingsService(db); let backupInFlight = false; - + const runScheduledBackup = async () => { if (backupInFlight) { logger.warn("Skipping scheduled database backup because a previous backup is still running"); return; } - + backupInFlight = true; try { + // Read retention from Instance Settings (DB) so changes take effect without restart + const generalSettings = await settingsSvc.getGeneral(); + const retention = generalSettings.backupRetention; + const result = await runDatabaseBackup({ connectionString: activeDatabaseConnectionString, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retention, filenamePrefix: "paperclip", }); logger.info( @@ -645,7 +651,7 @@ export async function startServer(): Promise { sizeBytes: result.sizeBytes, prunedCount: result.prunedCount, backupDir: config.databaseBackupDir, - retentionDays: config.databaseBackupRetentionDays, + retention, }, `Automatic database backup complete: ${formatDatabaseBackupResult(result)}`, ); @@ -655,11 +661,11 @@ export async function startServer(): Promise { backupInFlight = false; } }; - + logger.info( { intervalMinutes: config.databaseBackupIntervalMinutes, - retentionDays: config.databaseBackupRetentionDays, + retentionSource: "instance-settings-db", backupDir: config.databaseBackupDir, }, "Automatic database backups enabled", diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 0884b455df3..20f2ed534ed 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -4,7 +4,7 @@ import type { Db } from "@paperclipai/db"; import { validate } from "../middleware/validate.js"; import { activityService } from "../services/activity.js"; import { assertBoard, assertCompanyAccess } from "./authz.js"; -import { issueService } from "../services/index.js"; +import { heartbeatService, issueService } from "../services/index.js"; import { sanitizeRecord } from "../redaction.js"; const createActivitySchema = z.object({ @@ -20,6 +20,7 @@ const createActivitySchema = z.object({ export function activityRoutes(db: Db) { const router = Router(); const svc = activityService(db); + const heartbeat = heartbeatService(db); const issueSvc = issueService(db); async function resolveIssueByRef(rawId: string) { @@ -46,6 +47,7 @@ export function activityRoutes(db: Db) { router.post("/companies/:companyId/activity", validate(createActivitySchema), async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); const event = await svc.create({ companyId, ...req.body, @@ -80,6 +82,12 @@ export function activityRoutes(db: Db) { router.get("/heartbeat-runs/:runId/issues", async (req, res) => { const runId = req.params.runId as string; + const run = await heartbeat.getRun(runId); + if (!run) { + res.json([]); + return; + } + assertCompanyAccess(req, run.companyId); const result = await svc.issuesForRun(runId); res.json(result); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c54a39758be..4bf5f16c4d5 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -2297,6 +2297,10 @@ export function agentRoutes(db: Db) { router.post("/heartbeat-runs/:runId/cancel", async (req, res) => { assertBoard(req); const runId = req.params.runId as string; + const existing = await heartbeat.getRun(runId); + if (existing) { + assertCompanyAccess(req, existing.companyId); + } const run = await heartbeat.cancelRun(runId); if (run) { diff --git a/server/src/routes/approvals.ts b/server/src/routes/approvals.ts index 99d33abd86a..3ed20374700 100644 --- a/server/src/routes/approvals.ts +++ b/server/src/routes/approvals.ts @@ -1,4 +1,4 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { addApprovalCommentSchema, @@ -34,6 +34,15 @@ export function approvalRoutes(db: Db) { const secretsSvc = secretService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; + async function requireApprovalAccess(req: Request, id: string) { + const approval = await svc.getById(id); + if (!approval) { + return null; + } + assertCompanyAccess(req, approval.companyId); + return approval; + } + router.get("/companies/:companyId/approvals", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -121,6 +130,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.approve( id, req.body.decidedByUserId ?? "board", @@ -216,6 +229,10 @@ export function approvalRoutes(db: Db) { router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const { approval, applied } = await svc.reject( id, req.body.decidedByUserId ?? "board", @@ -243,6 +260,10 @@ export function approvalRoutes(db: Db) { async (req, res) => { assertBoard(req); const id = req.params.id as string; + if (!(await requireApprovalAccess(req, id))) { + res.status(404).json({ error: "Approval not found" }); + return; + } const approval = await svc.requestRevision( id, req.body.decidedByUserId ?? "board", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index d1978c9d6f7..22be5f863ca 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -24,7 +24,7 @@ import { logActivity, } from "../services/index.js"; import type { StorageService } from "../storage/types.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; export function companyRoutes(db: Db, storage?: StorageService) { const router = Router(); @@ -48,6 +48,17 @@ export function companyRoutes(db: Db, storage?: StorageService) { return parsed; } + function assertImportTargetAccess( + req: Request, + target: { mode: "new_company" } | { mode: "existing_company"; companyId: string }, + ) { + if (target.mode === "new_company") { + assertInstanceAdmin(req); + return; + } + assertCompanyAccess(req, target.companyId); + } + async function assertCanUpdateBranding(req: Request, companyId: string) { assertCompanyAccess(req, companyId); if (req.actor.type === "board") return; @@ -160,18 +171,14 @@ export function companyRoutes(db: Db, storage?: StorageService) { router.post("/import/preview", validate(companyPortabilityPreviewSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const preview = await portability.previewImport(req.body); res.json(preview); }); router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => { assertBoard(req); - if (req.body.target.mode === "existing_company") { - assertCompanyAccess(req, req.body.target.companyId); - } + assertImportTargetAccess(req, req.body.target); const actor = getActorInfo(req); const result = await portability.importBundle(req.body, req.actor.type === "board" ? req.actor.userId : null); await logActivity(db, { diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 7856591d689..65a1263226d 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -2,6 +2,7 @@ import type { Db } from "@paperclipai/db"; import { companies, instanceSettings } from "@paperclipai/db"; import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + DEFAULT_BACKUP_RETENTION, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -22,12 +23,14 @@ function normalizeGeneralSettings(raw: unknown): InstanceGeneralSettings { keyboardShortcuts: parsed.data.keyboardShortcuts ?? false, feedbackDataSharingPreference: parsed.data.feedbackDataSharingPreference ?? DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetention: parsed.data.backupRetention ?? DEFAULT_BACKUP_RETENTION, }; } return { censorUsernameInLogs: false, keyboardShortcuts: false, feedbackDataSharingPreference: DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, + backupRetention: DEFAULT_BACKUP_RETENTION, }; } diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 28e00b29f05..04c3638d523 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -1,6 +1,12 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import type { PatchInstanceGeneralSettings } from "@paperclipai/shared"; +import type { PatchInstanceGeneralSettings, BackupRetentionPolicy } from "@paperclipai/shared"; +import { + DAILY_RETENTION_PRESETS, + WEEKLY_RETENTION_PRESETS, + MONTHLY_RETENTION_PRESETS, + DEFAULT_BACKUP_RETENTION, +} from "@paperclipai/shared"; import { LogOut, SlidersHorizontal } from "lucide-react"; import { authApi } from "@/api/auth"; import { instanceSettingsApi } from "@/api/instanceSettings"; @@ -67,6 +73,7 @@ export function InstanceGeneralSettings() { const censorUsernameInLogs = generalQuery.data?.censorUsernameInLogs === true; const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true; const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt"; + const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION; return (
@@ -123,6 +130,108 @@ export function InstanceGeneralSettings() {
+
+
+
+

Backup retention

+

+ Configure how long to keep automatic database backups at each tier. Daily backups + are kept in full, then thinned to one per week and one per month. Backups are + compressed with gzip. +

+
+ +
+

Daily

+
+ {DAILY_RETENTION_PRESETS.map((days) => { + const active = backupRetention.dailyDays === days; + return ( + + ); + })} +
+
+ +
+

Weekly

+
+ {WEEKLY_RETENTION_PRESETS.map((weeks) => { + const active = backupRetention.weeklyWeeks === weeks; + const label = weeks === 1 ? "1 week" : `${weeks} weeks`; + return ( + + ); + })} +
+
+ +
+

Monthly

+
+ {MONTHLY_RETENTION_PRESETS.map((months) => { + const active = backupRetention.monthlyMonths === months; + const label = months === 1 ? "1 month" : `${months} months`; + return ( + + ); + })} +
+
+
+
+