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
45 changes: 9 additions & 36 deletions apps/sim/lib/mothership/agent-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,11 @@ import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-i
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { curateBlockDetail } from '@/lib/mothership/agent-cli/curation'
import { AUGMENTATION_ENGINES, runEngine } from '@/lib/mothership/agent-cli/engines'
import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport'
import { createFileUploadTransport } from '@/lib/mothership/agent-cli/file-upload-transport'
import { curateKnowledgeDocuments } from '@/lib/mothership/agent-cli/knowledge-curation'
import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects'
import { runCli } from '@/lib/mothership/agent-cli/run-cli'
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
import { executeAgentCliService } from '@/lib/mothership/agent-cli/services'
import { applySink } from '@/lib/mothership/agent-cli/sink'
import { createTableReadTransport } from '@/lib/mothership/agent-cli/table-read-transport'
import { createTracedCliTransport } from '@/lib/mothership/agent-cli/traced-transport'
import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport'
import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance'
import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target'
import {
Expand Down Expand Up @@ -79,44 +74,22 @@ async function executeBoundAgentCliRequest(
const endpoint = getInternalApiBaseUrl()
const sessionKey = context.chatId ? chatSandboxSessionKey(context.chatId) : null
const files = sessionKey ? createWorkbenchFileProvenance({ ...context, sessionKey }) : undefined
const reads = createFileReadTransport({
endpoint,
transport: createTableReadTransport({
endpoint,
transport: createTracedCliTransport(
endpoint,
createScopedCliTransport(endpoint, invocationIdentity)
),
registry: context.resolvedSecretTraceRegistry,
}),
userId: context.userId,
invocation: invocationIdentity,
registry: context.resolvedSecretTraceRegistry,
...(context.chatId !== undefined ? { chatId: context.chatId } : {}),
...(files ? { trackDownload: files.trackDownload } : {}),
})
const resources: ResourceChange[] = []
const identity: EmbeddedCliIdentity = {
endpoint,
apiKey,
workspaceId: context.workspaceId,
transport: createResourceEffectTransport(
transport: createAgentCliTransport({
endpoint,
files
? createFileUploadTransport({
endpoint,
workspaceId: context.workspaceId,
userId: context.userId,
invocation: invocationIdentity,
fallback: reads,
uploadProvenance: files.uploadProvenance,
})
: reads,
invocation: invocationIdentity,
registry: context.resolvedSecretTraceRegistry,
...(files ? { files } : {}),
resources,
request.invocation.kind === 'cli' ||
observeReads:
request.invocation.kind === 'cli' ||
(request.invocation.kind === 'augmentation' &&
AUGMENTATION_ENGINES[request.invocation.name]?.openReadResources === true)
),
AUGMENTATION_ENGINES[request.invocation.name]?.openReadResources === true),
}),
...(context.signal ? { signal: context.signal } : {}),
}

Expand Down
56 changes: 56 additions & 0 deletions apps/sim/lib/mothership/agent-cli/transport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport'
import { createFileUploadTransport } from '@/lib/mothership/agent-cli/file-upload-transport'
import { createResourceEffectTransport } from '@/lib/mothership/agent-cli/resource-effects'
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
import { createTableReadTransport } from '@/lib/mothership/agent-cli/table-read-transport'
import { createTracedCliTransport } from '@/lib/mothership/agent-cli/traced-transport'
import type { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance'
import type { CopilotChatDelegationContext } from '@/lib/mothership/auth/application-delegation'
import type { ResourceChange } from '@/lib/mothership/generated/resources'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'

/**
* The transport every Chat CLI invocation and augmentation engine reaches Sim through.
*
* Order is load-bearing: the file read layer sits outside in-process admission so every file
* read and every other `GET` runs under a delivery observer that records the secret provenance
* of the bytes, and resource effects observe what the composed stack returns.
*/
export function createAgentCliTransport(context: {
endpoint: string
invocation: CopilotChatDelegationContext & { workspaceId: string }
registry?: ResolvedSecretTraceRegistry
files?: ReturnType<typeof createWorkbenchFileProvenance>
resources: ResourceChange[]
observeReads: boolean
}): typeof fetch {
const { endpoint, invocation, registry, files } = context
const reads = createFileReadTransport({
endpoint,
transport: createTableReadTransport({
endpoint,
transport: createTracedCliTransport(endpoint, createScopedCliTransport(endpoint, invocation)),
registry,
}),
userId: invocation.userId,
invocation,
registry,
...(invocation.chatId !== undefined ? { chatId: invocation.chatId } : {}),
...(files ? { trackDownload: files.trackDownload } : {}),
})
return createResourceEffectTransport(
endpoint,
files
? createFileUploadTransport({
endpoint,
workspaceId: invocation.workspaceId,
userId: invocation.userId,
invocation,
fallback: reads,
uploadProvenance: files.uploadProvenance,
})
: reads,
context.resources,
context.observeReads
)
}
27 changes: 27 additions & 0 deletions apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createDelegatedPrincipal } from '@sim/testing/factories/principal.factory'
import {
mothershipWorkspaceTargetMock,
mothershipWorkspaceTargetMockFns,
Expand All @@ -7,6 +8,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isCopilotRequest } from '@/lib/api/server/routes/copilot-request'
import { assertWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
reportWorkspaceFileDelivery,
requireCopilotWorkspaceFileDeliveryObserver,
} from '@/lib/workspace-files/application/file-delivery-observer'

const { readScope, recordEffects, fetcher, routeMatcher, recordInput, mint } = vi.hoisted(() => ({
readScope: vi.fn(),
Expand Down Expand Up @@ -275,6 +280,28 @@ it('cannot deliver a successful unclassified file response when recording its un
expect(fetcher).toHaveBeenCalledOnce()
})

/**
* The file read and download use cases refuse a Chat principal that no delivery observer records,
* so the workbench's file reads depend on this proxy installing one around the route handler.
*/
it('dispatches a workbench file read under a delivery observer the Copilot guard admits', async () => {
routeMatcher.mockReturnValue({ params: { fileId: 'file' }, load: async () => ({ GET: fetcher }) })
fetcher.mockImplementation(async () => {
requireCopilotWorkspaceFileDeliveryObserver(createDelegatedPrincipal({ serviceId: 'copilot' }))
await reportWorkspaceFileDelivery({ status: 'exact', entries: [] })
return new Response('file text')
})
recordInput.mockResolvedValueOnce(undefined)

const response = await proxySandboxResourceRequest(
request('/api/v2/files/file/text?workspaceId=workspace'),
token
)

expect(response.status).toBe(200)
expect(await response.text()).toBe('file text')
})

vi.mock('@/lib/mothership/chat/delegation', () => ({ mintDelegationToken: mint }))

it('never resolves a real credential for an invalid callback scope', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** Chat's in-process CLI reading and reverting workspace file version history as the delegating user. */
/** Chat's in-process CLI reading workspace files and reverting their version history as the delegating user. */
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
Expand Down Expand Up @@ -35,15 +35,21 @@ import {
createKnowledgeAclFixtureIds,
seedKnowledgeAclFixture,
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport'
import { runCli } from '@/lib/mothership/agent-cli/run-cli'
import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport'
import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport'
import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation'
import {
updateWorkspaceFileContent,
uploadWorkspaceFile,
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file'
import { WorkspaceFileDeliveryUnobservedError } from '@/lib/workspace-files/application/file-delivery-observer'
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import '@/app/api/v2/files/[fileId]/text/route'
import '@/app/api/v2/files/[fileId]/versions/route'
import '@/app/api/v2/files/[fileId]/versions/[version]/route'
import '@/app/api/v2/files/[fileId]/versions/[version]/content/route'
Expand Down Expand Up @@ -121,36 +127,40 @@ describe('chat-delegated file version history', () => {
}

/**
* Chat's composed CLI transport: the provenance-observing read layer over in-process admission.
* Chat's composed CLI transport, the same stack every Chat CLI invocation and engine runs on.
* `layers` drops the outer layers to prove the inner ones hold on their own.
*/
function chatTransport(
function chatFetch(
fixture: { workspaceId: string; organizationId: string },
userId: string,
registry?: ResolvedSecretTraceRegistry,
layers: { observer?: boolean; invocationScope?: boolean } = {}
) {
): typeof fetch {
const invocation = { userId, workspaceId: fixture.workspaceId, chatId: generateId() }
const scoped = createScopedCliTransport(ORIGIN, invocation)
const transport =
layers.observer === false
? scoped
: createFileReadTransport({
? createScopedCliTransport(ORIGIN, invocation)
: createAgentCliTransport({
endpoint: ORIGIN,
transport: scoped,
userId,
invocation,
...(registry ? { registry } : {}),
resources: [],
observeReads: true,
})
return (url: string, init?: RequestInit) =>
return (input, init) =>
layers.invocationScope === false
? transport(`${ORIGIN}${url}`, init)
? transport(input, init)
: withWorkspaceInvocationScope(
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
() => transport(`${ORIGIN}${url}`, init)
() => transport(input, init)
)
}

function chatTransport(...args: Parameters<typeof chatFetch>) {
const transport = chatFetch(...args)
return (url: string, init?: RequestInit) => transport(`${ORIGIN}${url}`, init)
}

function registryFor(fixture: { workspaceId: string }, userId: string) {
return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId })
}
Expand Down Expand Up @@ -402,4 +412,129 @@ describe('chat-delegated file version history', () => {
expect(untracked.status).toBe(503)
expect(await untracked.text()).not.toContain(SECRET)
})

/**
* The current-file text read and download admitted Chat whether or not a delivery observer would
* record the secret provenance of the bytes; only the transport Chat composes today installed one.
*/
describe('current-file reads without a delivery observer', () => {
async function revertToSecretVersion(fixture: Awaited<ReturnType<typeof seedVersionedFile>>) {
const reverted = await chatTransport(fixture, fixture.aliceId)(
`/api/v2/files/${fixture.fileId}/versions/2/revert`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workspaceId: fixture.workspaceId }),
}
)
expect(reverted.status).toBe(200)
}

function inWorkspace<T>(
fixture: { workspaceId: string; organizationId: string },
run: () => T
) {
return withWorkspaceInvocationScope(
{ workspaceId: fixture.workspaceId, organizationId: fixture.organizationId },
run
)
}

function download(
fixture: { workspaceId: string; fileId: string },
principal: Parameters<typeof downloadWorkspaceFileStream.execute>[0]['principal']
) {
return downloadWorkspaceFileStream.execute({
principal,
input: { fileId: fixture.fileId, assertedWorkspaceId: fixture.workspaceId },
})
}

it('refuses a Chat text read before returning the secret', async () => {
const fixture = await seedVersionedFile()
await revertToSecretVersion(fixture)

const response = await chatTransport(fixture, fixture.bobId, undefined, { observer: false })(
`/api/v2/files/${fixture.fileId}/text?workspaceId=${fixture.workspaceId}`
)

expect(response.status).toBe(503)
const body = await response.text()
expect(body).not.toContain(SECRET)
expect(JSON.parse(body)).toMatchObject({ error: { code: 'SERVICE_UNAVAILABLE' } })
})

it('refuses a Chat download before streaming any bytes', async () => {
const fixture = await seedVersionedFile()
const principal = createCopilotChatPrincipal(
{ userId: fixture.bobId, workspaceId: fixture.workspaceId, chatId: generateId() },
WORKSPACE_FILES_DELEGATION_AUDIENCE
)

await expect(inWorkspace(fixture, () => download(fixture, principal))).rejects.toThrow(
WorkspaceFileDeliveryUnobservedError
)
})

it('still serves a workflow run, which no Chat model reads', async () => {
const fixture = await seedVersionedFile()
await revertToSecretVersion(fixture)
const now = Date.now()

const result = await inWorkspace(fixture, () =>
download(fixture, {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: fixture.bobId,
workspaceId: fixture.workspaceId,
delegationId: generateId(),
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
issuedAt: new Date(now),
expiresAt: new Date(now + 60_000),
})
)

expect(await new Response(result.stream).text()).toBe(`token=${SECRET}`)
})

it('serves a Chat `files read` through the composed CLI', async () => {
const fixture = await seedVersionedFile()
await revertToSecretVersion(fixture)
const registry = registryFor(fixture, fixture.bobId)

const result = await runCli(
['files', 'read', fixture.fileId],
{
endpoint: ORIGIN,
apiKey: 'mothership-in-process',
workspaceId: fixture.workspaceId,
transport: chatFetch(fixture, fixture.bobId, registry),
},
null
)

expect(result.exitCode, result.stderr).toBe(0)
const projected = projectResolvedSecretModelContent(result.stdout, registry)
if (!projected.safe) throw new Error('File text was withheld')
expect(projected.value).not.toContain(SECRET)
expect(projected.value).toContain('token=[REDACTED_SECRET]')
})

it('serves Chat through the transport that records provenance', async () => {
const fixture = await seedVersionedFile()
await revertToSecretVersion(fixture)
const registry = registryFor(fixture, fixture.bobId)

const response = await chatTransport(
fixture,
fixture.bobId,
registry
)(`/api/v2/files/${fixture.fileId}?workspaceId=${fixture.workspaceId}`)

expect(response.status).toBe(200)
const projected = projectResolvedSecretModelContent(await response.text(), registry)
if (!projected.safe) throw new Error('Downloaded file was withheld')
expect(projected.value).toBe('token=[REDACTED_SECRET]')
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/appl
import {
hasWorkspaceFileDeliveryObserver,
reportWorkspaceFileDelivery,
requireCopilotWorkspaceFileDeliveryObserver,
} from '@/lib/workspace-files/application/file-delivery-observer'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact'
Expand Down Expand Up @@ -100,6 +101,7 @@ async function executeDownloadWorkspaceFileStream({
DownloadWorkspaceFileInput,
ActiveWorkspaceFileContext
>): Promise<DownloadWorkspaceFileStreamResult> {
requireCopilotWorkspaceFileDeliveryObserver(principal)
const file = await getWorkspaceFile(context.workspaceId, context.fileId, {
throwOnError: true,
})
Expand Down
Loading
Loading