Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/lib/project-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/lib/project-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,15 @@ const ProjectSourceSchema: z.ZodType<ProjectSource> = z.discriminatedUnion('kind
mountName: z.string(),
workspaceDir: z.string(),
gitDetected: z.boolean().optional(),
readOnly: z.boolean().optional(),
}),
z.object({
kind: z.literal('git-remote'),
id: z.string(),
mountName: z.string(),
repoUrl: z.string(),
defaultBranch: z.string().optional(),
readOnly: z.boolean().optional(),
}),
]) as unknown as z.ZodType<ProjectSource>;

Expand Down
17 changes: 13 additions & 4 deletions src/main/agent-process.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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',
},
]);
});

Expand All @@ -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',
});
Expand Down
9 changes: 7 additions & 2 deletions src/main/agent-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
| {
Expand Down Expand Up @@ -737,7 +737,12 @@ export class AgentProcess {
// One ``--source <json>`` per source — omni serve's argparse uses
// ``action="append"``, so each emits a fresh dict.
for (const s of arg.sources) {
const desc: Record<string, unknown> = { kind: s.kind, mountName: s.mountName };
const desc: Record<string, unknown> = {
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;
}
Expand Down
39 changes: 38 additions & 1 deletion src/main/process-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions src/main/process-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ export class ProcessManager {
mountName,
kind: this.directoryHasGit(workspaceDir) ? 'local-git' : 'local',
workspaceDir,
writable: true,
...(isLauncherOwnedDir(workspaceDir) ? { launcherOwned: true } : {}),
},
];
Expand All @@ -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;
Expand All @@ -503,6 +505,7 @@ export class ProcessManager {
mountName: source.mountName,
kind: this.directoryHasGit(source.workspaceDir) ? 'local-git' : 'local',
workspaceDir: source.workspaceDir,
writable: !source.readOnly,
};
}

Expand Down Expand Up @@ -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);
Expand Down
22 changes: 17 additions & 5 deletions src/renderer/features/Projects/AddSourceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
AnimatedDialog,
Button,
Caption1,
Checkbox,
DialogBody,
DialogContent,
DialogFooter,
Expand Down Expand Up @@ -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<string | null>(null);

const [error, setError] = useState<string | null>(null);
Expand All @@ -112,6 +114,7 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog
setRepoUrl('');
setUrlMount('');
setBranch('');
setReadOnly(false);
setError(null);
}
}, [open, githubLinked]);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -216,8 +220,11 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog
}, []);
const handleLocalMount = useCallback((e: React.ChangeEvent<HTMLInputElement>) => 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<HTMLInputElement>) => setRepoUrl(e.target.value), []);
Expand All @@ -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 });
Expand Down Expand Up @@ -354,6 +361,11 @@ export const AddSourceDialog = memo(({ open, onClose, project }: AddSourceDialog
</>
)}

<div className={styles.field}>
<Checkbox checked={readOnly} onCheckedChange={setReadOnly} label="Read-only source" />
<span className={styles.hint}>Omni’s file editor can inspect this source but cannot change its files.</span>
</div>

{error && (
<div role="alert" style={{ color: 'var(--colorPaletteRedForeground1)' }}>
{error}
Expand Down
30 changes: 27 additions & 3 deletions src/renderer/features/Projects/EditSourceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);
const [error, setError] = useState<string | null>(null);
Expand All @@ -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]);
Expand Down Expand Up @@ -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));
Expand All @@ -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 }
Expand Down Expand Up @@ -207,6 +226,11 @@ export const EditSourceDialog = memo(({ open, onClose, project, source }: EditSo
</div>
)}

<div className={styles.field}>
<Checkbox checked={readOnly} onCheckedChange={setReadOnly} label="Read-only source" />
<span className={styles.hint}>Omni’s file editor can inspect this source but cannot change its files.</span>
</div>

{error && (
<div role="alert" style={{ color: 'var(--colorPaletteRedForeground1)' }}>
{error}
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/features/Projects/source-draft.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading