Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions apps/desktop/src/main/activity-log-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivityStoreSchema>;

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<ActivityStoreSchema>({
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, "id">): 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) {
Expand All @@ -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)`;
}
20 changes: 7 additions & 13 deletions apps/desktop/src/main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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()
Expand Down
183 changes: 183 additions & 0 deletions apps/desktop/test/activity-log-store.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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<Record<string, unknown>>;
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");
});
});
Loading