Skip to content

Commit 8b51a58

Browse files
GenerQAQclaude
andcommitted
feat: add Material URL proxy for unified presigned URL replacement
Replace direct S3 presigned URLs with Redis-backed material URLs that proxy file downloads through the API server. This restores protocol compatibility for encrypted projects (which previously returned null for public_url) while also unifying the URL format for non-encrypted projects. - Add MaterialService (token generation, Redis storage, S3 download+decrypt) - Add GET /api/v1/material/:token endpoint (public, no auth required) - Update ArtifactHandler.GetArtifact to always return material URLs - Update SessionService.GetMessages to use material URLs for assets - Update AgentSkillsService.GetFile to use material URLs for binary files - Revert docs/SDK changes that were workarounds for missing public_url Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 18875cd commit 8b51a58

27 files changed

Lines changed: 900 additions & 144 deletions

File tree

docs/content/docs/(guides)/store/(features)/skill.mdx

Lines changed: 4 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -130,38 +130,20 @@ for (const fileInfo of skillDetails.fileIndex || []) {
130130
result = client.skills.get_file(skill_id=skill.id, file_path="SKILL.md")
131131
print(result.content.raw)
132132

133-
# Binary files return a URL or base64-encoded content
133+
# Binary files return a URL instead
134134
result = client.skills.get_file(skill_id=skill.id, file_path="images/diagram.png")
135-
if result.raw_content:
136-
# Encryption enabled — binary content returned as base64
137-
import base64
138-
data = base64.b64decode(result.raw_content)
139-
elif result.url:
140-
# No encryption — presigned URL for direct download
141-
print(result.url)
135+
print(result.url)
142136
```
143137

144138
```typescript title="TypeScript"
145139
const fileResult = await client.skills.getFile({ skillId: skill.id, filePath: "SKILL.md" });
146140
console.log(fileResult.content?.raw);
147141

148-
// Binary files return a URL or base64-encoded content
142+
// Binary files return a URL instead
149143
const imageResult = await client.skills.getFile({ skillId: skill.id, filePath: "images/diagram.png" });
150-
if (imageResult.raw_content) {
151-
// Encryption enabled — binary content returned as base64
152-
const buffer = Buffer.from(imageResult.raw_content, 'base64');
153-
} else if (imageResult.url) {
154-
// No encryption — presigned URL for direct download
155-
console.log(imageResult.url);
156-
}
144+
console.log(imageResult.url);
157145
```
158146
</CodeGroup>
159-
160-
The SDK uses a 3-way fallback when downloading skill files:
161-
1. **`content.raw`** — text content, returned inline
162-
2. **`raw_content`** — base64-encoded binary content (when envelope encryption is enabled)
163-
3. **`url`** — presigned S3 URL for direct download (when encryption is off)
164-
165147
</Step>
166148

167149
<Step title="Delete skill">

docs/content/docs/(guides)/tool/(features)/disk_tools.mdx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,6 @@ while (true) {
135135
{"filename": "report.pdf", "file_path": "/", "expire": 3600}
136136
```
137137

138-
<Note>
139-
When **envelope encryption** is enabled on the project, presigned URLs cannot be generated because files are encrypted at rest with a per-project key. In this case the tool returns a message directing the LLM to use the API download endpoint instead.
140-
</Note>
141-
142138
### grep_disk
143139

144140
Search file contents with regex:

docs/content/docs/(guides)/tool/(features)/skill_tools.mdx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,6 @@ Returns: Skill ID, description, and file index with MIME types.
133133

134134
Returns: File content (text) or presigned URL (binary).
135135

136-
<Note>
137-
When **envelope encryption** is enabled, binary files are returned as base64-encoded content (`raw_content` field) instead of a presigned URL, since encrypted objects cannot be served directly from S3. The tool presents the content inline to the LLM regardless of delivery method.
138-
</Note>
139-
140136
<Warning>
141137
**Read-only:** These tools can only read skill content. To execute skill scripts, use [Sandbox Tools](/tool/bash_tools#mounting-agent-skills).
142138
</Warning>

src/client/acontext-py/src/acontext/agent/disk.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,10 @@ def execute(self, ctx: DiskContext, llm_arguments: dict) -> str:
455455
expire=expire,
456456
)
457457

458-
if result.public_url:
459-
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
458+
if not result.public_url:
459+
raise RuntimeError("Failed to get public URL: server did not return a URL.")
460460

461-
# Encryption enabled — no presigned URL
462-
return f"File '{normalized_path}{filename}' is available. Use the download API endpoint to retrieve its content."
461+
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
463462

464463
async def async_execute(self, ctx: AsyncDiskContext, llm_arguments: dict) -> str:
465464
"""Get a public download URL for a file (async)."""
@@ -479,10 +478,10 @@ async def async_execute(self, ctx: AsyncDiskContext, llm_arguments: dict) -> str
479478
expire=expire,
480479
)
481480

