Skip to content

Commit 10f89e0

Browse files
committed
fix(project-files): authorize paired sandbox copies and isolate previews
1 parent 64a4652 commit 10f89e0

12 files changed

Lines changed: 544 additions & 32 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { createRoot } from 'react-dom/client'
5+
import { expect, it, vi } from 'vitest'
6+
import { EmbeddedProjectFile } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/project-file'
7+
import { projectFilesKeys } from '@/hooks/queries/utils/project-file-keys'
8+
9+
vi.mock(
10+
'next/navigation',
11+
async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock
12+
)
13+
vi.mock('@/hooks/use-file-list-room', () => ({ useFileListRoom: () => {} }))
14+
const viewer = vi.hoisted(() => ({ input: {} as Record<string, unknown> }))
15+
vi.mock('@/app/workspace/[workspaceId]/files/components/file-viewer', () => ({
16+
FileViewer: (props: Record<string, unknown>) => {
17+
viewer.input = props
18+
return null
19+
},
20+
}))
21+
22+
it('excludes ownerless preview fields at the Project viewer boundary', async () => {
23+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
24+
const client = new QueryClient()
25+
client.setQueryData(projectFilesKeys.record('project', 'file'), {
26+
file: { id: 'file', owner: { entityType: 'project', entityId: 'project' } },
27+
capabilities: { canWrite: true },
28+
})
29+
const container = document.createElement('div')
30+
const root = createRoot(container)
31+
const preview = { streamingContent: 'Unqualified workspace preview', isAgentEditing: true }
32+
try {
33+
await act(async () =>
34+
root.render(
35+
<QueryClientProvider client={client}>
36+
<EmbeddedProjectFile projectId='project' fileId='file' {...preview} />
37+
</QueryClientProvider>
38+
)
39+
)
40+
expect(viewer.input).toMatchObject({
41+
owner: { entityType: 'project', entityId: 'project' },
42+
collaborative: true,
43+
canEdit: true,
44+
})
45+
expect(viewer.input).not.toHaveProperty('streamingContent')
46+
expect(viewer.input).not.toHaveProperty('isAgentEditing')
47+
} finally {
48+
await act(async () => root.unmount())
49+
client.clear()
50+
}
51+
})

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/project-file.tsx‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,14 @@ interface ProjectFileProps {
1919
fileId: string
2020
downloadSourceRef?: MutableRefObject<FileDownloadSource | null>
2121
previewMode?: PreviewMode
22-
streamingContent?: string
23-
isAgentEditing?: boolean
24-
streamIsIncremental?: boolean
25-
streamOperation?: string
26-
disableStreamingAutoScroll?: boolean
27-
previewContextKey?: string
2822
}
2923

