diff --git a/apps/desktop/src/main/activity-log-store.ts b/apps/desktop/src/main/activity-log-store.ts index 9df8807b..fc575c85 100644 --- a/apps/desktop/src/main/activity-log-store.ts +++ b/apps/desktop/src/main/activity-log-store.ts @@ -18,29 +18,54 @@ type ActivityStoreSchema = { events: ActivityEvent[]; }; +const MAX_BODY_LENGTH = 8_192; + +export interface ActivityLogStoreOptions { + maxEntries?: number; + cwd?: string; + name?: string; +} + export class ActivityLogStore { private readonly maxEntries: number; private readonly events: ActivityEvent[]; private readonly store: Store; - constructor(maxEntries = 200) { - this.maxEntries = maxEntries; + constructor(options?: ActivityLogStoreOptions | number) { + const opts = typeof options === "number" ? { maxEntries: options } : options; + this.maxEntries = opts?.maxEntries ?? 200; this.store = new Store({ - name: "desktop-activity-log", + name: opts?.name ?? "desktop-activity-log", + cwd: opts?.cwd, defaults: { events: [] } }); const persistedEvents = this.store.get("events", []); - this.events = Array.isArray(persistedEvents) - ? persistedEvents.slice(0, this.maxEntries) - : []; + const raw = Array.isArray(persistedEvents) ? persistedEvents : []; + this.events = raw.slice(0, this.maxEntries); + // Migrate: truncate oversized bodies from existing events and persist + let needsPersist = raw.length > this.maxEntries; + for (const event of this.events) { + const trimmedReq = truncateBody(event.requestBody); + const trimmedRes = truncateBody(event.responseBody); + if (trimmedReq !== event.requestBody || trimmedRes !== event.responseBody) { + event.requestBody = trimmedReq; + event.responseBody = trimmedRes; + needsPersist = true; + } + } + if (needsPersist) { + this.persist(); + } } add(event: Omit): ActivityEvent { const withId: ActivityEvent = { id: randomUUID(), - ...event + ...event, + requestBody: truncateBody(event.requestBody), + responseBody: truncateBody(event.responseBody), }; this.events.unshift(withId); if (this.events.length > this.maxEntries) { @@ -63,3 +88,10 @@ export class ActivityLogStore { this.store.set("events", this.events); } } + +function truncateBody(body: string | undefined): string | undefined { + if (!body || body.length <= MAX_BODY_LENGTH) { + return body; + } + return `${body.slice(0, MAX_BODY_LENGTH)}… (truncated, ${body.length} chars total)`; +} diff --git a/apps/desktop/src/main/app.ts b/apps/desktop/src/main/app.ts index 78dc8dfc..f5cf8875 100644 --- a/apps/desktop/src/main/app.ts +++ b/apps/desktop/src/main/app.ts @@ -712,20 +712,14 @@ export class DesktopApplication { if (typeof approvalId !== "string" || !approvalId.trim()) { throw new Error("approvalId is required"); } - const approved = this.approvalStore.approve(approvalId.trim()); - if (!approved) { - throw new Error("approval not found"); - } + this.approvalStore.approve(approvalId.trim()); return this.approvalStore.listPending(); }); ipcMain.handle("desktop:deny-approval", (_event, approvalId: string) => { if (typeof approvalId !== "string" || !approvalId.trim()) { throw new Error("approvalId is required"); } - const denied = this.approvalStore.deny(approvalId.trim()); - if (!denied) { - throw new Error("approval not found"); - } + this.approvalStore.deny(approvalId.trim()); return this.approvalStore.listPending(); }); ipcMain.handle("desktop:always-allow-approval", (_event, approvalId: string) => { @@ -734,13 +728,13 @@ export class DesktopApplication { } const pending = this.approvalStore.getPendingById(approvalId.trim()); if (!pending) { - throw new Error("approval not found"); + return { + pendingApprovals: this.approvalStore.listPending(), + settings: this.settingsStore.getAll() + }; } this.saveAlwaysAllowRuleForPending(pending); - const resolved = this.approvalStore.alwaysAllow(approvalId.trim()); - if (!resolved) { - throw new Error("approval not found"); - } + this.approvalStore.alwaysAllow(approvalId.trim()); return { pendingApprovals: this.approvalStore.listPending(), settings: this.settingsStore.getAll() diff --git a/apps/desktop/test/activity-log-store.test.ts b/apps/desktop/test/activity-log-store.test.ts new file mode 100644 index 00000000..84add10a --- /dev/null +++ b/apps/desktop/test/activity-log-store.test.ts @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, test } from "node:test"; +import { ActivityLogStore } from "../src/main/activity-log-store.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "activity-log-test-")); + tempDirs.push(dir); + return dir; +} + +function writeStoreFile(dir: string, name: string, events: unknown[]): void { + fs.writeFileSync( + path.join(dir, `${name}.json`), + JSON.stringify({ events }) + ); +} + +function readStoreFile(dir: string, name: string): { events: unknown[] } { + return JSON.parse( + fs.readFileSync(path.join(dir, `${name}.json`), "utf-8") + ); +} + +function makeEvent(overrides: Record = {}) { + return { + id: `test-${Math.random().toString(36).slice(2)}`, + timestamp: new Date().toISOString(), + method: "GET", + path: "/api/test", + statusCode: 200, + durationMs: 10, + ...overrides, + }; +} + +function createStore(dir: string, name: string, maxEntries = 200) { + return new ActivityLogStore({ maxEntries, cwd: dir, name }); +} + +// --- truncateBody via add() --- + +describe("ActivityLogStore body truncation", () => { + test("preserves short bodies unchanged", () => { + const dir = makeTempDir(); + const store = createStore(dir, "truncate-short", 10); + const event = store.add({ + timestamp: new Date().toISOString(), + method: "POST", + path: "/api/test", + statusCode: 200, + durationMs: 5, + requestBody: "short body", + responseBody: '{"ok": true}', + }); + + assert.equal(event.requestBody, "short body"); + assert.equal(event.responseBody, '{"ok": true}'); + }); + + test("truncates bodies exceeding 8192 bytes", () => { + const dir = makeTempDir(); + const store = createStore(dir, "truncate-large", 10); + const largeBody = "x".repeat(20_000); + const event = store.add({ + timestamp: new Date().toISOString(), + method: "POST", + path: "/api/test", + statusCode: 200, + durationMs: 5, + requestBody: largeBody, + responseBody: largeBody, + }); + + assert.ok(event.requestBody!.length < largeBody.length); + assert.ok(event.requestBody!.startsWith("x".repeat(100))); + assert.ok(event.requestBody!.includes("truncated")); + assert.ok(event.requestBody!.includes("20000 chars total")); + assert.ok(event.responseBody!.includes("truncated")); + }); + + test("preserves undefined bodies", () => { + const dir = makeTempDir(); + const store = createStore(dir, "truncate-undef", 10); + const event = store.add({ + timestamp: new Date().toISOString(), + method: "GET", + path: "/api/test", + statusCode: 200, + durationMs: 5, + }); + + assert.equal(event.requestBody, undefined); + assert.equal(event.responseBody, undefined); + }); +}); + +// --- Constructor migration --- + +describe("ActivityLogStore startup migration", () => { + test("truncates oversized bodies from persisted events on startup", () => { + const dir = makeTempDir(); + const name = "migrate-bodies"; + const largeBody = "y".repeat(20_000); + writeStoreFile(dir, name, [ + makeEvent({ requestBody: largeBody, responseBody: largeBody }), + makeEvent({ requestBody: "small", responseBody: "small" }), + ]); + + const store = createStore(dir, name); + const events = store.list(); + + assert.equal(events.length, 2); + assert.ok(events[0].requestBody!.includes("truncated")); + assert.equal(events[1].requestBody, "small"); + assert.equal(events[1].responseBody, "small"); + + // Verify the truncated data was persisted to disk + const persisted = readStoreFile(dir, name); + const persistedEvents = persisted.events as Array>; + assert.ok((persistedEvents[0].requestBody as string).includes("truncated")); + assert.equal(persistedEvents[1].requestBody, "small"); + }); + + test("trims event count to maxEntries on startup", () => { + const dir = makeTempDir(); + const name = "migrate-count"; + const events = Array.from({ length: 50 }, (_, i) => + makeEvent({ id: `evt-${i}`, requestBody: "ok" }) + ); + writeStoreFile(dir, name, events); + + const store = createStore(dir, name, 10); + assert.equal(store.list().length, 10); + + // Verify persisted file was trimmed too + const persisted = readStoreFile(dir, name); + assert.equal((persisted.events as unknown[]).length, 10); + }); + + test("does not rewrite file when nothing needs migration", () => { + const dir = makeTempDir(); + const name = "migrate-noop"; + writeStoreFile(dir, name, [makeEvent({ requestBody: "small" })]); + const statBefore = fs.statSync(path.join(dir, `${name}.json`)); + + createStore(dir, name); + + const statAfter = fs.statSync(path.join(dir, `${name}.json`)); + assert.equal(statAfter.mtimeMs, statBefore.mtimeMs); + }); +}); + +// --- maxEntries enforcement --- + +describe("ActivityLogStore maxEntries", () => { + test("caps events at maxEntries when adding", () => { + const dir = makeTempDir(); + const store = createStore(dir, "max-entries", 3); + for (let i = 0; i < 5; i++) { + store.add({ + timestamp: new Date().toISOString(), + method: "GET", + path: `/api/test/${i}`, + statusCode: 200, + durationMs: 1, + }); + } + + assert.equal(store.list().length, 3); + assert.equal(store.list()[0].path, "/api/test/4"); + }); +});