Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions app/api/sandboxes/files/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getCorsHeaders } from "@/lib/networking/getCorsHeaders";
import { uploadSandboxFilesHandler } from "@/lib/sandbox/uploadSandboxFilesHandler";

/**
* OPTIONS handler for CORS preflight requests.
*
* @returns A NextResponse with CORS headers.
*/
export async function OPTIONS() {
return new NextResponse(null, {
status: 200,
headers: getCorsHeaders(),
});
}

/**
* POST /api/sandboxes/files
*
* Uploads one or more files to the authenticated account's GitHub org submodule.
* Resolves the target path through git submodules so files land in the correct repo.
*
* Authentication: x-api-key header or Authorization Bearer token required.
*
* Request body (multipart/form-data):
* - files: File[] (required) - One or more files to upload
* - folder: string (required) - Target folder path within the repo
* (e.g. ".openclaw/workspace/orgs/myorg")
*
* Response (200):
* - status: "success"
* - uploaded: [{ path, success, error? }]
*
* Error (400/401/404):
* - status: "error"
* - error: string
*
* @param request - The request object
* @returns A NextResponse with upload results or error
*/
export async function POST(request: NextRequest): Promise<Response> {
return uploadSandboxFilesHandler(request);
}
91 changes: 91 additions & 0 deletions lib/github/commitFileToRepo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { parseGitHubRepoUrl } from "./parseGitHubRepoUrl";

export interface CommitFileResult {
path: string;
success: boolean;
error?: string;
}

