Skip to content
Merged
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
48 changes: 48 additions & 0 deletions apps/desktop/e2e/browser-page-dialogs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,48 @@ createRoot(document.getElementById('root')).render(createElement(Fixture));`,
await panelAction({ action: 'reload' })
await expect.poll(() => pageTitle()).toBe('form')

await check('clean reloads and navigation never ask to discard changes', async () => {
await inPage("document.documentElement.dataset.reloadProbe = 'before'")
await panelAction({ action: 'reload' })
await expect
.poll(() => inPage<string | undefined>('document.documentElement.dataset.reloadProbe'))
.toBeUndefined()
await expect.poll(() => pageTitle()).toBe('form')
expect(await pageDialog()).toBeNull()
await panelAction({ action: 'navigate', url: `${site}/next` })
await expect.poll(pageUrl).toBe(`${site}/next`)
expect(await pageDialog()).toBeNull()
await panelAction({ action: 'back' })
await expect.poll(pageUrl).toBe(`${site}/form`)
expect(await pageDialog()).toBeNull()
})

await check('clearing a draft removes the leave warning', async () => {
await userInput('#draft', 'temporary draft')
await inPage("document.getElementById('draft').value = ''")
await panelAction({ action: 'navigate', url: `${site}/next` })
await expect.poll(pageUrl).toBe(`${site}/next`)
expect(await pageDialog()).toBeNull()
await panelAction({ action: 'navigate', url: `${site}/form` })
await expect.poll(pageUrl).toBe(`${site}/form`)
})

await check('same-page navigation preserves a draft without a leave warning', async () => {
await userInput('#draft', 'same-page draft')
await panelAction({ action: 'navigate', url: `${site}/form#section` })
await expect.poll(pageUrl).toBe(`${site}/form#section`)
expect(await pageDialog()).toBeNull()
expect(await inPage<string>("document.getElementById('draft').value")).toBe('same-page draft')
await panelAction({ action: 'back' })
await expect.poll(pageUrl).toBe(`${site}/form`)
expect(await pageDialog()).toBeNull()
await inPage(`setTimeout(() => { location.href = ${JSON.stringify(`${site}/next`)} })`)
await expect.poll(pageUrl).toBe(`${site}/next`)
expect(await pageDialog()).toBeNull()
await panelAction({ action: 'navigate', url: `${site}/form` })
await expect.poll(pageUrl).toBe(`${site}/form`)
})

