Skip to content

Commit 32c6429

Browse files
committed
fix(mothership): preserve staging authorization and resumed billing
1 parent 5861625 commit 32c6429

23 files changed

Lines changed: 28077 additions & 33 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/usage-upgrade-display.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ vi.mock('@/lib/core/config/deployment-shape', async (importOriginal) => ({
1515
useDeploymentShape: () => ({ hosted: true }),
1616
}))
1717
vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({
18-
useWorkspaceHostContext: () => ({
18+
useOptionalWorkspaceHostContext: () => ({
1919
workspace: { id: 'workspace', billedAccountUserId: 'owner' },
2020
hostOrganizationId: 'organization',
2121
viewer: { isHostOrganizationAdmin: false },

‎apps/sim/lib/mcp/workflow-tool-schema.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ export function sanitizeToolName(name: string): string {
2626
* This converts the workflow's input format definition to JSON Schema format
2727
* that MCP clients can use to understand tool parameters.
2828
*/
29-
export function generateToolInputSchema(inputFormat: InputFormatField[]): McpToolSchema {
29+
export function generateToolInputSchema(
30+
inputFormat: InputFormatField[]
31+
): McpToolSchema & { properties: Record<string, McpToolSchemaProperty> } {
3032
const schema = z.toJSONSchema(z.object(generateWorkflowInputShape(inputFormat)), {
3133
target: 'draft-07',
3234
io: 'input',

‎apps/sim/lib/mothership/agent-cli/file-provenance.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ vi.mock('@/lib/workspace-files/application/resolve-rendered-workspace-artifact',
4141
resolveRenderedWorkspaceArtifact: mocks.render,
4242
}))
4343
vi.mock('@/lib/execution/remote-sandbox/session-files', () => ({
44+
SESSION_SANDBOX_HOME: '/home/user',
4445
readSessionSandboxFile: vi.fn(),
4546
writeSessionSandboxFile: vi.fn(),
4647
}))

‎apps/sim/lib/mothership/agent-cli/run-cli.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const { readFile, writeFile, embedded } = vi.hoisted(() => ({
66
embedded: vi.fn(),
77
}))
88
vi.mock('@/lib/execution/remote-sandbox/session-files', () => ({
9+
SESSION_SANDBOX_HOME: '/home/user',
910
readSessionSandboxFile: readFile,
1011
writeSessionSandboxFile: writeFile,
1112
}))

‎apps/sim/lib/mothership/application/authorize-chat-callback.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
type BillingAttributionSnapshot,
99
checkAttributedBillingBlocks,
1010
} from '@/lib/billing/core/billing-attribution'
11+
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case'
12+
import { OrchestrationError } from '@/lib/core/orchestration/types'
1113
import { chatOperations } from '@/lib/mothership/application/operations'
1214
import {
1315
COPILOT_APPLICATION_DELEGATION_TTL_MS,
@@ -19,8 +21,6 @@ import {
1921
COPILOT_VALIDATION_PURPOSE,
2022
type CopilotValidationPurpose,
2123
} from '@/lib/mothership/generated/billing-protocol-v1'
22-
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application/authorized-workspace-use-case'
23-
import { OrchestrationError } from '@/lib/core/orchestration/types'
2424
import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
2525

2626
const CALLBACK_AUDIENCE = 'sim:copilot-callback'
@@ -57,9 +57,10 @@ interface CopilotChatCallbackContext {
5757
chatId?: string
5858
delegationId: string
5959
purpose: Exclude<CopilotValidationPurpose, 'new-turn'>
60+
mode?: 'assistant' | 'agent'
6061
}
6162

62-
/** Reauthorizes the original server-owned scope across a Go lifecycle boundary. */
63+
/** Reauthorizes the original server-owned scope across a model lifecycle boundary. */
6364
export async function authorizeCopilotChatCallback(context: CopilotChatCallbackContext) {
6465
if (context.organizationId) {
6566
if (!context.chatId || context.workspaceId) {
@@ -75,7 +76,7 @@ export async function authorizeCopilotChatCallback(context: CopilotChatCallbackC
7576
ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS,
7677
}
7778
)
78-
await authorizeOrganizationChatDelegation.execute({ principal })
79+
await authorizeOrganizationChatDelegation.execute({ principal, mode: context.mode })
7980
return
8081
}
8182
if (!context.workspaceId) return

‎apps/sim/lib/mothership/chat/child-failure-transcript.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,8 @@ describe('child failure transcript boundaries', () => {
221221
error: failure,
222222
})
223223
)
224-
expect(markup).toContain('Inspect report — Failed')
224+
expect(markup).toContain('Inspect report')
225+
expect(markup).not.toContain('Inspect report — Failed')
225226
expect(markup).toContain(failure)
226227
expect(markup).not.toContain('<button')
227228
const sibling = renderToStaticMarkup(

‎apps/sim/lib/mothership/chat/organization-chats.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
44
import { OrchestrationError } from '@/lib/core/orchestration/types'
55
import { createTrustedOrganizationCopilotPrincipal } from '@/lib/mothership/auth/application-delegation'
66
import {
7-
authorizeOrganizationChatCancellation,
87
authorizeOrganizationChat,
8+
authorizeOrganizationChatCancellation,
99
authorizeOrganizationChatDelegation,
1010
authorizeOrganizationChatEvents,
1111
createOrganizationChat,
@@ -226,6 +226,18 @@ describe('organization Build admission', () => {
226226
})
227227
expect(permissionConfig).not.toHaveBeenCalled()
228228
})
229+
it('rechecks Build permission for a delegated continuation while allowing Search', async () => {
230+
authorize.mockResolvedValue({ userId: 'member-1', organizationId: 'org-1', role: 'owner' })
231+
permissionConfig.mockResolvedValue({ disableWorkspaceCreation: true })
232+
dbChainMockFns.limit.mockResolvedValue([{ id: 'private-chat' }])
233+
await expect(
234+
authorizeOrganizationChatDelegation.execute({ principal: principal(), mode: 'agent' })
235+
).rejects.toThrow('Build requires permission')
236+
await expect(
237+
authorizeOrganizationChatDelegation.execute({ principal: principal(), mode: 'assistant' })
238+
).resolves.toMatchObject({ userId: 'member-1' })
239+
})
240+
229241
it('checks current membership before the Build permission projection', async () => {
230242
authorize.mockRejectedValueOnce(new Error('Membership revoked'))
231243
await expect(

‎apps/sim/lib/mothership/chat/organization-chats.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,13 @@ export const authorizeOrganizationChatCancellation = {
192192

193193
/** A trusted service may act only on the subject's persisted private organization chat. */
194194
export const authorizeOrganizationChatDelegation = {
195-
async execute({ principal }: { principal: OrganizationDelegatedPrincipal }) {
195+
async execute({
196+
principal,
197+
mode,
198+
}: {
199+
principal: OrganizationDelegatedPrincipal
200+
mode?: 'assistant' | 'agent'
201+
}) {
196202
if (principal.serviceId !== 'copilot')
197203
throw new OrchestrationError('forbidden', 'Invalid conversation delegation')
198204
const operation = Object.values(organizationChatDelegationOperations).find(
@@ -216,6 +222,7 @@ export const authorizeOrganizationChatDelegation = {
216222
)
217223
.limit(1)
218224
if (!chat) throw new OrchestrationError('not_found', 'Conversation not found')
225+
if (mode === 'agent') await requireBuildPermission(context)
219226
return context
220227
},
221228
}

‎apps/sim/lib/mothership/request/application/controls-boundary.test.ts‎

Lines changed: 82 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,109 @@
11
/** @vitest-environment node */
2-
import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
2+
import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44

5-
const mocks = vi.hoisted(() => ({ latest: vi.fn(), stop: vi.fn(), signal: vi.fn() }))
5+
const mocks = vi.hoisted(() => ({
6+
latest: vi.fn(),
7+
stop: vi.fn(),
8+
signal: vi.fn(),
9+
permissions: vi.fn(),
10+
}))
611
vi.mock('@/lib/mothership/async-runs/repository', () => ({
712
getLatestRunForStream: mocks.latest,
813
requestRunStop: mocks.stop,
9-
areStreamToolExecutionsSettled: vi.fn(),
10-
getUnsettledClientWorkflowExecutions: vi.fn(),
11-
getUnsettledStreamSandboxProcesses: vi.fn(),
14+
areStreamToolExecutionsSettled: vi.fn(async () => true),
15+
getUnsettledClientWorkflowExecutions: vi.fn(async () => []),
16+
getUnsettledStreamSandboxProcesses: vi.fn(async () => []),
1217
}))
1318
vi.mock('@/lib/mothership/request/session/explicit-abort', () => ({
1419
requestExplicitStreamAbort: mocks.signal,
1520
}))
21+
vi.mock('@/lib/mothership/request/session', () => ({
22+
abortActiveStream: vi.fn(),
23+
waitForPendingChatStream: vi.fn(async () => true),
24+
releasePendingChatStream: vi.fn(),
25+
}))
26+
vi.mock('@/lib/permission-groups/resolve.server', async (importOriginal) => ({
27+
...(await importOriginal<typeof import('@/lib/permission-groups/resolve.server')>()),
28+
getUserPermissionConfigForOrganization: mocks.permissions,
29+
}))
1630
vi.mock('@/lib/auth/ban', () => ({ getActivelyBannedUserIds: async () => [] }))
1731

1832
import { abortRun } from '@/lib/mothership/request/application/controls'
1933

2034
beforeEach(() => {
2135
vi.clearAllMocks()
2236
resetDbChainMock()
37+
mocks.permissions
38+
.mockReset()
39+
.mockResolvedValue({ hideCopilot: true, disableWorkspaceCreation: true })
40+
mocks.stop.mockReset().mockResolvedValue(null)
41+
mocks.signal.mockReset().mockResolvedValue({ settled: true })
2342
mocks.latest.mockResolvedValue({
2443
chatId: 'chat',
2544
workspaceId: null,
2645
organizationId: 'organization',
2746
})
2847
})
2948
describe('abort authorization before service signaling', () => {
49+
const principal = { kind: 'session', userId: 'actor', sessionId: 'session' } as const
50+
const input = { streamId: 'stream', chatId: 'chat' }
51+
const ownedChat = {
52+
userId: 'actor',
53+
workspaceId: null,
54+
organizationId: 'organization',
55+
type: 'mothership',
56+
}
57+
58+
it('persists and forwards Stop for a current member after chat and Build capabilities are revoked', async () => {
59+
queueTableRows(schemaMock.copilotChats, [ownedChat])
60+
queueTableRows(schemaMock.copilotChats, [ownedChat])
61+
queueTableRows(schemaMock.member, [{ role: 'member' }])
62+
mocks.stop.mockResolvedValue({
63+
chatId: 'chat',
64+
workspaceId: null,
65+
organizationId: 'organization',
66+
})
67+
68+
await expect(abortRun.execute({ principal, input })).resolves.toEqual({
69+
aborted: true,
70+
settled: true,
71+
})
72+
73+
expect(mocks.stop).toHaveBeenCalledWith({
74+
streamId: 'stream',
75+
chatId: 'chat',
76+
userId: 'actor',
77+
organizationId: 'organization',
78+
workspaceId: undefined,
79+
})
80+
expect(mocks.signal).toHaveBeenCalledWith({
81+
streamId: 'stream',
82+
chatId: 'chat',
83+
userId: 'actor',
84+
timeoutMs: 3000,
85+
})
86+
expect(mocks.permissions).not.toHaveBeenCalled()
87+
})
88+
89+
it('rejects a removed member before persisting or forwarding Stop', async () => {
90+
queueTableRows(schemaMock.copilotChats, [ownedChat])
91+
queueTableRows(schemaMock.member, [])
92+
await expect(abortRun.execute({ principal, input })).rejects.toMatchObject({
93+
code: 'not_found',
94+
})
95+
expect(mocks.stop).not.toHaveBeenCalled()
96+
expect(mocks.signal).not.toHaveBeenCalled()
97+
})
98+
99+
it('propagates a live membership lookup failure before persisting or forwarding Stop', async () => {
100+
const error = new Error('membership database unavailable')
101+
dbChainMockFns.limit.mockResolvedValueOnce([ownedChat]).mockRejectedValueOnce(error)
102+
await expect(abortRun.execute({ principal, input })).rejects.toBe(error)
103+
expect(mocks.stop).not.toHaveBeenCalled()
104+
expect(mocks.signal).not.toHaveBeenCalled()
105+
})
106+
30107
it('rejects another user’s canonical chat before persisting or forwarding Stop', async () => {
31108
queueTableRows(schemaMock.copilotChats, [
32109
{ userId: 'other', workspaceId: null, organizationId: 'organization', type: 'mothership' },

‎apps/sim/lib/mothership/request/application/recover-stream.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {
88
} from '@/lib/billing/core/billing-attribution'
99
import { defineWorkspaceOperation } from '@/lib/core/application'
1010
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
11+
import { isHosted } from '@/lib/core/config/env-flags'
1112
import { OrchestrationError } from '@/lib/core/orchestration/types'
1213
import { getLatestRunForStream } from '@/lib/mothership/async-runs/repository'
1314
import { defineAuthorizedChatUseCase } from '@/lib/mothership/chat/application/authorized-chat-use-case'
1415
import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context'
1516
import { buildOnComplete, buildOnError } from '@/lib/mothership/chat/completion'
17+
import { restoreBillingAdmission } from '@/lib/mothership/request/lifecycle/admission'
1618
import { claimRunController } from '@/lib/mothership/request/lifecycle/controller-ownership'
1719
import { StreamRecoveryConfigSchema } from '@/lib/mothership/request/lifecycle/recovery-config'
1820
import { createSSEStream } from '@/lib/mothership/request/lifecycle/start'
@@ -93,11 +95,25 @@ export const readChatStream = defineAuthorizedChatUseCase({
9395
await releasePendingChatStream(chatId, run.streamId, lease)
9496
return (await getLatestRunForStream(run.streamId, userId)) ?? run
9597
}
98+
if (isHosted && !config.data.billingAdmission)
99+
throw new OrchestrationError(
100+
'forbidden',
101+
'Hosted recovery is missing its original billing admission'
102+
)
103+
const restoredAdmission = config.data.billingAdmission
104+
? restoreBillingAdmission(config.data.billingAdmission, {
105+
userId,
106+
workspaceId,
107+
organizationId,
108+
})
109+
: undefined
96110
const [events, billingAttribution, userPermission] = await Promise.all([
97111
readEvents(run.streamId, '0'),
98-
organizationId
99-
? resolveOrganizationBillingAttribution({ actorUserId: userId, organizationId })
100-
: resolveBillingAttribution({ actorUserId: userId, workspaceId: workspaceId! }),
112+
restoredAdmission
113+
? Promise.resolve(restoredAdmission.attribution)
114+
: organizationId
115+
? resolveOrganizationBillingAttribution({ actorUserId: userId, organizationId })
116+
: resolveBillingAttribution({ actorUserId: userId, workspaceId: workspaceId! }),
101117
workspaceId
102118
? getUserEntityPermissions(userId, 'workspace', workspaceId)
103119
: Promise.resolve(undefined),

0 commit comments

Comments
 (0)