Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import {
mothershipGoFetchMock,
mothershipGoFetchMockFns,
} from '@sim/testing/mocks/mothership-go-fetch.mock'
import {
mothershipWorkspaceTargetMock,
mothershipWorkspaceTargetMockFns,
} from '@sim/testing/mocks/mothership-workspace-target.mock'
import { redisConfigMockFns } from '@sim/testing/mocks/redis-config.mock'
import {
remoteSandboxProviderMock,
Expand All @@ -30,6 +34,7 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const io = vi.hoisted(() => ({ mount: vi.fn(), find: vi.fn(), write: vi.fn() }))
vi.mock('@/tools', () => toolsMock)
vi.mock('@/lib/mothership/application/workspace-target', () => mothershipWorkspaceTargetMock)
vi.mock('@/lib/mothership/tools/secret-mount-materializer.server', () => ({
materializeCopilotCodeSecrets: io.mount,
CopilotCodeSecretAccessError: class extends Error {},
Expand All @@ -54,6 +59,7 @@ vi.mock('@/lib/mothership/vfs/resource-writer', () => ({
}))

import { functionExecuteBodySchema } from '@/lib/api/contracts'
import * as inProcessTransport from '@/lib/api/server/routes/in-process-transport'
import { encryptSecret } from '@/lib/core/security/encryption'
import {
PRIVATE_TOOL_METADATA_REQUEST_HEADER,
Expand All @@ -73,12 +79,15 @@ import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/reso
import type { ToolExecutionContext } from '@/lib/mothership/tool-executor/types'
import { executeFunctionExecute } from '@/lib/mothership/tools/handlers/function-execute'
import { executeRunCode } from '@/lib/mothership/tools/handlers/run-code'
import { proxySandboxResourceRequest } from '@/lib/mothership/tools/sandbox-resource-transport'
import {
readSandboxResourceScope,
withSandboxResourceScope,
} from '@/lib/mothership/tools/sandbox-resources'
import { buildMothershipSandboxSession } from '@/lib/mothership/tools/sandbox-session'
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
import { reportTableRowDelivery } from '@/lib/table/application/row-delivery-observer'
import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import { buildFunctionExecuteBody, functionExecuteTool } from '@/tools/function/execute'
import type { CodeExecutionInput } from '@/tools/function/types'
Expand Down Expand Up @@ -320,6 +329,161 @@ function inResourceScope<T>(action: () => Promise<T>) {
)
}

async function sandboxApi(path: string, handler: () => Promise<Response>, method = 'GET') {
mothershipWorkspaceTargetMockFns.mockResolveInvocationWorkspace.mockResolvedValue(scope)
vi.spyOn(inProcessTransport, 'matchV2Route').mockReturnValue({
pattern: path,
params: { fileId: 'fixture', tableId: 'fixture' },
literals: 3,
load: async () => ({ GET: handler, POST: handler }),
})
return inResourceScope(async () => {
const session = await buildMothershipSandboxSession({
...scope,
sessionKey: chatSandboxSessionKey(chatId),
})
const endpoint = session.envs!.SIM_ENDPOINT
return proxySandboxResourceRequest(
new Request(`${endpoint}${path}`, {
method,
headers: { 'x-api-key': session.envs!.SIM_API_KEY },
}),
endpoint.split('/').at(-1)!
)
})
}

describe('sandbox API provenance admission', () => {
it('keeps ordinary API mutations usable for later code output and generated CLI input', async () => {
const response = await sandboxApi(
'/api/v2/custom-tools',
async () => Response.json({ data: { id: 'fixture-tool', title: 'fixture' } }),
'POST'
)
expect(response.status).toBe(200)
const result = await run('printf "[]" > operations.json; printf "ready"')
expect(result.projected.safe).toBe(true)
expect(result.projected.result).toMatchObject({ success: true, output: { stdout: 'ready' } })
expect(
(await readCliInputFile(chatSandboxSessionKey(chatId), 'operations.json')).toString()
).toBe('[]')
})

it('retains earlier secret protection after an API response without provenance', async () => {
await run('printf "%s" "$TOKEN" > saved.txt', ['TOKEN'])
await sandboxApi('/api/v2/custom-tools', async () => Response.json({ data: [] }))
const result = await run('cat saved.txt')
expect(result.projected.safe).toBe(true)
expect(result.projected.result).toMatchObject({
success: true,
output: { stdout: '{{TOKEN}}' },
})
await expect(readCliInputFile(chatSandboxSessionKey(chatId), 'saved.txt')).rejects.toThrow(
'protected workbench values'
)
})

it.each(['file', 'table'] as const)(
'imports explicit %s delivery evidence before later output',
async (source) => {
const response = await sandboxApi(
`/api/v2/${source === 'file' ? 'files/fixture' : 'tables/fixture/rows'}`,
async () => {
if (source === 'file') {
await reportWorkspaceFileDelivery({
status: 'exact',
entries: [
{
name: 'TOKEN',
encryptedValue: catalog[0].encryptedValue,
sourceUserId: scope.userId,
sourceWorkspaceId: scope.workspaceId,
},
],
})
} else {
await reportTableRowDelivery(
{
version: 1,
complete: true,
scope,
entries: [{ name: 'TOKEN', encryptedValue: catalog[0].encryptedValue }],
},
[{ value: canary }]
)
}
return new Response(canary)
}
)
await machine.writeFile('/home/user/delivered.txt', await response.text())
const result = await run('cat delivered.txt')
expect(result.projected.safe).toBe(true)
expect(result.projected.result).toMatchObject({
success: true,
output: { stdout: '{{TOKEN}}' },
})
}
)

it('preserves mutation completion and withholds its body when provenance storage fails', async () => {
const mutationPath = join(root, 'mutation.json')
const response = await sandboxApi(
'/api/v2/tables/fixture/rows',
async () => {
await writeFile(mutationPath, JSON.stringify({ committed: true, completed: false }))
const evalCommand = redis.eval.bind(redis)
vi.spyOn(redis, 'eval').mockImplementation((...args) => {
if (String(args[2]).startsWith('mothership:workbench-provenance:v2:')) {
return Promise.reject(new Error('Synthetic provenance storage failure'))
}
return evalCommand(...args)
})
await reportTableRowDelivery(
{
version: 1,
complete: true,
scope,
entries: [{ name: 'TOKEN', encryptedValue: catalog[0].encryptedValue }],
},
[{ value: canary }]
)
await writeFile(mutationPath, JSON.stringify({ committed: true, completed: true }))
return Response.json({ data: { value: canary } }, { status: 201 })
},
'POST'
)
expect(JSON.parse(await readFile(mutationPath, 'utf8'))).toEqual({
committed: true,
completed: true,
})
expect(response.status).toBe(502)
const body = await response.text()
expect(body).not.toContain(canary)
expect(body).toContain('completed with HTTP 201')
expect(body).toContain('Do not retry a mutation automatically')
})

it.each(['file', 'table'] as const)(
'preserves an explicit unknown %s delivery as unknown',
async (source) => {
await sandboxApi(
`/api/v2/${source === 'file' ? 'files/fixture' : 'tables/fixture/rows'}`,
async () => {
if (source === 'file') await reportWorkspaceFileDelivery({ status: 'unknown' })
else
await reportTableRowDelivery({ version: 1, complete: false, entries: [] }, [
{ value: 'unknown' },
])
return new Response('unknown')
}
)
const result = await run('printf "ready"')
expect(result.projected.safe).toBe(false)
expect(JSON.stringify(result.projected.result)).not.toContain('ready')
}
)
})

describe('persistent workbench output confidentiality', () => {
it.each(['javascript', 'shell'] as const)(
'redacts session credentials in %s output while preserving routing metadata',
Expand Down
49 changes: 0 additions & 49 deletions apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,6 @@ describe('private sandbox v2 resource transport', () => {
token
)
expect(await response.json()).toEqual({ data: { inserted: 1 } })
expect(recordInput).toHaveBeenCalledWith('mothership-chat:chat', false)
expect(recordInput.mock.invocationCallOrder[0]).toBeLessThan(
fetcher.mock.invocationCallOrder[0]!
)
expect(fetcher).toHaveBeenCalledTimes(1)
expect(recordEffects).toHaveBeenCalledWith(token, scope, [
{
Expand Down Expand Up @@ -261,14 +257,6 @@ vi.mock('@/lib/execution/remote-sandbox/session-file-provenance', () => ({
recordExistingSessionFileInput: recordInput,
}))

it('refuses delivery before dispatch when provenance cannot be recorded', async () => {
recordInput.mockRejectedValueOnce(new Error('storage unavailable'))
await expect(proxySandboxResourceRequest(request('/api/v2/tables/table'), token)).rejects.toThrow(
'storage unavailable'
)
expect(fetcher).not.toHaveBeenCalled()
})

it('does not poison public scratch after authenticated static catalog discovery', async () => {
routeMatcher.mockReturnValue({
params: { toolId: 'function_execute' },
Expand Down Expand Up @@ -333,40 +321,3 @@ it('keeps the server identity out of callback response headers and body', async
expect(JSON.stringify([...response.headers])).not.toContain('server-only-identity')
expect(await response.text()).not.toContain('server-only-identity')
})

it.each(['builtin', 'custom'] as const)(
'preserves public research scratch only for producer-classified %s block catalog content',
async (source) => {
routeMatcher.mockReturnValue({ params: {}, load: async () => ({ GET: fetcher }) })
fetcher.mockResolvedValue(
Response.json({
data: [
{
id: 'agent',
name: 'Agent',
description: 'Build an agent',
category: 'blocks',
source,
triggerAllowed: false,
triggerCapable: false,
triggerIds: [],
toolIds: [],
operationIds: [],
preview: false,
tags: [],
},
],
nextCursor: null,
})
)
expect((await proxySandboxResourceRequest(request('/api/v2/blocks'), token)).status).toBe(200)
if (source === 'builtin') expect(recordInput).not.toHaveBeenCalled()
else expect(recordInput).toHaveBeenCalledWith('mothership-chat:chat', false)
}
)
it('does not trust a source label in a malformed catalog result', async () => {
routeMatcher.mockReturnValue({ params: {}, load: async () => ({ GET: fetcher }) })
fetcher.mockResolvedValue(Response.json({ data: [{ source: 'builtin' }], nextCursor: null }))
expect((await proxySandboxResourceRequest(request('/api/v2/blocks'), token)).status).toBe(200)
expect(recordInput).toHaveBeenCalledWith('mothership-chat:chat', false)
})
Loading
Loading