|
1 | | -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' |
| 1 | +import { |
| 2 | + existsSync, |
| 3 | + mkdirSync, |
| 4 | + mkdtempSync, |
| 5 | + readFileSync, |
| 6 | + renameSync, |
| 7 | + writeFileSync, |
| 8 | +} from 'node:fs' |
2 | 9 | import { tmpdir } from 'node:os' |
3 | 10 | import { join } from 'node:path' |
4 | 11 | import { type ElectronApplication, expect, test } from '@playwright/test' |
@@ -203,6 +210,115 @@ test.describe('background executor', () => { |
203 | 210 | }) |
204 | 211 | }) |
205 | 212 |
|
| 213 | + test('background consent cannot grant access when Sim cannot verify the call', async () => { |
| 214 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-offline-consent-')) |
| 215 | + const launched = await launch(sim, userData) |
| 216 | + app = launched.app |
| 217 | + const deviceId = await registeredDevice(sim) |
| 218 | + const readable = join(userData, 'private.txt') |
| 219 | + writeFileSync(readable, 'offline consent fixture') |
| 220 | + |
| 221 | + await check('offline approval returns an error without remembering a grant', async () => { |
| 222 | + const shown = launched.app.waitForEvent('window') |
| 223 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 224 | + const prompt = await shown |
| 225 | + await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible() |
| 226 | + sim.disconnect() |
| 227 | + await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click() |
| 228 | + await expect |
| 229 | + .poll(() => |
| 230 | + launched.app.evaluate(({ safeStorage }, toolCallId) => { |
| 231 | + const fs = process.getBuiltinModule('node:fs') |
| 232 | + const path = `${process.env.SIM_DESKTOP_USER_DATA}/desktop-executor-journal.json` |
| 233 | + const envelope = JSON.parse(fs.readFileSync(path, 'utf8')) |
| 234 | + const journal = JSON.parse( |
| 235 | + safeStorage.decryptString(Buffer.from(envelope.ciphertext, 'base64')) |
| 236 | + ) as { entries: { toolCallId: string; state: string }[] } |
| 237 | + return journal.entries.find((entry) => entry.toolCallId === toolCallId)?.state |
| 238 | + }, call) |
| 239 | + ) |
| 240 | + .toBe('result') |
| 241 | + sim.reconnect() |
| 242 | + const completion = await settled(sim, call) |
| 243 | + expect(completion.status).toBe('error') |
| 244 | + expect(JSON.stringify(completion)).not.toContain('offline consent fixture') |
| 245 | + const nextPrompt = launched.app.waitForEvent('window') |
| 246 | + const next = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 247 | + await (await nextPrompt).getByRole('button', { name: "Don't allow", exact: true }).click() |
| 248 | + expect((await settled(sim, next)).status).toBe('error') |
| 249 | + }) |
| 250 | + }) |
| 251 | + |
| 252 | + test('background consent does not reopen the main app after its windows are closed', async () => { |
| 253 | + test.skip(process.platform !== 'darwin', 'The macOS app remains running without a window.') |
| 254 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-windowless-consent-')) |
| 255 | + const launched = await launch(sim, userData) |
| 256 | + app = launched.app |
| 257 | + const deviceId = await registeredDevice(sim) |
| 258 | + const readable = join(userData, 'private.txt') |
| 259 | + writeFileSync(readable, 'windowless consent fixture') |
| 260 | + await launched.window.close() |
| 261 | + |
| 262 | + await check('only the standalone consent window opens for a background read', async () => { |
| 263 | + const shown = launched.app.waitForEvent('window') |
| 264 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 265 | + const prompt = await shown |
| 266 | + await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible() |
| 267 | + expect( |
| 268 | + await launched.app.evaluate(({ BrowserWindow }) => |
| 269 | + BrowserWindow.getAllWindows().map((window) => window.getParentWindow() === null) |
| 270 | + ) |
| 271 | + ).toEqual([true]) |
| 272 | + await prompt.getByRole('button', { name: "Don't allow", exact: true }).click() |
| 273 | + expect((await settled(sim, call)).status).toBe('error') |
| 274 | + }) |
| 275 | + }) |
| 276 | + |
| 277 | + test('sign-out clears folder grants even when desktop settings cannot be saved', async () => { |
| 278 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-grant-cleanup-')) |
| 279 | + const launched = await launch(sim, userData) |
| 280 | + app = launched.app |
| 281 | + const deviceId = await registeredDevice(sim) |
| 282 | + const readable = join(userData, 'private.txt') |
| 283 | + writeFileSync(readable, 'cleanup consent fixture') |
| 284 | + const shown = launched.app.waitForEvent('window') |
| 285 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 286 | + await (await shown).getByRole('button', { name: 'Allow folder', exact: true }).click() |
| 287 | + expect((await settled(sim, call)).status).toBe('success') |
| 288 | + const grants = join(userData, 'local-filesystem-grants.json') |
| 289 | + expect(existsSync(grants)).toBe(true) |
| 290 | + await launched.window.evaluate(() => { |
| 291 | + const button = document.createElement('button') |
| 292 | + button.textContent = 'Enable full file access' |
| 293 | + button.onclick = async () => { |
| 294 | + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop |
| 295 | + await api.settings.setFullFileAccess?.(true) |
| 296 | + button.textContent = 'Full file access enabled' |
| 297 | + } |
| 298 | + document.body.append(button) |
| 299 | + }) |
| 300 | + await launched.window |
| 301 | + .getByRole('button', { name: 'Enable full file access', exact: true }) |
| 302 | + .click() |
| 303 | + await expect( |
| 304 | + launched.window.getByRole('button', { name: 'Full file access enabled', exact: true }) |
| 305 | + ).toBeVisible() |
| 306 | + const settings = join(userData, 'settings.json') |
| 307 | + if (existsSync(settings)) renameSync(settings, `${settings}.backup`) |
| 308 | + mkdirSync(settings) |
| 309 | + |
| 310 | + await check('failed settings persistence does not skip independent grant cleanup', async () => { |
| 311 | + await launched.app.evaluate(({ Menu }) => { |
| 312 | + const item = Menu.getApplicationMenu() |
| 313 | + ?.items.flatMap((entry) => entry.submenu?.items ?? []) |
| 314 | + .find((entry) => entry.label === 'Sign Out') |
| 315 | + if (!item) throw new Error('Missing Sign Out menu item') |
| 316 | + item.click() |
| 317 | + }) |
| 318 | + await expect.poll(() => existsSync(grants)).toBe(false) |
| 319 | + }) |
| 320 | + }) |
| 321 | + |
206 | 322 | test('B: a result produced while the network is cut is delivered once after reconnecting', async () => { |
207 | 323 | const userData = mkdtempSync(join(tmpdir(), 'sim-executor-b-')) |
208 | 324 | app = (await launch(sim, userData)).app |
|
0 commit comments