diff --git a/src/context-types.ts b/src/context-types.ts index d17faa1f..d5adaa8e 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -507,6 +507,24 @@ export interface SidebarAgent { export interface SidebarContextShape { /** The webServer service face this plugin uses. */ webServer: SidebarWebServer + /** + * DSH filesystem abstraction. Explorer listing uses it so workspace anchors + * contributed by remote-runtime plugins resolve in their execution world. + */ + fs: { + resolve(path: string): Promise<{ targetKey: unknown; displayPath: string }> + contains( + parent: { targetKey: unknown; displayPath: string }, + child: { targetKey: unknown; displayPath: string }, + ): boolean + lstat(path: string): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other' } | undefined> + stat(target: { targetKey: unknown; displayPath: string }): Promise<{ type: 'file' | 'directory' | 'other' } | undefined> + listDir(target: { targetKey: unknown; displayPath: string }): Promise> + } /** The session store (host `.get`) and the client list feed (`.list`) faces. */ sessions: SidebarSessionStore & SidebarSessionsService /** The web runtime trust list (bind-derived). */ diff --git a/src/fs-tree.ts b/src/fs-tree.ts index 89cdc5f9..97decfcc 100644 --- a/src/fs-tree.ts +++ b/src/fs-tree.ts @@ -1,11 +1,9 @@ /** - * Single-level directory listing for the sidebar explorer. Streams the level - * with opendir, sorts directories first then names (case-insensitive), and - * marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are - * stat'ed once to expose their target kind — a symlink to a directory - * expands like a directory — and dangling links are flagged broken. The - * probe runs only for entries that are actually symlinks, so levels without - * links stay as cheap as before. + * Single-level directory listing for the sidebar explorer. Local callers use + * opendir directly; routed workspaces use the DSH filesystem service so an + * empty host anchor can expose another execution world's tree. Both paths sort + * directories first, dim dot-prefixed entries, classify directory symlinks, + * and flag dangling links. */ import { opendir, stat } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, resolve } from 'node:path' @@ -82,6 +80,57 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise 0 } } +/** Minimal routed filesystem face used by the explorer. */ +export interface SidebarFileSystem { + resolve(path: string): Promise<{ targetKey: unknown; displayPath: string }> + lstat(path: string): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other' } | undefined> + stat(target: { targetKey: unknown; displayPath: string }): Promise<{ type: 'file' | 'directory' | 'other' } | undefined> + listDir(target: { targetKey: unknown; displayPath: string }): Promise> +} + +/** + * List one directory through DSH's filesystem service. This is the path used + * for workspace anchors whose visible files live in another execution world + * (for example an SSH Remote workspace); row paths deliberately remain in the + * session's host-path namespace so every follow-up API request can route the + * same anchor descendant again. + */ +export async function listDirectoryWith( + fs: SidebarFileSystem, + path: string, + maxEntries = 1000, + resolvedTarget?: { targetKey: unknown; displayPath: string }, +): Promise { + try { + const target = resolvedTarget ?? await fs.resolve(path) + const listed = await fs.listDir(target) + const truncated = listed.length > maxEntries + const visible = listed.slice(0, maxEntries) + const entries = await Promise.all(visible.map(async (entry): Promise => { + const childPath = join(path, entry.name) + const pathInfo = await fs.lstat(childPath).catch(() => undefined) + const isSymlink = pathInfo?.type === 'symlink' + const targetInfo = isSymlink ? await fs.stat(entry.target).catch(() => undefined) : undefined + return { + name: entry.name, + path: childPath, + isDir: (targetInfo?.type ?? entry.type) === 'directory', + hidden: entry.name.startsWith('.'), + isSymlink, + broken: isSymlink && targetInfo === undefined, + } + })) + entries.sort(compareEntries) + return { path, entries, truncated } + } catch (error) { + throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) + } +} + /** How many symlink target stats run in flight during one level listing. */ const SYMLINK_PROBE_CONCURRENCY = 32 diff --git a/src/index.ts b/src/index.ts index 85c53d6c..9a5380ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,7 +29,7 @@ import { type SidebarConfig, type SidebarPrefs, } from './config.ts' -import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' +import { parentOf, requireAbsolute, listDirectoryWith, rootLabel } from './fs-tree.ts' import { resolveSessionPath } from './session-path.ts' import { writeWorkspaceUpload } from './fs-operations.ts' import { ensureWorkspacePath, ensureWorkspaceWritePath } from './path-security.ts' @@ -77,8 +77,8 @@ export type { /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' -/** Services required before mounting: the webserver routes, the session store, the web runtime's trusted hosts, and the tool registry. */ -export const inject = ['webServer', 'sessions', 'webRuntime', 'tools'] +/** Services required before mounting: the webserver routes, session store, routed filesystem, trust source, and tool registry. */ +export const inject = ['webServer', 'sessions', 'fs', 'webRuntime', 'tools'] /** Content types for the media route, by extension. */ const MEDIA_TYPES: Record = { @@ -331,8 +331,20 @@ function buildApi( 'fs.tree': async (payload) => { const { cwd } = await cwdOf(payload) const record = payload as { path?: unknown } - const target = record.path === undefined ? cwd : await ensureWorkspacePath(cwd, requireString(payload, 'path'), fenceEnabledOf(getSettings)) - return listDirectory(target, resolved.listLimit) + const requested = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path')) + try { + const target = await ctx.fs.resolve(requested) + if (fenceEnabledOf(getSettings)) { + const workspaceTarget = await ctx.fs.resolve(cwd) + if (!ctx.fs.contains(workspaceTarget, target)) { + throw new SidebarError('forbidden', `path "${requested}" is outside workspace`, 403) + } + } + return listDirectoryWith(ctx.fs, requested, resolved.listLimit, target) + } catch (error) { + if (error instanceof SidebarError) throw error + throw new SidebarError('fs-error', `cannot list "${requested}": ${error instanceof Error ? error.message : String(error)}`, 400) + } }, 'fs.search': async (payload) => { // The editor side panel's global name search: rooted at the session diff --git a/tests/fs-tree-routed.spec.ts b/tests/fs-tree-routed.spec.ts new file mode 100644 index 00000000..53550927 --- /dev/null +++ b/tests/fs-tree-routed.spec.ts @@ -0,0 +1,84 @@ +/** + * Routed filesystem listing: workspace anchors can represent files in another + * execution world (for example dsh-ssh-remote). The explorer must list through + * the host filesystem service instead of re-reading the empty local anchor. + */ +import { describe, expect, it, vi } from 'vitest' +import { listDirectoryWith, type SidebarFileSystem } from '../src/fs-tree.ts' + +const root = '/home/me/.dsh/ssh-workspace-anchors/project' + +/** Minimal filesystem fake for the listing seam. */ +function fakeFs(entries: Array<{ + name: string + type: 'file' | 'directory' | 'other' + target: { targetKey: string; displayPath: string } + size?: number +}>): SidebarFileSystem & { + resolve: ReturnType + lstat: ReturnType + stat: ReturnType + listDir: ReturnType +} { + return { + resolve: vi.fn(async (path: string) => ({ targetKey: `ssh://gpu${path}`, displayPath: 'gpu:/work/project' })), + lstat: vi.fn(async (_path: string) => ({ type: 'file' as const })), + stat: vi.fn(async (_target: { targetKey: unknown; displayPath: string }) => ({ type: 'file' as const })), + listDir: vi.fn(async (_target: { targetKey: unknown; displayPath: string }) => entries), + } +} + +describe('fs-tree routed filesystem listing', () => { + it('lists and classifies remote children through the filesystem service', async () => { + const fs = fakeFs([ + { name: 'src', type: 'directory', target: { targetKey: 'ssh://gpu/work/project/src', displayPath: 'gpu:/work/project/src' } }, + { name: '.env', type: 'file', target: { targetKey: 'ssh://gpu/work/project/.env', displayPath: 'gpu:/work/project/.env' }, size: 7 }, + { name: 'link', type: 'other', target: { targetKey: 'ssh://gpu/work/project/link', displayPath: 'gpu:/work/project/link' } }, + ]) + + const listing = await listDirectoryWith(fs, root, 1000) + + expect(fs.resolve).toHaveBeenCalledWith(root) + expect(fs.listDir).toHaveBeenCalledWith(expect.objectContaining({ targetKey: `ssh://gpu${root}` })) + expect(listing).toEqual({ + path: root, + entries: [ + { name: 'src', path: `${root}/src`, isDir: true, hidden: false, isSymlink: false, broken: false }, + { name: '.env', path: `${root}/.env`, isDir: false, hidden: true, isSymlink: false, broken: false }, + { name: 'link', path: `${root}/link`, isDir: false, hidden: false, isSymlink: false, broken: false }, + ], + truncated: false, + }) + }) + + it('preserves routed symlink classification without local filesystem probes', async () => { + const fs = fakeFs([ + { name: 'linked-docs', type: 'other', target: { targetKey: 'ssh://gpu/work/shared-docs', displayPath: 'gpu:/work/shared-docs' } }, + { name: 'dangling', type: 'other', target: { targetKey: 'ssh://gpu/work/missing', displayPath: 'gpu:/work/missing' } }, + ]) + fs.lstat.mockImplementation(async (path: string) => path.endsWith('linked-docs') || path.endsWith('dangling') + ? { type: 'symlink' as const } + : { type: 'file' as const }) + fs.stat.mockImplementation(async (target: { targetKey: string }) => target.targetKey.endsWith('shared-docs') + ? { type: 'directory' as const } + : undefined) + + const listing = await listDirectoryWith(fs, root) + + expect(listing.entries).toEqual([ + expect.objectContaining({ name: 'linked-docs', isDir: true, isSymlink: true, broken: false }), + expect.objectContaining({ name: 'dangling', isDir: false, isSymlink: true, broken: true }), + ]) + }) + + it('applies the explorer row limit after remote listing', async () => { + const fs = fakeFs([ + { name: 'a', type: 'directory', target: { targetKey: 'a', displayPath: 'a' } }, + { name: 'b', type: 'file', target: { targetKey: 'b', displayPath: 'b' } }, + ]) + + const listing = await listDirectoryWith(fs, root, 1) + expect(listing.entries.map(entry => entry.name)).toEqual(['a']) + expect(listing.truncated).toBe(true) + }) +}) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index ffe16fcb..72fa9560 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => { const unwrapped = loader.unwrapExports(sidebar) as Record expect(unwrapped).toBe(sidebar) expect(unwrapped.name).toBe('dsh-better-sidebar') - expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'webRuntime', 'tools']) + expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'fs', 'webRuntime', 'tools']) expect(unwrapped.Config).toBeDefined() expect(typeof unwrapped.apply).toBe('function') }) diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 99a834d9..a4cb8af9 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -492,10 +492,45 @@ describe('session cwd resolution over the API route', () => { interface CtxOverrides { sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined } sessionPersistence?: { inspect: (id: string) => Promise<{ meta: { cwd?: string } }> } + fs?: { + resolve(path: string): Promise<{ targetKey: string; displayPath: string }> + contains(parent: { targetKey: string }, child: { targetKey: string }): boolean + lstat(path: string): Promise<{ type: 'file' | 'directory' | 'symlink' | 'other' } | undefined> + stat(target: { targetKey: string }): Promise<{ type: 'file' | 'directory' | 'other' } | undefined> + listDir(target: { targetKey: string }): Promise> + } } const mountAll = (overrides: CtxOverrides = {}): SidebarWebRoute[] => { const routes: SidebarWebRoute[] = [] + const localFs = { + resolve: async (path: string) => { + const absolute = resolvePath(path) + const canonical = await import('node:fs/promises').then(({ realpath }) => realpath(absolute)) + return { targetKey: canonical, displayPath: absolute } + }, + contains: (parent: { targetKey: string }, child: { targetKey: string }) => child.targetKey === parent.targetKey || child.targetKey.startsWith(`${parent.targetKey}${process.platform === 'win32' ? '\\' : '/'}`), + lstat: async (path: string) => { + const info = await import('node:fs/promises').then(fs => fs.lstat(path)).catch(() => undefined) + return info === undefined ? undefined : { type: info.isSymbolicLink() ? 'symlink' as const : info.isDirectory() ? 'directory' as const : info.isFile() ? 'file' as const : 'other' as const } + }, + stat: async (target: { targetKey: string }) => { + const info = await import('node:fs/promises').then(fs => fs.stat(target.targetKey)).catch(() => undefined) + return info === undefined ? undefined : { type: info.isDirectory() ? 'directory' as const : info.isFile() ? 'file' as const : 'other' as const } + }, + listDir: async (target: { targetKey: string; displayPath: string }) => { + const entries = await import('node:fs/promises').then(fs => fs.readdir(target.targetKey, { withFileTypes: true })) + return Promise.all(entries.map(async entry => ({ + name: entry.name, + type: entry.isDirectory() ? 'directory' as const : entry.isFile() ? 'file' as const : 'other' as const, + target: await localFs.resolve(join(target.displayPath, entry.name)), + }))) + }, + } const ctx = { webRuntime: { trustedHosts: [] }, webServer: { @@ -503,6 +538,7 @@ describe('session cwd resolution over the API route', () => { registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, }, sessions: overrides.sessions ?? { get: () => undefined }, + fs: overrides.fs ?? localFs, tools: { register: () => () => {} }, // The vendored cordis runs registration effects immediately. effect: (fn: () => void | (() => void)) => { fn() }, @@ -675,6 +711,38 @@ describe('session cwd resolution over the API route', () => { expect(result).toMatchObject({ ok: false, status: 403, error: { code: 'forbidden' } }) }) + it('lists a routed SSH workspace instead of the empty local anchor', async () => { + const anchor = resolvePath('/home/me/.dsh/ssh-workspace-anchors/project') + const remoteRoot = 'ssh://gpu/work/project' + const fs = { + resolve: vi.fn(async (path: string) => ({ + targetKey: path === anchor ? remoteRoot : `${remoteRoot}/${path.slice(anchor.length + 1)}`, + displayPath: path === anchor ? 'gpu:/work/project' : `gpu:/work/project/${path.slice(anchor.length + 1)}`, + })), + contains: vi.fn((parent: { targetKey: string }, child: { targetKey: string }) => child.targetKey === parent.targetKey || child.targetKey.startsWith(`${parent.targetKey}/`)), + lstat: vi.fn(async () => ({ type: 'directory' as const })), + stat: vi.fn(async () => ({ type: 'directory' as const })), + listDir: vi.fn(async () => [{ + name: 'docs', + type: 'directory' as const, + target: { targetKey: `${remoteRoot}/docs`, displayPath: 'gpu:/work/project/docs' }, + }]), + } + const route = mount({ + sessions: { get: () => ({ header: { cwd: anchor } }) }, + fs, + }) + + const tree = await invoke(route, 'fs.tree', { sessionId: 'ssh' }) as unknown as { + ok: boolean + value?: { entries: Array<{ name: string; path: string }> } + } + + expect(tree.ok).toBe(true) + expect(tree.value?.entries).toEqual([expect.objectContaining({ name: 'docs', path: join(anchor, 'docs') })]) + expect(fs.listDir).toHaveBeenCalledWith(expect.objectContaining({ targetKey: remoteRoot })) + }) + it('rejects fs.tree paths outside the session workspace', async () => { const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-fs-security-')) const workspace = join(root, 'workspace') @@ -860,6 +928,30 @@ describe('side card settings routes', () => { const mountWithSettings = (settings?: unknown): SidebarWebRoute => { const routes: SidebarWebRoute[] = [] + const fs = { + resolve: async (path: string) => { + const absolute = resolvePath(path) + const canonical = await import('node:fs/promises').then(({ realpath }) => realpath(absolute)) + return { targetKey: canonical, displayPath: absolute } + }, + contains: (parent: { targetKey: string }, child: { targetKey: string }) => child.targetKey === parent.targetKey || child.targetKey.startsWith(`${parent.targetKey}${process.platform === 'win32' ? '\\' : '/'}`), + lstat: async (path: string) => { + const info = await import('node:fs/promises').then(module => module.lstat(path)).catch(() => undefined) + return info === undefined ? undefined : { type: info.isSymbolicLink() ? 'symlink' as const : info.isDirectory() ? 'directory' as const : info.isFile() ? 'file' as const : 'other' as const } + }, + stat: async (target: { targetKey: string }) => { + const info = await import('node:fs/promises').then(module => module.stat(target.targetKey)).catch(() => undefined) + return info === undefined ? undefined : { type: info.isDirectory() ? 'directory' as const : info.isFile() ? 'file' as const : 'other' as const } + }, + listDir: async (target: { targetKey: string; displayPath: string }) => { + const entries = await import('node:fs/promises').then(module => module.readdir(target.targetKey, { withFileTypes: true })) + return Promise.all(entries.map(async entry => ({ + name: entry.name, + type: entry.isDirectory() ? 'directory' as const : entry.isFile() ? 'file' as const : 'other' as const, + target: await fs.resolve(join(target.displayPath, entry.name)), + }))) + }, + } const ctx = { webRuntime: { trustedHosts: [] }, webServer: { @@ -867,6 +959,7 @@ describe('side card settings routes', () => { registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} }, }, sessions: { get: () => undefined }, + fs, tools: { register: () => () => {} }, effect: (fn: () => void | (() => void)) => { fn() }, inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {