dsh-mobile 0.8.3 → 0.8.5

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/lib/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve,
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { connect, createServer, isIP } from "node:net";
7
- import { chmod, copyFile, link, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { access, chmod, copyFile, link, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { performance as performance$1 } from "node:perf_hooks";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import { createSocket } from "node:dgram";
@@ -25,7 +25,7 @@ import { connect as connect$1 } from "node:tls";
25
25
  import { generate } from "selfsigned";
26
26
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
27
27
  import { lookup } from "node:dns/promises";
28
- import { constants } from "node:fs";
28
+ import { constants, readFileSync } from "node:fs";
29
29
  import { z as z$1 } from "zod";
30
30
  import webPush from "web-push";
31
31
  // src/access.ts
@@ -1036,10 +1036,17 @@ const Config = z.object({
1036
1036
  maxBodyBytes: z.natural(),
1037
1037
  upstreamTimeoutMs: z.natural(),
1038
1038
  upstreamApiTimeoutMs: z.natural(),
1039
+ catalogCache: z.boolean(),
1039
1040
  rateLimitWindowMs: z.natural(),
1040
1041
  maxPairingAttempts: z.natural(),
1041
1042
  maxRateLimitKeys: z.natural()
1042
1043
  });
1044
+ /** Resolve one optional boolean with an explicit default; reject other types. */
1045
+ function booleanValue(value, name, fallback) {
1046
+ if (value === void 0) return fallback;
1047
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
1048
+ return value;
1049
+ }
1043
1050
  function integer(value, name, fallback, minimum, maximum) {
1044
1051
  const resolved = value ?? fallback;
1045
1052
  if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`${name} must be an integer from ${String(minimum)} through ${String(maximum)}`);
@@ -1196,6 +1203,7 @@ function parseGatewayConfig(raw) {
1196
1203
  maxBodyBytes: integer(value.maxBodyBytes, "maxBodyBytes", 167772160, 1024, 268435456),
1197
1204
  upstreamTimeoutMs: integer(value.upstreamTimeoutMs, "upstreamTimeoutMs", 3e4, 1e3, 3e5),
1198
1205
  upstreamApiTimeoutMs: integer(value.upstreamApiTimeoutMs, "upstreamApiTimeoutMs", 0, 0, 2147483647),
1206
+ catalogCache: booleanValue(value.catalogCache, "catalogCache", true),
1199
1207
  rateLimitWindowMs: integer(value.rateLimitWindowMs, "rateLimitWindowMs", 6e4, 1e3, 36e5),
1200
1208
  maxPairingAttempts: integer(value.maxPairingAttempts, "maxPairingAttempts", 8, 1, 100),
1201
1209
  maxRateLimitKeys: integer(value.maxRateLimitKeys, "maxRateLimitKeys", 256, 1, 4096)
@@ -2469,6 +2477,305 @@ var ApiLatencyRecorder = class {
2469
2477
  }
2470
2478
  }
2471
2479
  };
2480
+ // src/catalog-cache.ts
2481
+ /** Host RPCs whose successful completion can change catalog results. */
2482
+ const CATALOG_MUTATION_METHODS = /* @__PURE__ */ new Set([
2483
+ "account/signOut",
2484
+ "account/startSignIn",
2485
+ "credentials/set",
2486
+ "credentials/unset",
2487
+ "pluginManager/installBundle",
2488
+ "pluginManager/removeBundle",
2489
+ "pluginManager/setBundleEnabled",
2490
+ "pluginManager/setPluginEnabled",
2491
+ "pluginManager/setVersionExemption",
2492
+ "pluginManager/updateBundle",
2493
+ "session/initializeDefaultModel",
2494
+ "session/selectModel",
2495
+ "settings/mutate",
2496
+ "settings/replace",
2497
+ "settings/update"
2498
+ ]);
2499
+ const CATALOG_CACHE_METHODS = /* @__PURE__ */ new Set(["llm/listProviders", "session/modelCatalog"]);
2500
+ const DEFAULT_CATALOG_CACHE_LIMITS = Object.freeze({
2501
+ freshMs: 5e3,
2502
+ staleMs: 6e4,
2503
+ maxEntries: 4,
2504
+ maxEntryBytes: 2097152,
2505
+ maxTotalBytes: 8388608,
2506
+ maxInFlightFills: 2
2507
+ });
2508
+ const REQUEST_KEYS = /* @__PURE__ */ new Set([
2509
+ "type",
2510
+ "rpcId",
2511
+ "method",
2512
+ "payload"
2513
+ ]);
2514
+ const RESPONSE_KEYS = /* @__PURE__ */ new Set([
2515
+ "type",
2516
+ "rpcId",
2517
+ "result"
2518
+ ]);
2519
+ const encoder = new TextEncoder();
2520
+ const isObject$1 = (value) => typeof value === "object" && value !== null;
2521
+ const hasExactKeys = (value, keys) => {
2522
+ const present = Object.keys(value);
2523
+ if (present.length !== keys.size) return false;
2524
+ for (const key of present) if (!keys.has(key)) return false;
2525
+ return true;
2526
+ };
2527
+ /**
2528
+ * Validate one catalog request against the captured wire contract.
2529
+ * @param routeMethod - The `/api/<method>` route segment.
2530
+ * @param bodyJson - The exact buffered request body.
2531
+ * @returns The eligible method and its live rpcId, or undefined for anything else.
2532
+ */
2533
+ function checkCatalogRequest(routeMethod, bodyJson) {
2534
+ if (!CATALOG_CACHE_METHODS.has(routeMethod)) return void 0;
2535
+ let parsed;
2536
+ try {
2537
+ parsed = JSON.parse(bodyJson);
2538
+ } catch {
2539
+ return;
2540
+ }
2541
+ if (!isObject$1(parsed) || !hasExactKeys(parsed, REQUEST_KEYS)) return void 0;
2542
+ if (parsed.type !== "client-request") return void 0;
2543
+ if (parsed.method !== routeMethod) return void 0;
2544
+ if (typeof parsed.rpcId !== "string" || parsed.rpcId.length === 0 || parsed.rpcId.length > 512) return void 0;
2545
+ const payload = parsed.payload;
2546
+ if (!isObject$1(payload) || !hasExactKeys(payload, /* @__PURE__ */ new Set(["args"]))) return void 0;
2547
+ const args = payload.args;
2548
+ if (!isObject$1(args) || Array.isArray(args) || Object.keys(args).length !== 0) return void 0;
2549
+ return {
2550
+ method: routeMethod,
2551
+ rpcId: parsed.rpcId
2552
+ };
2553
+ }
2554
+ /**
2555
+ * Validate a fill response against the captured success schema.
2556
+ * @param envelopeJson - The buffered upstream response body.
2557
+ * @param expectedRpcId - The gateway-generated rpcId the fill sent.
2558
+ * @returns Whether the envelope is the shareable success variant for that id.
2559
+ */
2560
+ function validateCatalogEnvelope(envelopeJson, expectedRpcId) {
2561
+ let parsed;
2562
+ try {
2563
+ parsed = JSON.parse(envelopeJson);
2564
+ } catch {
2565
+ return false;
2566
+ }
2567
+ if (!isObject$1(parsed) || !hasExactKeys(parsed, RESPONSE_KEYS)) return false;
2568
+ if (parsed.type !== "server-response") return false;
2569
+ if (parsed.rpcId !== expectedRpcId) return false;
2570
+ const result = parsed.result;
2571
+ if (!isObject$1(result)) return false;
2572
+ return result.ok === true && "value" in result;
2573
+ }
2574
+ /**
2575
+ * Re-stamp a validated envelope for one caller.
2576
+ * @param envelopeJson - The cached upstream envelope.
2577
+ * @param expectedRpcId - The fill rpcId the envelope must carry.
2578
+ * @param liveRpcId - The caller's rpcId to serialize into the response.
2579
+ * @returns The per-caller JSON body, or undefined when validation fails.
2580
+ */
2581
+ function restampEnvelope(envelopeJson, expectedRpcId, liveRpcId) {
2582
+ let parsed;
2583
+ try {
2584
+ parsed = JSON.parse(envelopeJson);
2585
+ } catch {
2586
+ return;
2587
+ }
2588
+ if (!validateCatalogEnvelope(envelopeJson, expectedRpcId)) return void 0;
2589
+ const body = parsed;
2590
+ body.rpcId = liveRpcId;
2591
+ return JSON.stringify(body);
2592
+ }
2593
+ /**
2594
+ * Generation-aware SWR cache over the injected fill transport.
2595
+ * @typeParam TUpstreamSnapshot - Opaque immutable upstream identity snapshot the gateway captures per request.
2596
+ */
2597
+ var CatalogCache = class {
2598
+ #limits;
2599
+ #clock;
2600
+ #fetchFill;
2601
+ #entries = /* @__PURE__ */ new Map();
2602
+ #flights = /* @__PURE__ */ new Map();
2603
+ #identityDigest;
2604
+ #epoch = 0;
2605
+ #disposed = false;
2606
+ constructor(deps) {
2607
+ this.#fetchFill = deps.fetchFill;
2608
+ this.#clock = deps.clock;
2609
+ this.#limits = {
2610
+ ...DEFAULT_CATALOG_CACHE_LIMITS,
2611
+ ...deps.limits
2612
+ };
2613
+ }
2614
+ get entryCount() {
2615
+ return this.#entries.size;
2616
+ }
2617
+ get residentBytes() {
2618
+ let total = 0;
2619
+ for (const entry of this.#entries.values()) total += entry.bytes;
2620
+ return total;
2621
+ }
2622
+ get inFlightFills() {
2623
+ let active = 0;
2624
+ for (const flight of this.#flights.values()) if (!flight.settled) active += 1;
2625
+ return active;
2626
+ }
2627
+ /**
2628
+ * Serve one eligible request from the current generation.
2629
+ * @param input - Method, caller rpcId, current identity digest and its snapshot.
2630
+ * @returns A per-caller replay, or passthrough when the ordinary path must run.
2631
+ */
2632
+ async serve(input) {
2633
+ if (this.#disposed) return { kind: "passthrough" };
2634
+ if (!CATALOG_CACHE_METHODS.has(input.method)) return { kind: "passthrough" };
2635
+ const method = input.method;
2636
+ this.#syncIdentity(input.identityDigest);
2637
+ const generation = this.#currentGeneration();
2638
+ const key = `${input.identityDigest}\u0000${method}`;
2639
+ const entry = this.#entries.get(key);
2640
+ if (entry !== void 0) {
2641
+ const age = this.#clock() - entry.completedAt;
2642
+ if (age < this.#limits.freshMs) {
2643
+ entry.lastServedAt = this.#clock();
2644
+ const body = restampEnvelope(entry.envelopeJson, entry.upstreamRpcId, input.rpcId);
2645
+ if (body !== void 0) return {
2646
+ kind: "hit",
2647
+ body,
2648
+ generation,
2649
+ validUntil: entry.validUntil
2650
+ };
2651
+ } else if (age < this.#limits.staleMs) {
2652
+ entry.lastServedAt = this.#clock();
2653
+ const body = restampEnvelope(entry.envelopeJson, entry.upstreamRpcId, input.rpcId);
2654
+ if (body !== void 0) {
2655
+ this.#startBackgroundFill(method, input.identityDigest, input.upstream);
2656
+ return {
2657
+ kind: "stale",
2658
+ body,
2659
+ generation,
2660
+ validUntil: entry.validUntil
2661
+ };
2662
+ }
2663
+ }
2664
+ }
2665
+ const flightKey = `${input.identityDigest}|${generation.epoch}|${method}`;
2666
+ const existing = this.#flights.get(flightKey);
2667
+ if (existing === void 0 && this.inFlightFills >= this.#limits.maxInFlightFills) return { kind: "passthrough" };
2668
+ const outcome = await (existing ?? this.#createFlight(method, generation, input.upstream)).promise;
2669
+ if (!outcome.shareable) return { kind: "passthrough" };
2670
+ if (!this.#isCurrent(generation)) return { kind: "passthrough" };
2671
+ if (!validateCatalogEnvelope(outcome.envelopeJson, outcome.upstreamRpcId)) return { kind: "passthrough" };
2672
+ const body = restampEnvelope(outcome.envelopeJson, outcome.upstreamRpcId, input.rpcId);
2673
+ if (body === void 0) return { kind: "passthrough" };
2674
+ return {
2675
+ kind: "replay",
2676
+ body,
2677
+ generation,
2678
+ validUntil: this.#clock() + this.#limits.staleMs
2679
+ };
2680
+ }
2681
+ /** Record the current upstream identity; a change clears entries and bumps the epoch. */
2682
+ noteIdentity(identityDigest) {
2683
+ this.#syncIdentity(identityDigest);
2684
+ }
2685
+ /** Invalidate every entry after a catalog-affecting mutation. */
2686
+ noteCatalogMutation() {
2687
+ this.#epoch += 1;
2688
+ this.#entries.clear();
2689
+ }
2690
+ /** Whether a generation is still the current one. */
2691
+ isCurrent(generation) {
2692
+ return this.#isCurrent(generation);
2693
+ }
2694
+ /** Final pre-commit check for a replay result: generation, disposal and age. */
2695
+ canCommit(result) {
2696
+ if (this.#disposed) return false;
2697
+ if (!this.#isCurrent(result.generation)) return false;
2698
+ return this.#clock() < result.validUntil;
2699
+ }
2700
+ /** Invalidate everything and refuse new fills or publication. */
2701
+ dispose() {
2702
+ this.#disposed = true;
2703
+ this.#entries.clear();
2704
+ }
2705
+ #isCurrent(generation) {
2706
+ if (this.#disposed) return false;
2707
+ return generation.identityDigest === this.#identityDigest && generation.epoch === this.#epoch;
2708
+ }
2709
+ #currentGeneration() {
2710
+ return {
2711
+ identityDigest: this.#identityDigest,
2712
+ epoch: this.#epoch
2713
+ };
2714
+ }
2715
+ #syncIdentity(identityDigest) {
2716
+ if (identityDigest === this.#identityDigest) return;
2717
+ this.#identityDigest = identityDigest;
2718
+ this.#epoch += 1;
2719
+ this.#entries.clear();
2720
+ }
2721
+ #createFlight(method, generation, upstream) {
2722
+ const flightKey = `${generation.identityDigest}|${generation.epoch}|${method}`;
2723
+ const invoke = async () => this.#fetchFill({
2724
+ method,
2725
+ generation,
2726
+ upstream
2727
+ });
2728
+ const flight = {
2729
+ generation,
2730
+ method,
2731
+ settled: false,
2732
+ promise: invoke().catch(() => ({ shareable: false })).then((outcome) => {
2733
+ flight.settled = true;
2734
+ if (this.#flights.get(flightKey) === flight) this.#flights.delete(flightKey);
2735
+ if (outcome.shareable && this.#isCurrent(generation)) this.#retain(method, generation, outcome);
2736
+ return outcome;
2737
+ })
2738
+ };
2739
+ this.#flights.set(flightKey, flight);
2740
+ return flight;
2741
+ }
2742
+ #startBackgroundFill(method, identityDigest, upstream) {
2743
+ if (this.#disposed) return;
2744
+ const generation = this.#currentGeneration();
2745
+ const flightKey = `${identityDigest}|${generation.epoch}|${method}`;
2746
+ if (this.#flights.has(flightKey)) return;
2747
+ if (this.inFlightFills >= this.#limits.maxInFlightFills) return;
2748
+ this.#createFlight(method, generation, upstream).promise.catch(() => void 0);
2749
+ }
2750
+ #retain(method, generation, outcome) {
2751
+ if (!validateCatalogEnvelope(outcome.envelopeJson, outcome.upstreamRpcId)) return;
2752
+ const bytes = encoder.encode(outcome.envelopeJson).length;
2753
+ if (bytes > this.#limits.maxEntryBytes) return;
2754
+ const key = `${generation.identityDigest}\u0000${method}`;
2755
+ const now = this.#clock();
2756
+ if (this.#entries.has(key)) this.#entries.delete(key);
2757
+ const evict = () => {
2758
+ const candidates = [...this.#entries.entries()].sort(([, a], [, b]) => a.lastServedAt - b.lastServedAt);
2759
+ for (const [candidateKey] of candidates) {
2760
+ if (this.#entries.size < this.#limits.maxEntries && this.residentBytes + bytes <= this.#limits.maxTotalBytes) break;
2761
+ this.#entries.delete(candidateKey);
2762
+ }
2763
+ };
2764
+ evict();
2765
+ if (this.#entries.size >= this.#limits.maxEntries) return;
2766
+ if (this.residentBytes + bytes > this.#limits.maxTotalBytes) return;
2767
+ this.#entries.set(key, {
2768
+ identityDigest: generation.identityDigest,
2769
+ method,
2770
+ envelopeJson: outcome.envelopeJson,
2771
+ upstreamRpcId: outcome.upstreamRpcId,
2772
+ completedAt: now,
2773
+ validUntil: now + this.#limits.staleMs,
2774
+ bytes,
2775
+ lastServedAt: now
2776
+ });
2777
+ }
2778
+ };
2472
2779
  // src/upstream-auth-log.ts
2473
2780
  const LOG_INTERVAL_MS = 3e4;
2474
2781
  /** Two fixed sampling slots keep a first failure visible even after a successful exchange. */
@@ -5387,6 +5694,9 @@ const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
5387
5694
  const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
5388
5695
  const MOBILE_BOOT_BATCH_PREFIX = `${AUTH_PREFIX}/mobile-boot/`;
5389
5696
  const MOBILE_DOCUMENT_VIEW_HEADER = "x-dsh-mobile-view";
5697
+ const CATALOG_CACHE_MARKER = "x-dsh-mobile-catalog";
5698
+ /** Eligible catalog envelopes are small fixed-shape JSON; larger declared bodies keep the streaming path. */
5699
+ const CATALOG_CANDIDATE_BODY_LIMIT = 65536;
5390
5700
  const MOBILE_PUSH_CONFIG_PATH = `${AUTH_PREFIX}/push/config`;
5391
5701
  const MOBILE_PUSH_SUBSCRIPTION_PATH = `${AUTH_PREFIX}/push/subscription`;
5392
5702
  const MOBILE_PUSH_PREFERENCES_PATH = `${AUTH_PREFIX}/push/preferences`;
@@ -5869,7 +6179,7 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
5869
6179
  };
5870
6180
  for (const entry of entries) {
5871
6181
  const size = sizes.get(entry.url);
5872
- if ((passThrough.has(entry.url) || size !== void 0 && size >= MAX_MOBILE_BOOT_ENTRY_BYTES) && entry.id !== MOBILE_LAYOUT_MODULE) {
6182
+ if ((passThrough.has(entry.url) || size !== void 0 && (size <= 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES)) && entry.id !== MOBILE_LAYOUT_MODULE) {
5873
6183
  rows.push({
5874
6184
  phase: "application",
5875
6185
  url: entry.url,
@@ -5878,7 +6188,7 @@ function splitMobileBootBatch(entries, sizes, passThrough) {
5878
6188
  });
5879
6189
  continue;
5880
6190
  }
5881
- const bytes = (size ?? 0) + MOBILE_BOOT_SEPARATOR_BYTES;
6191
+ const bytes = (size !== void 0 && size > 0 ? size : 0) + MOBILE_BOOT_SEPARATOR_BYTES;
5882
6192
  if (chunk !== void 0 && chunk.entries.length > 0 && chunk.bytes + bytes > MOBILE_BOOT_CHUNK_BYTES) flush();
5883
6193
  chunk ??= {
5884
6194
  ids: [],
@@ -6482,6 +6792,18 @@ var MobileAccessGateway = class {
6482
6792
  upstreamCookie;
6483
6793
  upstreamCookieExpiresAt = 0;
6484
6794
  upstreamCookieTask;
6795
+ #catalogCache;
6796
+ #upstreamIdentityRevision = 0;
6797
+ #catalogFills = /* @__PURE__ */ new Set();
6798
+ /** The catalog SWR cache, or undefined when `catalogCache` is disabled. */
6799
+ get catalogCache() {
6800
+ if (!this.config.catalogCache) return void 0;
6801
+ this.#catalogCache ??= new CatalogCache({
6802
+ fetchFill: (input) => this.catalogFill(input),
6803
+ clock: () => performance.now()
6804
+ });
6805
+ return this.#catalogCache;
6806
+ }
6485
6807
  upstreamAuthRequest;
6486
6808
  nextOperationId = 1;
6487
6809
  closing = false;
@@ -7529,6 +7851,21 @@ var MobileAccessGateway = class {
7529
7851
  return;
7530
7852
  }
7531
7853
  if (stockFrontend && target.decodedPathname === "/") request.url = "/";
7854
+ const candidateContentType = String(request.headers["content-type"] ?? "").split(";", 1)[0]?.trim().toLowerCase();
7855
+ const candidateDeclaredLength = request.headers["content-length"] === void 0 ? void 0 : Number(request.headers["content-length"]);
7856
+ const catalogRouteMethod = this.config.catalogCache && documentView === void 0 && request.method === "POST" && target.search === "" && candidateContentType === "application/json" && candidateDeclaredLength !== void 0 && Number.isFinite(candidateDeclaredLength) && candidateDeclaredLength > 0 && candidateDeclaredLength <= CATALOG_CANDIDATE_BODY_LIMIT && (target.decodedPathname === "/api/llm/listProviders" || target.decodedPathname === "/api/session/modelCatalog") ? target.decodedPathname.slice(5) : void 0;
7857
+ if (catalogRouteMethod !== void 0) {
7858
+ const candidateBody = await this.readRequestBody(request, Math.min(CATALOG_CANDIDATE_BODY_LIMIT, this.config.maxBodyBytes));
7859
+ const eligible = checkCatalogRequest(catalogRouteMethod, candidateBody.toString("utf8"));
7860
+ if (eligible !== void 0 && this.catalogCache !== void 0) {
7861
+ if (await this.serveCatalogCache(eligible, request, response, authorization)) return;
7862
+ response.setHeader(CATALOG_CACHE_MARKER, "miss-passthrough");
7863
+ await this.proxyHttp(request, response, authorization, documentView, candidateBody);
7864
+ return;
7865
+ }
7866
+ await this.proxyHttp(request, response, authorization, documentView, candidateBody);
7867
+ return;
7868
+ }
7532
7869
  await this.proxyHttp(request, response, authorization, documentView);
7533
7870
  }
7534
7871
  async handleExtensionRequest(targetInfo, target, request, response, authorization) {
@@ -7823,6 +8160,8 @@ var MobileAccessGateway = class {
7823
8160
  if (pair === void 0 || pair.length > 4096 || !UPSTREAM_COOKIE_PAIR.test(pair) || !Number.isSafeInteger(expiresAt) || maxAgeSeconds <= 0) throw new HttpError(502, "upstream_unavailable");
7824
8161
  this.upstreamCookie = pair;
7825
8162
  this.upstreamCookieExpiresAt = expiresAt;
8163
+ this.#upstreamIdentityRevision += 1;
8164
+ this.catalogCache?.noteIdentity(createHash("sha256").update(pair).digest("hex"));
7826
8165
  this.upstreamAuthDiagnostics.record(attempt, {
7827
8166
  outcome: "accepted",
7828
8167
  stage,
@@ -8244,7 +8583,7 @@ var MobileAccessGateway = class {
8244
8583
  }
8245
8584
  }
8246
8585
  operation.signal.throwIfAborted();
8247
- const headers = sanitizeResponseHeaders(index.headers, this.config.upstreamOrigin);
8586
+ const headers = sanitizeResponseHeaders(index.headers, new URL(request.url ?? "/", this.config.upstreamOrigin));
8248
8587
  delete headers["content-length"];
8249
8588
  delete headers["content-encoding"];
8250
8589
  delete headers.etag;
@@ -8346,7 +8685,7 @@ var MobileAccessGateway = class {
8346
8685
  const index = cursor++;
8347
8686
  const source = candidates[index].url;
8348
8687
  const size = await this.upstreamBundleSize(source, signal);
8349
- if (size === void 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
8688
+ if (size === void 0 || size <= 0 || size >= MAX_MOBILE_BOOT_ENTRY_BYTES) passThrough.add(source);
8350
8689
  else sizes.set(source, size);
8351
8690
  }
8352
8691
  };
@@ -8650,13 +8989,181 @@ var MobileAccessGateway = class {
8650
8989
  }
8651
8990
  };
8652
8991
  }
8992
+ /** Read one bounded request body verbatim; the caller owns replaying it upstream. */
8993
+ readRequestBody(request, limitBytes) {
8994
+ const declared = request.headers["content-length"];
8995
+ if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > limitBytes)) throw new HttpError(413, "payload_too_large");
8996
+ return new Promise((resolve, reject) => {
8997
+ const chunks = [];
8998
+ let received = 0;
8999
+ let failed = false;
9000
+ request.on("data", (chunk) => {
9001
+ if (failed) return;
9002
+ received += chunk.length;
9003
+ if (received > limitBytes) {
9004
+ failed = true;
9005
+ reject(new HttpError(413, "payload_too_large"));
9006
+ request.resume();
9007
+ return;
9008
+ }
9009
+ chunks.push(chunk);
9010
+ });
9011
+ request.once("end", () => {
9012
+ if (!failed) resolve(Buffer.concat(chunks));
9013
+ });
9014
+ request.once("error", (error) => {
9015
+ if (!failed) reject(error);
9016
+ });
9017
+ });
9018
+ }
9019
+ /** One gateway-owned catalog fill: its own socket, rpcId, deadline and byte cap. */
9020
+ async catalogFill(input) {
9021
+ const rpcId = `dsh-mobile-catalog-${randomUUID()}`;
9022
+ const body = JSON.stringify({
9023
+ type: "client-request",
9024
+ rpcId,
9025
+ method: input.method,
9026
+ payload: { args: {} }
9027
+ });
9028
+ return await new Promise((resolve) => {
9029
+ let settled = false;
9030
+ let upstreamRequest;
9031
+ const finish = (outcome) => {
9032
+ if (settled) return;
9033
+ settled = true;
9034
+ clearTimeout(absoluteDeadline);
9035
+ this.#catalogFills.delete(upstreamRequest);
9036
+ resolve(outcome);
9037
+ };
9038
+ upstreamRequest = request({
9039
+ protocol: "http:",
9040
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
9041
+ port: Number(this.config.upstreamOrigin.port),
9042
+ method: "POST",
9043
+ path: `/api/${input.method}`,
9044
+ headers: {
9045
+ host: this.config.upstreamOrigin.host,
9046
+ "content-type": "application/json",
9047
+ accept: "application/json",
9048
+ "accept-encoding": "identity",
9049
+ "content-length": String(Buffer.byteLength(body)),
9050
+ ...input.upstream.cookieHeader === void 0 ? {} : { cookie: input.upstream.cookieHeader }
9051
+ },
9052
+ agent: false
9053
+ }, (response) => {
9054
+ const status = response.statusCode ?? 0;
9055
+ const rawContentType = String(response.headers["content-type"] ?? "");
9056
+ const mediaType = rawContentType.split(";", 1)[0]?.trim().toLowerCase();
9057
+ const charset = /charset\s*=\s*([^;]+)/iu.exec(rawContentType)?.[1]?.trim().toLowerCase();
9058
+ if (status !== 200 || mediaType !== "application/json" || charset !== void 0 && charset !== "utf-8" && charset !== "utf8" || response.headers["set-cookie"] !== void 0 || response.headers["content-encoding"] !== void 0 && response.headers["content-encoding"] !== "identity") {
9059
+ response.destroy();
9060
+ finish({ shareable: false });
9061
+ return;
9062
+ }
9063
+ const chunks = [];
9064
+ let received = 0;
9065
+ let overflowed = false;
9066
+ response.on("data", (chunk) => {
9067
+ if (overflowed) return;
9068
+ received += chunk.length;
9069
+ if (received > DEFAULT_CATALOG_CACHE_LIMITS.maxEntryBytes) {
9070
+ overflowed = true;
9071
+ response.destroy();
9072
+ return;
9073
+ }
9074
+ chunks.push(chunk);
9075
+ });
9076
+ response.once("end", () => {
9077
+ if (overflowed) {
9078
+ finish({ shareable: false });
9079
+ return;
9080
+ }
9081
+ try {
9082
+ const envelopeJson = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
9083
+ JSON.parse(envelopeJson);
9084
+ finish({
9085
+ shareable: true,
9086
+ envelopeJson,
9087
+ upstreamRpcId: rpcId
9088
+ });
9089
+ } catch {
9090
+ finish({ shareable: false });
9091
+ }
9092
+ });
9093
+ response.once("error", () => finish({ shareable: false }));
9094
+ response.once("close", () => finish({ shareable: false }));
9095
+ });
9096
+ const absoluteDeadline = setTimeout(() => {
9097
+ upstreamRequest.destroy(/* @__PURE__ */ new Error("catalog fill deadline"));
9098
+ }, this.config.upstreamTimeoutMs);
9099
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
9100
+ upstreamRequest.destroy(/* @__PURE__ */ new Error("catalog fill idle timeout"));
9101
+ });
9102
+ upstreamRequest.once("error", () => finish({ shareable: false }));
9103
+ this.#catalogFills.add(upstreamRequest);
9104
+ upstreamRequest.end(body);
9105
+ });
9106
+ }
9107
+ /**
9108
+ * Serve one eligible catalog request from the SWR cache.
9109
+ * @returns true when the response was committed here; false for the ordinary path.
9110
+ */
9111
+ async serveCatalogCache(eligible, request, response, authorization) {
9112
+ const cache = this.catalogCache;
9113
+ if (cache === void 0) return false;
9114
+ const operation = this.allocateRequest(authorization, response, {});
9115
+ const onClose = () => {
9116
+ operation.abort();
9117
+ operation.release();
9118
+ };
9119
+ response.once("close", onClose);
9120
+ try {
9121
+ const snapshotRevision = this.#upstreamIdentityRevision;
9122
+ const hadIdentityBefore = this.upstreamCookie !== void 0;
9123
+ const cookieHeader = await waitForRequestTask(this.upstreamCookieHeader(), operation.signal);
9124
+ operation.signal.throwIfAborted();
9125
+ const superseded = hadIdentityBefore && snapshotRevision !== this.#upstreamIdentityRevision;
9126
+ if (response.destroyed || superseded) return false;
9127
+ const identityDigest = createHash("sha256").update(cookieHeader ?? "none").digest("hex");
9128
+ cache.noteIdentity(identityDigest);
9129
+ const result = await cache.serve({
9130
+ method: eligible.method,
9131
+ rpcId: eligible.rpcId,
9132
+ identityDigest,
9133
+ upstream: { cookieHeader }
9134
+ });
9135
+ operation.signal.throwIfAborted();
9136
+ if (result.kind === "passthrough" || !cache.canCommit(result) || response.destroyed) return false;
9137
+ const payload = Buffer.from(result.body, "utf8");
9138
+ const headers = {
9139
+ "content-type": "application/json",
9140
+ "cache-control": "no-store",
9141
+ [CATALOG_CACHE_MARKER]: result.kind
9142
+ };
9143
+ let body = payload;
9144
+ if (acceptsGzip(request.headers["accept-encoding"]) && payload.length >= MIN_COMPRESSIBLE_BYTES) {
9145
+ body = await gzipBuffer(payload);
9146
+ headers["content-encoding"] = "gzip";
9147
+ addVaryAcceptEncoding(headers);
9148
+ }
9149
+ if (!cache.canCommit(result) || response.destroyed) return false;
9150
+ headers["content-length"] = body.length;
9151
+ setSecurityHeaders(response, this.tlsEnabled, "proxied");
9152
+ response.writeHead(200, headers);
9153
+ response.end(body);
9154
+ return true;
9155
+ } finally {
9156
+ response.removeListener("close", onClose);
9157
+ operation.release();
9158
+ }
9159
+ }
8653
9160
  /**
8654
9161
  * Proxy one request, retrying transient failures for plugin and asset GETs.
8655
9162
  * Only bodyless GETs can be replayed, before response headers reach the client.
8656
9163
  * One request slot and cancellation signal cover attempts and backoff together.
8657
9164
  * API requests and requests with bodies are forwarded once.
8658
9165
  */
8659
- async proxyHttp(request, response, authorization, documentView) {
9166
+ async proxyHttp(request, response, authorization, documentView, bodyOverride) {
8660
9167
  const declared = request.headers["content-length"];
8661
9168
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
8662
9169
  const replayable = request.method === "GET" && request.headers["transfer-encoding"] === void 0 && (declared === void 0 || Number(declared) === 0) && (request.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request.url?.split("?", 1)[0]?.startsWith("/assets/") === true);
@@ -8684,7 +9191,7 @@ var MobileAccessGateway = class {
8684
9191
  for (let attempt = 1; attempt <= attempts; attempt++) {
8685
9192
  operation.signal.throwIfAborted();
8686
9193
  try {
8687
- await this.proxyHttpOnce(request, response, holder, operation.signal, replayable, documentView);
9194
+ await this.proxyHttpOnce(request, response, holder, operation.signal, replayable, documentView, bodyOverride);
8688
9195
  return;
8689
9196
  } catch (error) {
8690
9197
  if (operation.signal.aborted || response.destroyed) return;
@@ -8705,7 +9212,7 @@ var MobileAccessGateway = class {
8705
9212
  operation.release();
8706
9213
  }
8707
9214
  }
8708
- async proxyHttpOnce(request$2, response, holder, signal, replayable, documentView) {
9215
+ async proxyHttpOnce(request$2, response, holder, signal, replayable, documentView, bodyOverride) {
8709
9216
  let bodyDone;
8710
9217
  const projections = /* @__PURE__ */ new Set();
8711
9218
  try {
@@ -8736,7 +9243,8 @@ var MobileAccessGateway = class {
8736
9243
  });
8737
9244
  upstreamRequest.once("response", resolve);
8738
9245
  upstreamRequest.once("error", reject);
8739
- if (replayable) upstreamRequest.end();
9246
+ if (bodyOverride !== void 0) upstreamRequest.end(bodyOverride);
9247
+ else if (replayable) upstreamRequest.end();
8740
9248
  else {
8741
9249
  bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
8742
9250
  bodyDone.catch(reject);
@@ -8749,7 +9257,7 @@ var MobileAccessGateway = class {
8749
9257
  }
8750
9258
  if (graphEvents) holder.request?.setTimeout(this.config.upstreamApiTimeoutMs);
8751
9259
  setSecurityHeaders(response, this.tlsEnabled, "proxied");
8752
- const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
9260
+ const headers = sanitizeResponseHeaders(proxied.headers, new URL(request$2.url ?? "/", this.config.upstreamOrigin));
8753
9261
  if (graphEvents) {
8754
9262
  if (proxied.headers["content-encoding"] !== void 0 && proxied.headers["content-encoding"] !== "identity") throw new HttpError(502, "upstream_unavailable");
8755
9263
  delete headers["content-length"];
@@ -8760,6 +9268,7 @@ var MobileAccessGateway = class {
8760
9268
  delete headers["accept-ranges"];
8761
9269
  }
8762
9270
  const statusCode = proxied.statusCode ?? 502;
9271
+ if (statusCode >= 200 && statusCode < 300 && this.catalogCache !== void 0 && pathname.startsWith("/api/") && CATALOG_MUTATION_METHODS.has(pathname.slice(5))) this.catalogCache.noteCatalogMutation();
8763
9272
  this.httpFailures.recordUpstream(request$2.method, request$2.url?.split("?", 1)[0] ?? "", statusCode);
8764
9273
  const cacheControl = graphEvents ? void 0 : revisionedStaticCacheControl(request$2, statusCode);
8765
9274
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
@@ -9186,6 +9695,9 @@ var MobileAccessGateway = class {
9186
9695
  }
9187
9696
  async performClose() {
9188
9697
  this.closing = true;
9698
+ this.#catalogCache?.dispose();
9699
+ for (const fill of this.#catalogFills) fill.destroy(/* @__PURE__ */ new Error("gateway closing"));
9700
+ this.#catalogFills.clear();
9189
9701
  const detachPushOrigin = this.detachPushOrigin;
9190
9702
  this.detachPushOrigin = void 0;
9191
9703
  detachPushOrigin?.();
@@ -11907,7 +12419,7 @@ async function saveManagedSetup(setupFile, setup) {
11907
12419
  }
11908
12420
  function privateIpv4(value) {
11909
12421
  const parts = value.split(".").map(Number);
11910
- return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
12422
+ return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
11911
12423
  }
11912
12424
  function networkCidr(address, cidr) {
11913
12425
  const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
@@ -15429,11 +15941,23 @@ const PAIRING_DESCRIPTOR_LOCAL_PORT = DEFAULT_IROH_LOCAL_PORT;
15429
15941
  const READY_TIMEOUT_MS = 45e3;
15430
15942
  const IDENTITY_TIMEOUT_MS = 15e3;
15431
15943
  const IDENTITY_METADATA_MAX_BYTES = 8192;
15944
+ const IDENTITY_DIAGNOSTIC_MAX_BYTES = 4096;
15432
15945
  const PAIRING_TIMEOUT_MS = 15e3;
15433
15946
  const MAX_LINE_BYTES = 16384;
15434
15947
  /** Gateway pairing windows default to five minutes; the descriptor caps at ten. */
15435
15948
  const MAX_PAIRING_WINDOW_MS = 3e5;
15436
15949
  const MIN_PAIRING_WINDOW_MS = 1e4;
15950
+ /** Safe classified failure from the short-lived endpoint identity probe. */
15951
+ var IrohIdentityProbeError = class extends Error {
15952
+ code;
15953
+ diagnostic;
15954
+ constructor(code, diagnostic) {
15955
+ super(code);
15956
+ this.code = code;
15957
+ this.diagnostic = diagnostic;
15958
+ this.name = "IrohIdentityProbeError";
15959
+ }
15960
+ };
15437
15961
  function publicStatus$2(status) {
15438
15962
  return Object.freeze({ ...status });
15439
15963
  }
@@ -15465,16 +15989,21 @@ async function defaultResolveIdentity(executable, dataDir, options = {}) {
15465
15989
  }) : options.launch(executable, args);
15466
15990
  options.onSpawn?.(child);
15467
15991
  let output = Buffer.alloc(0);
15992
+ let stderrOutput = Buffer.alloc(0);
15468
15993
  let settled = false;
15994
+ const diagnostic = () => {
15995
+ const text = stderrOutput.toString("utf8").replaceAll(dataDir, "<data-dir>").trim().replace(/\s+/gu, " ");
15996
+ return text === "" ? void 0 : text.slice(0, IDENTITY_DIAGNOSTIC_MAX_BYTES);
15997
+ };
15469
15998
  return await new Promise((resolve, reject) => {
15470
15999
  const fail = (code) => {
15471
16000
  if (settled) return;
15472
16001
  settled = true;
15473
16002
  clearTimeout(deadline);
15474
16003
  terminateRemoteProcess(child).then(() => {
15475
- reject(new Error(code));
16004
+ reject(new IrohIdentityProbeError(code, diagnostic()));
15476
16005
  }, () => {
15477
- reject(new AggregateError([new Error(code), /* @__PURE__ */ new Error("iroh_probe_stop_failed")], "iroh_identity_cleanup_failed"));
16006
+ reject(new AggregateError([new IrohIdentityProbeError(code, diagnostic()), /* @__PURE__ */ new Error("iroh_probe_stop_failed")], "iroh_identity_cleanup_failed"));
15478
16007
  });
15479
16008
  };
15480
16009
  const deadline = setTimeout(() => {
@@ -15489,7 +16018,10 @@ async function defaultResolveIdentity(executable, dataDir, options = {}) {
15489
16018
  }
15490
16019
  output = Buffer.concat([output, chunk]);
15491
16020
  });
15492
- child.stderr?.resume();
16021
+ child.stderr?.on("data", (chunk) => {
16022
+ const remaining = IDENTITY_DIAGNOSTIC_MAX_BYTES - stderrOutput.length;
16023
+ if (remaining > 0) stderrOutput = Buffer.concat([stderrOutput, chunk.subarray(0, remaining)]);
16024
+ });
15493
16025
  child.once("error", () => {
15494
16026
  fail("iroh_identity_failed");
15495
16027
  });
@@ -15497,14 +16029,19 @@ async function defaultResolveIdentity(executable, dataDir, options = {}) {
15497
16029
  clearTimeout(deadline);
15498
16030
  if (settled) return;
15499
16031
  settled = true;
16032
+ const probeDiagnostic = diagnostic();
16033
+ if (code !== 0 && probeDiagnostic !== void 0 && /private (?:Windows )?data directory is not secure|private path ACL grants/iu.test(probeDiagnostic)) {
16034
+ reject(new IrohIdentityProbeError("iroh_data_directory_insecure", probeDiagnostic));
16035
+ return;
16036
+ }
15500
16037
  let metadata;
15501
16038
  try {
15502
16039
  metadata = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(output));
15503
16040
  } catch {
15504
- reject(/* @__PURE__ */ new Error("iroh_identity_invalid"));
16041
+ reject(new IrohIdentityProbeError("iroh_identity_invalid", probeDiagnostic));
15505
16042
  return;
15506
16043
  }
15507
- if (code !== 0 || typeof metadata !== "object" || metadata === null || !("version" in metadata) || metadata.version !== 1 || !("endpointId" in metadata) || typeof metadata.endpointId !== "string" || !/^[0-9a-f]{64}$/u.test(metadata.endpointId)) reject(/* @__PURE__ */ new Error("iroh_identity_invalid"));
16044
+ if (code !== 0 || typeof metadata !== "object" || metadata === null || !("version" in metadata) || metadata.version !== 1 || !("endpointId" in metadata) || typeof metadata.endpointId !== "string" || !/^[0-9a-f]{64}$/u.test(metadata.endpointId)) reject(new IrohIdentityProbeError("iroh_identity_invalid", probeDiagnostic));
15508
16045
  else resolve(metadata.endpointId);
15509
16046
  });
15510
16047
  });
@@ -15691,9 +16228,15 @@ var IrohController = class {
15691
16228
  this.options.onStatus?.(this.status());
15692
16229
  } catch {}
15693
16230
  }
15694
- async failGeneration(generation, errorCode, origin) {
16231
+ async failGeneration(generation, errorCode, origin, diagnostic) {
15695
16232
  if (generation !== this.generation) return;
15696
16233
  this.generation += 1;
16234
+ try {
16235
+ this.options.onFailure?.({
16236
+ errorCode,
16237
+ ...diagnostic === void 0 ? {} : { diagnostic }
16238
+ });
16239
+ } catch {}
15697
16240
  this.publish({
15698
16241
  enabled: this.enabled,
15699
16242
  state: "error",
@@ -15778,7 +16321,9 @@ var IrohController = class {
15778
16321
  });
15779
16322
  } }) : await this.options.resolveIdentity(executable, this.options.dataDirectory);
15780
16323
  } catch (error) {
15781
- await this.failGeneration(generation, error instanceof AggregateError ? "iroh_identity_cleanup_failed" : "iroh_identity_failed");
16324
+ const code = error instanceof AggregateError ? "iroh_identity_cleanup_failed" : error instanceof IrohIdentityProbeError ? error.code : "iroh_identity_failed";
16325
+ const diagnostic = error instanceof IrohIdentityProbeError ? error.diagnostic : error instanceof AggregateError && error.errors[0] instanceof IrohIdentityProbeError ? error.errors[0].diagnostic : void 0;
16326
+ await this.failGeneration(generation, code, void 0, diagnostic);
15782
16327
  return;
15783
16328
  }
15784
16329
  if (generation !== this.generation || !this.enabled) return;
@@ -16787,6 +17332,116 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
16787
17332
  })
16788
17333
  });
16789
17334
  }
17335
+ // src/macos-file-access.ts
17336
+ /**
17337
+ * Reports whether this process already holds the macOS Full Disk Access grant.
17338
+ *
17339
+ * macOS shows a folder authorization dialog the first time a process reads a
17340
+ * protected location (Desktop, Documents, Downloads, external or network
17341
+ * volumes). While that dialog waits for a click, the calling thread stays
17342
+ * blocked, so an unattended machine can stall every request that needs the
17343
+ * file system. Granting Full Disk Access removes that class of dialog.
17344
+ *
17345
+ * The probe reads the per-user TCC database. That file is itself protected by
17346
+ * TCC, so opening it succeeds only for a process macOS already trusts with
17347
+ * Full Disk Access; a denied read fails immediately with EACCES/EPERM because
17348
+ * the grant can only be made in System Settings and can never be requested
17349
+ * through a dialog. The probe therefore never blocks on user input, unlike
17350
+ * reading one of the protected folders themselves.
17351
+ */
17352
+ /** A final `ok` is sticky; only a non-ok result is retried, at most this often. */
17353
+ const DEFAULT_RETRY_MS = 2e3;
17354
+ /** A probe that never settles is treated as missing rather than awaited forever. */
17355
+ const DEFAULT_TIMEOUT_MS = 2e3;
17356
+ /**
17357
+ * Caches the Full Disk Access state so a status response can read it
17358
+ * synchronously while the file system check stays asynchronous.
17359
+ */
17360
+ var MacFileAccessProbe = class {
17361
+ platform;
17362
+ home;
17363
+ readAccess;
17364
+ now;
17365
+ retryMs;
17366
+ timeoutMs;
17367
+ statusValue = "missing";
17368
+ checkedAt = Number.NEGATIVE_INFINITY;
17369
+ activeProbe;
17370
+ constructor(options = {}) {
17371
+ this.platform = options.platform ?? process.platform;
17372
+ this.home = options.home ?? homedir();
17373
+ this.readAccess = options.readAccess ?? ((path) => access(path, constants.R_OK));
17374
+ this.now = options.now ?? Date.now;
17375
+ this.retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
17376
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
17377
+ }
17378
+ /** Whether the probe applies to this platform at all. */
17379
+ get applicable() {
17380
+ return this.platform === "darwin";
17381
+ }
17382
+ /** Latest cached result. Never performs I/O. */
17383
+ status() {
17384
+ return this.applicable ? this.statusValue : "ok";
17385
+ }
17386
+ /**
17387
+ * Refresh the cached result when it is not final yet.
17388
+ * @returns The result after this call.
17389
+ */
17390
+ async refresh() {
17391
+ if (!this.applicable) return "ok";
17392
+ if (this.statusValue === "ok") return "ok";
17393
+ if (this.activeProbe !== void 0) return this.activeProbe;
17394
+ const at = this.now();
17395
+ if (at - this.checkedAt < this.retryMs) return this.statusValue;
17396
+ this.checkedAt = at;
17397
+ const pending = this.probe();
17398
+ this.activeProbe = pending;
17399
+ try {
17400
+ this.statusValue = await pending;
17401
+ return this.statusValue;
17402
+ } finally {
17403
+ if (this.activeProbe === pending) this.activeProbe = void 0;
17404
+ }
17405
+ }
17406
+ /** Run one bounded access check and collapse every failure into `missing`. */
17407
+ async probe() {
17408
+ const database = join(this.home, "Library", "Application Support", "com.apple.TCC", "TCC.db");
17409
+ let timer;
17410
+ try {
17411
+ await Promise.race([this.readAccess(database), new Promise((_resolve, reject) => {
17412
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("macos_file_access_probe_timeout")), this.timeoutMs);
17413
+ timer.unref?.();
17414
+ })]);
17415
+ return "ok";
17416
+ } catch {
17417
+ return "missing";
17418
+ } finally {
17419
+ if (timer !== void 0) clearTimeout(timer);
17420
+ }
17421
+ }
17422
+ };
17423
+ /** System Settings deep link for the Full Disk Access pane. */
17424
+ const MAC_FULL_DISK_ACCESS_PANE = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
17425
+ /** Absolute path so a GUI-launched process does not depend on PATH. */
17426
+ const OPEN_COMMAND = "/usr/bin/open";
17427
+ function runCommand(file, args) {
17428
+ return new Promise((resolve, reject) => {
17429
+ execFile(file, [...args], (error) => {
17430
+ if (error === null) resolve();
17431
+ else reject(error);
17432
+ });
17433
+ });
17434
+ }
17435
+ /**
17436
+ * Open the Full Disk Access pane from the host process.
17437
+ *
17438
+ * The desktop app's renderer refuses to navigate to a custom scheme, so the
17439
+ * button cannot use `window.open`; `open` runs on the Mac that owns the app.
17440
+ * @param run - Command runner, injectable for tests.
17441
+ */
17442
+ async function openMacFullDiskAccessSettings(run = runCommand) {
17443
+ await run(OPEN_COMMAND, [MAC_FULL_DISK_ACCESS_PANE]);
17444
+ }
16790
17445
  // src/mobile-guide.ts
16791
17446
  /** Compose the guide with the current customization state injected. */
16792
17447
  function buildMobileGuide(state) {
@@ -19926,11 +20581,33 @@ function launchedProfileName(argv) {
19926
20581
  }
19927
20582
  return "web";
19928
20583
  }
20584
+ function profileInstallingModule(dshHome, moduleUrl) {
20585
+ let directory;
20586
+ try {
20587
+ directory = dirname(fileURLToPath(moduleUrl));
20588
+ } catch {
20589
+ return;
20590
+ }
20591
+ const profilesRoot = resolve(dshHome, "profiles");
20592
+ for (let depth = 0; depth < 16; depth += 1) {
20593
+ const candidate = resolve(directory);
20594
+ const fromProfiles = relative(profilesRoot, candidate);
20595
+ if (fromProfiles !== "" && !fromProfiles.startsWith("..") && !isAbsolute(fromProfiles)) try {
20596
+ const manifest = JSON.parse(readFileSync(join(candidate, "package.json"), "utf8"));
20597
+ if (typeof manifest.dependencies?.[PACKAGE_NAME] === "string" && typeof manifest.dsh?.profile === "object" && manifest.dsh.profile !== null) return candidate;
20598
+ } catch {}
20599
+ const parent = dirname(candidate);
20600
+ if (parent === candidate) break;
20601
+ directory = parent;
20602
+ }
20603
+ }
19929
20604
  /** Resolve the launcher-owned Desktop profile, or the CLI profile outside Desktop. */
19930
- function releaseProfileDirectory(ctx, dshHome, argv) {
20605
+ function releaseProfileDirectory(ctx, dshHome, argv, moduleUrl = import.meta.url) {
19931
20606
  const desktopProfiles = ctx.get("desktopProfiles");
19932
20607
  const desktopDirectory = desktopProfiles?.current?.dir;
19933
20608
  if (typeof desktopDirectory === "string" && isAbsolute(desktopDirectory)) return desktopDirectory;
20609
+ const installedProfile = profileInstallingModule(dshHome, moduleUrl);
20610
+ if (installedProfile !== void 0) return installedProfile;
19934
20611
  if (desktopProfiles !== void 0 || ctx.get("desktopRuntime") !== void 0) return void 0;
19935
20612
  return join(dshHome, "profiles", launchedProfileName(argv));
19936
20613
  }
@@ -23377,34 +24054,71 @@ function withInstallationIdentity(config, installationId, explicitIdentity) {
23377
24054
  // src/lan-setup.ts
23378
24055
  const FIREWALL_TCP_RULE = "DSH Mobile HTTPS";
23379
24056
  const FIREWALL_UDP_RULE = "DSH Mobile Discovery";
24057
+ const MIN_MANAGED_PORT = 1024;
24058
+ const MAX_MANAGED_PORT = 65535;
24059
+ function assertManagedPort(port, name) {
24060
+ if (!Number.isSafeInteger(port) || port < MIN_MANAGED_PORT || port > MAX_MANAGED_PORT) throw new Error(`${name} must be from ${String(MIN_MANAGED_PORT)} through ${String(MAX_MANAGED_PORT)}`);
24061
+ }
24062
+ function powerShellFailureDetail(error) {
24063
+ if (!(error instanceof Error)) return "unknown failure";
24064
+ const code = "code" in error && typeof error.code === "string" ? ` (${error.code})` : "";
24065
+ const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim().replace(/\s+/gu, " ").slice(0, 512) : "";
24066
+ return `${code}${stderr === "" ? "" : `: ${stderr}`}`;
24067
+ }
23380
24068
  async function runElevatedPowerShell(script) {
23381
- await execFileText("powershell.exe", [
23382
- "-NoProfile",
23383
- "-NonInteractive",
23384
- "-Command",
23385
- [
23386
- "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
23387
- ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
23388
- "; exit $process.ExitCode"
23389
- ].join(" ")
23390
- ], { windowsHide: true });
23391
- }
23392
- /** Install the two Windows inbound rules used by the managed LAN listener. */
24069
+ const launch = [
24070
+ "$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru",
24071
+ ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${Buffer.from(script, "utf16le").toString("base64")}')`,
24072
+ "; exit $process.ExitCode"
24073
+ ].join(" ");
24074
+ try {
24075
+ await execFileText("powershell.exe", [
24076
+ "-NoProfile",
24077
+ "-NonInteractive",
24078
+ "-Command",
24079
+ launch
24080
+ ], { windowsHide: true });
24081
+ } catch (error) {
24082
+ throw new Error(`Windows firewall operation failed${powerShellFailureDetail(error)}`, { cause: error });
24083
+ }
24084
+ }
24085
+ function firewallRuleScript(name, protocol, port) {
24086
+ return [
24087
+ `$rules = @(Get-NetFirewallRule -DisplayName '${name}' -ErrorAction SilentlyContinue)`,
24088
+ "if ($rules.Count -eq 0) {",
24089
+ ` New-NetFirewallRule -DisplayName '${name}' -Direction Inbound -Action Allow -Protocol ${protocol} -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
24090
+ "} else {",
24091
+ " foreach ($rule in $rules) {",
24092
+ " $rule | Set-NetFirewallRule -Enabled True -Direction Inbound -Action Allow -Profile Any",
24093
+ ` $rule | Get-NetFirewallPortFilter | Set-NetFirewallPortFilter -Protocol ${protocol} -LocalPort ${String(port)}`,
24094
+ " $rule | Get-NetFirewallAddressFilter | Set-NetFirewallAddressFilter -RemoteAddress LocalSubnet",
24095
+ " }",
24096
+ "}",
24097
+ `$verified = @(Get-NetFirewallRule -DisplayName '${name}' -Enabled True -Direction Inbound -Action Allow -Profile Any -ErrorAction Stop)`,
24098
+ `$matchingPort = @($verified | Get-NetFirewallPortFilter | Where-Object { $_.Protocol -eq '${protocol}' -and $_.LocalPort -contains '${String(port)}' })`,
24099
+ `$matchingAddress = @($verified | Get-NetFirewallAddressFilter | Where-Object { $_.RemoteAddress -contains 'LocalSubnet' })`,
24100
+ `if ($verified.Count -eq 0 -or $matchingPort.Count -eq 0 -or $matchingAddress.Count -eq 0) { throw "DSH Mobile firewall rule '${name}' is not active with the expected scope" }`
24101
+ ];
24102
+ }
24103
+ /** Build the elevated Windows firewall script without removing a working rule first. */
24104
+ function windowsFirewallSetupScript(port) {
24105
+ assertManagedPort(port, "Windows firewall port");
24106
+ return [
24107
+ "$ErrorActionPreference = 'Stop'",
24108
+ ...firewallRuleScript(FIREWALL_TCP_RULE, "TCP", port),
24109
+ ...firewallRuleScript(FIREWALL_UDP_RULE, "UDP", port)
24110
+ ].join("\n");
24111
+ }
24112
+ /** Install or update the two Windows inbound rules used by the managed LAN listener. */
23393
24113
  async function configureWindowsFirewall(port) {
23394
24114
  if (process.platform !== "win32") return;
23395
- await runElevatedPowerShell([
23396
- "$ErrorActionPreference = 'Stop'",
23397
- `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
23398
- `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,
23399
- `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,
23400
- `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`
23401
- ].join("; "));
24115
+ await runElevatedPowerShell(windowsFirewallSetupScript(port));
23402
24116
  }
23403
24117
  /** Generate private TLS material and persist a managed setup selected by the user. */
23404
24118
  async function prepareManagedLanSetup(options) {
23405
24119
  if (!isAbsolute(options.setupFile) || !isAbsolute(options.controlFile)) throw new Error("managed LAN setup paths must be absolute");
23406
- if (!Number.isSafeInteger(options.listenPort) || options.listenPort < 1024 || options.listenPort > 65535) throw new Error("managed LAN listenPort must be from 1024 through 65535");
23407
- if (!Number.isSafeInteger(options.dshPort) || options.dshPort < 1024 || options.dshPort > 65535) throw new Error("managed LAN dshPort must be from 1024 through 65535");
24120
+ assertManagedPort(options.listenPort, "managed LAN listenPort");
24121
+ assertManagedPort(options.dshPort, "managed LAN dshPort");
23408
24122
  const directory = dirname(options.setupFile);
23409
24123
  const tlsDirectory = join(directory, "tls");
23410
24124
  await mkdir(tlsDirectory, {
@@ -24042,6 +24756,7 @@ async function apply(ctx, config) {
24042
24756
  const cloudflaredComponent = new CloudflaredComponentManager({ stateDirectory });
24043
24757
  await cloudflaredComponent.initialize();
24044
24758
  const frpComponent = new FrpComponentManager({ stateDirectory });
24759
+ const macFileAccess = new MacFileAccessProbe();
24045
24760
  await frpComponent.initialize();
24046
24761
  const irohComponent = new IrohComponentManager({
24047
24762
  stateDirectory,
@@ -24301,6 +25016,9 @@ async function apply(ctx, config) {
24301
25016
  component: irohComponent,
24302
25017
  dataDirectory: join(remoteDirectory, "iroh", "sidecar"),
24303
25018
  createGateway: (endpointId) => createIrohGateway(endpointId),
25019
+ onFailure: ({ errorCode, diagnostic }) => {
25020
+ logger.warn("iroh startup failed: %s%s", errorCode, diagnostic === void 0 ? "" : `; ${diagnostic}`);
25021
+ },
24304
25022
  /**
24305
25023
  * Read the pairing CA the live gateway advertises. The fingerprint must
24306
25024
  * match the gateway instance id, otherwise a rotated CA would mint QR
@@ -24389,7 +25107,10 @@ async function apply(ctx, config) {
24389
25107
  originMode: originController.mode(),
24390
25108
  caddyConfiguration: caddyConfig.status(),
24391
25109
  caddyState: caddyController.status(),
24392
- ...process.platform === "darwin" ? { macosHost: true } : {}
25110
+ ...macFileAccess.applicable ? {
25111
+ macosHost: true,
25112
+ macFileAccess: macFileAccess.status()
25113
+ } : {}
24393
25114
  });
24394
25115
  const lanPayload = () => ({
24395
25116
  ...loaded.kind === "unconfigured" ? {
@@ -24713,9 +25434,16 @@ async function apply(ctx, config) {
24713
25434
  return;
24714
25435
  }
24715
25436
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/control`) {
25437
+ await macFileAccess.refresh();
24716
25438
  sendJson(response, 200, remotePayload(), false);
24717
25439
  return;
24718
25440
  }
25441
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/macos-file-access/open-settings`) {
25442
+ if (!macFileAccess.applicable) throw new HttpError(404, "not_found");
25443
+ await openMacFullDiskAccessSettings();
25444
+ sendJson(response, 200, { opened: true }, false);
25445
+ return;
25446
+ }
24719
25447
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/remote/provider`) {
24720
25448
  const body = await readJsonObject(request, 4096);
24721
25449
  if (body.provider !== "tailscale" && body.provider !== "cpolar" && body.provider !== "cloudflared" && body.provider !== "frp" && body.provider !== "origin" && body.provider !== "iroh") throw new HttpError(400, "bad_request");