@ystemsrx/cfshare 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/src/manager.ts ADDED
@@ -0,0 +1,2712 @@
1
+ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import { createReadStream, createWriteStream } from "node:fs";
5
+ import http, { type IncomingMessage, type ServerResponse } from "node:http";
6
+ import https from "node:https";
7
+ import net from "node:net";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { pipeline } from "node:stream/promises";
11
+ import ignore from "ignore";
12
+ import { lookup as mimeLookup } from "mime-types";
13
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
14
+ import yazl from "yazl";
15
+ import { renderFileExplorerTemplate } from "./templates/fileExplorerTemplate.js";
16
+ import { renderMarkdownPreviewTemplate } from "./templates/markdownPreviewTemplate.js";
17
+ import { loadPolicy } from "./policy.js";
18
+ import type {
19
+ AccessMode,
20
+ AccessState,
21
+ AuditEvent,
22
+ CfsharePluginConfig,
23
+ CfsharePolicy,
24
+ ExposureRecord,
25
+ ExposureSession,
26
+ ExposureStatus,
27
+ ExposureType,
28
+ FilePresentationMode,
29
+ ManifestEntry,
30
+ RateLimitPolicy,
31
+ } from "./types.js";
32
+
33
+ const MAX_LOG_LINES = 4000;
34
+ const MAX_RESPONSE_MANIFEST_ITEMS = 200;
35
+ const MAX_RESPONSE_MANIFEST_ITEMS_MULTI_GET = 20;
36
+ const MAX_EXPOSURE_GET_ITEMS = 200;
37
+ const MAX_EXPOSURE_LOG_ITEMS = 100;
38
+ const MAX_EXPOSURE_LOG_LINES_RESPONSE = 1000;
39
+ const DEFAULT_PROBE_TIMEOUT_MS = 3000;
40
+ const CLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/gi;
41
+ const MARKDOWN_PREVIEW_EXTENSIONS = new Set([".md", ".rmd", ".qmd"]);
42
+ const INLINE_TEXT_PREVIEW_EXTENSIONS = new Set([".html", ".htm", ".svg"]);
43
+ const BINARY_MIME_PREFIXES = ["image/", "audio/", "video/", "font/"];
44
+ const BINARY_MIME_EXACT = new Set([
45
+ "application/pdf",
46
+ "application/zip",
47
+ "application/x-zip-compressed",
48
+ "application/gzip",
49
+ "application/x-gzip",
50
+ "application/x-7z-compressed",
51
+ "application/x-rar-compressed",
52
+ "application/octet-stream",
53
+ ]);
54
+ const INVALID_QUICK_TUNNEL_SUBDOMAINS = new Set(["api"]);
55
+ const WINDOWS_METADATA_SUFFIX_RE = /:zone\.identifier$/i;
56
+
57
+ type RateLimitState = {
58
+ windowStart: number;
59
+ count: number;
60
+ };
61
+
62
+ type ReverseProxyHandle = {
63
+ server: http.Server;
64
+ port: number;
65
+ };
66
+
67
+ type FileServerHandle = {
68
+ server: http.Server;
69
+ port: number;
70
+ manifest: ManifestEntry[];
71
+ };
72
+
73
+ type CloudflaredStartResult = {
74
+ process: ChildProcessWithoutNullStreams;
75
+ publicUrl: string;
76
+ };
77
+
78
+ type EnvCheckResult = {
79
+ cloudflared: {
80
+ ok: boolean;
81
+ path?: string;
82
+ version?: string;
83
+ };
84
+ defaults: Record<string, unknown>;
85
+ warnings: string[];
86
+ };
87
+
88
+ type ToolContext = {
89
+ workspaceDir?: string;
90
+ };
91
+
92
+ type ExposureFilter = {
93
+ status?: ExposureStatus;
94
+ type?: ExposureType;
95
+ };
96
+
97
+ type ExposureGetField =
98
+ | "id"
99
+ | "type"
100
+ | "status"
101
+ | "port"
102
+ | "public_url"
103
+ | "expires_at"
104
+ | "local_url"
105
+ | "stats"
106
+ | "file_sharing"
107
+ | "last_error"
108
+ | "manifest"
109
+ | "created_at";
110
+
111
+ type ExposureGetParams = {
112
+ id?: string;
113
+ ids?: string[];
114
+ filter?: ExposureFilter;
115
+ fields?: ExposureGetField[];
116
+ opts?: {
117
+ probe_public?: boolean;
118
+ };
119
+ };
120
+
121
+ type ExposureLogsOpts = {
122
+ lines?: number;
123
+ since_seconds?: number;
124
+ component?: "tunnel" | "origin" | "all";
125
+ };
126
+
127
+ type ExposeInputSummary = {
128
+ input_path: string;
129
+ name: string;
130
+ type: "file" | "directory";
131
+ size?: number;
132
+ };
133
+
134
+ type ManifestResponseMeta = {
135
+ total_count: number;
136
+ returned_count: number;
137
+ truncated: boolean;
138
+ total_size_bytes: number;
139
+ };
140
+
141
+ function nowIso(): string {
142
+ return toLocalIso(new Date());
143
+ }
144
+
145
+ function toLocalIso(date: Date): string {
146
+ const year = date.getFullYear();
147
+ const month = String(date.getMonth() + 1).padStart(2, "0");
148
+ const day = String(date.getDate()).padStart(2, "0");
149
+ const hours = String(date.getHours()).padStart(2, "0");
150
+ const minutes = String(date.getMinutes()).padStart(2, "0");
151
+ const seconds = String(date.getSeconds()).padStart(2, "0");
152
+ const millis = String(date.getMilliseconds()).padStart(3, "0");
153
+
154
+ const offsetMinutes = -date.getTimezoneOffset();
155
+ const sign = offsetMinutes >= 0 ? "+" : "-";
156
+ const absOffsetMinutes = Math.abs(offsetMinutes);
157
+ const offsetHours = String(Math.trunc(absOffsetMinutes / 60)).padStart(2, "0");
158
+ const offsetMins = String(absOffsetMinutes % 60).padStart(2, "0");
159
+
160
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${millis}${sign}${offsetHours}:${offsetMins}`;
161
+ }
162
+
163
+ function timestampMs(input: string | undefined): number | undefined {
164
+ if (!input) {
165
+ return undefined;
166
+ }
167
+ const ms = Date.parse(input);
168
+ return Number.isFinite(ms) ? ms : undefined;
169
+ }
170
+
171
+ function randomId(prefix: string): string {
172
+ return `${prefix}_${Date.now().toString(36)}_${crypto.randomBytes(3).toString("hex")}`;
173
+ }
174
+
175
+ function maskSecret(value?: string): string | undefined {
176
+ if (!value) {
177
+ return undefined;
178
+ }
179
+ if (value.length <= 6) {
180
+ return "*".repeat(value.length);
181
+ }
182
+ return `${value.slice(0, 3)}***${value.slice(-2)}`;
183
+ }
184
+
185
+ function isSubPath(target: string, base: string): boolean {
186
+ const rel = path.relative(base, target);
187
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
188
+ }
189
+
190
+ function normalizePathForIgnore(input: string): string | undefined {
191
+ const normalized = input.split(path.sep).join("/");
192
+ if (!normalized || normalized === "." || normalized === "..") {
193
+ return undefined;
194
+ }
195
+ if (
196
+ normalized.startsWith("/") ||
197
+ normalized.startsWith("../") ||
198
+ normalized.includes("/../") ||
199
+ normalized.endsWith("/..")
200
+ ) {
201
+ return undefined;
202
+ }
203
+ return normalized.replace(/^\.\/+/, "");
204
+ }
205
+
206
+ function getIgnoreMatchCandidates(realPath: string): string[] {
207
+ const candidates = new Set<string>();
208
+ const relToCwd = normalizePathForIgnore(path.relative(process.cwd(), realPath));
209
+ const relToRoot = normalizePathForIgnore(path.relative(path.parse(realPath).root, realPath));
210
+ const baseName = normalizePathForIgnore(path.basename(realPath));
211
+ if (relToCwd) {
212
+ candidates.add(relToCwd);
213
+ }
214
+ if (relToRoot) {
215
+ candidates.add(relToRoot);
216
+ }
217
+ if (baseName) {
218
+ candidates.add(baseName);
219
+ }
220
+ return Array.from(candidates);
221
+ }
222
+
223
+ function normalizeAllowlistPaths(values: string[] | undefined): string[] {
224
+ if (!values) {
225
+ return [];
226
+ }
227
+ const out = values
228
+ .filter((entry): entry is string => typeof entry === "string")
229
+ .map((entry) => entry.trim())
230
+ .filter(Boolean)
231
+ .map((entry) => (entry.startsWith("/") ? entry : `/${entry}`));
232
+ return Array.from(new Set(out));
233
+ }
234
+
235
+ function normalizeAccessMode(value: unknown, fallback: AccessMode): AccessMode {
236
+ if (value === "token" || value === "basic" || value === "none") {
237
+ return value;
238
+ }
239
+ return fallback;
240
+ }
241
+
242
+ function normalizeTtl(value: unknown, policy: CfsharePolicy): number {
243
+ const n = typeof value === "number" ? Math.trunc(value) : policy.defaultTtlSeconds;
244
+ return Math.max(60, Math.min(policy.maxTtlSeconds, n));
245
+ }
246
+
247
+ function normalizeFilePresentation(value: unknown): FilePresentationMode {
248
+ if (value === "preview" || value === "raw" || value === "download") {
249
+ return value;
250
+ }
251
+ return "download";
252
+ }
253
+
254
+ function parseRequestedPresentation(input: unknown): FilePresentationMode | undefined {
255
+ if (input === "preview" || input === "raw" || input === "download") {
256
+ return input;
257
+ }
258
+ return undefined;
259
+ }
260
+
261
+ function resolveRequestPresentation(url: URL, fallback: FilePresentationMode): FilePresentationMode {
262
+ const requested =
263
+ parseRequestedPresentation(ensureString(url.searchParams.get("delivery"))) ??
264
+ parseRequestedPresentation(ensureString(url.searchParams.get("presentation"))) ??
265
+ parseRequestedPresentation(ensureString(url.searchParams.get("view")));
266
+ return requested ?? fallback;
267
+ }
268
+
269
+ function normalizeWorkspaceRelativePath(input: string | undefined): string | undefined {
270
+ if (!input) {
271
+ return undefined;
272
+ }
273
+ const normalized = input.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, "");
274
+ if (!normalized || normalized === ".") {
275
+ return undefined;
276
+ }
277
+ const safe = path.posix.normalize(normalized);
278
+ if (!safe || safe === "." || safe === ".." || safe.startsWith("../") || safe.includes("/../")) {
279
+ return undefined;
280
+ }
281
+ return safe;
282
+ }
283
+
284
+ function sanitizeFilename(input: string): string {
285
+ return input.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/_+/g, "_");
286
+ }
287
+
288
+ function ensureString(input: unknown): string | undefined {
289
+ if (typeof input !== "string") {
290
+ return undefined;
291
+ }
292
+ const trimmed = input.trim();
293
+ return trimmed || undefined;
294
+ }
295
+
296
+ function buildContentDisposition(params: {
297
+ mode: FilePresentationMode;
298
+ filePath: string;
299
+ downloadName?: string;
300
+ }): string | undefined {
301
+ if (params.mode === "raw") {
302
+ return undefined;
303
+ }
304
+ const verb = params.mode === "preview" ? "inline" : "attachment";
305
+ const filename = params.downloadName ?? path.basename(params.filePath);
306
+ return `${verb}; filename*=UTF-8''${encodeURIComponent(filename)}`;
307
+ }
308
+
309
+ function isTextLikeMime(mime: string): boolean {
310
+ const base = mime.split(";")[0]?.trim().toLowerCase() ?? "";
311
+ if (!base) {
312
+ return false;
313
+ }
314
+ if (base.startsWith("text/")) {
315
+ return true;
316
+ }
317
+ if (base.endsWith("+json") || base.endsWith("+xml")) {
318
+ return true;
319
+ }
320
+ return (
321
+ base === "application/json" ||
322
+ base === "application/xml" ||
323
+ base === "application/javascript" ||
324
+ base === "application/x-javascript" ||
325
+ base === "application/typescript" ||
326
+ base === "application/x-typescript" ||
327
+ base === "application/yaml" ||
328
+ base === "application/x-yaml" ||
329
+ base === "application/toml" ||
330
+ base === "application/graphql" ||
331
+ base === "application/sql"
332
+ );
333
+ }
334
+
335
+ function isLikelyBinaryMime(mime: string): boolean {
336
+ const base = mime.split(";")[0]?.trim().toLowerCase() ?? "";
337
+ if (!base) {
338
+ return false;
339
+ }
340
+ if (isTextLikeMime(base)) {
341
+ return false;
342
+ }
343
+ if (BINARY_MIME_EXACT.has(base)) {
344
+ return true;
345
+ }
346
+ return BINARY_MIME_PREFIXES.some((prefix) => base.startsWith(prefix));
347
+ }
348
+
349
+ async function sniffFileSample(filePath: string, maxBytes = 8192): Promise<Buffer> {
350
+ const handle = await fs.open(filePath, "r");
351
+ try {
352
+ const buf = Buffer.alloc(maxBytes);
353
+ const { bytesRead } = await handle.read(buf, 0, maxBytes, 0);
354
+ return buf.subarray(0, bytesRead);
355
+ } finally {
356
+ await handle.close();
357
+ }
358
+ }
359
+
360
+ function isLikelyTextBuffer(sample: Buffer): boolean {
361
+ if (sample.length === 0) {
362
+ return true;
363
+ }
364
+ for (const byte of sample) {
365
+ if (byte === 0) {
366
+ return false;
367
+ }
368
+ }
369
+
370
+ let suspicious = 0;
371
+ for (const byte of sample) {
372
+ if (byte === 9 || byte === 10 || byte === 13) {
373
+ continue;
374
+ }
375
+ if (byte < 32 || byte === 127) {
376
+ suspicious += 1;
377
+ }
378
+ }
379
+
380
+ try {
381
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
382
+ return suspicious / sample.length < 0.2;
383
+ } catch {
384
+ return suspicious / sample.length < 0.05;
385
+ }
386
+ }
387
+
388
+ async function detectFileDeliveryCapabilities(params: {
389
+ filePath: string;
390
+ mime: string;
391
+ }): Promise<{ isBinary: boolean; previewSupported: boolean }> {
392
+ const ext = path.extname(params.filePath).toLowerCase();
393
+ const mimeBase = params.mime.split(";")[0]?.trim().toLowerCase() ?? "";
394
+ const likelyBinaryByMime = isLikelyBinaryMime(params.mime);
395
+ const sample = await sniffFileSample(params.filePath);
396
+ const textByContent = isLikelyTextBuffer(sample);
397
+
398
+ const isBinary = sample.length > 0 ? !textByContent : likelyBinaryByMime;
399
+ const imagePreviewByMime = mimeBase.startsWith("image/");
400
+ const imagePreviewByExt = [
401
+ ".png",
402
+ ".jpg",
403
+ ".jpeg",
404
+ ".gif",
405
+ ".webp",
406
+ ".bmp",
407
+ ".tif",
408
+ ".tiff",
409
+ ".ico",
410
+ ".heic",
411
+ ".svg",
412
+ ].includes(ext);
413
+ const mediaPreviewByMime = mimeBase.startsWith("audio/") || mimeBase.startsWith("video/");
414
+ const mediaPreviewByExt = [
415
+ ".mp3",
416
+ ".wav",
417
+ ".flac",
418
+ ".ogg",
419
+ ".m4a",
420
+ ".aac",
421
+ ".mp4",
422
+ ".mov",
423
+ ".webm",
424
+ ".m4v",
425
+ ].includes(ext);
426
+ const previewSupported =
427
+ (isBinary &&
428
+ (ext === ".pdf" ||
429
+ mimeBase === "application/pdf" ||
430
+ imagePreviewByMime ||
431
+ imagePreviewByExt ||
432
+ mediaPreviewByMime ||
433
+ mediaPreviewByExt)) ||
434
+ (!isBinary && (MARKDOWN_PREVIEW_EXTENSIONS.has(ext) || INLINE_TEXT_PREVIEW_EXTENSIONS.has(ext)));
435
+
436
+ return { isBinary, previewSupported };
437
+ }
438
+
439
+ function shouldRenderMarkdownPreview(filePath: string, presentation: FilePresentationMode): boolean {
440
+ if (presentation !== "preview") {
441
+ return false;
442
+ }
443
+ return MARKDOWN_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase());
444
+ }
445
+
446
+ function stripLeadingFrontMatter(input: string): string {
447
+ const source = input.replace(/^\uFEFF/, "");
448
+ const match = source.match(/^---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)\r?\n?/);
449
+ if (!match) {
450
+ return source;
451
+ }
452
+ return source.slice(match[0].length);
453
+ }
454
+
455
+ function buildMarkdownPreviewHtml(params: { title: string; markdown: string }): string {
456
+ const payload = Buffer.from(stripLeadingFrontMatter(params.markdown), "utf8").toString("base64");
457
+ return renderMarkdownPreviewTemplate({
458
+ title: params.title,
459
+ payload,
460
+ });
461
+ }
462
+
463
+ function resolveBinPath(bin: string): string | undefined {
464
+ if (path.isAbsolute(bin)) {
465
+ return bin;
466
+ }
467
+
468
+ const pathEnv = process.env.PATH ?? "";
469
+ const dirs = pathEnv.split(path.delimiter).filter(Boolean);
470
+ const candidates =
471
+ process.platform === "win32" ? [bin, `${bin}.exe`, `${bin}.cmd`, `${bin}.bat`] : [bin];
472
+
473
+ for (const dir of dirs) {
474
+ for (const candidate of candidates) {
475
+ const full = path.join(dir, candidate);
476
+ try {
477
+ const stat = spawnSync(full, ["--version"], {
478
+ timeout: 1500,
479
+ stdio: "ignore",
480
+ });
481
+ if (!stat.error) {
482
+ return full;
483
+ }
484
+ } catch {
485
+ // ignore candidate
486
+ }
487
+ }
488
+ }
489
+ return undefined;
490
+ }
491
+
492
+ function extractCloudflaredVersion(output: string): string | undefined {
493
+ const match = output.match(/version\s+([0-9]+\.[0-9]+\.[0-9]+)/i);
494
+ return match?.[1];
495
+ }
496
+
497
+ async function fileExists(input: string): Promise<boolean> {
498
+ try {
499
+ await fs.access(input);
500
+ return true;
501
+ } catch {
502
+ return false;
503
+ }
504
+ }
505
+
506
+ async function mkdirp(input: string): Promise<void> {
507
+ await fs.mkdir(input, { recursive: true });
508
+ }
509
+
510
+ async function findFreePort(): Promise<number> {
511
+ return await new Promise<number>((resolve, reject) => {
512
+ const server = net.createServer();
513
+ server.once("error", reject);
514
+ server.listen(0, "127.0.0.1", () => {
515
+ const address = server.address();
516
+ if (!address || typeof address === "string") {
517
+ server.close();
518
+ reject(new Error("failed to resolve free port"));
519
+ return;
520
+ }
521
+ const port = address.port;
522
+ server.close((error) => {
523
+ if (error) {
524
+ reject(error);
525
+ return;
526
+ }
527
+ resolve(port);
528
+ });
529
+ });
530
+ });
531
+ }
532
+
533
+ async function probeLocalPort(port: number): Promise<boolean> {
534
+ return await new Promise<boolean>((resolve) => {
535
+ const socket = net.connect({ port, host: "127.0.0.1" });
536
+ const done = (ok: boolean) => {
537
+ socket.removeAllListeners();
538
+ socket.destroy();
539
+ resolve(ok);
540
+ };
541
+ socket.once("connect", () => done(true));
542
+ socket.once("error", () => done(false));
543
+ socket.setTimeout(1200, () => done(false));
544
+ });
545
+ }
546
+
547
+ function formatRelativeUrl(input: string): string {
548
+ return input
549
+ .split(path.sep)
550
+ .map((segment) => encodeURIComponent(segment))
551
+ .join("/");
552
+ }
553
+
554
+ async function walkFiles(dir: string, baseDir = dir): Promise<string[]> {
555
+ const entries = await fs.readdir(dir, { withFileTypes: true });
556
+ const files: string[] = [];
557
+ for (const entry of entries) {
558
+ if (WINDOWS_METADATA_SUFFIX_RE.test(entry.name)) {
559
+ continue;
560
+ }
561
+ const abs = path.join(dir, entry.name);
562
+ if (entry.isDirectory()) {
563
+ files.push(...(await walkFiles(abs, baseDir)));
564
+ continue;
565
+ }
566
+ if (entry.isFile()) {
567
+ files.push(path.relative(baseDir, abs));
568
+ }
569
+ }
570
+ return files;
571
+ }
572
+
573
+ async function sha256File(input: string): Promise<string> {
574
+ const hash = crypto.createHash("sha256");
575
+ const stream = createReadStream(input);
576
+ for await (const chunk of stream) {
577
+ hash.update(chunk as Buffer);
578
+ }
579
+ return hash.digest("hex");
580
+ }
581
+
582
+ function parseBasicAuth(input: string | undefined): { username: string; password: string } | null {
583
+ if (!input || !input.startsWith("Basic ")) {
584
+ return null;
585
+ }
586
+ const encoded = input.slice("Basic ".length).trim();
587
+ try {
588
+ const decoded = Buffer.from(encoded, "base64").toString("utf8");
589
+ const index = decoded.indexOf(":");
590
+ if (index < 0) {
591
+ return null;
592
+ }
593
+ return { username: decoded.slice(0, index), password: decoded.slice(index + 1) };
594
+ } catch {
595
+ return null;
596
+ }
597
+ }
598
+
599
+ function parseBearerToken(authHeader: string | undefined): string | undefined {
600
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
601
+ return undefined;
602
+ }
603
+ const token = authHeader.slice("Bearer ".length).trim();
604
+ return token || undefined;
605
+ }
606
+
607
+ function shouldAllowPath(pathname: string, allowlist: string[]): boolean {
608
+ if (allowlist.length === 0) {
609
+ return true;
610
+ }
611
+ return allowlist.some((prefix) => {
612
+ if (prefix === "/") {
613
+ return true;
614
+ }
615
+ if (pathname === prefix) {
616
+ return true;
617
+ }
618
+ return pathname.startsWith(`${prefix.endsWith("/") ? prefix : `${prefix}/`}`);
619
+ });
620
+ }
621
+
622
+ function isValidQuickTunnelUrl(input: string): boolean {
623
+ try {
624
+ const parsed = new URL(input);
625
+ if (parsed.protocol !== "https:") {
626
+ return false;
627
+ }
628
+ const host = parsed.hostname.toLowerCase();
629
+ if (!host.endsWith(".trycloudflare.com")) {
630
+ return false;
631
+ }
632
+ const subdomain = host.slice(0, -".trycloudflare.com".length);
633
+ if (!subdomain || subdomain.includes(".")) {
634
+ return false;
635
+ }
636
+ if (INVALID_QUICK_TUNNEL_SUBDOMAINS.has(subdomain)) {
637
+ return false;
638
+ }
639
+ return /^[a-z0-9-]+$/i.test(subdomain);
640
+ } catch {
641
+ return false;
642
+ }
643
+ }
644
+
645
+ function pickQuickTunnelUrlFromLine(line: string): string | undefined {
646
+ const matches = line.match(CLOUDFLARE_URL_RE);
647
+ if (!matches) {
648
+ return undefined;
649
+ }
650
+ for (const candidate of matches) {
651
+ if (isValidQuickTunnelUrl(candidate)) {
652
+ return candidate;
653
+ }
654
+ }
655
+ return undefined;
656
+ }
657
+
658
+ function matchAuditFilters(
659
+ event: AuditEvent,
660
+ filters: {
661
+ id?: string;
662
+ event?: string;
663
+ type?: string;
664
+ from_ts?: string;
665
+ to_ts?: string;
666
+ },
667
+ ): boolean {
668
+ const fromMs = timestampMs(filters.from_ts);
669
+ const toMs = timestampMs(filters.to_ts);
670
+ const eventMs = timestampMs(event.ts);
671
+
672
+ if (filters.id && event.id !== filters.id) {
673
+ return false;
674
+ }
675
+ if (filters.event && event.event !== filters.event) {
676
+ return false;
677
+ }
678
+ if (filters.type && event.type !== filters.type) {
679
+ return false;
680
+ }
681
+ if (filters.from_ts) {
682
+ if (fromMs !== undefined && eventMs !== undefined) {
683
+ if (eventMs < fromMs) {
684
+ return false;
685
+ }
686
+ } else if (event.ts < filters.from_ts) {
687
+ return false;
688
+ }
689
+ }
690
+ if (filters.to_ts) {
691
+ if (toMs !== undefined && eventMs !== undefined) {
692
+ if (eventMs > toMs) {
693
+ return false;
694
+ }
695
+ } else if (event.ts > filters.to_ts) {
696
+ return false;
697
+ }
698
+ }
699
+ return true;
700
+ }
701
+
702
+ export class CfshareManager {
703
+ private readonly logger: OpenClawPluginApi["logger"];
704
+ private readonly resolvePath: (input: string) => string;
705
+ private readonly pluginConfig: CfsharePluginConfig;
706
+ private readonly cloudflaredPathInput: string;
707
+ private readonly stateDir: string;
708
+ private readonly policyFile: string;
709
+ private readonly ignoreFile: string;
710
+ private readonly workspaceRoot: string;
711
+ private readonly auditFile: string;
712
+ private readonly sessionsFile: string;
713
+ private readonly exportsDir: string;
714
+
715
+ private initialized = false;
716
+ private initializing?: Promise<void>;
717
+ private policy!: CfsharePolicy;
718
+ private policyWarnings: string[] = [];
719
+ private ignoreMatcher = ignore();
720
+ private cloudflaredResolvedPath?: string;
721
+ private guardTimer?: NodeJS.Timeout;
722
+ private readonly sessions = new Map<string, ExposureSession>();
723
+
724
+ constructor(api: OpenClawPluginApi) {
725
+ this.logger = api.logger;
726
+ this.resolvePath = api.resolvePath;
727
+ this.pluginConfig = (api.pluginConfig ?? {}) as CfsharePluginConfig;
728
+
729
+ this.stateDir = this.resolvePath(this.pluginConfig.stateDir ?? "~/.openclaw/cfshare");
730
+ this.policyFile = this.resolvePath(
731
+ this.pluginConfig.policyFile ?? path.join(this.stateDir, "policy.json"),
732
+ );
733
+ this.ignoreFile = this.resolvePath(
734
+ this.pluginConfig.ignoreFile ?? path.join(this.stateDir, "policy.ignore"),
735
+ );
736
+ this.workspaceRoot = path.join(this.stateDir, "workspaces");
737
+ this.auditFile = path.join(this.stateDir, "audit.jsonl");
738
+ this.sessionsFile = path.join(this.stateDir, "sessions.json");
739
+ this.exportsDir = path.join(this.stateDir, "exports");
740
+ this.cloudflaredPathInput = this.pluginConfig.cloudflaredPath ?? "cloudflared";
741
+ }
742
+
743
+ private async ensureInitialized(): Promise<void> {
744
+ if (this.initialized) {
745
+ return;
746
+ }
747
+ if (!this.initializing) {
748
+ this.initializing = this.initialize();
749
+ }
750
+ await this.initializing;
751
+ }
752
+
753
+ private async initialize(): Promise<void> {
754
+ await mkdirp(this.stateDir);
755
+ await mkdirp(this.workspaceRoot);
756
+ await mkdirp(this.exportsDir);
757
+ await this.reloadPolicy();
758
+ this.startGuard();
759
+ this.initialized = true;
760
+ }
761
+
762
+ private async reloadPolicy(): Promise<void> {
763
+ const loaded = await loadPolicy({
764
+ policyFile: this.policyFile,
765
+ ignoreFile: this.ignoreFile,
766
+ pluginConfig: this.pluginConfig,
767
+ });
768
+ this.policy = loaded.effective;
769
+ this.policyWarnings = loaded.warnings;
770
+ this.ignoreMatcher = loaded.matcher;
771
+ }
772
+
773
+ private appendLog(session: ExposureSession, component: "tunnel" | "origin" | "manager", line: string) {
774
+ const entry = { ts: nowIso(), component, line };
775
+ session.logs.push(entry);
776
+ if (session.logs.length > MAX_LOG_LINES) {
777
+ session.logs.splice(0, session.logs.length - MAX_LOG_LINES);
778
+ }
779
+ }
780
+
781
+ private async writeAudit(event: AuditEvent): Promise<void> {
782
+ try {
783
+ await fs.appendFile(this.auditFile, `${JSON.stringify(event)}\n`, "utf8");
784
+ } catch (error) {
785
+ this.logger.warn(`cfshare: failed to write audit event: ${String(error)}`);
786
+ }
787
+ }
788
+
789
+ private async persistSessionsSnapshot(): Promise<void> {
790
+ const records = Array.from(this.sessions.values()).map((session) => ({
791
+ id: session.id,
792
+ type: session.type,
793
+ status: session.status,
794
+ expiresAt: session.expiresAt,
795
+ workspaceDir: session.workspaceDir,
796
+ processPid: session.process?.pid,
797
+ }));
798
+ await fs.writeFile(this.sessionsFile, JSON.stringify(records, null, 2), "utf8");
799
+ }
800
+
801
+ private makeAccessState(params: {
802
+ mode: AccessMode;
803
+ protectOrigin: boolean;
804
+ allowlistPaths?: string[];
805
+ }): AccessState {
806
+ const allowlistPaths = normalizeAllowlistPaths(params.allowlistPaths);
807
+ if (params.mode === "token") {
808
+ return {
809
+ mode: "token",
810
+ protectOrigin: params.protectOrigin,
811
+ allowlistPaths,
812
+ token: crypto.randomBytes(16).toString("hex"),
813
+ };
814
+ }
815
+ if (params.mode === "basic") {
816
+ return {
817
+ mode: "basic",
818
+ protectOrigin: params.protectOrigin,
819
+ allowlistPaths,
820
+ username: "cfshare",
821
+ password: crypto.randomBytes(12).toString("base64url"),
822
+ };
823
+ }
824
+ return {
825
+ mode: "none",
826
+ protectOrigin: params.protectOrigin,
827
+ allowlistPaths,
828
+ };
829
+ }
830
+
831
+ private makeResponsePublicUrl(session: ExposureSession): string | undefined {
832
+ const base = session.publicUrl;
833
+ if (!base) {
834
+ return undefined;
835
+ }
836
+ if (session.access.mode !== "token" || !session.access.token) {
837
+ return base;
838
+ }
839
+ try {
840
+ const out = new URL(base);
841
+ out.searchParams.set("token", session.access.token);
842
+ return out.toString();
843
+ } catch {
844
+ const sep = base.includes("?") ? "&" : "?";
845
+ return `${base}${sep}token=${encodeURIComponent(session.access.token)}`;
846
+ }
847
+ }
848
+
849
+ private buildRateLimiter(policy: RateLimitPolicy): (ip: string) => boolean {
850
+ if (!policy.enabled) {
851
+ return () => true;
852
+ }
853
+ const state = new Map<string, RateLimitState>();
854
+ return (ip: string) => {
855
+ const now = Date.now();
856
+ const row = state.get(ip);
857
+ if (!row || now - row.windowStart >= policy.windowMs) {
858
+ state.set(ip, { windowStart: now, count: 1 });
859
+ return true;
860
+ }
861
+ if (row.count >= policy.maxRequests) {
862
+ return false;
863
+ }
864
+ row.count += 1;
865
+ return true;
866
+ };
867
+ }
868
+
869
+ private isAuthorized(req: IncomingMessage, access: AccessState): boolean {
870
+ if (!access.protectOrigin || access.mode === "none") {
871
+ return true;
872
+ }
873
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
874
+ if (access.mode === "token") {
875
+ const queryToken = ensureString(url.searchParams.get("token"));
876
+ const headerToken = ensureString(req.headers["x-cfshare-token"] as string | undefined);
877
+ const bearer = parseBearerToken(req.headers.authorization);
878
+ return queryToken === access.token || headerToken === access.token || bearer === access.token;
879
+ }
880
+ const basic = parseBasicAuth(req.headers.authorization);
881
+ return basic?.username === access.username && basic.password === access.password;
882
+ }
883
+
884
+ private async startReverseProxy(params: {
885
+ upstreamPort: number;
886
+ session: ExposureSession;
887
+ access: AccessState;
888
+ }): Promise<ReverseProxyHandle> {
889
+ const proxyPort = await findFreePort();
890
+ const allowRequest = this.buildRateLimiter(this.policy.rateLimit);
891
+ const upstreamBase = `http://127.0.0.1:${params.upstreamPort}`;
892
+
893
+ const server = http.createServer((req, res) => {
894
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
895
+ const clientIp = req.socket.remoteAddress ?? "unknown";
896
+
897
+ params.session.stats.requests += 1;
898
+ params.session.stats.lastAccessAt = nowIso();
899
+
900
+ if (!allowRequest(clientIp)) {
901
+ res.writeHead(429, { "content-type": "application/json" });
902
+ res.end(JSON.stringify({ error: "rate_limited" }));
903
+ this.appendLog(params.session, "origin", `rate limit blocked ip=${clientIp}`);
904
+ return;
905
+ }
906
+
907
+ if (!shouldAllowPath(url.pathname, params.access.allowlistPaths)) {
908
+ res.writeHead(403, { "content-type": "application/json" });
909
+ res.end(JSON.stringify({ error: "path_not_allowed", path: url.pathname }));
910
+ return;
911
+ }
912
+
913
+ if (!this.isAuthorized(req, params.access)) {
914
+ const headers: Record<string, string> = {
915
+ "content-type": "application/json",
916
+ };
917
+ if (params.access.mode === "basic") {
918
+ headers["www-authenticate"] = 'Basic realm="cfshare"';
919
+ }
920
+ res.writeHead(401, headers);
921
+ res.end(JSON.stringify({ error: "unauthorized" }));
922
+ return;
923
+ }
924
+
925
+ const upstreamUrl = new URL(url.pathname + url.search, upstreamBase);
926
+ const transport = upstreamUrl.protocol === "https:" ? https : http;
927
+ const proxyReq = transport.request(
928
+ {
929
+ protocol: upstreamUrl.protocol,
930
+ hostname: upstreamUrl.hostname,
931
+ port: upstreamUrl.port,
932
+ method: req.method,
933
+ path: upstreamUrl.pathname + upstreamUrl.search,
934
+ headers: {
935
+ ...req.headers,
936
+ host: upstreamUrl.host,
937
+ },
938
+ },
939
+ (proxyRes) => {
940
+ res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
941
+ proxyRes.on("data", (chunk: Buffer) => {
942
+ params.session.stats.bytesSent += chunk.length;
943
+ });
944
+ proxyRes.pipe(res);
945
+ },
946
+ );
947
+
948
+ proxyReq.on("error", (error) => {
949
+ this.appendLog(params.session, "origin", `proxy error: ${String(error)}`);
950
+ if (!res.headersSent) {
951
+ res.writeHead(502, { "content-type": "application/json" });
952
+ res.end(JSON.stringify({ error: "proxy_error" }));
953
+ return;
954
+ }
955
+ res.end();
956
+ });
957
+
958
+ req.pipe(proxyReq);
959
+ });
960
+
961
+ await new Promise<void>((resolve, reject) => {
962
+ server.once("error", reject);
963
+ server.listen(proxyPort, "127.0.0.1", () => resolve());
964
+ });
965
+
966
+ this.appendLog(params.session, "origin", `reverse proxy listening on 127.0.0.1:${proxyPort}`);
967
+ return { server, port: proxyPort };
968
+ }
969
+
970
+ private async sendFileResponse(params: {
971
+ req: IncomingMessage;
972
+ res: ServerResponse;
973
+ session: ExposureSession;
974
+ filePath: string;
975
+ downloadName?: string;
976
+ presentation?: FilePresentationMode;
977
+ countAsDownload?: boolean;
978
+ }): Promise<void> {
979
+ const stat = await fs.stat(params.filePath);
980
+ const detectedMime = String(mimeLookup(params.filePath) || "application/octet-stream");
981
+ const capabilities = await detectFileDeliveryCapabilities({
982
+ filePath: params.filePath,
983
+ mime: detectedMime,
984
+ });
985
+ let presentation = params.presentation ?? "download";
986
+ if (presentation === "preview" && !capabilities.previewSupported) {
987
+ presentation = "raw";
988
+ }
989
+ const mime =
990
+ presentation === "raw" && !capabilities.isBinary
991
+ ? "text/plain; charset=utf-8"
992
+ : detectedMime;
993
+ const method = (params.req.method ?? "GET").toUpperCase();
994
+
995
+ if (method !== "GET" && method !== "HEAD") {
996
+ params.res.writeHead(405, { "content-type": "application/json" });
997
+ params.res.end(JSON.stringify({ error: "method_not_allowed" }));
998
+ return;
999
+ }
1000
+
1001
+ if (shouldRenderMarkdownPreview(params.filePath, presentation)) {
1002
+ const fileRaw = await fs.readFile(params.filePath, "utf8");
1003
+ const previewHtml = buildMarkdownPreviewHtml({
1004
+ title: params.downloadName ?? path.basename(params.filePath),
1005
+ markdown: fileRaw,
1006
+ });
1007
+ const body = Buffer.from(previewHtml, "utf8");
1008
+ const headers: Record<string, string> = {
1009
+ "content-type": "text/html; charset=utf-8",
1010
+ "cache-control": "no-store",
1011
+ "x-content-type-options": "nosniff",
1012
+ "content-length": String(body.length),
1013
+ };
1014
+ params.res.writeHead(200, headers);
1015
+ if (method === "HEAD") {
1016
+ params.res.end();
1017
+ return;
1018
+ }
1019
+ if (params.countAsDownload) {
1020
+ params.session.stats.downloads += 1;
1021
+ }
1022
+ params.session.stats.bytesSent += body.length;
1023
+ params.res.end(body);
1024
+ return;
1025
+ }
1026
+
1027
+ const headers: Record<string, string> = {
1028
+ "content-type": String(mime),
1029
+ "accept-ranges": "bytes",
1030
+ "cache-control": "no-store",
1031
+ "x-content-type-options": "nosniff",
1032
+ };
1033
+ const contentDisposition = buildContentDisposition({
1034
+ mode: presentation,
1035
+ filePath: params.filePath,
1036
+ downloadName: params.downloadName,
1037
+ });
1038
+ if (contentDisposition) {
1039
+ headers["content-disposition"] = contentDisposition;
1040
+ }
1041
+
1042
+ const range = ensureString(params.req.headers.range);
1043
+ if (!range) {
1044
+ headers["content-length"] = String(stat.size);
1045
+ params.res.writeHead(200, headers);
1046
+ if (method === "HEAD") {
1047
+ params.res.end();
1048
+ return;
1049
+ }
1050
+ if (params.countAsDownload) {
1051
+ params.session.stats.downloads += 1;
1052
+ }
1053
+ params.session.stats.bytesSent += stat.size;
1054
+ await pipeline(createReadStream(params.filePath), params.res);
1055
+ return;
1056
+ }
1057
+
1058
+ const match = range.match(/^bytes=(\d*)-(\d*)$/i);
1059
+ if (!match) {
1060
+ params.res.writeHead(416, { "content-type": "application/json" });
1061
+ params.res.end(JSON.stringify({ error: "invalid_range" }));
1062
+ return;
1063
+ }
1064
+
1065
+ const start = match[1] ? Number.parseInt(match[1], 10) : 0;
1066
+ const end = match[2] ? Number.parseInt(match[2], 10) : stat.size - 1;
1067
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || end >= stat.size) {
1068
+ params.res.writeHead(416, { "content-type": "application/json" });
1069
+ params.res.end(JSON.stringify({ error: "invalid_range" }));
1070
+ return;
1071
+ }
1072
+
1073
+ headers["content-length"] = String(end - start + 1);
1074
+ headers["content-range"] = `bytes ${start}-${end}/${stat.size}`;
1075
+ params.res.writeHead(206, headers);
1076
+ if (method === "HEAD") {
1077
+ params.res.end();
1078
+ return;
1079
+ }
1080
+ if (params.countAsDownload) {
1081
+ params.session.stats.downloads += 1;
1082
+ }
1083
+ params.session.stats.bytesSent += end - start + 1;
1084
+ await pipeline(createReadStream(params.filePath, { start, end }), params.res);
1085
+ }
1086
+
1087
+ private async createZipArchive(workspaceDir: string): Promise<{ zipPath: string; size: number }> {
1088
+ const zipPath = path.join(workspaceDir, "_cfshare_bundle.zip");
1089
+ const files = await walkFiles(workspaceDir);
1090
+
1091
+ await new Promise<void>((resolve, reject) => {
1092
+ const zip = new yazl.ZipFile();
1093
+ const out = createWriteStream(zipPath);
1094
+
1095
+ out.once("error", reject);
1096
+ out.once("close", () => resolve());
1097
+ zip.outputStream.pipe(out);
1098
+
1099
+ for (const relPath of files) {
1100
+ if (relPath === path.basename(zipPath)) {
1101
+ continue;
1102
+ }
1103
+ zip.addFile(path.join(workspaceDir, relPath), relPath);
1104
+ }
1105
+ zip.end();
1106
+ });
1107
+
1108
+ const stat = await fs.stat(zipPath);
1109
+ return { zipPath, size: stat.size };
1110
+ }
1111
+
1112
+ private async createFolderZipArchive(params: {
1113
+ workspaceDir: string;
1114
+ folderPath: string;
1115
+ }): Promise<{ zipPath: string; size: number; downloadName: string }> {
1116
+ const safeFolderPath = normalizeWorkspaceRelativePath(params.folderPath);
1117
+ if (!safeFolderPath) {
1118
+ throw new Error("invalid_folder_path");
1119
+ }
1120
+ const folderAbs = path.join(params.workspaceDir, safeFolderPath);
1121
+ if (!isSubPath(folderAbs, params.workspaceDir) || !(await fileExists(folderAbs))) {
1122
+ throw new Error("folder_not_found");
1123
+ }
1124
+ const stat = await fs.stat(folderAbs);
1125
+ if (!stat.isDirectory()) {
1126
+ throw new Error("not_a_directory");
1127
+ }
1128
+
1129
+ const zipName = `${sanitizeFilename(path.basename(folderAbs) || "folder")}.zip`;
1130
+ const tempZipName = `.cfshare_folder_${randomId("zip")}.zip`;
1131
+ const zipPath = path.join(params.workspaceDir, tempZipName);
1132
+ const files = await walkFiles(folderAbs);
1133
+
1134
+ await new Promise<void>((resolve, reject) => {
1135
+ const zip = new yazl.ZipFile();
1136
+ const out = createWriteStream(zipPath);
1137
+ out.once("error", reject);
1138
+ out.once("close", () => resolve());
1139
+ zip.outputStream.pipe(out);
1140
+
1141
+ for (const relPath of files) {
1142
+ const zipEntry = path.posix.join(path.basename(folderAbs), relPath.split(path.sep).join("/"));
1143
+ zip.addFile(path.join(folderAbs, relPath), zipEntry);
1144
+ }
1145
+ zip.end();
1146
+ });
1147
+
1148
+ const zipStat = await fs.stat(zipPath);
1149
+ return {
1150
+ zipPath,
1151
+ size: zipStat.size,
1152
+ downloadName: zipName,
1153
+ };
1154
+ }
1155
+
1156
+ private async startFileServer(params: {
1157
+ session: ExposureSession;
1158
+ workspaceDir: string;
1159
+ mode: "normal" | "zip";
1160
+ presentation: FilePresentationMode;
1161
+ maxDownloads?: number;
1162
+ directSingleFileRoot: boolean;
1163
+ autoEnterSingleDirectory: boolean;
1164
+ rootDirectories: string[];
1165
+ }): Promise<FileServerHandle> {
1166
+ const port = await findFreePort();
1167
+
1168
+ const manifest: ManifestEntry[] = [];
1169
+ let zipBundle: { zipPath: string; size: number } | undefined;
1170
+ if (params.mode === "normal") {
1171
+ const files = await walkFiles(params.workspaceDir);
1172
+ for (const relPath of files) {
1173
+ if (relPath === "_cfshare_bundle.zip") {
1174
+ continue;
1175
+ }
1176
+ const abs = path.join(params.workspaceDir, relPath);
1177
+ const stat = await fs.stat(abs);
1178
+ const detectedMime = String(mimeLookup(abs) || "application/octet-stream");
1179
+ const capabilities = await detectFileDeliveryCapabilities({
1180
+ filePath: abs,
1181
+ mime: detectedMime,
1182
+ });
1183
+ manifest.push({
1184
+ name: relPath,
1185
+ size: stat.size,
1186
+ sha256: await sha256File(abs),
1187
+ relative_url: `/${formatRelativeUrl(relPath)}`,
1188
+ modified_at: toLocalIso(stat.mtime),
1189
+ is_binary: capabilities.isBinary,
1190
+ preview_supported: capabilities.previewSupported,
1191
+ });
1192
+ }
1193
+ } else {
1194
+ zipBundle = await this.createZipArchive(params.workspaceDir);
1195
+ manifest.push({
1196
+ name: "download.zip",
1197
+ size: zipBundle.size,
1198
+ sha256: await sha256File(zipBundle.zipPath),
1199
+ relative_url: "/__cfshare__/download.zip",
1200
+ modified_at: nowIso(),
1201
+ is_binary: true,
1202
+ preview_supported: false,
1203
+ });
1204
+ }
1205
+
1206
+ const explorerManifest = manifest;
1207
+
1208
+ const allowRequest = this.buildRateLimiter(this.policy.rateLimit);
1209
+
1210
+ const server = http.createServer(async (req, res) => {
1211
+ const clientIp = req.socket.remoteAddress ?? "unknown";
1212
+ params.session.stats.requests += 1;
1213
+ params.session.stats.lastAccessAt = nowIso();
1214
+
1215
+ if (!allowRequest(clientIp)) {
1216
+ res.writeHead(429, { "content-type": "application/json" });
1217
+ res.end(JSON.stringify({ error: "rate_limited" }));
1218
+ return;
1219
+ }
1220
+
1221
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
1222
+ const pathname = decodeURIComponent(url.pathname);
1223
+ const requestedPresentation = resolveRequestPresentation(url, params.presentation);
1224
+
1225
+ const checkMaxDownloads = async () => {
1226
+ if (typeof params.maxDownloads !== "number") {
1227
+ return;
1228
+ }
1229
+ if (params.session.stats.downloads >= params.maxDownloads) {
1230
+ await this.stopExposure(params.session.id, { reason: "max_downloads_reached", expired: false });
1231
+ }
1232
+ };
1233
+
1234
+ try {
1235
+ if (pathname === "/") {
1236
+ if (params.mode === "normal" && params.directSingleFileRoot && explorerManifest.length === 1) {
1237
+ const filePath = path.join(params.workspaceDir, explorerManifest[0].name);
1238
+ await this.sendFileResponse({
1239
+ req,
1240
+ res,
1241
+ session: params.session,
1242
+ filePath,
1243
+ presentation: requestedPresentation,
1244
+ countAsDownload: true,
1245
+ });
1246
+ await checkMaxDownloads();
1247
+ return;
1248
+ }
1249
+
1250
+ const body = Buffer.from(
1251
+ renderFileExplorerTemplate({
1252
+ title: "cfshare",
1253
+ mode: params.mode,
1254
+ presentation: params.presentation,
1255
+ manifest: explorerManifest,
1256
+ autoEnterSingleDirectory: params.autoEnterSingleDirectory,
1257
+ rootDirectories: params.rootDirectories,
1258
+ }),
1259
+ "utf8",
1260
+ );
1261
+ res.writeHead(200, {
1262
+ "content-type": "text/html; charset=utf-8",
1263
+ "cache-control": "no-store",
1264
+ "x-content-type-options": "nosniff",
1265
+ "content-length": String(body.length),
1266
+ });
1267
+ if ((req.method ?? "GET").toUpperCase() === "HEAD") {
1268
+ res.end();
1269
+ return;
1270
+ }
1271
+ params.session.stats.bytesSent += body.length;
1272
+ res.end(body);
1273
+ return;
1274
+ }
1275
+
1276
+ if (pathname === "/__cfshare__/download-folder.zip" && params.mode === "normal") {
1277
+ const folderPath = normalizeWorkspaceRelativePath(ensureString(url.searchParams.get("path")));
1278
+ if (!folderPath) {
1279
+ res.writeHead(400, { "content-type": "application/json" });
1280
+ res.end(JSON.stringify({ error: "invalid_folder_path" }));
1281
+ return;
1282
+ }
1283
+ let bundle: { zipPath: string; size: number; downloadName: string };
1284
+ try {
1285
+ bundle = await this.createFolderZipArchive({
1286
+ workspaceDir: params.workspaceDir,
1287
+ folderPath,
1288
+ });
1289
+ } catch (error) {
1290
+ const message = error instanceof Error ? error.message : String(error);
1291
+ const status = message === "folder_not_found" ? 404 : 400;
1292
+ res.writeHead(status, { "content-type": "application/json" });
1293
+ res.end(JSON.stringify({ error: message }));
1294
+ return;
1295
+ }
1296
+ try {
1297
+ await this.sendFileResponse({
1298
+ req,
1299
+ res,
1300
+ session: params.session,
1301
+ filePath: bundle.zipPath,
1302
+ downloadName: bundle.downloadName,
1303
+ presentation: "download",
1304
+ countAsDownload: true,
1305
+ });
1306
+ } finally {
1307
+ await fs.unlink(bundle.zipPath).catch(() => undefined);
1308
+ }
1309
+ await checkMaxDownloads();
1310
+ return;
1311
+ }
1312
+
1313
+ if (pathname === "/__cfshare__/download.zip" && params.mode === "zip" && zipBundle) {
1314
+ await this.sendFileResponse({
1315
+ req,
1316
+ res,
1317
+ session: params.session,
1318
+ filePath: zipBundle.zipPath,
1319
+ presentation: "download",
1320
+ countAsDownload: true,
1321
+ });
1322
+ await checkMaxDownloads();
1323
+ return;
1324
+ }
1325
+
1326
+ if (params.mode === "zip") {
1327
+ res.writeHead(404, { "content-type": "application/json" });
1328
+ res.end(JSON.stringify({ error: "not_found" }));
1329
+ return;
1330
+ }
1331
+
1332
+ const normalized = pathname.replace(/^\/+/, "");
1333
+ const target = path.join(params.workspaceDir, normalized);
1334
+ if (!isSubPath(target, params.workspaceDir) || !(await fileExists(target))) {
1335
+ res.writeHead(404, { "content-type": "application/json" });
1336
+ res.end(JSON.stringify({ error: "not_found" }));
1337
+ return;
1338
+ }
1339
+
1340
+ const stat = await fs.stat(target);
1341
+ if (!stat.isFile()) {
1342
+ res.writeHead(404, { "content-type": "application/json" });
1343
+ res.end(JSON.stringify({ error: "not_file" }));
1344
+ return;
1345
+ }
1346
+
1347
+ await this.sendFileResponse({
1348
+ req,
1349
+ res,
1350
+ session: params.session,
1351
+ filePath: target,
1352
+ presentation: requestedPresentation,
1353
+ countAsDownload: true,
1354
+ });
1355
+ await checkMaxDownloads();
1356
+ } catch (error) {
1357
+ this.appendLog(params.session, "origin", `file server error: ${String(error)}`);
1358
+ if (!res.headersSent) {
1359
+ res.writeHead(500, { "content-type": "application/json" });
1360
+ res.end(JSON.stringify({ error: "internal_error" }));
1361
+ return;
1362
+ }
1363
+ res.end();
1364
+ }
1365
+ });
1366
+
1367
+ await new Promise<void>((resolve, reject) => {
1368
+ server.once("error", reject);
1369
+ server.listen(port, "127.0.0.1", () => resolve());
1370
+ });
1371
+
1372
+ this.appendLog(params.session, "origin", `file server listening on 127.0.0.1:${port}`);
1373
+ return { server, port, manifest };
1374
+ }
1375
+
1376
+ private async detectWorkspaceRootBehavior(workspaceDir: string): Promise<{
1377
+ directSingleFileRoot: boolean;
1378
+ autoEnterSingleDirectory: boolean;
1379
+ rootDirectories: string[];
1380
+ }> {
1381
+ const entries = await fs.readdir(workspaceDir, { withFileTypes: true });
1382
+ const rootDirectories = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
1383
+ if (entries.length !== 1) {
1384
+ return {
1385
+ directSingleFileRoot: false,
1386
+ autoEnterSingleDirectory: false,
1387
+ rootDirectories,
1388
+ };
1389
+ }
1390
+ const rootEntry = entries[0];
1391
+ return {
1392
+ directSingleFileRoot: rootEntry.isFile(),
1393
+ autoEnterSingleDirectory: rootEntry.isDirectory(),
1394
+ rootDirectories,
1395
+ };
1396
+ }
1397
+
1398
+ private async copyInputsToWorkspace(
1399
+ pathsInput: string[],
1400
+ workspaceDir: string,
1401
+ ctx?: ToolContext,
1402
+ ): Promise<void> {
1403
+ const allowedRoots = this.policy.allowedPathRoots.map((root) => this.resolvePath(root));
1404
+ const workspaceRoot = ctx?.workspaceDir ? this.resolvePath(ctx.workspaceDir) : undefined;
1405
+
1406
+ for (const item of pathsInput) {
1407
+ const resolved = path.resolve(item);
1408
+ const real = await fs.realpath(resolved).catch(() => resolved);
1409
+ const ignoreCandidates = getIgnoreMatchCandidates(real);
1410
+ if (ignoreCandidates.some((candidate) => this.ignoreMatcher.ignores(candidate))) {
1411
+ throw new Error(`path blocked by ignore policy: ${item}`);
1412
+ }
1413
+
1414
+ if (allowedRoots.length > 0 && !allowedRoots.some((root) => isSubPath(real, root))) {
1415
+ throw new Error(`path outside allowed roots: ${item}`);
1416
+ }
1417
+
1418
+ if (workspaceRoot && !isSubPath(real, workspaceRoot)) {
1419
+ this.logger.warn(`cfshare: exposing path outside workspace (${item})`);
1420
+ }
1421
+
1422
+ const sourceStat = await fs.stat(real);
1423
+ const baseName = sanitizeFilename(path.basename(real) || "item");
1424
+ let target = path.join(workspaceDir, baseName);
1425
+ let seq = 1;
1426
+ while (await fileExists(target)) {
1427
+ target = path.join(workspaceDir, `${baseName}_${seq}`);
1428
+ seq += 1;
1429
+ }
1430
+
1431
+ if (sourceStat.isDirectory()) {
1432
+ await fs.cp(real, target, { recursive: true, dereference: true });
1433
+ } else if (sourceStat.isFile()) {
1434
+ await mkdirp(path.dirname(target));
1435
+ await fs.copyFile(real, target);
1436
+ } else {
1437
+ throw new Error(`unsupported path type: ${item}`);
1438
+ }
1439
+ }
1440
+ }
1441
+
1442
+ private makeExposureRecord(session: ExposureSession): ExposureRecord {
1443
+ return {
1444
+ id: session.id,
1445
+ type: session.type,
1446
+ status: session.status,
1447
+ public_url: this.makeResponsePublicUrl(session),
1448
+ local_url: session.localUrl,
1449
+ expires_at: session.expiresAt,
1450
+ };
1451
+ }
1452
+
1453
+ private async summarizeExposeInputs(pathsInput: string[]): Promise<ExposeInputSummary[]> {
1454
+ const out: ExposeInputSummary[] = [];
1455
+ for (const rawPath of pathsInput) {
1456
+ const resolved = path.resolve(rawPath);
1457
+ const real = await fs.realpath(resolved).catch(() => resolved);
1458
+ const stat = await fs.stat(real);
1459
+ if (stat.isDirectory()) {
1460
+ out.push({
1461
+ input_path: rawPath,
1462
+ name: path.basename(real) || rawPath,
1463
+ type: "directory",
1464
+ });
1465
+ continue;
1466
+ }
1467
+ if (stat.isFile()) {
1468
+ out.push({
1469
+ input_path: rawPath,
1470
+ name: path.basename(real) || rawPath,
1471
+ type: "file",
1472
+ size: stat.size,
1473
+ });
1474
+ }
1475
+ }
1476
+ return out;
1477
+ }
1478
+
1479
+ private buildExposeFilesResponseManifest(params: {
1480
+ inputs: ExposeInputSummary[];
1481
+ fullManifest: ManifestEntry[];
1482
+ detailedLimit?: number;
1483
+ }): {
1484
+ manifest: Array<ManifestEntry | ExposeInputSummary>;
1485
+ manifest_mode: "detailed" | "summary";
1486
+ manifest_meta: ManifestResponseMeta;
1487
+ } {
1488
+ const allFiles = params.inputs.length > 0 && params.inputs.every((entry) => entry.type === "file");
1489
+ const shouldReturnDetailed = allFiles && params.inputs.length > 1;
1490
+ const totalSizeBytes = params.fullManifest.reduce((sum, entry) => sum + entry.size, 0);
1491
+
1492
+ if (shouldReturnDetailed) {
1493
+ const limit = Math.max(1, Math.trunc(params.detailedLimit ?? MAX_RESPONSE_MANIFEST_ITEMS));
1494
+ const manifest = params.fullManifest.slice(0, limit);
1495
+ return {
1496
+ manifest,
1497
+ manifest_mode: "detailed",
1498
+ manifest_meta: {
1499
+ total_count: params.fullManifest.length,
1500
+ returned_count: manifest.length,
1501
+ truncated: params.fullManifest.length > manifest.length,
1502
+ total_size_bytes: totalSizeBytes,
1503
+ },
1504
+ };
1505
+ }
1506
+ return {
1507
+ manifest: params.inputs,
1508
+ manifest_mode: "summary",
1509
+ manifest_meta: {
1510
+ total_count: params.inputs.length,
1511
+ returned_count: params.inputs.length,
1512
+ truncated: false,
1513
+ total_size_bytes: totalSizeBytes,
1514
+ },
1515
+ };
1516
+ }
1517
+
1518
+ private makeManifestResponse(
1519
+ manifest: ManifestEntry[] | undefined,
1520
+ limit = MAX_RESPONSE_MANIFEST_ITEMS,
1521
+ ): { manifest: ManifestEntry[]; manifest_meta: ManifestResponseMeta } {
1522
+ const source = manifest ?? [];
1523
+ const safeLimit = Math.max(1, Math.trunc(limit));
1524
+ const sliced = source.slice(0, safeLimit);
1525
+ const totalSizeBytes = source.reduce((sum, entry) => sum + entry.size, 0);
1526
+ return {
1527
+ manifest: sliced,
1528
+ manifest_meta: {
1529
+ total_count: source.length,
1530
+ returned_count: sliced.length,
1531
+ truncated: source.length > sliced.length,
1532
+ total_size_bytes: totalSizeBytes,
1533
+ },
1534
+ };
1535
+ }
1536
+
1537
+ private startGuard() {
1538
+ if (this.guardTimer) {
1539
+ return;
1540
+ }
1541
+ this.guardTimer = setInterval(() => {
1542
+ void this.reapExpired();
1543
+ }, 30_000);
1544
+ this.guardTimer.unref();
1545
+ }
1546
+
1547
+ private async reapExpired(): Promise<void> {
1548
+ const now = Date.now();
1549
+ const toStop = Array.from(this.sessions.values()).filter(
1550
+ (session) => session.status === "running" && new Date(session.expiresAt).getTime() <= now,
1551
+ );
1552
+ for (const session of toStop) {
1553
+ await this.stopExposure(session.id, { reason: "expired", expired: true });
1554
+ }
1555
+ }
1556
+
1557
+ private async startTunnel(session: ExposureSession, targetPort: number): Promise<CloudflaredStartResult> {
1558
+ const cloudflaredBin = this.cloudflaredResolvedPath ?? resolveBinPath(this.cloudflaredPathInput);
1559
+ if (!cloudflaredBin) {
1560
+ throw new Error(`cloudflared not found in PATH: ${this.cloudflaredPathInput}`);
1561
+ }
1562
+ this.cloudflaredResolvedPath = cloudflaredBin;
1563
+
1564
+ const edgeIpVersion = this.policy.tunnel.edgeIpVersion;
1565
+ const protocol = this.policy.tunnel.protocol;
1566
+
1567
+ const args = [
1568
+ "tunnel",
1569
+ "--url",
1570
+ `http://127.0.0.1:${targetPort}`,
1571
+ "--edge-ip-version",
1572
+ edgeIpVersion,
1573
+ "--protocol",
1574
+ protocol,
1575
+ "--no-autoupdate",
1576
+ ];
1577
+
1578
+ this.appendLog(session, "tunnel", `spawn: ${cloudflaredBin} ${args.join(" ")}`);
1579
+
1580
+ const proc = spawn(cloudflaredBin, args, {
1581
+ stdio: ["ignore", "pipe", "pipe"],
1582
+ });
1583
+
1584
+ let settled = false;
1585
+
1586
+ return await new Promise<CloudflaredStartResult>((resolve, reject) => {
1587
+ const timeout = setTimeout(() => {
1588
+ if (settled) {
1589
+ return;
1590
+ }
1591
+ settled = true;
1592
+ proc.kill("SIGTERM");
1593
+ reject(new Error("timed out waiting for cloudflared URL"));
1594
+ }, 30_000);
1595
+
1596
+ const onLine = (line: string) => {
1597
+ this.appendLog(session, "tunnel", line);
1598
+ const url = pickQuickTunnelUrlFromLine(line);
1599
+ if (url && !settled) {
1600
+ settled = true;
1601
+ clearTimeout(timeout);
1602
+ resolve({ process: proc, publicUrl: url });
1603
+ }
1604
+ };
1605
+
1606
+ const attachStream = (stream: NodeJS.ReadableStream) => {
1607
+ let buffer = "";
1608
+ stream.on("data", (chunk: Buffer | string) => {
1609
+ buffer += chunk.toString();
1610
+ const lines = buffer.split(/\r?\n/);
1611
+ buffer = lines.pop() ?? "";
1612
+ for (const line of lines) {
1613
+ if (line.trim()) {
1614
+ onLine(line.trim());
1615
+ }
1616
+ }
1617
+ });
1618
+ stream.on("end", () => {
1619
+ if (buffer.trim()) {
1620
+ onLine(buffer.trim());
1621
+ }
1622
+ });
1623
+ };
1624
+
1625
+ attachStream(proc.stdout);
1626
+ attachStream(proc.stderr);
1627
+
1628
+ proc.once("error", (error) => {
1629
+ if (settled) {
1630
+ return;
1631
+ }
1632
+ settled = true;
1633
+ clearTimeout(timeout);
1634
+ reject(error);
1635
+ });
1636
+
1637
+ proc.once("exit", (code, signal) => {
1638
+ if (!settled) {
1639
+ settled = true;
1640
+ clearTimeout(timeout);
1641
+ reject(new Error(`cloudflared exited before URL (code=${String(code)}, signal=${String(signal)})`));
1642
+ }
1643
+ });
1644
+ });
1645
+ }
1646
+
1647
+ private async startTunnelWithRetry(params: {
1648
+ session: ExposureSession;
1649
+ targetPort: number;
1650
+ maxAttempts?: number;
1651
+ }): Promise<CloudflaredStartResult> {
1652
+ const maxAttempts = Math.max(1, Math.trunc(params.maxAttempts ?? 2));
1653
+ let lastError = "unknown";
1654
+
1655
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
1656
+ let tunnel: CloudflaredStartResult | undefined;
1657
+ try {
1658
+ this.appendLog(
1659
+ params.session,
1660
+ "manager",
1661
+ `starting tunnel attempt ${attempt}/${maxAttempts} on port ${params.targetPort}`,
1662
+ );
1663
+ tunnel = await this.startTunnel(params.session, params.targetPort);
1664
+
1665
+ if (!isValidQuickTunnelUrl(tunnel.publicUrl)) {
1666
+ throw new Error(`invalid quick tunnel url: ${tunnel.publicUrl}`);
1667
+ }
1668
+
1669
+ this.appendLog(
1670
+ params.session,
1671
+ "manager",
1672
+ `tunnel ready on attempt ${attempt}/${maxAttempts}: ${tunnel.publicUrl}`,
1673
+ );
1674
+ return tunnel;
1675
+ } catch (error) {
1676
+ lastError = error instanceof Error ? error.message : String(error);
1677
+ this.appendLog(
1678
+ params.session,
1679
+ "manager",
1680
+ `tunnel attempt ${attempt}/${maxAttempts} failed: ${lastError}`,
1681
+ );
1682
+ if (tunnel?.process) {
1683
+ this.appendLog(params.session, "manager", "stopping failed tunnel process before retry");
1684
+ await this.terminateProcess(tunnel.process);
1685
+ }
1686
+ }
1687
+ }
1688
+
1689
+ throw new Error(`failed to start cloudflared tunnel after ${maxAttempts} attempts: ${lastError}`);
1690
+ }
1691
+
1692
+ private async terminateProcess(proc?: ChildProcessWithoutNullStreams): Promise<void> {
1693
+ if (!proc || proc.killed) {
1694
+ return;
1695
+ }
1696
+ const pid = proc.pid;
1697
+ try {
1698
+ process.kill(pid, 0);
1699
+ } catch {
1700
+ return;
1701
+ }
1702
+
1703
+ proc.kill("SIGTERM");
1704
+ await new Promise<void>((resolve) => {
1705
+ const timer = setTimeout(() => {
1706
+ try {
1707
+ proc.kill("SIGKILL");
1708
+ } catch {
1709
+ // ignore
1710
+ }
1711
+ resolve();
1712
+ }, 2500);
1713
+ proc.once("exit", () => {
1714
+ clearTimeout(timer);
1715
+ resolve();
1716
+ });
1717
+ });
1718
+ }
1719
+
1720
+ async envCheck(): Promise<EnvCheckResult> {
1721
+ await this.ensureInitialized();
1722
+ await this.reloadPolicy();
1723
+
1724
+ const warnings: string[] = [...this.policyWarnings];
1725
+ let cloudflaredPath = resolveBinPath(this.cloudflaredPathInput);
1726
+ let cloudflaredVersion: string | undefined;
1727
+
1728
+ if (!cloudflaredPath && path.isAbsolute(this.cloudflaredPathInput)) {
1729
+ cloudflaredPath = this.cloudflaredPathInput;
1730
+ }
1731
+
1732
+ if (cloudflaredPath) {
1733
+ const result = spawnSync(cloudflaredPath, ["--version"], {
1734
+ timeout: 2000,
1735
+ encoding: "utf8",
1736
+ });
1737
+ if (result.error) {
1738
+ warnings.push(`cloudflared exists but version check failed: ${String(result.error)}`);
1739
+ } else {
1740
+ const combined = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
1741
+ cloudflaredVersion = extractCloudflaredVersion(combined) ?? combined.trim().split(/\r?\n/)[0];
1742
+ }
1743
+ } else {
1744
+ warnings.push(`cloudflared binary not found (${this.cloudflaredPathInput})`);
1745
+ }
1746
+
1747
+ const defaults = {
1748
+ ...this.policy,
1749
+ state_dir: this.stateDir,
1750
+ policy_file: this.policyFile,
1751
+ ignore_file: this.ignoreFile,
1752
+ os: {
1753
+ platform: process.platform,
1754
+ arch: process.arch,
1755
+ release: os.release(),
1756
+ uid: typeof process.getuid === "function" ? process.getuid() : undefined,
1757
+ },
1758
+ };
1759
+
1760
+ return {
1761
+ cloudflared: {
1762
+ ok: Boolean(cloudflaredPath && cloudflaredVersion),
1763
+ path: cloudflaredPath,
1764
+ version: cloudflaredVersion,
1765
+ },
1766
+ defaults,
1767
+ warnings,
1768
+ };
1769
+ }
1770
+
1771
+ async exposePort(
1772
+ params: {
1773
+ port: number;
1774
+ opts?: {
1775
+ ttl_seconds?: number;
1776
+ access?: AccessMode;
1777
+ protect_origin?: boolean;
1778
+ allowlist_paths?: string[];
1779
+ };
1780
+ },
1781
+ ): Promise<Record<string, unknown>> {
1782
+ await this.ensureInitialized();
1783
+
1784
+ const port = Math.trunc(params.port);
1785
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1786
+ throw new Error("invalid port");
1787
+ }
1788
+ if (this.policy.blockedPorts.includes(port)) {
1789
+ throw new Error(`port blocked by policy: ${port}`);
1790
+ }
1791
+
1792
+ const alive = await probeLocalPort(port);
1793
+ if (!alive) {
1794
+ throw new Error(`local service is not reachable on 127.0.0.1:${port}`);
1795
+ }
1796
+
1797
+ const ttlSeconds = normalizeTtl(params.opts?.ttl_seconds, this.policy);
1798
+ const expiresAt = toLocalIso(new Date(Date.now() + ttlSeconds * 1000));
1799
+ const accessMode = normalizeAccessMode(params.opts?.access, this.policy.defaultExposePortAccess);
1800
+ const protectOrigin =
1801
+ typeof params.opts?.protect_origin === "boolean" ? params.opts.protect_origin : accessMode !== "none";
1802
+
1803
+ const id = randomId("port");
1804
+ const session: ExposureSession = {
1805
+ id,
1806
+ type: "port",
1807
+ status: "starting",
1808
+ createdAt: nowIso(),
1809
+ expiresAt,
1810
+ localUrl: `http://127.0.0.1:${port}`,
1811
+ sourcePort: port,
1812
+ originPort: port,
1813
+ tunnelPort: port,
1814
+ logs: [],
1815
+ stats: { requests: 0, downloads: 0, bytesSent: 0 },
1816
+ access: this.makeAccessState({
1817
+ mode: accessMode,
1818
+ protectOrigin,
1819
+ allowlistPaths: params.opts?.allowlist_paths,
1820
+ }),
1821
+ };
1822
+
1823
+ this.sessions.set(id, session);
1824
+
1825
+ try {
1826
+ let tunnelTargetPort = port;
1827
+ if (protectOrigin || session.access.allowlistPaths.length > 0 || this.policy.rateLimit.enabled) {
1828
+ const proxy = await this.startReverseProxy({
1829
+ upstreamPort: port,
1830
+ session,
1831
+ access: session.access,
1832
+ });
1833
+ session.proxyServer = proxy.server;
1834
+ session.tunnelPort = proxy.port;
1835
+ tunnelTargetPort = proxy.port;
1836
+ }
1837
+
1838
+ const tunnel = await this.startTunnelWithRetry({
1839
+ session,
1840
+ targetPort: tunnelTargetPort,
1841
+ maxAttempts: 2,
1842
+ });
1843
+ session.process = tunnel.process;
1844
+ session.publicUrl = tunnel.publicUrl;
1845
+ session.status = "running";
1846
+
1847
+ session.process.on("exit", (code, signal) => {
1848
+ if (session.status === "running") {
1849
+ session.status = "error";
1850
+ session.lastError = `cloudflared exited (code=${String(code)}, signal=${String(signal)})`;
1851
+ this.appendLog(session, "tunnel", session.lastError);
1852
+ }
1853
+ });
1854
+
1855
+ session.timeoutHandle = setTimeout(() => {
1856
+ void this.stopExposure(session.id, { reason: "expired", expired: true });
1857
+ }, ttlSeconds * 1000);
1858
+ session.timeoutHandle.unref();
1859
+
1860
+ await this.persistSessionsSnapshot();
1861
+ await this.writeAudit({
1862
+ ts: nowIso(),
1863
+ event: "exposure_started",
1864
+ id: session.id,
1865
+ type: session.type,
1866
+ details: {
1867
+ source_port: port,
1868
+ public_url: session.publicUrl,
1869
+ expires_at: expiresAt,
1870
+ access_mode: session.access.mode,
1871
+ },
1872
+ });
1873
+
1874
+ return {
1875
+ id: session.id,
1876
+ public_url: this.makeResponsePublicUrl(session),
1877
+ local_url: session.localUrl,
1878
+ expires_at: session.expiresAt,
1879
+ access_info: {
1880
+ mode: session.access.mode,
1881
+ protect_origin: session.access.protectOrigin,
1882
+ token: maskSecret(session.access.token),
1883
+ username: session.access.username,
1884
+ password: maskSecret(session.access.password),
1885
+ allowlist_paths: session.access.allowlistPaths,
1886
+ },
1887
+ };
1888
+ } catch (error) {
1889
+ session.status = "error";
1890
+ session.lastError = error instanceof Error ? error.message : String(error);
1891
+ await this.stopExposure(id, { reason: session.lastError, expired: false, keepAudit: true });
1892
+ throw error;
1893
+ }
1894
+ }
1895
+
1896
+ async exposeFiles(
1897
+ params: {
1898
+ paths: string[];
1899
+ opts?: {
1900
+ mode?: "normal" | "zip";
1901
+ presentation?: FilePresentationMode;
1902
+ ttl_seconds?: number;
1903
+ access?: AccessMode;
1904
+ max_downloads?: number;
1905
+ };
1906
+ },
1907
+ ctx?: ToolContext,
1908
+ ): Promise<Record<string, unknown>> {
1909
+ await this.ensureInitialized();
1910
+
1911
+ if (!Array.isArray(params.paths) || params.paths.length === 0) {
1912
+ throw new Error("paths is required");
1913
+ }
1914
+
1915
+ const ttlSeconds = normalizeTtl(params.opts?.ttl_seconds, this.policy);
1916
+ const expiresAt = toLocalIso(new Date(Date.now() + ttlSeconds * 1000));
1917
+ const mode = params.opts?.mode ?? "normal";
1918
+ const presentation = normalizeFilePresentation(params.opts?.presentation);
1919
+ const accessMode = normalizeAccessMode(params.opts?.access, this.policy.defaultExposeFilesAccess);
1920
+ const protectOrigin = accessMode !== "none";
1921
+
1922
+ const id = randomId("files");
1923
+ const workspaceDir = path.join(this.workspaceRoot, id);
1924
+ await mkdirp(workspaceDir);
1925
+ const inputSummary = await this.summarizeExposeInputs(params.paths);
1926
+
1927
+ const session: ExposureSession = {
1928
+ id,
1929
+ type: "files",
1930
+ status: "starting",
1931
+ createdAt: nowIso(),
1932
+ expiresAt,
1933
+ localUrl: "",
1934
+ originPort: 0,
1935
+ tunnelPort: 0,
1936
+ workspaceDir,
1937
+ fileMode: mode,
1938
+ filePresentation: presentation,
1939
+ logs: [],
1940
+ stats: { requests: 0, downloads: 0, bytesSent: 0 },
1941
+ access: this.makeAccessState({
1942
+ mode: accessMode,
1943
+ protectOrigin,
1944
+ }),
1945
+ maxDownloads:
1946
+ typeof params.opts?.max_downloads === "number" ? Math.trunc(params.opts.max_downloads) : undefined,
1947
+ };
1948
+
1949
+ this.sessions.set(id, session);
1950
+
1951
+ try {
1952
+ await this.copyInputsToWorkspace(params.paths, workspaceDir, ctx);
1953
+ const rootBehavior = await this.detectWorkspaceRootBehavior(workspaceDir);
1954
+ const fileServer = await this.startFileServer({
1955
+ session,
1956
+ workspaceDir,
1957
+ mode,
1958
+ presentation,
1959
+ maxDownloads: session.maxDownloads,
1960
+ directSingleFileRoot: mode === "normal" && rootBehavior.directSingleFileRoot,
1961
+ autoEnterSingleDirectory: mode === "normal" && rootBehavior.autoEnterSingleDirectory,
1962
+ rootDirectories: mode === "normal" ? rootBehavior.rootDirectories : [],
1963
+ });
1964
+
1965
+ session.originServer = fileServer.server;
1966
+ session.manifest = fileServer.manifest;
1967
+ session.originPort = fileServer.port;
1968
+ session.localUrl = `http://127.0.0.1:${fileServer.port}`;
1969
+
1970
+ let tunnelTargetPort = fileServer.port;
1971
+ if (protectOrigin) {
1972
+ const proxy = await this.startReverseProxy({
1973
+ upstreamPort: fileServer.port,
1974
+ session,
1975
+ access: session.access,
1976
+ });
1977
+ session.proxyServer = proxy.server;
1978
+ session.tunnelPort = proxy.port;
1979
+ tunnelTargetPort = proxy.port;
1980
+ }
1981
+
1982
+ if (!session.tunnelPort) {
1983
+ session.tunnelPort = tunnelTargetPort;
1984
+ }
1985
+
1986
+ const tunnel = await this.startTunnelWithRetry({
1987
+ session,
1988
+ targetPort: tunnelTargetPort,
1989
+ maxAttempts: 2,
1990
+ });
1991
+ session.process = tunnel.process;
1992
+ session.publicUrl = tunnel.publicUrl;
1993
+ session.status = "running";
1994
+
1995
+ session.process.on("exit", (code, signal) => {
1996
+ if (session.status === "running") {
1997
+ session.status = "error";
1998
+ session.lastError = `cloudflared exited (code=${String(code)}, signal=${String(signal)})`;
1999
+ this.appendLog(session, "tunnel", session.lastError);
2000
+ }
2001
+ });
2002
+
2003
+ session.timeoutHandle = setTimeout(() => {
2004
+ void this.stopExposure(session.id, { reason: "expired", expired: true });
2005
+ }, ttlSeconds * 1000);
2006
+ session.timeoutHandle.unref();
2007
+
2008
+ await this.persistSessionsSnapshot();
2009
+ await this.writeAudit({
2010
+ ts: nowIso(),
2011
+ event: "exposure_started",
2012
+ id: session.id,
2013
+ type: session.type,
2014
+ details: {
2015
+ public_url: session.publicUrl,
2016
+ expires_at: session.expiresAt,
2017
+ files_count: session.manifest?.length ?? 0,
2018
+ mode,
2019
+ presentation,
2020
+ },
2021
+ });
2022
+
2023
+ const responseManifest =
2024
+ mode === "zip"
2025
+ ? {
2026
+ ...this.makeManifestResponse(session.manifest, MAX_RESPONSE_MANIFEST_ITEMS),
2027
+ manifest_mode: "detailed" as const,
2028
+ }
2029
+ : this.buildExposeFilesResponseManifest({
2030
+ inputs: inputSummary,
2031
+ fullManifest: session.manifest ?? [],
2032
+ detailedLimit: MAX_RESPONSE_MANIFEST_ITEMS,
2033
+ });
2034
+
2035
+ return {
2036
+ id: session.id,
2037
+ public_url: this.makeResponsePublicUrl(session),
2038
+ expires_at: session.expiresAt,
2039
+ mode,
2040
+ presentation,
2041
+ manifest_mode: responseManifest.manifest_mode,
2042
+ manifest_meta: responseManifest.manifest_meta,
2043
+ manifest: responseManifest.manifest,
2044
+ };
2045
+ } catch (error) {
2046
+ session.status = "error";
2047
+ session.lastError = error instanceof Error ? error.message : String(error);
2048
+ await this.stopExposure(id, { reason: session.lastError, expired: false, keepAudit: true });
2049
+ throw error;
2050
+ }
2051
+ }
2052
+
2053
+ exposureList(): ExposureRecord[] {
2054
+ return Array.from(this.sessions.values()).map((session) => this.makeExposureRecord(session));
2055
+ }
2056
+
2057
+ private normalizeRequestedIds(rawIds: string[]): string[] {
2058
+ const out: string[] = [];
2059
+ const seen = new Set<string>();
2060
+ for (const item of rawIds) {
2061
+ if (typeof item !== "string") {
2062
+ continue;
2063
+ }
2064
+ const id = item.trim();
2065
+ if (!id || seen.has(id)) {
2066
+ continue;
2067
+ }
2068
+ seen.add(id);
2069
+ out.push(id);
2070
+ }
2071
+ return out;
2072
+ }
2073
+
2074
+ private matchesExposureFilter(session: ExposureSession, filter?: ExposureFilter): boolean {
2075
+ if (!filter) {
2076
+ return true;
2077
+ }
2078
+ if (filter.status && session.status !== filter.status) {
2079
+ return false;
2080
+ }
2081
+ if (filter.type && session.type !== filter.type) {
2082
+ return false;
2083
+ }
2084
+ return true;
2085
+ }
2086
+
2087
+ private resolveExposureSelection(query: {
2088
+ id?: string;
2089
+ ids?: string[];
2090
+ filter?: ExposureFilter;
2091
+ }): { selectorUsed: boolean; selectedIds: string[]; missingIds: string[] } {
2092
+ const explicitIds = this.normalizeRequestedIds([
2093
+ ...(typeof query.id === "string" ? [query.id] : []),
2094
+ ...((query.ids ?? []).filter((value): value is string => typeof value === "string")),
2095
+ ]);
2096
+
2097
+ const hasAll = explicitIds.includes("all");
2098
+ const allSessions = Array.from(this.sessions.values());
2099
+
2100
+ if (hasAll) {
2101
+ const selectedIds = allSessions
2102
+ .filter((session) => this.matchesExposureFilter(session, query.filter))
2103
+ .map((session) => session.id);
2104
+ return { selectorUsed: true, selectedIds, missingIds: [] };
2105
+ }
2106
+
2107
+ if (explicitIds.length > 0) {
2108
+ const selectedIds: string[] = [];
2109
+ const missingIds: string[] = [];
2110
+ for (const id of explicitIds) {
2111
+ const session = this.sessions.get(id);
2112
+ if (!session) {
2113
+ missingIds.push(id);
2114
+ continue;
2115
+ }
2116
+ if (this.matchesExposureFilter(session, query.filter)) {
2117
+ selectedIds.push(id);
2118
+ }
2119
+ }
2120
+ return { selectorUsed: true, selectedIds, missingIds };
2121
+ }
2122
+
2123
+ if (query.filter) {
2124
+ const selectedIds = allSessions
2125
+ .filter((session) => this.matchesExposureFilter(session, query.filter))
2126
+ .map((session) => session.id);
2127
+ return { selectorUsed: true, selectedIds, missingIds: [] };
2128
+ }
2129
+
2130
+ return { selectorUsed: false, selectedIds: [], missingIds: [] };
2131
+ }
2132
+
2133
+ private async buildExposureDetail(
2134
+ session: ExposureSession,
2135
+ opts?: {
2136
+ probe_public?: boolean;
2137
+ include_manifest?: boolean;
2138
+ manifest_limit?: number;
2139
+ },
2140
+ ): Promise<Record<string, unknown>> {
2141
+ const tunnelAlive = Boolean(session.process && !session.process.killed);
2142
+ const originAlive =
2143
+ session.type === "port"
2144
+ ? await probeLocalPort(session.sourcePort ?? session.originPort)
2145
+ : Boolean(session.originServer?.listening);
2146
+
2147
+ let publicProbe: { ok: boolean; status?: number; error?: string } | undefined;
2148
+ if (opts?.probe_public && session.publicUrl) {
2149
+ try {
2150
+ const probeUrl = new URL(session.publicUrl);
2151
+ if (session.access.mode === "token" && session.access.token) {
2152
+ probeUrl.searchParams.set("token", session.access.token);
2153
+ }
2154
+
2155
+ const controller = new AbortController();
2156
+ const timer = setTimeout(() => controller.abort(), DEFAULT_PROBE_TIMEOUT_MS);
2157
+ const headers: Record<string, string> = {};
2158
+ if (session.access.mode === "basic" && session.access.username && session.access.password) {
2159
+ headers.authorization = `Basic ${Buffer.from(
2160
+ `${session.access.username}:${session.access.password}`,
2161
+ ).toString("base64")}`;
2162
+ }
2163
+
2164
+ const response = await fetch(probeUrl.toString(), {
2165
+ method: "HEAD",
2166
+ signal: controller.signal,
2167
+ headers,
2168
+ });
2169
+ clearTimeout(timer);
2170
+ publicProbe = { ok: response.ok, status: response.status };
2171
+ } catch (error) {
2172
+ publicProbe = { ok: false, error: String(error) };
2173
+ }
2174
+ }
2175
+
2176
+ const fileSharing =
2177
+ session.type === "files"
2178
+ ? {
2179
+ mode: session.fileMode ?? "normal",
2180
+ presentation: session.filePresentation ?? "download",
2181
+ }
2182
+ : undefined;
2183
+ const includeManifest = Boolean(opts?.include_manifest);
2184
+ const manifestBundle =
2185
+ session.type === "files" ? this.makeManifestResponse(session.manifest, opts?.manifest_limit) : undefined;
2186
+
2187
+ const detail: Record<string, unknown> = {
2188
+ id: session.id,
2189
+ type: session.type,
2190
+ created_at: session.createdAt,
2191
+ status: {
2192
+ state: session.status,
2193
+ tunnel_alive: tunnelAlive,
2194
+ origin_alive: originAlive,
2195
+ public_probe: publicProbe,
2196
+ },
2197
+ port: {
2198
+ source_port: session.sourcePort,
2199
+ origin_port: session.originPort,
2200
+ tunnel_port: session.tunnelPort,
2201
+ },
2202
+ public_url: this.makeResponsePublicUrl(session),
2203
+ expires_at: session.expiresAt,
2204
+ local_url: session.localUrl,
2205
+ stats: session.stats,
2206
+ file_sharing: fileSharing,
2207
+ last_error: session.lastError,
2208
+ };
2209
+ if (manifestBundle) {
2210
+ detail.manifest_meta = manifestBundle.manifest_meta;
2211
+ if (includeManifest) {
2212
+ detail.manifest = manifestBundle.manifest;
2213
+ }
2214
+ }
2215
+ return detail;
2216
+ }
2217
+
2218
+ private projectExposureDetail(
2219
+ detail: Record<string, unknown>,
2220
+ fields?: ExposureGetField[],
2221
+ ): Record<string, unknown> {
2222
+ if (!fields || fields.length === 0) {
2223
+ return detail;
2224
+ }
2225
+ const out: Record<string, unknown> = {
2226
+ id: detail.id,
2227
+ };
2228
+ for (const field of fields) {
2229
+ if (field === "id") {
2230
+ continue;
2231
+ }
2232
+ if (Object.prototype.hasOwnProperty.call(detail, field)) {
2233
+ out[field] = detail[field];
2234
+ if (field === "manifest" && Object.prototype.hasOwnProperty.call(detail, "manifest_meta")) {
2235
+ out.manifest_meta = detail.manifest_meta;
2236
+ }
2237
+ }
2238
+ }
2239
+ return out;
2240
+ }
2241
+
2242
+ private makeExposureGetNotFound(id: string, fields?: ExposureGetField[]): Record<string, unknown> {
2243
+ const out: Record<string, unknown> = { id, error: "not_found" };
2244
+ if (fields?.includes("status")) {
2245
+ out.status = "not_found";
2246
+ }
2247
+ return out;
2248
+ }
2249
+
2250
+ async exposureGet(params: ExposureGetParams): Promise<Record<string, unknown>> {
2251
+ await this.ensureInitialized();
2252
+
2253
+ const fields = Array.isArray(params.fields) ? this.normalizeRequestedIds(params.fields) : undefined;
2254
+ const typedFields = fields as ExposureGetField[] | undefined;
2255
+ const legacySingle =
2256
+ Boolean(params.id) && params.id !== "all" && !params.ids && !params.filter && !params.fields;
2257
+
2258
+ const selection = this.resolveExposureSelection({
2259
+ id: params.id,
2260
+ ids: params.ids,
2261
+ filter: params.filter,
2262
+ });
2263
+
2264
+ if (!selection.selectorUsed) {
2265
+ throw new Error("id, ids, or filter is required");
2266
+ }
2267
+
2268
+ if (legacySingle) {
2269
+ const legacyId = params.id as string;
2270
+ const session = this.sessions.get(legacyId);
2271
+ if (!session) {
2272
+ return { id: legacyId, status: "not_found" };
2273
+ }
2274
+ return await this.buildExposureDetail(session, {
2275
+ probe_public: params.opts?.probe_public,
2276
+ include_manifest: true,
2277
+ manifest_limit: MAX_RESPONSE_MANIFEST_ITEMS,
2278
+ });
2279
+ }
2280
+
2281
+ const manifestRequested = typedFields?.includes("manifest") ?? false;
2282
+ const manifestLimit =
2283
+ selection.selectedIds.length > 1
2284
+ ? MAX_RESPONSE_MANIFEST_ITEMS_MULTI_GET
2285
+ : MAX_RESPONSE_MANIFEST_ITEMS;
2286
+ const responseSelectedIds = selection.selectedIds.slice(0, MAX_EXPOSURE_GET_ITEMS);
2287
+ const selectedIdsTruncated = selection.selectedIds.length > responseSelectedIds.length;
2288
+ const items: Record<string, unknown>[] = [];
2289
+ for (const id of responseSelectedIds) {
2290
+ const session = this.sessions.get(id);
2291
+ if (!session) {
2292
+ items.push(this.makeExposureGetNotFound(id, typedFields));
2293
+ continue;
2294
+ }
2295
+ const detail = await this.buildExposureDetail(session, {
2296
+ probe_public: params.opts?.probe_public,
2297
+ include_manifest: manifestRequested,
2298
+ manifest_limit: manifestLimit,
2299
+ });
2300
+ items.push(this.projectExposureDetail(detail, typedFields));
2301
+ }
2302
+ for (const missingId of selection.missingIds) {
2303
+ items.push(this.makeExposureGetNotFound(missingId, typedFields));
2304
+ }
2305
+
2306
+ return {
2307
+ items,
2308
+ count: items.length,
2309
+ matched_ids: responseSelectedIds,
2310
+ matched_total_count: selection.selectedIds.length,
2311
+ matched_ids_truncated: selectedIdsTruncated,
2312
+ missing_ids: selection.missingIds,
2313
+ filter: params.filter,
2314
+ fields: typedFields,
2315
+ };
2316
+ }
2317
+
2318
+ async stopExposure(
2319
+ idOrIds: string | string[],
2320
+ opts?: { reason?: string; expired?: boolean; keepAudit?: boolean },
2321
+ ): Promise<Record<string, unknown>> {
2322
+ await this.ensureInitialized();
2323
+
2324
+ const requested = this.normalizeRequestedIds(Array.isArray(idOrIds) ? idOrIds : [idOrIds]);
2325
+ if (requested.length === 0) {
2326
+ return { stopped: [], failed: [{ id: "unknown", error: "id or ids is required" }], cleaned: [] };
2327
+ }
2328
+
2329
+ const includeAll = requested.includes("all");
2330
+ const ids: string[] = [];
2331
+ const failed: Array<{ id: string; error: string }> = [];
2332
+
2333
+ if (includeAll) {
2334
+ ids.push(...Array.from(this.sessions.keys()));
2335
+ if (ids.length === 0) {
2336
+ return { stopped: [], failed: [{ id: "all", error: "not_found" }], cleaned: [] };
2337
+ }
2338
+ } else {
2339
+ for (const id of requested) {
2340
+ if (this.sessions.has(id)) {
2341
+ ids.push(id);
2342
+ } else {
2343
+ failed.push({ id, error: "not_found" });
2344
+ }
2345
+ }
2346
+ }
2347
+
2348
+ const stopIds = this.normalizeRequestedIds(ids);
2349
+ if (stopIds.length === 0) {
2350
+ return { stopped: [], failed, cleaned: [] };
2351
+ }
2352
+
2353
+ const stopped: string[] = [];
2354
+ const cleaned: string[] = [];
2355
+
2356
+ for (const id of stopIds) {
2357
+ const session = this.sessions.get(id);
2358
+ if (!session) {
2359
+ failed.push({ id, error: "not_found" });
2360
+ continue;
2361
+ }
2362
+ try {
2363
+ if (session.timeoutHandle) {
2364
+ clearTimeout(session.timeoutHandle);
2365
+ session.timeoutHandle = undefined;
2366
+ }
2367
+
2368
+ await this.terminateProcess(session.process);
2369
+
2370
+ if (session.proxyServer?.listening) {
2371
+ await new Promise<void>((resolve) => session.proxyServer?.close(() => resolve()));
2372
+ }
2373
+ if (session.originServer?.listening) {
2374
+ await new Promise<void>((resolve) => session.originServer?.close(() => resolve()));
2375
+ }
2376
+
2377
+ if (session.workspaceDir && (await fileExists(session.workspaceDir))) {
2378
+ await fs.rm(session.workspaceDir, { recursive: true, force: true });
2379
+ cleaned.push(session.workspaceDir);
2380
+ }
2381
+
2382
+ session.status = opts?.expired ? "expired" : "stopped";
2383
+ if (opts?.reason) {
2384
+ session.lastError = opts.reason;
2385
+ this.appendLog(session, "manager", `stop reason: ${opts.reason}`);
2386
+ }
2387
+ stopped.push(id);
2388
+
2389
+ await this.writeAudit({
2390
+ ts: nowIso(),
2391
+ event: opts?.expired ? "exposure_expired" : "exposure_stopped",
2392
+ id: session.id,
2393
+ type: session.type,
2394
+ details: {
2395
+ reason: opts?.reason,
2396
+ public_url: session.publicUrl,
2397
+ },
2398
+ });
2399
+ } catch (error) {
2400
+ failed.push({ id, error: String(error) });
2401
+ } finally {
2402
+ this.sessions.delete(id);
2403
+ }
2404
+ }
2405
+
2406
+ await this.persistSessionsSnapshot();
2407
+ return { stopped, failed, cleaned };
2408
+ }
2409
+
2410
+ private exposureLogsOne(session: ExposureSession, opts?: ExposureLogsOpts): Record<string, unknown> {
2411
+ const lines = Math.max(1, Math.min(MAX_EXPOSURE_LOG_LINES_RESPONSE, Math.trunc(opts?.lines ?? 200)));
2412
+ const component = opts?.component ?? "all";
2413
+ const threshold =
2414
+ typeof opts?.since_seconds === "number"
2415
+ ? Date.now() - Math.max(1, Math.trunc(opts.since_seconds)) * 1000
2416
+ : undefined;
2417
+
2418
+ const filtered = session.logs.filter((entry) => {
2419
+ if (component !== "all" && entry.component !== component) {
2420
+ return false;
2421
+ }
2422
+ if (threshold !== undefined && new Date(entry.ts).getTime() < threshold) {
2423
+ return false;
2424
+ }
2425
+ return true;
2426
+ });
2427
+
2428
+ return {
2429
+ id: session.id,
2430
+ component,
2431
+ lines: filtered
2432
+ .slice(-lines)
2433
+ .map((entry) => `${entry.ts} [${entry.component}] ${entry.line}`),
2434
+ };
2435
+ }
2436
+
2437
+ exposureLogs(idOrIds: string | string[], opts?: ExposureLogsOpts): Record<string, unknown> {
2438
+ const requested = this.normalizeRequestedIds(Array.isArray(idOrIds) ? idOrIds : [idOrIds]);
2439
+ if (requested.length === 0) {
2440
+ return { items: [], missing_ids: [], error: "id or ids is required" };
2441
+ }
2442
+
2443
+ const includeAll = requested.includes("all");
2444
+ const targetIds = includeAll ? Array.from(this.sessions.keys()) : requested;
2445
+ const responseTargetIds = targetIds.slice(0, MAX_EXPOSURE_LOG_ITEMS);
2446
+ const targetIdsTruncated = targetIds.length > responseTargetIds.length;
2447
+
2448
+ const singleLegacy = !Array.isArray(idOrIds) && !includeAll;
2449
+ if (singleLegacy) {
2450
+ const session = this.sessions.get(targetIds[0] ?? "");
2451
+ if (!session) {
2452
+ return { id: idOrIds, component: opts?.component ?? "all", lines: [], error: "not_found" };
2453
+ }
2454
+ return this.exposureLogsOne(session, opts);
2455
+ }
2456
+
2457
+ const missingIds: string[] = [];
2458
+ const items: Record<string, unknown>[] = [];
2459
+ for (const id of responseTargetIds) {
2460
+ const session = this.sessions.get(id);
2461
+ if (!session) {
2462
+ missingIds.push(id);
2463
+ items.push({ id, component: opts?.component ?? "all", lines: [], error: "not_found" });
2464
+ continue;
2465
+ }
2466
+ items.push(this.exposureLogsOne(session, opts));
2467
+ }
2468
+
2469
+ return {
2470
+ items,
2471
+ requested_count: responseTargetIds.length,
2472
+ requested_total_count: targetIds.length,
2473
+ requested_ids_truncated: targetIdsTruncated,
2474
+ found_count: responseTargetIds.length - missingIds.length,
2475
+ missing_ids: missingIds,
2476
+ };
2477
+ }
2478
+
2479
+ async maintenance(
2480
+ action: "start_guard" | "run_gc" | "set_policy",
2481
+ opts?: {
2482
+ policy?: unknown;
2483
+ ignore_patterns?: string[];
2484
+ },
2485
+ ): Promise<Record<string, unknown>> {
2486
+ await this.ensureInitialized();
2487
+
2488
+ if (action === "start_guard") {
2489
+ this.startGuard();
2490
+ return {
2491
+ ok: true,
2492
+ action,
2493
+ details: {
2494
+ running: Boolean(this.guardTimer),
2495
+ },
2496
+ };
2497
+ }
2498
+
2499
+ if (action === "run_gc") {
2500
+ const details = await this.runGc();
2501
+ return { ok: true, action, details };
2502
+ }
2503
+
2504
+ if (!opts?.policy && !opts?.ignore_patterns) {
2505
+ return {
2506
+ ok: false,
2507
+ action,
2508
+ details: "set_policy requires opts.policy or opts.ignore_patterns",
2509
+ };
2510
+ }
2511
+
2512
+ const current = await this.readPolicyJson();
2513
+ const nextPolicy = this.deepMerge(current, (opts.policy ?? {}) as Record<string, unknown>);
2514
+ await fs.writeFile(this.policyFile, `${JSON.stringify(nextPolicy, null, 2)}\n`, "utf8");
2515
+
2516
+ if (Array.isArray(opts.ignore_patterns)) {
2517
+ await fs.writeFile(this.ignoreFile, `${opts.ignore_patterns.join("\n")}\n`, "utf8");
2518
+ }
2519
+
2520
+ await this.reloadPolicy();
2521
+ await this.writeAudit({
2522
+ ts: nowIso(),
2523
+ event: "policy_updated",
2524
+ details: {
2525
+ policy_file: this.policyFile,
2526
+ },
2527
+ });
2528
+
2529
+ return {
2530
+ ok: true,
2531
+ action,
2532
+ details: {
2533
+ policy_file: this.policyFile,
2534
+ ignore_file: this.ignoreFile,
2535
+ effective: this.policy,
2536
+ },
2537
+ };
2538
+ }
2539
+
2540
+ private deepMerge(
2541
+ base: Record<string, unknown>,
2542
+ patch: Record<string, unknown>,
2543
+ ): Record<string, unknown> {
2544
+ const out: Record<string, unknown> = { ...base };
2545
+ for (const [key, value] of Object.entries(patch)) {
2546
+ if (
2547
+ value &&
2548
+ typeof value === "object" &&
2549
+ !Array.isArray(value) &&
2550
+ typeof out[key] === "object" &&
2551
+ out[key] !== null &&
2552
+ !Array.isArray(out[key])
2553
+ ) {
2554
+ out[key] = this.deepMerge(out[key] as Record<string, unknown>, value as Record<string, unknown>);
2555
+ } else {
2556
+ out[key] = value;
2557
+ }
2558
+ }
2559
+ return out;
2560
+ }
2561
+
2562
+ private async readPolicyJson(): Promise<Record<string, unknown>> {
2563
+ try {
2564
+ const raw = await fs.readFile(this.policyFile, "utf8");
2565
+ const parsed = JSON.parse(raw);
2566
+ if (parsed && typeof parsed === "object") {
2567
+ return parsed as Record<string, unknown>;
2568
+ }
2569
+ } catch {
2570
+ // fallback to empty
2571
+ }
2572
+ return {};
2573
+ }
2574
+
2575
+ private async runGc(): Promise<Record<string, unknown>> {
2576
+ const removedWorkspaces: string[] = [];
2577
+ const killedPids: number[] = [];
2578
+
2579
+ const activeWorkspaces = new Set(
2580
+ Array.from(this.sessions.values())
2581
+ .map((session) => session.workspaceDir)
2582
+ .filter((entry): entry is string => Boolean(entry)),
2583
+ );
2584
+
2585
+ const workspaces = await fs.readdir(this.workspaceRoot, { withFileTypes: true }).catch(() => []);
2586
+ for (const entry of workspaces) {
2587
+ if (!entry.isDirectory()) {
2588
+ continue;
2589
+ }
2590
+ const abs = path.join(this.workspaceRoot, entry.name);
2591
+ if (activeWorkspaces.has(abs)) {
2592
+ continue;
2593
+ }
2594
+ await fs.rm(abs, { recursive: true, force: true });
2595
+ removedWorkspaces.push(abs);
2596
+ }
2597
+
2598
+ try {
2599
+ const raw = await fs.readFile(this.sessionsFile, "utf8");
2600
+ const rows = JSON.parse(raw) as Array<{ id?: string; processPid?: number; workspaceDir?: string }>;
2601
+ for (const row of rows ?? []) {
2602
+ if (!row.processPid || this.sessions.has(String(row.id ?? ""))) {
2603
+ continue;
2604
+ }
2605
+ try {
2606
+ process.kill(row.processPid, 0);
2607
+ process.kill(row.processPid, "SIGTERM");
2608
+ killedPids.push(row.processPid);
2609
+ } catch {
2610
+ // ignore
2611
+ }
2612
+ }
2613
+ } catch {
2614
+ // ignore missing snapshot
2615
+ }
2616
+
2617
+ await this.persistSessionsSnapshot();
2618
+ await this.writeAudit({
2619
+ ts: nowIso(),
2620
+ event: "gc_run",
2621
+ details: {
2622
+ removed_workspaces: removedWorkspaces.length,
2623
+ killed_pids: killedPids.length,
2624
+ },
2625
+ });
2626
+
2627
+ return {
2628
+ removed_workspaces: removedWorkspaces,
2629
+ killed_pids: killedPids,
2630
+ };
2631
+ }
2632
+
2633
+ async auditQuery(filters?: {
2634
+ id?: string;
2635
+ event?: string;
2636
+ type?: "port" | "files";
2637
+ from_ts?: string;
2638
+ to_ts?: string;
2639
+ limit?: number;
2640
+ }): Promise<AuditEvent[]> {
2641
+ await this.ensureInitialized();
2642
+
2643
+ const limit = Math.max(1, Math.min(10_000, Math.trunc(filters?.limit ?? 500)));
2644
+ let raw = "";
2645
+ try {
2646
+ raw = await fs.readFile(this.auditFile, "utf8");
2647
+ } catch (error) {
2648
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
2649
+ return [];
2650
+ }
2651
+ throw error;
2652
+ }
2653
+
2654
+ const events = raw
2655
+ .split(/\r?\n/)
2656
+ .map((line) => line.trim())
2657
+ .filter(Boolean)
2658
+ .map((line) => {
2659
+ try {
2660
+ return JSON.parse(line) as AuditEvent;
2661
+ } catch {
2662
+ return null;
2663
+ }
2664
+ })
2665
+ .filter((event): event is AuditEvent => Boolean(event));
2666
+
2667
+ return events.filter((event) => matchAuditFilters(event, filters ?? {})).slice(-limit);
2668
+ }
2669
+
2670
+ async auditExport(range?: {
2671
+ from_ts?: string;
2672
+ to_ts?: string;
2673
+ id?: string;
2674
+ event?: string;
2675
+ type?: "port" | "files";
2676
+ output_path?: string;
2677
+ }): Promise<Record<string, unknown>> {
2678
+ await this.ensureInitialized();
2679
+
2680
+ const events = await this.auditQuery({
2681
+ id: range?.id,
2682
+ event: range?.event,
2683
+ type: range?.type,
2684
+ from_ts: range?.from_ts,
2685
+ to_ts: range?.to_ts,
2686
+ limit: 10_000,
2687
+ });
2688
+
2689
+ const outputPath = this.resolvePath(
2690
+ range?.output_path ?? path.join(this.exportsDir, `audit-${Date.now().toString(36)}.jsonl`),
2691
+ );
2692
+ await mkdirp(path.dirname(outputPath));
2693
+
2694
+ const data = events.map((event) => JSON.stringify(event)).join("\n");
2695
+ await fs.writeFile(outputPath, data ? `${data}\n` : "", "utf8");
2696
+
2697
+ await this.writeAudit({
2698
+ ts: nowIso(),
2699
+ event: "audit_exported",
2700
+ details: {
2701
+ output_path: outputPath,
2702
+ count: events.length,
2703
+ },
2704
+ });
2705
+
2706
+ return {
2707
+ ok: true,
2708
+ output_path: outputPath,
2709
+ count: events.length,
2710
+ };
2711
+ }
2712
+ }