|
1 | | -import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' |
| 1 | +import { |
| 2 | + existsSync, |
| 3 | + mkdirSync, |
| 4 | + mkdtempSync, |
| 5 | + readFileSync, |
| 6 | + renameSync, |
| 7 | + rmSync, |
| 8 | + writeFileSync, |
| 9 | +} from 'node:fs' |
2 | 10 | import { tmpdir } from 'node:os' |
3 | 11 | import { join } from 'node:path' |
4 | 12 | import { type ElectronApplication, expect, test } from '@playwright/test' |
@@ -104,7 +112,9 @@ test.describe('background executor', () => { |
104 | 112 | args: { command: `sleep 1; echo B-${n} >> '${marker}'`, waitSeconds: 30 }, |
105 | 113 | }) |
106 | 114 | ) |
| 115 | + const readConsent = launched.app.waitForEvent('window') |
107 | 116 | const localRead = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 117 | + await (await readConsent).getByRole('button', { name: 'Allow folder', exact: true }).click() |
108 | 118 |
|
109 | 119 | await window.goto(`${sim.origin}/workspace/ws-other/home`) |
110 | 120 | await window.reload() |
@@ -157,6 +167,171 @@ test.describe('background executor', () => { |
157 | 167 | }) |
158 | 168 | }) |
159 | 169 |
|
| 170 | + test('background file reads require consent and Stop cancels pending permission', async () => { |
| 171 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-consent-')) |
| 172 | + const launched = await launch(sim, userData) |
| 173 | + app = launched.app |
| 174 | + const deviceId = await registeredDevice(sim) |
| 175 | + const readable = join(userData, 'private.txt') |
| 176 | + writeFileSync(readable, 'background consent fixture') |
| 177 | + |
| 178 | + await check('background read stays pending until folder consent', async () => { |
| 179 | + const shown = launched.app.waitForEvent('window', { timeout: 10_000 }) |
| 180 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 181 | + const prompt = await shown |
| 182 | + await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible() |
| 183 | + expect(sim.requireCall(call).completions).toHaveLength(0) |
| 184 | + await prompt.getByRole('button', { name: "Don't allow", exact: true }).click() |
| 185 | + const completion = await settled(sim, call) |
| 186 | + expect(completion.status).toBe('error') |
| 187 | + expect(JSON.stringify(completion)).not.toContain('background consent fixture') |
| 188 | + }) |
| 189 | + |
| 190 | + await check('Stop dismisses background consent without granting access', async () => { |
| 191 | + const shown = launched.app.waitForEvent('window', { timeout: 10_000 }) |
| 192 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 193 | + const prompt = await shown |
| 194 | + await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible() |
| 195 | + sim.stopCall(call) |
| 196 | + await expect.poll(() => prompt.isClosed()).toBe(true) |
| 197 | + await settled(sim, call) |
| 198 | + expect(sim.requireCall(call).completions[0]?.outcome).toBe('superseded') |
| 199 | + }) |
| 200 | + |
| 201 | + await check('approved background reads reuse the shared folder grant', async () => { |
| 202 | + const shown = launched.app.waitForEvent('window', { timeout: 10_000 }) |
| 203 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 204 | + const prompt = await shown |
| 205 | + await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click() |
| 206 | + expect((await settled(sim, call)).status).toBe('success') |
| 207 | + const next = sim.issue(deviceId, CHAT_A, 'read_local_file', { path: readable }) |
| 208 | + expect(JSON.stringify((await settled(sim, next)).data)).toContain( |
| 209 | + 'background consent fixture' |
| 210 | + ) |
| 211 | + }) |
| 212 | + }) |
| 213 | + |
| 214 | + test('background consent cannot grant access when Sim cannot verify the call', async () => { |
| 215 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-offline-consent-')) |
| 216 | + const launched = await launch(sim, userData) |
| 217 | + app = launched.app |
| 218 | + const deviceId = await registeredDevice(sim) |
| 219 | + const readable = join(userData, 'private.txt') |
| 220 | + writeFileSync(readable, 'offline consent fixture') |
| 221 | + |
| 222 | + await check('offline approval returns an error without remembering a grant', async () => { |
| 223 | + const shown = launched.app.waitForEvent('window') |
| 224 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 225 | + const prompt = await shown |
| 226 | + await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible() |
| 227 | + sim.disconnect() |
| 228 | + await prompt.getByRole('button', { name: 'Allow folder', exact: true }).click() |
| 229 | + await expect |
| 230 | + .poll(() => |
| 231 | + launched.app.evaluate(({ safeStorage }, toolCallId) => { |
| 232 | + const fs = process.getBuiltinModule('node:fs') |
| 233 | + const path = `${process.env.SIM_DESKTOP_USER_DATA}/desktop-executor-journal.json` |
| 234 | + const envelope = JSON.parse(fs.readFileSync(path, 'utf8')) |
| 235 | + const journal = JSON.parse( |
| 236 | + safeStorage.decryptString(Buffer.from(envelope.ciphertext, 'base64')) |
| 237 | + ) as { entries: { toolCallId: string; state: string }[] } |
| 238 | + return journal.entries.find((entry) => entry.toolCallId === toolCallId)?.state |
| 239 | + }, call) |
| 240 | + ) |
| 241 | + .toBe('result') |
| 242 | + sim.reconnect() |
| 243 | + const completion = await settled(sim, call) |
| 244 | + expect(completion.status).toBe('error') |
| 245 | + expect(JSON.stringify(completion)).not.toContain('offline consent fixture') |
| 246 | + const nextPrompt = launched.app.waitForEvent('window') |
| 247 | + const next = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 248 | + await (await nextPrompt).getByRole('button', { name: "Don't allow", exact: true }).click() |
| 249 | + expect((await settled(sim, next)).status).toBe('error') |
| 250 | + }) |
| 251 | + }) |
| 252 | + |
| 253 | + test('background consent does not reopen the main app after its windows are closed', async () => { |
| 254 | + test.skip(process.platform !== 'darwin', 'The macOS app remains running without a window.') |
| 255 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-windowless-consent-')) |
| 256 | + const launched = await launch(sim, userData) |
| 257 | + app = launched.app |
| 258 | + const deviceId = await registeredDevice(sim) |
| 259 | + const readable = join(userData, 'private.txt') |
| 260 | + writeFileSync(readable, 'windowless consent fixture') |
| 261 | + await launched.window.close() |
| 262 | + |
| 263 | + await check('only the standalone consent window opens for a background read', async () => { |
| 264 | + const shown = launched.app.waitForEvent('window') |
| 265 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 266 | + const prompt = await shown |
| 267 | + await expect(prompt.getByRole('button', { name: 'Allow folder', exact: true })).toBeVisible() |
| 268 | + expect( |
| 269 | + await launched.app.evaluate(({ BrowserWindow }) => |
| 270 | + BrowserWindow.getAllWindows().map((window) => window.getParentWindow() === null) |
| 271 | + ) |
| 272 | + ).toEqual([true]) |
| 273 | + await prompt.getByRole('button', { name: "Don't allow", exact: true }).click() |
| 274 | + expect((await settled(sim, call)).status).toBe('error') |
| 275 | + }) |
| 276 | + }) |
| 277 | + |
| 278 | + test('sign-out clears folder grants even when desktop settings cannot be saved', async () => { |
| 279 | + const userData = mkdtempSync(join(tmpdir(), 'sim-executor-grant-cleanup-')) |
| 280 | + const launched = await launch(sim, userData) |
| 281 | + app = launched.app |
| 282 | + const deviceId = await registeredDevice(sim) |
| 283 | + const readable = join(userData, 'private.txt') |
| 284 | + writeFileSync(readable, 'cleanup consent fixture') |
| 285 | + const shown = launched.app.waitForEvent('window') |
| 286 | + const call = sim.issue(deviceId, CHAT_B, 'read_local_file', { path: readable }) |
| 287 | + await (await shown).getByRole('button', { name: 'Allow folder', exact: true }).click() |
| 288 | + expect((await settled(sim, call)).status).toBe('success') |
| 289 | + const grants = join(userData, 'local-filesystem-grants.json') |
| 290 | + expect(existsSync(grants)).toBe(true) |
| 291 | + await launched.window.evaluate(() => { |
| 292 | + const button = document.createElement('button') |
| 293 | + button.textContent = 'Enable full file access' |
| 294 | + button.onclick = async () => { |
| 295 | + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop |
| 296 | + await api.settings.setFullFileAccess?.(true) |
| 297 | + button.textContent = 'Full file access enabled' |
| 298 | + } |
| 299 | + document.body.append(button) |
| 300 | + }) |
| 301 | + await launched.window |
| 302 | + .getByRole('button', { name: 'Enable full file access', exact: true }) |
| 303 | + .click() |
| 304 | + await expect( |
| 305 | + launched.window.getByRole('button', { name: 'Full file access enabled', exact: true }) |
| 306 | + ).toBeVisible() |
| 307 | + const settings = join(userData, 'settings.json') |
| 308 | + if (existsSync(settings)) renameSync(settings, `${settings}.backup`) |
| 309 | + mkdirSync(settings) |
| 310 | + |
| 311 | + try { |
| 312 | + await check( |
| 313 | + 'failed settings persistence does not skip independent grant cleanup', |
| 314 | + async () => { |
| 315 | + const failedSignOut = launched.app.waitForEvent('window') |
| 316 | + await launched.app.evaluate(({ Menu }) => { |
| 317 | + const item = Menu.getApplicationMenu() |
| 318 | + ?.items.flatMap((entry) => entry.submenu?.items ?? []) |
| 319 | + .find((entry) => entry.label === 'Sign Out') |
| 320 | + if (!item) throw new Error('Missing Sign Out menu item') |
| 321 | + item.click() |
| 322 | + }) |
| 323 | + const failure = await failedSignOut |
| 324 | + await failure.getByRole('button', { name: 'OK', exact: true }).click() |
| 325 | + expect(existsSync(join(userData, 'account-data-teardown-required.json'))).toBe(true) |
| 326 | + await expect.poll(() => existsSync(grants)).toBe(false) |
| 327 | + } |
| 328 | + ) |
| 329 | + } finally { |
| 330 | + rmSync(settings, { recursive: true, force: true }) |
| 331 | + if (existsSync(`${settings}.backup`)) renameSync(`${settings}.backup`, settings) |
| 332 | + } |
| 333 | + }) |
| 334 | + |
160 | 335 | test('B: a result produced while the network is cut is delivered once after reconnecting', async () => { |
161 | 336 | const userData = mkdtempSync(join(tmpdir(), 'sim-executor-b-')) |
162 | 337 | app = (await launch(sim, userData)).app |
@@ -237,12 +412,15 @@ test.describe('background executor', () => { |
237 | 412 | writeFileSync(join(source, 'q3', 'export.bin'), large) |
238 | 413 | await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`) |
239 | 414 |
|
| 415 | + const importConsent = launched.app.waitForEvent('window') |
240 | 416 | const call = sim.issue(deviceId, CHAT_B, 'import_local_files', { |
241 | 417 | path: source, |
242 | 418 | targetWorkspaceId: WORKSPACE, |
243 | 419 | folderId: 'folder-e2e', |
244 | 420 | }) |
245 | 421 |
|
| 422 | + await (await importConsent).getByRole('button', { name: 'Allow folder', exact: true }).click() |
| 423 | + |
246 | 424 | await check('D: the import completes with every entry it stored', async () => { |
247 | 425 | const completion = await settled(sim, call, 60_000) |
248 | 426 | expect(completion.status).toBe('success') |
|
0 commit comments