Skip to content
File

Blob: src/worker/index.ts

typescript272 lines
1import { getSandbox, proxyTerminal } from "@cloudflare/sandbox";
2import { authenticateRequest, deriveSandboxId, validateHostAccessPolicy, type AuthResult } from "./auth";
3 
4export { Sandbox } from "@cloudflare/sandbox";
5 
6// ---------------------------------------------------------------------------
7// Worker entry
8// ---------------------------------------------------------------------------
9 
10export default {
11 async fetch(request: Request, env: Env): Promise<Response> {
12 const url = new URL(request.url);
13 const hostPolicyError = validateHostAccessPolicy(request, env);
14 if (hostPolicyError) {
15 return jsonError(hostPolicyError, "ACCESS_REQUIRED", 403);
16 }
17 
18 // ---- Health check (no auth) ----
19 if (url.pathname === "/api/health") {
20 return Response.json({ ok: true });
21 }
22 
23 // ---- Terminal WebSocket upgrade ----
24 if (url.pathname === "/ws/terminal") {
25 if (request.headers.get("Upgrade") !== "websocket") {
26 return jsonError("Expected WebSocket upgrade", "UPGRADE_REQUIRED", 426);
27 }
28 return handleTerminal(request, env, url);
29 }
30 
31 // ---- Session management API ----
32 if (url.pathname === "/api/sessions") {
33 return handleSessionApi(request, env, url);
34 }
35 
36 // ---- Sandbox destruction ----
37 if (url.pathname === "/api/sandbox" && request.method === "DELETE") {
38 return handleSandboxDestroy(request, env, url);
39 }
40 
41 // ---- Workspace backup ----
42 if (url.pathname === "/api/workspace/backup" && request.method === "POST") {
43 return handleBackup(request, env, url);
44 }
45 
46 // ---- Workspace restore ----
47 if (url.pathname === "/api/workspace/restore" && request.method === "POST") {
48 return handleRestore(request, env, url);
49 }
50 
51 // Static assets and SPA fallback are handled by the platform
52 // (not_found_handling: "single-page-application" in wrangler.jsonc).
53 return jsonError("Not found", "NOT_FOUND", 404);
54 },
55};
56 
57// ---------------------------------------------------------------------------
58// Auth helper — authenticate and derive owned sandbox
59// ---------------------------------------------------------------------------
60 
61async function authenticateAndResolveSandbox(
62 request: Request,
63 env: Env,
64 url: URL,
65): Promise<{ auth: AuthResult; sandboxId: string } | { error: Response }> {
66 const auth = await authenticateRequest(request, env);
67 if (!auth.authenticated) {
68 return { error: jsonError(auth.error || "Unauthorized", "AUTH_REQUIRED", 401) };
69 }
70 
71 const workspace = url.searchParams.get("workspace") ?? "default";
72 const sandboxId = deriveSandboxId(auth.userId, workspace);
73 return { auth, sandboxId };
74}
75 
76// ---------------------------------------------------------------------------
77// Terminal WebSocket handler
78// ---------------------------------------------------------------------------
79 
80async function handleTerminal(request: Request, env: Env, url: URL): Promise<Response> {
81 const result = await authenticateAndResolveSandbox(request, env, url);
82 if ("error" in result) return result.error;
83 
84 const sandbox = getSandbox(env.Sandbox, result.sandboxId);
85 const sessionId = url.searchParams.get("session") ?? "";
86 
87 // Parse initial dimensions from query params, with safe clamping.
88 const cols = clampDimension(url.searchParams.get("cols"), 80, 1, 500);
89 const rows = clampDimension(url.searchParams.get("rows"), 24, 1, 200);
90 
91 return proxyTerminal(sandbox, sessionId, request, {
92 cols,
93 rows,
94 shell: "/usr/local/bin/ccccocc-shell",
95 });
96}
97 
98// ---------------------------------------------------------------------------
99// Session API handler
100// ---------------------------------------------------------------------------
101 
102async function handleSessionApi(request: Request, env: Env, url: URL): Promise<Response> {
103 const result = await authenticateAndResolveSandbox(request, env, url);
104 if ("error" in result) return result.error;
105 
106 const sandbox = getSandbox(env.Sandbox, result.sandboxId);
107 
108 // POST — create a new session
109 if (request.method === "POST") {
110 let body: {
111 id?: string;
112 cwd?: string;
113 env?: Record<string, string>;
114 labels?: Record<string, string>;
115 };
116 try {
117 body = await request.json();
118 } catch {
119 return jsonError("Invalid JSON body", "INVALID_BODY", 400);
120 }
121 
122 if (!body.id) {
123 return jsonError("Missing session id", "MISSING_SESSION_ID", 400);
124 }
125 
126 // Validate cwd — must be under /workspace to prevent path traversal
127 const cwd = body.cwd || "/workspace";
128 if (!isAllowedCwd(cwd)) {
129 return jsonError("cwd must be under /workspace", "INVALID_CWD", 400);
130 }
131 
132 await sandbox.createSession({
133 id: body.id,
134 cwd,
135 env: sanitizeEnv(body.env || {}),
136 });
137 
138 // Labels are client-side metadata — the Sandbox SDK does not persist
139 // them, but we echo them back so the caller can track them.
140 return Response.json({ id: body.id, cwd, labels: body.labels }, { status: 201 });
141 }
142 
143 // DELETE — remove a session
144 if (request.method === "DELETE") {
145 const sessionId = url.searchParams.get("session");
146 if (!sessionId) {
147 return jsonError("Missing session id", "MISSING_SESSION_ID", 400);
148 }
149 await sandbox.deleteSession(sessionId);
150 return new Response(null, { status: 204 });
151 }
152 
153 return jsonError("Method not allowed", "METHOD_NOT_ALLOWED", 405);
154}
155 
156// ---------------------------------------------------------------------------
157// Sandbox destruction
158// ---------------------------------------------------------------------------
159 
160async function handleSandboxDestroy(request: Request, env: Env, url: URL): Promise<Response> {
161 const result = await authenticateAndResolveSandbox(request, env, url);
162 if ("error" in result) return result.error;
163 
164 const sandbox = getSandbox(env.Sandbox, result.sandboxId);
165 await sandbox.destroy();
166 return new Response(null, { status: 204 });
167}
168 
169// ---------------------------------------------------------------------------
170// Workspace backup / restore
171// ---------------------------------------------------------------------------
172 
173async function handleBackup(request: Request, env: Env, url: URL): Promise<Response> {
174 const result = await authenticateAndResolveSandbox(request, env, url);
175 if ("error" in result) return result.error;
176 
177 const sandbox = getSandbox(env.Sandbox, result.sandboxId);
178 
179 let body: { dir?: string; name?: string } = {};
180 try {
181 body = await request.json();
182 } catch {
183 // empty body is fine — use defaults
184 }
185 
186 const backup = await sandbox.createBackup({
187 dir: body.dir || "/workspace",
188 name: body.name || `backup-${Date.now()}`,
189 });
190 
191 return Response.json(backup, { status: 201 });
192}
193 
194async function handleRestore(request: Request, env: Env, url: URL): Promise<Response> {
195 const result = await authenticateAndResolveSandbox(request, env, url);
196 if ("error" in result) return result.error;
197 
198 const sandbox = getSandbox(env.Sandbox, result.sandboxId);
199 
200 let body: { id?: string; dir?: string };
201 try {
202 body = await request.json();
203 } catch {
204 return jsonError("Invalid JSON body", "INVALID_BODY", 400);
205 }
206 
207 if (!body.id) {
208 return jsonError("Missing backup id", "MISSING_BACKUP", 400);
209 }
210 
211 const res = await sandbox.restoreBackup({
212 id: body.id,
213 dir: body.dir || "/workspace",
214 });
215 return Response.json(res);
216}
217 
218// ---------------------------------------------------------------------------
219// Environment variable sanitization
220// ---------------------------------------------------------------------------
221 
222const ENV_DENY_PATTERNS = [
223 /SECRET/i,
224 /TOKEN/i,
225 /KEY/i,
226 /PASSWORD/i,
227 /CREDENTIAL/i,
228 /^AUTH/i,
229 /^AWS_/i,
230 /^AZURE_/i,
231 /^GCP_/i,
232 /^GOOGLE_/i,
233 /^CF_/i,
234 /^CLOUDFLARE/i,
235 /^SANDBOX_/i,
236];
237 
238export function sanitizeEnv(env: Record<string, string>): Record<string, string> {
239 const result: Record<string, string> = {};
240 for (const [key, value] of Object.entries(env)) {
241 if (typeof value !== "string") continue;
242 if (ENV_DENY_PATTERNS.some((p) => p.test(key))) continue;
243 result[key] = value;
244 }
245 return result;
246}
247 
248// ---------------------------------------------------------------------------
249// cwd validation
250// ---------------------------------------------------------------------------
251 
252function isAllowedCwd(cwd: string): boolean {
253 // Normalize and check it's under /workspace
254 const normalized = cwd.replace(/\/+/g, "/").replace(/\/$/, "");
255 return normalized === "/workspace" || normalized.startsWith("/workspace/");
256}
257 
258// ---------------------------------------------------------------------------
259// Helpers
260// ---------------------------------------------------------------------------
261 
262function jsonError(message: string, code: string, status: number): Response {
263 return Response.json({ error: message, code }, { status });
264}
265 
266function clampDimension(raw: string | null, fallback: number, min: number, max: number): number {
267 if (!raw) return fallback;
268 const n = parseInt(raw, 10);
269 if (Number.isNaN(n)) return fallback;
270 return Math.max(min, Math.min(n, max));
271}