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
22 changes: 20 additions & 2 deletions apps/desktop/src/main/agent/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
getDatabase: vi.fn(() => ({ db: true })),
getIndexDatabase: vi.fn(() => ({ indexDb: true })),
getOrDeriveVaultKey: vi.fn(async () => new Uint8Array(32).fill(1)),
getOrInitializeLocalVaultKey: vi.fn(async () => new Uint8Array(32).fill(1)),
secureCleanup: vi.fn(),
getOrCreateVaultUuid: vi.fn(() => 'vault-1'),
createConversationStore: vi.fn(() => ({ store: 'conversations' })),
Expand All @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
}
})),
registerAgentHandlers: vi.fn(),
registerUnavailableAgentHandlers: vi.fn(),
unregisterAgentHandlers: vi.fn(),
getPublicStatus: vi.fn(() => ({
url: 'http://127.0.0.1:54321',
Expand Down Expand Up @@ -49,7 +50,7 @@ vi.mock('../database', () => ({
getIndexDatabase: mocks.getIndexDatabase
}))
vi.mock('../crypto', () => ({
getOrDeriveVaultKey: mocks.getOrDeriveVaultKey,
getOrInitializeLocalVaultKey: mocks.getOrInitializeLocalVaultKey,
secureCleanup: mocks.secureCleanup
}))
vi.mock('./storage/vault-id', () => ({ getOrCreateVaultUuid: mocks.getOrCreateVaultUuid }))
Expand All @@ -59,6 +60,7 @@ vi.mock('./storage/conversation-store', () => ({
vi.mock('./storage/message-store', () => ({ createMessageStore: mocks.createMessageStore }))
vi.mock('../ipc/agent-handlers', () => ({
registerAgentHandlers: mocks.registerAgentHandlers,
registerUnavailableAgentHandlers: mocks.registerUnavailableAgentHandlers,
unregisterAgentHandlers: mocks.unregisterAgentHandlers
}))
vi.mock('./mcp/lifecycle', () => ({ getPublicStatus: mocks.getPublicStatus }))
Expand All @@ -79,9 +81,25 @@ vi.mock('./runtime/runtime', () => ({
import { startAgent } from './bootstrap'

describe('startAgent', () => {
it('registers unavailable IPC handlers when the local vault key cannot be created', async () => {
mocks.getOrInitializeLocalVaultKey.mockRejectedValueOnce(new Error('keychain locked'))

const agent = await startAgent()

expect(mocks.registerUnavailableAgentHandlers).toHaveBeenCalledWith('keychain locked')
expect(mocks.getOrCreateVaultUuid).toHaveBeenCalledWith({ db: true })
expect(mocks.createConversationStore).not.toHaveBeenCalled()
expect(mocks.runtimeInstall).not.toHaveBeenCalled()

await agent.shutdown()

expect(mocks.unregisterAgentHandlers).toHaveBeenCalled()
})

it('creates stores, installs runtime, and registers IPC handlers', async () => {
await startAgent()

expect(mocks.getOrInitializeLocalVaultKey).toHaveBeenCalledWith({ db: true }, 'vault-1')
expect(mocks.getOrCreateVaultUuid).toHaveBeenCalledWith({ db: true })
expect(mocks.createConversationStore).toHaveBeenCalledWith({
db: { db: true },
Expand Down
29 changes: 25 additions & 4 deletions apps/desktop/src/main/agent/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { getOrDeriveVaultKey, secureCleanup } from '../crypto'
import { getOrInitializeLocalVaultKey, secureCleanup } from '../crypto'
import { getDatabase, getIndexDatabase } from '../database'
import { registerAgentHandlers, unregisterAgentHandlers } from '../ipc/agent-handlers'
import {
registerAgentHandlers,
registerUnavailableAgentHandlers,
unregisterAgentHandlers
} from '../ipc/agent-handlers'
import { createLogger } from '../lib/logger'
import { detectClaudeBinary } from './cli/claude-binary'
import { spawnClaudeTurn } from './cli/spawn'
Expand Down Expand Up @@ -32,10 +36,23 @@ export interface AgentHandle {

export async function startAgent(): Promise<AgentHandle> {
const db = getDatabase()
const vaultId = getOrCreateVaultUuid(db)
let vaultKey: Uint8Array
try {
vaultKey = await getOrInitializeLocalVaultKey(db, vaultId)
} catch (error) {
const reason = extractErrorMessage(error, 'Vault key unavailable')
logger.warn(`Agent runtime unavailable: ${reason}`)
registerUnavailableAgentHandlers(reason)
return {
shutdown: async () => {
unregisterAgentHandlers()
}
}
}

const indexDb = getIndexDatabase()
const vaultKey = await getOrDeriveVaultKey()
const deviceId = process.env.MEMRY_DEVICE ?? 'desktop'
const vaultId = getOrCreateVaultUuid(db)
const conversations = createConversationStore({ db, vaultKey, deviceId })
const messages = createMessageStore({ db, vaultKey, deviceId })
const handles = createVaultServiceHandles({ dataDb: db, indexDb })
Expand Down Expand Up @@ -119,3 +136,7 @@ export async function startAgent(): Promise<AgentHandle> {
}
}
}

function extractErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ describe('Conversation store', () => {
expect(list.map((conversation) => conversation.title)).toEqual(['New', 'Old'])
})

it('skips undecryptable conversations when listing a vault', () => {
const otherKey = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES)
const otherStore = createConversationStore({ db, vaultKey: otherKey, deviceId: 'device-2' })
otherStore.create({ vaultId: 'v', title: 'Unreadable', backend: 'claude_cli' })
store.create({ vaultId: 'v', title: 'Readable', backend: 'claude_cli' })

const list = store.listByVault('v')

expect(list.map((conversation) => conversation.title)).toEqual(['Readable'])
})

it('updates pinned status and bumps the field clock for pinned only', () => {
const conversation = store.create({ vaultId: 'v', title: 'X', backend: 'claude_cli' })
const before = conversation.fieldClocks
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/agent/storage/conversation-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,13 @@ export function createConversationStore(deps: StoreDeps): ConversationStore {
.orderBy(desc(schema.agentConversations.updatedAt))
.all()

return rows.map((row) => agentConversationRowToModel(row, vaultKey))
return rows.flatMap((row) => {
try {
return [agentConversationRowToModel(row, vaultKey)]
} catch {
return []
}
})
},

update(id, patch, changedFields) {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/main/crypto/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const expectedFunctions = [
'deleteKey',
'retrieveKey',
'storeKey',
// vault key state
'computeVaultKeyVerifier',
'getOrInitializeLocalVaultKey',
'storeVaultKeyVerifier',
// primitives
'secureCleanup',
// memory-lock
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/crypto/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export { CBOR_FIELD_ORDER } from '@memry/contracts/cbor-ordering'

export { deleteKey, retrieveKey, storeKey } from './keychain'

export {
computeVaultKeyVerifier,
getOrInitializeLocalVaultKey,
storeVaultKeyVerifier
} from './vault-key-state'

export { secureCleanup } from './primitives'
export { lockKeyMaterial, unlockKeyMaterial } from './memory-lock'

Expand Down
172 changes: 172 additions & 0 deletions apps/desktop/src/main/crypto/vault-key-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import Database from 'better-sqlite3'
import { eq } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import sodium from 'libsodium-wrappers-sumo'
import keytar from 'keytar'

import * as schema from '@memry/db-schema/data-schema'
import { KEYCHAIN_ENTRIES, KEY_DERIVATION_CONTEXTS } from '@memry/contracts/crypto'

import { deriveKey } from './keys'
import {
VAULT_KEY_VERIFIER_SETTING,
computeVaultKeyVerifier,
getOrInitializeLocalVaultKey
} from './vault-key-state'

vi.mock('keytar', () => ({
default: {
setPassword: vi.fn(),
getPassword: vi.fn(),
deletePassword: vi.fn()
}
}))

function freshDb() {
const sqlite = new Database(':memory:')
sqlite.exec(`
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
modified_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE TABLE agent_conversations (
id TEXT PRIMARY KEY,
vault_id TEXT NOT NULL,
title_ciphertext TEXT NOT NULL,
backend TEXT NOT NULL,
trust_list TEXT NOT NULL DEFAULT '[]',
pinned INTEGER NOT NULL DEFAULT 0,
vector_clock TEXT NOT NULL,
field_clocks TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER,
last_synced_at INTEGER
);

CREATE TABLE agent_messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content_ciphertext TEXT NOT NULL,
attachments_ciphertext TEXT NOT NULL,
tool_call_id TEXT,
status TEXT NOT NULL,
vector_clock TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER
);
`)
return drizzle(sqlite, { schema })
}

function keychainPassword(bytes: Uint8Array): string {
return sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL)
}

describe('vault key state', () => {
beforeAll(async () => {
await sodium.ready
})

beforeEach(() => {
vi.clearAllMocks()
})

it('creates and binds a local master key when the vault has no encrypted agent data', async () => {
const db = freshDb()
let storedMasterKey = ''
vi.mocked(keytar.getPassword).mockResolvedValue(null)
vi.mocked(keytar.setPassword).mockImplementation(async (_service, _account, password) => {
storedMasterKey = password
})

const vaultKey = await getOrInitializeLocalVaultKey(db, 'vault-1')

expect(vaultKey).toHaveLength(32)
expect(keytar.setPassword).toHaveBeenCalledWith(
KEYCHAIN_ENTRIES.MASTER_KEY.service,
KEYCHAIN_ENTRIES.MASTER_KEY.account,
expect.any(String)
)
expect(storedMasterKey).not.toBe('')

const verifier = db
.select({ value: schema.settings.value })
.from(schema.settings)
.where(eq(schema.settings.key, VAULT_KEY_VERIFIER_SETTING))
.get()
expect(verifier?.value).toBe(computeVaultKeyVerifier(vaultKey, 'vault-1'))
})

it('resets legacy unbound agent data before creating a local master key', async () => {
const db = freshDb()
db.insert(schema.agentConversations)
.values({
id: 'conversation-1',
vaultId: 'vault-1',
titleCiphertext: '{"version":1,"nonce":"x","ciphertext":"y"}',
backend: 'claude_cli',
trustList: [],
pinned: false,
vectorClock: {},
fieldClocks: {},
createdAt: 1,
updatedAt: 1,
deletedAt: null,
lastSyncedAt: null
})
.run()
vi.mocked(keytar.getPassword).mockResolvedValue(null)

await getOrInitializeLocalVaultKey(db, 'vault-1')

const conversations = db.select().from(schema.agentConversations).all()
expect(conversations).toEqual([])
expect(keytar.setPassword).toHaveBeenCalledWith(
KEYCHAIN_ENTRIES.MASTER_KEY.service,
KEYCHAIN_ENTRIES.MASTER_KEY.account,
expect.any(String)
)
})

it('does not create a replacement master key when the vault already has a verifier', async () => {
const db = freshDb()
db.insert(schema.settings)
.values({ key: VAULT_KEY_VERIFIER_SETTING, value: 'existing-verifier' })
.run()
vi.mocked(keytar.getPassword).mockResolvedValue(null)

await expect(getOrInitializeLocalVaultKey(db, 'vault-1')).rejects.toThrow(
'Vault key verifier exists but master key is missing'
)
expect(keytar.setPassword).not.toHaveBeenCalled()
})

it('rejects a keychain master key that does not match the vault verifier', async () => {
const db = freshDb()
const masterA = new Uint8Array(32).fill(0x11)
const masterB = new Uint8Array(32).fill(0x22)
const vaultKeyA = await deriveKey(masterA, KEY_DERIVATION_CONTEXTS.VAULT_KEY, 32)

db.insert(schema.settings)
.values({
key: VAULT_KEY_VERIFIER_SETTING,
value: computeVaultKeyVerifier(vaultKeyA, 'vault-1')
})
.run()

vi.mocked(keytar.getPassword).mockImplementation(async (_service, account) => {
if (account === KEYCHAIN_ENTRIES.MASTER_KEY.account) return keychainPassword(masterB)
return null
})

await expect(getOrInitializeLocalVaultKey(db, 'vault-1')).rejects.toThrow(
'Current master key does not match this vault'
)
})
})
Loading
Loading