@haowjy/remote-workspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js ADDED
@@ -0,0 +1,914 @@
1
+ import express from "express";
2
+ import multer from "multer";
3
+ import { createReadStream, existsSync } from "node:fs";
4
+ import { execFile } from "node:child_process";
5
+ import { timingSafeEqual } from "node:crypto";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { fileURLToPath } from "node:url";
10
+ import { promisify } from "node:util";
11
+ import { lookup as mimeLookup } from "mime-types";
12
+ function parseIntegerFromEnv(envName, fallbackValue, options) {
13
+ const rawValue = process.env[envName];
14
+ const parsedValue = Number.parseInt(rawValue ?? String(fallbackValue), 10);
15
+ if (!Number.isInteger(parsedValue)) {
16
+ throw new Error(`Invalid ${envName}: expected integer`);
17
+ }
18
+ if (parsedValue < options.min) {
19
+ throw new Error(`Invalid ${envName}: must be >= ${options.min}`);
20
+ }
21
+ if (options.max !== undefined && parsedValue > options.max) {
22
+ throw new Error(`Invalid ${envName}: must be <= ${options.max}`);
23
+ }
24
+ return parsedValue;
25
+ }
26
+ const REPO_ROOT = path.resolve(process.env.REPO_ROOT ?? process.cwd());
27
+ const HOST = "127.0.0.1";
28
+ const PORT = parseIntegerFromEnv("REMOTE_WS_PORT", 18080, { min: 1, max: 65535 });
29
+ const MAX_PREVIEW_BYTES = parseIntegerFromEnv("REMOTE_WS_MAX_PREVIEW_BYTES", 1_048_576, {
30
+ min: 1,
31
+ });
32
+ const MAX_UPLOAD_BYTES = parseIntegerFromEnv("REMOTE_WS_MAX_UPLOAD_BYTES", 26_214_400, {
33
+ min: 1,
34
+ });
35
+ const MAX_TREE_ENTRIES = parseIntegerFromEnv("REMOTE_WS_MAX_TREE_ENTRIES", 5000, {
36
+ min: 1,
37
+ });
38
+ const CLIPBOARD_DIRECTORY_NAME = ".clipboard";
39
+ const CLIPBOARD_DIRECTORY_PATH = path.resolve(REPO_ROOT, CLIPBOARD_DIRECTORY_NAME);
40
+ const SCREENSHOTS_DIRECTORY_NAME = ".playwright-mcp";
41
+ const SCREENSHOTS_DIRECTORY_PATH = path.resolve(REPO_ROOT, SCREENSHOTS_DIRECTORY_NAME);
42
+ const ALLOWED_IMAGE_EXTENSIONS = new Set([
43
+ ".png",
44
+ ".jpg",
45
+ ".jpeg",
46
+ ".gif",
47
+ ".webp",
48
+ ".svg",
49
+ ".bmp",
50
+ ".heic",
51
+ ".heif",
52
+ ".avif",
53
+ ]);
54
+ const IMAGE_CACHE_CONTROL = "private, max-age=60, stale-while-revalidate=300";
55
+ const METADATA_CACHE_CONTROL = "private, max-age=10, stale-while-revalidate=30";
56
+ const BASIC_AUTH_PASSWORD = process.env.REMOTE_WS_PASSWORD ?? "";
57
+ const AUTH_WINDOW_MS = 10 * 60 * 1000;
58
+ const AUTH_MAX_ATTEMPTS = 20;
59
+ const AUTH_BLOCK_MS = 15 * 60 * 1000;
60
+ const app = express();
61
+ const authFailureByIP = new Map();
62
+ function parseBasicAuthPassword(req) {
63
+ const authHeader = req.header("authorization");
64
+ if (!authHeader) {
65
+ return null;
66
+ }
67
+ if (!authHeader.startsWith("Basic ")) {
68
+ return null;
69
+ }
70
+ const encoded = authHeader.slice("Basic ".length).trim();
71
+ if (!encoded) {
72
+ return null;
73
+ }
74
+ let decoded;
75
+ try {
76
+ decoded = Buffer.from(encoded, "base64").toString("utf8");
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ const separatorIndex = decoded.indexOf(":");
82
+ if (separatorIndex < 0) {
83
+ return null;
84
+ }
85
+ return decoded.slice(separatorIndex + 1);
86
+ }
87
+ function constantTimePasswordMatch(actualPassword, expectedPassword) {
88
+ const actual = Buffer.from(actualPassword, "utf8");
89
+ const expected = Buffer.from(expectedPassword, "utf8");
90
+ if (actual.length !== expected.length) {
91
+ return false;
92
+ }
93
+ return timingSafeEqual(actual, expected);
94
+ }
95
+ function clientIPAddress(req) {
96
+ const forwardedFor = req.header("x-forwarded-for");
97
+ if (forwardedFor) {
98
+ const first = forwardedFor.split(",")[0]?.trim();
99
+ if (first) {
100
+ return first;
101
+ }
102
+ }
103
+ return req.ip || req.socket.remoteAddress || "unknown";
104
+ }
105
+ function normalizeAuthority(authorityUrl) {
106
+ const protocolPort = authorityUrl.protocol === "https:" ? "443" : "80";
107
+ const port = authorityUrl.port || protocolPort;
108
+ return `${authorityUrl.hostname.toLowerCase()}:${port}`;
109
+ }
110
+ function normalizeRequestAuthority(req) {
111
+ const hostHeader = req.header("x-forwarded-host") ?? req.header("host");
112
+ if (!hostHeader) {
113
+ return null;
114
+ }
115
+ const protocolHeader = req.header("x-forwarded-proto") ?? req.protocol ?? "http";
116
+ const protocol = protocolHeader.toLowerCase().startsWith("https") ? "https" : "http";
117
+ try {
118
+ return normalizeAuthority(new URL(`${protocol}://${hostHeader}`));
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
124
+ function isSameOriginMutation(req) {
125
+ const requestAuthority = normalizeRequestAuthority(req);
126
+ if (!requestAuthority) {
127
+ return false;
128
+ }
129
+ const originHeader = req.header("origin");
130
+ if (originHeader) {
131
+ try {
132
+ const originAuthority = normalizeAuthority(new URL(originHeader));
133
+ return originAuthority === requestAuthority;
134
+ }
135
+ catch {
136
+ return false;
137
+ }
138
+ }
139
+ const refererHeader = req.header("referer");
140
+ if (refererHeader) {
141
+ try {
142
+ const refererAuthority = normalizeAuthority(new URL(refererHeader));
143
+ return refererAuthority === requestAuthority;
144
+ }
145
+ catch {
146
+ return false;
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+ function recordAuthFailure(req) {
152
+ const now = Date.now();
153
+ const ip = clientIPAddress(req);
154
+ const existing = authFailureByIP.get(ip);
155
+ if (!existing || now - existing.windowStartAt >= AUTH_WINDOW_MS) {
156
+ const first = { windowStartAt: now, failures: 1, blockedUntil: 0 };
157
+ authFailureByIP.set(ip, first);
158
+ return {};
159
+ }
160
+ existing.failures += 1;
161
+ if (existing.failures >= AUTH_MAX_ATTEMPTS) {
162
+ existing.blockedUntil = now + AUTH_BLOCK_MS;
163
+ existing.failures = 0;
164
+ existing.windowStartAt = now;
165
+ return { blockedUntil: existing.blockedUntil };
166
+ }
167
+ return {};
168
+ }
169
+ if (BASIC_AUTH_PASSWORD.length > 0) {
170
+ app.use((req, res, next) => {
171
+ const now = Date.now();
172
+ const ip = clientIPAddress(req);
173
+ const existing = authFailureByIP.get(ip);
174
+ if (existing?.blockedUntil && existing.blockedUntil > now) {
175
+ const retryAfterSeconds = Math.ceil((existing.blockedUntil - now) / 1000);
176
+ res.setHeader("Retry-After", String(retryAfterSeconds));
177
+ res.status(429).send("Too many authentication failures");
178
+ return;
179
+ }
180
+ const suppliedPassword = parseBasicAuthPassword(req);
181
+ if (suppliedPassword !== null &&
182
+ constantTimePasswordMatch(suppliedPassword, BASIC_AUTH_PASSWORD)) {
183
+ authFailureByIP.delete(ip);
184
+ if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH" || req.method === "DELETE") {
185
+ if (!isSameOriginMutation(req)) {
186
+ res.status(403).send("Origin validation failed");
187
+ return;
188
+ }
189
+ }
190
+ next();
191
+ return;
192
+ }
193
+ const failure = recordAuthFailure(req);
194
+ if (failure.blockedUntil) {
195
+ const retryAfterSeconds = Math.ceil((failure.blockedUntil - Date.now()) / 1000);
196
+ res.setHeader("Retry-After", String(retryAfterSeconds));
197
+ res.status(429).send("Too many authentication failures");
198
+ return;
199
+ }
200
+ res.setHeader("WWW-Authenticate", 'Basic realm="remote-workspace", charset="UTF-8"');
201
+ res.status(401).send("Authentication required");
202
+ });
203
+ }
204
+ app.use(express.json({ limit: "256kb" }));
205
+ const execFileAsync = promisify(execFile);
206
+ function getSingleQueryValue(value) {
207
+ if (typeof value === "string") {
208
+ return value;
209
+ }
210
+ if (Array.isArray(value) && typeof value[0] === "string") {
211
+ return value[0];
212
+ }
213
+ return undefined;
214
+ }
215
+ function toRepoRelativePath(absPath) {
216
+ const relative = path.relative(REPO_ROOT, absPath);
217
+ if (!relative) {
218
+ return "";
219
+ }
220
+ return relative.split(path.sep).join("/");
221
+ }
222
+ function isHiddenRepoRelativePath(repoRelativePath) {
223
+ if (!repoRelativePath) {
224
+ return false;
225
+ }
226
+ return repoRelativePath.split("/").some((segment) => segment.startsWith("."));
227
+ }
228
+ function isBlockedHiddenRepoRelativePath(repoRelativePath) {
229
+ return isHiddenRepoRelativePath(repoRelativePath);
230
+ }
231
+ function parseGitIgnoredStdout(stdout) {
232
+ if (!stdout) {
233
+ return new Set();
234
+ }
235
+ return new Set(String(stdout)
236
+ .split(/\r?\n/)
237
+ .map((line) => line.trim())
238
+ .filter(Boolean));
239
+ }
240
+ async function getGitIgnoredPathSet(repoRelativePaths) {
241
+ const normalizedPaths = Array.from(new Set(repoRelativePaths.filter(Boolean)));
242
+ if (normalizedPaths.length === 0) {
243
+ return new Set();
244
+ }
245
+ try {
246
+ const { stdout } = await execFileAsync("git", ["-C", REPO_ROOT, "check-ignore", "--", ...normalizedPaths], { maxBuffer: 1024 * 1024 });
247
+ return parseGitIgnoredStdout(stdout);
248
+ }
249
+ catch (error) {
250
+ const gitError = error;
251
+ return parseGitIgnoredStdout(gitError.stdout);
252
+ }
253
+ }
254
+ async function assertPathAccessible(absPath, options) {
255
+ const repoRelativePath = toRepoRelativePath(absPath);
256
+ if (!options?.allowHidden && isBlockedHiddenRepoRelativePath(repoRelativePath)) {
257
+ throw new Error("Hidden paths are not accessible");
258
+ }
259
+ if (!options?.allowGitIgnored && repoRelativePath) {
260
+ const ignoredPathSet = await getGitIgnoredPathSet([repoRelativePath]);
261
+ if (ignoredPathSet.has(repoRelativePath)) {
262
+ throw new Error("Gitignored paths are not accessible");
263
+ }
264
+ }
265
+ }
266
+ function isWithinRepo(absPath) {
267
+ const relative = path.relative(REPO_ROOT, absPath);
268
+ return (relative === "" ||
269
+ (!relative.startsWith("..") && !path.isAbsolute(relative)));
270
+ }
271
+ function resolveRepoPath(relativePath) {
272
+ const input = (relativePath ?? "").trim();
273
+ if (input.includes("\u0000")) {
274
+ throw new Error("Path contains null byte");
275
+ }
276
+ const resolved = path.resolve(REPO_ROOT, input);
277
+ if (!isWithinRepo(resolved)) {
278
+ throw new Error("Path escapes repository root");
279
+ }
280
+ return resolved;
281
+ }
282
+ function sanitizeUploadFilename(originalName) {
283
+ const stripped = path.basename(originalName).replace(/[\u0000-\u001f]/g, "");
284
+ const cleaned = stripped.trim();
285
+ if (!cleaned || cleaned === "." || cleaned === "..") {
286
+ return `upload-${Date.now()}`;
287
+ }
288
+ return cleaned;
289
+ }
290
+ function validateUploadFilename(fileName) {
291
+ if (!fileName) {
292
+ return "Filename is required";
293
+ }
294
+ if (/\s/.test(fileName)) {
295
+ return "Filename cannot contain spaces";
296
+ }
297
+ if (fileName === "." || fileName === ".." || fileName.startsWith(".")) {
298
+ return "Filename is invalid";
299
+ }
300
+ if (!/^[A-Za-z0-9._-]+$/.test(fileName)) {
301
+ return "Filename may only contain letters, numbers, dot, underscore, and dash";
302
+ }
303
+ const extension = path.extname(fileName).toLowerCase();
304
+ if (!extension || !ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
305
+ return "Filename must use an allowed image extension";
306
+ }
307
+ return null;
308
+ }
309
+ function isLikelyBinary(buffer) {
310
+ for (const byte of buffer) {
311
+ if (byte === 0) {
312
+ return true;
313
+ }
314
+ }
315
+ return false;
316
+ }
317
+ function setMetadataCacheHeaders(res) {
318
+ res.setHeader("Cache-Control", METADATA_CACHE_CONTROL);
319
+ }
320
+ function buildWeakETag(stats) {
321
+ return `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"`;
322
+ }
323
+ function shouldRespondNotModified(req, etag, lastModifiedMillis) {
324
+ const ifNoneMatch = req.header("if-none-match");
325
+ if (ifNoneMatch) {
326
+ const candidates = ifNoneMatch.split(",").map((value) => value.trim());
327
+ if (candidates.includes("*") || candidates.includes(etag)) {
328
+ return true;
329
+ }
330
+ }
331
+ const ifModifiedSince = req.header("if-modified-since");
332
+ if (ifModifiedSince) {
333
+ const since = Date.parse(ifModifiedSince);
334
+ const lastModifiedSeconds = Math.floor(lastModifiedMillis / 1000) * 1000;
335
+ if (!Number.isNaN(since) && since >= lastModifiedSeconds) {
336
+ return true;
337
+ }
338
+ }
339
+ return false;
340
+ }
341
+ function setImageCacheHeaders(req, res, stats) {
342
+ const etag = buildWeakETag(stats);
343
+ res.setHeader("Cache-Control", IMAGE_CACHE_CONTROL);
344
+ res.setHeader("ETag", etag);
345
+ res.setHeader("Last-Modified", new Date(stats.mtimeMs).toUTCString());
346
+ if (shouldRespondNotModified(req, etag, stats.mtimeMs)) {
347
+ res.status(304).end();
348
+ return true;
349
+ }
350
+ return false;
351
+ }
352
+ function resolveNamedImagePath(baseDirectoryPath, requestedName) {
353
+ const safeName = sanitizeUploadFilename(requestedName);
354
+ const validationError = validateUploadFilename(safeName);
355
+ if (validationError) {
356
+ throw new Error(validationError);
357
+ }
358
+ const absoluteDirectoryPath = path.resolve(baseDirectoryPath);
359
+ const absolutePath = path.resolve(absoluteDirectoryPath, safeName);
360
+ const relativeToDirectory = path.relative(absoluteDirectoryPath, absolutePath);
361
+ if (relativeToDirectory.startsWith("..") || path.isAbsolute(relativeToDirectory)) {
362
+ throw new Error("Path escapes target directory");
363
+ }
364
+ return { path: absolutePath, name: safeName };
365
+ }
366
+ async function ensureDirectory(absPath, options) {
367
+ const stats = await fs.stat(absPath);
368
+ if (!stats.isDirectory()) {
369
+ throw new Error("Target path is not a directory");
370
+ }
371
+ const realPath = await fs.realpath(absPath);
372
+ if (!isWithinRepo(realPath)) {
373
+ throw new Error("Resolved path escapes repository root");
374
+ }
375
+ await assertPathAccessible(realPath, options);
376
+ }
377
+ async function ensureFile(absPath, options) {
378
+ const stats = await fs.stat(absPath);
379
+ if (!stats.isFile()) {
380
+ throw new Error("Target path is not a file");
381
+ }
382
+ const realPath = await fs.realpath(absPath);
383
+ if (!isWithinRepo(realPath)) {
384
+ throw new Error("Resolved path escapes repository root");
385
+ }
386
+ await assertPathAccessible(realPath, options);
387
+ }
388
+ const storage = multer.diskStorage({
389
+ destination: async (req, _file, callback) => {
390
+ try {
391
+ await ensureClipboardDirectoryReady();
392
+ req.uploadDirectoryPath =
393
+ CLIPBOARD_DIRECTORY_PATH;
394
+ callback(null, CLIPBOARD_DIRECTORY_PATH);
395
+ }
396
+ catch (error) {
397
+ callback(error, "");
398
+ }
399
+ },
400
+ filename: (req, file, callback) => {
401
+ const uploadDirectoryPath = req.uploadDirectoryPath;
402
+ if (!uploadDirectoryPath) {
403
+ callback(new Error("Upload directory unavailable"), "");
404
+ return;
405
+ }
406
+ const requestedName = getSingleQueryValue(req.query.name);
407
+ if (!requestedName) {
408
+ callback(new Error("Missing required query parameter: name"), "");
409
+ return;
410
+ }
411
+ const sanitizedRequestedName = sanitizeUploadFilename(requestedName ?? "");
412
+ const validationError = validateUploadFilename(sanitizedRequestedName);
413
+ if (validationError) {
414
+ callback(new Error(validationError), "");
415
+ return;
416
+ }
417
+ const targetPath = path.join(uploadDirectoryPath, sanitizedRequestedName);
418
+ if (existsSync(targetPath)) {
419
+ callback(new Error("Filename already exists"), "");
420
+ return;
421
+ }
422
+ callback(null, sanitizedRequestedName);
423
+ },
424
+ });
425
+ const upload = multer({
426
+ storage,
427
+ limits: {
428
+ files: 1,
429
+ fileSize: MAX_UPLOAD_BYTES,
430
+ },
431
+ fileFilter: (_req, file, callback) => {
432
+ const isImageMime = file.mimetype.startsWith("image/");
433
+ const originalExtension = path.extname(file.originalname).toLowerCase();
434
+ if (!isImageMime ||
435
+ (originalExtension !== "" && !ALLOWED_IMAGE_EXTENSIONS.has(originalExtension))) {
436
+ callback(new Error("Only image uploads are allowed"));
437
+ return;
438
+ }
439
+ callback(null, true);
440
+ },
441
+ });
442
+ const clipboardUploadMiddleware = upload.fields([
443
+ { name: "file", maxCount: 1 },
444
+ { name: "files", maxCount: 1 },
445
+ ]);
446
+ function handleClipboardUpload(req, res) {
447
+ const requestFiles = req.files;
448
+ const uploadedFile = requestFiles?.file?.[0] ?? requestFiles?.files?.[0];
449
+ if (!uploadedFile) {
450
+ res.status(400).json({ error: "Missing upload file" });
451
+ return;
452
+ }
453
+ const uploaded = {
454
+ name: uploadedFile.filename,
455
+ path: toRepoRelativePath(uploadedFile.path),
456
+ size: uploadedFile.size,
457
+ };
458
+ const uploadDirectoryPath = req.uploadDirectoryPath;
459
+ res.json({
460
+ directory: uploadDirectoryPath ? toRepoRelativePath(uploadDirectoryPath) : "",
461
+ uploaded: [uploaded],
462
+ });
463
+ }
464
+ async function ensureClipboardDirectoryReady() {
465
+ await fs.mkdir(CLIPBOARD_DIRECTORY_PATH, { recursive: true });
466
+ await ensureDirectory(CLIPBOARD_DIRECTORY_PATH, {
467
+ allowHidden: true,
468
+ allowGitIgnored: true,
469
+ });
470
+ }
471
+ function buildTreeFromPaths(filePaths) {
472
+ const root = { name: "", path: "", type: "directory", children: [] };
473
+ for (const filePath of filePaths) {
474
+ const segments = filePath.split("/");
475
+ let current = root;
476
+ for (let i = 0; i < segments.length; i++) {
477
+ const segment = segments[i];
478
+ const isFile = i === segments.length - 1;
479
+ const currentPath = segments.slice(0, i + 1).join("/");
480
+ if (!current.children) {
481
+ current.children = [];
482
+ }
483
+ let existing = current.children.find((c) => c.name === segment);
484
+ if (!existing) {
485
+ existing = {
486
+ name: segment,
487
+ path: currentPath,
488
+ type: isFile ? "file" : "directory",
489
+ ...(isFile ? {} : { children: [] }),
490
+ };
491
+ current.children.push(existing);
492
+ }
493
+ else if (!isFile && !existing.children) {
494
+ // Was added as a file but now needs to be a directory (shouldn't happen, but safe)
495
+ existing.type = "directory";
496
+ existing.children = [];
497
+ }
498
+ if (!isFile) {
499
+ current = existing;
500
+ }
501
+ }
502
+ }
503
+ return root;
504
+ }
505
+ function sortTree(node) {
506
+ if (!node.children)
507
+ return;
508
+ node.children.sort((a, b) => {
509
+ if (a.type !== b.type)
510
+ return a.type === "directory" ? -1 : 1;
511
+ return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
512
+ });
513
+ for (const child of node.children) {
514
+ sortTree(child);
515
+ }
516
+ }
517
+ app.get("/api/tree", async (_req, res) => {
518
+ try {
519
+ const { stdout } = await execFileAsync("git", ["-C", REPO_ROOT, "ls-files", "--cached", "--others", "--exclude-standard"], { maxBuffer: 10 * 1024 * 1024 });
520
+ const allPaths = String(stdout)
521
+ .split(/\r?\n/)
522
+ .map((line) => line.trim())
523
+ .filter(Boolean);
524
+ // Filter hidden paths
525
+ const visiblePaths = allPaths.filter((p) => !isHiddenRepoRelativePath(p));
526
+ const truncated = visiblePaths.length > MAX_TREE_ENTRIES;
527
+ const paths = truncated ? visiblePaths.slice(0, MAX_TREE_ENTRIES) : visiblePaths;
528
+ const root = buildTreeFromPaths(paths);
529
+ sortTree(root);
530
+ setMetadataCacheHeaders(res);
531
+ res.json({
532
+ root,
533
+ totalFiles: visiblePaths.length,
534
+ truncated,
535
+ });
536
+ }
537
+ catch (error) {
538
+ const message = error instanceof Error ? error.message : "Failed to build file tree";
539
+ res.status(400).json({ error: message });
540
+ }
541
+ });
542
+ app.get("/api/list", async (req, res) => {
543
+ try {
544
+ const requestedPath = getSingleQueryValue(req.query.path);
545
+ const directoryPath = resolveRepoPath(requestedPath);
546
+ await ensureDirectory(directoryPath);
547
+ const dirEntries = await fs.readdir(directoryPath, { withFileTypes: true });
548
+ const candidates = [];
549
+ const entries = [];
550
+ let skippedSymlinks = 0;
551
+ let skippedHidden = 0;
552
+ let skippedIgnored = 0;
553
+ for (const dirEntry of dirEntries) {
554
+ if (dirEntry.isSymbolicLink()) {
555
+ skippedSymlinks += 1;
556
+ continue;
557
+ }
558
+ const childPath = path.join(directoryPath, dirEntry.name);
559
+ const childRepoRelativePath = toRepoRelativePath(childPath);
560
+ if (isBlockedHiddenRepoRelativePath(childRepoRelativePath)) {
561
+ skippedHidden += 1;
562
+ continue;
563
+ }
564
+ if (!dirEntry.isDirectory() && !dirEntry.isFile()) {
565
+ continue;
566
+ }
567
+ candidates.push({
568
+ childPath,
569
+ childRepoRelativePath,
570
+ isDirectory: dirEntry.isDirectory(),
571
+ name: dirEntry.name,
572
+ });
573
+ }
574
+ const ignoredPathSet = await getGitIgnoredPathSet(candidates.map((candidate) => candidate.childRepoRelativePath));
575
+ for (const candidate of candidates) {
576
+ if (ignoredPathSet.has(candidate.childRepoRelativePath)) {
577
+ skippedIgnored += 1;
578
+ continue;
579
+ }
580
+ let childStats;
581
+ try {
582
+ childStats = await fs.stat(candidate.childPath);
583
+ }
584
+ catch {
585
+ // The file may disappear between readdir and stat (race with external writers).
586
+ continue;
587
+ }
588
+ entries.push({
589
+ name: candidate.name,
590
+ path: candidate.childRepoRelativePath,
591
+ type: candidate.isDirectory ? "directory" : "file",
592
+ size: childStats.size,
593
+ modifiedAt: childStats.mtime.toISOString(),
594
+ });
595
+ }
596
+ entries.sort((left, right) => {
597
+ if (left.type !== right.type) {
598
+ return left.type === "directory" ? -1 : 1;
599
+ }
600
+ return left.name.localeCompare(right.name, undefined, {
601
+ numeric: true,
602
+ sensitivity: "base",
603
+ });
604
+ });
605
+ const currentPath = toRepoRelativePath(directoryPath);
606
+ const parentPath = currentPath
607
+ ? toRepoRelativePath(path.dirname(directoryPath))
608
+ : null;
609
+ setMetadataCacheHeaders(res);
610
+ res.json({
611
+ currentPath,
612
+ parentPath,
613
+ entries,
614
+ skippedSymlinks,
615
+ skippedHidden,
616
+ skippedIgnored,
617
+ });
618
+ }
619
+ catch (error) {
620
+ const message = error instanceof Error ? error.message : "Failed to list";
621
+ res.status(400).json({ error: message });
622
+ }
623
+ });
624
+ app.get("/api/clipboard/list", async (_req, res) => {
625
+ try {
626
+ await ensureClipboardDirectoryReady();
627
+ const dirEntries = await fs.readdir(CLIPBOARD_DIRECTORY_PATH, {
628
+ withFileTypes: true,
629
+ });
630
+ const entries = [];
631
+ for (const dirEntry of dirEntries) {
632
+ if (!dirEntry.isFile()) {
633
+ continue;
634
+ }
635
+ const extension = path.extname(dirEntry.name).toLowerCase();
636
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(extension)) {
637
+ continue;
638
+ }
639
+ const absPath = path.join(CLIPBOARD_DIRECTORY_PATH, dirEntry.name);
640
+ let stats;
641
+ try {
642
+ stats = await fs.stat(absPath);
643
+ }
644
+ catch {
645
+ continue;
646
+ }
647
+ entries.push({
648
+ name: dirEntry.name,
649
+ path: toRepoRelativePath(absPath),
650
+ type: "file",
651
+ size: stats.size,
652
+ modifiedAt: stats.mtime.toISOString(),
653
+ });
654
+ }
655
+ entries.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
656
+ setMetadataCacheHeaders(res);
657
+ res.json({
658
+ directory: CLIPBOARD_DIRECTORY_NAME,
659
+ entries,
660
+ });
661
+ }
662
+ catch (error) {
663
+ const message = error instanceof Error ? error.message : "Unable to list clipboard";
664
+ res.status(400).json({ error: message });
665
+ }
666
+ });
667
+ app.get("/api/clipboard/file", async (req, res) => {
668
+ try {
669
+ const requestedName = getSingleQueryValue(req.query.name);
670
+ if (!requestedName) {
671
+ res.status(400).json({ error: "Missing ?name=..." });
672
+ return;
673
+ }
674
+ await ensureClipboardDirectoryReady();
675
+ const { path: absPath } = resolveNamedImagePath(CLIPBOARD_DIRECTORY_PATH, requestedName);
676
+ await ensureFile(absPath, { allowHidden: true, allowGitIgnored: true });
677
+ const stats = await fs.stat(absPath);
678
+ if (setImageCacheHeaders(req, res, stats)) {
679
+ return;
680
+ }
681
+ const mimeType = mimeLookup(absPath) || "application/octet-stream";
682
+ res.setHeader("Content-Type", mimeType);
683
+ res.setHeader("Content-Length", String(stats.size));
684
+ await pipeline(createReadStream(absPath), res);
685
+ }
686
+ catch (error) {
687
+ const message = error instanceof Error ? error.message : "Unable to stream clipboard file";
688
+ if (!res.headersSent) {
689
+ res.status(400).json({ error: message });
690
+ return;
691
+ }
692
+ res.destroy();
693
+ }
694
+ });
695
+ app.delete("/api/clipboard/file", async (req, res) => {
696
+ try {
697
+ const requestedName = getSingleQueryValue(req.query.name);
698
+ if (!requestedName) {
699
+ res.status(400).json({ error: "Missing ?name=..." });
700
+ return;
701
+ }
702
+ await ensureClipboardDirectoryReady();
703
+ const { path: absPath } = resolveNamedImagePath(CLIPBOARD_DIRECTORY_PATH, requestedName);
704
+ await fs.unlink(absPath);
705
+ res.setHeader("Cache-Control", "no-store");
706
+ res.status(204).end();
707
+ }
708
+ catch (error) {
709
+ const nodeError = error;
710
+ if (nodeError.code === "ENOENT") {
711
+ res.status(404).json({ error: "File not found" });
712
+ return;
713
+ }
714
+ const message = error instanceof Error ? error.message : "Unable to delete clipboard file";
715
+ res.status(400).json({ error: message });
716
+ }
717
+ });
718
+ app.get("/api/screenshots/list", async (_req, res) => {
719
+ try {
720
+ const dirEntries = await fs.readdir(SCREENSHOTS_DIRECTORY_PATH, {
721
+ withFileTypes: true,
722
+ }).catch(() => []);
723
+ const entries = [];
724
+ for (const dirEntry of dirEntries) {
725
+ if (!dirEntry.isFile())
726
+ continue;
727
+ const extension = path.extname(dirEntry.name).toLowerCase();
728
+ if (!ALLOWED_IMAGE_EXTENSIONS.has(extension))
729
+ continue;
730
+ const absPath = path.join(SCREENSHOTS_DIRECTORY_PATH, dirEntry.name);
731
+ let stats;
732
+ try {
733
+ stats = await fs.stat(absPath);
734
+ }
735
+ catch {
736
+ continue;
737
+ }
738
+ entries.push({
739
+ name: dirEntry.name,
740
+ path: `${SCREENSHOTS_DIRECTORY_NAME}/${dirEntry.name}`,
741
+ type: "file",
742
+ size: stats.size,
743
+ modifiedAt: stats.mtime.toISOString(),
744
+ });
745
+ }
746
+ entries.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
747
+ setMetadataCacheHeaders(res);
748
+ res.json({
749
+ directory: SCREENSHOTS_DIRECTORY_NAME,
750
+ entries,
751
+ });
752
+ }
753
+ catch (error) {
754
+ const message = error instanceof Error ? error.message : "Unable to list screenshots";
755
+ res.status(400).json({ error: message });
756
+ }
757
+ });
758
+ app.get("/api/screenshots/file", async (req, res) => {
759
+ try {
760
+ const requestedName = getSingleQueryValue(req.query.name);
761
+ if (!requestedName) {
762
+ res.status(400).json({ error: "Missing ?name=..." });
763
+ return;
764
+ }
765
+ const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
766
+ const stats = await fs.stat(absPath);
767
+ if (!stats.isFile()) {
768
+ res.status(400).json({ error: "Not a file" });
769
+ return;
770
+ }
771
+ const realPath = await fs.realpath(absPath);
772
+ const relativeToDirectory = path.relative(SCREENSHOTS_DIRECTORY_PATH, realPath);
773
+ if (relativeToDirectory.startsWith("..") ||
774
+ path.isAbsolute(relativeToDirectory)) {
775
+ res.status(400).json({ error: "Path escapes target directory" });
776
+ return;
777
+ }
778
+ if (setImageCacheHeaders(req, res, stats)) {
779
+ return;
780
+ }
781
+ const mimeType = mimeLookup(absPath) || "application/octet-stream";
782
+ res.setHeader("Content-Type", mimeType);
783
+ res.setHeader("Content-Length", String(stats.size));
784
+ await pipeline(createReadStream(absPath), res);
785
+ }
786
+ catch (error) {
787
+ const message = error instanceof Error ? error.message : "Unable to stream screenshot";
788
+ if (!res.headersSent) {
789
+ res.status(400).json({ error: message });
790
+ return;
791
+ }
792
+ res.destroy();
793
+ }
794
+ });
795
+ app.delete("/api/screenshots/file", async (req, res) => {
796
+ try {
797
+ const requestedName = getSingleQueryValue(req.query.name);
798
+ if (!requestedName) {
799
+ res.status(400).json({ error: "Missing ?name=..." });
800
+ return;
801
+ }
802
+ const { path: absPath } = resolveNamedImagePath(SCREENSHOTS_DIRECTORY_PATH, requestedName);
803
+ await fs.unlink(absPath);
804
+ res.setHeader("Cache-Control", "no-store");
805
+ res.status(204).end();
806
+ }
807
+ catch (error) {
808
+ const nodeError = error;
809
+ if (nodeError.code === "ENOENT") {
810
+ res.status(404).json({ error: "File not found" });
811
+ return;
812
+ }
813
+ const message = error instanceof Error ? error.message : "Unable to delete screenshot";
814
+ res.status(400).json({ error: message });
815
+ }
816
+ });
817
+ app.get("/api/text", async (req, res) => {
818
+ try {
819
+ const requestedPath = getSingleQueryValue(req.query.path);
820
+ if (!requestedPath) {
821
+ res.status(400).json({ error: "Missing ?path=..." });
822
+ return;
823
+ }
824
+ const absPath = resolveRepoPath(requestedPath);
825
+ await ensureFile(absPath);
826
+ const stats = await fs.stat(absPath);
827
+ const maxReadBytes = Math.min(stats.size, MAX_PREVIEW_BYTES);
828
+ const handle = await fs.open(absPath, "r");
829
+ const buffer = Buffer.alloc(maxReadBytes);
830
+ let readResult;
831
+ try {
832
+ readResult = await handle.read(buffer, 0, maxReadBytes, 0);
833
+ }
834
+ finally {
835
+ await handle.close();
836
+ }
837
+ const data = buffer.subarray(0, readResult.bytesRead);
838
+ if (isLikelyBinary(data)) {
839
+ res.json({
840
+ path: toRepoRelativePath(absPath),
841
+ binary: true,
842
+ truncated: stats.size > data.length,
843
+ size: stats.size,
844
+ });
845
+ return;
846
+ }
847
+ res.json({
848
+ path: toRepoRelativePath(absPath),
849
+ binary: false,
850
+ truncated: stats.size > data.length,
851
+ size: stats.size,
852
+ content: data.toString("utf8"),
853
+ });
854
+ }
855
+ catch (error) {
856
+ const message = error instanceof Error ? error.message : "Unable to read file";
857
+ res.status(400).json({ error: message });
858
+ }
859
+ });
860
+ app.get("/api/file", async (req, res) => {
861
+ try {
862
+ const requestedPath = getSingleQueryValue(req.query.path);
863
+ if (!requestedPath) {
864
+ res.status(400).json({ error: "Missing ?path=..." });
865
+ return;
866
+ }
867
+ const absPath = resolveRepoPath(requestedPath);
868
+ await ensureFile(absPath);
869
+ const stats = await fs.stat(absPath);
870
+ const mimeType = mimeLookup(absPath) || "application/octet-stream";
871
+ if (String(mimeType).startsWith("image/")) {
872
+ if (setImageCacheHeaders(req, res, stats)) {
873
+ return;
874
+ }
875
+ }
876
+ res.setHeader("Content-Type", mimeType);
877
+ res.setHeader("Content-Length", String(stats.size));
878
+ await pipeline(createReadStream(absPath), res);
879
+ }
880
+ catch (error) {
881
+ const message = error instanceof Error ? error.message : "Unable to stream file";
882
+ if (!res.headersSent) {
883
+ res.status(400).json({ error: message });
884
+ return;
885
+ }
886
+ res.destroy();
887
+ }
888
+ });
889
+ app.post("/api/clipboard/upload", clipboardUploadMiddleware, handleClipboardUpload);
890
+ // Backward compatibility for older cached clients still posting to /api/upload.
891
+ app.post("/api/upload", clipboardUploadMiddleware, handleClipboardUpload);
892
+ const currentFilePath = fileURLToPath(import.meta.url);
893
+ const currentDirectoryPath = path.dirname(currentFilePath);
894
+ const staticDirectoryPath = path.resolve(currentDirectoryPath, "..", "static");
895
+ app.use(express.static(staticDirectoryPath));
896
+ app.get("/", (_req, res) => {
897
+ res.sendFile(path.join(staticDirectoryPath, "index.html"));
898
+ });
899
+ app.use((error, _req, res, _next) => {
900
+ if (error instanceof multer.MulterError) {
901
+ res.status(400).json({ error: error.message });
902
+ return;
903
+ }
904
+ const message = error instanceof Error ? error.message : "Unexpected server error";
905
+ res.status(500).json({ error: message });
906
+ });
907
+ app.listen(PORT, HOST, () => {
908
+ console.log(`[remote-workspace] root: ${REPO_ROOT}`);
909
+ console.log(`[remote-workspace] http://${HOST}:${PORT}`);
910
+ if (BASIC_AUTH_PASSWORD.length > 0) {
911
+ console.log("[remote-workspace] basic auth: enabled");
912
+ }
913
+ console.log(`[remote-workspace] Tailscale serve example: tailscale serve --bg --https=443 127.0.0.1:${PORT}`);
914
+ });