|
6 | 6 | * durable Stop write, and results arrive through the confirm route. |
7 | 7 | */ |
8 | 8 | import { authMock, authMockFns } from '@sim/testing/mocks/auth.mock' |
9 | | -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' |
| 9 | +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' |
10 | 10 |
|
11 | 11 | const { redisUrl, inheritedEnv } = await vi.hoisted(async () => { |
12 | 12 | const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') |
@@ -36,6 +36,7 @@ import { |
36 | 36 | closeStreamToolAdmission, |
37 | 37 | requestRunStop, |
38 | 38 | } from '@/lib/mothership/async-runs/repository' |
| 39 | +import { getAutoAllowedTools } from '@/lib/mothership/persistence/tool-permission/auto-allow' |
39 | 40 | import { prePersistClientExecutableToolCall } from '@/lib/mothership/request/handlers' |
40 | 41 | import { waitForClientToolCompletion } from '@/lib/mothership/request/tools/client' |
41 | 42 | import { TraceCollector } from '@/lib/mothership/request/trace' |
@@ -72,7 +73,7 @@ const reportsResult = (toolCallId: string, status: 'success' | 'error' = 'succes |
72 | 73 | data: { ok: true }, |
73 | 74 | }) |
74 | 75 |
|
75 | | -const userAnswers = (toolCallId: string, decision: 'allow' | 'skip') => |
| 76 | +const userAnswers = (toolCallId: string, decision: 'allow' | 'allow_chat' | 'skip') => |
76 | 77 | post(toolPermissionPOST, '/api/copilot/tool-permission', { |
77 | 78 | decisions: [{ toolCallId, decision }], |
78 | 79 | }) |
@@ -160,7 +161,11 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit |
160 | 161 | streamComplete: false, |
161 | 162 | wasAborted: false, |
162 | 163 | errors: [], |
163 | | - toolPermissions: { enabled: true, autoAllowed: new Set(), autoAllowPermitted: true }, |
| 164 | + toolPermissions: { |
| 165 | + enabled: true, |
| 166 | + autoAllowed: await getAutoAllowedTools(userId, chatId), |
| 167 | + autoAllowPermitted: true, |
| 168 | + }, |
164 | 169 | } |
165 | 170 | await prePersistClientExecutableToolCall( |
166 | 171 | { |
@@ -224,6 +229,11 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit |
224 | 229 | }) |
225 | 230 | }) |
226 | 231 |
|
| 232 | + /** Turns read the chat's stored allow list, so one test's "don't ask again" must not leak. */ |
| 233 | + afterEach(async () => { |
| 234 | + await db.update(copilotChats).set({ autoAllowedTools: [] }).where(eq(copilotChats.id, chatId)) |
| 235 | + }) |
| 236 | + |
227 | 237 | afterAll(async () => { |
228 | 238 | await db.delete(copilotChats).where(eq(copilotChats.id, chatId)) |
229 | 239 | await deleteWorkspaceFixture(db, eq(workspace.id, workspaceId)) |
@@ -262,6 +272,37 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit |
262 | 272 | expect(await storedCall(toolCallId)).toMatchObject({ status: 'pending', claimedBy: null }) |
263 | 273 | }) |
264 | 274 |
|
| 275 | + it('refuses to act in the signed-in browser until the user approves it', async () => { |
| 276 | + const { runId } = await startRun() |
| 277 | + const toolCallId = await agentCalls(runId, 'browser_click', { elementId: 4 }) |
| 278 | + |
| 279 | + expect((await desktopClaims(toolCallId)).status).toBe(403) |
| 280 | + expect(await storedCall(toolCallId)).toMatchObject({ status: 'pending', claimedBy: null }) |
| 281 | + |
| 282 | + expect((await userAnswers(toolCallId, 'allow')).status).toBe(200) |
| 283 | + expect((await desktopClaims(toolCallId)).status).toBe(200) |
| 284 | + }) |
| 285 | + |
| 286 | + it('remembers allowing the browser for this chat across every browser action, and only those', async () => { |
| 287 | + const { runId } = await startRun() |
| 288 | + const clickId = await agentCalls(runId, 'browser_click', { elementId: 4 }) |
| 289 | + expect((await userAnswers(clickId, 'allow_chat')).status).toBe(200) |
| 290 | + |
| 291 | + const nextTurn = await startRun() |
| 292 | + const typeId = await agentCalls(nextTurn.runId, 'browser_type', { elementId: 4, text: 'hi' }) |
| 293 | + expect((await desktopClaims(typeId)).status).toBe(200) |
| 294 | + |
| 295 | + const commandId = await agentCalls(nextTurn.runId, 'terminal', { |
| 296 | + operation: 'run', |
| 297 | + args: { command: 'ls' }, |
| 298 | + }) |
| 299 | + const commandClaim = await desktopClaims(commandId) |
| 300 | + expect(commandClaim.status).toBe(403) |
| 301 | + expect(await commandClaim.json()).toMatchObject({ |
| 302 | + error: 'The user has not approved this tool call', |
| 303 | + }) |
| 304 | + }) |
| 305 | + |
265 | 306 | it('refuses a call the user declined before its gate was recorded on the row', async () => { |
266 | 307 | const { runId } = await startRun() |
267 | 308 | const toolCallId = generateId() |
@@ -371,7 +412,9 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit |
371 | 412 |
|
372 | 413 | it('acknowledges a desktop result that lands after Stop without reviving the call', async () => { |
373 | 414 | const { runId, streamId } = await startRun() |
374 | | - const toolCallId = await desktopIsRunning(runId, 'browser_click') |
| 415 | + const toolCallId = await agentCalls(runId, 'browser_click', { elementId: 4 }) |
| 416 | + expect((await userAnswers(toolCallId, 'allow')).status).toBe(200) |
| 417 | + expect((await desktopClaims(toolCallId)).status).toBe(200) |
375 | 418 | await requestRunStop({ userId, workspaceId, streamId, chatId }) |
376 | 419 | expect(await storedCall(toolCallId)).toMatchObject({ |
377 | 420 | status: 'cancelled', |
|
0 commit comments