Skip to content
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
12 changes: 5 additions & 7 deletions src/main/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,13 +271,11 @@ export function initDatabase(dbPath: string) {

// Bound the durable usage log: drop events older than the retention window so
// a long-lived install can't accumulate unboundedly (aggregation reads scan
// this table). Runs once per startup; cheap on a bounded table.
try {
const cutoff = Date.now() - USAGE_EVENTS_RETENTION_DAYS * 86_400_000;
sqlite.prepare("DELETE FROM usage_events WHERE ts < ?").run(cutoff);
} catch {
// usage_events may not exist on a partially-migrated db; ignore.
}
// this table). Runs once per startup; cheap on a bounded table. Migration and
// schema validation above guarantee this table exists, so SQLite failures here
// must remain observable instead of being mistaken for legacy schema drift.
const cutoff = Date.now() - USAGE_EVENTS_RETENTION_DAYS * 86_400_000;
sqlite.prepare("DELETE FROM usage_events WHERE ts < ?").run(cutoff);

const receiptCutoff = Date.now() - REMOTE_COMMAND_RECEIPTS_RETENTION_DAYS * 86_400_000;
sqlite.prepare("DELETE FROM remote_command_receipts WHERE updated_at < ?").run(receiptCutoff);
Expand Down
3 changes: 2 additions & 1 deletion src/main/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ describe("database migration registry", () => {
[27, "token usage ledger"],
[28, "pull request watches"],
[29, "project workspace"],
[30, "repair empty thread models"],
]);
expect(LATEST_SCHEMA_VERSION).toBe(29);
expect(LATEST_SCHEMA_VERSION).toBe(30);
expect(() => validateMigrationRegistry()).not.toThrow();
});

Expand Down
26 changes: 26 additions & 0 deletions src/main/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ function foldContextSuffix(sqlite: SqliteDatabase, table: string, column: string
}
}

function repairEmptyThreadModels(sqlite: SqliteDatabase): void {
const rows = sqlite.prepare("SELECT rowid, config FROM threads").all() as {
rowid: number;
config: string;
}[];
const update = sqlite.prepare("UPDATE threads SET config = ? WHERE rowid = ?");
for (const row of rows) {
let parsed: unknown;
try {
parsed = JSON.parse(row.config);
} catch {
continue;
}
if (!parsed || typeof parsed !== "object") continue;
const config = parsed as { model?: unknown };
if (typeof config.model !== "string" || config.model.trim().length > 0) continue;
config.model = "auto";
update.run(JSON.stringify(config), row.rowid);
}
}