await check('leaving a draft from the URL bar asks, and Stay keeps it', async () => {
await userInput('#draft', 'draft')
await expect
Expand Down Expand Up @@ -391,6 +433,12 @@ createRoot(document.getElementById('root')).render(createElement(Fixture));`,
await userInput('#draft')
await panelAction({ action: 'reload' })
await expect(shell.getByRole('button', { name: 'Stay', exact: true })).toBeFocused()
await shell.getByRole('dialog').screenshot({
path: test.info().outputPath('leave-page-modal.png'),
animations: 'allow',
caret: 'initial',
})
await expect(shell.getByRole('button', { name: 'Stay', exact: true })).toBeFocused()
await shell.keyboard.press('Escape')
await expect.poll(pageDialog).toBeNull()
await expect
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/e2e/executor-sim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ export async function launch(
app.context().pages().forEach(leaveDialogsToDesktop)
app.context().on('page', leaveDialogsToDesktop)
const window = await app.firstWindow()
await window.waitForURL((url) => url.origin === sim.origin, { waitUntil: 'load' })
return { app, window }
}

Expand Down
76 changes: 71 additions & 5 deletions apps/desktop/e2e/local-files.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,36 @@ createRoot(document.getElementById('settings')).render(
.click({ noWaitAfter: true })
expect(await survivor).toMatchObject({ ok: true, data: { text: 'shared contents' } })
})
await test.step('expired requests cannot enable Full file access from a confirmation', async () => {
if (!app) throw new Error('Desktop app is not running')
calls.expiringFullAccess = {
toolName: 'read_local_file',
args: { path: join(outside, 'private.txt') },
}
const permission = await requestPermission({
operation: 'read',
toolCallId: 'expiringFullAccess',
})
const shown = app.waitForEvent('window')
await permission.prompt
.getByRole('button', { name: 'Full file access', exact: true })
.click({ noWaitAfter: true })
const confirmation = await shown
calls.expiringFullAccess = undefined
await confirmation
.getByRole('button', { name: 'Enable', exact: true })
.click({ noWaitAfter: true })
expect(await permission.result).toMatchObject({ ok: false })
expect(
await window.evaluate(async () =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.settings.getPreferences()
)
).toMatchObject({ fullFileAccess: false })
})
await test.step('Full file access is opt-in, survives restart, and stops granting access when disabled', async () => {
if (!app) throw new Error('Desktop app is not running')
const fullFolder = join(root, 'Full access')
mkdirSync(fullFolder)
writeFileSync(join(fullFolder, 'file.txt'), 'full access contents')
Expand All @@ -315,7 +344,40 @@ createRoot(document.getElementById('settings')).render(
await expect(
window.getByRole('switch', { name: 'Full file access', exact: true })
).not.toBeChecked()
await window.getByRole('switch', { name: 'Full file access', exact: true }).click()
const permission = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' })
const confirmationShown = app.waitForEvent('window')
await permission.prompt
.getByRole('button', { name: 'Full file access', exact: true })
.click({ noWaitAfter: true })
const confirmation = await confirmationShown
await expect(confirmation.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused()
const returnedPrompt = app.waitForEvent('window')
await confirmation
.getByRole('button', { name: 'Cancel', exact: true })
.click({ noWaitAfter: true })
const folderPrompt = await returnedPrompt
expect(
await window.evaluate(async () =>
(
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
).simDesktop.settings.getPreferences()
)
).toMatchObject({ fullFileAccess: false })
const acceptedConfirmation = app.waitForEvent('window')
await folderPrompt
.getByRole('button', { name: 'Full file access', exact: true })
.click({ noWaitAfter: true })
const allowAll = await acceptedConfirmation
await allowAll.screenshot({
path: test.info().outputPath('full-file-access-confirmation.png'),
})
await allowAll
.getByRole('button', { name: 'Enable', exact: true })
.click({ noWaitAfter: true })
expect(await permission.result).toMatchObject({
ok: true,
data: { text: 'full access contents' },
})
await expect(
window.getByRole('switch', { name: 'Full file access', exact: true })
).toBeChecked()
Expand Down Expand Up @@ -383,11 +445,11 @@ createRoot(document.getElementById('settings')).render(
await expect(
window.getByRole('switch', { name: 'Full file access', exact: true })
).not.toBeChecked()
const permission = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' })
await permission.prompt
const revoked = await requestPermission({ operation: 'read', toolCallId: 'fullAccess' })
await revoked.prompt
.getByRole('button', { name: "Don't allow", exact: true })
.click({ noWaitAfter: true })
expect(await permission.result).toMatchObject({ ok: false })
expect(await revoked.result).toMatchObject({ ok: false })
})
await test.step('a folder grant works in another chat but does not permit symlink escapes', async () => {
expect(await invoke({ operation: 'read', toolCallId: 'otherChat' })).toMatchObject({
Expand Down Expand Up @@ -877,8 +939,12 @@ createRoot(document.getElementById('settings')).render(
try {
await toggle.click()
await expect(
window.getByText('Could not update file access', { exact: true })
window.getByText(
'Could not save file access settings. Your previous setting may return after restarting Sim.',
{ exact: true }
)
).toBeVisible()
await expect(toggle).not.toBeChecked()
expect(
await window.evaluate(async () =>
(
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ test.describe('desktop shell smoke', () => {
test('OAuth popups share the session without inheriting the privileged preload', async () => {
app = await launchApp(origin)
const window = await app.firstWindow()
await window.waitForURL(`${origin}/home`, { waitUntil: 'load' })
await window.evaluate(() => {
document.cookie = 'sim-e2e-session=shared; Path=/; SameSite=Lax'
})
Expand Down Expand Up @@ -319,7 +320,7 @@ test.describe('desktop shell smoke', () => {
await window.locator('#server').click()
const picker = await pickerPromise

expect(picker.url()).toBe('sim-shell://pages/server.html')
await expect(picker).toHaveURL('sim-shell://pages/server.html')
await expect(picker.getByRole('dialog', { name: 'Sim server', exact: true })).toBeVisible()
await expect(picker.getByLabel('Server URL')).toHaveValue('http://127.0.0.1:1')
await expect(picker.getByLabel('Server URL')).toBeFocused()
Expand Down
10 changes: 8 additions & 2 deletions apps/desktop/src/main/desktop-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface DesktopSettingsService {

interface DesktopSettingsServiceDeps {
config: ConfigStore
onFullFileAccessChanged?: (preferences: DesktopPreferences) => void
getMainWindow: () => BrowserWindow | null
openMainWindowAt: (route?: string) => void
setAutoDownloadUpdates: (enabled: boolean) => void
Expand Down Expand Up @@ -185,9 +186,14 @@ export function createDesktopSettingsService(
deps.config.set('fullFileAccess', enabled)
if (!deps.config.flush()) {
deps.config.set('fullFileAccess', false)
throw new Error('Could not save file access settings')
deps.onFullFileAccessChanged?.(read())
Comment thread
waleedlatif1 marked this conversation as resolved.
throw new Error(
'Could not save file access settings. Your previous setting may return after restarting Sim.'
)
}
return read()
const preferences = read()
deps.onFullFileAccessChanged?.(preferences)
return preferences
},
setPreventSleepWhileRunning(enabled) {
deps.config.set('preventSleepWhileRunning', enabled)
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,10 @@ function main(): void {
})
const localFilePermissions = new LocalFilePermissions(
localFilesystem,
() => config.get('fullFileAccess') === true
() => config.get('fullFileAccess') === true,
() => {
desktopSettings.setFullFileAccess(true)
}
)
const clearLocalFileAccess = async () => {
config.set('fullFileAccess', false)
Expand Down Expand Up @@ -546,6 +549,9 @@ function main(): void {

const desktopSettings = createDesktopSettingsService({
config,
onFullFileAccessChanged: (preferences) => {
broadcast('desktop:settings:full-file-access-changed', preferences)
},
getMainWindow,
openMainWindowAt: (route) => void openMainWindowAt(route),
setAutoDownloadUpdates: (enabled) => updater?.setAutoDownload(enabled),
Expand Down
77 changes: 56 additions & 21 deletions apps/desktop/src/main/local-file-permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export class LocalFilePermissions {

constructor(
private readonly filesystem: LocalFilesystemService,
private readonly fullFileAccess: () => boolean = () => false
private readonly fullFileAccess: () => boolean = () => false,
private readonly enableFullFileAccess?: () => void
) {}

async authorize(
Expand All @@ -71,18 +72,7 @@ export class LocalFilePermissions {
throw new Error('A valid destination workspace and folder are required for imports.')
const path = await realpath(nativePath(authorization.args.path))
if (this.fullFileAccess()) {
const info = await stat(path)
const folder = info.isDirectory() ? path : dirname(path)
const identity = await stat(folder, { bigint: true })
return this.authorizedAccess(
{
path,
resolve: realpath,
open: (requested, directory = false) =>
openNativeFile(folder, relative(folder, requested), identity, directory),
},
{ ...context, isCurrent: () => context.isCurrent() && this.fullFileAccess() }
)
return this.unrestrictedAccess(path, context)
}
const existing = await this.filesystem.nativeAccess(path)
if (existing) return this.authorizedAccess(existing, context)
Expand Down Expand Up @@ -114,11 +104,30 @@ export class LocalFilePermissions {
}
pending.contexts.add(context)
await this.waitForDecision(pending, context)
if (this.fullFileAccess()) return this.unrestrictedAccess(path, context)
const access = await this.filesystem.nativeAccess(path)
if (!access) throw new Error('The approved folder is no longer available.')
return this.authorizedAccess(access, context)
}

private async unrestrictedAccess(
path: string,
context: LocalFilePermissionContext
): Promise<LocalFileAccess> {
const info = await stat(path)
const folder = info.isDirectory() ? path : dirname(path)
const identity = await stat(folder, { bigint: true })
return this.authorizedAccess(
{
path,
resolve: realpath,
open: (requested, directory = false) =>
openNativeFile(folder, relative(folder, requested), identity, directory),
},
{ ...context, isCurrent: () => context.isCurrent() && this.fullFileAccess() }
)
}

private waitForDecision(
pending: PendingFolderDecision,
context: LocalFilePermissionContext
Expand Down Expand Up @@ -170,7 +179,7 @@ export class LocalFilePermissions {
signal: AbortSignal
): Promise<void> {
const context = await this.currentContext(contexts, signal)
if (await this.filesystem.nativeAccess(folder)) return
if (this.fullFileAccess() || (await this.filesystem.nativeAccess(folder))) return
const root = await lstat(folder, { bigint: true })
if (!root.isDirectory()) throw new Error('The folder is no longer available.')
const displayedPath = JSON.stringify(folder).replace(
Expand All @@ -184,16 +193,42 @@ export class LocalFilePermissions {
signal,
title: 'Allow access to this folder?',
message: displayedPath,
detail: `Sim can read files in this folder and its subfolders, use them across chats, and import them into your workspaces on ${context.origin}.\n\nManage or remove access in File → Folder Access.`,
buttons: ['Allow folder', "Don't allow"],
detail: `Sim can read and import files from this folder and its subfolders across chats on ${context.origin}.\n\nManage access in File → Folder Access.`,
buttons: [
'Allow folder',
"Don't allow",
...(this.enableFullFileAccess ? ['Full file access'] : []),
],
defaultId: 1,
cancelId: 1,
}
const result = await (parent ? showShellDialog(parent, options) : showShellDialog(options))
signal.throwIfAborted()
if (result.response !== 0) throw new Error('The user did not allow this local file access.')
const current = await this.currentContext(contexts, signal)
await this.filesystem.grantDirectory({ path: folder }, current.generation, root)
while (true) {
await this.currentContext(contexts, signal)
const result = await (parent ? showShellDialog(parent, options) : showShellDialog(options))
signal.throwIfAborted()
if (result.response === 2 && this.enableFullFileAccess) {
const confirmation = {
signal,
title: 'Enable full file access?',
message: `Sim can read and import files from any folder on this computer across chats on ${context.origin}.`,
detail: 'Turn this off in Settings → Desktop → Full file access.',
buttons: ['Enable', 'Cancel'],
defaultId: 1,
cancelId: 1,
}
const answer = await (parent
? showShellDialog(parent, confirmation)
: showShellDialog(confirmation))
await this.currentContext(contexts, signal)
if (answer.response !== 0) continue
this.enableFullFileAccess()
return
}
if (result.response !== 0) throw new Error('The user did not allow this local file access.')
const current = await this.currentContext(contexts, signal)
await this.filesystem.grantDirectory({ path: folder }, current.generation, root)
return
}
}

private async authorizedAccess(
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ const api: SimDesktopApi = {
ipcRenderer.invoke('desktop:settings:set', key, value),
setFullFileAccess: (enabled: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set-full-file-access', enabled),
onFullFileAccessChanged: (
callback: (preferences: DesktopPreferences) => void
): (() => void) => {
const listener = (_event: unknown, preferences: DesktopPreferences) => callback(preferences)
ipcRenderer.on('desktop:settings:full-file-access-changed', listener)
return () => ipcRenderer.removeListener('desktop:settings:full-file-access-changed', listener)
},
setPreventSleepWhileRunning: (enabled: boolean): Promise<DesktopPreferences> =>
ipcRenderer.invoke('desktop:settings:set-prevent-sleep', enabled),
setBrowserSearchSuggestionsEnabled: (enabled: boolean): Promise<DesktopPreferences> =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export function BrowserPageDialogModal({ dialog, open, onAnswer }: BrowserPageDi
<ChipConfirmModal
open={open}
onOpenChange={(nextOpen) => !nextOpen && answer(false)}
title='Leave site?'
text='Changes you made may not be saved.'
title='Leave page?'
text='Changes on this page may not be saved.'
defaultAction='dismiss'
dismissLabel='Stay'
confirm={{ label: 'Leave', onClick: () => answer(true) }}
Expand Down
Loading
Loading