File
Blob: src/worker/index.ts
| 1 | import { getSandbox, proxyTerminal } from "@cloudflare/sandbox"; |
| 2 | import { authenticateRequest, deriveSandboxId, validateHostAccessPolicy, type AuthResult } from "./auth"; |
| 3 | |
| 4 | export { Sandbox } from "@cloudflare/sandbox"; |
| 5 | |
| 6 | // --------------------------------------------------------------------------- |
| 7 | // Worker entry |
| 8 | // --------------------------------------------------------------------------- |
| 9 | |
| 10 | export 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 | |
| 61 | async 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 | |
| 80 | async 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 | |
| 102 | async 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 | |
| 160 | async 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 | |
| 173 | async 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 | |
| 194 | async 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 | |
| 222 | const 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 | |
| 238 | export 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 | |
| 252 | function 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 | |
| 262 | function jsonError(message: string, code: string, status: number): Response { |
| 263 | return Response.json({ error: message, code }, { status }); |
| 264 | } |
| 265 | |
| 266 | function 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 | } |