diff --git a/src/lib/project-files.test.ts b/src/lib/project-files.test.ts index 618df56..0841140 100644 --- a/src/lib/project-files.test.ts +++ b/src/lib/project-files.test.ts @@ -425,6 +425,44 @@ describe('project config roundtrip', () => { } }); + it('roundtrips read-only source policy', () => { + const p = makeProject({ + sources: [ + { + id: 'src-readonly', + mountName: 'reference', + kind: 'local', + workspaceDir: '/home/user/reference', + readOnly: true, + }, + ], + }); + const text = serializeProjectConfig(p); + const parsed = parseProjectConfig(text); + + expect(text).toContain('readOnly: true'); + expect(parsed.isOk() && parsed.value.sources[0]?.readOnly).toBe(true); + }); + + it('keeps old source records without readOnly valid', () => { + const text = [ + 'id: p1', + 'label: Legacy', + 'slug: legacy', + 'createdAt: 2026-04-12T00:00:00Z', + 'sources:', + ' - id: src-1', + ' mountName: code', + ' kind: local', + ' workspaceDir: /home/user/code', + '', + ].join('\n'); + const parsed = parseProjectConfig(text); + + expect(parsed.isOk()).toBe(true); + expect(parsed.isOk() && parsed.value.sources[0]?.readOnly).toBeUndefined(); + }); + it('rejects an unknown source kind', () => { const text = 'id: p1\nlabel: X\nslug: x\ncreatedAt: 2026-04-12T00:00:00Z\nsources:\n - kind: carrier-pigeon\n address: home\n'; diff --git a/src/lib/project-files.ts b/src/lib/project-files.ts index dc7641d..ff8a97c 100644 --- a/src/lib/project-files.ts +++ b/src/lib/project-files.ts @@ -211,6 +211,7 @@ const ProjectSourceSchema: z.ZodType = z.discriminatedUnion('kind mountName: z.string(), workspaceDir: z.string(), gitDetected: z.boolean().optional(), + readOnly: z.boolean().optional(), }), z.object({ kind: z.literal('git-remote'), @@ -218,6 +219,7 @@ const ProjectSourceSchema: z.ZodType = z.discriminatedUnion('kind mountName: z.string(), repoUrl: z.string(), defaultBranch: z.string().optional(), + readOnly: z.boolean().optional(), }), ]) as unknown as z.ZodType; diff --git a/src/main/agent-process.test.ts b/src/main/agent-process.test.ts index bb6f298..124554f 100644 --- a/src/main/agent-process.test.ts +++ b/src/main/agent-process.test.ts @@ -218,7 +218,9 @@ describe('AgentProcess (serve mode)', () => { expect(args).toContain('json'); expect(args).toContain('--workspace'); expect(args[args.indexOf('--workspace') + 1]).toBe('/test/workspace'); - expect(sourceDescriptors(args)).toEqual([{ kind: 'local-git', mountName: 'launcher', path: '/test/workspace' }]); + expect(sourceDescriptors(args)).toEqual([ + { kind: 'local-git', mountName: 'launcher', writable: true, path: '/test/workspace' }, + ]); }); it('emits multiple --source descriptors for a multi-source project', async () => { @@ -233,9 +235,15 @@ describe('AgentProcess (serve mode)', () => { }); const [, args] = spawnCall(0); expect(sourceDescriptors(args)).toEqual([ - { kind: 'local-git', mountName: 'launcher', path: '/repos/launcher' }, - { kind: 'local-git', mountName: 'omni-code', path: '/repos/omni-code' }, - { kind: 'git-remote', mountName: 'omniagents', repoUrl: 'https://github.com/me/omniagents.git', ref: 'main' }, + { kind: 'local-git', mountName: 'launcher', writable: true, path: '/repos/launcher' }, + { kind: 'local-git', mountName: 'omni-code', writable: true, path: '/repos/omni-code' }, + { + kind: 'git-remote', + mountName: 'omniagents', + writable: true, + repoUrl: 'https://github.com/me/omniagents.git', + ref: 'main', + }, ]); }); @@ -248,6 +256,7 @@ describe('AgentProcess (serve mode)', () => { expect(sourceDescriptors(spawnCall(0)[1])[0]).toEqual({ kind: 'git-remote', mountName: 'bar', + writable: true, repoUrl: 'https://github.com/foo/bar.git', ref: 'main', }); diff --git a/src/main/agent-process.ts b/src/main/agent-process.ts index e174896..a875728 100644 --- a/src/main/agent-process.ts +++ b/src/main/agent-process.ts @@ -71,7 +71,7 @@ export type AgentProcessMode = 'serve' | 'compute'; * they keep the explicit "Apply to my folder" gate. Launcher-side only; the * ``--source`` descriptor sent to omni serve does not include it. */ -export type AgentProcessSource = { mountName: string } & ( +export type AgentProcessSource = { mountName: string; writable?: boolean } & ( | { kind: 'local-git'; workspaceDir: string; ref?: string; launcherOwned?: boolean } | { kind: 'local'; workspaceDir: string; launcherOwned?: boolean } | { @@ -737,7 +737,12 @@ export class AgentProcess { // One ``--source `` per source — omni serve's argparse uses // ``action="append"``, so each emits a fresh dict. for (const s of arg.sources) { - const desc: Record = { kind: s.kind, mountName: s.mountName }; + const desc: Record = { + kind: s.kind, + mountName: s.mountName, + // Older callers predate source-level policy and remain writable. + writable: s.writable ?? true, + }; if (s.kind === 'local' || s.kind === 'local-git') { desc.path = s.workspaceDir; } diff --git a/src/main/process-manager.test.ts b/src/main/process-manager.test.ts index ff1242d..edb5e38 100644 --- a/src/main/process-manager.test.ts +++ b/src/main/process-manager.test.ts @@ -60,7 +60,7 @@ vi.mock('node:child_process', async () => { // --------------------------------------------------------------------------- import { execFileSync } from 'node:child_process'; -import { mkdtempSync } from 'node:fs'; +import { mkdirSync, mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -219,6 +219,43 @@ describe('ProcessManager', () => { }); }); + describe('source writability', () => { + it('maps missing readOnly to writable and readOnly to non-writable for every source kind', async () => { + const localDir = mkdtempSync(path.join(tmpdir(), 'omni-source-local-')); + const localGitDir = mkdtempSync(path.join(tmpdir(), 'omni-source-git-')); + mkdirSync(path.join(localGitDir, '.git')); + const project: Project = { + id: 'proj_sources', + label: 'Sources', + slug: 'sources', + createdAt: 0, + sources: [ + { id: 'local', mountName: 'local', kind: 'local', workspaceDir: localDir }, + { id: 'local-git', mountName: 'local-git', kind: 'local', workspaceDir: localGitDir, readOnly: true }, + { + id: 'remote', + mountName: 'remote', + kind: 'git-remote', + repoUrl: 'https://github.com/acme/reference.git', + readOnly: true, + }, + ], + }; + const { pm } = makePm({ storeData: { projects: [project] } }); + + await pm.start('tab-1', { workspaceDir: localDir, projectId: project.id }); + + const arg = hoisted.agentProcessInstances[0]!.start.mock.calls[0]![0] as { + sources: Array<{ kind: string; writable?: boolean }>; + }; + expect(arg.sources.map(({ kind, writable }) => ({ kind, writable }))).toEqual([ + { kind: 'local', writable: true }, + { kind: 'local-git', writable: false }, + { kind: 'git-remote', writable: false }, + ]); + }); + }); + describe('getStatus', () => { it('returns uninitialized for unknown processId', () => { const { pm } = makePm(); diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index d36ae3e..c658604 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -478,6 +478,7 @@ export class ProcessManager { mountName, kind: this.directoryHasGit(workspaceDir) ? 'local-git' : 'local', workspaceDir, + writable: true, ...(isLauncherOwnedDir(workspaceDir) ? { launcherOwned: true } : {}), }, ]; @@ -490,6 +491,7 @@ export class ProcessManager { mountName: source.mountName, kind: 'git-remote', repoUrl: source.repoUrl, + writable: !source.readOnly, }; if (source.defaultBranch) { result.ref = source.defaultBranch; @@ -503,6 +505,7 @@ export class ProcessManager { mountName: source.mountName, kind: this.directoryHasGit(source.workspaceDir) ? 'local-git' : 'local', workspaceDir: source.workspaceDir, + writable: !source.readOnly, }; } @@ -574,6 +577,7 @@ export class ProcessManager { mountName, kind: this.directoryHasGit(extra.workspaceDir) ? 'local-git' : 'local', workspaceDir: extra.workspaceDir, + writable: true, ...(isLauncherOwnedDir(extra.workspaceDir) ? { launcherOwned: true } : {}), }); mountedDirs.add(resolved); diff --git a/src/renderer/features/Projects/AddSourceDialog.tsx b/src/renderer/features/Projects/AddSourceDialog.tsx index b03dc82..948a732 100644 --- a/src/renderer/features/Projects/AddSourceDialog.tsx +++ b/src/renderer/features/Projects/AddSourceDialog.tsx @@ -19,6 +19,7 @@ import { AnimatedDialog, Button, Caption1, + Checkbox, DialogBody, DialogContent, DialogFooter, @@ -99,6 +100,7 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog const [repoUrl, setRepoUrl] = useState(''); const [urlMount, setUrlMount] = useState(''); const [branch, setBranch] = useState(''); + const [readOnly, setReadOnly] = useState(false); const [addTokenHost, setAddTokenHost] = useState(null); const [error, setError] = useState(null); @@ -112,6 +114,7 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog setRepoUrl(''); setUrlMount(''); setBranch(''); + setReadOnly(false); setError(null); } }, [open, githubLinked]); @@ -171,10 +174,11 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog kind: 'git-remote', repoUrl: repo.cloneUrl, defaultBranch: repo.defaultBranch, + readOnly, }; void addDraft({ ...draft, mountName: deriveMountName(draft) }, true); }, - [addDraft] + [addDraft, readOnly] ); // Provider adapters for the generic RepoPicker. @@ -216,8 +220,11 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog }, []); const handleLocalMount = useCallback((e: React.ChangeEvent) => setLocalMount(e.target.value), []); const handleAddLocal = useCallback(() => { - void addDraft({ ...emptyLocalDraft(), kind: 'local', workspaceDir: localDir, mountName: localMount }, false); - }, [addDraft, localDir, localMount]); + void addDraft( + { ...emptyLocalDraft(), kind: 'local', workspaceDir: localDir, mountName: localMount, readOnly }, + false + ); + }, [addDraft, localDir, localMount, readOnly]); // Git URL const handleRepoUrl = useCallback((e: React.ChangeEvent) => setRepoUrl(e.target.value), []); @@ -226,10 +233,10 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog const closeAddToken = useCallback(() => setAddTokenHost(null), []); const handleAddUrl = useCallback(() => { void addDraft( - { ...emptyLocalDraft(), kind: 'git-remote', repoUrl, defaultBranch: branch, mountName: urlMount }, + { ...emptyLocalDraft(), kind: 'git-remote', repoUrl, defaultBranch: branch, mountName: urlMount, readOnly }, false ); - }, [addDraft, repoUrl, branch, urlMount]); + }, [addDraft, repoUrl, branch, urlMount, readOnly]); const localPlaceholder = deriveMountName({ ...emptyLocalDraft(), workspaceDir: localDir }); const urlPlaceholder = deriveMountName({ ...emptyLocalDraft(), kind: 'git-remote', repoUrl }); @@ -354,6 +361,11 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog )} +
+ + Omni’s file editor can inspect this source but cannot change its files. +
+ {error && (
{error} diff --git a/src/renderer/features/Projects/EditSourceDialog.tsx b/src/renderer/features/Projects/EditSourceDialog.tsx index 4453f5c..6c816a2 100644 --- a/src/renderer/features/Projects/EditSourceDialog.tsx +++ b/src/renderer/features/Projects/EditSourceDialog.tsx @@ -15,7 +15,16 @@ import { makeStyles, shorthands, tokens } from '@fluentui/react-components'; import { useStore } from '@nanostores/react'; import { memo, useCallback, useEffect, useState } from 'react'; -import { AnimatedDialog, Button, DialogBody, DialogContent, DialogFooter, DialogHeader, Input } from '@/renderer/ds'; +import { + AnimatedDialog, + Button, + Checkbox, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + Input, +} from '@/renderer/ds'; import { GitCredentialDialog } from '@/renderer/features/SettingsModal/GitCredentialDialog'; import { DirectoryBrowserDialog } from '@/renderer/features/Tickets/DirectoryBrowserDialog'; import { persistedStoreApi } from '@/renderer/services/store'; @@ -66,6 +75,7 @@ export const EditSourceDialog = memo(({ open, onClose, project, source }: EditSo const [workspaceDir, setWorkspaceDir] = useState(''); const [repoUrl, setRepoUrl] = useState(''); const [branch, setBranch] = useState(''); + const [readOnly, setReadOnly] = useState(false); const [browseDir, setBrowseDir] = useState(false); const [addTokenHost, setAddTokenHost] = useState(null); const [error, setError] = useState(null); @@ -79,6 +89,7 @@ export const EditSourceDialog = memo(({ open, onClose, project, source }: EditSo setWorkspaceDir(source.kind === 'local' ? source.workspaceDir : ''); setRepoUrl(source.kind === 'git-remote' ? source.repoUrl : ''); setBranch(source.kind === 'git-remote' ? (source.defaultBranch ?? '') : ''); + setReadOnly(source.readOnly ?? false); setError(null); } }, [open, source]); @@ -116,13 +127,21 @@ export const EditSourceDialog = memo(({ open, onClose, project, source }: EditSo const trimmedBranch = branch.trim(); const next: ProjectSource = source.kind === 'local' - ? { id: source.id, mountName, kind: 'local', workspaceDir: path } + ? { + id: source.id, + mountName, + kind: 'local', + workspaceDir: path, + ...(source.gitDetected !== undefined ? { gitDetected: source.gitDetected } : {}), + ...(readOnly ? { readOnly: true } : {}), + } : { id: source.id, mountName, kind: 'git-remote', repoUrl: path, ...(trimmedBranch ? { defaultBranch: trimmedBranch } : {}), + ...(readOnly ? { readOnly: true } : {}), }; const existingIdentities = new Set(project.sources.filter((s) => s.id !== source.id).map(sourceIdentityKey)); @@ -143,7 +162,7 @@ export const EditSourceDialog = memo(({ open, onClose, project, source }: EditSo } finally { setSaving(false); } - }, [isLocal, workspaceDir, repoUrl, mount, branch, project.id, project.sources, source, onClose]); + }, [isLocal, workspaceDir, repoUrl, mount, branch, readOnly, project.id, project.sources, source, onClose]); const mountPlaceholder = deriveMountName( isLocal ? { ...emptyLocalDraft(), workspaceDir } : { ...emptyLocalDraft(), kind: 'git-remote', repoUrl } @@ -207,6 +226,11 @@ export const EditSourceDialog = memo(({ open, onClose, project, source }: EditSo
)} +
+ + Omni’s file editor can inspect this source but cannot change its files. +
+ {error && (
{error} diff --git a/src/renderer/features/Projects/source-draft.test.ts b/src/renderer/features/Projects/source-draft.test.ts new file mode 100644 index 0000000..c17ea06 --- /dev/null +++ b/src/renderer/features/Projects/source-draft.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { draftsToSources, emptyLocalDraft } from './source-draft'; + +describe('source drafts', () => { + it('defaults new sources to writable', () => { + const result = draftsToSources([{ ...emptyLocalDraft(), workspaceDir: '/repo/code', mountName: 'code' }]); + + expect(result.ok && result.sources[0]?.readOnly).toBeUndefined(); + }); + + it('preserves a read-only selection', () => { + const result = draftsToSources([ + { ...emptyLocalDraft(), workspaceDir: '/repo/reference', mountName: 'reference', readOnly: true }, + ]); + + expect(result.ok && result.sources[0]?.readOnly).toBe(true); + }); +}); diff --git a/src/renderer/features/Projects/source-draft.ts b/src/renderer/features/Projects/source-draft.ts index 6cefad8..b7f7563 100644 --- a/src/renderer/features/Projects/source-draft.ts +++ b/src/renderer/features/Projects/source-draft.ts @@ -18,6 +18,7 @@ export type SourceDraft = { workspaceDir: string; repoUrl: string; defaultBranch: string; + readOnly: boolean; }; /** Auto-derive a mountName slug from a path or repo URL. */ @@ -42,7 +43,16 @@ const newSourceId = (): string => Math.random().toString(36).slice(2, 18); /** Construct a fresh empty local-source draft. */ export function emptyLocalDraft(): SourceDraft { - return { uid: nextUid(), id: null, kind: 'local', mountName: '', workspaceDir: '', repoUrl: '', defaultBranch: '' }; + return { + uid: nextUid(), + id: null, + kind: 'local', + mountName: '', + workspaceDir: '', + repoUrl: '', + defaultBranch: '', + readOnly: false, + }; } /** @@ -65,7 +75,7 @@ export function draftsToSources( return { ok: false, error: `Duplicate mount name: "${mountName}". Each source needs a unique name.` }; } seenMountNames.add(mountName); - const baseFields = { id: d.id ?? newSourceId(), mountName }; + const baseFields = { id: d.id ?? newSourceId(), mountName, ...(d.readOnly ? { readOnly: true } : {}) }; if (d.kind === 'local') { sources.push({ ...baseFields, kind: 'local', workspaceDir: path }); } else { diff --git a/src/shared/types.ts b/src/shared/types.ts index 81c6753..40a5998 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1973,6 +1973,8 @@ export type ProjectSource = ( ) & { id: string; mountName: string; + /** When true, filesystem tools must not mutate this source. Missing means writable. */ + readOnly?: boolean; }; /** diff --git a/tests/e2e/specs/read-only-source.spec.ts b/tests/e2e/specs/read-only-source.spec.ts new file mode 100644 index 0000000..5aab513 --- /dev/null +++ b/tests/e2e/specs/read-only-source.spec.ts @@ -0,0 +1,44 @@ +import type { Page } from '@playwright/test'; +import { expect, test } from 'tests/e2e/fixtures/test'; + +async function createProject(page: Page, projectName: string): Promise { + await page.getByRole('button', { name: 'New project' }).click(); + await page.getByRole('textbox', { name: 'Project name' }).fill(projectName); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(page.getByText(projectName, { exact: true }).last()).toBeVisible(); +} + +async function openProject(page: Page, projectName: string): Promise { + await page.getByRole('tree', { name: 'Projects' }).getByRole('treeitem', { name: projectName }).click(); + await expect(page.getByText(projectName, { exact: true }).last()).toBeVisible(); +} + +async function openSourceEditor(page: Page): Promise { + await page.getByRole('button', { name: 'Source actions' }).click(); + await page.getByRole('menuitem', { name: 'Edit source' }).click(); + await expect(page.getByRole('dialog').filter({ hasText: 'Edit source' })).toBeVisible(); +} + +test.describe('read-only project sources', () => { + test('keeps a source read-only after editing and restart', async ({ app }) => { + const projectName = 'E2E Read-only Source'; + + await createProject(app.page, projectName); + await app.page.getByRole('button', { name: 'Add source' }).click(); + await expect(app.page.getByRole('dialog').filter({ hasText: 'Add source' })).toBeVisible(); + await app.page.getByRole('combobox', { name: 'Source type' }).selectOption('url'); + await app.page.getByRole('textbox', { name: 'Repo URL' }).fill('https://example.com/acme/reference.git'); + await app.page.getByRole('textbox', { name: 'Source mount name' }).fill('reference'); + await app.page.getByRole('checkbox', { name: 'Read-only source' }).check(); + await app.page.getByRole('button', { name: 'Add source' }).last().click(); + + await openSourceEditor(app.page); + await expect(app.page.getByRole('checkbox', { name: 'Read-only source' })).toBeChecked(); + await app.page.getByRole('button', { name: 'Cancel' }).click(); + + const restarted = await app.restart(); + await openProject(restarted, projectName); + await openSourceEditor(restarted); + await expect(restarted.getByRole('checkbox', { name: 'Read-only source' })).toBeChecked(); + }); +});