Skip to content

Commit 4028a91

Browse files
committed
acquire MCP server locks before reads in attachment reconcile (TOCTOU)
1 parent 7e6cdac commit 4028a91

2 files changed

Lines changed: 24 additions & 12 deletions

File tree

apps/sim/lib/workspaces/fork/copy/workflow-mcp-attachments.test.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,11 @@ function makeTx(selectResults: unknown[][]) {
2626
const inserted: Array<Record<string, unknown>> = []
2727
const updates: Array<Record<string, unknown>> = []
2828
let call = 0
29+
const select = vi.fn(() => ({
30+
from: () => ({ where: () => Promise.resolve(selectResults[call++] ?? []) }),
31+
}))
2932
const tx = {
30-
select: () => ({
31-
from: () => ({ where: () => Promise.resolve(selectResults[call++] ?? []) }),
32-
}),
33+
select,
3334
insert: () => ({
3435
values: (values: Array<Record<string, unknown>>) => {
3536
inserted.push(...values)
@@ -45,7 +46,7 @@ function makeTx(selectResults: unknown[][]) {
4546
}),
4647
}),
4748
}
48-
return { tx: tx as unknown as DbOrTx, inserted, updates }
49+
return { tx: tx as unknown as DbOrTx, inserted, updates, select }
4950
}
5051

5152
const attachment = (overrides: Record<string, unknown> = {}) => ({
@@ -69,7 +70,8 @@ const serverMappingRow = {
6970
describe('reconcileForkWorkflowMcpAttachments', () => {
7071
it('creates the target attachment for a mapped server + written pair (push: child -> parent)', async () => {
7172
mockGetEdgeMappingRows.mockResolvedValue([serverMappingRow])
72-
const { tx, inserted } = makeTx([
73+
mockAcquireLock.mockClear()
74+
const { tx, inserted, select } = makeTx([
7375
[{ id: 'srv-child' }, { id: 'srv-parent' }], // both mapped servers still live
7476
[attachment({ serverId: 'srv-child', workflowId: 'wf-child' })],
7577
[], // no existing target attachments
@@ -89,6 +91,11 @@ describe('reconcileForkWorkflowMcpAttachments', () => {
8991
})
9092
expect(result.affectedServerIds).toEqual(['srv-parent'])
9193
expect(mockAcquireLock).toHaveBeenCalledWith(tx, 'srv-parent')
94+
// The lock must precede every read: locking after the diff is computed would let a
95+
// concurrent attach commit in between and abort the promote on the unique constraint.
96+
expect(mockAcquireLock.mock.invocationCallOrder[0]).toBeLessThan(
97+
select.mock.invocationCallOrder[0]
98+
)
9299
})
93100

94101
it('archives a target attachment whose source counterpart was detached', async () => {

apps/sim/lib/workspaces/fork/copy/workflow-mcp-attachments.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,21 @@ export async function reconcileForkWorkflowMcpAttachments(params: {
122122
}
123123
if (serverMap.size === 0) return { affectedServerIds: [] }
124124

125+
// Same per-server serialization as the deploy-time tool sync and the attach/delete routes,
126+
// in sorted order so two concurrent syncs can't deadlock on each other's server locks.
127+
// Acquired BEFORE the reads below: every other attachment writer locks first, so locking
128+
// after computing the diff would let a concurrent attach commit in between and turn our
129+
// insert into a unique-constraint abort of the whole promote transaction (and a concurrent
130+
// server delete into an FK abort).
131+
for (const serverId of [...new Set(serverMap.values())].sort()) {
132+
await acquireWorkflowMcpServerLock(tx, serverId)
133+
}
134+
125135
// Liveness guard: a mapped server may have been deleted since the fork (server deletion is a
126136
// hard delete that cascades its tools but leaves the identity row). A dead SOURCE server has
127137
// nothing to mirror; a dead TARGET server must be skipped or the insert below would violate
128-
// the `server_id` FK and abort the whole promote transaction.
138+
// the `server_id` FK and abort the whole promote transaction. Target liveness is stable for
139+
// the rest of the transaction: the delete route takes the per-server lock we now hold.
129140
const mappedServerIds = [...new Set([...serverMap.keys(), ...serverMap.values()])]
130141
const liveServerIds = new Set(
131142
(
@@ -270,12 +281,6 @@ export async function reconcileForkWorkflowMcpAttachments(params: {
270281
return { affectedServerIds: [] }
271282
}
272283

273-
// Same per-server serialization as the deploy-time tool sync, in sorted order so two
274-
// concurrent syncs can't deadlock on each other's server locks.
275-
for (const serverId of [...affectedServerIds].sort()) {
276-
await acquireWorkflowMcpServerLock(tx, serverId)
277-
}
278-
279284
if (inserts.length > 0) await tx.insert(workflowMcpTool).values(inserts)
280285
for (const update of updates) {
281286
await tx.update(workflowMcpTool).set(update.set).where(eq(workflowMcpTool.id, update.id))

0 commit comments

Comments
 (0)