diff --git a/backend/src/index.ts b/backend/src/index.ts index 82fedfd2..877adbd8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -12,6 +12,7 @@ import { createHealthRoutes } from './routes/health' import { createTTSRoutes, cleanupExpiredCache } from './routes/tts'; import { createSTTRoutes } from './routes/stt' import { createFileRoutes } from './routes/files' +import { createFilesystemRoutes } from './routes/filesystem' import { createScheduleRoutes } from './routes/schedules' async function getAppVersion(): Promise { @@ -357,7 +358,8 @@ protectedApi.use('/*', requireAuth) protectedApi.route('/repos', createRepoRoutes(db, gitAuthService, scheduleService, openCodeClient, openCodeSupervisor)) protectedApi.route('/settings', createSettingsRoutes(db, gitAuthService, openCodeClient, openCodeSupervisor)) -protectedApi.route('/files', createFileRoutes()) + protectedApi.route('/files', createFileRoutes()) + protectedApi.route('/filesystem', createFilesystemRoutes()) protectedApi.route('/providers', createProvidersRoutes(db, openCodeClient, openCodeSupervisor)) protectedApi.route('/oauth', createOAuthRoutes(openCodeClient, openCodeSupervisor)) protectedApi.route('/tts', createTTSRoutes(db)) diff --git a/backend/src/routes/filesystem.test.ts b/backend/src/routes/filesystem.test.ts new file mode 100644 index 00000000..aa754f7e --- /dev/null +++ b/backend/src/routes/filesystem.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { createFilesystemRoutes } from './filesystem' + +let tmpRoot: string +let app: Hono +const originalBrowseRoot = process.env.REPO_BROWSE_ROOT + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-fs-route-')) + process.env.REPO_BROWSE_ROOT = tmpRoot + app = new Hono() + app.route('/filesystem', createFilesystemRoutes()) +}) + +afterEach(async () => { + if (originalBrowseRoot === undefined) { + delete process.env.REPO_BROWSE_ROOT + } else { + process.env.REPO_BROWSE_ROOT = originalBrowseRoot + } + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('GET /api/filesystem/browse', () => { + it('returns the directory listing for the root', async () => { + await fs.mkdir(path.join(tmpRoot, 'projects')) + + const res = await app.request('/filesystem/browse') + expect(res.status).toBe(200) + + const body = await res.json() as { isRoot: boolean; entries: { name: string }[] } + expect(body.isRoot).toBe(true) + expect(body.entries.map((e) => e.name)).toEqual(['projects']) + }) + + it('returns 403 for a path outside the root', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-fs-outside-')) + try { + const res = await app.request(`/filesystem/browse?path=${encodeURIComponent(outside)}`) + expect(res.status).toBe(403) + } finally { + await fs.rm(outside, { recursive: true, force: true }) + } + }) + + it('returns 404 for a missing directory', async () => { + const res = await app.request(`/filesystem/browse?path=${encodeURIComponent(path.join(tmpRoot, 'missing'))}`) + expect(res.status).toBe(404) + }) + + it('returns 501 when browsing is not configured', async () => { + delete process.env.REPO_BROWSE_ROOT + const res = await app.request('/filesystem/browse') + expect(res.status).toBe(501) + }) +}) diff --git a/backend/src/routes/filesystem.ts b/backend/src/routes/filesystem.ts new file mode 100644 index 00000000..9d350ca3 --- /dev/null +++ b/backend/src/routes/filesystem.ts @@ -0,0 +1,22 @@ +import { Hono } from 'hono' +import type { ContentfulStatusCode } from 'hono/utils/http-status' +import * as filesystemService from '../services/filesystem' +import { logger } from '../utils/logger' +import { getErrorMessage, getStatusCode } from '../utils/error-utils' + +export function createFilesystemRoutes() { + const app = new Hono() + + app.get('/browse', async (c) => { + try { + const requestedPath = c.req.query('path') + const result = await filesystemService.browseDirectory(requestedPath) + return c.json(result) + } catch (error: unknown) { + logger.error('Failed to browse directory:', error) + return c.json({ error: getErrorMessage(error) || 'Failed to browse directory' }, getStatusCode(error) as ContentfulStatusCode) + } + }) + + return app +} diff --git a/backend/src/services/filesystem.test.ts b/backend/src/services/filesystem.test.ts new file mode 100644 index 00000000..069b4d1b --- /dev/null +++ b/backend/src/services/filesystem.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { promises as fs } from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import { browseDirectory } from './filesystem' + +let tmpRoot: string +const originalBrowseRoot = process.env.REPO_BROWSE_ROOT + +beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-browse-')) + process.env.REPO_BROWSE_ROOT = tmpRoot +}) + +afterEach(async () => { + if (originalBrowseRoot === undefined) { + delete process.env.REPO_BROWSE_ROOT + } else { + process.env.REPO_BROWSE_ROOT = originalBrowseRoot + } + await fs.rm(tmpRoot, { recursive: true, force: true }) +}) + +describe('browseDirectory', () => { + it('lists only directories at the browse root and marks the root', async () => { + await fs.mkdir(path.join(tmpRoot, 'projects')) + await fs.mkdir(path.join(tmpRoot, 'archive')) + await fs.writeFile(path.join(tmpRoot, 'readme.txt'), 'ignored file') + + const result = await browseDirectory() + + expect(result.isRoot).toBe(true) + expect(result.parentPath).toBeNull() + expect(result.path).toBe(path.resolve(tmpRoot)) + expect(result.entries.map((e) => e.name)).toEqual(['archive', 'projects']) + }) + + it('marks git repositories', async () => { + const repoDir = path.join(tmpRoot, 'my-repo') + await fs.mkdir(repoDir) + await fs.mkdir(path.join(repoDir, '.git')) + await fs.mkdir(path.join(tmpRoot, 'plain')) + + const result = await browseDirectory() + + const repo = result.entries.find((e) => e.name === 'my-repo') + const plain = result.entries.find((e) => e.name === 'plain') + expect(repo?.isGitRepo).toBe(true) + expect(plain?.isGitRepo).toBe(false) + }) + + it('ignores hidden directories', async () => { + await fs.mkdir(path.join(tmpRoot, '.hidden')) + await fs.mkdir(path.join(tmpRoot, 'visible')) + + const result = await browseDirectory() + + expect(result.entries.map((e) => e.name)).toEqual(['visible']) + }) + + it('navigates into a subdirectory and exposes the parent path', async () => { + const sub = path.join(tmpRoot, 'level1') + await fs.mkdir(sub) + await fs.mkdir(path.join(sub, 'level2')) + + const result = await browseDirectory(sub) + + expect(result.isRoot).toBe(false) + expect(result.parentPath).toBe(path.resolve(tmpRoot)) + expect(result.entries.map((e) => e.name)).toEqual(['level2']) + }) + + it('rejects paths outside the browse root with 403', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-outside-')) + try { + await expect(browseDirectory(outside)).rejects.toMatchObject({ statusCode: 403 }) + } finally { + await fs.rm(outside, { recursive: true, force: true }) + } + }) + + it('rejects traversal above the root with 403', async () => { + await expect(browseDirectory(path.join(tmpRoot, '..'))).rejects.toMatchObject({ statusCode: 403 }) + }) + + it('rejects a symlink inside the root that escapes the root with 403', async () => { + const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'ocm-outside-')) + try { + const linkPath = path.join(tmpRoot, 'escape-link') + await fs.symlink(outside, linkPath) + + await expect(browseDirectory(linkPath)).rejects.toMatchObject({ statusCode: 403 }) + } finally { + await fs.rm(outside, { recursive: true, force: true }) + } + }) + + it('returns 404 for a non-existent directory', async () => { + await expect(browseDirectory(path.join(tmpRoot, 'nope'))).rejects.toMatchObject({ statusCode: 404 }) + }) + + it('returns 400 when the path is a file', async () => { + const filePath = path.join(tmpRoot, 'file.txt') + await fs.writeFile(filePath, 'data') + + await expect(browseDirectory(filePath)).rejects.toMatchObject({ statusCode: 400 }) + }) + + it('returns 501 when REPO_BROWSE_ROOT is not configured', async () => { + delete process.env.REPO_BROWSE_ROOT + await expect(browseDirectory()).rejects.toMatchObject({ statusCode: 501 }) + }) +}) diff --git a/backend/src/services/filesystem.ts b/backend/src/services/filesystem.ts new file mode 100644 index 00000000..ed741212 --- /dev/null +++ b/backend/src/services/filesystem.ts @@ -0,0 +1,89 @@ +import { promises as fs } from 'node:fs' +import path from 'node:path' +import { getBrowseRootPath } from '@opencode-manager/shared/config/env' +import type { BrowseDirectoryResponse, DirectoryEntry } from '@opencode-manager/shared/types' + +function getRoot(): string { + const configured = getBrowseRootPath() + if (!configured) { + throw { + message: 'Folder browsing is disabled. Set REPO_BROWSE_ROOT in the server environment to enable it.', + statusCode: 501, + } + } + return path.resolve(configured) +} + +async function resolveWithinRoot(root: string, requestedPath?: string): Promise { + const resolved = (!requestedPath || requestedPath.trim() === '') ? root : path.resolve(requestedPath) + + const realRoot = await fs.realpath(root) + let realResolved: string + try { + realResolved = await fs.realpath(resolved) + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException | undefined)?.code + if (code === 'ENOENT' || code === 'ENOTDIR') { + throw { message: 'Directory not found', statusCode: 404 } + } + throw err + } + + const rel = path.relative(realRoot, realResolved) + if (rel === '..' || rel.startsWith(`..${path.sep}`)) { + throw { message: 'Path is outside the allowed browse root', statusCode: 403 } + } + + return resolved +} + +async function isGitRepo(entryPath: string): Promise { + try { + const stats = await fs.lstat(path.join(entryPath, '.git')) + return stats.isDirectory() || stats.isFile() + } catch { + return false + } +} + +export async function browseDirectory(requestedPath?: string): Promise { + const root = getRoot() + const targetPath = await resolveWithinRoot(root, requestedPath) + + let stats + try { + stats = await fs.stat(targetPath) + } catch { + throw { message: 'Directory not found', statusCode: 404 } + } + + if (!stats.isDirectory()) { + throw { message: 'Path is not a directory', statusCode: 400 } + } + + const dirEntries = await fs.readdir(targetPath, { withFileTypes: true }) + const directories = dirEntries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + + const entries: DirectoryEntry[] = await Promise.all( + directories.map(async (entry) => { + const entryPath = path.join(targetPath, entry.name) + return { + name: entry.name, + path: entryPath, + isGitRepo: await isGitRepo(entryPath), + } + }) + ) + + entries.sort((a, b) => a.name.localeCompare(b.name)) + + const isRoot = targetPath === root + const parentPath = isRoot ? null : path.dirname(targetPath) + + return { + path: targetPath, + parentPath, + isRoot, + entries, + } +} diff --git a/frontend/src/api/filesystem.ts b/frontend/src/api/filesystem.ts new file mode 100644 index 00000000..994e5cc9 --- /dev/null +++ b/frontend/src/api/filesystem.ts @@ -0,0 +1,9 @@ +import { fetchWrapper } from './fetchWrapper' +import { API_BASE_URL } from '@/config' +import type { BrowseDirectoryResponse } from '@opencode-manager/shared/types' + +export async function browseDirectory(path?: string): Promise { + return fetchWrapper(`${API_BASE_URL}/api/filesystem/browse`, { + params: { path }, + }) +} diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx index d8a0254a..e29acc6e 100644 --- a/frontend/src/components/repo/AddRepoDialog.tsx +++ b/frontend/src/components/repo/AddRepoDialog.tsx @@ -5,7 +5,8 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { Loader2 } from 'lucide-react' +import { DirectoryPickerDialog } from './DirectoryPickerDialog' +import { Loader2, FolderSearch } from 'lucide-react' import { showToast } from '@/lib/toast' import { invalidateRepoListCaches } from '@/lib/queryInvalidation' import { getRepoBaseDirectoryName, getRepoDirectoryNameError, getRepoNameFromUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' @@ -25,6 +26,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { const [directoryName, setDirectoryName] = useState('') const [branch, setBranch] = useState('') const [skipSSHVerification, setSkipSSHVerification] = useState(false) + const [pickerOpen, setPickerOpen] = useState(false) const directoryTouched = useRef(false) const queryClient = useQueryClient() @@ -143,17 +145,17 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { return ( - + - + Add Repository
- + setRepoType(value as 'remote' | 'local' | 'folder')}> - + Remote Local Folder @@ -163,43 +165,67 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { {repoType === 'remote' ? (
- + handleRepoUrlChange(e.target.value)} disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[44px] text-base" /> -

+

Full URL or shorthand format (owner/repo for GitHub)

) : repoType === 'local' ? (
- - setLocalPath(e.target.value)} - disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" - /> -

+ +

+ setLocalPath(e.target.value)} + disabled={mutation.isPending} + className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[44px] text-base" + /> + +
+

