Skip to content
Closed
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
97 changes: 96 additions & 1 deletion app/api/files/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { spawn } from "child_process";
import {
getAllowedFileRoots,
isExistingFilePathAllowed,
Expand Down Expand Up @@ -123,6 +124,68 @@ function parseUploadFileNames(value: unknown): string[] | null {
return value;
}

type WritableTarget = { ok: string; size: number };

/**
* Authorize writing to an existing file: the lexical path must sit inside the
* allowed roots and, after resolving symlinks, still resolve inside them so a
* link inside an allowed root cannot redirect the write outside it.
*/
async function getWritableFile(segments: string[]): Promise<WritableTarget | { response: NextResponse }> {
const filePath = filePathFromSegments(segments);
const allowedRoots = await getAllowedFileRoots();
if (!isFilePathAllowed(filePath, allowedRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}

let stat: fs.Stats;
try {
stat = fs.statSync(filePath);
} catch {
return { response: NextResponse.json({ error: "File not found" }, { status: 404 }) };
}
if (!stat.isFile()) {
return { response: NextResponse.json({ error: "Not a file" }, { status: 400 }) };
}

const realPath = fs.realpathSync(filePath);
const realRoots = new Set<string>();
for (const root of allowedRoots) {
try {
realRoots.add(fs.realpathSync(root));
} catch {
// Ignore stale session roots that no longer exist.
}
}
if (!isFilePathAllowed(realPath, realRoots)) {
return { response: NextResponse.json({ error: "Access denied" }, { status: 403 }) };
}

return { ok: realPath, size: stat.size };
}

/** Launch the platform's file manager on a directory, resolving once launched. */
function openInSystemFileManager(directory: string): Promise<string | null> {
return new Promise((resolve) => {
let command: string;
let args: string[];
if (process.platform === "win32") {
command = "explorer";
args = [directory];
} else if (process.platform === "darwin") {
command = "open";
args = [directory];
} else {
command = "xdg-open";
args = [directory];
}
const child = spawn(command, args, { detached: true, stdio: "ignore" });
child.on("error", () => resolve(`Failed to launch file manager (${command})`));
child.unref();
resolve(null);
});
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ path: string[] }> }
Expand All @@ -133,10 +196,42 @@ export async function POST(

try {
const { path: segments } = await params;
const type = request.nextUrl.searchParams.get("type") ?? "upload";

if (type === "save") {
const writable = await getWritableFile(segments);
if ("response" in writable) return writable.response;
if (writable.size > TEXT_PREVIEW_MAX_BYTES) {
return NextResponse.json({ error: "File too large to edit (>256KB)" }, { status: 413 });
}
const body = await request.json().catch(() => null) as { content?: unknown } | null;
if (typeof body?.content !== "string") {
return NextResponse.json({ error: "content must be a string" }, { status: 400 });
}
try {
fs.writeFileSync(writable.ok, body.content, "utf-8");
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
return NextResponse.json({ ok: true, size: Buffer.byteLength(body.content, "utf-8") });
}

if (type === "open-directory") {
const uploadDirectory = await getUploadDirectory(segments);
if ("response" in uploadDirectory) return uploadDirectory.response;
const launchError = await openInSystemFileManager(uploadDirectory.directory);
if (launchError) {
return NextResponse.json({ error: launchError }, { status: 500 });
}
return NextResponse.json({ ok: true });
}

const uploadDirectory = await getUploadDirectory(segments);
if ("response" in uploadDirectory) return uploadDirectory.response;
const { directory } = uploadDirectory;
const type = request.nextUrl.searchParams.get("type") ?? "upload";

if (type === "upload-check") {
const body = await request.json().catch(() => null) as { fileNames?: unknown } | null;
Expand Down
3 changes: 2 additions & 1 deletion app/api/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function compareModelEntries(

async function loadModels(cwd: string): Promise<ModelsData> {
const nameMap = new Map<string, string>();
let modelList: { id: string; name: string; provider: string }[] = [];
let modelList: { id: string; name: string; provider: string; acceptsImages: boolean }[] = [];
let defaultModel: { provider: string; modelId: string } | null = null;
const thinkingLevels: Record<string, string[]> = {};
const thinkingLevelMaps: Record<string, Record<string, string | null>> = {};
Expand Down Expand Up @@ -55,6 +55,7 @@ async function loadModels(cwd: string): Promise<ModelsData> {
id: m.id,
name: m.name,
provider: m.provider,
acceptsImages: m.input?.includes("image") ?? false,
})).sort(compareModelEntries);
for (const m of visible) {
const key = `${m.provider}:${m.id}`;
Expand Down
97 changes: 97 additions & 0 deletions app/api/terminal/[id]/events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { isApiRequestAllowed } from "@/lib/request-security";
import { getTerminalSession } from "@/lib/terminal-manager";

export const dynamic = "force-dynamic";

const HEARTBEAT_INTERVAL_MS = 30_000;

// GET /api/terminal/[id]/events - SSE stream of terminal output
// Frames: { type: "connected", session } → { type: "data", data }* → { type: "exit", exitCode }
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
if (!isApiRequestAllowed(req)) {
return new Response("Untrusted API request", { status: 403 });
}
if (req.signal.aborted) return new Response(null, { status: 204 });

const { id } = await params;
const session = getTerminalSession(id);
if (!session || !session.isRunning) {
return new Response("Terminal not found", { status: 404 });
}

// Captured by `start` so the stream's `cancel()` can tear down subscriptions.
let cancelStream: (closeController: boolean) => void = () => {};

const stream = new ReadableStream<Uint8Array>({
start(controller) {
const encoder = new TextEncoder();
let closed = false;
let heartbeat: ReturnType<typeof setInterval> | null = null;
let unsubscribeData: (() => void) | null = null;
let unsubscribeExit: (() => void) | null = null;
let abortHandler: (() => void) | null = null;

const cleanup = (closeController: boolean) => {
if (closed) return;
closed = true;
if (heartbeat !== null) clearInterval(heartbeat);
unsubscribeData?.();
unsubscribeData = null;
unsubscribeExit?.();
unsubscribeExit = null;
if (abortHandler) req.signal.removeEventListener("abort", abortHandler);
if (closeController) {
try { controller.close(); } catch { /* stream already closed */ }
}
};
cancelStream = cleanup;

const enqueue = (data: unknown) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
} catch {
cleanup(false);
}
};

abortHandler = () => cleanup(true);
if (req.signal.aborted) {
cleanup(true);
return;
}
req.signal.addEventListener("abort", abortHandler, { once: true });

unsubscribeData = session.subscribeData((data) => enqueue({ type: "data", data }));
unsubscribeExit = session.subscribeExit((exitCode) => {
enqueue({ type: "exit", exitCode });
// Give the browser a chance to read the exit frame before closing.
setTimeout(() => cleanup(true), 50);
});

enqueue({ type: "connected", session: session.toPublicInfo() });
heartbeat = setInterval(() => {
if (!closed) {
try { controller.enqueue(encoder.encode(":\n\n")); } catch { cleanup(false); }
}
}, HEARTBEAT_INTERVAL_MS);
},
cancel() {
// The request was closed by the client; only stop forwarding events.
// The shell itself keeps running so the terminal can reconnect later.
cancelStream(false);
},
});

return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
77 changes: 77 additions & 0 deletions app/api/terminal/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { isApiRequestAllowed } from "@/lib/request-security";
import { closeTerminalSession, getTerminalSession } from "@/lib/terminal-manager";

export const dynamic = "force-dynamic";

const MAX_INPUT_BYTES = 256 * 1024;

// POST /api/terminal/[id] - write input or resize a running terminal
// body: { type: "input", data: string } | { type: "resize", cols, rows }
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}

const { id } = await params;
const session = getTerminalSession(id);
if (!session || !session.isRunning) {
return NextResponse.json({ error: "Terminal not found" }, { status: 404 });
}

try {
const body = (await req.json().catch(() => null)) as
| { type?: unknown; data?: unknown; cols?: unknown; rows?: unknown }
| null;
if (!body) {
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
}
const type = body.type;

if (type === "input") {
if (typeof body.data !== "string") {
return NextResponse.json({ error: "data must be a string" }, { status: 400 });
}
if (body.data.length > MAX_INPUT_BYTES) {
return NextResponse.json({ error: "Input too large" }, { status: 413 });
}
session.write(body.data);
return NextResponse.json({ success: true });
}

if (type === "resize") {
const clamp = (value: unknown, fallback: number, min: number, max: number) => {
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
};
const cols = clamp(body.cols, 1, 1, 500);
const rows = clamp(body.rows, 1, 1, 200);
session.resize(cols, rows);
return NextResponse.json({ success: true });
}

return NextResponse.json({ error: "Unsupported action" }, { status: 400 });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}

// DELETE /api/terminal/[id] - kill the shell process and discard the session
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}

const { id } = await params;
if (!closeTerminalSession(id)) {
return NextResponse.json({ error: "Terminal not found" }, { status: 404 });
}
return NextResponse.json({ success: true });
}
77 changes: 77 additions & 0 deletions app/api/terminal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { NextResponse } from "next/server";
import { resolve } from "path";
import { statSync } from "fs";
import { isApiRequestAllowed } from "@/lib/request-security";
import { getAllowedFileRoots, isFilePathAllowed } from "@/lib/file-access";
import {
createTerminalSession,
listTerminalSessions,
DEFAULT_TERMINAL_COLUMNS,
DEFAULT_TERMINAL_ROWS,
} from "@/lib/terminal-manager";

export const dynamic = "force-dynamic";

function clampDimension(value: unknown, fallback: number, min: number, max: number): number {
const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.floor(n)));
}

