Skip to content
Closed
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
289 changes: 289 additions & 0 deletions docs/local-artifacts-mockup.html

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
import { registerManagedPreviewIpcHandlers } from './managed-preview-ipc'
import { registerManagedPreviewProtocol } from './managed-preview-protocol'
import { ManagedPreviewResources } from './managed-preview-resources'
import type { ManagedPreviewSource } from '../shared/preview-resources'
import {
createOfficePreviewFrameProcessResolver,
createOfficePreviewProcessMemoryReader
Expand Down Expand Up @@ -85,6 +86,8 @@ import { SessionPersistenceCoordinator } from './session-persistence/coordinator
import { type SessionPersistenceBackend } from './session-persistence/ipc'
import { tryDecryptKey } from './settings/crypto'
import { registerSettingsIpcHandlers } from './settings/ipc'
import { registerLocalFsIpcHandlers } from './local-fs/ipc'
import { LocalFsService } from './local-fs/service'
import { getAppClaudeConfigDir } from './settings/provider-env'
import { createDefaultSettingsService, type SettingsService } from './settings/service'
import type { StoredConnectors } from './settings/types'
Expand Down Expand Up @@ -221,14 +224,18 @@ const registerIpcHandlers = async ({
const artifactRunRegistry = new ArtifactRunRegistry()
// Share one upload repository so composer staging, prompt finalization, and previews agree.
const uploadRepository = createDefaultUploadRepository()
// Shared local-fs service backs both the "This computer" browser IPC and the managed-preview
// resolver below, so path validation stays identical across both entry points.
const localFsService = new LocalFsService()
// One source-neutral resolver keeps previews and user-requested exports on identical trust checks.
const resolveManagedFilePath = (
source: 'artifact' | 'upload',
source: ManagedPreviewSource,
request: { path: string }
): Promise<string> =>
source === 'artifact'
? artifactRepository.resolveManagedFilePath(request)
: uploadRepository.resolveManagedUploadPath(request)
): Promise<string> => {
if (source === 'artifact') return artifactRepository.resolveManagedFilePath(request)
if (source === 'upload') return uploadRepository.resolveManagedUploadPath(request)
return localFsService.resolveFilePath(request)
}
// One registry owns short-lived capability URLs for both managed artifact repositories.
const previewResources = new ManagedPreviewResources({
resolvePath: resolveManagedFilePath
Expand Down Expand Up @@ -711,6 +718,7 @@ const registerIpcHandlers = async ({
runtimeRef.current ? runtimeRef.current.getActiveArtifactRunIds() : []
)
registerUploadIpcHandlers(uploadRepository)
registerLocalFsIpcHandlers(localFsService)
registerSessionPersistenceIpcHandlers(sessionPersistenceBackend, reviewRepository)
registerProjectFilesIpcHandlers(
projectFilesRepository,
Expand Down
33 changes: 33 additions & 0 deletions src/main/local-fs/ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { ipcMain } from 'electron'

import type { ArtifactPreviewResult, ReadArtifactPreviewRequest } from '../../shared/artifacts'
import type { LocalDirListing, LocalRoots } from '../../shared/local-fs'
import { LocalFsService } from './service'

// Channel names for the local ("This computer") file browser. Grouped under the local-fs: prefix.
export const LOCAL_FS_LIST_DIR_CHANNEL = 'local-fs:list-dir'
export const LOCAL_FS_READ_PREVIEW_CHANNEL = 'local-fs:read-preview'
export const LOCAL_FS_GET_ROOTS_CHANNEL = 'local-fs:get-roots'
export const LOCAL_FS_REVEAL_CHANNEL = 'local-fs:reveal'
export const LOCAL_FS_OPEN_PATH_CHANNEL = 'local-fs:open-path'

// Registers the local-fs IPC handlers against a service instance (injectable for tests).
export const registerLocalFsIpcHandlers = (
service: LocalFsService = new LocalFsService()
): void => {
ipcMain.handle(LOCAL_FS_LIST_DIR_CHANNEL, (_event, path: string): Promise<LocalDirListing> =>
service.listDir(path)
)
ipcMain.handle(
LOCAL_FS_READ_PREVIEW_CHANNEL,
(_event, request: ReadArtifactPreviewRequest): Promise<ArtifactPreviewResult> =>
service.readPreview(request)
)
ipcMain.handle(LOCAL_FS_GET_ROOTS_CHANNEL, (): LocalRoots => service.getRoots())
ipcMain.handle(LOCAL_FS_REVEAL_CHANNEL, (_event, path: string): void => {
service.revealInFolder(path)
})
ipcMain.handle(LOCAL_FS_OPEN_PATH_CHANNEL, (_event, path: string): Promise<string> =>
service.openPath(path)
)
}
77 changes: 77 additions & 0 deletions src/main/local-fs/service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { mkdir, mkdtemp, realpath, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

// electron's app/shell are unavailable in the test runner; stub the two calls the service makes.
vi.mock('electron', () => ({
app: { getPath: (name: string) => (name === 'home' ? '/home/testuser' : '/tmp') },
shell: { showItemInFolder: vi.fn(), openPath: vi.fn(async () => '') }
}))

import { LocalFsService } from './service'

let root = ''
const service = new LocalFsService()

beforeAll(async () => {
root = await mkdtemp(join(tmpdir(), 'local-fs-test-'))
await mkdir(join(root, 'sub'))
await mkdir(join(root, 'Alpha'))
await writeFile(join(root, 'notes.md'), '# Title\n\nHello world.\n', 'utf8')
await writeFile(join(root, 'data.csv'), 'a,b\n1,2\n', 'utf8')
})

afterAll(() => {
vi.restoreAllMocks()
})

describe('LocalFsService.listDir', () => {
it('lists entries directories-first, alphabetical', async () => {
const listing = await service.listDir(root)
expect(listing.entries.map((e) => e.name)).toEqual(['Alpha', 'sub', 'data.csv', 'notes.md'])
expect(listing.entries[0].isDirectory).toBe(true)
expect(listing.entries.find((e) => e.name === 'notes.md')?.size).toBeGreaterThan(0)
})

it('resolves symlinks and .. via realpath', async () => {
const link = join(root, 'link-to-sub')
await symlink(join(root, 'sub'), link)
const listing = await service.listDir(link)
// realpath both sides: macOS resolves the tmp dir through a /private symlink prefix.
expect(listing.resolvedPath).toBe(await realpath(join(root, 'sub')))
})

it('rejects relative paths', async () => {
await expect(service.listDir('relative')).rejects.toThrow(/absolute/)
})

it('rejects paths with control characters', async () => {
await expect(service.listDir('/tmp/\x00x')).rejects.toThrow(/invalid characters/)
})
})

describe('LocalFsService.readPreview', () => {
it('reads a bounded UTF-8 preview', async () => {
const result = await service.readPreview({ path: join(root, 'notes.md') })
expect(result.content).toContain('# Title')
expect(result.encoding).toBe('utf8')
})

it('refuses to preview a directory', async () => {
await expect(service.readPreview({ path: join(root, 'sub') })).rejects.toThrow(/not a file/)
})

it('rejects relative preview paths', async () => {
await expect(service.readPreview({ path: 'notes.md' })).rejects.toThrow(/absolute/)
})
})

describe('LocalFsService.getRoots', () => {
it('returns home and a machine name', () => {
const roots = service.getRoots()
expect(roots.home).toBe('/home/testuser')
expect(roots.machineName.length).toBeGreaterThan(0)
})
})
98 changes: 98 additions & 0 deletions src/main/local-fs/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { hostname, userInfo } from 'node:os'
import { readdir, realpath, stat } from 'node:fs/promises'
import { join } from 'node:path'

import { app, shell } from 'electron'

import type { ArtifactPreviewResult, ReadArtifactPreviewRequest } from '../../shared/artifacts'
import type { LocalDirEntry, LocalDirListing, LocalRoots } from '../../shared/local-fs'
import { LOCAL_DIR_ENTRY_CAP, sortLocalEntries, validateLocalPath } from '../../shared/local-fs'
import { readBoundedManagedFilePreview } from '../managed-file-preview'

// Builds a user-facing machine name from the OS hostname (stripping a trailing ".local" that macOS
// appends) with a possessive owner prefix when the login name is available — e.g. "roxi's MacBook".
const buildMachineName = (): string => {
const raw = hostname().replace(/\.local$/i, '')
let owner = ''
try {
owner = userInfo().username
} catch {
owner = ''
}
return owner ? `${owner}'s ${raw}` : raw
}

// Throws a tagged error when the path fails the shared validation (non-absolute / control chars).
const assertValidLocalPath = (path: string): void => {
const problem = validateLocalPath(path)
if (problem === 'not_absolute') throw new Error('Local path must be absolute.')
if (problem === 'control_chars') throw new Error('Local path contains invalid characters.')
}

// Service for browsing and previewing arbitrary local files. Unlike the artifact/upload readers,
// this deliberately does NOT confine paths to a storage root: the feature's contract is
// "Home start, full-disk navigable". Path validation rejects only malformed input; sensitive-file
// warnings are surfaced in the renderer (see isSensitiveLocalPath).
export class LocalFsService {
// Absolute paths for the browser's initial location and "Go to → Home".
getRoots(): LocalRoots {
return { home: app.getPath('home'), machineName: buildMachineName() }
}

// Lists one directory. Resolves symlinks/.. via realpath, sorts dirs-first, caps entry count.
async listDir(path: string): Promise<LocalDirListing> {
assertValidLocalPath(path)
const resolvedPath = await realpath(path)
const dirents = await readdir(resolvedPath, { withFileTypes: true })
const truncated = dirents.length > LOCAL_DIR_ENTRY_CAP
const capped = truncated ? dirents.slice(0, LOCAL_DIR_ENTRY_CAP) : dirents

const entries: LocalDirEntry[] = []
for (const dirent of capped) {
const isDirectory = dirent.isDirectory()
// Stat each entry for size/mtime; skip entries that vanish or deny access mid-listing so one
// unreadable file never fails the whole directory.
try {
const entryStat = await stat(join(resolvedPath, dirent.name))
entries.push({
name: dirent.name,
isDirectory: isDirectory || entryStat.isDirectory(),
size: entryStat.isDirectory() ? 0 : entryStat.size,
mtimeMs: Math.round(entryStat.mtimeMs)
})
} catch {
entries.push({ name: dirent.name, isDirectory, size: 0, mtimeMs: 0 })
}
}

return { entries: sortLocalEntries(entries), truncated, resolvedPath }
}

// Validates + canonicalizes an absolute file path, asserting it is a regular file. Shared by the
// bounded preview reader and the streaming managed-preview resolver (binary renderers).
async resolveFilePath(request: { path: string }): Promise<string> {
assertValidLocalPath(request.path)
const resolvedPath = await realpath(request.path)
const fileStat = await stat(resolvedPath)
if (!fileStat.isFile()) throw new Error('Local preview path is not a file.')
return resolvedPath
}

// Reads a bounded preview of one local file, reusing the shared bounded reader.
async readPreview(request: ReadArtifactPreviewRequest): Promise<ArtifactPreviewResult> {
const resolvedPath = await this.resolveFilePath(request)
return readBoundedManagedFilePreview(resolvedPath, request, 'Invalid local preview encoding.')
}

// Reveals a file in the OS file manager (Finder / Explorer).
revealInFolder(path: string): void {
assertValidLocalPath(path)
shell.showItemInFolder(path)
}

// Opens a file with the OS default application. Returns the shell error string, or '' on success.
async openPath(path: string): Promise<string> {
assertValidLocalPath(path)
return shell.openPath(path)
}
}
13 changes: 13 additions & 0 deletions src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
ProbeResult
} from '../shared/compute'
import type { DirListing, DownloadDest, LocalFile } from '../shared/remote-fs'
import type { LocalDirListing, LocalRoots } from '../shared/local-fs'
import type { OpenLogFileResult, RevealLogFileResult } from '../shared/logs'
import type { OpenSessionFromNotificationRequest } from '../shared/notifications'
import type {
Expand Down Expand Up @@ -447,6 +448,18 @@ interface OpenScienceAPI {
// Reads a bounded preview from upload storage using the same preview result shape as artifacts.
readPreview(request: ReadArtifactPreviewRequest): Promise<ArtifactPreviewResult>
}
localFs: {
// Lists a directory on the machine Kiro runs on (the "This computer" browser).
listDir(path: string): Promise<LocalDirListing>
// Reads a bounded preview of a local file (same result shape as artifacts/uploads).
readPreview(request: ReadArtifactPreviewRequest): Promise<ArtifactPreviewResult>
// Home directory + friendly machine name for the browser's initial location and label.
getRoots(): Promise<LocalRoots>
// Reveals a local file in the OS file manager.
reveal(path: string): Promise<void>
// Opens a local file with the OS default application; resolves to '' on success.
openPath(path: string): Promise<string>
}
notebook: {
state(request: NotebookSessionRequest): Promise<NotebookSessionState>
getReference(request: NotebookSessionRequest): Promise<NotebookSessionReference | null>
Expand Down
22 changes: 22 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type {
ProbeResult
} from '../shared/compute'
import type { DirListing, DownloadDest, LocalFile } from '../shared/remote-fs'
import type { LocalDirListing, LocalRoots } from '../shared/local-fs'
import type { OpenLogFileResult, RevealLogFileResult } from '../shared/logs'
import type { OpenSessionFromNotificationRequest } from '../shared/notifications'
import type {
Expand Down Expand Up @@ -493,6 +494,18 @@ type OpenScienceAPI = {
// Reads a bounded preview from upload storage using the same preview result shape as artifacts.
readPreview: (request: ReadArtifactPreviewRequest) => Promise<ArtifactPreviewResult>
}
localFs: {
// Lists a directory on the machine Kiro runs on (the "This computer" browser).
listDir: (path: string) => Promise<LocalDirListing>
// Reads a bounded preview of a local file (same result shape as artifacts/uploads).
readPreview: (request: ReadArtifactPreviewRequest) => Promise<ArtifactPreviewResult>
// Home directory + friendly machine name for the browser's initial location and label.
getRoots: () => Promise<LocalRoots>
// Reveals a local file in the OS file manager.
reveal: (path: string) => Promise<void>
// Opens a local file with the OS default application; resolves to '' on success.
openPath: (path: string) => Promise<string>
}
notebook: {
state: (request: NotebookSessionRequest) => Promise<NotebookSessionState>
// Resolves an existing notebook entry for a session without creating one, or null when absent.
Expand Down Expand Up @@ -1026,6 +1039,15 @@ const api: OpenScienceAPI = {
readPreview: (request) =>
ipcRenderer.invoke('uploads:read-preview', request) as Promise<ArtifactPreviewResult>
},
localFs: {
// Local-fs IPC stays behind the preload bridge so renderer code never receives raw fs access.
listDir: (path) => ipcRenderer.invoke('local-fs:list-dir', path) as Promise<LocalDirListing>,
readPreview: (request) =>
ipcRenderer.invoke('local-fs:read-preview', request) as Promise<ArtifactPreviewResult>,
getRoots: () => ipcRenderer.invoke('local-fs:get-roots') as Promise<LocalRoots>,
reveal: (path) => ipcRenderer.invoke('local-fs:reveal', path) as Promise<void>,
openPath: (path) => ipcRenderer.invoke('local-fs:open-path', path) as Promise<string>
},
notebook: {
// Notebook commands stay behind typed IPC so renderer code never talks to local RPC directly.
state: (request) =>
Expand Down
Loading
Loading