Directory name for a new repo, or an absolute path to link an existing Git repo

) : (
- - setFolderPath(e.target.value)} - disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" - /> -

+ +

+ setFolderPath(e.target.value)} + disabled={mutation.isPending} + className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[44px] text-base" + /> + +
+

Scans the folder for nested Git repositories and links each one

@@ -207,13 +233,13 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { {showDirectoryName && (
- + handleDirectoryNameChange(e.target.value)} disabled={mutation.isPending} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[44px] text-base" /> {directoryNameError ? (

@@ -229,7 +255,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { {' '}Choose a different directory name to clone this fork.

) : ( -

+

Custom directory name for the cloned repository

)} @@ -237,15 +263,15 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { )}
- + setBranch(e.target.value)} disabled={mutation.isPending || repoType === 'folder'} - className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base" + className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[44px] text-base" /> -

+

{repoType === 'folder' ? 'Links each repository on its current branch' : branch @@ -263,13 +289,13 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { checked={skipSSHVerification} onChange={(e) => setSkipSSHVerification(e.target.checked)} disabled={mutation.isPending} - className="mt-1 h-5 w-5 rounded border-[#2a2a2a] bg-[#1a1a1a] text-blue-600 focus:ring-blue-600" + className="mt-1 h-5 w-5 rounded border-border bg-muted text-primary focus:ring-primary" />

-
@@ -279,7 +305,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
) } diff --git a/frontend/src/components/repo/DirectoryPickerDialog.tsx b/frontend/src/components/repo/DirectoryPickerDialog.tsx new file mode 100644 index 00000000..85ce387a --- /dev/null +++ b/frontend/src/components/repo/DirectoryPickerDialog.tsx @@ -0,0 +1,125 @@ +import { useState, useCallback, useEffect } from 'react' +import { useQuery } from '@tanstack/react-query' +import { browseDirectory } from '@/api/filesystem' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' +import { FetchError } from '@/api/fetchWrapper' +import { Folder, FolderGit2, ChevronUp, Loader2, FolderX } from 'lucide-react' + +interface DirectoryPickerDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + onSelect: (path: string) => void + title?: string +} + +export function DirectoryPickerDialog({ open, onOpenChange, onSelect, title = 'Select Folder' }: DirectoryPickerDialogProps) { + const [currentPath, setCurrentPath] = useState(undefined) + + useEffect(() => { + if (open) { + setCurrentPath(undefined) + } + }, [open]) + + const { data, isLoading, isError, error } = useQuery({ + queryKey: ['filesystem-browse', currentPath], + queryFn: () => browseDirectory(currentPath), + enabled: open, + retry: false, + }) + + const isDisabled = error instanceof FetchError && error.statusCode === 501 + + const navigateTo = useCallback((path: string) => { + setCurrentPath(path) + }, []) + + const handleSelect = useCallback(() => { + if (data?.path) { + onSelect(data.path) + onOpenChange(false) + } + }, [data?.path, onSelect, onOpenChange]) + + return ( + + + + {title} + + +
+