// GET /api/terminal - list running terminal sessions
export async function GET(req: Request) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}
return NextResponse.json({
sessions: listTerminalSessions().map((session) => session.toPublicInfo()),
});
}

// POST /api/terminal - create a shell process in an allowed workspace
// body: { cwd: string, cols?: number, rows?: number }
export async function POST(req: Request) {
if (!isApiRequestAllowed(req)) {
return NextResponse.json({ error: "Untrusted API request" }, { status: 403 });
}

try {
const body = (await req.json().catch(() => null)) as
| { cwd?: unknown; cols?: unknown; rows?: unknown }
| null;
const cwd = typeof body?.cwd === "string" ? body.cwd.trim() : "";
if (!cwd) {
return NextResponse.json({ error: "cwd is required" }, { status: 400 });
}

const normalizedCwd = resolve(cwd);
const roots = await getAllowedFileRoots();
if (!isFilePathAllowed(normalizedCwd, roots)) {
return NextResponse.json(
{ error: "cwd is not an allowed workspace" },
{ status: 403 },
);
}
try {
if (!statSync(normalizedCwd).isDirectory()) {
return NextResponse.json({ error: "cwd is not a directory" }, { status: 400 });
}
} catch {
return NextResponse.json(
{ error: `Directory does not exist: ${normalizedCwd}` },
{ status: 400 },
);
}

const cols = clampDimension(body?.cols, DEFAULT_TERMINAL_COLUMNS, 1, 500);
const rows = clampDimension(body?.rows, DEFAULT_TERMINAL_ROWS, 1, 200);
const session = createTerminalSession(normalizedCwd, cols, rows);

return NextResponse.json(
{ success: true, session: session.toPublicInfo() },
{ status: 201 },
);
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
Loading