diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts deleted file mode 100644 index 8001382f33..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.test.ts +++ /dev/null @@ -1,391 +0,0 @@ -import path from 'node:path'; -import type { IFileSystem } from '@emdash/core/files'; -import { err, ok } from '@emdash/shared'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { IFilesRuntime } from '@main/core/runtime/types'; -import { ClaudeTrustService } from './claude-trust-service'; - -const mockReadFile = vi.hoisted(() => vi.fn()); -const mockWriteFile = vi.hoisted(() => vi.fn()); -const mockMkdir = vi.hoisted(() => vi.fn()); -const mockRename = vi.hoisted(() => vi.fn()); -const mockRm = vi.hoisted(() => vi.fn()); -const mockWarn = vi.hoisted(() => vi.fn()); - -vi.mock('node:fs', () => ({ - promises: { - readFile: mockReadFile, - writeFile: mockWriteFile, - mkdir: mockMkdir, - rename: mockRename, - rm: mockRm, - }, -})); - -vi.mock('@main/core/settings/settings-service', () => ({ - appSettingsService: { get: vi.fn() }, -})); - -vi.mock('@main/lib/logger', () => ({ - log: { - warn: mockWarn, - info: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, -})); - -function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): ClaudeTrustService { - return new ClaudeTrustService({ - getTaskSettings: () => - Promise.resolve({ autoTrustWorktrees: overrides.autoTrustWorktrees ?? true }), - }); -} - -function makeRemoteFs( - overrides: Partial> = {} -): Pick { - return { - realPath: vi.fn(async () => ok('/remote/worktree')), - readText: vi.fn(async (p: string) => - err({ - type: 'fs-error' as const, - path: p, - message: `File not found: ${p}`, - code: 'NOT_FOUND', - }) - ), - writeText: vi.fn(async (_path: string, content: string) => - ok({ bytesWritten: content.length }) - ), - ...overrides, - }; -} - -function makeFilesRuntime(args: { - fs: Pick; -}): IFilesRuntime { - return { - path: { - join: (...parts: string[]) => path.posix.join(...parts), - dirname: (value: string) => path.posix.dirname(value), - basename: (value: string) => path.posix.basename(value), - isAbsolute: (value: string) => path.posix.isAbsolute(value), - relative: (from: string, to: string) => path.posix.relative(from, to), - contains: (parent: string, child: string) => { - const rel = path.posix.relative(parent, child); - return ( - rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) - ); - }, - }, - openTree: vi.fn(), - watchChanges: vi.fn(), - fileSystem: vi.fn(() => ok(args.fs as IFileSystem)), - dispose: vi.fn(), - } as unknown as IFilesRuntime; -} - -describe('ClaudeTrustService', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockReadFile.mockRejectedValue(Object.assign(new Error('not found'), { code: 'ENOENT' })); - mockWriteFile.mockResolvedValue(undefined); - mockMkdir.mockResolvedValue(undefined); - mockRename.mockResolvedValue(undefined); - mockRm.mockResolvedValue(undefined); - }); - - it('skips providers without trust config', async () => { - const service = makeService(); - - await service.maybeAutoTrustLocal({ - providerId: 'codex', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockReadFile).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('skips when auto-trust is disabled', async () => { - const service = makeService({ autoTrustWorktrees: false }); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockReadFile).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('trusts Claude worktrees when forced even if auto-trust is disabled', async () => { - const service = makeService({ autoTrustWorktrees: false }); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - force: true, - }); - - expect(mockReadFile).toHaveBeenCalledWith('/home/local-user/.claude.json', 'utf8'); - expect(mockWriteFile).toHaveBeenCalledTimes(1); - }); - - it('writes local config atomically when missing', async () => { - const service = makeService(); - const workspacePath = '/absolute/path'; - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath, - homedir: '/home/local-user', - }); - - expect(mockWriteFile).toHaveBeenCalledTimes(1); - expect(mockMkdir).toHaveBeenCalledWith('/home/local-user', { recursive: true }); - expect(mockRename).toHaveBeenCalledTimes(1); - - const [tmpPath, content] = mockWriteFile.mock.calls[0]; - const [renameFrom, renameTo] = mockRename.mock.calls[0]; - expect(tmpPath).toContain('/home/local-user/.claude.json.'); - expect(tmpPath).toContain('.tmp'); - expect(renameFrom).toBe(tmpPath); - expect(renameTo).toBe('/home/local-user/.claude.json'); - - const written = JSON.parse(String(content)); - expect(written.projects[workspacePath]).toEqual({ - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - }); - }); - - it('refuses to auto-trust relative local workspace paths', async () => { - const service = makeService(); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: './relative/path', - homedir: '/home/local-user', - }); - - expect(mockReadFile).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockWarn).toHaveBeenCalledWith( - 'ClaudeTrustService: refusing to auto-trust non-absolute workspace path', - { path: './relative/path' } - ); - }); - - it('adds Copilot trusted folders', async () => { - const service = makeService(); - mockReadFile.mockResolvedValue(JSON.stringify({ trustedFolders: ['/already/trusted'] })); - - await service.maybeAutoTrustLocal({ - providerId: 'copilot', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockMkdir).toHaveBeenCalledWith('/home/local-user/.copilot', { recursive: true }); - const [tmpPath, content] = mockWriteFile.mock.calls[0]; - const [renameFrom, renameTo] = mockRename.mock.calls[0]; - expect(tmpPath).toContain('/home/local-user/.copilot/config.json.'); - expect(renameFrom).toBe(tmpPath); - expect(renameTo).toBe('/home/local-user/.copilot/config.json'); - expect(JSON.parse(String(content)).trustedFolders).toEqual([ - '/already/trusted', - '/tmp/worktree', - ]); - }); - - it('does not rewrite Copilot config when folder is already trusted', async () => { - const service = makeService(); - mockReadFile.mockResolvedValue(JSON.stringify({ trustedFolders: ['/tmp/worktree'] })); - - await service.maybeAutoTrustLocal({ - providerId: 'copilot', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockRename).not.toHaveBeenCalled(); - }); - - it('is idempotent when already trusted', async () => { - const service = makeService(); - const trustedPath = '/already/trusted'; - mockReadFile.mockResolvedValue( - JSON.stringify({ - projects: { - [trustedPath]: { - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - }, - }, - }) - ); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: trustedPath, - homedir: '/home/local-user', - }); - - expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockRename).not.toHaveBeenCalled(); - }); - - it('refuses to overwrite corrupt JSON and logs a warning', async () => { - const service = makeService(); - mockReadFile.mockResolvedValue('{ invalid json'); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockRename).not.toHaveBeenCalled(); - expect(mockWarn).toHaveBeenCalledWith( - 'ClaudeTrustService: refusing to overwrite corrupt Claude config', - expect.objectContaining({ error: expect.any(String) }) - ); - }); - - it('refuses to overwrite non-object config root', async () => { - const service = makeService(); - mockReadFile.mockResolvedValue(JSON.stringify([1, 2, 3])); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockWarn).toHaveBeenCalledWith( - 'ClaudeTrustService: refusing to overwrite non-object Claude config root' - ); - }); - - it('serializes concurrent calls so no trust entry is lost', async () => { - const service = makeService(); - let callCount = 0; - - mockReadFile.mockImplementation(async () => { - callCount++; - if (callCount === 1) return null; - const [, content] = mockWriteFile.mock.calls[0]; - return String(content); - }); - - await Promise.all([ - service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/worktree/a', - homedir: '/home/local-user', - }), - service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/worktree/b', - homedir: '/home/local-user', - }), - ]); - - expect(mockWriteFile).toHaveBeenCalledTimes(2); - const secondWriteContent = JSON.parse(String(mockWriteFile.mock.calls[1][1])); - expect(secondWriteContent.projects[path.resolve('/worktree/a')]).toEqual({ - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - }); - expect(secondWriteContent.projects[path.resolve('/worktree/b')]).toEqual({ - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - }); - }); - - it('writes ssh config and renames tmp file remotely', async () => { - const service = makeService(); - const remoteFs = makeRemoteFs({ - realPath: vi.fn(async () => ok('/remote/worktree')), - }); - const files = makeFilesRuntime({ fs: remoteFs }); - - const ctx: IExecutionContext = { - root: undefined, - supportsLocalSpawn: false, - exec: vi.fn().mockImplementation(async (command: string, args: string[] = []) => { - if (command === 'sh') { - return { stdout: '/home/remote-user', stderr: '' }; - } - if (command === 'mv') { - expect(args[0]).toContain('/home/remote-user/.claude.json.'); - expect(args[1]).toBe('/home/remote-user/.claude.json'); - return { stdout: '', stderr: '' }; - } - if (command === 'mkdir') { - return { stdout: '', stderr: '' }; - } - return { stdout: '', stderr: '' }; - }), - execStreaming: vi.fn(), - dispose: vi.fn(), - }; - - await service.maybeAutoTrustSsh({ - providerId: 'claude', - workspacePath: '/remote/worktree', - ctx, - files, - }); - - expect(remoteFs.readText).toHaveBeenCalledWith('/home/remote-user/.claude.json', { - maxBytes: expect.any(Number), - }); - expect(remoteFs.writeText).toHaveBeenCalledTimes(1); - const [tmpPath, content] = vi.mocked(remoteFs.writeText).mock.calls[0]; - expect(tmpPath).toContain('/home/remote-user/.claude.json.'); - const written = JSON.parse(String(content)); - expect(written.projects['/remote/worktree']).toEqual({ - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - }); - expect(ctx.exec).toHaveBeenCalledWith('mv', [tmpPath, '/home/remote-user/.claude.json']); - }); - - it('refuses to auto-trust relative ssh workspace paths', async () => { - const service = makeService(); - const remoteFs = makeRemoteFs(); - const files = makeFilesRuntime({ fs: remoteFs }); - const ctx: IExecutionContext = { - root: undefined, - supportsLocalSpawn: false, - exec: vi.fn(), - execStreaming: vi.fn(), - dispose: vi.fn(), - }; - - await service.maybeAutoTrustSsh({ - providerId: 'claude', - workspacePath: 'relative/worktree', - ctx, - files, - }); - - expect(remoteFs.realPath).not.toHaveBeenCalled(); - expect(remoteFs.writeText).not.toHaveBeenCalled(); - expect(ctx.exec).not.toHaveBeenCalled(); - expect(mockWarn).toHaveBeenCalledWith( - 'ClaudeTrustService: refusing to auto-trust non-absolute workspace path', - { path: 'relative/worktree' } - ); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts deleted file mode 100644 index 7731a32ef7..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/claude-trust-service.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { isFileNotFoundError, isFileNotFoundException, type IFileSystem } from '@emdash/core/files'; -import { err, ok, type Result } from '@emdash/shared'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import { appSettingsService } from '@main/core/settings/settings-service'; -import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; -import { log } from '@main/lib/logger'; -import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; -import { normalizeLocalWorkspacePath, normalizeSshWorkspacePath } from './workspace-trust-paths'; -import type { WorkspaceTrustLocalArgs, WorkspaceTrustSshArgs } from './workspace-trust-types'; - -const CLAUDE_PROVIDER_ID: AgentProviderId = 'claude'; -const COPILOT_PROVIDER_ID: AgentProviderId = 'copilot'; -const CLAUDE_CONFIG_NAME = '.claude.json'; -const COPILOT_CONFIG_NAME = '.copilot/config.json'; -const CLAUDE_CONFIG_MAX_BYTES = 2 * 1024 * 1024; - -export class ClaudeTrustService { - private readonly configLocks = new Map>(); - - constructor( - private readonly deps: { - getTaskSettings: () => Promise<{ autoTrustWorktrees: boolean }>; - } - ) {} - - async maybeAutoTrustLocal({ - providerId, - workspacePath, - homedir, - force = false, - }: WorkspaceTrustLocalArgs): Promise { - const trustConfig = await this.getTrustConfig(providerId, force); - if (!trustConfig) return; - const normalizedPath = normalizeLocalWorkspacePath(workspacePath, 'ClaudeTrustService'); - if (!normalizedPath) return; - const configPath = path.join(homedir, trustConfig.configName); - await this.withLock(configPath, () => - this.ensureTrusted(normalizedPath, { - readConfig: () => readLocalConfig(configPath), - writeConfig: (content) => writeLocalConfigAtomic(configPath, content), - trustConfig, - }) - ); - } - - async maybeAutoTrustSsh({ - providerId, - workspacePath, - ctx, - files, - force = false, - }: WorkspaceTrustSshArgs): Promise { - const trustConfig = await this.getTrustConfig(providerId, force); - if (!trustConfig) return; - - const normalizedPath = await normalizeSshWorkspacePath( - files, - workspacePath, - 'ClaudeTrustService' - ); - if (!normalizedPath) return; - const homeDir = await resolveRemoteHome(ctx); - const homeFs = files.fileSystem(); - if (!homeFs.success) { - log.warn('ClaudeTrustService: failed to open filesystem for auto-trust', { - path: normalizedPath, - error: homeFs.error.message, - }); - return; - } - const configPath = path.posix.join(homeDir, trustConfig.configName); - - await this.withLock(configPath, () => - this.ensureTrusted(normalizedPath, { - readConfig: () => readRemoteConfig(homeFs.data, configPath), - writeConfig: (content) => writeRemoteConfigAtomic(homeFs.data, ctx, configPath, content), - trustConfig, - }) - ); - } - - private async getTrustConfig( - providerId: AgentProviderId, - force: boolean - ): Promise { - if (providerId !== CLAUDE_PROVIDER_ID && providerId !== COPILOT_PROVIDER_ID) return null; - if (!force) { - const { autoTrustWorktrees } = await this.deps.getTaskSettings(); - if (!autoTrustWorktrees) return null; - } - - if (providerId === COPILOT_PROVIDER_ID) { - return { - configName: COPILOT_CONFIG_NAME, - parseWarningName: 'Copilot', - withTrustedPath: withCopilotTrustedFolder, - }; - } - - return { - configName: CLAUDE_CONFIG_NAME, - parseWarningName: 'Claude', - withTrustedPath: withClaudeTrustedProject, - }; - } - - private withLock(configPath: string, fn: () => Promise): Promise { - const prev = this.configLocks.get(configPath) ?? Promise.resolve(); - const next = prev.then(fn, fn); - this.configLocks.set(configPath, next); - return next; - } - - private async ensureTrusted( - normalizedPath: string, - io: { - readConfig: () => Promise>; - writeConfig: (content: string) => Promise>; - trustConfig: TrustConfig; - } - ): Promise { - try { - const rawConfig = await io.readConfig(); - if (!rawConfig.success) { - log.warn('ClaudeTrustService: failed to read auto-trust config', { - path: normalizedPath, - error: rawConfig.error.message, - }); - return; - } - const config = parseConfig(rawConfig.data, io.trustConfig.parseWarningName); - if (!config) return; - const nextConfig = io.trustConfig.withTrustedPath(config, normalizedPath); - if (!nextConfig) return; - const written = await io.writeConfig(JSON.stringify(nextConfig, null, 2) + '\n'); - if (!written.success) { - log.warn('ClaudeTrustService: failed to write auto-trust config', { - path: normalizedPath, - error: written.error.message, - }); - } - } catch (error: unknown) { - log.warn('ClaudeTrustService: failed to auto-trust worktree', { - path: normalizedPath, - error: String(error), - }); - } - } -} - -export const claudeTrustService = new ClaudeTrustService({ - getTaskSettings: () => appSettingsService.get('tasks'), -}); - -type TrustConfig = { - configName: string; - parseWarningName: string; - withTrustedPath: ( - config: Record, - worktreePath: string - ) => Record | null; -}; - -type TrustIoError = { message: string }; -type TrustIoResult = Result; - -function parseConfig(raw: string | null, warningName: string): Record | null { - if (!raw || raw.trim() === '') return {}; - - try { - const parsed = JSON.parse(raw); - if (isPlainObject(parsed)) return parsed; - log.warn(`ClaudeTrustService: refusing to overwrite non-object ${warningName} config root`); - return null; - } catch (error: unknown) { - log.warn(`ClaudeTrustService: refusing to overwrite corrupt ${warningName} config`, { - error: String(error), - }); - return null; - } -} - -function withClaudeTrustedProject( - config: Record, - worktreePath: string -): Record | null { - const projects = isPlainObject(config.projects) ? config.projects : {}; - const existing = isPlainObject(projects[worktreePath]) ? projects[worktreePath] : {}; - - const alreadyTrusted = - existing['hasTrustDialogAccepted'] === true && - existing['hasCompletedProjectOnboarding'] === true; - if (alreadyTrusted) return null; - - return { - ...config, - projects: { - ...projects, - [worktreePath]: { - ...existing, - hasTrustDialogAccepted: true, - hasCompletedProjectOnboarding: true, - }, - }, - }; -} - -function withCopilotTrustedFolder( - config: Record, - worktreePath: string -): Record | null { - const trustedFolders = Array.isArray(config.trustedFolders) ? config.trustedFolders : []; - if (trustedFolders.includes(worktreePath)) return null; - - return { - ...config, - trustedFolders: [...trustedFolders, worktreePath], - }; -} - -async function readLocalConfig(configPath: string): Promise> { - try { - return ok(await fs.readFile(configPath, 'utf8')); - } catch (error: unknown) { - if (isFileNotFoundException(error)) return ok(null); - return err({ message: errorMessage(error) }); - } -} - -async function writeLocalConfigAtomic( - configPath: string, - content: string -): Promise> { - const tmpPath = `${configPath}.${randomUUID()}.tmp`; - try { - await fs.mkdir(path.dirname(configPath), { recursive: true }); - await fs.writeFile(tmpPath, content, 'utf8'); - await fs.rename(tmpPath, configPath); - return ok(); - } catch (error: unknown) { - try { - await fs.rm(tmpPath, { force: true }); - } catch {} - return err({ message: errorMessage(error) }); - } -} - -async function readRemoteConfig( - remoteFs: Pick, - configPath: string -): Promise> { - const result = await remoteFs.readText(configPath, { maxBytes: CLAUDE_CONFIG_MAX_BYTES }); - if (result.success) return ok(result.data.content); - if (isFileNotFoundError(result.error)) return ok(null); - return err(result.error); -} - -async function writeRemoteConfigAtomic( - remoteFs: Pick, - ctx: IExecutionContext, - configPath: string, - content: string -): Promise> { - const tmpPath = `${configPath}.${randomUUID()}.tmp`; - try { - await ctx.exec('mkdir', ['-p', path.posix.dirname(configPath)]); - const written = await remoteFs.writeText(tmpPath, content); - if (!written.success) return err(written.error); - await ctx.exec('mv', [tmpPath, configPath]); - return ok(); - } catch (error: unknown) { - try { - await ctx.exec('rm', ['-f', tmpPath]); - } catch {} - return err({ message: errorMessage(error) }); - } -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function isPlainObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts deleted file mode 100644 index 2e37c92e8c..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import path from 'node:path'; -import type { IFileSystem } from '@emdash/core/files'; -import { ok } from '@emdash/shared'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { IFilesRuntime } from '@main/core/runtime/types'; -import { CursorTrustService } from './cursor-trust-service'; - -const mockAccess = vi.hoisted(() => vi.fn()); -const mockMkdir = vi.hoisted(() => vi.fn()); -const mockWriteFile = vi.hoisted(() => vi.fn()); -const mockWarn = vi.hoisted(() => vi.fn()); - -vi.mock('node:fs', () => ({ - promises: { - access: mockAccess, - mkdir: mockMkdir, - writeFile: mockWriteFile, - }, -})); - -vi.mock('@main/core/settings/settings-service', () => ({ - appSettingsService: { get: vi.fn() }, -})); - -vi.mock('@main/lib/logger', () => ({ - log: { - warn: mockWarn, - info: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }, -})); - -function nodeNotFound() { - return Object.assign(new Error('not found'), { code: 'ENOENT' }); -} - -function makeService(overrides: { autoTrustWorktrees?: boolean } = {}): CursorTrustService { - return new CursorTrustService({ - getTaskSettings: () => - Promise.resolve({ autoTrustWorktrees: overrides.autoTrustWorktrees ?? true }), - }); -} - -function makeRemoteFs( - overrides: Partial> = {} -): Pick { - return { - realPath: vi.fn(async () => ok('/remote/worktree')), - exists: vi.fn(async () => ok(false)), - writeText: vi.fn(async (_path: string, content: string) => - ok({ bytesWritten: content.length }) - ), - ...overrides, - }; -} - -function makeFilesRuntime(args: { - fs: Pick; -}): IFilesRuntime { - return { - path: { - join: (...parts: string[]) => path.posix.join(...parts), - dirname: (value: string) => path.posix.dirname(value), - basename: (value: string) => path.posix.basename(value), - isAbsolute: (value: string) => path.posix.isAbsolute(value), - relative: (from: string, to: string) => path.posix.relative(from, to), - contains: (parent: string, child: string) => { - const rel = path.posix.relative(parent, child); - return ( - rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) - ); - }, - }, - openTree: vi.fn(), - watchChanges: vi.fn(), - fileSystem: vi.fn(() => ok(args.fs as IFileSystem)), - dispose: vi.fn(), - } as unknown as IFilesRuntime; -} - -function makeCtx(): IExecutionContext { - return { - root: undefined, - supportsLocalSpawn: false, - exec: vi.fn().mockImplementation(async (command: string) => { - if (command === 'sh') return { stdout: '/home/remote-user', stderr: '' }; - return { stdout: '', stderr: '' }; - }), - execStreaming: vi.fn(), - dispose: vi.fn(), - }; -} - -describe('CursorTrustService', () => { - beforeEach(() => { - vi.clearAllMocks(); - mockAccess.mockRejectedValue(nodeNotFound()); - mockMkdir.mockResolvedValue(undefined); - mockWriteFile.mockResolvedValue(undefined); - }); - - it('skips non-Cursor providers', async () => { - const service = makeService(); - - await service.maybeAutoTrustLocal({ - providerId: 'claude', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockAccess).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('skips when auto-trust is disabled', async () => { - const service = makeService({ autoTrustWorktrees: false }); - - await service.maybeAutoTrustLocal({ - providerId: 'cursor', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockAccess).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('trusts Cursor workspaces when forced even if auto-trust is disabled', async () => { - const service = makeService({ autoTrustWorktrees: false }); - - await service.maybeAutoTrustLocal({ - providerId: 'cursor', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - force: true, - }); - - expect(mockAccess).toHaveBeenCalledWith( - '/home/local-user/.cursor/projects/tmp-worktree/.workspace-trusted' - ); - expect(mockWriteFile).toHaveBeenCalledTimes(1); - }); - - it('writes the local Cursor workspace trust marker when missing', async () => { - const service = makeService(); - - await service.maybeAutoTrustLocal({ - providerId: 'cursor', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - const markerPath = '/home/local-user/.cursor/projects/tmp-worktree/.workspace-trusted'; - expect(mockAccess).toHaveBeenCalledWith(markerPath); - expect(mockMkdir).toHaveBeenCalledWith('/home/local-user/.cursor/projects/tmp-worktree', { - recursive: true, - }); - expect(mockWriteFile).toHaveBeenCalledWith(markerPath, expect.any(String), 'utf8'); - - const marker = JSON.parse(String(mockWriteFile.mock.calls[0][1])); - expect(marker).toEqual({ - trustedAt: expect.any(String), - workspacePath: '/tmp/worktree', - trustMethod: 'emdash-auto-trust', - }); - }); - - it('refuses to auto-trust relative local workspace paths', async () => { - const service = makeService(); - - await service.maybeAutoTrustLocal({ - providerId: 'cursor', - workspacePath: './relative/path', - homedir: '/home/local-user', - }); - - expect(mockAccess).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - expect(mockWarn).toHaveBeenCalledWith( - 'CursorTrustService: refusing to auto-trust non-absolute workspace path', - { path: './relative/path' } - ); - }); - - it('is idempotent when the local marker already exists', async () => { - const service = makeService(); - mockAccess.mockResolvedValue(undefined); - - await service.maybeAutoTrustLocal({ - providerId: 'cursor', - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - }); - - expect(mockMkdir).not.toHaveBeenCalled(); - expect(mockWriteFile).not.toHaveBeenCalled(); - }); - - it('matches Cursor CLI workspace trust directory derivation for long workspace paths', async () => { - const service = makeService(); - - await service.maybeAutoTrustLocal({ - providerId: 'cursor', - workspacePath: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', - homedir: '/Users/janburzinski', - }); - - expect(mockWriteFile).toHaveBeenCalledWith( - '/Users/janburzinski/.cursor/projects/Users-janburzinski-emdash-worktrees-emdash-official-tough-falcons-notice/.workspace-trusted', - expect.any(String), - 'utf8' - ); - }); - - it('writes the ssh Cursor workspace trust marker remotely', async () => { - const service = makeService(); - const remoteFs = makeRemoteFs({ - realPath: vi.fn(async () => ok('/remote/worktree')), - }); - const files = makeFilesRuntime({ fs: remoteFs }); - const ctx = makeCtx(); - - await service.maybeAutoTrustSsh({ - providerId: 'cursor', - workspacePath: '/remote/worktree', - ctx, - files, - }); - - const markerPath = '/home/remote-user/.cursor/projects/remote-worktree/.workspace-trusted'; - expect(remoteFs.exists).toHaveBeenCalledWith(markerPath); - expect(remoteFs.writeText).toHaveBeenCalledWith(markerPath, expect.any(String)); - - const marker = JSON.parse(String(vi.mocked(remoteFs.writeText).mock.calls[0][1])); - expect(marker).toEqual({ - trustedAt: expect.any(String), - workspacePath: '/remote/worktree', - trustMethod: 'emdash-auto-trust', - }); - }); - - it('refuses to auto-trust relative ssh workspace paths', async () => { - const service = makeService(); - const remoteFs = makeRemoteFs(); - const files = makeFilesRuntime({ fs: remoteFs }); - const ctx = makeCtx(); - - await service.maybeAutoTrustSsh({ - providerId: 'cursor', - workspacePath: 'relative/worktree', - ctx, - files, - }); - - expect(remoteFs.realPath).not.toHaveBeenCalled(); - expect(remoteFs.exists).not.toHaveBeenCalled(); - expect(remoteFs.writeText).not.toHaveBeenCalled(); - expect(ctx.exec).not.toHaveBeenCalled(); - expect(mockWarn).toHaveBeenCalledWith( - 'CursorTrustService: refusing to auto-trust non-absolute workspace path', - { path: 'relative/worktree' } - ); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts deleted file mode 100644 index 89ae19b989..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/cursor-trust-service.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { promises as fs } from 'node:fs'; -import path from 'node:path'; -import { isFileNotFoundException, type IFileSystem } from '@emdash/core/files'; -import { err, ok, type Result } from '@emdash/shared'; -import { appSettingsService } from '@main/core/settings/settings-service'; -import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; -import { log } from '@main/lib/logger'; -import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; -import { normalizeLocalWorkspacePath, normalizeSshWorkspacePath } from './workspace-trust-paths'; -import type { WorkspaceTrustLocalArgs, WorkspaceTrustSshArgs } from './workspace-trust-types'; - -const CURSOR_PROVIDER_ID: AgentProviderId = 'cursor'; -const CURSOR_DATA_DIR_NAME = '.cursor'; -const CURSOR_PROJECTS_DIR_NAME = 'projects'; -const CURSOR_TRUST_MARKER_NAME = '.workspace-trusted'; - -export class CursorTrustService { - constructor( - private readonly deps: { - getTaskSettings: () => Promise<{ autoTrustWorktrees: boolean }>; - } - ) {} - - async maybeAutoTrustLocal({ - providerId, - workspacePath, - homedir, - force = false, - }: WorkspaceTrustLocalArgs): Promise { - if (!(await this.shouldAutoTrust(providerId, force))) return; - - const normalizedWorkspacePath = normalizeLocalWorkspacePath( - workspacePath, - 'CursorTrustService' - ); - if (!normalizedWorkspacePath) return; - const dataDir = path.join(homedir, CURSOR_DATA_DIR_NAME); - const markerPath = path.join( - cursorProjectDir(normalizedWorkspacePath, dataDir, path), - CURSOR_TRUST_MARKER_NAME - ); - - await this.ensureTrusted(markerPath, normalizedWorkspacePath, { - exists: () => localExists(markerPath), - write: (content) => writeLocalMarker(markerPath, content), - }); - } - - async maybeAutoTrustSsh({ - providerId, - workspacePath, - ctx, - files, - force = false, - }: WorkspaceTrustSshArgs): Promise { - if (!(await this.shouldAutoTrust(providerId, force))) return; - - const normalizedWorkspacePath = await normalizeSshWorkspacePath( - files, - workspacePath, - 'CursorTrustService' - ); - if (!normalizedWorkspacePath) return; - const homeDir = await resolveRemoteHome(ctx); - const homeFs = files.fileSystem(); - if (!homeFs.success) { - log.warn('CursorTrustService: failed to open filesystem for auto-trust', { - path: normalizedWorkspacePath, - error: homeFs.error.message, - }); - return; - } - const dataDir = path.posix.join(homeDir, CURSOR_DATA_DIR_NAME); - const markerPath = path.posix.join( - cursorProjectDir(normalizedWorkspacePath, dataDir, path.posix), - CURSOR_TRUST_MARKER_NAME - ); - - await this.ensureTrusted(markerPath, normalizedWorkspacePath, { - exists: () => remoteExists(homeFs.data, markerPath), - write: (content) => writeRemoteText(homeFs.data, markerPath, content), - }); - } - - private async shouldAutoTrust(providerId: AgentProviderId, force: boolean): Promise { - if (providerId !== CURSOR_PROVIDER_ID) return false; - if (force) return true; - const { autoTrustWorktrees } = await this.deps.getTaskSettings(); - return autoTrustWorktrees; - } - - private async ensureTrusted( - markerPath: string, - workspacePath: string, - io: { - exists: () => Promise>; - write: (content: string) => Promise>; - } - ): Promise { - try { - const exists = await io.exists(); - if (!exists.success) { - log.warn('CursorTrustService: failed to check auto-trust marker', { - path: workspacePath, - markerPath, - error: exists.error.message, - }); - return; - } - if (exists.data) return; - - const written = await io.write( - JSON.stringify(createTrustMarker(workspacePath), null, 2) + '\n' - ); - if (!written.success) { - log.warn('CursorTrustService: failed to write auto-trust marker', { - path: workspacePath, - markerPath, - error: written.error.message, - }); - } - } catch (error: unknown) { - log.warn('CursorTrustService: failed to auto-trust worktree', { - path: workspacePath, - markerPath, - error: String(error), - }); - } - } -} - -type TrustIoError = { message: string }; -type TrustIoResult = Result; - -export const cursorTrustService = new CursorTrustService({ - getTaskSettings: () => appSettingsService.get('tasks'), -}); - -function createTrustMarker(workspacePath: string): Record { - return { - trustedAt: new Date().toISOString(), - workspacePath, - trustMethod: 'emdash-auto-trust', - }; -} - -function cursorProjectDir( - workspacePath: string, - dataDir: string, - pathImpl: Pick -): string { - // Mirrors Cursor CLI's workspace trust lookup: cursor-config Xq(workspacePath). - return pathImpl.join(dataDir, CURSOR_PROJECTS_DIR_NAME, slugifyPath(workspacePath)); -} - -function slugifyPath(value: string): string { - return value - .replace(/[^a-zA-Z0-9]/g, '-') - .replace(/-+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -async function localExists(markerPath: string): Promise> { - try { - await fs.access(markerPath); - return ok(true); - } catch (error: unknown) { - if (isFileNotFoundException(error)) return ok(false); - return err({ message: errorMessage(error) }); - } -} - -async function remoteExists( - remoteFs: Pick, - markerPath: string -): Promise> { - return remoteFs.exists(markerPath); -} - -async function writeLocalMarker(markerPath: string, content: string): Promise> { - try { - await fs.mkdir(path.dirname(markerPath), { recursive: true }); - await fs.writeFile(markerPath, content, 'utf8'); - return ok(); - } catch (error: unknown) { - return err({ message: errorMessage(error) }); - } -} - -async function writeRemoteText( - remoteFs: Pick, - absPath: string, - content: string -): Promise> { - const result = await remoteFs.writeText(absPath, content); - return result.success ? ok() : result; -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts deleted file mode 100644 index a2aff73296..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-paths.ts +++ /dev/null @@ -1,42 +0,0 @@ -import path from 'node:path'; -import type { IFilesRuntime } from '@main/core/runtime/types'; -import { log } from '@main/lib/logger'; - -export function normalizeLocalWorkspacePath( - workspacePath: string, - serviceName: string -): string | null { - if (!path.isAbsolute(workspacePath)) { - log.warn(`${serviceName}: refusing to auto-trust non-absolute workspace path`, { - path: workspacePath, - }); - return null; - } - - return path.normalize(workspacePath); -} - -export async function normalizeSshWorkspacePath( - files: IFilesRuntime, - workspacePath: string, - serviceName: string -): Promise { - if (!files.path.isAbsolute(workspacePath)) { - log.warn(`${serviceName}: refusing to auto-trust non-absolute workspace path`, { - path: workspacePath, - }); - return null; - } - - const opened = files.fileSystem(); - if (!opened.success) { - log.warn(`${serviceName}: failed to open filesystem for workspace trust`, { - path: workspacePath, - error: opened.error.message, - }); - return null; - } - - const realPath = await opened.data.realPath(workspacePath); - return realPath.success ? realPath.data : path.posix.normalize(workspacePath); -} diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts deleted file mode 100644 index ff6282acb7..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { IFilesRuntime } from '@main/core/runtime/types'; - -vi.mock('@main/core/settings/settings-service', () => ({ - appSettingsService: { get: vi.fn() }, -})); - -import { WorkspaceTrustService } from './workspace-trust-service'; - -function makeProvider() { - return { - maybeAutoTrustLocal: vi.fn().mockResolvedValue(undefined), - maybeAutoTrustSsh: vi.fn().mockResolvedValue(undefined), - }; -} - -function makeCtx(): IExecutionContext { - return { - root: undefined, - supportsLocalSpawn: false, - exec: vi.fn(), - execStreaming: vi.fn(), - dispose: vi.fn(), - }; -} - -function makeFilesRuntime(): IFilesRuntime { - return { fileSystem: vi.fn() } as unknown as IFilesRuntime; -} - -describe('WorkspaceTrustService', () => { - it('delegates local workspace trust to each provider', async () => { - const first = makeProvider(); - const second = makeProvider(); - const service = new WorkspaceTrustService([first, second]); - const args = { - providerId: 'cursor' as const, - workspacePath: '/tmp/worktree', - homedir: '/home/local-user', - force: true, - }; - - await service.maybeAutoTrustLocal(args); - - expect(first.maybeAutoTrustLocal).toHaveBeenCalledWith(args); - expect(second.maybeAutoTrustLocal).toHaveBeenCalledWith(args); - }); - - it('delegates ssh workspace trust to each provider', async () => { - const first = makeProvider(); - const second = makeProvider(); - const service = new WorkspaceTrustService([first, second]); - const args = { - providerId: 'cursor' as const, - workspacePath: '/remote/worktree', - ctx: makeCtx(), - files: makeFilesRuntime(), - force: true, - }; - - await service.maybeAutoTrustSsh(args); - - expect(first.maybeAutoTrustSsh).toHaveBeenCalledWith(args); - expect(second.maybeAutoTrustSsh).toHaveBeenCalledWith(args); - }); -}); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts deleted file mode 100644 index f0b0adfa92..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-service.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { claudeTrustService } from './claude-trust-service'; -import { cursorTrustService } from './cursor-trust-service'; -import type { - WorkspaceTrustLocalArgs, - WorkspaceTrustProvider, - WorkspaceTrustSshArgs, -} from './workspace-trust-types'; - -export class WorkspaceTrustService { - constructor(private readonly providers: readonly WorkspaceTrustProvider[]) {} - - async maybeAutoTrustLocal(args: WorkspaceTrustLocalArgs): Promise { - for (const provider of this.providers) { - await provider.maybeAutoTrustLocal(args); - } - } - - async maybeAutoTrustSsh(args: WorkspaceTrustSshArgs): Promise { - for (const provider of this.providers) { - await provider.maybeAutoTrustSsh(args); - } - } -} - -export const workspaceTrustService = new WorkspaceTrustService([ - claudeTrustService, - cursorTrustService, -]); diff --git a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts b/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts deleted file mode 100644 index ec3cfe3716..0000000000 --- a/apps/emdash-desktop/src/main/core/agent-hooks/workspace-trust-types.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { IExecutionContext } from '@main/core/execution-context/types'; -import type { IFilesRuntime } from '@main/core/runtime/types'; -import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; - -export type WorkspaceTrustLocalArgs = { - providerId: AgentProviderId; - workspacePath: string; - homedir: string; - force?: boolean; -}; - -export type WorkspaceTrustSshArgs = { - providerId: AgentProviderId; - workspacePath: string; - ctx: IExecutionContext; - files: IFilesRuntime; - force?: boolean; -}; - -export type WorkspaceTrustProvider = { - maybeAutoTrustLocal(args: WorkspaceTrustLocalArgs): Promise; - maybeAutoTrustSsh(args: WorkspaceTrustSshArgs): Promise; -}; diff --git a/apps/emdash-desktop/src/main/core/agents/plugin-fs.test.ts b/apps/emdash-desktop/src/main/core/agents/plugin-fs.test.ts new file mode 100644 index 0000000000..a028bfbb3d --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agents/plugin-fs.test.ts @@ -0,0 +1,44 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createPluginFs } from './plugin-fs'; + +describe('createPluginFs', () => { + let root: string; + + beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'plugin-fs-test-')); + }); + + afterEach(async () => { + await fs.rm(root, { recursive: true, force: true }); + }); + + it('returns null when reading a missing file', async () => { + const pluginFs = createPluginFs(root); + await expect(pluginFs.read('missing.json')).resolves.toBeNull(); + }); + + it('throws on non-not-found read errors instead of masking them as null', async () => { + const pluginFs = createPluginFs(root); + await fs.mkdir(path.join(root, 'a-directory')); + + await expect(pluginFs.read('a-directory')).rejects.toThrow(); + }); + + it('writes atomically without leaving tmp files behind', async () => { + const pluginFs = createPluginFs(root); + + await pluginFs.write('nested/config.json', '{"ok":true}'); + + await expect(pluginFs.read('nested/config.json')).resolves.toBe('{"ok":true}'); + const entries = await fs.readdir(path.join(root, 'nested')); + expect(entries).toEqual(['config.json']); + }); + + it('rejects path escapes', async () => { + const pluginFs = createPluginFs(root); + await expect(pluginFs.write('../escape.txt', 'nope')).rejects.toThrow(/path escape/); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/agents/plugin-fs.ts b/apps/emdash-desktop/src/main/core/agents/plugin-fs.ts index b59d1d0c6f..bcf405ad00 100644 --- a/apps/emdash-desktop/src/main/core/agents/plugin-fs.ts +++ b/apps/emdash-desktop/src/main/core/agents/plugin-fs.ts @@ -1,6 +1,8 @@ +import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import { dirname, join, resolve, sep } from 'node:path'; import type { PluginFs } from '@emdash/core/agents/plugins'; +import { isFileNotFoundException } from '@emdash/core/files'; /** * Create a CLIAgentPluginFs scoped to a given root directory. @@ -23,15 +25,23 @@ export function createPluginFs(root: string): PluginFs { async read(path: string): Promise { try { return await fs.readFile(resolveSafe(path), 'utf-8'); - } catch { - return null; + } catch (error: unknown) { + if (isFileNotFoundException(error)) return null; + throw error; } }, async write(path: string, content: string): Promise { const abs = resolveSafe(path); await fs.mkdir(dirname(abs), { recursive: true }); - await fs.writeFile(abs, content, 'utf-8'); + const tmpPath = `${abs}.${randomUUID()}.tmp`; + try { + await fs.writeFile(tmpPath, content, 'utf-8'); + await fs.rename(tmpPath, abs); + } catch (error: unknown) { + await fs.rm(tmpPath, { force: true }).catch(() => {}); + throw error; + } }, async delete(path: string): Promise { diff --git a/apps/emdash-desktop/src/main/core/agents/remote-plugin-fs.ts b/apps/emdash-desktop/src/main/core/agents/remote-plugin-fs.ts new file mode 100644 index 0000000000..136aef537c --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agents/remote-plugin-fs.ts @@ -0,0 +1,83 @@ +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import type { PluginFs } from '@emdash/core/agents/plugins'; +import { isFileNotFoundError, type IFileSystem } from '@emdash/core/files'; +import type { IExecutionContext } from '@main/core/execution-context/types'; + +const MAX_PLUGIN_READ_BYTES = 2 * 1024 * 1024; + +/** + * Create a PluginFs scoped to a remote root directory. + * All paths are resolved relative to root; path-escape attempts throw. + */ +export function createRemotePluginFs( + ctx: IExecutionContext, + remoteFs: IFileSystem, + root: string +): PluginFs { + const absRoot = normalizeRoot(root); + + function resolveSafe(value: string): string { + const normalized = value.replace(/\\/g, '/'); + const abs = path.posix.normalize(path.posix.join(absRoot, normalized)); + const rootWithSep = absRoot.endsWith('/') ? absRoot : `${absRoot}/`; + const absWithSep = abs.endsWith('/') ? abs : `${abs}/`; + if (!absWithSep.startsWith(rootWithSep) && abs !== absRoot) { + throw new Error(`Remote plugin fs: path escape attempt: ${value}`); + } + return abs; + } + + return { + async read(value: string): Promise { + const abs = resolveSafe(value); + const result = await remoteFs.readText(abs, { maxBytes: MAX_PLUGIN_READ_BYTES }); + if (result.success) return result.data.content; + if (isFileNotFoundError(result.error)) return null; + throw new Error(`Remote plugin fs: failed to read ${abs}: ${result.error.message}`); + }, + + async write(value: string, content: string): Promise { + const abs = resolveSafe(value); + const tmpPath = `${abs}.${randomUUID()}.tmp`; + + try { + await ctx.exec('mkdir', ['-p', path.posix.dirname(abs)]); + const written = await remoteFs.writeText(tmpPath, content); + if (!written.success) throw new Error(written.error.message); + await ctx.exec('mv', [tmpPath, abs]); + } catch (error) { + try { + await ctx.exec('rm', ['-f', tmpPath]); + } catch {} + throw error; + } + }, + + async delete(value: string): Promise { + await ctx.exec('rm', ['-f', resolveSafe(value)]); + }, + + async exists(value: string): Promise { + const result = await remoteFs.exists(resolveSafe(value)); + return result.success ? result.data : false; + }, + + async list(value: string): Promise { + try { + const { stdout } = await ctx.exec('ls', ['-A', resolveSafe(value)]); + return stdout.split('\n').filter(Boolean); + } catch { + return []; + } + }, + }; +} + +function normalizeRoot(root: string): string { + const normalized = path.posix.normalize(root.replace(/\\/g, '/')); + if (!path.posix.isAbsolute(normalized)) { + throw new Error(`Remote plugin fs root must be absolute: ${root}`); + } + return normalized; +} diff --git a/apps/emdash-desktop/src/main/core/agents/workspace-trust-target.ts b/apps/emdash-desktop/src/main/core/agents/workspace-trust-target.ts new file mode 100644 index 0000000000..3301b627ef --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agents/workspace-trust-target.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; +import type { PluginFs } from '@emdash/core/agents/plugins'; +import type { IFileSystem } from '@emdash/core/files'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import { log } from '@main/lib/logger'; +import { createPluginFs } from './plugin-fs'; +import { createRemotePluginFs } from './remote-plugin-fs'; + +export type WorkspaceTrustHost = + | { kind: 'local'; homedir: string } + | { kind: 'ssh'; ctx: IExecutionContext; files: IFilesRuntime }; + +export type TrustTarget = { + fs: PluginFs; + lockKey: string; + workspacePath: string; +}; + +export async function resolveTrustTarget( + host: WorkspaceTrustHost, + workspacePath: string +): Promise { + if (host.kind === 'local') { + const normalizedPath = normalizeLocalWorkspacePath(workspacePath); + if (!normalizedPath) return null; + return { + fs: createPluginFs(host.homedir), + lockKey: `local:${path.resolve(host.homedir)}`, + workspacePath: normalizedPath, + }; + } + + if (!isAbsoluteSshWorkspacePath(host.files, workspacePath)) return null; + + const opened = host.files.fileSystem(); + if (!opened.success) { + log.warn('WorkspaceTrust: failed to open filesystem for workspace trust', { + path: workspacePath, + error: opened.error.message, + }); + return null; + } + + const normalizedPath = await normalizeSshWorkspacePath(opened.data, workspacePath); + if (!normalizedPath) return null; + const homeDir = await resolveRemoteHome(host.ctx); + return { + fs: createRemotePluginFs(host.ctx, opened.data, homeDir), + lockKey: `ssh:${homeDir}`, + workspacePath: normalizedPath, + }; +} + +function normalizeLocalWorkspacePath(workspacePath: string): string | null { + if (!path.isAbsolute(workspacePath)) { + log.warn('WorkspaceTrust: refusing to auto-trust non-absolute workspace path', { + path: workspacePath, + }); + return null; + } + + return path.normalize(workspacePath); +} + +function isAbsoluteSshWorkspacePath(files: IFilesRuntime, workspacePath: string): boolean { + if (!files.path.isAbsolute(workspacePath)) { + log.warn('WorkspaceTrust: refusing to auto-trust non-absolute workspace path', { + path: workspacePath, + }); + return false; + } + + return true; +} + +async function normalizeSshWorkspacePath( + remoteFs: IFileSystem, + workspacePath: string +): Promise { + const realPath = await remoteFs.realPath(workspacePath); + return realPath.success ? realPath.data : path.posix.normalize(workspacePath); +} diff --git a/apps/emdash-desktop/src/main/core/agents/workspace-trust.test.ts b/apps/emdash-desktop/src/main/core/agents/workspace-trust.test.ts new file mode 100644 index 0000000000..fb456961e0 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agents/workspace-trust.test.ts @@ -0,0 +1,259 @@ +import path from 'node:path'; +import type { ITrustBehavior } from '@emdash/core/agents/plugins'; +import type { FileError, IFileSystem } from '@emdash/core/files'; +import { err, ok } from '@emdash/shared'; +import { describe, expect, it, vi } from 'vitest'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import type { IFilesRuntime } from '@main/core/runtime/types'; +import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import { WorkspaceTrustService } from './workspace-trust'; + +const mockWarn = vi.hoisted(() => vi.fn()); + +vi.mock('@main/lib/logger', () => ({ + log: { + warn: mockWarn, + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +// Mocked so importing the module (which wires the singleton) stays free of +// DB and plugin-registry side effects; tests construct their own instances. +vi.mock('@main/core/settings/settings-service', () => ({ + appSettingsService: { get: vi.fn() }, +})); + +vi.mock('./plugin-registry', () => ({ + getPlugin: vi.fn(() => ({ behavior: {} })), +})); + +function makeService(overrides: { + getTaskSettings?: () => Promise<{ autoTrustWorktrees: boolean }>; + getTrustBehavior?: (providerId: AgentProviderId) => ITrustBehavior | undefined; +}): WorkspaceTrustService { + return new WorkspaceTrustService({ + getTaskSettings: overrides.getTaskSettings ?? vi.fn(async () => ({ autoTrustWorktrees: true })), + getTrustBehavior: + overrides.getTrustBehavior ?? + vi.fn(() => ({ + trustWorkspace: vi.fn(async () => {}), + })), + }); +} + +function makeCtx(): IExecutionContext { + return { + root: undefined, + supportsLocalSpawn: false, + // resolveRemoteHome runs `sh -c 'printf %s "$HOME"'` through this. + exec: vi.fn(async (cmd: string) => + cmd === 'sh' ? { stdout: '/home/remote-user', stderr: '' } : { stdout: '', stderr: '' } + ), + execStreaming: vi.fn(), + dispose: vi.fn(), + } as unknown as IExecutionContext; +} + +function makeFilesRuntime( + fs: Partial, + fileSystem: IFilesRuntime['fileSystem'] = vi.fn(() => ok(fs as unknown as IFileSystem)) +) { + return { + path: { + join: (...parts: string[]) => path.posix.join(...parts), + dirname: (value: string) => path.posix.dirname(value), + basename: (value: string) => path.posix.basename(value), + isAbsolute: (value: string) => path.posix.isAbsolute(value), + relative: (from: string, to: string) => path.posix.relative(from, to), + contains: (parent: string, child: string) => { + const rel = path.posix.relative(parent, child); + return ( + rel === '' || (rel !== '..' && !rel.startsWith('../') && !path.posix.isAbsolute(rel)) + ); + }, + }, + openTree: vi.fn(), + watchChanges: vi.fn(), + fileSystem, + dispose: vi.fn(), + } as unknown as IFilesRuntime; +} + +describe('WorkspaceTrustService', () => { + it('skips when auto-trust is disabled', async () => { + const trustWorkspace = vi.fn(); + const service = makeService({ + getTaskSettings: vi.fn(async () => ({ autoTrustWorktrees: false })), + getTrustBehavior: vi.fn(() => ({ trustWorkspace })), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: '/tmp/worktree', + host: { kind: 'local', homedir: '/home/local-user' }, + }); + + expect(trustWorkspace).not.toHaveBeenCalled(); + }); + + it('trusts when forced even if auto-trust is disabled', async () => { + const trustWorkspace = vi.fn(); + const getTaskSettings = vi.fn(async () => ({ autoTrustWorktrees: false })); + const service = makeService({ + getTaskSettings, + getTrustBehavior: vi.fn(() => ({ trustWorkspace })), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: '/tmp/worktree', + host: { kind: 'local', homedir: '/home/local-user' }, + force: true, + }); + + expect(getTaskSettings).not.toHaveBeenCalled(); + expect(trustWorkspace).toHaveBeenCalledWith(expect.any(Object), { + workspacePath: path.normalize('/tmp/worktree'), + }); + }); + + it('no-ops when the provider has no trust behavior', async () => { + const getTaskSettings = vi.fn(async () => ({ autoTrustWorktrees: true })); + const service = makeService({ + getTaskSettings, + getTrustBehavior: vi.fn(() => undefined), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: '/tmp/worktree', + host: { kind: 'local', homedir: '/home/local-user' }, + }); + + expect(getTaskSettings).not.toHaveBeenCalled(); + }); + + it('refuses non-absolute workspace paths', async () => { + const trustWorkspace = vi.fn(); + const service = makeService({ + getTrustBehavior: vi.fn(() => ({ trustWorkspace })), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: 'relative/worktree', + host: { kind: 'local', homedir: '/home/local-user' }, + }); + + expect(trustWorkspace).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'WorkspaceTrust: refusing to auto-trust non-absolute workspace path', + { path: 'relative/worktree' } + ); + }); + + it('logs and swallows trust behavior failures', async () => { + const service = makeService({ + getTrustBehavior: vi.fn(() => ({ + trustWorkspace: vi.fn(async () => { + throw new Error('boom'); + }), + })), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: '/tmp/worktree', + host: { kind: 'local', homedir: '/home/local-user' }, + }); + + expect(mockWarn).toHaveBeenCalledWith('WorkspaceTrust: failed to auto-trust worktree', { + providerId: 'claude', + path: path.normalize('/tmp/worktree'), + error: 'Error: boom', + }); + }); + + it('logs a specific warning when the SSH filesystem cannot be opened', async () => { + const trustWorkspace = vi.fn(); + const fileSystem: IFilesRuntime['fileSystem'] = vi.fn(() => + err({ + type: 'fs-error', + path: '/remote/worktree', + message: 'connection closed', + } satisfies FileError) + ); + const files = makeFilesRuntime({}, fileSystem); + const service = makeService({ + getTrustBehavior: vi.fn(() => ({ trustWorkspace })), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: '/remote/worktree', + host: { kind: 'ssh', ctx: makeCtx(), files }, + }); + + expect(fileSystem).toHaveBeenCalledTimes(1); + expect(trustWorkspace).not.toHaveBeenCalled(); + expect(mockWarn).toHaveBeenCalledWith( + 'WorkspaceTrust: failed to open filesystem for workspace trust', + { + path: '/remote/worktree', + error: 'connection closed', + } + ); + }); + + it('writes SSH config atomically through the remote PluginFs', async () => { + const behavior: ITrustBehavior = { + trustWorkspace: async (fs, ctx) => { + await fs.write( + '.claude.json', + JSON.stringify({ + projects: { + [ctx.workspacePath]: { + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + }, + }, + }) + ); + }, + }; + const remoteFs = { + realPath: vi.fn(async () => ok('/remote/worktree')), + writeText: vi.fn(async (_value: string, content: string) => + ok({ bytesWritten: content.length }) + ), + }; + const fileSystem: IFilesRuntime['fileSystem'] = vi.fn(() => + ok(remoteFs as unknown as IFileSystem) + ); + const files = makeFilesRuntime(remoteFs, fileSystem); + const ctx = makeCtx(); + const service = makeService({ + getTrustBehavior: vi.fn(() => behavior), + }); + + await service.maybeAutoTrust({ + providerId: 'claude', + workspacePath: '/remote/worktree', + host: { kind: 'ssh', ctx, files }, + }); + + expect(fileSystem).toHaveBeenCalledTimes(1); + expect(remoteFs.writeText).toHaveBeenCalledTimes(1); + const [tmpPath, content] = remoteFs.writeText.mock.calls[0]; + expect(tmpPath).toContain('/home/remote-user/.claude.json.'); + expect(tmpPath).toContain('.tmp'); + expect(JSON.parse(String(content)).projects['/remote/worktree']).toEqual({ + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + }); + expect(ctx.exec).toHaveBeenCalledWith('mkdir', ['-p', '/home/remote-user']); + expect(ctx.exec).toHaveBeenCalledWith('mv', [tmpPath, '/home/remote-user/.claude.json']); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/agents/workspace-trust.ts b/apps/emdash-desktop/src/main/core/agents/workspace-trust.ts new file mode 100644 index 0000000000..941f3845ab --- /dev/null +++ b/apps/emdash-desktop/src/main/core/agents/workspace-trust.ts @@ -0,0 +1,77 @@ +import type { ITrustBehavior } from '@emdash/core/agents/plugins'; +import { appSettingsService } from '@main/core/settings/settings-service'; +import { log } from '@main/lib/logger'; +import type { AgentProviderId } from '@shared/core/agents/agent-provider-registry'; +import { getPlugin } from './plugin-registry'; +import { resolveTrustTarget, type WorkspaceTrustHost } from './workspace-trust-target'; + +export type WorkspaceTrustArgs = { + providerId: AgentProviderId; + workspacePath: string; + host: WorkspaceTrustHost; + force?: boolean; +}; + +type WorkspaceTrustDeps = { + getTaskSettings: () => Promise<{ autoTrustWorktrees: boolean }>; + getTrustBehavior: (providerId: AgentProviderId) => ITrustBehavior | undefined; +}; + +export class WorkspaceTrustService { + private readonly homeLocks = new Map>(); + + constructor(private readonly deps: WorkspaceTrustDeps) {} + + /** + * Mark the workspace as trusted in the provider's config so the agent CLI + * skips its trust prompt. No-op unless the provider has a trust behavior + * and auto-trust is enabled (or `force` is set, e.g. for auto-approve runs). + */ + async maybeAutoTrust({ + providerId, + workspacePath, + host, + force = false, + }: WorkspaceTrustArgs): Promise { + const behavior = this.deps.getTrustBehavior(providerId); + if (!behavior) return; + if (!(await this.shouldAutoTrust(force))) return; + + const target = await resolveTrustTarget(host, workspacePath); + if (!target) return; + + await this.withHomeLock(target.lockKey, async () => { + try { + await behavior.trustWorkspace(target.fs, { workspacePath: target.workspacePath }); + } catch (error: unknown) { + log.warn('WorkspaceTrust: failed to auto-trust worktree', { + providerId, + path: target.workspacePath, + error: String(error), + }); + } + }); + } + + private async shouldAutoTrust(force: boolean): Promise { + if (force) return true; + const { autoTrustWorktrees } = await this.deps.getTaskSettings(); + return autoTrustWorktrees; + } + + /** + * Serialize trust writes per home directory: trust configs are shared + * read-merge-write files, so concurrent writers would lose updates. + */ + private withHomeLock(lockKey: string, fn: () => Promise): Promise { + const prev = this.homeLocks.get(lockKey) ?? Promise.resolve(); + const next = prev.then(fn, fn); + this.homeLocks.set(lockKey, next); + return next; + } +} + +export const workspaceTrustService = new WorkspaceTrustService({ + getTaskSettings: () => appSettingsService.get('tasks'), + getTrustBehavior: (providerId) => getPlugin(providerId).behavior.trust, +}); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts index fa2006fe2f..d06a0dd71f 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/conversation-provider-respawn.test.ts @@ -36,10 +36,9 @@ vi.mock('@main/core/agent-hooks/agent-hook-service', () => ({ }, })); -vi.mock('@main/core/agent-hooks/workspace-trust-service', () => ({ +vi.mock('@main/core/agents/workspace-trust', () => ({ workspaceTrustService: { - maybeAutoTrustLocal: vi.fn(), - maybeAutoTrustSsh: vi.fn(), + maybeAutoTrust: vi.fn(), }, })); diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts index 319a1d8d18..67fa394dcd 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/local-conversation.ts @@ -1,8 +1,8 @@ import { homedir } from 'node:os'; import { agentHookService } from '@main/core/agent-hooks/agent-hook-service'; import { ensureHooksInstalled } from '@main/core/agent-hooks/hook-config-service'; -import { workspaceTrustService } from '@main/core/agent-hooks/workspace-trust-service'; import { getPlugin } from '@main/core/agents/plugin-registry'; +import { workspaceTrustService } from '@main/core/agents/workspace-trust'; import { ConversationSessionSupervisor } from '@main/core/conversations/conversation-session-supervisor'; import { resolveAgentSessionCommandArgs } from '@main/core/conversations/resolve-agent-session-command'; import { @@ -117,10 +117,10 @@ export class LocalConversationProvider implements ConversationProvider { let spill: SpillLargePromptResult | undefined; try { - await workspaceTrustService.maybeAutoTrustLocal({ + await workspaceTrustService.maybeAutoTrust({ providerId: conversation.providerId, workspacePath: this.taskPath, - homedir: homedir(), + host: { kind: 'local', homedir: homedir() }, force: conversation.autoApprove === true, }); await ensureHooksInstalled({ diff --git a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts index 1b77bad169..66627d7d67 100644 --- a/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts +++ b/apps/emdash-desktop/src/main/core/conversations/impl/ssh-conversation.ts @@ -1,5 +1,5 @@ -import { workspaceTrustService } from '@main/core/agent-hooks/workspace-trust-service'; import { getPlugin } from '@main/core/agents/plugin-registry'; +import { workspaceTrustService } from '@main/core/agents/workspace-trust'; import { ConversationSessionSupervisor } from '@main/core/conversations/conversation-session-supervisor'; import { resolveAgentSessionCommandArgs } from '@main/core/conversations/resolve-agent-session-command'; import type { ConversationProvider } from '@main/core/conversations/types'; @@ -117,11 +117,10 @@ export class SshConversationProvider implements ConversationProvider { if (!spawnToken) return; try { - await workspaceTrustService.maybeAutoTrustSsh({ + await workspaceTrustService.maybeAutoTrust({ providerId: conversation.providerId, workspacePath: this.taskPath, - ctx: this.ctx, - files: this.filesRuntime, + host: { kind: 'ssh', ctx: this.ctx, files: this.filesRuntime }, force: conversation.autoApprove === true, }); diff --git a/packages/core/src/agents/plugins/capabilities/trust.ts b/packages/core/src/agents/plugins/capabilities/trust.ts new file mode 100644 index 0000000000..3be88ccaa5 --- /dev/null +++ b/packages/core/src/agents/plugins/capabilities/trust.ts @@ -0,0 +1,24 @@ +import z from 'zod'; +import { definePluginCapability } from '../../../lib/plugins/capability'; +import type { PluginFs } from '../../runtime/fs'; + +export type TrustContext = { + workspacePath: string; +}; + +export type ITrustBehavior = { + trustWorkspace(fs: PluginFs, ctx: TrustContext): Promise; +}; + +export const trustCapability = definePluginCapability()( + 'trust', + z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('supported'), + }), + z.object({ + kind: z.literal('none'), + }), + ]), + { kind: 'none' } +); diff --git a/packages/core/src/agents/plugins/helpers/index.ts b/packages/core/src/agents/plugins/helpers/index.ts index ff48d0e5ce..e8eef9bd76 100644 --- a/packages/core/src/agents/plugins/helpers/index.ts +++ b/packages/core/src/agents/plugins/helpers/index.ts @@ -9,3 +9,4 @@ export * from './mcp'; export * from './merge'; export * from './parse-hook-event'; export * from './standard-command'; +export * from './trust'; diff --git a/packages/core/src/agents/plugins/helpers/trust.ts b/packages/core/src/agents/plugins/helpers/trust.ts new file mode 100644 index 0000000000..8ff426dea8 --- /dev/null +++ b/packages/core/src/agents/plugins/helpers/trust.ts @@ -0,0 +1,46 @@ +import type { PluginFs } from '../../runtime/fs'; +import type { ITrustBehavior } from '../capabilities/trust'; + +export type JsonConfigTrustBehaviorOptions = { + configName: string; + withTrustedPath: ( + config: Record, + workspacePath: string + ) => Record | null; +}; + +export function buildJsonConfigTrustBehavior({ + configName, + withTrustedPath, +}: JsonConfigTrustBehaviorOptions): ITrustBehavior { + return { + async trustWorkspace(fs: PluginFs, ctx): Promise { + const config = parseConfig(await fs.read(configName), configName); + + const nextConfig = withTrustedPath(config, ctx.workspacePath); + if (!nextConfig) return; + + await fs.write(configName, JSON.stringify(nextConfig, null, 2) + '\n'); + }, + }; +} + +function parseConfig(raw: string | null, configName: string): Record { + if (!raw || raw.trim() === '') return {}; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error: unknown) { + throw new Error(`refusing to overwrite corrupt config ${configName}: ${String(error)}`); + } + + if (!isPlainObject(parsed)) { + throw new Error(`refusing to overwrite non-object config root in ${configName}`); + } + return parsed; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/agents/plugins/index.ts b/packages/core/src/agents/plugins/index.ts index c0dfb747e4..9da9b8bb7b 100644 --- a/packages/core/src/agents/plugins/index.ts +++ b/packages/core/src/agents/plugins/index.ts @@ -11,6 +11,7 @@ import { modelsCapability } from './capabilities/models'; import { pluginsCapability } from './capabilities/plugins'; import { promptCapability } from './capabilities/prompt'; import { sessionsCapability } from './capabilities/sessions'; +import { trustCapability } from './capabilities/trust'; export const PLUGIN_CAPABILITIES = { acp: acpCapability, @@ -23,6 +24,7 @@ export const PLUGIN_CAPABILITIES = { plugins: pluginsCapability, prompt: promptCapability, sessions: sessionsCapability, + trust: trustCapability, } as const; export type Capabilities = typeof PLUGIN_CAPABILITIES; @@ -77,6 +79,7 @@ export type { IHooksBehavior } from './capabilities/hooks'; export type { IMcpBehavior, McpServerRegistration } from './capabilities/mcp'; export type { IPlugins } from './capabilities/plugins'; export type { ISessionsBehavior } from './capabilities/sessions'; +export type { ITrustBehavior, TrustContext } from './capabilities/trust'; // Typed registry factory export { createPluginRegistry } from '../../lib/plugins/registry'; diff --git a/packages/plugins/src/agents/impl/claude/index.ts b/packages/plugins/src/agents/impl/claude/index.ts index 8a842ffebb..b3c9abb510 100644 --- a/packages/plugins/src/agents/impl/claude/index.ts +++ b/packages/plugins/src/agents/impl/claude/index.ts @@ -10,6 +10,7 @@ import { import { enrichClaudeUpdate } from './acp-transform'; import { buildClaudeHookConfig } from './hooks'; import { icon } from './icon'; +import { buildClaudeTrustBehavior } from './trust'; const _require = createRequire(import.meta.url); @@ -110,6 +111,9 @@ export const plugin = definePlugin( sessions: { kind: 'resumable', }, + trust: { + kind: 'supported', + }, }, { icon } ); @@ -148,4 +152,5 @@ export const provider = registerPluginBehavior(plugin, { }, hooks: buildClaudeHookConfig(), mcp: passthroughMcpAdapter('.claude.json'), + trust: buildClaudeTrustBehavior(), }); diff --git a/packages/plugins/src/agents/impl/claude/trust.test.ts b/packages/plugins/src/agents/impl/claude/trust.test.ts new file mode 100644 index 0000000000..d3babbbe27 --- /dev/null +++ b/packages/plugins/src/agents/impl/claude/trust.test.ts @@ -0,0 +1,82 @@ +import type { PluginFs } from '@emdash/core/agents/plugins'; +import { describe, expect, it, vi } from 'vitest'; +import { provider } from './index'; + +function createMemoryFs(initial: Record = {}): PluginFs & { + files: Map; + writes: Array<{ path: string; content: string }>; +} { + const files = new Map(Object.entries(initial)); + const writes: Array<{ path: string; content: string }> = []; + + return { + files, + writes, + read: async (path) => files.get(path) ?? null, + write: vi.fn(async (path, content) => { + writes.push({ path, content }); + files.set(path, content); + }), + delete: async (path) => { + files.delete(path); + }, + exists: async (path) => files.has(path), + list: async () => [], + }; +} + +describe('Claude trust behavior', () => { + it('writes trusted project config when missing', async () => { + const fs = createMemoryFs(); + + await provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }); + + expect(fs.writes).toHaveLength(1); + expect(fs.writes[0].path).toBe('.claude.json'); + expect(JSON.parse(fs.writes[0].content)).toEqual({ + projects: { + '/tmp/worktree': { + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + }, + }, + }); + }); + + it('does not rewrite when the project is already trusted', async () => { + const fs = createMemoryFs({ + '.claude.json': JSON.stringify({ + projects: { + '/tmp/worktree': { + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + }, + }, + }), + }); + + await provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }); + + expect(fs.writes).toHaveLength(0); + }); + + it('refuses to overwrite corrupt config', async () => { + const fs = createMemoryFs({ '.claude.json': '{ invalid json' }); + + await expect( + provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }) + ).rejects.toThrow(/corrupt config \.claude\.json/); + + expect(fs.writes).toHaveLength(0); + }); + + it('refuses to overwrite non-object config root', async () => { + const fs = createMemoryFs({ '.claude.json': JSON.stringify([1, 2, 3]) }); + + await expect( + provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }) + ).rejects.toThrow(/non-object config root/); + + expect(fs.writes).toHaveLength(0); + }); +}); diff --git a/packages/plugins/src/agents/impl/claude/trust.ts b/packages/plugins/src/agents/impl/claude/trust.ts new file mode 100644 index 0000000000..54c5b2d070 --- /dev/null +++ b/packages/plugins/src/agents/impl/claude/trust.ts @@ -0,0 +1,37 @@ +import { buildJsonConfigTrustBehavior } from '@emdash/core/agents/plugins/helpers'; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function withClaudeTrustedProject( + config: Record, + workspacePath: string +): Record | null { + const projects = isPlainObject(config.projects) ? config.projects : {}; + const existing = isPlainObject(projects[workspacePath]) ? projects[workspacePath] : {}; + + const alreadyTrusted = + existing['hasTrustDialogAccepted'] === true && + existing['hasCompletedProjectOnboarding'] === true; + if (alreadyTrusted) return null; + + return { + ...config, + projects: { + ...projects, + [workspacePath]: { + ...existing, + hasTrustDialogAccepted: true, + hasCompletedProjectOnboarding: true, + }, + }, + }; +} + +export function buildClaudeTrustBehavior() { + return buildJsonConfigTrustBehavior({ + configName: '.claude.json', + withTrustedPath: withClaudeTrustedProject, + }); +} diff --git a/packages/plugins/src/agents/impl/copilot/index.ts b/packages/plugins/src/agents/impl/copilot/index.ts index bc7e5341d0..29ef118615 100644 --- a/packages/plugins/src/agents/impl/copilot/index.ts +++ b/packages/plugins/src/agents/impl/copilot/index.ts @@ -6,6 +6,7 @@ import { } from '@emdash/core/agents/plugins/helpers'; import { buildCopilotHookConfig } from './hooks'; import { icon } from './icon'; +import { buildCopilotTrustBehavior } from './trust'; export const plugin = definePlugin( { @@ -37,6 +38,9 @@ export const plugin = definePlugin( sessions: { kind: 'resumable', }, + trust: { + kind: 'supported', + }, }, { icon } ); @@ -54,4 +58,5 @@ export const provider = registerPluginBehavior(plugin, { }, hooks: buildCopilotHookConfig(), mcp: copilotMcpAdapter(), + trust: buildCopilotTrustBehavior(), }); diff --git a/packages/plugins/src/agents/impl/copilot/trust.test.ts b/packages/plugins/src/agents/impl/copilot/trust.test.ts new file mode 100644 index 0000000000..5f8a7b1ff9 --- /dev/null +++ b/packages/plugins/src/agents/impl/copilot/trust.test.ts @@ -0,0 +1,53 @@ +import type { PluginFs } from '@emdash/core/agents/plugins'; +import { describe, expect, it, vi } from 'vitest'; +import { provider } from './index'; + +function createMemoryFs(initial: Record = {}): PluginFs & { + files: Map; + writes: Array<{ path: string; content: string }>; +} { + const files = new Map(Object.entries(initial)); + const writes: Array<{ path: string; content: string }> = []; + + return { + files, + writes, + read: async (path) => files.get(path) ?? null, + write: vi.fn(async (path, content) => { + writes.push({ path, content }); + files.set(path, content); + }), + delete: async (path) => { + files.delete(path); + }, + exists: async (path) => files.has(path), + list: async () => [], + }; +} + +describe('Copilot trust behavior', () => { + it('adds trusted folders while preserving existing entries', async () => { + const fs = createMemoryFs({ + '.copilot/config.json': JSON.stringify({ trustedFolders: ['/already/trusted'] }), + }); + + await provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }); + + expect(fs.writes).toHaveLength(1); + expect(fs.writes[0].path).toBe('.copilot/config.json'); + expect(JSON.parse(fs.writes[0].content).trustedFolders).toEqual([ + '/already/trusted', + '/tmp/worktree', + ]); + }); + + it('does not rewrite when the folder is already trusted', async () => { + const fs = createMemoryFs({ + '.copilot/config.json': JSON.stringify({ trustedFolders: ['/tmp/worktree'] }), + }); + + await provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }); + + expect(fs.writes).toHaveLength(0); + }); +}); diff --git a/packages/plugins/src/agents/impl/copilot/trust.ts b/packages/plugins/src/agents/impl/copilot/trust.ts new file mode 100644 index 0000000000..70f584f927 --- /dev/null +++ b/packages/plugins/src/agents/impl/copilot/trust.ts @@ -0,0 +1,21 @@ +import { buildJsonConfigTrustBehavior } from '@emdash/core/agents/plugins/helpers'; + +function withCopilotTrustedFolder( + config: Record, + workspacePath: string +): Record | null { + const trustedFolders = Array.isArray(config.trustedFolders) ? config.trustedFolders : []; + if (trustedFolders.includes(workspacePath)) return null; + + return { + ...config, + trustedFolders: [...trustedFolders, workspacePath], + }; +} + +export function buildCopilotTrustBehavior() { + return buildJsonConfigTrustBehavior({ + configName: '.copilot/config.json', + withTrustedPath: withCopilotTrustedFolder, + }); +} diff --git a/packages/plugins/src/agents/impl/cursor/index.ts b/packages/plugins/src/agents/impl/cursor/index.ts index 66c36ed95b..2c6052f461 100644 --- a/packages/plugins/src/agents/impl/cursor/index.ts +++ b/packages/plugins/src/agents/impl/cursor/index.ts @@ -1,6 +1,7 @@ import { definePlugin, registerPluginBehavior } from '@emdash/core/agents/plugins'; import { buildStandardCommand, cursorMcpAdapter } from '@emdash/core/agents/plugins/helpers'; import { icon } from './icon'; +import { buildCursorTrustBehavior } from './trust'; export const plugin = definePlugin( { @@ -53,6 +54,9 @@ export const plugin = definePlugin( sessions: { kind: 'resumable', }, + trust: { + kind: 'supported', + }, }, { icon } ); @@ -67,4 +71,5 @@ export const provider = registerPluginBehavior(plugin, { }), }, mcp: cursorMcpAdapter(), + trust: buildCursorTrustBehavior(), }); diff --git a/packages/plugins/src/agents/impl/cursor/trust.test.ts b/packages/plugins/src/agents/impl/cursor/trust.test.ts new file mode 100644 index 0000000000..f13b63d000 --- /dev/null +++ b/packages/plugins/src/agents/impl/cursor/trust.test.ts @@ -0,0 +1,55 @@ +import type { PluginFs } from '@emdash/core/agents/plugins'; +import { describe, expect, it, vi } from 'vitest'; +import { provider } from './index'; + +function createMemoryFs(initial: Record = {}): PluginFs & { + files: Map; + writes: Array<{ path: string; content: string }>; +} { + const files = new Map(Object.entries(initial)); + const writes: Array<{ path: string; content: string }> = []; + + return { + files, + writes, + read: async (path) => files.get(path) ?? null, + write: vi.fn(async (path, content) => { + writes.push({ path, content }); + files.set(path, content); + }), + delete: async (path) => { + files.delete(path); + }, + exists: async (path) => files.has(path), + list: async () => [], + }; +} + +describe('Cursor trust behavior', () => { + it('writes the Cursor workspace trust marker using the CLI slug derivation', async () => { + const fs = createMemoryFs(); + + await provider.behavior.trust!.trustWorkspace(fs, { + workspacePath: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', + }); + + expect(fs.writes).toHaveLength(1); + expect(fs.writes[0].path).toBe( + '.cursor/projects/Users-janburzinski-emdash-worktrees-emdash-official-tough-falcons-notice/.workspace-trusted' + ); + expect(JSON.parse(fs.writes[0].content)).toEqual({ + trustedAt: expect.any(String), + workspacePath: '/Users/janburzinski/emdash/worktrees/emdash-official/tough-falcons-notice', + trustMethod: 'emdash-auto-trust', + }); + }); + + it('does not rewrite the marker when it already exists', async () => { + const markerPath = '.cursor/projects/tmp-worktree/.workspace-trusted'; + const fs = createMemoryFs({ [markerPath]: 'existing' }); + + await provider.behavior.trust!.trustWorkspace(fs, { workspacePath: '/tmp/worktree' }); + + expect(fs.writes).toHaveLength(0); + }); +}); diff --git a/packages/plugins/src/agents/impl/cursor/trust.ts b/packages/plugins/src/agents/impl/cursor/trust.ts new file mode 100644 index 0000000000..c967eff03f --- /dev/null +++ b/packages/plugins/src/agents/impl/cursor/trust.ts @@ -0,0 +1,36 @@ +import type { ITrustBehavior, PluginFs, TrustContext } from '@emdash/core/agents/plugins'; + +export function buildCursorTrustBehavior(): ITrustBehavior { + return { + async trustWorkspace(fs: PluginFs, ctx: TrustContext): Promise { + const markerPath = [ + '.cursor', + 'projects', + slugifyPath(ctx.workspacePath), + '.workspace-trusted', + ].join('/'); + + if (await fs.exists(markerPath)) return; + + await fs.write( + markerPath, + JSON.stringify(createTrustMarker(ctx.workspacePath), null, 2) + '\n' + ); + }, + }; +} + +function slugifyPath(value: string): string { + return value + .replace(/[^a-zA-Z0-9]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function createTrustMarker(workspacePath: string): Record { + return { + trustedAt: new Date().toISOString(), + workspacePath, + trustMethod: 'emdash-auto-trust', + }; +}