+ {data?.path ?? 'Loading...'} +

+
+ +
+ {isLoading ? ( +
+ +
+ ) : isDisabled ? ( +
+ +

Folder browsing is not enabled

+

+ Ask your administrator to set REPO_BROWSE_ROOT in the + server environment, then enter the path manually for now. +

+
+ ) : isError ? ( +
+ {error instanceof Error ? error.message : 'Failed to load directory'} +
+ ) : ( +
    + {data && !data.isRoot && data.parentPath !== null && ( +
  • + +
  • + )} + {data?.entries.length === 0 && ( +
  • No subfolders
  • + )} + {data?.entries.map((entry) => ( +
  • + +
  • + ))} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/shared/src/config/env.ts b/shared/src/config/env.ts index 411cce71..47c657c4 100644 --- a/shared/src/config/env.ts +++ b/shared/src/config/env.ts @@ -36,6 +36,17 @@ const resolveWorkspacePath = (): string => { return path.resolve(DEFAULTS.WORKSPACE.BASE_PATH) } +const resolveBrowseRoot = (): string => { + const envPath = process.env.REPO_BROWSE_ROOT + if (!envPath) { + return '' + } + if (envPath.startsWith('~')) { + return path.join(os.homedir(), envPath.slice(1)) + } + return path.resolve(envPath) +} + const generateDefaultSecret = (): string => { return randomBytes(32).toString('base64').slice(0, 32) } @@ -69,6 +80,7 @@ export const ENV = { WORKSPACE: { get BASE_PATH() { return resolveWorkspacePath() }, + get BROWSE_ROOT() { return resolveBrowseRoot() }, REPOS_DIR: DEFAULTS.WORKSPACE.REPOS_DIR, SCHEDULE_WORKTREES_DIR: DEFAULTS.WORKSPACE.SCHEDULE_WORKTREES_DIR, CONFIG_DIR: DEFAULTS.WORKSPACE.CONFIG_DIR, @@ -119,6 +131,7 @@ export const ENV = { } as const export const getWorkspacePath = () => ENV.WORKSPACE.BASE_PATH +export const getBrowseRootPath = () => ENV.WORKSPACE.BROWSE_ROOT export const getReposPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.REPOS_DIR) export const getScheduleWorktreesPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.SCHEDULE_WORKTREES_DIR) export const getConfigPath = () => path.join(ENV.WORKSPACE.BASE_PATH, ENV.WORKSPACE.CONFIG_DIR) diff --git a/shared/src/schemas/filesystem.ts b/shared/src/schemas/filesystem.ts new file mode 100644 index 00000000..4263d2f2 --- /dev/null +++ b/shared/src/schemas/filesystem.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' + +export const DirectoryEntrySchema = z.object({ + name: z.string(), + path: z.string(), + isGitRepo: z.boolean(), +}) + +export const BrowseDirectoryResponseSchema = z.object({ + path: z.string(), + parentPath: z.string().nullable(), + isRoot: z.boolean(), + entries: z.array(DirectoryEntrySchema), +}) + +export const BrowseDirectoryRequestSchema = z.object({ + path: z.string().optional(), +}) diff --git a/shared/src/schemas/index.ts b/shared/src/schemas/index.ts index aa5257ac..21dca6a8 100644 --- a/shared/src/schemas/index.ts +++ b/shared/src/schemas/index.ts @@ -1,6 +1,7 @@ export * from './settings' export * from './repo' export * from './files' +export * from './filesystem' export * from './opencode' export * from './auth' export * from './sse' diff --git a/shared/src/types/index.ts b/shared/src/types/index.ts index 34733203..0d152e3a 100644 --- a/shared/src/types/index.ts +++ b/shared/src/types/index.ts @@ -36,6 +36,11 @@ import { SessionSchema, MessageSchema, } from '../schemas/opencode' +import { + DirectoryEntrySchema, + BrowseDirectoryResponseSchema, + BrowseDirectoryRequestSchema, +} from '../schemas/filesystem' import { NotificationPreferencesSchema, PushSubscriptionRequestSchema, @@ -82,6 +87,10 @@ export type FilePatchRequest = z.infer export type Session = z.infer export type Message = z.infer +export type DirectoryEntry = z.infer +export type BrowseDirectoryResponse = z.infer +export type BrowseDirectoryRequest = z.infer + export type NotificationPreferences = z.infer export type PushSubscriptionRequest = z.infer export type PushSubscriptionRecord = z.infer