30-
export function EmbeddedProjectFile({ projectId, fileId, ...viewerProps }: ProjectFileProps) {
24+
export function EmbeddedProjectFile({
25+
projectId,
26+
fileId,
27+
downloadSourceRef,
28+
previewMode,
29+
}: ProjectFileProps) {
3130
useFileListRoom({ owner: { entityType: 'project', entityId: projectId } })
3231
const { data, isPending, error } = useProjectFile(projectId, fileId)
3332
if (isPending) {
@@ -51,7 +50,8 @@ export function EmbeddedProjectFile({ projectId, fileId, ...viewerProps }: Proje
5150
return (
5251
<div className='flex h-full flex-col overflow-hidden'>
5352
<FileViewer
54-
{...viewerProps}
53+
downloadSourceRef={downloadSourceRef}
54+
previewMode={previewMode}
5555
key={data.file.id}
5656
file={data.file}
5757
owner={data.file.owner}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -314,12 +314,6 @@ export const ResourceContent = memo(function ResourceContent({
314314
fileId={resource.id}
315315
downloadSourceRef={downloadSourceRef}
316316
previewMode={previewMode}
317-
streamingContent={previewSession?.fileId === resource.id ? textStreamingContent : undefined}
318-
isAgentEditing={isAgentEditing}
319-
streamIsIncremental={streamIsIncremental}
320-
streamOperation={previewSession?.operation}
321-
disableStreamingAutoScroll={disableStreamingAutoScroll}
322-
previewContextKey={previewContextKey}
323317
/>
324318
)
325319
}

‎apps/sim/lib/api/contracts/mothership-assistant-tools.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ export const listWorkspacesInputSchema = z.object({
349349

350350
/** Project discovery accepts pagination only; the conversation determines the accessible scope. */
351351
export const listUserProjectsInputSchema = z.object({
352-
cursor: z.string().uuid().optional(),
352+
cursor: z.string().min(1).optional(),
353353
limit: z.number().int().min(1).max(100).default(50),
354354
})
355355
export type ListUserProjectsInput = z.output<typeof listUserProjectsInputSchema>

‎apps/sim/lib/mothership/agent-cli/file-copy-transport.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function createFileCopyCliTransport(
3636
}
3737
)
3838
if (!parsed.success) return parsed.response
39+
request.signal.throwIfAborted()
3940
try {
4041
const principal = markCopilotFileCopyRequest(request, context, bound)
4142
requireResourceDelegation(principal, {

‎apps/sim/lib/mothership/generated/sim-assistant-tools.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ export const listWorkspacesInputSchema = z.object({
369369

370370
/** Project discovery accepts pagination only; the conversation determines the accessible scope. */
371371
export const listUserProjectsInputSchema = z.object({
372-
cursor: z.string().uuid().optional(),
372+
cursor: z.string().min(1).optional(),
373373
limit: z.number().int().min(1).max(100).default(50),
374374
})
375375
export type ListUserProjectsInput = z.output<typeof listUserProjectsInputSchema>

‎apps/sim/lib/mothership/generated/tool-catalog-v1.ts‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7933,12 +7933,7 @@ export const ListUserProjects: ToolCatalogEntry = {
79337933
$schema: 'http://json-schema.org/draft-07/schema#',
79347934
type: 'object',
79357935
properties: {
7936-
cursor: {
7937-
type: 'string',
7938-
format: 'uuid',
7939-
pattern:
7940-
'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$',
7941-
},
7936+
cursor: { type: 'string', minLength: 1 },
79427937
limit: { default: 50, type: 'integer', minimum: 1, maximum: 100 },
79437938
},
79447939
},

‎apps/sim/lib/mothership/generated/tool-schemas-v1.ts‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8101,9 +8101,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
81018101
properties: {
81028102
cursor: {
81038103
type: 'string',
8104-
format: 'uuid',
8105-
pattern:
8106-
'^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$',
8104+
minLength: 1,
81078105
},
81088106
limit: {
81098107
default: 50,
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { createLogger } from '@sim/logger'
2+
import { generateId } from '@sim/utils/id'
3+
import { NextRequest } from 'next/server'
4+
import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy'
5+
import { V2_PARSE_DEFAULTS, v2InvalidBodyResponse } from '@/lib/api/server/routes/v2-json-route'
6+
import { parseRequest } from '@/lib/api/server/validation'
7+
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
8+
import { createFileCopyCliTransport } from '@/lib/mothership/agent-cli/file-copy-transport'
9+
import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects'
10+
import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation'
11+
import type { ResourceChange } from '@/lib/mothership/generated/resources'
12+
import {
13+
type readSandboxResourceScope,
14+
recordSandboxResourceEffects,
15+
} from '@/lib/mothership/tools/sandbox-resources'
16+
17+
const logger = createLogger('SandboxFileCopy')
18+
19+
/** A live callback delegates the exact pair; the shared operation owns both authorization and durable provenance. */
20+
export async function proxySandboxFileCopyRequest(
21+
request: Request,
22+
token: string,
23+
scope: NonNullable<Awaited<ReturnType<typeof readSandboxResourceScope>>>
24+
): Promise<Response> {
25+
if (
26+
scope.fileOwnerProtocolVersion !== 1 ||
27+
request.headers.has('x-mothership-file-owner') ||
28+
request.headers.has('x-mothership-workspace-id')
29+
)
30+
return Response.json(
31+
{ error: 'Paired file owner routing is unavailable or contradictory' },
32+
{ status: 403 }
33+
)
34+
if (request.method !== 'POST')
35+
return Response.json({ error: 'Method not allowed' }, { status: 405 })
36+
const parsed = await parseRequest(
37+
v2CopyFileItemsContract,
38+
new NextRequest(request.clone()),
39+
{},
40+
{
41+
...V2_PARSE_DEFAULTS,
42+
invalidJsonResponse: () => v2InvalidBodyResponse(request),
43+
}
44+
)
45+
if (!parsed.success) return parsed.response
46+
const endpoint = getInternalApiBaseUrl()
47+
const transport = createFileCopyCliTransport(
48+
endpoint,
49+
{
50+
userId: scope.userId,
51+
chatId: scope.chatId,
52+
toolCallId: scope.toolCallId,
53+
copilotToolExecution: true,
54+
copilotResourceAdmission: createCopilotResourceAdmission({
55+
userId: scope.userId,
56+
invocation: { kind: 'chat', chatId: scope.chatId },
57+
}),
58+
},
59+
parsed.data.body
60+
)
61+
const forwarded = new Request(`${endpoint.replace(/\/$/, '')}${v2CopyFileItemsContract.path}`, {
62+
method: 'POST',
63+
headers: { 'content-type': 'application/json' },
64+
body: JSON.stringify(parsed.data.body),
65+
signal: request.signal,
66+
})
67+
const effects: ResourceChange[] = []
68+
let completed: Response | undefined
69+
const dispatch: typeof fetch = async (input, init) => {
70+
completed = await transport(input, init)
71+
return completed
72+
}
73+
let response: Response
74+
try {
75+
response = await createResourceEffectTransport(endpoint, dispatch, effects)(forwarded)
76+
} catch (error) {
77+
if (!completed) throw error
78+
response = completed
79+
logger.warn('Copy callback effect projection failed after API completion', {
80+
toolCallId: scope.toolCallId,
81+
})
82+
}
83+
const requestId = generateId()
84+
await recordSandboxResourceEffects(
85+
token,
86+
scope,
87+
effects.map((effect, index) => ({
88+
...effect,
89+
effectId: `${scope.runId}:${scope.toolCallId}:${requestId}:${index}`,
90+
}))
91+
)
92+
return response
93+
}

‎apps/sim/lib/mothership/tools/sandbox-resource-transport.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { generateId } from '@sim/utils/id'
33
import { NextRequest } from 'next/server'
4+
import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy'
45
import { v2DownloadFileContract, v2ReadFileTextContract } from '@/lib/api/contracts/v2/files'
56
import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request'
67
import { matchV2Route } from '@/lib/api/server/routes/in-process-transport'
@@ -19,6 +20,7 @@ import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resour
1920
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
2021
import { FileOperationOwner } from '@/lib/mothership/generated/file-owner'
2122
import type { ResourceChange } from '@/lib/mothership/generated/resources'
23+
import { proxySandboxFileCopyRequest } from '@/lib/mothership/tools/sandbox-file-copy'
2224
import { proxySandboxProjectFileRequest } from '@/lib/mothership/tools/sandbox-project-files'
2325
import {
2426
readSandboxResourceScope,
@@ -51,6 +53,8 @@ export async function proxySandboxResourceRequest(
5153
const scope = await readSandboxResourceScope(token, request.headers.get('x-api-key'))
5254
if (!scope)
5355
return Response.json({ error: 'Sandbox tool execution is no longer active' }, { status: 403 })
56+
if (path === v2CopyFileItemsContract.path)
57+
return proxySandboxFileCopyRequest(request, token, scope)
5458
const ownerHeader = request.headers.get('x-mothership-file-owner')
5559
let owner: FileOperationOwner | undefined
5660
if (ownerHeader !== null) {

0 commit comments

Comments
 (0)