Skip to content

Commit 3154713

Browse files
committed
feat(desktop): improve local folder settings
1 parent 927c767 commit 3154713

6 files changed

Lines changed: 168 additions & 92 deletions

File tree

apps/desktop/src/main/local-filesystem.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ describe('LocalFilesystemService', () => {
5555
})
5656
})
5757

58-
it('returns an opaque mount URI and never exposes the host path', async () => {
58+
it('returns an opaque mount URI plus a display path for trusted UI', async () => {
5959
const granted = await mount(service)
6060
expect(granted.uri).toMatch(/^localfs:\/\/[^/]+\/$/)
61-
expect(JSON.stringify(granted)).not.toContain(root)
61+
// The tmp root is outside the home directory, so the display path is the
62+
// canonical absolute path; paths under home render home-relative.
63+
expect(granted.path).toBe(await realpath(root))
6264

6365
const listData = dataOf(await service.handle({ operation: 'list_mounts' }))
6466
expect(listData).toEqual({ mounts: [granted] })

apps/desktop/src/main/local-filesystem.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'
2+
import { homedir } from 'node:os'
23
import { basename, isAbsolute, resolve, sep } from 'node:path'
34
import type {
45
LocalFilesystemData,
@@ -94,6 +95,20 @@ function localUri(mountId: string, relativePath = ''): string {
9495
return `localfs://${mountId}/${encodedPath}`
9596
}
9697

98+
/**
99+
* Home-relative display form of a granted directory's host path
100+
* (`/Users/me/Documents/Notes` -> `~/Documents/Notes`). Shown only in trusted
101+
* desktop UI; agent tool results stay limited to opaque localfs:// URIs.
102+
*/
103+
function displayPath(rootPath: string): string {
104+
const home = homedir()
105+
if (rootPath === home) return '~'
106+
if (home && rootPath.startsWith(`${home}${sep}`)) {
107+
return `~${rootPath.slice(home.length)}`
108+
}
109+
return rootPath
110+
}
111+
97112
function parsePositiveInteger(value: unknown, name: string, fallback: number, max: number): number {
98113
if (value === undefined) return fallback
99114
if (!Number.isInteger(value) || (value as number) < 1 || (value as number) > max) {
@@ -342,6 +357,7 @@ export class LocalFilesystemService {
342357
id: mount.id,
343358
name: mount.name,
344359
uri: mount.uri,
360+
path: displayPath(mount.rootPath),
345361
remembered: mount.remembered,
346362
}
347363
}

apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx

Lines changed: 76 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,13 @@ import type {
77
LocalFilesystemMount,
88
LocalFilesystemResponse,
99
} from '@sim/desktop-bridge'
10-
import {
11-
Chip,
12-
ChipConfirmModal,
13-
Label,
14-
Switch,
15-
Table,
16-
TableBody,
17-
TableCell,
18-
TableRow,
19-
toast,
20-
} from '@sim/emcn'
10+
import { Chip, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn'
11+
import { Folder } from '@sim/emcn/icons'
2112
import { useParams, useRouter } from 'next/navigation'
2213
import { getDesktopBridge } from '@/lib/desktop'
2314
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
2415
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
16+
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
2517
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
2618

2719
function getMounts(response: LocalFilesystemResponse): LocalFilesystemMount[] | null {
@@ -38,14 +30,10 @@ interface PreferenceRowProps {
3830

3931
function PreferenceRow({ id, label, checked, disabled, onCheckedChange }: PreferenceRowProps) {
4032
return (
41-
<TableRow>
42-
<TableCell>
43-
<Label htmlFor={id}>{label}</Label>
44-
</TableCell>
45-
<TableCell>
46-
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
47-
</TableCell>
48-
</TableRow>
33+
<div className='flex items-center justify-between'>
34+
<Label htmlFor={id}>{label}</Label>
35+
<Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onCheckedChange} />
36+
</div>
4937
)
5038
}
5139

@@ -142,98 +130,98 @@ export function Desktop() {
142130
<>
143131
<SettingsPanel>
144132
<SettingsSection label='Notifications'>
145-
<Table>
146-
<TableBody>
147-
<PreferenceRow
148-
id='desktop-notifications'
149-
label='Enable desktop notifications'
150-
checked={preferences.notificationsEnabled}
151-
disabled={pendingPreference !== null}
152-
onCheckedChange={(checked) =>
153-
void updatePreference('notificationsEnabled', checked)
154-
}
155-
/>
156-
<PreferenceRow
157-
id='desktop-notification-sounds'
158-
label='Play notification sounds'
159-
checked={preferences.notificationSounds}
160-
disabled={notificationsDisabled || pendingPreference !== null}
161-
onCheckedChange={(checked) => void updatePreference('notificationSounds', checked)}
162-
/>
163-
<PreferenceRow
164-
id='desktop-notifications-unfocused'
165-
label="Notify only when Sim isn't focused"
166-
checked={preferences.notificationsOnlyWhenUnfocused}
167-
disabled={notificationsDisabled || pendingPreference !== null}
168-
onCheckedChange={(checked) =>
169-
void updatePreference('notificationsOnlyWhenUnfocused', checked)
170-
}
171-
/>
172-
</TableBody>
173-
</Table>
133+
<div className='flex flex-col gap-3'>
134+
<PreferenceRow
135+
id='desktop-notifications'
136+
label='Enable desktop notifications'
137+
checked={preferences.notificationsEnabled}
138+
disabled={pendingPreference !== null}
139+
onCheckedChange={(checked) => void updatePreference('notificationsEnabled', checked)}
140+
/>
141+
<PreferenceRow
142+
id='desktop-notification-sounds'
143+
label='Play notification sounds'
144+
checked={preferences.notificationSounds}
145+
disabled={notificationsDisabled || pendingPreference !== null}
146+
onCheckedChange={(checked) => void updatePreference('notificationSounds', checked)}
147+
/>
148+
<PreferenceRow
149+
id='desktop-notifications-unfocused'
150+
label="Notify only when Sim isn't focused"
151+
checked={preferences.notificationsOnlyWhenUnfocused}
152+
disabled={notificationsDisabled || pendingPreference !== null}
153+
onCheckedChange={(checked) =>
154+
void updatePreference('notificationsOnlyWhenUnfocused', checked)
155+
}
156+
/>
157+
</div>
174158
</SettingsSection>
175159

176160
<SettingsSection label='App behavior'>
177-
<Table>
178-
<TableBody>
179-
<PreferenceRow
180-
id='desktop-launch-at-login'
181-
label='Launch Sim at login'
182-
checked={preferences.launchAtLogin}
183-
disabled={pendingPreference !== null}
184-
onCheckedChange={(checked) => void updatePreference('launchAtLogin', checked)}
185-
/>
186-
</TableBody>
187-
</Table>
161+
<div className='flex flex-col gap-3'>
162+
<PreferenceRow
163+
id='desktop-launch-at-login'
164+
label='Launch Sim at login'
165+
checked={preferences.launchAtLogin}
166+
disabled={pendingPreference !== null}
167+
onCheckedChange={(checked) => void updatePreference('launchAtLogin', checked)}
168+
/>
169+
<PreferenceRow
170+
id='desktop-auto-download-updates'
171+
label='Automatically download updates'
172+
checked={preferences.autoDownloadUpdates}
173+
disabled={pendingPreference !== null}
174+
onCheckedChange={(checked) => void updatePreference('autoDownloadUpdates', checked)}
175+
/>
176+
</div>
188177
</SettingsSection>
189178

190179
<SettingsSection
191-
label='Files'
180+
label='Local folders'
192181
action={
193182
<Chip onClick={() => void addFolder()} disabled={mountMutationPending}>
194183
Add folder
195184
</Chip>
196185
}
197186
>
198187
{mounts.length === 0 ? (
199-
<SettingsEmptyState variant='inline'>No folders added.</SettingsEmptyState>
188+
<SettingsEmptyState variant='inline'>
189+
No folder access granted. Chat can only read folders you add here.
190+
</SettingsEmptyState>
200191
) : (
201-
<Table>
202-
<TableBody>
203-
{mounts.map((mount) => (
204-
<TableRow key={mount.id}>
205-
<TableCell>{mount.name}</TableCell>
206-
<TableCell>{mount.uri}</TableCell>
207-
<TableCell>
192+
<div className='flex flex-col gap-2'>
193+
{mounts.map((mount) => (
194+
<SettingsResourceRow
195+
key={mount.id}
196+
icon={<Folder />}
197+
title={mount.name}
198+
description={mount.path}
199+
trailing={
200+
<div className='flex flex-shrink-0 items-center gap-2'>
201+
{!mount.remembered && (
202+
<span className='text-[var(--text-muted)] text-caption'>
203+
Until app restarts
204+
</span>
205+
)}
208206
<Chip onClick={() => setMountToForget(mount)}>Revoke</Chip>
209-
</TableCell>
210-
</TableRow>
211-
))}
212-
</TableBody>
213-
</Table>
207+
</div>
208+
}
209+
/>
210+
))}
211+
</div>
214212
)}
215213
</SettingsSection>
216-
217-
<SettingsSection label='Updates'>
218-
<Table>
219-
<TableBody>
220-
<PreferenceRow
221-
id='desktop-auto-download-updates'
222-
label='Automatically download updates'
223-
checked={preferences.autoDownloadUpdates}
224-
disabled={pendingPreference !== null}
225-
onCheckedChange={(checked) => void updatePreference('autoDownloadUpdates', checked)}
226-
/>
227-
</TableBody>
228-
</Table>
229-
</SettingsSection>
230214
</SettingsPanel>
231215

232216
<ChipConfirmModal
233217
open={mountToForget !== null}
234218
onOpenChange={(open) => !open && setMountToForget(null)}
235219
title='Revoke folder access'
236-
text={`Sim will no longer be able to access ${mountToForget?.name ?? 'this folder'}.`}
220+
text={[
221+
'Sim will no longer be able to read ',
222+
{ text: mountToForget?.name ?? 'this folder', bold: true },
223+
'. You can grant access again at any time.',
224+
]}
237225
confirm={{
238226
label: 'Revoke access',
239227
pending: mountMutationPending,

apps/sim/lib/copilot/tools/client/local-filesystem.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('executeLocalFilesystemTool', () => {
4444
mockReportCompletion.mockResolvedValue(undefined)
4545
})
4646

47-
it('executes read-only tools through the desktop bridge and reports the result', async () => {
47+
it('executes read-only tools through the desktop bridge and strips host paths from the result', async () => {
4848
localFilesystem.mockResolvedValue({
4949
ok: true,
5050
data: {
@@ -53,6 +53,7 @@ describe('executeLocalFilesystemTool', () => {
5353
id: 'mount-1',
5454
name: 'project',
5555
uri: 'localfs://mount-1/',
56+
path: '~/code/project',
5657
remembered: true,
5758
},
5859
],
@@ -80,6 +81,41 @@ describe('executeLocalFilesystemTool', () => {
8081
})
8182
})
8283

84+
it('strips the host path from a fresh mount_directory grant before reporting it', async () => {
85+
localFilesystem.mockResolvedValue({
86+
ok: true,
87+
data: {
88+
mount: {
89+
id: 'mount-2',
90+
name: 'Desktop',
91+
uri: 'localfs://mount-2/',
92+
path: '~/Desktop',
93+
remembered: true,
94+
},
95+
cancelled: false,
96+
},
97+
})
98+
99+
executeLocalFilesystemTool('tool-mount', 'local_mount_directory', {}, { workspaceId: 'ws-1' })
100+
101+
await vi.waitFor(() => {
102+
expect(mockReportCompletion).toHaveBeenCalledWith(
103+
'tool-mount',
104+
'success',
105+
'Local filesystem tool completed.',
106+
{
107+
mount: {
108+
id: 'mount-2',
109+
name: 'Desktop',
110+
uri: 'localfs://mount-2/',
111+
remembered: true,
112+
},
113+
cancelled: false,
114+
}
115+
)
116+
})
117+
})
118+
83119
it('forgets a remembered mount through the desktop bridge', async () => {
84120
localFilesystem.mockResolvedValue({
85121
ok: true,

apps/sim/lib/copilot/tools/client/local-filesystem.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ function successfulData(response: LocalFilesystemResponse): LocalFilesystemData
8282
return response.data
8383
}
8484

85+
/**
86+
* Mount objects carry a human-readable host `path` for trusted desktop UI
87+
* (the Desktop settings page). Tool results are forwarded to the copilot
88+
* backend, which must only ever see display names and opaque localfs:// URIs,
89+
* so the host path is dropped here before the completion report.
90+
*/
91+
function withoutHostPaths(data: LocalFilesystemData): LocalFilesystemData {
92+
if ('mount' in data) {
93+
return {
94+
...data,
95+
mount: data.mount ? omitPath(data.mount) : data.mount,
96+
}
97+
}
98+
if ('mounts' in data) {
99+
return { ...data, mounts: data.mounts.map(omitPath) }
100+
}
101+
return data
102+
}
103+
104+
function omitPath<T extends { path?: string }>(mount: T): Omit<T, 'path'> {
105+
const { path: _path, ...rest } = mount
106+
return rest
107+
}
108+
85109
async function stageLocalFile(
86110
args: Record<string, unknown>,
87111
context: LocalFilesystemExecutionContext
@@ -149,7 +173,9 @@ async function execute(
149173
if (toolName === LOCAL_FILESYSTEM_TOOL_NAMES.stageFile) {
150174
return stageLocalFile(args, context)
151175
}
152-
return successfulData(await bridge().localFilesystem(requestForTool(toolName, args)))
176+
return withoutHostPaths(
177+
successfulData(await bridge().localFilesystem(requestForTool(toolName, args)))
178+
)
153179
}
154180

155181
export function executeLocalFilesystemTool(

packages/desktop-bridge/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export interface LocalFilesystemMount {
7878
id: string
7979
name: string
8080
uri: string
81+
/**
82+
* Human-readable host path of the granted directory (home-relative, e.g.
83+
* `~/Documents/Notes`), for display in trusted desktop UI such as the
84+
* settings page. Optional so a newer web deployment remains compatible with
85+
* older installed shells. Never forward this to the copilot backend — tool
86+
* results must stay limited to opaque `localfs://` URIs.
87+
*/
88+
path?: string
8189
/** True when the encrypted grant will be restored after restarting the desktop app. */
8290
remembered: boolean
8391
}

0 commit comments

Comments
 (0)