Unverified 提交 26dc0390 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

[codex] fix binary-safe cloud sandbox sync uploads (#3188)

## What changed - switch the cloud sandbox upload client to a multipart manifest-plus-file request instead of JSON `Record<string, string>` payloads - read synced files as raw bytes for both full and incremental uploads while preserving `replaceAll`, `deletedFiles`, and queued incremental sync behavior - update the fake cloud sandbox server and desktop-side tests to validate multipart uploads and binary byte preservation ## Why The previous upload path decoded files as UTF-8 strings before sending them to the engine, which corrupted binary assets and broke cloud previews for files like images, fonts, and wasm. ## Impact Desktop cloud sandbox syncs now send byte-exact file contents to the engine, including incremental syncs. ## Compatibility - depends on the engine multipart upload support in dyad-sh/dyad-llm-engine#146 - the engine PR keeps legacy JSON uploads available for older desktop clients, but this desktop change is required for binary-safe uploads ## Validation - `npm test -- src/ipc/utils/cloud_sandbox_provider.test.ts` - `npm run ts` - `npm run lint` - `npm run fmt:check` <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3188" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 0fb5773a
...@@ -42,6 +42,55 @@ import { ...@@ -42,6 +42,55 @@ import {
uploadCloudSandboxFiles, uploadCloudSandboxFiles,
} from "./cloud_sandbox_provider"; } from "./cloud_sandbox_provider";
type ParsedMultipartUpload = {
manifest: {
replaceAll: boolean;
deletedFiles: string[];
files: Array<{
path: string;
fieldName: string;
}>;
};
files: Record<string, Buffer>;
};
async function parseMultipartUpload(
init: RequestInit | undefined,
): Promise<ParsedMultipartUpload> {
const request = new Request("https://dyad.test/upload", {
method: "POST",
body: init?.body as BodyInit,
headers: init?.headers,
});
const formData = await request.formData();
const manifestValue = formData.get("manifest");
if (typeof manifestValue !== "string") {
throw new Error("Expected manifest form field.");
}
const manifest = JSON.parse(
manifestValue,
) as ParsedMultipartUpload["manifest"];
const files = Object.fromEntries(
await Promise.all(
manifest.files.map(async ({ path, fieldName }) => {
const filePart = formData.get(fieldName);
if (!(filePart instanceof File)) {
throw new Error(`Expected file part for ${fieldName}.`);
}
return [path, Buffer.from(await filePart.arrayBuffer())] as const;
}),
),
);
return {
manifest,
files,
};
}
describe("cloud_sandbox_provider incremental sync", () => { describe("cloud_sandbox_provider incremental sync", () => {
let appPath: string; let appPath: string;
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
...@@ -91,12 +140,19 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -91,12 +140,19 @@ describe("cloud_sandbox_provider incremental sync", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0];
expect(init?.method).toBe("POST"); expect(init?.method).toBe("POST");
expect(JSON.parse(String(init?.body))).toEqual({ const upload = await parseMultipartUpload(init);
files: { expect(upload.manifest).toEqual({
"src.ts": "console.log('hi');",
},
replaceAll: false, replaceAll: false,
deletedFiles: [], deletedFiles: [],
files: [
{
path: "src.ts",
fieldName: "file_0",
},
],
});
expect(upload.files).toEqual({
"src.ts": Buffer.from("console.log('hi');"),
}); });
}); });
...@@ -113,12 +169,19 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -113,12 +169,19 @@ describe("cloud_sandbox_provider incremental sync", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({ const upload = await parseMultipartUpload(init);
files: { expect(upload.manifest).toEqual({
"keep.ts": "updated",
},
replaceAll: false, replaceAll: false,
deletedFiles: ["old.ts"], deletedFiles: ["old.ts"],
files: [
{
path: "keep.ts",
fieldName: "file_0",
},
],
});
expect(upload.files).toEqual({
"keep.ts": Buffer.from("updated"),
}); });
}); });
...@@ -131,14 +194,51 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -131,14 +194,51 @@ describe("cloud_sandbox_provider incremental sync", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({ const upload = await parseMultipartUpload(init);
files: { expect(upload.manifest).toEqual({
"a.ts": "A",
"nested/b.ts": "B",
},
replaceAll: true, replaceAll: true,
deletedFiles: [], deletedFiles: [],
files: [
{
path: "a.ts",
fieldName: "file_0",
},
{
path: "nested/b.ts",
fieldName: "file_1",
},
],
});
expect(upload.files).toEqual({
"a.ts": Buffer.from("A"),
"nested/b.ts": Buffer.from("B"),
});
});
it("uploads binary files without utf-8 transcoding", async () => {
const originalBytes = Buffer.from([0x00, 0xff, 0x10, 0x80, 0x41, 0x42]);
await fs.writeFile(path.join(appPath, "assets.bin"), originalBytes);
await syncCloudSandboxDirtyPaths({
appId: 1,
changedPaths: ["assets.bin"],
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0];
const upload = await parseMultipartUpload(init);
expect(upload.manifest).toEqual({
replaceAll: false,
deletedFiles: [],
files: [
{
path: "assets.bin",
fieldName: "file_0",
},
],
}); });
expect(upload.files["assets.bin"]).toEqual(originalBytes);
}); });
it("excludes gitignored paths, keeps root env files, and skips symlinks", async () => { it("excludes gitignored paths, keeps root env files, and skips symlinks", async () => {
...@@ -173,10 +273,10 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -173,10 +273,10 @@ describe("cloud_sandbox_provider incremental sync", () => {
}); });
await expect(buildCloudSandboxFileMap(appPath)).resolves.toEqual({ await expect(buildCloudSandboxFileMap(appPath)).resolves.toEqual({
".env": "ROOT_ENV=1", ".env": Buffer.from("ROOT_ENV=1"),
".env.local": "ROOT_ENV_LOCAL=1", ".env.local": Buffer.from("ROOT_ENV_LOCAL=1"),
"symlink-target.ts": "outside", "symlink-target.ts": Buffer.from("outside"),
"visible.ts": "export const ok = true;", "visible.ts": Buffer.from("export const ok = true;"),
}); });
}); });
...@@ -201,13 +301,24 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -201,13 +301,24 @@ describe("cloud_sandbox_provider incremental sync", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({ const upload = await parseMultipartUpload(init);
files: { expect(upload.manifest).toEqual({
".env.local": "SAFE_ENV=1",
"changed.ts": "updated",
},
replaceAll: false, replaceAll: false,
deletedFiles: ["ignored.ts", "linked.ts"], deletedFiles: ["ignored.ts", "linked.ts"],
files: [
{
path: ".env.local",
fieldName: "file_0",
},
{
path: "changed.ts",
fieldName: "file_1",
},
],
});
expect(upload.files).toEqual({
".env.local": Buffer.from("SAFE_ENV=1"),
"changed.ts": Buffer.from("updated"),
}); });
}); });
...@@ -222,13 +333,24 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -222,13 +333,24 @@ describe("cloud_sandbox_provider incremental sync", () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0]; const [, init] = fetchMock.mock.calls[0];
expect(JSON.parse(String(init?.body))).toEqual({ const upload = await parseMultipartUpload(init);
files: { expect(upload.manifest).toEqual({
".gitignore": "dist\n",
"index.ts": "console.log('ok');",
},
replaceAll: true, replaceAll: true,
deletedFiles: [], deletedFiles: [],
files: [
{
path: ".gitignore",
fieldName: "file_0",
},
{
path: "index.ts",
fieldName: "file_1",
},
],
});
expect(upload.files).toEqual({
".gitignore": Buffer.from("dist\n"),
"index.ts": Buffer.from("console.log('ok');"),
}); });
}); });
...@@ -308,12 +430,19 @@ describe("cloud_sandbox_provider incremental sync", () => { ...@@ -308,12 +430,19 @@ describe("cloud_sandbox_provider incremental sync", () => {
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
const [, secondInit] = fetchMock.mock.calls[1]; const [, secondInit] = fetchMock.mock.calls[1];
expect(JSON.parse(String(secondInit?.body))).toEqual({ const upload = await parseMultipartUpload(secondInit);
files: { expect(upload.manifest).toEqual({
"second.ts": "second",
},
replaceAll: false, replaceAll: false,
deletedFiles: [], deletedFiles: [],
files: [
{
path: "second.ts",
fieldName: "file_0",
},
],
});
expect(upload.files).toEqual({
"second.ts": Buffer.from("second"),
}); });
}); });
......
...@@ -14,12 +14,23 @@ const DYAD_ENGINE_URL = ...@@ -14,12 +14,23 @@ const DYAD_ENGINE_URL =
const CLOUD_SANDBOX_EXCLUDED_DIRS = new Set(["node_modules", ".git", ".next"]); const CLOUD_SANDBOX_EXCLUDED_DIRS = new Set(["node_modules", ".git", ".next"]);
const CLOUD_SANDBOX_ROOT_ALLOWLIST = new Set([".env", ".env.local"]); const CLOUD_SANDBOX_ROOT_ALLOWLIST = new Set([".env", ".env.local"]);
export type CloudSandboxFileMap = Record<string, string>; type CloudSandboxFileBytes = Uint8Array;
export type CloudSandboxFileMap = Record<string, CloudSandboxFileBytes>;
export type CloudSandboxSyncUpdate = { export type CloudSandboxSyncUpdate = {
appId: number; appId: number;
errorMessage: string | null; errorMessage: string | null;
}; };
type CloudSandboxUploadManifest = {
replaceAll: boolean;
deletedFiles: string[];
files: Array<{
path: string;
fieldName: string;
}>;
};
const CloudSandboxCreateResponseSchema = z.object({ const CloudSandboxCreateResponseSchema = z.object({
sandboxId: z.string().trim().min(1), sandboxId: z.string().trim().min(1),
previewUrl: z.string().trim().min(1), previewUrl: z.string().trim().min(1),
...@@ -216,7 +227,10 @@ async function cloudSandboxFetch( ...@@ -216,7 +227,10 @@ async function cloudSandboxFetch(
): Promise<Response> { ): Promise<Response> {
const apiKey = getDyadEngineApiKey(); const apiKey = getDyadEngineApiKey();
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body) { const isMultipartBody =
typeof FormData !== "undefined" && init.body instanceof FormData;
if (!headers.has("Content-Type") && init.body && !isMultipartBody) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
if (apiKey) { if (apiKey) {
...@@ -284,6 +298,40 @@ async function parseResponseJson<T>( ...@@ -284,6 +298,40 @@ async function parseResponseJson<T>(
return result.data; return result.data;
} }
function buildCloudSandboxUploadFormData(input: {
files: CloudSandboxFileMap;
replaceAll: boolean;
deletedFiles: string[];
}): FormData {
const formData = new FormData();
const manifest: CloudSandboxUploadManifest = {
replaceAll: input.replaceAll,
deletedFiles: input.deletedFiles,
files: [],
};
const sortedFiles = Object.entries(input.files).sort(([left], [right]) =>
left.localeCompare(right),
);
for (const [filePath, content] of sortedFiles) {
const fieldName = `file_${manifest.files.length}`;
manifest.files.push({
path: filePath,
fieldName,
});
formData.append(
fieldName,
new Blob([Uint8Array.from(content)], {
type: "application/octet-stream",
}),
path.posix.basename(filePath) || fieldName,
);
}
formData.append("manifest", JSON.stringify(manifest));
return formData;
}
export async function buildCloudSandboxFileMap( export async function buildCloudSandboxFileMap(
appPath: string, appPath: string,
): Promise<CloudSandboxFileMap> { ): Promise<CloudSandboxFileMap> {
...@@ -292,7 +340,7 @@ export async function buildCloudSandboxFileMap( ...@@ -292,7 +340,7 @@ export async function buildCloudSandboxFileMap(
files.map(async (relativePath) => { files.map(async (relativePath) => {
const normalizedPath = normalizePath(relativePath); const normalizedPath = normalizePath(relativePath);
const fullPath = path.join(appPath, normalizedPath); const fullPath = path.join(appPath, normalizedPath);
const content = await fsPromises.readFile(fullPath, "utf-8"); const content = await fsPromises.readFile(fullPath);
return [normalizedPath, content] as const; return [normalizedPath, content] as const;
}), }),
); );
...@@ -448,7 +496,7 @@ async function buildCloudSandboxPartialFileMap(input: { ...@@ -448,7 +496,7 @@ async function buildCloudSandboxPartialFileMap(input: {
continue; continue;
} }
const content = await fsPromises.readFile(fullPath, "utf-8"); const content = await fsPromises.readFile(fullPath);
files[normalizedPath] = content; files[normalizedPath] = content;
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") { if ((error as NodeJS.ErrnoException).code === "ENOENT") {
...@@ -698,7 +746,7 @@ class DyadEngineCloudSandboxProvider implements CloudSandboxProvider { ...@@ -698,7 +746,7 @@ class DyadEngineCloudSandboxProvider implements CloudSandboxProvider {
) { ) {
const response = await cloudSandboxFetch(`/sandboxes/${sandboxId}/files`, { const response = await cloudSandboxFetch(`/sandboxes/${sandboxId}/files`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: buildCloudSandboxUploadFormData({
files, files,
replaceAll: options?.replaceAll ?? false, replaceAll: options?.replaceAll ?? false,
deletedFiles: options?.deletedFiles ?? [], deletedFiles: options?.deletedFiles ?? [],
......
...@@ -77,7 +77,7 @@ export const CANNED_MESSAGE = ` ...@@ -77,7 +77,7 @@ export const CANNED_MESSAGE = `
type FakeCloudSandbox = { type FakeCloudSandbox = {
id: string; id: string;
files: Record<string, string>; files: Record<string, Buffer>;
createdAt: number; createdAt: number;
previewAuthToken: string; previewAuthToken: string;
syncRevision: number; syncRevision: number;
...@@ -101,6 +101,81 @@ function createServiceResponse<T>(responseObject: T) { ...@@ -101,6 +101,81 @@ function createServiceResponse<T>(responseObject: T) {
}; };
} }
async function parseMultipartFormData(req: express.Request) {
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const entry of value) {
headers.append(key, entry);
}
continue;
}
if (value !== undefined) {
headers.set(key, value);
}
}
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const request = new Request("http://localhost/fake-cloud-upload", {
method: req.method,
headers,
body: Buffer.concat(chunks),
});
return request.formData();
}
async function parseCloudSandboxUpload(req: express.Request) {
if (!req.is("multipart/form-data")) {
return {
replaceAll: req.body.replaceAll === true,
deletedFiles: Array.isArray(req.body.deletedFiles)
? req.body.deletedFiles
: [],
files: Object.fromEntries(
Object.entries(req.body.files ?? {}).map(([filePath, content]) => [
filePath,
Buffer.from(String(content), "utf8"),
]),
) as Record<string, Buffer>,
};
}
const formData = await parseMultipartFormData(req);
const manifestValue = formData.get("manifest");
if (typeof manifestValue !== "string") {
throw new Error("Expected multipart sandbox upload manifest.");
}
const manifest = JSON.parse(manifestValue) as {
replaceAll: boolean;
deletedFiles?: string[];
files?: Array<{ path: string; fieldName: string }>;
};
const files: Record<string, Buffer> = {};
for (const entry of manifest.files ?? []) {
const filePart = formData.get(entry.fieldName);
if (!(filePart instanceof File)) {
throw new Error(`Expected multipart file part ${entry.fieldName}.`);
}
files[entry.path] = Buffer.from(await filePart.arrayBuffer());
}
return {
replaceAll: manifest.replaceAll === true,
deletedFiles: manifest.deletedFiles ?? [],
files,
};
}
function escapeHtml(text: string) { function escapeHtml(text: string) {
return text return text
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
...@@ -110,10 +185,10 @@ function escapeHtml(text: string) { ...@@ -110,10 +185,10 @@ function escapeHtml(text: string) {
function getSandboxPreviewHtml(sandbox: FakeCloudSandbox) { function getSandboxPreviewHtml(sandbox: FakeCloudSandbox) {
const interestingSource = const interestingSource =
sandbox.files["src/App.tsx"] ?? sandbox.files["src/App.tsx"]?.toString("utf8") ??
sandbox.files["src/App.jsx"] ?? sandbox.files["src/App.jsx"]?.toString("utf8") ??
sandbox.files["app/page.tsx"] ?? sandbox.files["app/page.tsx"]?.toString("utf8") ??
sandbox.files["index.html"] ?? sandbox.files["index.html"]?.toString("utf8") ??
""; "";
const fileList = Object.keys(sandbox.files) const fileList = Object.keys(sandbox.files)
...@@ -121,17 +196,16 @@ function getSandboxPreviewHtml(sandbox: FakeCloudSandbox) { ...@@ -121,17 +196,16 @@ function getSandboxPreviewHtml(sandbox: FakeCloudSandbox) {
.slice(0, 12) .slice(0, 12)
.map((file) => `<li>${escapeHtml(file)}</li>`) .map((file) => `<li>${escapeHtml(file)}</li>`)
.join(""); .join("");
const snapshotDigest = crypto const snapshotHasher = crypto.createHash("sha1");
.createHash("sha1") for (const [filePath, content] of Object.entries(sandbox.files).sort(
.update( ([leftPath], [rightPath]) => leftPath.localeCompare(rightPath),
JSON.stringify( )) {
Object.entries(sandbox.files).sort(([leftPath], [rightPath]) => snapshotHasher.update(filePath);
leftPath.localeCompare(rightPath), snapshotHasher.update("\0");
), snapshotHasher.update(content);
), snapshotHasher.update("\0");
) }
.digest("hex") const snapshotDigest = snapshotHasher.digest("hex").slice(0, 12);
.slice(0, 12);
return `<!doctype html> return `<!doctype html>
<html> <html>
...@@ -561,29 +635,31 @@ app.delete("/engine/v1/sandboxes/:sandboxId", (req, res) => { ...@@ -561,29 +635,31 @@ app.delete("/engine/v1/sandboxes/:sandboxId", (req, res) => {
res.status(204).end(); res.status(204).end();
}); });
app.post("/engine/v1/sandboxes/:sandboxId/files", (req, res) => { app.post("/engine/v1/sandboxes/:sandboxId/files", async (req, res) => {
const sandbox = cloudSandboxes.get(req.params.sandboxId); const sandbox = cloudSandboxes.get(req.params.sandboxId);
if (!sandbox) { if (!sandbox) {
res.status(404).json({ error: "Sandbox not found" }); res.status(404).json({ error: "Sandbox not found" });
return; return;
} }
const upload = await parseCloudSandboxUpload(req);
console.log( console.log(
`[fake-cloud] upload sandbox=${sandbox.id} replaceAll=${String(req.body.replaceAll)} fileCount=${Object.keys(req.body.files ?? {}).length} deletedCount=${(req.body.deletedFiles ?? []).length}`, `[fake-cloud] upload sandbox=${sandbox.id} replaceAll=${String(upload.replaceAll)} fileCount=${Object.keys(upload.files).length} deletedCount=${upload.deletedFiles.length}`,
); );
sandbox.lastActiveAt = Date.now(); sandbox.lastActiveAt = Date.now();
sandbox.lastSuccessfulSyncAt = Date.now(); sandbox.lastSuccessfulSyncAt = Date.now();
sandbox.initialSyncCompleted = true; sandbox.initialSyncCompleted = true;
sandbox.syncRevision += 1; sandbox.syncRevision += 1;
sandbox.files = req.body.replaceAll sandbox.files = upload.replaceAll
? { ...req.body.files } ? { ...upload.files }
: { : {
...sandbox.files, ...sandbox.files,
...req.body.files, ...upload.files,
}; };
for (const deletedFile of req.body.deletedFiles ?? []) { for (const deletedFile of upload.deletedFiles) {
delete sandbox.files[deletedFile]; delete sandbox.files[deletedFile];
} }
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论