feat: add safe manual workspace uploads

This commit is contained in:
Federico Jaramillo Martinez
2026-06-25 15:16:44 +02:00
parent f0b779b062
commit e46d9ecbf8
34 changed files with 2314 additions and 131 deletions
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it, vi } from "vitest";
import { RemoteMachineClient } from "./machineClient.js";
describe("RemoteMachineClient", () => {
it("forwards raw binary request bodies with the provided content type", async () => {
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl);
const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
await client.request("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "image/png" });
const { input, init } = onlyFetchCall(fetchImpl);
expect(fetchInputUrl(input)).toBe("https://remote.example.test/api/projects/p1/workspaces/w1/file?path=image.png");
expect(init.method).toBe("PUT");
expect(new Headers(init.headers).get("content-type")).toBe("image/png");
if (!(init.body instanceof ArrayBuffer)) throw new Error("Expected binary request body");
expect(Array.from(new Uint8Array(init.body))).toEqual([0x89, 0x50, 0x4e, 0x47]);
});
it("serializes structured request bodies as JSON by default", async () => {
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/base/", token: "secret" }, fetchImpl);
await client.request("POST", "/api/sessions", { cwd: "/repo" });
const { input, init } = onlyFetchCall(fetchImpl);
expect(fetchInputUrl(input)).toBe("https://remote.example.test/base/api/sessions");
expect(new Headers(init.headers).get("authorization")).toBe("Bearer secret");
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
expect(init.body).toBe(JSON.stringify({ cwd: "/repo" }));
});
});
function fetchInputUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
if (input instanceof URL) return input.href;
return input.url;
}
function onlyFetchCall(fetchImpl: ReturnType<typeof vi.fn<typeof fetch>>): { input: RequestInfo | URL; init: RequestInit } {
expect(fetchImpl).toHaveBeenCalledTimes(1);
const call = fetchImpl.mock.calls[0];
if (call === undefined) throw new Error("Expected fetch call");
const [input, init] = call;
if (init === undefined) throw new Error("Expected fetch init");
return { input, init };
}
+34 -4
View File
@@ -16,6 +16,7 @@ export interface MachineJsonResponse {
export interface MachineRequestOptions {
timeoutMs?: number;
contentType?: string;
}
export interface MachineClient {
@@ -82,13 +83,14 @@ export class RemoteMachineClient implements MachineClient {
const controller = new AbortController();
const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS);
try {
const requestBody = serializeRequestBody(method, body);
const init: RequestInit = {
method,
headers: this.requestHeaders(body),
headers: this.requestHeaders(body, options),
signal: controller.signal,
redirect: "manual",
};
if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body);
if (requestBody !== undefined) init.body = requestBody;
return await this.fetchImpl(this.remoteUrl(path), init);
} catch (error) {
if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504);
@@ -98,11 +100,11 @@ export class RemoteMachineClient implements MachineClient {
}
}
private requestHeaders(body: unknown): HeadersInit {
private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit {
return {
...this.remoteHeaders(),
accept: "*/*",
...(body === undefined ? {} : { "content-type": "application/json" }),
...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }),
};
}
@@ -147,6 +149,34 @@ function headersToRecord(headers: Headers): Record<string, string> {
return Object.fromEntries(headers.entries());
}
function serializeRequestBody(method: string, body: unknown): NonNullable<RequestInit["body"]> | undefined {
if (body === undefined || method === "GET" || method === "HEAD") return undefined;
if (isRawRequestBody(body)) return body;
if (ArrayBuffer.isView(body)) return copyArrayBufferView(body);
const serialized: string = JSON.stringify(body);
return serialized;
}
function defaultContentTypeForBody(body: unknown): string {
return isRawRequestBody(body) || ArrayBuffer.isView(body) ? "application/octet-stream" : "application/json";
}
function isRawRequestBody(body: unknown): body is NonNullable<RequestInit["body"]> {
return typeof body === "string"
|| body instanceof URLSearchParams
|| body instanceof Blob
|| body instanceof FormData
|| body instanceof ReadableStream
|| body instanceof ArrayBuffer;
}
function copyArrayBufferView(view: ArrayBufferView): ArrayBuffer {
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}
function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream {
if (body === null) throw new Error("Response body is not readable");
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config.
+21 -4
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify";
import type { WebSocket } from "ws";
import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js";
import { bridgeSockets } from "../webSocketBridge.js";
import { RemoteMachineRequestError } from "./machineClient.js";
import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js";
import { MachineService } from "./machineService.js";
export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES;
@@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
app.route<{ Params: { machineId: string }; Body: unknown }>({
method: spec.method,
url: `/api/machines/:machineId${spec.path}`,
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply),
handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply),
});
}
@@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new
}
}
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise<FastifyReply> {
async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise<FastifyReply> {
if (machineId === "local") {
return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" });
}
@@ -45,7 +45,10 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met
}
try {
const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body);
const requestOptions = proxyRequestOptions(body, contentType);
const upstream = requestOptions === undefined
? await client.request(method, remoteApiPath(machineId, requestUrl), body)
: await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions);
reply.code(upstream.statusCode);
applySafeHeaders(reply, upstream.headers);
if (upstream.body === undefined) return await reply.send();
@@ -81,6 +84,20 @@ function remoteApiPath(machineId: string, requestUrl: string): string {
return `/api${compatPath}`;
}
function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined {
if (!isRawProxyBody(body)) return undefined;
const value = firstHeaderValue(contentType);
return value === undefined || value === "" ? undefined : { contentType: value };
}
function isRawProxyBody(body: unknown): boolean {
return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body);
}
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function applySafeHeaders(reply: FastifyReply, headers: Record<string, string | string[] | undefined>): void {
for (const [name, value] of Object.entries(headers)) {
if (value === undefined) continue;