diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 79d8cc75e..90a743cf7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -375,7 +375,9 @@ tools. This requirement does not add execution prerequisites to Files reads. Skills and their immutable versions are Core-owned tenant resources, independent of Sessions and native Skill installations. Serialize version allocation and pointer -mutations under the owning Skill row; preserve unique version identities across +mutations under the owning Skill row. Keep top-level name and description aligned +with the default version in the same transaction, including default-changing uploads; +nondefault uploads preserve that metadata. Preserve unique version identities across concurrent uploads and deletion. Metadata reads never load or decrypt bundle bytes. Encrypt bundle contents with a tenant, Skill and version binding using the existing service cipher. Deleting a Skill reclaims its versions without affecting already @@ -784,7 +786,11 @@ metadata insertion and bytes commit atomically; deletion removes metadata and unlinks the object in one transaction. Keep OIDs private and authorize every metadata/content/delete lookup by tenant before opening a body. Stream bounded chunks; never hold an entire general Files upload in memory or use filenames as -filesystem paths. A read-only repeatable-read transaction preserves an admitted +filesystem paths. Public source download admission is separate from internal byte +consumption: reject direct downloads of the supported `user_data` purpose after +tenant-scoped metadata lookup; initialization and workspace copies keep their +authorized Store read. Core Web must not offer that unavailable download action. +A read-only repeatable-read transaction preserves an admitted source snapshot across concurrent deletion. Resolve that snapshot before entering the existing Environment write path; deleting a source does not undo a completed workspace copy. Bound request/transaction lifetimes, roll back incomplete bodies, diff --git a/apps/web/e2e/fixture-core.mjs b/apps/web/e2e/fixture-core.mjs index 97845a260..00a142d7b 100644 --- a/apps/web/e2e/fixture-core.mjs +++ b/apps/web/e2e/fixture-core.mjs @@ -843,15 +843,10 @@ const server = http.createServer(async (request, response) => { if (request.headers["openai-beta"] != null) return sendError(response, 400, "Source Files do not accept the Agents beta header in this fixture."); const source = state.sourceFiles.get(id); if (!source) return sendError(response, 404, "Fixture Source File not found."); - response.writeHead(200, { - "content-type": "application/octet-stream", - "content-disposition": `attachment; filename="${source.metadata.filename}"`, - "content-length": source.data.length, - "cache-control": "no-store", - "x-content-type-options": "nosniff", - }); - response.end(source.data); - return; + return sendJson(response, { error: { + message: "Not allowed to download files of purpose: user_data", + type: "invalid_request_error", code: null, param: null, + } }, 400); } const sourceFileMatch = url.pathname.match(/^\/v1\/files\/([^/]+)$/); diff --git a/apps/web/e2e/vault-credentials.spec.ts b/apps/web/e2e/vault-credentials.spec.ts index 6892686e2..3019b8685 100644 --- a/apps/web/e2e/vault-credentials.spec.ts +++ b/apps/web/e2e/vault-credentials.spec.ts @@ -171,6 +171,7 @@ test("creates, replaces, uses, and deletes a write-only Vault Credential", async await expect(vaultCard).toBeVisible(); await vaultCard.getByRole("button", { name: "Credential", exact: true }).click(); const credentialDialog = page.getByRole("dialog", { name: "Add static bearer Credential" }); + await expect(credentialDialog.getByLabel("Name")).toBeFocused(); await credentialDialog.getByLabel("Name").fill("Private docs MCP"); await credentialDialog.getByLabel("Exact MCP server URL").fill(mcpURL); await fillWriteOnlyBearer(credentialDialog); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 41a29971a..0bf723930 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -335,7 +335,6 @@ export function App() { const sourceFilesOperations = useMemo(() => ({ uploadSourceFile: (input, options) => core.uploadSourceFile(input, options), retrieveSourceFile: (fileId, options) => core.retrieveSourceFile(fileId, options), - downloadSourceFile: (fileId, options) => core.downloadSourceFile(fileId, options), deleteSourceFile: (fileId, options) => core.deleteSourceFile(fileId, options), retrieveEnvironment: (environmentId, options) => core.retrieveEnvironment(environmentId, options), createEnvironmentFile: (environmentId, input, options) => core.createEnvironmentFile(environmentId, input, options), diff --git a/apps/web/src/features/system/SourceFilesPanel.test.tsx b/apps/web/src/features/system/SourceFilesPanel.test.tsx index a0e96d59d..c3c1492f6 100644 --- a/apps/web/src/features/system/SourceFilesPanel.test.tsx +++ b/apps/web/src/features/system/SourceFilesPanel.test.tsx @@ -7,7 +7,6 @@ function operations(): SourceFilesOperations { return { uploadSourceFile: vi.fn(), retrieveSourceFile: vi.fn(), - downloadSourceFile: vi.fn(), deleteSourceFile: vi.fn(), retrieveEnvironment: vi.fn(), createEnvironmentFile: vi.fn(), @@ -38,6 +37,8 @@ describe("SourceFilesPanel", () => { expect(html).toContain("512 MiB source · 50 MiB destination"); expect(html).toContain("Upload Source File"); expect(html).toContain("Operate by Core ID"); + expect(html).toContain("Uploaded Source Files cannot be downloaded directly"); + expect(html).not.toContain(">Download"); expect(html).toContain("Core has no Source Files list API"); expect(html).toContain("Copy to hosted Workspace"); expect(html).toContain("Check Environment"); diff --git a/apps/web/src/features/system/SourceFilesPanel.tsx b/apps/web/src/features/system/SourceFilesPanel.tsx index ad77d532a..f7524125f 100644 --- a/apps/web/src/features/system/SourceFilesPanel.tsx +++ b/apps/web/src/features/system/SourceFilesPanel.tsx @@ -1,4 +1,4 @@ -import { Download, FileUp, FolderInput, RefreshCw, Search, Trash2 } from "lucide-react"; +import { FileUp, FolderInput, RefreshCw, Search, Trash2 } from "lucide-react"; import { useEffect, useRef, useState, type ChangeEvent } from "react"; import { @@ -16,14 +16,13 @@ import "./SourceFilesPanel.css"; export interface SourceFilesOperations { uploadSourceFile: AgentCore["uploadSourceFile"]; retrieveSourceFile: AgentCore["retrieveSourceFile"]; - downloadSourceFile: AgentCore["downloadSourceFile"]; deleteSourceFile: AgentCore["deleteSourceFile"]; retrieveEnvironment: AgentCore["retrieveEnvironment"]; createEnvironmentFile: AgentCore["createEnvironmentFile"]; listEnvironmentFiles: AgentCore["listEnvironmentFiles"]; } -type Operation = "upload" | "retrieve" | "download" | "delete" | "environment" | "copy" | null; +type Operation = "upload" | "retrieve" | "delete" | "environment" | "copy" | null; type NoticeTone = "info" | "success" | "error" | "warning"; interface Notice { @@ -76,11 +75,6 @@ function parentDirectory(path: string): string { return path.slice(0, path.lastIndexOf("/")) || "/workspace"; } -function safeDownloadFilename(metadata: SourceFile | null, fileId: string): string { - if (metadata?.id !== fileId) return "source-file.bin"; - return metadata.filename.replace(/[\\/\0\r\n]/g, "_") || "source-file.bin"; -} - function knownRejected(error: unknown): boolean { return error instanceof AgentCoreError && error.status >= 400 && error.status < 500; } @@ -197,36 +191,6 @@ export function SourceFilesPanel({ } }; - const download = async () => { - if (!currentId) return; - const { controller, request } = start("download"); - try { - const content = await operations.downloadSourceFile(currentId, { signal: controller.signal }); - if (!finish(request, controller)) return; - const bytes = content.data.buffer.slice( - content.data.byteOffset, - content.data.byteOffset + content.data.byteLength, - ) as ArrayBuffer; - const url = URL.createObjectURL(new Blob([bytes], { type: content.content_type })); - try { - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = safeDownloadFilename(metadata, currentId); - anchor.rel = "noopener"; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - } finally { - URL.revokeObjectURL(url); - } - setNotice({ tone: "success", text: `Downloaded ${formatFileSize(content.bytes)} after validating the complete binary response.` }); - } catch (error) { - if (!current(request, controller) || isAbort(error)) return; - finish(request, controller); - setNotice({ tone: "error", text: safeFailure(error, "download") }); - } - }; - const deleteFile = async () => { if (!currentId) return; const id = currentId; @@ -361,7 +325,7 @@ export function SourceFilesPanel({
{environmentFilesEnabled ? "512 MiB source · 50 MiB destination" : "512 MiB source"}
@@ -399,9 +363,9 @@ export function SourceFilesPanel({
-
+

Uploaded Source Files cannot be downloaded directly. Copy them to a Workspace for execution.

Core has no Source Files list API. Refreshing or reopening this page requires the ID again.

@@ -463,7 +427,7 @@ export function SourceFilesPanel({ /> {!destinationValid ?

Use one canonical absolute file path beneath /workspace/; parent traversal and trailing slashes are rejected.

: null} - {metadata && metadata.bytes > maxDestinationBytes ?

This Source File is retained and downloadable, but it exceeds the 50 MiB destination-copy limit.

: null} + {metadata && metadata.bytes > maxDestinationBytes ?

This Source File is retained, but it exceeds the 50 MiB destination-copy limit.

: null}