Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/funny-lavender-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/agents-api": patch
---

Add S3 presigned URL support for private media delivery
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -212,4 +212,6 @@ BLOB_STORAGE_LOCAL_PATH=.blob-storage
# Custom endpoint URL. Often required for non-AWS providers (R2, MinIO, B2, Spaces, etc.).
# BLOB_STORAGE_S3_ENDPOINT=
# Path-style URLs. Default false for AWS S3. Some S3-compatible providers require true.
# BLOB_STORAGE_S3_FORCE_PATH_STYLE=false
# BLOB_STORAGE_S3_FORCE_PATH_STYLE=false
# Expiry in seconds for S3 presigned media URLs. Default 7200 (2 hours). Range: 60–604800.
# BLOB_STORAGE_PRESIGNED_URL_EXPIRY_SECONDS=7200
1 change: 1 addition & 0 deletions agents-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"dependencies": {
"@ai-sdk/google": "3.0.4",
"@aws-sdk/client-s3": "^3.985.0",
"@aws-sdk/s3-request-presigner": "3.995.0",
"@hono/mcp": "^0.1.5",
"@hono/swagger-ui": "^0.5.1",
"@hono/zod-openapi": "^1.1.5",
Expand Down
2 changes: 1 addition & 1 deletion agents-api/src/domains/manage/routes/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ app.openapi(

const llmContext = formatMessagesForLLMContext(messages);

const resolvedMessages = resolveMessagesListBlobUris(messages);
const resolvedMessages = await resolveMessagesListBlobUris(messages);

return c.json({
data: {
Expand Down
2 changes: 1 addition & 1 deletion agents-api/src/domains/run/routes/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ app.openapi(
}),
]);

const resolvedMessages = resolveMessagesListBlobUris(
const resolvedMessages = await resolveMessagesListBlobUris(
messageList.map((msg) => ({ ...msg, content: msg.content as MessageContent }))
);

Expand Down
181 changes: 168 additions & 13 deletions agents-api/src/domains/run/services/__tests__/resolve-blob-uris.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { MessageContent } from '@inkeep/agents-core';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
resolveMessageBlobUris,
resolveMessagesListBlobUris,
Expand All @@ -11,8 +11,31 @@ vi.mock('../../../../env', () => ({
},
}));

const mockGetPresignedUrl = vi.fn();

vi.mock('../blob-storage/index', async (importOriginal) => {
const actual = await importOriginal<typeof import('../blob-storage/index')>();
return {
...actual,
getBlobStorageProvider: vi.fn(() => ({
getPresignedUrl: undefined,
})),
};
});

describe('resolveMessageBlobUris', () => {
it('resolves blob file parts to media proxy URLs', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('resolves blob file parts to media proxy URLs when presigned URLs are not available', async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first test ("resolves blob file parts to media proxy URLs when presigned URLs are not available") relies on the top-level mock returning { getPresignedUrl: undefined }, while all other tests re-mock getBlobStorageProvider within the test body. This works because beforeEach calls vi.clearAllMocks() but does not reset the module-level mock's return value — so the top-level undefined presigned URL mock is still in effect.

This is fragile: if someone adds a test above that changes the mock return value and forgets to reset it, this test breaks silently. Consider explicitly setting getBlobStorageProvider in this test too, matching the pattern of all other tests in the suite.

const { getBlobStorageProvider } = await import('../blob-storage/index');
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
});

const content: MessageContent = {
text: 'Hello',
parts: [
Expand All @@ -25,7 +48,7 @@ describe('resolveMessageBlobUris', () => {
],
};

const resolved = resolveMessageBlobUris(content);
const resolved = await resolveMessageBlobUris(content);

expect(resolved.parts).toHaveLength(2);
expect(resolved.parts?.[0]).toEqual({ kind: 'text', text: 'Hello' });
Expand All @@ -36,7 +59,51 @@ describe('resolveMessageBlobUris', () => {
});
});

it('uses provided base URL override when specified', () => {
it('generates presigned URLs when provider supports getPresignedUrl', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
mockGetPresignedUrl.mockResolvedValue(
'https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc'
);
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
getPresignedUrl: mockGetPresignedUrl,
});

const content: MessageContent = {
text: 'Hello',
parts: [
{
kind: 'file',
data: 'blob://v1/t_tenant/media/p_project/conv/c_conversation/m_msg/sha256-hash.png',
metadata: { mimeType: 'image/png' },
},
],
};

const resolved = await resolveMessageBlobUris(content);

expect(mockGetPresignedUrl).toHaveBeenCalledWith(
'v1/t_tenant/media/p_project/conv/c_conversation/m_msg/sha256-hash.png'
);
expect(resolved.parts?.[0]).toEqual({
kind: 'file',
data: 'https://bucket.s3.amazonaws.com/key?X-Amz-Signature=abc',
metadata: { mimeType: 'image/png' },
});
});

it('falls back to proxy URL when presigned URL generation fails', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
mockGetPresignedUrl.mockRejectedValue(new Error('S3 credential expired'));
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
getPresignedUrl: mockGetPresignedUrl,
});

const content: MessageContent = {
text: 'Hello',
parts: [
Expand All @@ -48,7 +115,70 @@ describe('resolveMessageBlobUris', () => {
],
};

const resolved = resolveMessageBlobUris(content, 'https://api.example.com');
const resolved = await resolveMessageBlobUris(content);

expect(resolved.parts?.[0]).toEqual({
kind: 'file',
data: 'http://localhost:3002/manage/tenants/tenant/projects/project/conversations/conversation/media/m_msg%2Fsha256-hash.png',
metadata: { mimeType: 'image/png' },
});
});

it('handles mixed content with presigned URLs active', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
mockGetPresignedUrl.mockResolvedValue('https://bucket.s3.amazonaws.com/signed');
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
getPresignedUrl: mockGetPresignedUrl,
});

const content: MessageContent = {
text: 'Mixed',
parts: [
{ kind: 'text', text: 'Hello' },
{
kind: 'file',
data: 'blob://v1/t_tenant/media/p_project/conv/c_conversation/m_msg/sha256-hash.png',
metadata: { mimeType: 'image/png' },
},
{
kind: 'file',
data: 'https://example.com/external.png',
metadata: { mimeType: 'image/png' },
},
],
};

const resolved = await resolveMessageBlobUris(content);

expect(resolved.parts).toHaveLength(3);
expect(resolved.parts?.[0]).toEqual({ kind: 'text', text: 'Hello' });
expect(resolved.parts?.[1]?.data).toBe('https://bucket.s3.amazonaws.com/signed');
expect(resolved.parts?.[2]?.data).toBe('https://example.com/external.png');
});

