mcp/upload.ts
Rama main · réplica de lectura del repositorio original.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { type } from "arktype";
import { fileTypeFromBuffer } from "file-type";
import { apiFetch } from "../utils/apiFetch.ts";
import { log } from "../utils/cli.ts";
import { resolveRepoRoot } from "../utils/sandbox.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
const UploadFileParams = type({
path: type.string.describe("absolute path to file to upload"),
});
/**
* this tool runs in the action process, outside the shell sandbox, so an unconfined read would
* publish `/proc/self/environ` or `/var/lib/pullfrog` secrets. confine it to the reach the
* agent's own file tools have (the repo and temp dirs), checked on the opened file via
* `/proc/self/fd` so a symlink or a path swapped after the check cannot escape. off linux, a
* re-resolved path whose inode must match the descriptor only narrows that window.
*/
function readUploadable(filePath: string): Buffer {
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
try {
const opened = fs.fstatSync(fd);
if (!opened.isFile()) throw new Error(`${filePath} is not a regular file`);
const real =
process.platform === "linux"
? fs.readlinkSync(`/proc/self/fd/${fd}`)
: fs.realpathSync(filePath);
const resolved = fs.statSync(real);
if (resolved.ino !== opened.ino || resolved.dev !== opened.dev) {
throw new Error(`${filePath} changed while it was being opened`);
}
const roots = [resolveRepoRoot(), os.tmpdir(), "/tmp"]
.filter((root) => fs.existsSync(root))
.map((root) => fs.realpathSync(root));
if (!roots.some((root) => real.startsWith(root + path.sep))) {
throw new Error("upload_file only uploads files under the repository or a temp directory");
}
return fs.readFileSync(fd);
} finally {
fs.closeSync(fd);
}
}
export function UploadFileTool(ctx: ToolContext) {
return tool({
name: "upload_file",
mutates: true,
description:
"upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed. when embedding uploaded images in comments or PR bodies, always use markdown image syntax: ",
parameters: UploadFileParams,
execute: execute(async (params) => {
// read file from disk eagerly on purpose to avoid its content being changed by the time it's uploaded
const buffer = readUploadable(params.path);
const filename = path.basename(params.path);
const contentLength = buffer.length;
const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream";
const response = await apiFetch({
path: "/api/upload/signed-url",
method: "POST",
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
filename,
contentType,
contentLength,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`failed to get upload URL: ${error}`);
}
const { uploadUrl, publicUrl, contentDisposition } = (await response.json()) as {
uploadUrl: string;
publicUrl: string;
contentDisposition?: string | undefined;
};
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": contentType,
// should be set automatically, but given this header is signed it's better to be explicit
"Content-Length": String(contentLength),
...(contentDisposition && { "Content-Disposition": contentDisposition }),
},
body: buffer,
});
if (!uploadResponse.ok) {
throw new Error(`failed to upload file: ${uploadResponse.statusText}`);
}
log.info(`» uploaded file ${publicUrl}`);
return { success: true, publicUrl, filename, contentLength, contentType };
}),
});
}