/**
* Append-only database history. Never reuse or reorder a version: add the next
* integer at the end, even when repairing a migration that shipped previously.
Expand Down Expand Up @@ -336,6 +357,11 @@ export const DATABASE_MIGRATIONS = [
name: "project workspace",
migrate: (sqlite) => addColumnIfMissing(sqlite, "projects", "workspace_id", "TEXT"),
},
{
version: 30,
name: "repair empty thread models",
migrate: repairEmptyThreadModels,
},
] as const satisfies readonly DatabaseMigration[];

export const LATEST_SCHEMA_VERSION = DATABASE_MIGRATIONS[DATABASE_MIGRATIONS.length - 1]!.version;
Expand Down
46 changes: 44 additions & 2 deletions src/main/db/projectsThreads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
dbGetProject,
dbGetThreads,
dbMarkLiveThreadsInactive,
dbSetState,
dbUpsertProject,
dbUpsertThread,
} from "./projectsThreads";
Expand Down Expand Up @@ -199,7 +200,7 @@ describe("projectsThreads (real sqlite round-trip)", () => {
name: string;
}[];
expect(columns.some((column) => column.name === "workspace_id")).toBe(true);
expect(dbGetState("schema_version")).toBe("29");
expect(dbGetState("schema_version")).toBe("30");
});

it("repairs safe schema drift even when the database claims the latest version", () => {
Expand Down Expand Up @@ -245,14 +246,55 @@ describe("projectsThreads (real sqlite round-trip)", () => {
name: string;
}[];
expect(columns.some((column) => column.name === "workspace_id")).toBe(true);
expect(dbGetState("schema_version")).toBe("29");
expect(dbGetState("schema_version")).toBe("30");
expect(dbGetProject("legacy-project")).toMatchObject({
id: "legacy-project",
name: "Legacy project",
location: { kind: "posix", path: "/tmp/legacy-project" },
});
});

it("repairs blank legacy thread models from schema v29 without changing valid configs", () => {
const sqlite = getSqlite();
const insert = sqlite.prepare(`
INSERT INTO threads (
id, project_id, title, agent_kind, config, status, attention,
can_resume_with_config, archived, done, starred, presentation_mode,
sort_order, created_at, updated_at
) VALUES (
@id, 'project-1', @title, 'claude', @config, 'idle', 'none',
0, 0, 0, 0, 'gui', @sortOrder,
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'
)
`);
insert.run({
id: "legacy-empty-model",
title: "Legacy",
config: JSON.stringify({ model: "", effort: "high" }),
sortOrder: 0,
});
insert.run({
id: "valid-model",
title: "Valid",
config: JSON.stringify({ model: "sonnet", effort: "low" }),
sortOrder: 1,
});
dbSetState("schema_version", "29");

closeDatabase();
initDatabase(join(dir, "state.sqlite"));

expect(dbGetState("schema_version")).toBe("30");
expect(dbGetThread("legacy-empty-model")?.config).toEqual({
model: "auto",
effort: "high",
});
expect(dbGetThread("valid-model")?.config).toEqual({
model: "sonnet",
effort: "low",
});
});

it("round-trips the project workspace through the bulk renderer sync", () => {
const project = {
id: "project-bulk",
Expand Down
108 changes: 108 additions & 0 deletions src/main/secretStorageKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const safeStorageMock = vi.hoisted(() => ({
decryptString: vi.fn<(value: Buffer) => string>(),
encryptString: vi.fn<(value: string) => Buffer>(),
getSelectedStorageBackend: vi.fn<() => string>(),
isEncryptionAvailable: vi.fn<() => boolean>(),
}));

vi.mock("electron", () => ({ safeStorage: safeStorageMock }));

import { readOrCreateSafeStorageSecretKey } from "./secretStorageKey";

describe("readOrCreateSafeStorageSecretKey", () => {
let dir: string;
let consoleWarn: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "poracode-safe-storage-"));
consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
safeStorageMock.decryptString.mockReset();
safeStorageMock.encryptString.mockReset().mockImplementation((value) => Buffer.from(value));
safeStorageMock.getSelectedStorageBackend.mockReset().mockReturnValue("gnome_libsecret");
safeStorageMock.isEncryptionAvailable.mockReset().mockReturnValue(true);
});

afterEach(() => {
consoleWarn.mockRestore();
rmSync(dir, { recursive: true, force: true });
});

it.each([
{ available: false, backend: "gnome_libsecret" },
{ available: true, backend: "basic_text" },
])(
"uses a session-only key when secure Linux storage is unavailable",
({ available, backend }) => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(available);
safeStorageMock.getSelectedStorageBackend.mockReturnValue(backend);

const first = readOrCreateSafeStorageSecretKey(dir, "linux");
const second = readOrCreateSafeStorageSecretKey(dir, "linux");

expect(Buffer.from(first, "base64")).toHaveLength(32);
expect(Buffer.from(second, "base64")).toHaveLength(32);
expect(second).toBe(first);
expect(safeStorageMock.encryptString).not.toHaveBeenCalled();
expect(safeStorageMock.decryptString).not.toHaveBeenCalled();
expect(() => readFileSync(join(dir, "secret-key.safe"))).toThrow(/ENOENT|no such file/i);
expect(consoleWarn).toHaveBeenCalledWith(
"[credential-storage] secure OS encryption is unavailable; credentials are session-only.",
);
},
);

it("persists a newly generated key only through safeStorage encryption", () => {
safeStorageMock.encryptString.mockImplementation(() => Buffer.from("sealed-key"));

const key = readOrCreateSafeStorageSecretKey(dir, "linux");

expect(Buffer.from(key, "base64")).toHaveLength(32);
expect(safeStorageMock.encryptString).toHaveBeenCalledWith(key);
expect(readFileSync(join(dir, "secret-key.safe"), "utf8")).toBe(
Buffer.from("sealed-key").toString("base64"),
);
});

it("recovers from an undecryptable stored key with only a fixed local warning", () => {
writeFileSync(join(dir, "secret-key.safe"), Buffer.from("old-sealed-key").toString("base64"));
safeStorageMock.decryptString.mockImplementation(() => {
throw new Error("unexpected crypto details");
});

const key = readOrCreateSafeStorageSecretKey(dir, "linux");

expect(Buffer.from(key, "base64")).toHaveLength(32);
expect(consoleWarn).toHaveBeenCalledWith(
"[credential-storage] stored key recovery (decrypt_failed); rotating encrypted key.",
);
expect(consoleWarn).not.toHaveBeenCalledWith(expect.stringContaining("unexpected crypto"));
});

it("rotates an invalid decrypted key with only a fixed local warning", () => {
writeFileSync(join(dir, "secret-key.safe"), Buffer.from("old-sealed-key").toString("base64"));
safeStorageMock.decryptString.mockReturnValue(Buffer.alloc(16).toString("base64"));

const key = readOrCreateSafeStorageSecretKey(dir, "linux");

expect(Buffer.from(key, "base64")).toHaveLength(32);
expect(consoleWarn).toHaveBeenCalledWith(
"[credential-storage] stored key recovery (invalid_key); rotating encrypted key.",
);
});

it("keeps unexpected encryption failures observable without leaking the key", () => {
safeStorageMock.encryptString.mockImplementation(() => {
throw new Error("crypto backend details");
});

expect(() => readOrCreateSafeStorageSecretKey(dir, "linux")).toThrow(
"Unable to encrypt the Poracode secret storage key.",
);
expect(() => readFileSync(join(dir, "secret-key.safe"))).toThrow(/ENOENT|no such file/i);
});
});
81 changes: 69 additions & 12 deletions src/main/secretStorageKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { safeStorage } from "electron";
import { writeFileAtomic } from "@/shared/atomicFile";

const SAFE_STORAGE_KEY_FILE = "secret-key.safe";
let sessionOnlyKey: string | undefined;

function keyFilePath(baseDir: string): string {
return join(baseDir, SAFE_STORAGE_KEY_FILE);
Expand All @@ -14,25 +15,81 @@ function isValidKey(value: string): boolean {
return Buffer.from(value, "base64").length === 32;
}

export function readOrCreateSafeStorageSecretKey(baseDir: string): string {
if (!safeStorage.isEncryptionAvailable()) {
throw new Error("Electron safeStorage encryption is not available.");
function hasErrorCode(error: unknown, code: string): boolean {
return (
error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code
);
}

function reportKeyRecovery(reason: "decrypt_failed" | "invalid_key"): void {
console.warn(`[credential-storage] stored key recovery (${reason}); rotating encrypted key.`);
}

function throwSecretStorageError(message: string): never {
throw new Error(message);
}

function createPersistentKey(path: string): string {
const key = randomBytes(32).toString("base64");
let encrypted: Buffer;
try {
encrypted = safeStorage.encryptString(key);
} catch {
throw new Error("Unable to encrypt the Poracode secret storage key.");
}
try {
writeFileAtomic(path, encrypted.toString("base64"), { encoding: "utf8", mode: 0o600 });
} catch {
throw new Error("Unable to persist the encrypted Poracode secret storage key.");
}
return key;
}

export function readOrCreateSafeStorageSecretKey(
baseDir: string,
platform: NodeJS.Platform = process.platform,
): string {
let encryptionAvailable: boolean;
try {
encryptionAvailable = safeStorage.isEncryptionAvailable();
if (
encryptionAvailable &&
platform === "linux" &&
safeStorage.getSelectedStorageBackend() === "basic_text"
) {
encryptionAvailable = false;
}
} catch {
throw new Error("Unable to inspect OS-backed secret storage.");
}
if (!encryptionAvailable) {
console.warn(
"[credential-storage] secure OS encryption is unavailable; credentials are session-only.",
);
sessionOnlyKey ??= randomBytes(32).toString("base64");
return sessionOnlyKey;
}

const path = keyFilePath(baseDir);
let serialized: string;
try {
serialized = readFileSync(path, "utf8");
} catch (error) {
if (hasErrorCode(error, "ENOENT")) return createPersistentKey(path);
throwSecretStorageError("Unable to read the encrypted Poracode secret storage key.");
}

try {
const encrypted = Buffer.from(readFileSync(path, "utf8"), "base64");
const encrypted = Buffer.from(serialized, "base64");
const key = safeStorage.decryptString(encrypted);
if (isValidKey(key)) return key;
reportKeyRecovery("invalid_key");
} catch {
// Either the key file does not exist yet (first launch) or the OS-level
// safeStorage key is no longer available (e.g. credential reset, reinstall,
// different user). Fall through to regenerate; anything sealed with the
// prior key is unrecoverable regardless.
// Credential resets and OS keychain changes make the old key unrecoverable.
// Report the typed recovery without including the key file path or contents,
// then rotate to a fresh OS-encrypted key so the app remains usable.
reportKeyRecovery("decrypt_failed");
}

const key = randomBytes(32).toString("base64");
const encrypted = safeStorage.encryptString(key).toString("base64");
writeFileAtomic(path, encrypted, { encoding: "utf8", mode: 0o600 });
return key;
return createPersistentKey(path);
}
29 changes: 29 additions & 0 deletions src/shared/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ describe("ipcProcedureMap", () => {
}
});

it("repairs blank legacy thread models at the database persistence boundary", () => {
const baseThread = {
id: "thread-1",
projectId: "project-1",
title: "Thread",
agentKind: "claude" as const,
status: "idle" as const,
attention: "none" as const,
canResumeWithConfig: false,
archived: false,
done: false,
starred: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};

const parsed = ipcProcedureMap.dbSyncAll.parseArgs(
[],
[
{ ...baseThread, config: { model: "", effort: "high" } },
{ ...baseThread, id: "thread-2", config: { model: "sonnet", effort: "low" } },
],
JSON.stringify({ kind: "home" }),
);

expect(parsed.threads[0]?.config).toEqual({ model: "auto", effort: "high" });
expect(parsed.threads[1]?.config).toEqual({ model: "sonnet", effort: "low" });
});

it("covers every main-local procedure with a local handler", () => {
const handlers = createLocalIpcHandlers({
getMainWindow: () => null as never,
Expand Down
Loading