/**
* Commits a single file to a GitHub repository using the Contents API.
* Creates the file if it doesn't exist, or updates it if it does.
*
* @param params - The parameters object
* @param params.githubRepo - The full GitHub repository URL (e.g. "https://github.com/owner/repo")
* @param params.path - The file path within the repository (e.g. "src/index.ts")
* @param params.content - The file content as a Buffer or string
* @param params.message - The commit message
* @returns The result indicating success or failure
*/
export async function commitFileToRepo({
githubRepo,
path,
content,
message,
}: {
githubRepo: string;
path: string;
content: Buffer | string;
message: string;
}): Promise<CommitFileResult> {
const token = process.env.GITHUB_TOKEN;
if (!token) {
return { path, success: false, error: "GITHUB_TOKEN is not configured" };
}

const repoInfo = parseGitHubRepoUrl(githubRepo);
if (!repoInfo) {
return { path, success: false, error: "Invalid GitHub repository URL" };
}

const { owner, repo } = repoInfo;
const base64Content =
typeof content === "string"
? Buffer.from(content).toString("base64")
: content.toString("base64");

const headers = {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "Recoup-API",
"Content-Type": "application/json",
};

const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}`;

// Check if file already exists to get its SHA (required for updates)
let existingSha: string | undefined;
const existingResponse = await fetch(apiUrl, { headers });
if (existingResponse.ok) {
const existingData = (await existingResponse.json()) as { sha?: string };
existingSha = existingData.sha;
}

const body: Record<string, string> = {
message,
content: base64Content,
branch: "main",
};
if (existingSha) {
body.sha = existingSha;
}

const response = await fetch(apiUrl, {
method: "PUT",
headers,
body: JSON.stringify(body),
});

if (!response.ok) {
const errorData = (await response.json().catch(() => ({}))) as {
message?: string;
};
return {
path,
success: false,
error: errorData.message ?? `GitHub API error: ${response.status}`,
};
}

return { path, success: true };
}
193 changes: 193 additions & 0 deletions lib/sandbox/__tests__/uploadSandboxFilesHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextResponse } from "next/server";

import { uploadSandboxFilesHandler } from "../uploadSandboxFilesHandler";
import { validateAuthContext } from "@/lib/auth/validateAuthContext";
import { selectAccountSnapshots } from "@/lib/supabase/account_snapshots/selectAccountSnapshots";
import { resolveSubmodulePath } from "@/lib/github/resolveSubmodulePath";
import { commitFileToRepo } from "@/lib/github/commitFileToRepo";

vi.mock("@/lib/networking/getCorsHeaders", () => ({
getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })),
}));

vi.mock("@/lib/auth/validateAuthContext", () => ({
validateAuthContext: vi.fn(),
}));

vi.mock("@/lib/supabase/account_snapshots/selectAccountSnapshots", () => ({
selectAccountSnapshots: vi.fn(),
}));

vi.mock("@/lib/github/resolveSubmodulePath", () => ({
resolveSubmodulePath: vi.fn(),
}));

vi.mock("@/lib/github/commitFileToRepo", () => ({
commitFileToRepo: vi.fn(),
}));

const mockAccountId = "550e8400-e29b-41d4-a716-446655440000";
const mockGithubRepo = "https://github.com/testorg/test-repo";
const mockFolder = ".openclaw/workspace/orgs/myorg";

/**
*
* @param files
* @param folder
*/
function createMockFormData(files: { name: string; content: string }[], folder: string) {
const formData = new FormData();
for (const file of files) {
formData.append("files", new Blob([file.content], { type: "text/plain" }), file.name);
}
formData.append("folder", folder);
return formData;
}

/**
*
* @param formData
*/
function createMockRequest(formData?: FormData) {
if (!formData) {
formData = createMockFormData([{ name: "test.txt", content: "hello" }], mockFolder);
}
return new Request("https://example.com/api/sandboxes/files", {
method: "POST",
body: formData,
});
}

describe("uploadSandboxFilesHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("authentication", () => {
it("returns 401 when auth fails", async () => {
vi.mocked(validateAuthContext).mockResolvedValue(
NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }),
);

const request = createMockRequest();
const response = await uploadSandboxFilesHandler(request as never);
const data = await response.json();

expect(response.status).toBe(401);
expect(data.error).toBe("Unauthorized");
});
});

describe("validation", () => {
it("returns 400 when folder is missing", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: mockAccountId,
orgId: null,
authToken: "token",
});

const formData = new FormData();
formData.append("files", new Blob(["hello"], { type: "text/plain" }), "test.txt");
// no folder field

const request = createMockRequest(formData);
const response = await uploadSandboxFilesHandler(request as never);
const data = await response.json();

expect(response.status).toBe(400);
expect(data.error).toContain("folder");
});

it("returns 400 when no files are provided", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: mockAccountId,
orgId: null,
authToken: "token",
});

const formData = new FormData();
formData.append("folder", mockFolder);

const request = createMockRequest(formData);
const response = await uploadSandboxFilesHandler(request as never);
const data = await response.json();

expect(response.status).toBe(400);
expect(data.error).toContain("file");
});
});

describe("snapshot lookup", () => {
it("returns 404 when no GitHub repo is found", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: mockAccountId,
orgId: null,
authToken: "token",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([]);

const request = createMockRequest();
const response = await uploadSandboxFilesHandler(request as never);
const data = await response.json();

expect(response.status).toBe(404);
expect(data.error).toContain("GitHub repository");
});
});

describe("successful upload", () => {
it("commits files to the resolved submodule repo", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: mockAccountId,
orgId: null,
authToken: "token",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([
{ github_repo: mockGithubRepo } as never,
]);
vi.mocked(resolveSubmodulePath).mockResolvedValue({
githubRepo: "https://github.com/testorg/myorg-repo",
path: "test.txt",
});
vi.mocked(commitFileToRepo).mockResolvedValue({ path: "test.txt", success: true });

const request = createMockRequest();
const response = await uploadSandboxFilesHandler(request as never);
const data = await response.json();

expect(response.status).toBe(200);
expect(data.status).toBe("success");
expect(data.uploaded).toHaveLength(1);
expect(data.uploaded[0].success).toBe(true);
});

it("returns per-file results including errors", async () => {
vi.mocked(validateAuthContext).mockResolvedValue({
accountId: mockAccountId,
orgId: null,
authToken: "token",
});
vi.mocked(selectAccountSnapshots).mockResolvedValue([
{ github_repo: mockGithubRepo } as never,
]);
vi.mocked(resolveSubmodulePath).mockResolvedValue({
githubRepo: mockGithubRepo,
path: "test.txt",
});
vi.mocked(commitFileToRepo).mockResolvedValue({
path: "test.txt",
success: false,
error: "Permission denied",
});

const request = createMockRequest();
const response = await uploadSandboxFilesHandler(request as never);
const data = await response.json();

expect(response.status).toBe(200);
expect(data.uploaded[0].success).toBe(false);
expect(data.uploaded[0].error).toBe("Permission denied");
});
});
});
Loading
Loading