Visitar URL original
v0.9.15: mship improvements, memory improvements, nextjs bump, plane and ramp integrations, outbox hardening by waleedlatif1 · Pull Request #8748 · simstudioai/sim · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
141 commits
Select commit Hold shift + click to select a range
cb05f50
docs(library): update agentic-ai-coding-tools-what-they-are-and-how-t…
icecrasher321 Oct 5, 2026
1056845
feat(library): How do you build an AI bot for Discord without code? (…
icecrasher321 Oct 5, 2026
733f4e9
perf(sandbox): grant the run_code session lease in the reconnect inst…
waleedlatif1 Oct 5, 2026
dc10d4c
fix(chat): label simple effort options with the effort they send (#8619)
waleedlatif1 Oct 5, 2026
807523e
feat(workflows): stop a manual v2 run after a block, and `workflows r…
waleedlatif1 Oct 5, 2026
b2c5b76
fix(executor): drop the full JSON clone of execution state at run com…
waleedlatif1 Oct 5, 2026
c723046
fix(logs): keep a block log's file size from changing after the block…
waleedlatif1 Oct 5, 2026
ea88578
feat(projects): add project identity and lifecycle foundation (#8580)
mzxchandra Oct 5, 2026
a865f47
fix(mcp): restrict MCP server destination changes to admins (#8629)
TheodoreSpeaks Oct 5, 2026
2c8eeaf
fix(code-placeholders): reject shell arithmetic placeholders (#8628)
icecrasher321 Oct 5, 2026
6b3cd82
fix(credentials): keep the field-less GitHub App installation out of …
TheodoreSpeaks Oct 5, 2026
844d181
fix(executor): fail a stop-after run whose routing skips the stop blo…
waleedlatif1 Oct 5, 2026
21c8a07
chore(terms): update terms of service (#8636)
waleedlatif1 Oct 5, 2026
7050250
fix(projects): restore workspace deletion and tighten Project lifecyc…
waleedlatif1 Oct 5, 2026
d32d4c9
feat(mothership): keep each chat's reasoning effort, default to mediu…
waleedlatif1 Oct 5, 2026
bd06886
feat(library): Best AI Coding Agents for Refactoring and Debugging: 5…
icecrasher321 Oct 5, 2026
6845fd9
feat(library): Best Conversational AI Platforms With CRM and Helpdesk…
icecrasher321 Oct 5, 2026
42c972c
docs(library): update langgraph-alternatives (#8640)
icecrasher321 Oct 5, 2026
8524cb9
docs(library): update dify-alternatives (#8641)
icecrasher321 Oct 5, 2026
2613543
docs(library): update best-ai-agents-for-customer-support-automation …
icecrasher321 Oct 5, 2026
2b007e1
docs(library): update ai-agent-observability (#8645)
icecrasher321 Oct 6, 2026
73bb265
docs(library): update aeo-vs-geo-what-answer-engine-and-generative-en…
icecrasher321 Oct 6, 2026
75cc375
docs(library): update mcp-security (#8649)
icecrasher321 Oct 6, 2026
2d043e9
docs(library): update ai-agents-in-procurement (#8648)
icecrasher321 Oct 6, 2026
e8e946c
docs(library): update sim-open-source-zapier-alternative (#8646)
icecrasher321 Oct 6, 2026
d4f26a7
fix(seo): make content pagination indexable (#8651)
waleedlatif1 Oct 6, 2026
46cd710
fix(mothership): refuse desktop claims for unapproved or stopped call…
waleedlatif1 Oct 6, 2026
269f9b8
fix(mothership): roll back a failed effort save by pick, not by value…
waleedlatif1 Oct 6, 2026
20a7350
fix(mcp): treat different URL credentials as a new destination (#8656)
TheodoreSpeaks Oct 6, 2026
3064219
fix(executor): unwrap boxed primitives by internal slot when checking…
waleedlatif1 Oct 6, 2026
777b3e9
fix(projects): revoke banned users' keys first and create imported wo…
waleedlatif1 Oct 6, 2026
648bf45
fix(mothership): default chat effort back to high (#8659)
waleedlatif1 Oct 6, 2026
239526a
improvement(tables): read row counts and versions through an append-o…
TheodoreSpeaks Oct 6, 2026
70e1980
docs(workflows): describe what still runs when a stop-after target is…
waleedlatif1 Oct 6, 2026
ef59524
test(mcp): exercise the real destination check in lifecycle tests (#8…
TheodoreSpeaks Oct 6, 2026
9af89fc
fix(mothership): fail unclaimed desktop calls fast and stop dropping …
waleedlatif1 Oct 6, 2026
c98e3c7
fix(sandbox): judge session lease coverage from the request's own clo…
waleedlatif1 Oct 6, 2026
a766a03
fix(mothership): keep a new chat's effort pick changed while its firs…
waleedlatif1 Oct 6, 2026
ba12945
feat(desktop): device registry, inbox and leased claims for a backgro…
waleedlatif1 Oct 6, 2026
65b247f
fix(mothership): keep local file tools running when the chat view cha…
waleedlatif1 Oct 6, 2026
63ba307
docs(library): update best-multi-agent-frameworks-2026 (#8678)
icecrasher321 Oct 6, 2026
b20ddc1
docs(library): update open-source-ai-agent-platforms (#8679)
icecrasher321 Oct 6, 2026
32a7f8e
fix(copilot): store tool file outputs from workspace chats under the …
waleedlatif1 Oct 6, 2026
7308e45
feat(library): 25 Multi-Step AI Agent Examples Beyond Chatbots (#8677)
icecrasher321 Oct 6, 2026
c9c2c4e
feat(plane): add core integration with self-hosted support (#8660)
waleedlatif1 Oct 6, 2026
d8ba4bf
feat(sso): allow self-hosted DNS verification bypass (#8681)
waleedlatif1 Oct 6, 2026
5156261
feat(desktop): bind turns to a desktop and enforce its deadlines from…
waleedlatif1 Oct 6, 2026
ab25c2a
docs(library): update ai-agent-examples-by-department-and-industry (#…
icecrasher321 Oct 6, 2026
94cba3b
fix(mothership): end desktop tools at sign-out and settle held report…
waleedlatif1 Oct 6, 2026
82591b5
docs(library): update top-ai-assistants-2026 (#8683)
icecrasher321 Oct 6, 2026
7307778
docs(library): update sim-vs-dedicated-chatbot-builders (#8684)
icecrasher321 Oct 6, 2026
5fccf71
docs(library): update best-ai-agents-for-lead-enrichment-2026 (#8687)
icecrasher321 Oct 6, 2026
c8ef18d
fix(desktop): inbox persistence order, desktop-only overdue sweep, an…
waleedlatif1 Oct 6, 2026
1f114dd
fix(mothership): re-read the transcript until the server has saved a …
waleedlatif1 Oct 6, 2026
662bd70
fix(mothership): re-attach at once when the user returns to a recover…
waleedlatif1 Oct 6, 2026
8714a8e
fix(mothership): keep a message the server never admitted instead of …
waleedlatif1 Oct 6, 2026
62287f6
test(desktop): keep the SQL and TypeScript desktop-call classifiers i…
waleedlatif1 Oct 6, 2026
5d86e42
docs(library): update 6-best-ai-observability-tools-for-production-ag…
icecrasher321 Oct 6, 2026
482f146
fix(plane): correct tile styling and capped connector syncs (#8690)
waleedlatif1 Oct 6, 2026
11f8190
fix(ci): stop each HTTP end-to-end app fully and start the next from …
waleedlatif1 Oct 6, 2026
3cc1ec1
fix(plane): validate webhook fields by registration mode (#8698)
waleedlatif1 Oct 6, 2026
d59e923
improvement(trigger): downsize connector sync and document processing…
waleedlatif1 Oct 6, 2026
551e05f
feat(mothership): let the worker choose models when the model picker …
waleedlatif1 Oct 6, 2026
5f1a29e
perf(cli): load a manual run's draft once and keep embedded CLI resul…
waleedlatif1 Oct 6, 2026
f4855cd
fix(mothership): prune composer contexts during render so fast typing…
waleedlatif1 Oct 6, 2026
632ba60
feat(desktop): run a chat's desktop tools in the background executor …
waleedlatif1 Oct 6, 2026
f98df1b
fix(e2e): compile every timed route first in the HTTP suites and stop…
waleedlatif1 Oct 7, 2026
6adf4c6
fix(mothership): close the remaining ways a chat send is lost, resent…
waleedlatif1 Oct 7, 2026
5ee756f
chore(table): remove unused table-update background task (#8708)
waleedlatif1 Oct 7, 2026
8fc4c1a
fix(analytics): protect production Google measurement (#8710)
waleedlatif1 Oct 7, 2026
db30e1a
improvement(triggers): move TRIGGER_TYPES to a dependency-free module…
waleedlatif1 Oct 7, 2026
ca11667
fix(chat): resolve a new chat's history entry to the chat route on Ba…
waleedlatif1 Oct 7, 2026
0d74834
feat(desktop): show background desktop chats and keep the chat view d…
waleedlatif1 Oct 7, 2026
d4d16a0
feat(desktop): keep the machine awake for background chats and yield …
waleedlatif1 Oct 7, 2026
6713fab
feat(desktop): import local files from the background executor (#8672)
waleedlatif1 Oct 7, 2026
da993c5
fix(events): start every SSE response once its subscriptions are live…
waleedlatif1 Oct 7, 2026
351b8b9
fix(mothership): keep a chatless surface's queue across remounts and …
waleedlatif1 Oct 7, 2026
e82689a
fix(ci): stop the detached telemetry flush too, re-signal stragglers …
waleedlatif1 Oct 7, 2026
ccce7f7
fix(webhooks): skip duplicate webhook deliveries already in progress …
waleedlatif1 Oct 7, 2026
ab6b400
chore(trigger): fix queues that never serialized and schedule missing…
waleedlatif1 Oct 7, 2026
b5a6d53
fix(mothership): run same-workflow workflow tools in order instead of…
waleedlatif1 Oct 7, 2026
18ad8b8
improvement(cron): only start outbox and file-search passes when work…
waleedlatif1 Oct 7, 2026
dfe8ecf
fix(desktop): read tmux format output with a separator no tmux versio…
waleedlatif1 Oct 7, 2026
ab588d4
fix(events): authorize the events queued before a stream opens once (…
waleedlatif1 Oct 7, 2026
988b079
fix(mothership): keep a withdrawn first message held at the queue hea…
waleedlatif1 Oct 7, 2026
29038b3
fix(ci): match E2E app tags literally, make them unique per run, and …
waleedlatif1 Oct 7, 2026
38942a7
fix(desktop): run an agent command on tmux that cannot tag its pane, …
waleedlatif1 Oct 7, 2026
2e7082f
fix(mothership): keep any queued send the server may already hold fro…
waleedlatif1 Oct 7, 2026
c5d07cc
fix(settings): prevent false unsaved prompts and preserve drafts (#8704)
waleedlatif1 Oct 7, 2026
48cbd4f
fix(ci): tidy stop-session.sh scans, listings and arguments (#8724)
waleedlatif1 Oct 7, 2026
00cedfa
fix(mothership): claim under the chat lock in the Stop-versus-recover…
waleedlatif1 Oct 7, 2026
edb2e6b
chore(nextjs): upgrade to 16.4.0 (#8729)
waleedlatif1 Oct 7, 2026
ae0a420
fix(mothership): retry a send that failed while the browser stayed on…
waleedlatif1 Oct 7, 2026
334b9f3
fix(desktop): stop the agent's tmux runs a previous process left goin…
waleedlatif1 Oct 7, 2026
566cea6
fix(desktop): frame each tmux format record so a newline in a field c…
waleedlatif1 Oct 7, 2026
1741ff9
test(mothership): stop the online case of the accepted-resend test pa…
waleedlatif1 Oct 7, 2026
055ff9c
improvement(outbox): load handler modules only for the event types a …
waleedlatif1 Oct 7, 2026
c3475fe
fix(mothership): decide once whether a queued send may already be on …
waleedlatif1 Oct 7, 2026
8f617da
refactor(desktop): one forward-only state per recorded tmux run, and …
waleedlatif1 Oct 7, 2026
102019b
test(desktop): stop the agent's terminal commands in the real app, wi…
waleedlatif1 Oct 7, 2026
96014bd
fix(mothership): keep a failed direct send ahead of follow-ups queued…
waleedlatif1 Oct 7, 2026
61ac093
fix(desktop): leave a stopped native file tool's outcome to Stop (#8732)
waleedlatif1 Oct 7, 2026
435d413
test(desktop): pin the journal snapshot before recovery, keep start g…
waleedlatif1 Oct 7, 2026
f83d977
refactor(mothership): one re-queue policy and one send payload, and k…
waleedlatif1 Oct 7, 2026
af5501b
improvement(desktop): Electron E2E for desktop tool lifetimes against…
waleedlatif1 Oct 7, 2026
f0e5700
fix(mothership): send a late queue write to the chat a new-chat queue…
waleedlatif1 Oct 7, 2026
3899fde
refactor(mothership): one hold field and one retry field on a queued …
waleedlatif1 Oct 7, 2026
00b6cee
fix(mothership): a pure resend verdict, and Send-now drops a message …
waleedlatif1 Oct 7, 2026
9e953d1
refactor(mothership): keep a queued send's reused id in one field (#8…
waleedlatif1 Oct 7, 2026
5d3806b
fix(desktop): keep a chat-view import alive while it works, by the le…
waleedlatif1 Oct 7, 2026
8760ca7
refactor(mothership): a saved Stop handoff's id wins on restore (#8747)
waleedlatif1 Oct 7, 2026
b74b9dd
fix(ci): count both sides of a rename in the desktop live gate, keep …
waleedlatif1 Oct 7, 2026
1a4a156
fix(desktop): linear path trim, prompt Stop between keystrokes, clear…
waleedlatif1 Oct 7, 2026
a7aaa2c
fix(outbox): log unloaded handler modules as a failure, not a complet…
waleedlatif1 Oct 7, 2026
9a1aca0
fix(desktop): stop a run the model never got the result of (#8749)
waleedlatif1 Oct 7, 2026
69d9ac6
fix(mothership): close a pre-aborted SSE stream, keep the new-chat ef…
waleedlatif1 Oct 7, 2026
6a92e05
fix(mothership): use a spinner while workflow resources load (#8756)
waleedlatif1 Oct 7, 2026
14e6c0a
improvement(mothership): relabel the Sol pick GPT-6.1 Sol and retire …
waleedlatif1 Oct 7, 2026
b4c38df
feat(providers): add Nebius Token Factory (#8759)
waleedlatif1 Oct 7, 2026
9ab24e9
ci: faster, cheaper, consistently named CI (#8750)
waleedlatif1 Oct 7, 2026
f775b29
fix(settings): protect generated keys and identity transitions (#8761)
waleedlatif1 Oct 7, 2026
bd7085e
fix(sandbox): keep reused E2B workbench leases within the runtime cap…
waleedlatif1 Oct 7, 2026
d8cb659
ci: duration-balanced integration shards, warm desktop-live, CI on ev…
waleedlatif1 Oct 7, 2026
b7fa794
fix(mothership): cancel desktop tools a signed-out turn starts late, …
waleedlatif1 Oct 7, 2026
96090ca
fix(tables): stop the fold sweep from starting a page query past its …
TheodoreSpeaks Oct 7, 2026
7b287b2
fix(docs): prerender complete LLM documentation (#8767)
waleedlatif1 Oct 7, 2026
350b016
improvement(ci): check out same-repo pull requests through a git mirr…
waleedlatif1 Oct 7, 2026
6df74d6
fix(docs): publish full LLM text as a static asset (#8769)
waleedlatif1 Oct 7, 2026
886cfa2
fix(billing): converge Stripe subscription syncs and stop webhook ech…
waleedlatif1 Oct 8, 2026
b7d5532
perf(pii): batch spaCy NER passes in redact_batch (#8772)
TheodoreSpeaks Oct 8, 2026
1f1e25d
improvement(files): add sharing to file resource view (#8775)
waleedlatif1 Oct 8, 2026
c53367c
feat(permission-groups): add agent default (#8771)
waleedlatif1 Oct 8, 2026
37f5cdf
feat(integrations): add Ramp and Microsoft Intune (#8780)
waleedlatif1 Oct 8, 2026
df34c67
feat(models): add current frontier models (#8777)
waleedlatif1 Oct 8, 2026
1f8c281
fix(executor): preserve stop-after across pause and resume (#8776)
waleedlatif1 Oct 8, 2026
59e749e
fix(audit): harden data drains and expand security event coverage (#8…
waleedlatif1 Oct 8, 2026
31a911d
improvement(desktop): ship the background executor without a flag, cl…
waleedlatif1 Oct 8, 2026
684b228
feat(api): expose credential sharing and SSO administration (#8770)
waleedlatif1 Oct 8, 2026
c93fe67
feat(buffer): complete stable API tool coverage (#8779)
waleedlatif1 Oct 8, 2026
b6861b3
fix(desktop): poll desktop activity only for users with a desktop, ke…
waleedlatif1 Oct 8, 2026
e3800f6
fix(code-placeholders): reject arithmetic comparisons and heredoc ope…
waleedlatif1 Oct 8, 2026
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
Prev Previous commit
Next Next commit
fix(mothership): refuse desktop claims for unapproved or stopped calls (
#8643)

* fix(mothership): refuse desktop claims for unapproved or stopped calls

The desktop authorize route claimed any pending call, so a gated terminal
run that was still awaiting approval, or a call on a run the user had
stopped, could be claimed and executed. The claim now locks the run row and
refuses once tool admission has closed (Stop, a newer turn, or the run's
end), and refuses a call held for the user's decision until they allow it.
Whether a call is gated depends on the turn, so pre-persist records it on
the row (permission_requested_at).

Stop now settles the stopped runs' open desktop calls in the transaction
that closes admission: unclaimed calls as never started, claimed calls as
outcome unknown. A result for an already-settled call (a retry, or one that
lost to Stop) is acknowledged with the stored outcome instead of 404/500.

* 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.

* test(mothership): assert the acknowledged outcome, not the publish mock

* test(mothership): give the hand-built tool call table the new permission column

* refactor(mothership): one claim primitive, one desktop-tool classifier, sealed Stop results

The desktop claim is now an option of the run-locked tool execution claim
(claimSimToolExecution becomes claimToolExecution) instead of a second copy
of the admission check. Stop picks the open desktop calls with the shared TS
classifier, which moves to lib/mothership/tools/desktop-tools.ts, instead of
a SQL restatement of it, and seals each result the way the confirm route
does, so a waiter restores what Stop did rather than failing to unseal it.

* fix(mothership): a declined call stays unclaimable without its gate marker

Calls gated before permission_requested_at existed carry no marker, so a
recorded decision that does not allow the call now disqualifies it too.

* test(mothership): assert authorize outcomes, not claim mock calls
  • Loading branch information
waleedlatif1 authored Oct 6, 2026
commit 46cd710f60c161a0e29ea9993970d415aa6d1916
19 changes: 19 additions & 0 deletions apps/sim/app/api/copilot/confirm/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,25 @@ describe('Copilot Confirm API Route', () => {
)
})

it.each([
['client_tool', 'running', 'success', completeAsyncToolCall],
['browser_snapshot', 'pending', 'error', completePendingAsyncToolCall],
] as const)(
'acknowledges a %s result whose write lost to a settlement that landed first',
async (toolName, storedStatus, status, completion) => {
const row = { ...existingRow, toolName, claimedBy: null }
getAsyncToolCall
.mockResolvedValueOnce({ ...row, status: storedStatus })
.mockResolvedValueOnce({ ...row, status: 'failed' })
completion.mockResolvedValueOnce(null)

const response = await POST(createMockPostRequest({ toolCallId: 'tool-call-123', status }))

expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ toolCallId: 'tool-call-123', status: 'error' })
}
)

it('does not publish when another terminal confirmation already won', async () => {
completeAsyncToolCall.mockResolvedValueOnce(null)

Expand Down
91 changes: 54 additions & 37 deletions apps/sim/app/api/copilot/confirm/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isBrowserToolName, isCurrentBrowserToolName } from '@sim/browser-protocol'
import type { Span } from '@opentelemetry/api'
import { isBrowserToolName } from '@sim/browser-protocol'
import { createLogger } from '@sim/logger'
import { isTerminalToolName } from '@sim/terminal-protocol'
import { getErrorMessage, toError } from '@sim/utils/errors'
Expand All @@ -12,7 +13,7 @@ import {
ASYNC_TOOL_STATUS,
type AsyncCompletionData,
type AsyncConfirmationStatus,
DESKTOP_TOOL_CLAIM_OWNER,
type AsyncTerminalStatus,
isDeliveredAsyncStatus,
isTerminalAsyncStatus,
isWorkflowToolExecutionClaimable,
Expand All @@ -38,11 +39,9 @@ import {
createUnauthorizedResponse,
} from '@/lib/mothership/request/http'
import { withIncomingGoSpan } from '@/lib/mothership/request/otel'
import {
retainSealedClientToolContext,
sealClientToolCompletion,
} from '@/lib/mothership/request/tools/client-completion-seal.server'
import { sealClientToolSettlement } from '@/lib/mothership/request/tools/client-completion-seal.server'
import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
import { getDesktopToolClaimOwner } from '@/lib/mothership/tools/desktop-tools'
import {
createStructuralWorkflowToolCompletionData,
getWorkflowToolCompletionExecutionId,
Expand Down Expand Up @@ -83,6 +82,24 @@ function createConfirmationResponse(
return NextResponse.json({ success: true, message, toolCallId, status })
}

/**
* A result for a call that is already settled — a retried delivery, or one that lost to the server
* settling the call first (Stop, a fast "not started" failure) — answers with the stored outcome.
* Nothing is written or published again, and the reporter stops retrying.
*/
function acknowledgeSettledToolCall(
span: Span,
toolCallId: string,
storedStatus: AsyncTerminalStatus
): NextResponse {
const settledStatus = getWorkflowToolConfirmationStatus(storedStatus)
span.setAttributes({
[TraceAttr.ToolConfirmationStatus]: settledStatus,
[TraceAttr.CopilotConfirmOutcome]: CopilotConfirmOutcome.Delivered,
})
return createConfirmationResponse(toolCallId, settledStatus, 'Tool call was already settled')
}

/** Atomically finalize or detach a client tool before publishing its wakeup event. */
async function updateToolCallStatus(
existing: NonNullable<Awaited<ReturnType<typeof getAsyncToolCall>>>,
Expand Down Expand Up @@ -284,26 +301,22 @@ export const POST = withRouteHandler((req: NextRequest) => {
)
}

if (!isWorkflowTool && isTerminalAsyncStatus(existing.status)) {
return acknowledgeSettledToolCall(span, toolCallId, existing.status)
}

const isErrorOrCancelledOutcome =
status === ASYNC_TOOL_CONFIRMATION_STATUS.error ||
status === ASYNC_TOOL_CONFIRMATION_STATUS.cancelled
const isNativeClientTool =
isBrowserToolName(existing.toolName) ||
isTerminalToolName(existing.toolName) ||
existing.toolName === 'import_local_files'
const nativeClaimOwner = getDesktopToolClaimOwner(existing.toolName)
const isPreclaimNativeTerminalOutcome =
(isCurrentBrowserToolName(existing.toolName) ||
isTerminalToolName(existing.toolName) ||
existing.toolName === 'import_local_files') &&
nativeClaimOwner !== undefined &&
existing.status === ASYNC_TOOL_STATUS.pending &&
isErrorOrCancelledOutcome
const nativeClaimOwner = isCurrentBrowserToolName(existing.toolName)
? DESKTOP_TOOL_CLAIM_OWNER.browser
: isTerminalToolName(existing.toolName)
? DESKTOP_TOOL_CLAIM_OWNER.terminal
: existing.toolName === 'import_local_files'
? DESKTOP_TOOL_CLAIM_OWNER.files
: undefined
const isIndeterminateNativeExit =
isPreclaimNativeTerminalOutcome &&
status === ASYNC_TOOL_CONFIRMATION_STATUS.error &&
Expand Down Expand Up @@ -401,27 +414,24 @@ export const POST = withRouteHandler((req: NextRequest) => {
}
: {
message: getClientToolCompletionMessage(status),
data: {
...retainSealedClientToolContext(existing.result),
...(await sealClientToolCompletion({
toolCallId,
runId: existing.runId,
userId: authenticatedUserId,
...(isIndeterminateNativeExit
? {
message: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
data: {
error: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
outcomeUnknown: true,
doNotRetry: true,
},
}
: {
...(message !== undefined ? { message } : {}),
...(data !== undefined ? { data } : {}),
}),
})),
},
data: await sealClientToolSettlement(existing.result, {
toolCallId,
runId: existing.runId,
userId: authenticatedUserId,
...(isIndeterminateNativeExit
? {
message: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
data: {
error: NATIVE_HANDOFF_INTERRUPTED_MESSAGE,
outcomeUnknown: true,
doNotRetry: true,
},
}
: {
...(message !== undefined ? { message } : {}),
...(data !== undefined ? { data } : {}),
}),
}),
}

const updateOutcome = await updateToolCallStatus(
Expand Down Expand Up @@ -453,6 +463,13 @@ export const POST = withRouteHandler((req: NextRequest) => {
)
: updateOutcome

if (reconciledOutcome === 'conflict' && !isWorkflowTool) {
const settled = await getAsyncToolCall(toolCallId).catch(() => null)
if (settled && isTerminalAsyncStatus(settled.status)) {
return acknowledgeSettledToolCall(span, toolCallId, settled.status)
}
}

if (reconciledOutcome === 'conflict' && isPreclaimNativeTerminalOutcome) {
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.ToolCallNotFound)
return createNotFoundResponse('Pending client tool call not found')
Expand Down
11 changes: 3 additions & 8 deletions apps/sim/app/api/desktop/tool/authorize/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('@/lib/mothership/async-runs/repository', () => mothershipAsyncRunsMock)

import { POST } from './route'

const claimPendingAsyncToolCall = mothershipAsyncRunsMockFns.mockClaimPendingAsyncToolCall
const claimToolExecution = mothershipAsyncRunsMockFns.mockClaimToolExecution
const getAsyncToolCall = mothershipAsyncRunsMockFns.mockGetAsyncToolCall
const getRunSegment = mothershipAsyncRunsMockFns.mockGetRunSegment
const resolveInvocationWorkspace = mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace
Expand Down Expand Up @@ -50,7 +50,7 @@ describe('desktop tool authorization', () => {
userId: 'user-1',
status: 'active',
})
claimPendingAsyncToolCall.mockResolvedValue({ toolCallId: 'browser-tool', status: 'running' })
claimToolExecution.mockResolvedValue({ outcome: 'claimed' })
})

it('never returns presentation activity as an executable browser argument', async () => {
Expand Down Expand Up @@ -84,7 +84,6 @@ describe('desktop tool authorization', () => {
const response = await POST(request('retired-browser-tool'))

expect(response.status).toBe(403)
expect(claimPendingAsyncToolCall).not.toHaveBeenCalled()
})

it('rejects a replayed browser action after its pending row was claimed', async () => {
Expand All @@ -98,7 +97,6 @@ describe('desktop tool authorization', () => {

const response = await POST(request('browser-tool'))
expect(response.status).toBe(404)
expect(claimPendingAsyncToolCall).not.toHaveBeenCalled()
})

it('rejects workspace VFS calls and mutating legacy local tools', async () => {
Expand Down Expand Up @@ -176,13 +174,11 @@ describe('desktop tool authorization', () => {
{ userId: 'user-1', chatId: 'chat-1', organizationId: 'org-1', workspaceId: undefined },
'target'
)
expect(claimPendingAsyncToolCall).toHaveBeenCalledExactlyOnceWith('import-1', 'desktop-files')
getAsyncToolCall.mockResolvedValue({ ...tool, status: 'running', claimedBy: 'desktop-files' })
expect((await POST(request('import-1', true))).status).toBe(409)
expect((await POST(request('import-1'))).status).toBe(200)
getAsyncToolCall.mockResolvedValue({ ...tool, status: 'running', claimedBy: 'sim-stream' })
expect((await POST(request('import-1'))).status).toBe(404)
expect(claimPendingAsyncToolCall).toHaveBeenCalledOnce()
})

it('rejects inaccessible destinations and lost import claims before exposing files', async () => {
Expand All @@ -197,8 +193,7 @@ describe('desktop tool authorization', () => {
new OrchestrationError('not_found', 'Workspace not found')
)
expect((await POST(request('import-1', true))).status).toBe(404)
expect(claimPendingAsyncToolCall).not.toHaveBeenCalled()
claimPendingAsyncToolCall.mockResolvedValueOnce(null)
claimToolExecution.mockResolvedValueOnce({ outcome: 'existing' })
expect((await POST(request('import-1', true))).status).toBe(409)
})

Expand Down
69 changes: 50 additions & 19 deletions apps/sim/app/api/desktop/tool/authorize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
import { DESKTOP_TOOL_CLAIM_OWNER } from '@/lib/mothership/async-runs/lifecycle'
import {
claimPendingAsyncToolCall,
claimToolExecution,
getAsyncToolCall,
getRunSegment,
type ToolExecutionClaim,
} from '@/lib/mothership/async-runs/repository'
import {
authenticateCopilotRequestSessionOnly,
Expand All @@ -20,12 +21,33 @@ import {
} from '@/lib/mothership/request/http'
import { isUserLocalVfsToolCall } from '@/lib/mothership/tools/local-filesystem'

const admissionClosedResponse = () =>
NextResponse.json(
{ error: 'This chat turn ended or was stopped, so the tool call can no longer run' },
{ status: 410 }
)

/** A refused claim answers the same way for every tool, except how each reports a lost race. */
function refusedClaimResponse(
claim: Exclude<ToolExecutionClaim['outcome'], 'claimed'>,
notPending: () => NextResponse
): NextResponse {
if (claim === 'closed') return admissionClosedResponse()
if (claim === 'awaiting_permission')
return NextResponse.json({ error: 'The user has not approved this tool call' }, { status: 403 })
return notPending()
}

/**
* Electron calls this endpoint from the main process before every privileged
* native model action. It returns only server-persisted canonical tool args;
* Electron validates local-file requests against them and uses them directly
* for browser and terminal tools. The presentation-only `activity` field is
* dropped: desktop actions reject arguments they do not declare.
*
* Nothing is handed over once the run's tool admission has closed (Stop, a
* newer turn, or the run's end), nor for a call held for the user's decision
* that they have not allowed.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()
Expand All @@ -37,13 +59,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!parsed.success) return parsed.response

const toolCall = await getAsyncToolCall(parsed.data.body.toolCallId)
if (!toolCall || (toolCall.status !== 'pending' && toolCall.status !== 'running')) {
return createNotFoundResponse('Pending client tool call not found')
}
if (!toolCall) return createNotFoundResponse('Pending client tool call not found')
const run = await getRunSegment(toolCall.runId)
if (!run || run.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// Ahead of the status checks: Stop settles the run's open calls in the same commit.
if (run.toolAdmissionClosedAt) return admissionClosedResponse()
if (toolCall.status !== 'pending' && toolCall.status !== 'running') {
return createNotFoundResponse('Pending client tool call not found')
}
if (run.status === 'complete' || run.status === 'error' || run.status === 'cancelled') {
return createNotFoundResponse('Pending client tool call not found')
}
Expand Down Expand Up @@ -84,14 +109,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json(projected.body, { status: projected.status })
}
if (parsed.data.body.claim) {
if (
toolCall.status !== 'pending' ||
!(await claimPendingAsyncToolCall(toolCall.toolCallId, DESKTOP_TOOL_CLAIM_OWNER.files))
)
return NextResponse.json(
const alreadyStarted = () =>
NextResponse.json(
{ error: 'This import was already started; inspect its result before retrying' },
{ status: 409 }
)
if (toolCall.status !== 'pending') return alreadyStarted()
const { outcome } = await claimToolExecution({
toolCallId: toolCall.toolCallId,
runId: toolCall.runId,
userId,
claimedBy: DESKTOP_TOOL_CLAIM_OWNER.files,
})
if (outcome !== 'claimed') return refusedClaimResponse(outcome, alreadyStarted)
} else if (
toolCall.status !== 'running' ||
toolCall.claimedBy !== DESKTOP_TOOL_CLAIM_OWNER.files
Expand All @@ -104,16 +134,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
// the Electron boundary — a replayed renderer event must not run a command
// or click a button twice.
if (isBrowserTool || isTerminalTool) {
if (toolCall.status !== 'pending') {
return createNotFoundResponse('Pending client tool call not found')
}
const claimed = await claimPendingAsyncToolCall(
toolCall.toolCallId,
isBrowserTool ? DESKTOP_TOOL_CLAIM_OWNER.browser : DESKTOP_TOOL_CLAIM_OWNER.terminal
)
if (!claimed) {
return createNotFoundResponse('Pending client tool call not found')
}
const notPending = () => createNotFoundResponse('Pending client tool call not found')
if (toolCall.status !== 'pending') return notPending()
const { outcome } = await claimToolExecution({
toolCallId: toolCall.toolCallId,
runId: toolCall.runId,
userId,
claimedBy: isBrowserTool
? DESKTOP_TOOL_CLAIM_OWNER.browser
: DESKTOP_TOOL_CLAIM_OWNER.terminal,
})
if (outcome !== 'claimed') return refusedClaimResponse(outcome, notPending)
}

return NextResponse.json({
Expand Down
Loading
Loading