Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/context-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<{
name: string
type: 'file' | 'directory' | 'other'
target: { targetKey: unknown; displayPath: string }
}>>
}
/** The session store (host `.get`) and the client list feed (`.list`) faces. */
sessions: SidebarSessionStore & SidebarSessionsService
/** The web runtime trust list (bind-derived). */
Expand Down
63 changes: 56 additions & 7 deletions src/fs-tree.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -82,6 +80,57 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
return { path, entries: rows, truncated: overflow > 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<Array<{
name: string
type: 'file' | 'directory' | 'other'
target: { targetKey: unknown; displayPath: string }
}>>
}

/**
* 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<SidebarFsListing> {
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<SidebarFsEntry> => {
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

Expand Down
22 changes: 17 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions tests/fs-tree-routed.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>
lstat: ReturnType<typeof vi.fn>
stat: ReturnType<typeof vi.fn>
listDir: ReturnType<typeof vi.fn>
} {
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)
})
})
2 changes: 1 addition & 1 deletion tests/plugin-shape.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
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')
})
Expand Down
93 changes: 93 additions & 0 deletions tests/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -492,17 +492,53 @@ 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<Array<{
name: string
type: 'file' | 'directory' | 'other'
target: { targetKey: string; displayPath: string }
}>>
}
}

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: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
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() },
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -860,13 +928,38 @@ 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: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
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) => {
Expand Down