Skip to content

Commit b19f1cc

Browse files
committed
fix(mothership): settle every open desktop call on Stop and answer 410 after it
Stop also settles delivered desktop calls and reads of granted local folders. Authorize checks admission before the call's status, so a call Stop already settled answers 410 rather than 404.
1 parent 2f9688d commit b19f1cc

3 files changed

Lines changed: 39 additions & 9 deletions

File tree

‎apps/sim/app/api/desktop/tool/authorize/route.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5959
if (!parsed.success) return parsed.response
6060

6161
const toolCall = await getAsyncToolCall(parsed.data.body.toolCallId)
62-
if (!toolCall || (toolCall.status !== 'pending' && toolCall.status !== 'running')) {
63-
return createNotFoundResponse('Pending client tool call not found')
64-
}
62+
if (!toolCall) return createNotFoundResponse('Pending client tool call not found')
6563
const run = await getRunSegment(toolCall.runId)
6664
if (!run || run.userId !== userId) {
6765
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
6866
}
67+
// Ahead of the status checks: Stop settles the run's open calls in the same commit.
68+
if (run.toolAdmissionClosedAt) return admissionClosedResponse()
69+
if (toolCall.status !== 'pending' && toolCall.status !== 'running') {
70+
return createNotFoundResponse('Pending client tool call not found')
71+
}
6972
if (run.status === 'complete' || run.status === 'error' || run.status === 'cancelled') {
7073
return createNotFoundResponse('Pending client tool call not found')
7174
}
72-
if (run.toolAdmissionClosedAt) return admissionClosedResponse()
7375

7476
const args = isRecordLike(toolCall.args) ? (toolCall.args as Record<string, unknown>) : {}
7577
const isBrowserTool = isCurrentBrowserToolName(toolCall.toolName)

‎apps/sim/lib/mothership/async-runs/repository.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
} from '@/lib/mothership/observability/database'
5959
import type { BillingAdmission } from '@/lib/mothership/request/lifecycle/recovery-config'
6060
import { markSpanForError } from '@/lib/mothership/request/otel'
61+
import { USER_LOCAL_VFS_ROOT } from '@/lib/mothership/tools/local-filesystem'
6162
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
6263

6364
const logger = createLogger('CopilotAsyncRunsRepo')
@@ -216,6 +217,22 @@ export async function requestRunStop(
216217
* none can be claimed afterwards and none is left looking live. A call nobody claimed never
217218
* started; one the desktop claimed may already have acted.
218219
*/
220+
function isUserLocalVfsPath(path: SQL): SQL {
221+
return sql`(${path} = ${USER_LOCAL_VFS_ROOT} OR ${path} LIKE ${`${USER_LOCAL_VFS_ROOT}/%`})`
222+
}
223+
224+
/** The SQL form of `isUserLocalVfsToolCall`: a VFS read of a granted local folder. */
225+
const isUserLocalVfsCall = or(
226+
and(
227+
inArray(copilotAsyncToolCalls.toolName, ['read', 'grep']),
228+
isUserLocalVfsPath(sql`${copilotAsyncToolCalls.args}->>'path'`)
229+
),
230+
and(
231+
eq(copilotAsyncToolCalls.toolName, 'glob'),
232+
isUserLocalVfsPath(sql`${copilotAsyncToolCalls.args}->>'pattern'`)
233+
)
234+
)
235+
219236
async function cancelOpenDesktopToolCalls(
220237
tx: RunAdmissionTransaction,
221238
stoppedRuns: SQL | undefined
@@ -247,8 +264,9 @@ async function cancelOpenDesktopToolCalls(
247264
inArray(copilotAsyncToolCalls.status, [
248265
ASYNC_TOOL_STATUS.pending,
249266
ASYNC_TOOL_STATUS.running,
267+
ASYNC_TOOL_STATUS.delivered,
250268
]),
251-
inArray(copilotAsyncToolCalls.toolName, [...DESKTOP_TOOL_NAMES])
269+
or(inArray(copilotAsyncToolCalls.toolName, [...DESKTOP_TOOL_NAMES]), isUserLocalVfsCall)
252270
)
253271
)
254272
}

‎apps/sim/lib/mothership/tools/client/desktop-tool-authorization.integration.ts‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const { redisUrl, inheritedEnv } = await vi.hoisted(async () => {
1616
COPILOT_TOOL_PERMISSIONS_ENABLED: process.env.COPILOT_TOOL_PERMISSIONS_ENABLED,
1717
}
1818
/** The real Redis module, the confirmation channel and the permission flag read these at import. */
19-
process.env.REDIS_URL = url
19+
if (url) process.env.REDIS_URL = url
2020
process.env.COPILOT_TOOL_PERMISSIONS_ENABLED = 'true'
2121
return { redisUrl: url, inheritedEnv }
2222
})
@@ -253,7 +253,7 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit
253253

254254
await requestRunStop({ userId, workspaceId, streamId, chatId })
255255

256-
expect((await desktopClaims(toolCallId)).ok).toBe(false)
256+
expect((await desktopClaims(toolCallId)).status).toBe(410)
257257
expect(await storedCall(toolCallId)).toMatchObject({
258258
status: 'cancelled',
259259
claimedBy: null,
@@ -267,7 +267,7 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit
267267

268268
await closeStreamToolAdmission(streamId, userId)
269269

270-
expect((await desktopClaims(toolCallId)).ok).toBe(false)
270+
expect((await desktopClaims(toolCallId)).status).toBe(410)
271271
expect(await storedCall(toolCallId)).toMatchObject({ status: 'pending', claimedBy: null })
272272
})
273273

@@ -277,7 +277,17 @@ describe.runIf(Boolean(redisUrl))('desktop tool calls the server no longer admit
277277

278278
await closeStreamToolAdmission(streamId, userId)
279279

280-
expect((await desktopClaims(toolCallId)).ok).toBe(false)
280+
expect((await desktopClaims(toolCallId)).status).toBe(410)
281+
})
282+
283+
it('settles a read of a granted local folder when the user stops', async () => {
284+
const { runId, streamId } = await startRun()
285+
const toolCallId = await agentCalls(runId, 'read', { path: 'user-local/Project--mount-1/a.md' })
286+
287+
await requestRunStop({ userId, workspaceId, streamId, chatId })
288+
289+
expect(await storedCall(toolCallId)).toMatchObject({ status: 'cancelled' })
290+
expect((await desktopClaims(toolCallId)).status).toBe(410)
281291
})
282292

283293
it('refuses the claim itself once admission closed, so a claim racing Stop cannot win', async () => {

0 commit comments

Comments
 (0)