482-
if result.public_url:
483-
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
481+
if not result.public_url:
482+
raise RuntimeError("Failed to get public URL: server did not return a URL.")
484483

485-
return f"File '{normalized_path}{filename}' is available. Use the download API endpoint to retrieve its content."
484+
return f"Public download URL for '{normalized_path}{filename}' (expires in {expire}s):\n{result.public_url}"
486485

487486

488487
class GrepArtifactsTool(BaseTool):

src/client/acontext-py/src/acontext/resources/async_skills.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,6 @@ async def download(
170170

171171
if resp.content is not None:
172172
file_dest.write_text(resp.content.raw, encoding="utf-8")
173-
elif resp.raw_content is not None:
174-
import base64
175-
file_dest.write_bytes(base64.b64decode(resp.raw_content))
176173
elif resp.url is not None:
177174
async with httpx.AsyncClient() as http:
178175
r = await http.get(resp.url)

src/client/acontext-py/src/acontext/resources/skills.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,6 @@ def download(
172172

173173
if resp.content is not None:
174174
file_dest.write_text(resp.content.raw, encoding="utf-8")
175-
elif resp.raw_content is not None:
176-
import base64
177-
file_dest.write_bytes(base64.b64decode(resp.raw_content))
178175
elif resp.url is not None:
179176
r = httpx.get(resp.url)
180177
r.raise_for_status()

src/client/acontext-py/src/acontext/types/skill.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,6 @@ class GetSkillFileResp(BaseModel):
6464
None,
6565
description="Parsed file content if available (present if file is parseable)",
6666
)
67-
raw_content: str | None = Field(
68-
None,
69-
description="Base64-encoded binary content (present when encryption is enabled for non-text files)",
70-
)
71-
content_mime: str | None = Field(
72-
None,
73-
description="MIME type of raw_content",
74-
)
7567

7668

7769
class DownloadSkillToSandboxResp(BaseModel):

src/client/acontext-ts/src/agent/disk.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,12 +290,11 @@ export class DownloadFileTool extends AbstractBaseTool {
290290
expire,
291291
});
292292

293-
if (result.public_url) {
294-
return `Public download URL for '${normalizedPath}${filename}' (expires in ${expire}s):\n${result.public_url}`;
293+
if (!result.public_url) {
294+
throw new Error('Failed to get public URL: server did not return a URL.');
295295
}
296296

297-
// Encryption enabled — no presigned URL, content available via API proxy
298-
return `File '${normalizedPath}${filename}' is available. Use the download API endpoint to retrieve its content.`;
297+
return `Public download URL for '${normalizedPath}${filename}' (expires in ${expire}s):\n${result.public_url}`;
299298
}
300299
}
301300

src/client/acontext-ts/src/resources/skills.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -156,10 +156,6 @@ export class SkillsAPI {
156156

157157
if (resp.content) {
158158
await fs.writeFile(fileDest, resp.content.raw, 'utf-8');
159-
} else if (resp.raw_content) {
160-
// Binary content returned as base64 (when encryption is enabled)
161-
const buffer = Buffer.from(resp.raw_content, 'base64');
162-
await fs.writeFile(fileDest, buffer);
163159
} else if (resp.url) {
164160
const r = await fetch(resp.url);
165161
if (!r.ok) {

src/client/acontext-ts/src/types/skill.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ export const GetSkillFileRespSchema = z.object({
4747
mime: z.string(),
4848
url: z.string().nullable().optional(),
4949
content: FileContentSchema.nullable().optional(),
50-
raw_content: z.string().nullable().optional(), // base64-encoded binary content (when encryption enabled)
51-
content_mime: z.string().nullable().optional(),
5250
});
5351

5452
export type GetSkillFileResp = z.infer<typeof GetSkillFileRespSchema>;

0 commit comments

Comments
 (0)