it('uses provided base URL override when specified', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
});

const content: MessageContent = {
text: 'Hello',
parts: [
{
kind: 'file',
data: 'blob://v1/t_tenant/media/p_project/conv/c_conversation/m_msg/sha256-hash.png',
metadata: { mimeType: 'image/png' },
},
],
};

const resolved = await resolveMessageBlobUris(content, 'https://api.example.com');

expect(resolved.parts?.[0]).toEqual({
kind: 'file',
Expand All @@ -57,12 +187,19 @@ describe('resolveMessageBlobUris', () => {
});
});

it('returns content unchanged when there are no parts', () => {
it('returns content unchanged when there are no parts', async () => {
const content: MessageContent = { text: 'Hello' };
expect(resolveMessageBlobUris(content)).toEqual(content);
expect(await resolveMessageBlobUris(content)).toEqual(content);
});

it('returns non-blob file URIs unchanged', () => {
it('returns non-blob file URIs unchanged', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
});

const content: MessageContent = {
text: 'Hello',
parts: [
Expand All @@ -74,10 +211,17 @@ describe('resolveMessageBlobUris', () => {
],
};

expect(resolveMessageBlobUris(content)).toEqual(content);
expect(await resolveMessageBlobUris(content)).toEqual(content);
});

it('filters malformed blob keys that do not include tenant/project/conversation', () => {
it('filters malformed blob keys that do not include tenant/project/conversation', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
});

const content: MessageContent = {
text: 'Hello',
parts: [
Expand All @@ -90,13 +234,24 @@ describe('resolveMessageBlobUris', () => {
],
};

const resolved = resolveMessageBlobUris(content);
const resolved = await resolveMessageBlobUris(content);
expect(resolved.parts).toEqual([{ kind: 'text', text: 'keep-me' }]);
});
});

describe('resolveMessagesListBlobUris', () => {
it('resolves blob URIs for each message in the list', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('resolves blob URIs for each message in the list', async () => {
const { getBlobStorageProvider } = await import('../blob-storage/index');
vi.mocked(getBlobStorageProvider).mockReturnValue({
upload: vi.fn(),
download: vi.fn(),
delete: vi.fn(),
});

const messages = [
{
id: 'msg-1',
Expand All @@ -117,7 +272,7 @@ describe('resolveMessagesListBlobUris', () => {
},
];

const resolved = resolveMessagesListBlobUris(messages);
const resolved = await resolveMessagesListBlobUris(messages);

expect(resolved[0].content.parts?.[0]).toEqual({
kind: 'file',
Expand Down
79 changes: 79 additions & 0 deletions agents-api/src/domains/run/services/__tests__/s3-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ vi.mock('@aws-sdk/client-s3', () => ({
DeleteObjectCommand: vi.fn(),
}));

vi.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: vi
.fn()
.mockResolvedValue('https://bucket.s3.us-east-1.amazonaws.com/key?signed=true'),
}));

vi.mock('@smithy/node-http-handler', () => ({
NodeHttpHandler: vi.fn().mockImplementation(() => ({})),
}));
Expand Down Expand Up @@ -102,4 +108,77 @@ describe('S3BlobStorageProvider', () => {
'S3 delete failed for key tenant/project/file.png: NoSuchKey'
);
});

it('generates presigned URLs using getSignedUrl', async () => {
vi.doMock('../../../../env', () => ({
env: {
BLOB_STORAGE_S3_ENDPOINT: 'http://localhost:9000',
BLOB_STORAGE_S3_BUCKET: 'bucket',
BLOB_STORAGE_S3_REGION: 'us-east-1',
BLOB_STORAGE_S3_ACCESS_KEY_ID: 'key',
BLOB_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
BLOB_STORAGE_S3_FORCE_PATH_STYLE: true,
BLOB_STORAGE_PRESIGNED_URL_EXPIRY_SECONDS: 7200,
},
}));

const { S3BlobStorageProvider } = await import('../blob-storage/s3-provider');
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const provider = new S3BlobStorageProvider();

const url = await provider.getPresignedUrl('v1/t_tenant/media/file.png');

expect(url).toBe('https://bucket.s3.us-east-1.amazonaws.com/key?signed=true');
expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
expiresIn: 7200,
});
});

it('uses custom expiry when provided', async () => {
vi.doMock('../../../../env', () => ({
env: {
BLOB_STORAGE_S3_ENDPOINT: 'http://localhost:9000',
BLOB_STORAGE_S3_BUCKET: 'bucket',
BLOB_STORAGE_S3_REGION: 'us-east-1',
BLOB_STORAGE_S3_ACCESS_KEY_ID: 'key',
BLOB_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
BLOB_STORAGE_S3_FORCE_PATH_STYLE: true,
BLOB_STORAGE_PRESIGNED_URL_EXPIRY_SECONDS: 7200,
},
}));

const { S3BlobStorageProvider } = await import('../blob-storage/s3-provider');
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
const provider = new S3BlobStorageProvider();

await provider.getPresignedUrl('v1/t_tenant/media/file.png', 900);

expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), {
expiresIn: 900,
});
});

it('wraps presigned URL errors with key context', async () => {
vi.doMock('../../../../env', () => ({
env: {
BLOB_STORAGE_S3_ENDPOINT: 'http://localhost:9000',
BLOB_STORAGE_S3_BUCKET: 'bucket',
BLOB_STORAGE_S3_REGION: 'us-east-1',
BLOB_STORAGE_S3_ACCESS_KEY_ID: 'key',
BLOB_STORAGE_S3_SECRET_ACCESS_KEY: 'secret',
BLOB_STORAGE_S3_FORCE_PATH_STYLE: true,
BLOB_STORAGE_PRESIGNED_URL_EXPIRY_SECONDS: 7200,
},
}));

const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
vi.mocked(getSignedUrl).mockRejectedValueOnce(new Error('SignatureError'));

const { S3BlobStorageProvider } = await import('../blob-storage/s3-provider');
const provider = new S3BlobStorageProvider();

await expect(provider.getPresignedUrl('tenant/project/file.png')).rejects.toThrow(
'S3 presigned URL failed for key tenant/project/file.png: SignatureError'
);
});
});
Loading
Loading