dsh-mobile 0.6.2 → 0.6.3

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CONTRIBUTORS.md +11 -4
  3. package/README.en.md +11 -7
  4. package/README.md +11 -7
  5. package/SECURITY.md +3 -2
  6. package/apps/mobile/README.md +14 -2
  7. package/apps/mobile/release.json +3 -3
  8. package/cordis.patch.yml +2 -1
  9. package/docs/ATTACH_EXISTING_FRPS.md +1 -1
  10. package/docs/CADDY_MANAGED.en.md +15 -1
  11. package/docs/CADDY_MANAGED.md +15 -1
  12. package/docs/CLOUDFLARE_TUNNEL.en.md +2 -2
  13. package/docs/CLOUDFLARE_TUNNEL.md +3 -3
  14. package/docs/EXTENSION_WORKERS.en.md +49 -0
  15. package/docs/EXTENSION_WORKERS.md +49 -0
  16. package/docs/MOBILE_COMPOSER_TOOLBAR.en.md +19 -0
  17. package/docs/MOBILE_COMPOSER_TOOLBAR.md +19 -0
  18. package/docs/README.md +2 -0
  19. package/docs/SELF_HOSTED_FRP.en.md +1 -1
  20. package/docs/SELF_HOSTED_FRP.md +1 -1
  21. package/docs/TRUSTED_NETWORKS.en.md +1 -1
  22. package/docs/TRUSTED_NETWORKS.md +1 -1
  23. package/lib/cli.js +1 -0
  24. package/lib/cli.js.map +1 -1
  25. package/lib/client.js +864 -97
  26. package/lib/client.js.map +1 -1
  27. package/lib/extension-worker-runtime.mjs +1646 -0
  28. package/lib/extension-worker-runtime.mjs.map +1 -0
  29. package/lib/index.d.mts +199 -7
  30. package/lib/index.mjs +1594 -533
  31. package/lib/index.mjs.map +1 -1
  32. package/lib/mobile-layout.js.map +1 -1
  33. package/package.json +15 -6
  34. package/packages/question-fixes/LICENSE +201 -0
  35. package/packages/question-fixes/package.json +9 -2
  36. package/node_modules/dsh-mobile-question-fixes/lib/client.js +0 -78
  37. package/node_modules/dsh-mobile-question-fixes/lib/client.js.map +0 -1
  38. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs +0 -14
  39. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs.map +0 -1
  40. package/node_modules/dsh-mobile-question-fixes/package.json +0 -25
package/lib/index.mjs CHANGED
@@ -1,28 +1,29 @@
1
1
  import { createRequire } from "node:module";
2
- import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import z from "@deepseek-ai/schemastery";
6
6
  import { connect, createServer } from "node:net";
7
- import { chmod, copyFile, lstat, mkdir, mkdtemp, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { chmod, copyFile, lstat, mkdir, mkdtemp, open, opendir, readFile, readdir, realpath, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
8
8
  import { execFile, spawn } from "node:child_process";
9
9
  import { createSocket } from "node:dgram";
10
10
  import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
11
11
  import { createServer as createServer$1, request } from "node:http";
12
12
  import { createServer as createServer$2, request as request$1 } from "node:https";
13
13
  import { Readable, Transform, finished } from "node:stream";
14
- import { pipeline } from "node:stream/promises";
14
+ import { finished as finished$1, pipeline } from "node:stream/promises";
15
15
  import { promisify } from "node:util";
16
16
  import { createGzip, gzip } from "node:zlib";
17
17
  import Bonjour from "bonjour-service";
18
18
  import * as QRCode from "qrcode";
19
19
  import WebSocket, { WebSocketServer } from "ws";
20
20
  import { Logger, Service } from "@deepseek-ai/cordis";
21
+ import { Worker } from "node:worker_threads";
21
22
  import { connect as connect$1 } from "node:tls";
22
23
  import { generate } from "selfsigned";
23
24
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
24
25
  import { lookup } from "node:dns/promises";
25
- import { createWriteStream } from "node:fs";
26
+ import { constants } from "node:fs";
26
27
  //#region src/access.ts
27
28
  /** Stable error categories converted to deliberately terse HTTP responses. */
28
29
  var AccessError = class extends Error {
@@ -860,9 +861,69 @@ var WebSocketPathStore = class {
860
861
  }
861
862
  };
862
863
  //#endregion
864
+ //#region src/extension-worker-config.ts
865
+ function integer$1(value, name, fallback, minimum, maximum) {
866
+ const resolved = value ?? fallback;
867
+ if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`hostExecution.${name} must be an integer from ${minimum} through ${maximum}`);
868
+ return resolved;
869
+ }
870
+ /** Resolve configuration once; reject malformed modes, ids and incoherent stream windows. */
871
+ function resolveHostExecution(raw) {
872
+ if (raw !== void 0 && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new Error("hostExecution must be an object");
873
+ const value = raw ?? {};
874
+ const mode = value.mode ?? "in-process";
875
+ if (mode !== "worker" && mode !== "in-process") throw new Error("hostExecution.mode is invalid");
876
+ if (value.extensions !== void 0 && (!Array.isArray(value.extensions) || value.extensions.some((id) => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(id)) || new Set(value.extensions).size !== value.extensions.length)) throw new Error("hostExecution.extensions must contain unique local extension ids");
877
+ const streamWindowBytes = integer$1(value.streamWindowBytes, "streamWindowBytes", 262144, 1024, 4194304);
878
+ const streamChunkBytes = integer$1(value.streamChunkBytes, "streamChunkBytes", 65536, 1024, streamWindowBytes);
879
+ const streamAggregateBytes = integer$1(value.streamAggregateBytes, "streamAggregateBytes", 1048576, streamWindowBytes, 33554432);
880
+ return Object.freeze({
881
+ mode,
882
+ extensions: value.extensions === void 0 ? void 0 : Object.freeze([...value.extensions]),
883
+ maxWorkers: integer$1(value.maxWorkers, "maxWorkers", 8, 1, 128),
884
+ maxRegistrations: integer$1(value.maxRegistrations, "maxRegistrations", 256, 1, 4096),
885
+ maxOldGenerationSizeMb: integer$1(value.maxOldGenerationSizeMb, "maxOldGenerationSizeMb", 128, 16, 4096),
886
+ maxYoungGenerationSizeMb: integer$1(value.maxYoungGenerationSizeMb, "maxYoungGenerationSizeMb", 16, 1, 512),
887
+ stackSizeMb: integer$1(value.stackSizeMb, "stackSizeMb", 4, 1, 64),
888
+ activationTimeoutMs: integer$1(value.activationTimeoutMs, "activationTimeoutMs", 5e3, 1, 3e5),
889
+ operationTimeoutMs: integer$1(value.operationTimeoutMs, "operationTimeoutMs", 3e4, 1, 3e5),
890
+ cancelGraceMs: integer$1(value.cancelGraceMs, "cancelGraceMs", 250, 1, 3e4),
891
+ streamCancelGraceMs: integer$1(value.streamCancelGraceMs, "streamCancelGraceMs", 1e3, 1, 3e4),
892
+ disposeGraceMs: integer$1(value.disposeGraceMs, "disposeGraceMs", 2e3, 1, 3e4),
893
+ resultMaxBytes: integer$1(value.resultMaxBytes, "resultMaxBytes", 4194304, 1024, 4194304),
894
+ streamWindowBytes,
895
+ streamChunkBytes,
896
+ streamAggregateBytes,
897
+ logWindowMs: integer$1(value.logWindowMs, "logWindowMs", 1e3, 1, 6e4),
898
+ logMessagesPerWindow: integer$1(value.logMessagesPerWindow, "logMessagesPerWindow", 64, 1, 1024),
899
+ logMessageBytes: integer$1(value.logMessageBytes, "logMessageBytes", 4096, 128, 65536)
900
+ });
901
+ }
902
+ //#endregion
863
903
  //#region src/config.ts
864
904
  /** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
865
905
  const Config = z.object({
906
+ hostExecution: z.object({
907
+ mode: z.union([z.const("in-process"), z.const("worker")]),
908
+ extensions: z.union([z.const(void 0), z.array(String)]),
909
+ maxWorkers: z.natural(),
910
+ maxRegistrations: z.natural(),
911
+ maxOldGenerationSizeMb: z.natural(),
912
+ maxYoungGenerationSizeMb: z.natural(),
913
+ stackSizeMb: z.natural(),
914
+ activationTimeoutMs: z.natural(),
915
+ operationTimeoutMs: z.natural(),
916
+ cancelGraceMs: z.natural(),
917
+ streamCancelGraceMs: z.natural(),
918
+ disposeGraceMs: z.natural(),
919
+ resultMaxBytes: z.natural(),
920
+ streamWindowBytes: z.natural(),
921
+ streamChunkBytes: z.natural(),
922
+ streamAggregateBytes: z.natural(),
923
+ logWindowMs: z.natural(),
924
+ logMessagesPerWindow: z.natural(),
925
+ logMessageBytes: z.natural()
926
+ }),
866
927
  setupFile: z.string().hidden(),
867
928
  publicOrigin: z.string(),
868
929
  listenHost: z.string(),
@@ -977,7 +1038,9 @@ function parsePublicOrigin$1(value) {
977
1038
  });
978
1039
  }
979
1040
  function parseTls(value, listenHost) {
1041
+ if (value !== void 0 && (value === null || typeof value !== "object" || Array.isArray(value))) throw new Error("tls must be an object");
980
1042
  const mode = value?.mode ?? "provided";
1043
+ if (value?.mode === null || mode !== "provided" && mode !== "disabled") throw new Error("tls.mode must be provided or disabled");
981
1044
  if (mode === "disabled") {
982
1045
  if (!isLoopbackAddress(listenHost)) throw new Error("TLS may be disabled only on an IP loopback listener");
983
1046
  return Object.freeze({ mode });
@@ -1024,6 +1087,7 @@ function parseGatewayConfig(raw) {
1024
1087
  if (sessionTtlMs > deviceTtlMs) throw new Error("sessionTtlMs must not exceed deviceTtlMs");
1025
1088
  return Object.freeze({
1026
1089
  listenHost,
1090
+ hostExecution: resolveHostExecution(value.hostExecution),
1027
1091
  listenPort,
1028
1092
  upstreamOrigin,
1029
1093
  authorities: Object.freeze(authorities),
@@ -2258,6 +2322,660 @@ async function readComputerImage(path, signal) {
2258
2322
  }
2259
2323
  }
2260
2324
  //#endregion
2325
+ //#region src/extension-worker.ts
2326
+ /** Supervise opt-in local hosts without executing extension code on the DSH thread. */
2327
+ /** JSON bytes serialized and size-checked inside the worker. */
2328
+ var PreparedJsonResult = class {
2329
+ bytes;
2330
+ constructor(bytes) {
2331
+ this.bytes = bytes;
2332
+ }
2333
+ };
2334
+ /** Identify worker results without invoking an extension serializer in the gateway. */
2335
+ function isPreparedJson(value) {
2336
+ return value instanceof PreparedJsonResult;
2337
+ }
2338
+ /** Resolve the installed standalone entry adjacent to the plugin bundle. */
2339
+ function defaultExtensionWorkerModule() {
2340
+ return new URL("./extension-worker-runtime.mjs", import.meta.url);
2341
+ }
2342
+ /** Every thread counts until actual exit, including activation, retirement and termination. */
2343
+ var ExtensionWorkerBudget = class {
2344
+ maximum;
2345
+ count = 0;
2346
+ constructor(maximum) {
2347
+ this.maximum = maximum;
2348
+ }
2349
+ get current() {
2350
+ return this.count;
2351
+ }
2352
+ reserve() {
2353
+ if (this.count >= this.maximum) throw new MobileExtensionError("extension_worker_limit", "local extension worker limit reached", 503);
2354
+ this.count += 1;
2355
+ let released = false;
2356
+ return () => {
2357
+ if (!released) {
2358
+ released = true;
2359
+ this.count -= 1;
2360
+ }
2361
+ };
2362
+ }
2363
+ };
2364
+ /** Diagnostic count; production admission uses an instance-owned budget. */
2365
+ const activeWorkerCount = { current: 0 };
2366
+ const LIFECYCLE_ID = "lifecycle";
2367
+ /** Node accepts quoted NODE_OPTIONS words; inspect option names without exposing their values. */
2368
+ function nodeOptionWords(value) {
2369
+ const words = [];
2370
+ let word = "";
2371
+ let quoted = false;
2372
+ let escaped = false;
2373
+ for (const character of value) {
2374
+ if (escaped) {
2375
+ word += character;
2376
+ escaped = false;
2377
+ continue;
2378
+ }
2379
+ if (character === "\\") {
2380
+ escaped = true;
2381
+ continue;
2382
+ }
2383
+ if (character === "\"") {
2384
+ quoted = !quoted;
2385
+ continue;
2386
+ }
2387
+ if (/\s/u.test(character) && !quoted) {
2388
+ if (word !== "") {
2389
+ words.push(word);
2390
+ word = "";
2391
+ }
2392
+ continue;
2393
+ }
2394
+ word += character;
2395
+ }
2396
+ if (word !== "") words.push(word);
2397
+ return words;
2398
+ }
2399
+ /** Known parent V8 heap flags take precedence over Worker resourceLimits. */
2400
+ function parentWorkerHeapOverrides(argv, nodeOptions) {
2401
+ const heapFlags = /* @__PURE__ */ new Set([
2402
+ "--max-old-space-size",
2403
+ "--max-semi-space-size",
2404
+ "--max-heap-size",
2405
+ "--max-old-space-size-percentage"
2406
+ ]);
2407
+ const found = /* @__PURE__ */ new Set();
2408
+ for (const argument of [...argv, ...nodeOptionWords(nodeOptions ?? "")]) {
2409
+ const name = argument.split("=", 1)[0].replaceAll("_", "-");
2410
+ if (heapFlags.has(name)) found.add(name);
2411
+ }
2412
+ return Object.freeze([...found]);
2413
+ }
2414
+ const initialNodeOptions = process.env.NODE_OPTIONS;
2415
+ var ExtensionWorkerHost = class {
2416
+ options;
2417
+ limits;
2418
+ worker;
2419
+ pending = /* @__PURE__ */ new Map();
2420
+ routePending = /* @__PURE__ */ new Map();
2421
+ streams = /* @__PURE__ */ new Map();
2422
+ detachedRoutes = /* @__PURE__ */ new Set();
2423
+ executionGrace = /* @__PURE__ */ new Map();
2424
+ exited;
2425
+ unavailableController = new AbortController();
2426
+ termination;
2427
+ activation;
2428
+ dead = false;
2429
+ disposed = false;
2430
+ maxBufferedBytes = 0;
2431
+ cancelledStreams = 0;
2432
+ logWindowStart = Date.now();
2433
+ logCount = 0;
2434
+ stdioBytes = 0;
2435
+ stdioResume;
2436
+ constructor(options) {
2437
+ this.options = options;
2438
+ this.limits = resolveHostExecution(options.limits);
2439
+ const overrides = new Set(parentWorkerHeapOverrides(process.execArgv, initialNodeOptions));
2440
+ for (const flag of parentWorkerHeapOverrides([], process.env.NODE_OPTIONS)) overrides.add(flag);
2441
+ if (overrides.size > 0) throw new MobileExtensionError("extension_worker_heap_override", `worker heap limits require launching DSH without ${[...overrides].join(", ")}`, 503);
2442
+ const release = options.budget?.reserve() ?? (() => void 0);
2443
+ try {
2444
+ this.worker = new Worker(options.workerModule, {
2445
+ workerData: { limits: this.limits },
2446
+ stdout: true,
2447
+ stderr: true,
2448
+ resourceLimits: {
2449
+ maxOldGenerationSizeMb: this.limits.maxOldGenerationSizeMb,
2450
+ maxYoungGenerationSizeMb: this.limits.maxYoungGenerationSizeMb,
2451
+ stackSizeMb: this.limits.stackSizeMb
2452
+ }
2453
+ });
2454
+ } catch (error) {
2455
+ release();
2456
+ throw error;
2457
+ }
2458
+ const drainConsole = (chunk) => {
2459
+ this.stdioBytes += chunk.byteLength;
2460
+ if (this.stdioBytes < this.limits.logMessageBytes * this.limits.logMessagesPerWindow || this.stdioResume !== void 0) return;
2461
+ this.worker.stdout?.pause();
2462
+ this.worker.stderr?.pause();
2463
+ this.stdioResume = setTimeout(() => {
2464
+ this.stdioResume = void 0;
2465
+ this.stdioBytes = 0;
2466
+ if (!this.dead) {
2467
+ this.worker.stdout?.resume();
2468
+ this.worker.stderr?.resume();
2469
+ }
2470
+ }, this.limits.logWindowMs);
2471
+ this.stdioResume.unref();
2472
+ };
2473
+ this.worker.stdout?.on("data", drainConsole);
2474
+ this.worker.stderr?.on("data", drainConsole);
2475
+ this.worker.unref();
2476
+ activeWorkerCount.current += 1;
2477
+ this.exited = new Promise((resolve) => {
2478
+ this.worker.once("exit", () => {
2479
+ this.dead = true;
2480
+ if (this.stdioResume !== void 0) clearTimeout(this.stdioResume);
2481
+ this.markUnavailable();
2482
+ release();
2483
+ activeWorkerCount.current -= 1;
2484
+ for (const timer of this.executionGrace.values()) clearTimeout(timer);
2485
+ this.executionGrace.clear();
2486
+ const error = this.unavailable();
2487
+ for (const entry of this.pending.values()) {
2488
+ entry.cleanup();
2489
+ entry.reject(error);
2490
+ }
2491
+ for (const entry of this.routePending.values()) {
2492
+ entry.cleanup();
2493
+ entry.reject(error);
2494
+ }
2495
+ this.pending.clear();
2496
+ this.routePending.clear();
2497
+ this.detachedRoutes.clear();
2498
+ for (const live of this.streams.values()) {
2499
+ if (live.cancelTimer !== void 0) clearTimeout(live.cancelTimer);
2500
+ live.bridge.destroy(error);
2501
+ }
2502
+ this.streams.clear();
2503
+ resolve();
2504
+ });
2505
+ });
2506
+ this.worker.on("message", (raw) => {
2507
+ try {
2508
+ this.handleMessage(raw);
2509
+ } catch (error) {
2510
+ this.terminate("invalid-worker-message");
2511
+ }
2512
+ });
2513
+ this.worker.on("error", () => {
2514
+ this.terminate("worker-error");
2515
+ });
2516
+ }
2517
+ /** A stopped generation never silently respawns. */
2518
+ available() {
2519
+ return !this.dead && this.termination === void 0 && !this.disposed && !this.unavailableController.signal.aborted;
2520
+ }
2521
+ /** Activate once and receive executable-free registration metadata. */
2522
+ async activate() {
2523
+ let timer;
2524
+ try {
2525
+ const reply = new Promise((resolve, reject) => {
2526
+ this.pending.set(LIFECYCLE_ID, {
2527
+ resolve,
2528
+ reject,
2529
+ cleanup: () => void 0
2530
+ });
2531
+ this.post({
2532
+ kind: "activate",
2533
+ id: LIFECYCLE_ID,
2534
+ hostFile: this.options.hostFile,
2535
+ generation: this.options.generation,
2536
+ manifest: this.options.manifest
2537
+ });
2538
+ });
2539
+ await Promise.race([reply, new Promise((_, reject) => {
2540
+ timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", "extension worker activation timed out", 500)), this.options.activationTimeoutMs ?? this.limits.activationTimeoutMs);
2541
+ })]);
2542
+ if (this.activation === void 0) throw new MobileExtensionError("host_activation_failed", "extension worker activation failed", 500);
2543
+ return this.activation;
2544
+ } catch (error) {
2545
+ await this.terminate("activation-failed");
2546
+ throw error;
2547
+ } finally {
2548
+ if (timer !== void 0) clearTimeout(timer);
2549
+ }
2550
+ }
2551
+ actionMetadata() {
2552
+ return this.activation?.actions ?? [];
2553
+ }
2554
+ routeMetadata() {
2555
+ return this.activation?.routes ?? [];
2556
+ }
2557
+ /** Underlying action: settles only when execution completes or the thread exits. */
2558
+ invokeExecution(action, input, caller) {
2559
+ if (!this.available()) return Promise.reject(this.unavailable());
2560
+ if (caller.signal.aborted) return Promise.reject(caller.signal.reason);
2561
+ const id = randomUUID();
2562
+ return new Promise((resolve, reject) => {
2563
+ const onAbort = () => {
2564
+ this.post({
2565
+ kind: "cancel",
2566
+ id
2567
+ });
2568
+ this.armExecutionGrace(id);
2569
+ };
2570
+ caller.signal.addEventListener("abort", onAbort, { once: true });
2571
+ this.pending.set(id, {
2572
+ resolve,
2573
+ reject,
2574
+ cleanup: () => {
2575
+ caller.signal.removeEventListener("abort", onAbort);
2576
+ this.settleExecutionGrace(id);
2577
+ }
2578
+ });
2579
+ try {
2580
+ this.post({
2581
+ kind: "invoke",
2582
+ id,
2583
+ action,
2584
+ deviceId: caller.deviceId,
2585
+ input
2586
+ });
2587
+ } catch (error) {
2588
+ this.pending.get(id)?.cleanup();
2589
+ this.pending.delete(id);
2590
+ reject(error instanceof Error ? error : new Error(String(error)));
2591
+ }
2592
+ });
2593
+ }
2594
+ /** Direct caller budget; service dispatch observes invokeExecution separately. */
2595
+ invoke(action, input, caller) {
2596
+ return this.callerBudget((signal) => this.invokeExecution(action, input, {
2597
+ ...caller,
2598
+ signal
2599
+ }), caller.signal, this.actionMetadata().find((entry) => entry.name === action)?.timeoutMs ?? this.limits.operationTimeoutMs, "extension_action_timeout");
2600
+ }
2601
+ /** Underlying route: late streams are cancelled and observed to confirmed end. */
2602
+ handleRouteExecution(routeIndex, request) {
2603
+ if (!this.available()) return Promise.reject(this.unavailable());
2604
+ if (request.signal.aborted) return Promise.reject(request.signal.reason);
2605
+ const id = randomUUID();
2606
+ return new Promise((resolve, reject) => {
2607
+ const onAbort = () => {
2608
+ this.post({
2609
+ kind: "cancel",
2610
+ id
2611
+ });
2612
+ if (this.routePending.has(id)) {
2613
+ this.detachedRoutes.add(id);
2614
+ this.armExecutionGrace(id);
2615
+ }
2616
+ this.cancelStream(id);
2617
+ };
2618
+ request.signal.addEventListener("abort", onAbort, { once: true });
2619
+ this.routePending.set(id, {
2620
+ resolve,
2621
+ reject,
2622
+ cleanup: () => {
2623
+ request.signal.removeEventListener("abort", onAbort);
2624
+ this.settleExecutionGrace(id);
2625
+ }
2626
+ });
2627
+ try {
2628
+ this.post({
2629
+ kind: "route",
2630
+ id,
2631
+ routeIndex,
2632
+ method: request.method,
2633
+ path: request.pathname,
2634
+ query: [...request.query.entries()],
2635
+ headers: request.headers,
2636
+ body: new Uint8Array(request.body),
2637
+ deviceId: request.deviceId
2638
+ });
2639
+ } catch (error) {
2640
+ this.routePending.get(id)?.cleanup();
2641
+ this.routePending.delete(id);
2642
+ reject(error instanceof Error ? error : new Error(String(error)));
2643
+ }
2644
+ });
2645
+ }
2646
+ /** Handler budget ends at stream-start; stream lifetime remains cancellation-bound. */
2647
+ handleRoute(routeIndex, request) {
2648
+ return this.callerBudget((signal) => this.handleRouteExecution(routeIndex, {
2649
+ ...request,
2650
+ signal
2651
+ }), request.signal, this.routeMetadata()[routeIndex]?.timeoutMs ?? this.limits.operationTimeoutMs, "extension_route_timeout", true);
2652
+ }
2653
+ async callerBudget(start, external, timeoutMs, code, holdForStream = false) {
2654
+ const controller = new AbortController();
2655
+ let timer;
2656
+ let detach;
2657
+ const onAbort = () => {
2658
+ controller.abort(external.reason);
2659
+ detach?.(external.reason ?? new MobileExtensionError("extension_cancelled", "caller cancelled", 409));
2660
+ };
2661
+ const onUnavailable = () => {
2662
+ detach?.(this.unavailableController.signal.reason);
2663
+ };
2664
+ if (external.aborted) throw external.reason;
2665
+ if (!this.available()) throw this.unavailable();
2666
+ external.addEventListener("abort", onAbort, { once: true });
2667
+ this.unavailableController.signal.addEventListener("abort", onUnavailable, { once: true });
2668
+ const release = () => {
2669
+ external.removeEventListener("abort", onAbort);
2670
+ this.unavailableController.signal.removeEventListener("abort", onUnavailable);
2671
+ if (timer !== void 0) clearTimeout(timer);
2672
+ };
2673
+ let retained = false;
2674
+ try {
2675
+ const result = await Promise.race([start(controller.signal), new Promise((_, reject) => {
2676
+ detach = reject;
2677
+ timer = setTimeout(() => {
2678
+ const error = new MobileExtensionError(code, "extension operation timed out", 500);
2679
+ controller.abort(error);
2680
+ reject(error);
2681
+ }, timeoutMs);
2682
+ })]);
2683
+ if (holdForStream && result !== null && typeof result === "object" && "body" in result && result.body instanceof Readable) {
2684
+ retained = true;
2685
+ result.body.once("close", release);
2686
+ result.body.once("end", release);
2687
+ }
2688
+ return result;
2689
+ } finally {
2690
+ if (timer !== void 0) clearTimeout(timer);
2691
+ if (!retained) release();
2692
+ }
2693
+ }
2694
+ streamStats() {
2695
+ return {
2696
+ activeStreams: [...this.streams.values()].filter((live) => !live.ended).length,
2697
+ maxBufferedBytes: this.maxBufferedBytes,
2698
+ cancelledStreams: this.cancelledStreams,
2699
+ bufferedBytes: this.totalUnacked()
2700
+ };
2701
+ }
2702
+ /** Graceful effects cleanup followed by termination; completion requires actual exit. */
2703
+ async dispose() {
2704
+ if (!this.available()) {
2705
+ await this.terminate("dispose-unavailable");
2706
+ return;
2707
+ }
2708
+ this.disposed = true;
2709
+ let timer;
2710
+ try {
2711
+ const reply = new Promise((resolve, reject) => {
2712
+ this.pending.set(LIFECYCLE_ID, {
2713
+ resolve,
2714
+ reject,
2715
+ cleanup: () => void 0
2716
+ });
2717
+ this.post({
2718
+ kind: "dispose",
2719
+ id: LIFECYCLE_ID
2720
+ });
2721
+ });
2722
+ await Promise.race([reply, new Promise((resolve) => {
2723
+ timer = setTimeout(resolve, this.limits.disposeGraceMs);
2724
+ })]);
2725
+ } finally {
2726
+ if (timer !== void 0) clearTimeout(timer);
2727
+ await this.terminate("disposed");
2728
+ }
2729
+ }
2730
+ whenExited() {
2731
+ return this.exited;
2732
+ }
2733
+ /** Stop admission now, retain execution/budget ownership until confirmed thread exit. */
2734
+ terminate(reason) {
2735
+ this.markUnavailable();
2736
+ this.termination ??= (async () => {
2737
+ if (!this.dead) await this.worker.terminate();
2738
+ await this.exited;
2739
+ })();
2740
+ this.termination.catch(() => void 0);
2741
+ return this.termination;
2742
+ }
2743
+ unavailable() {
2744
+ return new MobileExtensionError("extension_host_unavailable", `extension ${this.options.manifest.id} worker is unavailable; explicit recovery is required`, 503);
2745
+ }
2746
+ markUnavailable() {
2747
+ if (this.unavailableController.signal.aborted) return;
2748
+ const error = this.unavailable();
2749
+ this.unavailableController.abort(error);
2750
+ try {
2751
+ this.options.onUnavailable?.(error);
2752
+ } catch (cause) {}
2753
+ }
2754
+ armExecutionGrace(id) {
2755
+ if (this.executionGrace.has(id) || !this.available()) return;
2756
+ const timer = setTimeout(() => {
2757
+ this.executionGrace.delete(id);
2758
+ if (this.pending.has(id) || this.routePending.has(id)) this.terminate("cancel-unresponsive");
2759
+ }, this.limits.cancelGraceMs);
2760
+ timer.unref();
2761
+ this.executionGrace.set(id, timer);
2762
+ }
2763
+ settleExecutionGrace(id) {
2764
+ const timer = this.executionGrace.get(id);
2765
+ if (timer !== void 0) clearTimeout(timer);
2766
+ this.executionGrace.delete(id);
2767
+ }
2768
+ post(message) {
2769
+ if (!this.dead && this.termination === void 0) this.worker.postMessage(message);
2770
+ }
2771
+ handleMessage(raw) {
2772
+ if (this.dead || raw === null || typeof raw !== "object") return;
2773
+ const message = raw;
2774
+ if (message.kind === "log") {
2775
+ if (message.level !== "debug" && message.level !== "info" && message.level !== "warn" && message.level !== "error") return;
2776
+ if (!Array.isArray(message.args) || message.args.some((arg) => typeof arg !== "string")) return;
2777
+ if (Date.now() - this.logWindowStart >= this.limits.logWindowMs) {
2778
+ this.logWindowStart = Date.now();
2779
+ this.logCount = 0;
2780
+ }
2781
+ if (++this.logCount > this.limits.logMessagesPerWindow || Buffer.byteLength(message.args.join("")) > this.limits.logMessageBytes) return;
2782
+ try {
2783
+ this.options.logger[message.level](...message.args);
2784
+ } catch (error) {}
2785
+ return;
2786
+ }
2787
+ if (typeof message.runtimeId !== "string") return;
2788
+ if (this.activation !== void 0 && message.runtimeId !== this.activation.runtimeId) return;
2789
+ if (message.kind === "activated") {
2790
+ if (this.activation !== void 0 || !Array.isArray(message.actions) || !Array.isArray(message.routes)) throw new Error("invalid activation metadata");
2791
+ const actions = message.actions;
2792
+ const routes = message.routes;
2793
+ if (actions.length + routes.length > this.limits.maxRegistrations) throw new Error("worker registration limit exceeded");
2794
+ for (const action of actions) if (typeof action?.name !== "string" || !/^[a-z][a-z0-9-]{0,63}$/u.test(action.name)) throw new Error("invalid action metadata");
2795
+ for (const route of routes) if (typeof route?.method !== "string" || typeof route.path !== "string") throw new Error("invalid route metadata");
2796
+ this.activation = Object.freeze({
2797
+ runtimeId: message.runtimeId,
2798
+ actions: Object.freeze(actions),
2799
+ routes: Object.freeze(routes)
2800
+ });
2801
+ const pending = this.pending.get(LIFECYCLE_ID);
2802
+ this.pending.delete(LIFECYCLE_ID);
2803
+ pending?.cleanup();
2804
+ pending?.resolve(new PreparedJsonResult(Buffer.alloc(0)));
2805
+ return;
2806
+ }
2807
+ if (typeof message.id !== "string") return;
2808
+ const id = message.id;
2809
+ if (message.kind === "result" || message.kind === "error") {
2810
+ const pending = this.pending.get(id);
2811
+ this.pending.delete(id);
2812
+ pending?.cleanup();
2813
+ if (message.kind === "result") {
2814
+ if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.resultMaxBytes) {
2815
+ pending?.reject(new MobileExtensionError("extension_failed", "invalid worker result", 500));
2816
+ return;
2817
+ }
2818
+ pending?.resolve(new PreparedJsonResult(Buffer.from(message.bytes)));
2819
+ } else {
2820
+ const error = new MobileExtensionError(typeof message.code === "string" ? message.code.slice(0, 64) : "extension_failed", typeof message.message === "string" ? message.message.slice(0, 500) : "extension failed", typeof message.status === "number" && Number.isInteger(message.status) && message.status >= 400 && message.status <= 599 ? message.status : 500);
2821
+ pending?.reject(error);
2822
+ const route = this.routePending.get(id);
2823
+ this.routePending.delete(id);
2824
+ this.detachedRoutes.delete(id);
2825
+ route?.cleanup();
2826
+ route?.reject(error);
2827
+ }
2828
+ return;
2829
+ }
2830
+ if (message.kind === "route-response" || message.kind === "stream-start") {
2831
+ const route = this.routePending.get(id);
2832
+ if (route === void 0) {
2833
+ if (message.kind === "stream-start") this.post({
2834
+ kind: "stream-cancel",
2835
+ id
2836
+ });
2837
+ return;
2838
+ }
2839
+ if (message.kind === "stream-start" && this.detachedRoutes.has(id)) {
2840
+ this.cancelledStreams += 1;
2841
+ this.post({
2842
+ kind: "stream-cancel",
2843
+ id
2844
+ });
2845
+ return;
2846
+ }
2847
+ const response = responseMetadata(message);
2848
+ this.routePending.delete(id);
2849
+ this.detachedRoutes.delete(id);
2850
+ if (message.kind === "route-response") {
2851
+ route.cleanup();
2852
+ if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.resultMaxBytes) {
2853
+ route.reject(new MobileExtensionError("invalid_route_response", "invalid worker route response", 500));
2854
+ return;
2855
+ }
2856
+ route.resolve({
2857
+ ...response,
2858
+ body: Buffer.from(message.bytes)
2859
+ });
2860
+ } else {
2861
+ this.settleExecutionGrace(id);
2862
+ const live = this.openStream(id, route.cleanup);
2863
+ route.resolve({
2864
+ ...response,
2865
+ body: live.bridge
2866
+ });
2867
+ }
2868
+ return;
2869
+ }
2870
+ if (message.kind === "stream-chunk") {
2871
+ const live = this.streams.get(id);
2872
+ if (live === void 0 || live.ended || live.bridge.destroyed) return;
2873
+ if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.streamChunkBytes || live.unacked + message.bytes.byteLength > this.limits.streamWindowBytes || this.totalUnacked() + message.bytes.byteLength > this.limits.streamAggregateBytes) throw new Error("worker exceeded stream credit");
2874
+ live.unacked += message.bytes.byteLength;
2875
+ this.maxBufferedBytes = Math.max(this.maxBufferedBytes, this.totalUnacked());
2876
+ live.bridge.push(Buffer.from(message.bytes));
2877
+ return;
2878
+ }
2879
+ if (message.kind === "stream-end" || message.kind === "stream-error") {
2880
+ this.settleExecutionGrace(id);
2881
+ const detached = this.routePending.get(id);
2882
+ if (this.detachedRoutes.delete(id)) {
2883
+ this.routePending.delete(id);
2884
+ detached?.cleanup();
2885
+ detached?.reject(new MobileExtensionError("extension_cancelled", "caller detached", 409));
2886
+ }
2887
+ const live = this.streams.get(id);
2888
+ if (live === void 0) return;
2889
+ live.ended = true;
2890
+ if (live.cancelTimer !== void 0) clearTimeout(live.cancelTimer);
2891
+ if (message.kind === "stream-error") live.bridge.destroy(new Error(typeof message.message === "string" ? message.message.slice(0, 500) : "worker stream failed"));
2892
+ else if (live.bridge.destroyed) this.streams.delete(id);
2893
+ else live.bridge.push(null);
2894
+ }
2895
+ }
2896
+ totalUnacked() {
2897
+ let total = 0;
2898
+ for (const live of this.streams.values()) total += live.unacked;
2899
+ return total;
2900
+ }
2901
+ openStream(id, cleanup) {
2902
+ const live = {
2903
+ bridge: new WorkerStreamBridge((bytes) => {
2904
+ live.unacked -= bytes;
2905
+ this.post({
2906
+ kind: "stream-ack",
2907
+ id,
2908
+ bytes
2909
+ });
2910
+ }),
2911
+ unacked: 0,
2912
+ ended: false
2913
+ };
2914
+ live.bridge.on("error", () => void 0);
2915
+ const settle = () => {
2916
+ cleanup();
2917
+ if (live.ended) {
2918
+ if (live.unacked > 0) this.post({
2919
+ kind: "stream-cancel",
2920
+ id
2921
+ });
2922
+ this.streams.delete(id);
2923
+ } else this.cancelStream(id);
2924
+ };
2925
+ live.bridge.once("end", settle);
2926
+ live.bridge.once("close", settle);
2927
+ this.streams.set(id, live);
2928
+ return live;
2929
+ }
2930
+ cancelStream(id) {
2931
+ const live = this.streams.get(id);
2932
+ if (live === void 0 || live.ended || live.cancelTimer !== void 0) return;
2933
+ this.cancelledStreams += 1;
2934
+ this.post({
2935
+ kind: "stream-cancel",
2936
+ id
2937
+ });
2938
+ live.cancelTimer = setTimeout(() => {
2939
+ if (!live.ended) this.terminate("stream-cancel-unresponsive");
2940
+ }, this.limits.streamCancelGraceMs);
2941
+ live.cancelTimer.unref();
2942
+ live.bridge.destroy();
2943
+ }
2944
+ };
2945
+ function responseMetadata(message) {
2946
+ const headers = {};
2947
+ let bytes = typeof message.contentType === "string" ? Buffer.byteLength(message.contentType) : 0;
2948
+ if (message.headers !== void 0) {
2949
+ if (message.headers === null || typeof message.headers !== "object" || Array.isArray(message.headers)) throw new Error("invalid response headers");
2950
+ const entries = Object.entries(message.headers);
2951
+ if (entries.length > 3) throw new Error("invalid response header count");
2952
+ for (const [key, value] of entries) {
2953
+ if (typeof value !== "string" || !/^(?:content-disposition|cache-control|etag)$/iu.test(key)) throw new Error("invalid response header");
2954
+ bytes += Buffer.byteLength(key) + Buffer.byteLength(value);
2955
+ if (bytes > 16384) throw new Error("invalid response header bytes");
2956
+ headers[key] = value;
2957
+ }
2958
+ }
2959
+ return {
2960
+ ...typeof message.status === "number" ? { status: message.status } : {},
2961
+ ...typeof message.contentType === "string" ? { contentType: message.contentType } : {},
2962
+ ...message.headers === void 0 ? {} : { headers }
2963
+ };
2964
+ }
2965
+ /** Credit belongs to bytes actually removed by the consumer, including reads after source EOF. */
2966
+ var WorkerStreamBridge = class extends Readable {
2967
+ consume;
2968
+ constructor(consume) {
2969
+ super({ highWaterMark: 16384 });
2970
+ this.consume = consume;
2971
+ }
2972
+ _read() {}
2973
+ emit(event, ...args) {
2974
+ if (event === "data" && Buffer.isBuffer(args[0])) this.consume(args[0].byteLength);
2975
+ return super.emit(event, ...args);
2976
+ }
2977
+ };
2978
+ //#endregion
2261
2979
  //#region src/extensions.ts
2262
2980
  /** Maximum sizes enforced at the local-extension filesystem boundary. */
2263
2981
  const EXTENSION_LIMITS = Object.freeze({
@@ -2599,6 +3317,11 @@ var MobileAccessService = class extends Service {
2599
3317
  localRefreshAbort;
2600
3318
  localLifecycle = 0;
2601
3319
  localClosed = true;
3320
+ localExecution;
3321
+ localExecutionKey;
3322
+ workerBudget;
3323
+ disposal = /* @__PURE__ */ new Set();
3324
+ recoveries = /* @__PURE__ */ new Map();
2602
3325
  constructor(ctx) {
2603
3326
  super(ctx, "mobileAccess");
2604
3327
  }
@@ -2660,10 +3383,65 @@ var MobileAccessService = class extends Service {
2660
3383
  }
2661
3384
  /** Return loaded and failed local extension counts without exposing host errors. */
2662
3385
  status() {
3386
+ const unavailable = [...this.local.values()].filter((entry) => entry.worker !== void 0 && !entry.worker.available()).length;
2663
3387
  return Object.freeze({
2664
- loaded: this.registered.size + this.local.size,
2665
- failed: this.failures.size
3388
+ loaded: this.registered.size + this.local.size - unavailable,
3389
+ failed: this.failures.size + unavailable
3390
+ });
3391
+ }
3392
+ /** Local Host status does not reveal filesystem paths, credentials or extension errors. */
3393
+ hostStatus() {
3394
+ const limits = resolveHostExecution(this.localExecution?.hostExecution);
3395
+ return {
3396
+ hosts: [...this.local.values()].map((entry) => ({
3397
+ id: entry.manifest.id,
3398
+ name: entry.manifest.name,
3399
+ mode: entry.worker === void 0 ? "in-process" : "worker",
3400
+ state: entry.worker === void 0 || entry.worker.available() ? "ready" : "unavailable",
3401
+ generation: entry.digest
3402
+ })),
3403
+ limits: {
3404
+ maxWorkers: limits.maxWorkers,
3405
+ workers: this.workerBudget?.current ?? 0
3406
+ }
3407
+ };
3408
+ }
3409
+ /** Explicitly replace one unavailable worker without replaying requests or changing its files. */
3410
+ recoverExtension(id) {
3411
+ assertExtensionId(id);
3412
+ const existing = this.recoveries.get(id);
3413
+ if (existing !== void 0) return existing;
3414
+ const recovering = (async () => {
3415
+ if (this.localRefreshing !== void 0) await this.localRefreshing;
3416
+ const previous = this.local.get(id);
3417
+ if (this.localClosed || previous?.worker === void 0 || this.localContext === void 0) throw new MobileExtensionError("extension_recovery_unavailable", "local worker recovery is unavailable", 409);
3418
+ if (previous.worker.available()) throw new MobileExtensionError("extension_host_running", "extension worker is already running", 409);
3419
+ await previous.worker.terminate("explicit-recovery");
3420
+ const lifecycle = this.localLifecycle;
3421
+ const fingerprint = await extensionFingerprint(previous.directory);
3422
+ const fresh = await loadLocalExtension(previous.directory, this.localContext, fingerprint, void 0, this.localExecution);
3423
+ try {
3424
+ const confirmed = await extensionFingerprint(previous.directory);
3425
+ if (this.localClosed || this.localLifecycle !== lifecycle || this.local.get(id) !== previous || confirmed.digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", "extension changed during recovery", 409);
3426
+ } catch (error) {
3427
+ await abortAndDisposeLocal([fresh]);
3428
+ throw error;
3429
+ }
3430
+ this.local.set(id, fresh);
3431
+ this.failures.delete(id);
3432
+ this.queueDisposal([previous]);
3433
+ this.updateContentHash();
3434
+ })().finally(() => {
3435
+ if (this.recoveries.get(id) === recovering) this.recoveries.delete(id);
3436
+ });
3437
+ this.recoveries.set(id, recovering);
3438
+ return recovering;
3439
+ }
3440
+ queueDisposal(entries) {
3441
+ const pending = abortAndDisposeLocal(entries).finally(() => {
3442
+ this.disposal.delete(pending);
2666
3443
  });
3444
+ this.disposal.add(pending);
2667
3445
  }
2668
3446
  /** Locate one active extension. */
2669
3447
  extension(id, generation) {
@@ -2753,7 +3531,7 @@ var MobileAccessService = class extends Service {
2753
3531
  });
2754
3532
  }, {
2755
3533
  external: external?.signal ?? context.signal,
2756
- timeoutMs: action.timeoutMs ?? 3e4,
3534
+ timeoutMs: action.timeoutMs ?? ("host" in extension && extension.worker !== void 0 ? resolveHostExecution(this.localExecution?.hostExecution).operationTimeoutMs : 3e4),
2757
3535
  timeoutError: () => new MobileExtensionError("extension_action_timeout", `extension ${id} action ${actionName} timed out`, 500),
2758
3536
  onDetach: () => {
2759
3537
  this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
@@ -2791,7 +3569,7 @@ var MobileAccessService = class extends Service {
2791
3569
  signal
2792
3570
  }), {
2793
3571
  external: external?.signal ?? request.signal,
2794
- timeoutMs: route.timeoutMs ?? 3e4,
3572
+ timeoutMs: route.timeoutMs ?? ("host" in extension && extension.worker !== void 0 ? resolveHostExecution(this.localExecution?.hostExecution).operationTimeoutMs : 3e4),
2795
3573
  timeoutError: () => new MobileExtensionError("extension_route_timeout", `extension ${id} route ${method} ${pathname} timed out`, 500),
2796
3574
  onDetach: () => {
2797
3575
  this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
@@ -2823,13 +3601,24 @@ var MobileAccessService = class extends Service {
2823
3601
  }
2824
3602
  }
2825
3603
  /** Start the local directory watcher; an absent directory is intentionally inert. */
2826
- async startLocal(root, context) {
3604
+ async startLocal(root, context, options) {
2827
3605
  const targetRoot = resolve(root);
2828
- if (this.localRoot !== void 0 && resolve(this.localRoot) !== targetRoot) await this.stopLocal();
3606
+ const limits = resolveHostExecution(options?.hostExecution);
3607
+ const executionKey = JSON.stringify({
3608
+ limits,
3609
+ module: options?.hostExecution?.workerModule
3610
+ });
3611
+ if (this.localRoot !== void 0 && (resolve(this.localRoot) !== targetRoot || this.localExecutionKey !== executionKey)) await this.stopLocal();
2829
3612
  if (this.localTimer !== void 0) clearInterval(this.localTimer);
2830
3613
  const lifecycle = ++this.localLifecycle;
3614
+ this.workerBudget ??= new ExtensionWorkerBudget(limits.maxWorkers);
2831
3615
  this.localRoot = targetRoot;
2832
3616
  this.localContext = context;
3617
+ this.localExecution = {
3618
+ ...options,
3619
+ workerBudget: this.workerBudget
3620
+ };
3621
+ this.localExecutionKey = executionKey;
2833
3622
  this.localClosed = false;
2834
3623
  await mkdir(this.localRoot, { recursive: true });
2835
3624
  if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
@@ -2863,6 +3652,8 @@ var MobileAccessService = class extends Service {
2863
3652
  this.failures.clear();
2864
3653
  this.updateContentHash();
2865
3654
  await abortAndDisposeLocal(late);
3655
+ await Promise.allSettled([...this.disposal, ...this.recoveries.values()]);
3656
+ this.workerBudget = void 0;
2866
3657
  if (this.localTimer !== void 0) clearInterval(this.localTimer);
2867
3658
  this.localTimer = void 0;
2868
3659
  }
@@ -2906,7 +3697,7 @@ var MobileAccessService = class extends Service {
2906
3697
  const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : void 0;
2907
3698
  if (previous?.digest === fingerprint.digest) staged.push(previous);
2908
3699
  else {
2909
- const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal);
3700
+ const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal, this.localExecution);
2910
3701
  try {
2911
3702
  signal.throwIfAborted();
2912
3703
  if ((await extensionFingerprint(directory)).digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", `extension ${fingerprint.manifest.id} changed during activation`, 409);
@@ -2957,13 +3748,15 @@ var MobileAccessService = class extends Service {
2957
3748
  for (const name of names) this.failures.delete(name);
2958
3749
  for (const failure of this.failures.keys()) if (failure !== "local" && !names.includes(failure)) this.failures.delete(failure);
2959
3750
  this.failures.delete("local");
2960
- if (removed.length > 0) abortAndDisposeLocal(removed);
3751
+ if (removed.length > 0) this.queueDisposal(removed);
2961
3752
  this.updateContentHash();
2962
3753
  } catch (error) {
2963
3754
  await abortAndDisposeLocal(stagedFresh);
2964
3755
  if (this.localClosed || signal.aborted) return;
2965
3756
  const message = error instanceof Error ? error.message : String(error);
3757
+ const changedFailure = this.failures.get(failingName) !== message;
2966
3758
  this.failures.set(failingName, message);
3759
+ if (changedFailure && error instanceof MobileExtensionError && error.code === "extension_worker_heap_override") this.ctx.logger.warn("local extension worker refused: %s", error.message);
2967
3760
  if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
2968
3761
  }
2969
3762
  }
@@ -2973,12 +3766,12 @@ var MobileAccessService = class extends Service {
2973
3766
  if (previous !== void 0) {
2974
3767
  clearTimeout(previous.timer);
2975
3768
  this.retired.delete(active.manifest.id);
2976
- abortAndDisposeLocal([previous.active]);
3769
+ this.queueDisposal([previous.active]);
2977
3770
  }
2978
3771
  const timer = setTimeout(() => {
2979
3772
  if (this.retired.get(active.manifest.id)?.active !== active) return;
2980
3773
  this.retired.delete(active.manifest.id);
2981
- abortAndDisposeLocal([active]);
3774
+ this.queueDisposal([active]);
2982
3775
  }, RETIRED_GENERATION_TTL_MS);
2983
3776
  timer.unref();
2984
3777
  this.retired.set(active.manifest.id, {
@@ -3100,8 +3893,9 @@ async function abortAndDisposeLocal(entries) {
3100
3893
  pending.push(...invokeCleanups(entry.cleanups));
3101
3894
  }
3102
3895
  await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS);
3896
+ await Promise.all(entries.flatMap((entry) => entry.worker === void 0 ? [] : [entry.worker.dispose()]));
3103
3897
  }
3104
- async function loadLocalExtension(directory, context, known, parentSignal) {
3898
+ async function loadLocalExtension(directory, context, known, parentSignal, execution) {
3105
3899
  const root = await realExtensionRoot(directory);
3106
3900
  const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
3107
3901
  const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
@@ -3153,28 +3947,73 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
3153
3947
  }
3154
3948
  };
3155
3949
  try {
3156
- const activate = async () => {
3157
- controller.signal.throwIfAborted();
3158
- if (hostFile !== void 0) {
3159
- const digest = createHash("sha256").update(await readFile(hostFile)).digest("hex");
3950
+ const limits = resolveHostExecution(execution?.hostExecution);
3951
+ const worker = limits.mode === "worker" && (limits.extensions === void 0 || limits.extensions.includes(manifest.id)) && hostFile !== void 0 ? new ExtensionWorkerHost({
3952
+ workerModule: execution?.hostExecution?.workerModule ?? defaultExtensionWorkerModule(),
3953
+ hostFile,
3954
+ manifest,
3955
+ generation: known?.digest ?? createHash("sha256").update(manifest.id).digest("hex"),
3956
+ logger: workerLoggerAdapter(context),
3957
+ limits,
3958
+ ...execution?.workerBudget === void 0 ? {} : { budget: execution.workerBudget },
3959
+ onUnavailable: (error) => {
3960
+ controller.abort(error);
3961
+ }
3962
+ }) : void 0;
3963
+ let host;
3964
+ if (worker !== void 0) {
3965
+ try {
3966
+ await withActivationTimeout(worker.activate(), manifest.id, controller.signal);
3967
+ } catch (error) {
3968
+ await worker.terminate("activation-failed");
3969
+ throw error;
3970
+ }
3971
+ const proxyActions = {};
3972
+ for (const metadata of worker.actionMetadata()) proxyActions[metadata.name] = {
3973
+ ...metadata.timeoutMs === void 0 ? {} : { timeoutMs: metadata.timeoutMs },
3974
+ run: (actionContext, input) => worker.invokeExecution(metadata.name, input, actionContext)
3975
+ };
3976
+ const proxyRoutes = worker.routeMetadata().map((metadata, index) => ({
3977
+ method: metadata.method,
3978
+ path: metadata.path,
3979
+ ...metadata.kind === void 0 ? {} : { kind: metadata.kind },
3980
+ ...metadata.timeoutMs === void 0 ? {} : { timeoutMs: metadata.timeoutMs },
3981
+ handle: (request) => worker.handleRouteExecution(index, request)
3982
+ }));
3983
+ try {
3984
+ host = validateDefinition({
3985
+ ...manifest,
3986
+ ...Object.keys(proxyActions).length === 0 ? {} : { actions: proxyActions },
3987
+ ...proxyRoutes.length === 0 ? {} : { routes: proxyRoutes }
3988
+ });
3989
+ } catch (error) {
3990
+ await worker.terminate("definition-invalid");
3991
+ throw error;
3992
+ }
3993
+ } else {
3994
+ const activate = async () => {
3160
3995
  controller.signal.throwIfAborted();
3161
- let imported;
3162
- try {
3163
- imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`);
3164
- } catch {
3165
- throw new MobileExtensionError("host_load_failed", `could not load ${manifest.id}/host.mjs`, 500);
3996
+ if (hostFile !== void 0) {
3997
+ const digest = createHash("sha256").update(await readFile(hostFile)).digest("hex");
3998
+ controller.signal.throwIfAborted();
3999
+ let imported;
4000
+ try {
4001
+ imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`);
4002
+ } catch {
4003
+ throw new MobileExtensionError("host_load_failed", `could not load ${manifest.id}/host.mjs`, 500);
4004
+ }
4005
+ controller.signal.throwIfAborted();
4006
+ if (imported.default !== void 0) await imported.default(api);
3166
4007
  }
3167
- controller.signal.throwIfAborted();
3168
- if (imported.default !== void 0) await imported.default(api);
3169
- }
3170
- await Promise.all(pendingEffects);
3171
- };
3172
- await withActivationTimeout(activate(), manifest.id, controller.signal);
3173
- const host = validateDefinition({
3174
- ...manifest,
3175
- actions,
3176
- routes
3177
- });
4008
+ await Promise.all(pendingEffects);
4009
+ };
4010
+ await withActivationTimeout(activate(), manifest.id, controller.signal);
4011
+ host = validateDefinition({
4012
+ ...manifest,
4013
+ actions,
4014
+ routes
4015
+ });
4016
+ }
3178
4017
  activationOpen = false;
3179
4018
  const digest = known?.digest ?? createHash("sha256").update(manifest.id).digest("hex");
3180
4019
  return Object.freeze({
@@ -3186,7 +4025,8 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
3186
4025
  host,
3187
4026
  controller,
3188
4027
  cleanups: Object.freeze(cleanups),
3189
- digest
4028
+ digest,
4029
+ ...worker === void 0 ? {} : { worker }
3190
4030
  });
3191
4031
  } catch (error) {
3192
4032
  activationOpen = false;
@@ -3198,6 +4038,23 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
3198
4038
  parentSignal?.removeEventListener("abort", onParentAbort);
3199
4039
  }
3200
4040
  }
4041
+ /** Forward the worker's logger-only context adapter into the plugin logger. */
4042
+ function workerLoggerAdapter(context) {
4043
+ return {
4044
+ debug: (...args) => {
4045
+ context.logger.debug(...args);
4046
+ },
4047
+ info: (...args) => {
4048
+ context.logger.info(...args);
4049
+ },
4050
+ warn: (...args) => {
4051
+ context.logger.warn(...args);
4052
+ },
4053
+ error: (...args) => {
4054
+ context.logger.error(...args);
4055
+ }
4056
+ };
4057
+ }
3201
4058
  /** Construct the service in a Cordis plugin without importing DSH internals. */
3202
4059
  function createMobileAccessService(ctx) {
3203
4060
  return new MobileAccessService(ctx);
@@ -4992,7 +5849,8 @@ var MobileAccessGateway = class {
4992
5849
  }
4993
5850
  const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
4994
5851
  const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
4995
- if (request.method === "GET" && acceptsHtml && !stockFrontend) {
5852
+ const frontendDocument = target.decodedPathname === "/" || target.decodedPathname === "/index.html";
5853
+ if (request.method === "GET" && frontendDocument && acceptsHtml && !stockFrontend) {
4996
5854
  await this.proxyMobileIndex(request, response, authorization);
4997
5855
  return;
4998
5856
  }
@@ -5101,14 +5959,27 @@ var MobileAccessGateway = class {
5101
5959
  signal: abort.signal,
5102
5960
  deviceId: authorization.deviceId
5103
5961
  }, generation);
5104
- let serialized;
5105
- try {
5106
- serialized = Buffer.from(JSON.stringify(result));
5107
- } catch {
5108
- throw new MobileExtensionError("extension_failed", "extension action failed", 500);
5962
+ if (isPreparedJson(result)) {
5963
+ if (result.bytes.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
5964
+ if (!response.headersSent && !response.destroyed) {
5965
+ const body = Buffer.concat([result.bytes, Buffer.from("\n")]);
5966
+ setSecurityHeaders(response, this.tlsEnabled);
5967
+ response.writeHead(200, {
5968
+ "Content-Type": "application/json; charset=utf-8",
5969
+ "Content-Length": body.byteLength
5970
+ });
5971
+ response.end(body);
5972
+ }
5973
+ } else {
5974
+ let serialized;
5975
+ try {
5976
+ serialized = Buffer.from(JSON.stringify(result));
5977
+ } catch {
5978
+ throw new MobileExtensionError("extension_failed", "extension action failed", 500);
5979
+ }
5980
+ if (serialized.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
5981
+ sendJson(response, 200, result, this.tlsEnabled);
5109
5982
  }
5110
- if (serialized.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
5111
- sendJson(response, 200, result, this.tlsEnabled);
5112
5983
  } finally {
5113
5984
  generationSignal?.removeEventListener("abort", onGenerationAbort);
5114
5985
  abort.abort();
@@ -5371,7 +6242,7 @@ var MobileAccessGateway = class {
5371
6242
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
5372
6243
  port: Number(this.config.upstreamOrigin.port),
5373
6244
  method: "GET",
5374
- path: "/",
6245
+ path: incoming?.url ?? "/",
5375
6246
  headers,
5376
6247
  agent: false
5377
6248
  });
@@ -6436,12 +7307,180 @@ var MemoryDeviceStore = class {
6436
7307
  }
6437
7308
  };
6438
7309
  //#endregion
6439
- //#region src/frp-component.ts
6440
- const FRP_VERSION$1 = "0.70.1";
6441
- const MAX_ARCHIVE_ENTRIES$1 = 128;
6442
- const MAX_ARCHIVE_LIST_BYTES$1 = 262144;
6443
- /** Pinned official FRP release metadata for supported desktop targets. */
6444
- const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
7310
+ //#region src/managed-files.ts
7311
+ /** Refuse link-shaped ancestors inside an owned state root before accessing a child. */
7312
+ async function assertManagedParents(root, target, prefix) {
7313
+ const parent = resolve(root);
7314
+ const candidate = resolve(target);
7315
+ const offset = relative(parent, candidate);
7316
+ if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error(`${prefix}_path_invalid`);
7317
+ const paths = [parent];
7318
+ let cursor = parent;
7319
+ if (offset !== "") for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
7320
+ cursor = join(cursor, segment);
7321
+ paths.push(cursor);
7322
+ }
7323
+ for (const path of paths) {
7324
+ let entry;
7325
+ try {
7326
+ entry = await lstat(path);
7327
+ } catch (error) {
7328
+ if (error.code === "ENOENT") continue;
7329
+ throw error;
7330
+ }
7331
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
7332
+ }
7333
+ }
7334
+ /** Retry Windows handle contention while checking ownership before every mutation. */
7335
+ async function mutateManagedPath(validate, mutate) {
7336
+ let original;
7337
+ for (let attempt = 0;; attempt++) {
7338
+ await validate();
7339
+ try {
7340
+ await mutate();
7341
+ return;
7342
+ } catch (error) {
7343
+ const code = error.code;
7344
+ if (process.platform !== "win32" || code !== "EPERM" && code !== "EBUSY") throw error;
7345
+ if (attempt === 0) original = error;
7346
+ if (attempt === 7) throw original;
7347
+ await new Promise((resolve) => setTimeout(resolve, Math.min(50 * 2 ** attempt, 400)));
7348
+ }
7349
+ }
7350
+ }
7351
+ /** Move only owned paths, retaining parent checks on every Windows retry. */
7352
+ async function renameManagedPath(root, source, destination, prefix) {
7353
+ if (resolve(source) === resolve(root) || resolve(destination) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7354
+ await mutateManagedPath(async () => {
7355
+ await assertManagedParents(root, source, prefix);
7356
+ await assertManagedParents(root, destination, prefix);
7357
+ }, () => rename(source, destination));
7358
+ }
7359
+ /** Create a real owned directory; secure new directories without changing existing child ACLs. */
7360
+ async function ensureManagedDirectory(root, directory, prefix) {
7361
+ await assertManagedParents(root, directory, prefix);
7362
+ const created = await mkdir(directory, {
7363
+ recursive: true,
7364
+ mode: 448
7365
+ });
7366
+ const entry = await lstat(directory);
7367
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
7368
+ if (created !== void 0) await restrictPrivateFile(directory, 448);
7369
+ }
7370
+ /** Delete an owned child tree; remove links themselves and never delete the root. */
7371
+ async function removeManagedTree(root, target, prefix) {
7372
+ if (resolve(target) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7373
+ await assertManagedParents(root, target, prefix);
7374
+ let entry;
7375
+ try {
7376
+ entry = await lstat(target);
7377
+ } catch (error) {
7378
+ if (error.code === "ENOENT") return;
7379
+ throw error;
7380
+ }
7381
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
7382
+ await mutateManagedPath(() => assertManagedParents(root, target, prefix), () => unlink(target));
7383
+ return;
7384
+ }
7385
+ for (const name of await readdir(target)) await removeManagedTree(root, join(target, name), prefix);
7386
+ await mutateManagedPath(() => assertManagedParents(root, target, prefix), () => rmdir(target));
7387
+ }
7388
+ /** Validate every selected child, then await all deletion work even when one fails. */
7389
+ async function removeManagedPaths(root, paths, prefix) {
7390
+ for (const path of paths) {
7391
+ if (resolve(path) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7392
+ await assertManagedParents(root, path, prefix);
7393
+ }
7394
+ const errors = (await Promise.allSettled(paths.map((path) => removeManagedTree(root, path, prefix)))).filter((result) => result.status === "rejected").map((result) => result.reason);
7395
+ if (errors.length === 1) throw errors[0];
7396
+ if (errors.length > 1) throw new AggregateError(errors, `${prefix}_cleanup_failed`);
7397
+ }
7398
+ /** Atomically replace one sensitive file beneath real owned directories. */
7399
+ async function writeManagedPrivateFile(root, file, body, prefix) {
7400
+ await ensureManagedDirectory(root, dirname(file), prefix);
7401
+ try {
7402
+ const entry = await lstat(file);
7403
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`${prefix}_config_target_invalid`);
7404
+ } catch (error) {
7405
+ if (error.code !== "ENOENT") throw error;
7406
+ }
7407
+ const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
7408
+ let failed = false;
7409
+ try {
7410
+ await writeFile(temporary, body, {
7411
+ encoding: "utf8",
7412
+ flag: "wx",
7413
+ mode: 384
7414
+ });
7415
+ await restrictPrivateFile(temporary);
7416
+ await renameManagedPath(root, temporary, file, prefix);
7417
+ } catch (error) {
7418
+ failed = true;
7419
+ throw error;
7420
+ } finally {
7421
+ try {
7422
+ await removeManagedTree(root, temporary, prefix);
7423
+ } catch (error) {
7424
+ if (!failed) throw error;
7425
+ }
7426
+ }
7427
+ }
7428
+ /** Promote a verified directory, restoring the previous version if promotion fails. */
7429
+ async function replaceManagedDirectory(root, target, candidate, prefix, operations = {}) {
7430
+ if (resolve(target) === resolve(root) || resolve(candidate) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7431
+ const move = async (source, destination) => {
7432
+ await assertManagedParents(root, source, prefix);
7433
+ await assertManagedParents(root, destination, prefix);
7434
+ if (operations.move === void 0) await renameManagedPath(root, source, destination, prefix);
7435
+ else await operations.move(source, destination);
7436
+ };
7437
+ const remove = operations.remove ?? ((path) => removeManagedTree(root, path, prefix));
7438
+ const backup = join(dirname(target), `.${basename(target)}.previous-${randomBytes(12).toString("hex")}`);
7439
+ let previous = false;
7440
+ let promoted = false;
7441
+ try {
7442
+ await assertManagedParents(root, join(candidate, "publication-check"), prefix);
7443
+ await assertManagedParents(root, target, prefix);
7444
+ try {
7445
+ const entry = await lstat(target);
7446
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
7447
+ await move(target, backup);
7448
+ previous = true;
7449
+ } catch (error) {
7450
+ if (error.code !== "ENOENT") throw error;
7451
+ }
7452
+ try {
7453
+ await move(candidate, target);
7454
+ promoted = true;
7455
+ } catch (error) {
7456
+ if (previous) try {
7457
+ await move(backup, target);
7458
+ previous = false;
7459
+ } catch (restoreError) {
7460
+ throw new AggregateError([error, restoreError], `${prefix}_component_replace_failed`);
7461
+ }
7462
+ throw error;
7463
+ }
7464
+ } finally {
7465
+ try {
7466
+ await remove(candidate);
7467
+ } catch (_error) {
7468
+ process.emitWarning(`${prefix} candidate cleanup failed`, { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
7469
+ }
7470
+ if (promoted && previous) try {
7471
+ await remove(backup);
7472
+ } catch (_error) {
7473
+ process.emitWarning(`${prefix} previous component cleanup failed`, { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
7474
+ }
7475
+ }
7476
+ }
7477
+ //#endregion
7478
+ //#region src/frp-component.ts
7479
+ const FRP_VERSION$1 = "0.70.1";
7480
+ const MAX_ARCHIVE_ENTRIES$1 = 128;
7481
+ const MAX_ARCHIVE_LIST_BYTES$1 = 262144;
7482
+ /** Pinned official FRP release metadata for supported desktop targets. */
7483
+ const FRP_COMPONENT_RELEASES = Object.freeze(Object.fromEntries([
6445
7484
  {
6446
7485
  platform: "win32",
6447
7486
  arch: "x64",
@@ -6510,37 +7549,6 @@ async function regularFile$2(file) {
6510
7549
  throw error;
6511
7550
  }
6512
7551
  }
6513
- async function replaceDirectory(target, candidate) {
6514
- const backup = `${target}.previous-${randomBytes(12).toString("hex")}`;
6515
- let previous = false;
6516
- try {
6517
- try {
6518
- await rename(target, backup);
6519
- previous = true;
6520
- } catch (error) {
6521
- if (error.code !== "ENOENT") throw error;
6522
- }
6523
- try {
6524
- await rename(candidate, target);
6525
- } catch (error) {
6526
- if (previous) try {
6527
- await rename(backup, target);
6528
- } catch (restoreError) {
6529
- throw new AggregateError([error, restoreError], "frp_component_replace_failed");
6530
- }
6531
- throw error;
6532
- }
6533
- if (previous) await rm(backup, {
6534
- recursive: true,
6535
- force: true
6536
- });
6537
- } finally {
6538
- await rm(candidate, {
6539
- recursive: true,
6540
- force: true
6541
- });
6542
- }
6543
- }
6544
7552
  function sha256$2(bytes) {
6545
7553
  return createHash("sha256").update(bytes).digest("hex");
6546
7554
  }
@@ -6641,6 +7649,7 @@ var FrpComponentManager = class {
6641
7649
  componentStorage;
6642
7650
  logRoot;
6643
7651
  stagingRoot;
7652
+ stateDirectory;
6644
7653
  artifact;
6645
7654
  fetchArtifact;
6646
7655
  extractArtifact;
@@ -6650,8 +7659,9 @@ var FrpComponentManager = class {
6650
7659
  errorCode;
6651
7660
  queue = Promise.resolve();
6652
7661
  constructor(options) {
7662
+ if (!isAbsolute(options.stateDirectory)) throw new Error("frp state directory must be absolute");
6653
7663
  const stateDirectory = resolve(options.stateDirectory);
6654
- if (!isAbsolute(stateDirectory)) throw new Error("frp state directory must be absolute");
7664
+ this.stateDirectory = stateDirectory;
6655
7665
  const platform = options.platform ?? process.platform;
6656
7666
  const arch = options.arch ?? process.arch;
6657
7667
  this.artifact = FRP_COMPONENT_RELEASES[`${platform}-${arch}`];
@@ -6672,13 +7682,19 @@ var FrpComponentManager = class {
6672
7682
  }
6673
7683
  /** Inspect the managed executable without relying on global FRP installations. */
6674
7684
  async initialize() {
6675
- this.installed = await regularFile$2(this.executable);
6676
- this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
6677
- if (this.installed) try {
6678
- if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
6679
- this.errorCode = void 0;
6680
- } catch {
7685
+ this.installed = false;
7686
+ this.installedBytes = 0;
7687
+ this.errorCode = void 0;
7688
+ try {
7689
+ await assertManagedParents(this.stateDirectory, this.executable, "frp");
7690
+ this.installed = await regularFile$2(this.executable);
7691
+ this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
7692
+ if (this.installed) {
7693
+ if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
7694
+ }
7695
+ } catch (_error) {
6681
7696
  this.installed = false;
7697
+ this.installedBytes = 0;
6682
7698
  this.errorCode = "frp_component_invalid";
6683
7699
  }
6684
7700
  }
@@ -6701,10 +7717,8 @@ var FrpComponentManager = class {
6701
7717
  return this.enqueue(async () => {
6702
7718
  const artifact = this.artifact;
6703
7719
  if (artifact === void 0) throw new Error("frp_component_unsupported");
6704
- await mkdir(this.stagingRoot, {
6705
- recursive: true,
6706
- mode: 448
6707
- });
7720
+ await assertManagedParents(this.stateDirectory, this.executable, "frp");
7721
+ await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "frp");
6708
7722
  const staging = await mkdtemp(join(this.stagingRoot, "install-"));
6709
7723
  try {
6710
7724
  const controller = new AbortController();
@@ -6731,42 +7745,36 @@ var FrpComponentManager = class {
6731
7745
  await chmod(extracted, 448);
6732
7746
  if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
6733
7747
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
6734
- await mkdir(candidate, {
6735
- recursive: true,
6736
- mode: 448
6737
- });
6738
- const candidateExecutable = join(candidate, artifact.executableName);
6739
- await copyFile(extracted, candidateExecutable);
6740
- await chmod(candidateExecutable, 448);
6741
- await replaceDirectory(this.componentStorage, candidate);
7748
+ await ensureManagedDirectory(this.stateDirectory, candidate, "frp");
7749
+ try {
7750
+ const candidateExecutable = join(candidate, artifact.executableName);
7751
+ await copyFile(extracted, candidateExecutable);
7752
+ await chmod(candidateExecutable, 448);
7753
+ await replaceManagedDirectory(this.stateDirectory, this.componentStorage, candidate, "frp");
7754
+ } catch (error) {
7755
+ try {
7756
+ await removeManagedTree(this.stateDirectory, candidate, "frp");
7757
+ } catch (_cleanupError) {
7758
+ process.emitWarning("FRP candidate cleanup failed", { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
7759
+ }
7760
+ throw error;
7761
+ }
6742
7762
  this.installed = true;
6743
7763
  this.installedBytes = (await stat(this.executable)).size;
6744
7764
  this.errorCode = void 0;
6745
7765
  } finally {
6746
- await rm(staging, {
6747
- recursive: true,
6748
- force: true
6749
- });
7766
+ await removeManagedTree(this.stateDirectory, staging, "frp");
6750
7767
  }
6751
7768
  });
6752
7769
  }
6753
7770
  /** Remove all FRP executable, staging, and log files owned by DSH Mobile. */
6754
7771
  purge() {
6755
7772
  return this.enqueue(async () => {
6756
- await Promise.all([
6757
- rm(this.componentRoot, {
6758
- recursive: true,
6759
- force: true
6760
- }),
6761
- rm(this.logRoot, {
6762
- recursive: true,
6763
- force: true
6764
- }),
6765
- rm(this.stagingRoot, {
6766
- recursive: true,
6767
- force: true
6768
- })
6769
- ]);
7773
+ await removeManagedPaths(this.stateDirectory, [
7774
+ this.componentRoot,
7775
+ this.logRoot,
7776
+ this.stagingRoot
7777
+ ], "frp");
6770
7778
  this.installed = false;
6771
7779
  this.installedBytes = 0;
6772
7780
  this.errorCode = void 0;
@@ -7188,34 +8196,9 @@ function createFrpServerTemplate(settings) {
7188
8196
  entryTls: resolveFrpEntryTls(settings)
7189
8197
  });
7190
8198
  }
7191
- async function atomicPrivateWrite$2(file, body) {
7192
- const directory = dirname(file);
7193
- await mkdir(directory, {
7194
- recursive: true,
7195
- mode: 448
7196
- });
7197
- try {
7198
- const current = await lstat(file);
7199
- if (!current.isFile() || current.isSymbolicLink()) throw new Error("frp_config_target_invalid");
7200
- } catch (error) {
7201
- if (error.code !== "ENOENT") throw error;
7202
- }
7203
- const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
7204
- try {
7205
- await writeFile(temporary, body, {
7206
- encoding: "utf8",
7207
- flag: "wx",
7208
- mode: 384
7209
- });
7210
- await rename(temporary, file);
7211
- await restrictPrivateFile(file);
7212
- } catch (error) {
7213
- await rm(temporary, { force: true });
7214
- throw error;
7215
- }
7216
- }
7217
8199
  /** Owns private FRP settings and generation-specific frpc configuration. */
7218
8200
  var FrpConfigStore = class {
8201
+ ownedRoot;
7219
8202
  stateRoot;
7220
8203
  settingsFile;
7221
8204
  runtimeConfigFile;
@@ -7229,7 +8212,8 @@ var FrpConfigStore = class {
7229
8212
  proxyName;
7230
8213
  settingsValue;
7231
8214
  errorCode;
7232
- constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME) {
8215
+ constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME, ownedRoot = stateDirectory) {
8216
+ this.ownedRoot = ownedRoot;
7233
8217
  if (!isAbsolute(stateDirectory)) throw new Error("frp config state directory must be absolute");
7234
8218
  this.stateRoot = resolve(stateDirectory);
7235
8219
  this.settingsFile = join(this.stateRoot, "settings.json");
@@ -7238,6 +8222,14 @@ var FrpConfigStore = class {
7238
8222
  }
7239
8223
  /** Load private settings while rejecting links, oversized files, and unknown fields. */
7240
8224
  async initialize() {
8225
+ this.settingsValue = void 0;
8226
+ this.errorCode = void 0;
8227
+ try {
8228
+ await assertManagedParents(this.ownedRoot, this.settingsFile, "frp");
8229
+ } catch (_error) {
8230
+ this.errorCode = "frp_config_invalid";
8231
+ return;
8232
+ }
7241
8233
  let entry;
7242
8234
  try {
7243
8235
  entry = await lstat(this.settingsFile);
@@ -7292,8 +8284,8 @@ var FrpConfigStore = class {
7292
8284
  /** Atomically replace private FRP settings. */
7293
8285
  async configure(value) {
7294
8286
  const settings = parseFrpSettings(value);
7295
- await atomicPrivateWrite$2(this.settingsFile, `${JSON.stringify(settings)}\n`);
7296
- await rm(this.runtimeConfigFile, { force: true });
8287
+ await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, `${JSON.stringify(settings)}\n`, "frp");
8288
+ await removeManagedTree(this.ownedRoot, this.runtimeConfigFile, "frp");
7297
8289
  this.settingsValue = settings;
7298
8290
  this.errorCode = void 0;
7299
8291
  return this.status();
@@ -7302,15 +8294,12 @@ var FrpConfigStore = class {
7302
8294
  async writeRuntimeConfig(localPort) {
7303
8295
  const settings = this.settingsValue;
7304
8296
  if (settings === void 0) throw new Error("frp_config_missing");
7305
- await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort, this.proxyName));
8297
+ await writeManagedPrivateFile(this.ownedRoot, this.runtimeConfigFile, createFrpcToml(settings, localPort, this.proxyName), "frp");
7306
8298
  return this.runtimeConfigFile;
7307
8299
  }
7308
8300
  /** Remove only configuration files owned by the FRP provider. */
7309
8301
  async purge() {
7310
- await rm(this.stateRoot, {
7311
- recursive: true,
7312
- force: true
7313
- });
8302
+ await removeManagedPaths(this.ownedRoot, [this.settingsFile, this.runtimeConfigFile], "frp");
7314
8303
  this.settingsValue = void 0;
7315
8304
  this.errorCode = void 0;
7316
8305
  return this.status();
@@ -8197,25 +9186,11 @@ async function readIngressIdentity(file) {
8197
9186
  throw new Error("frp_ingress_ca_invalid", { cause: error });
8198
9187
  }
8199
9188
  }
8200
- async function writeIngressIdentity(file, fingerprint) {
8201
- const temporary = join(dirname(file), `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
8202
- try {
8203
- await writeFile(temporary, `${JSON.stringify({
8204
- version: 1,
8205
- caFingerprint: fingerprint
8206
- })}\n`, {
8207
- flag: "wx",
8208
- mode: 384
8209
- });
8210
- await rename(temporary, file);
8211
- await restrictPrivateFile(file);
8212
- } finally {
8213
- try {
8214
- await unlink(temporary);
8215
- } catch (error) {
8216
- if (error.code !== "ENOENT") throw error;
8217
- }
8218
- }
9189
+ async function writeIngressIdentity(file, fingerprint, ownedRoot) {
9190
+ await writeManagedPrivateFile(ownedRoot, file, `${JSON.stringify({
9191
+ version: 1,
9192
+ caFingerprint: fingerprint
9193
+ })}\n`, "frp_ingress");
8219
9194
  }
8220
9195
  function coversPublicIp(certificate, address) {
8221
9196
  return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
@@ -8227,9 +9202,11 @@ function coversPublicIp(certificate, address) {
8227
9202
  * replaced silently. The leaf is re-signed when expiring or no longer valid for
8228
9203
  * the public IPv4, and the running gateway reloads it after a renewal check.
8229
9204
  */
8230
- async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
9205
+ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint, ownedRoot = dirname(stateFile)) {
8231
9206
  if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
8232
9207
  const paths = frpIngressPaths(stateFile);
9208
+ await assertManagedParents(ownedRoot, paths.statusFile, "frp_ingress");
9209
+ await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
8233
9210
  try {
8234
9211
  const directory = await lstat(paths.directory);
8235
9212
  if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
@@ -8246,10 +9223,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8246
9223
  if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
8247
9224
  if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
8248
9225
  if (previousFingerprint === void 0) {
8249
- await mkdir(dirname(paths.statusFile), {
8250
- recursive: true,
8251
- mode: 448
8252
- });
9226
+ await ensureManagedDirectory(ownedRoot, dirname(paths.statusFile), "frp_ingress");
8253
9227
  try {
8254
9228
  await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
8255
9229
  flag: "wx",
@@ -8269,6 +9243,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8269
9243
  };
8270
9244
  let ca;
8271
9245
  try {
9246
+ await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
8272
9247
  ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
8273
9248
  } catch (error) {
8274
9249
  throw new Error("frp_ingress_ca_invalid", { cause: error });
@@ -8276,7 +9251,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8276
9251
  const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
8277
9252
  if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
8278
9253
  if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
8279
- if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
9254
+ if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint, ownedRoot);
8280
9255
  const publicHost = new URL(settings.publicOrigin).hostname;
8281
9256
  let leaf;
8282
9257
  try {
@@ -8285,6 +9260,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8285
9260
  leaf = void 0;
8286
9261
  }
8287
9262
  if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
9263
+ await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
8288
9264
  await issueServerCertificate({
8289
9265
  caCertFile: paths.caCertFile,
8290
9266
  caKeyFile: paths.caKeyFile
@@ -8314,7 +9290,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8314
9290
  * No secret ever leaves this function: the CA is reported by fingerprint only,
8315
9291
  * and the token is never read.
8316
9292
  */
8317
- async function frpIngressSelfCheck(settings, stateFile) {
9293
+ async function frpIngressSelfCheck(settings, stateFile, ownedRoot = dirname(stateFile)) {
8318
9294
  const paths = frpIngressPaths(stateFile);
8319
9295
  const base = {
8320
9296
  mode: resolveFrpMode(settings),
@@ -8331,6 +9307,8 @@ async function frpIngressSelfCheck(settings, stateFile) {
8331
9307
  }
8332
9308
  };
8333
9309
  if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
9310
+ await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
9311
+ await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
8334
9312
  let caFingerprint;
8335
9313
  try {
8336
9314
  caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
@@ -8345,8 +9323,10 @@ async function frpIngressSelfCheck(settings, stateFile) {
8345
9323
  });
8346
9324
  }
8347
9325
  /** Remove only the five files owned by the local self-signed FRP entry. */
8348
- async function purgeFrpIngressCertificates(stateFile) {
9326
+ async function purgeFrpIngressCertificates(stateFile, ownedRoot = dirname(stateFile)) {
8349
9327
  const paths = frpIngressPaths(stateFile);
9328
+ await assertManagedParents(ownedRoot, paths.statusFile, "frp_ingress");
9329
+ await assertManagedParents(ownedRoot, paths.directory, "frp_ingress");
8350
9330
  try {
8351
9331
  const marker = await lstat(paths.statusFile);
8352
9332
  if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
@@ -8372,6 +9352,7 @@ async function purgeFrpIngressCertificates(stateFile) {
8372
9352
  paths.certFile,
8373
9353
  paths.keyFile
8374
9354
  ]) {
9355
+ await assertManagedParents(ownedRoot, file, "frp_ingress");
8375
9356
  let entry;
8376
9357
  try {
8377
9358
  entry = await lstat(file);
@@ -8383,6 +9364,7 @@ async function purgeFrpIngressCertificates(stateFile) {
8383
9364
  await unlink(file);
8384
9365
  }
8385
9366
  try {
9367
+ await assertManagedParents(ownedRoot, paths.directory, "frp_ingress");
8386
9368
  await rmdir(paths.directory);
8387
9369
  } catch (error) {
8388
9370
  if (error.code === "ENOENT") return;
@@ -9303,39 +10285,15 @@ function parseOriginSettings(value) {
9303
10285
  allowedCidrs: validateOriginAllowedCidrs(record.allowedCidrs, listenHost)
9304
10286
  });
9305
10287
  }
9306
- async function atomicPrivateWrite$1(file, body) {
9307
- const directory = dirname(file);
9308
- await mkdir(directory, {
9309
- recursive: true,
9310
- mode: 448
9311
- });
9312
- try {
9313
- const current = await lstat(file);
9314
- if (!current.isFile() || current.isSymbolicLink()) throw new Error("origin_config_target_invalid");
9315
- } catch (error) {
9316
- if (error.code !== "ENOENT") throw error;
9317
- }
9318
- const temporary = join(directory, "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
9319
- try {
9320
- await writeFile(temporary, body, {
9321
- encoding: "utf8",
9322
- flag: "wx",
9323
- mode: 384
9324
- });
9325
- await rename(temporary, file);
9326
- await restrictPrivateFile(file);
9327
- } catch (error) {
9328
- await rm(temporary, { force: true });
9329
- throw error;
9330
- }
9331
- }
9332
10288
  /** Owns only origin settings; shared paired-device storage is never removed. */
9333
10289
  var OriginConfigStore = class {
10290
+ ownedRoot;
9334
10291
  stateRoot;
9335
10292
  settingsFile;
9336
10293
  settingsValue;
9337
10294
  errorCode;
9338
- constructor(stateDirectory) {
10295
+ constructor(stateDirectory, ownedRoot = stateDirectory) {
10296
+ this.ownedRoot = ownedRoot;
9339
10297
  if (!isAbsolute(stateDirectory)) throw new Error("origin config state directory must be absolute");
9340
10298
  this.stateRoot = resolve(stateDirectory);
9341
10299
  this.settingsFile = join(this.stateRoot, "settings.json");
@@ -9343,6 +10301,12 @@ var OriginConfigStore = class {
9343
10301
  async initialize() {
9344
10302
  this.settingsValue = void 0;
9345
10303
  this.errorCode = void 0;
10304
+ try {
10305
+ await assertManagedParents(this.ownedRoot, this.settingsFile, "origin");
10306
+ } catch (_error) {
10307
+ this.errorCode = "origin_config_invalid";
10308
+ return;
10309
+ }
9346
10310
  let entry;
9347
10311
  try {
9348
10312
  entry = await lstat(this.settingsFile);
@@ -9381,13 +10345,13 @@ var OriginConfigStore = class {
9381
10345
  }
9382
10346
  async configure(value) {
9383
10347
  const settings = parseOriginSettings(value);
9384
- await atomicPrivateWrite$1(this.settingsFile, JSON.stringify(settings) + "\n");
10348
+ await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, JSON.stringify(settings) + "\n", "origin");
9385
10349
  this.settingsValue = settings;
9386
10350
  this.errorCode = void 0;
9387
10351
  return this.status();
9388
10352
  }
9389
10353
  async purge() {
9390
- await rm(this.settingsFile, { force: true });
10354
+ await removeManagedTree(this.ownedRoot, this.settingsFile, "origin");
9391
10355
  this.settingsValue = void 0;
9392
10356
  this.errorCode = void 0;
9393
10357
  return this.status();
@@ -9805,6 +10769,7 @@ var CloudflaredComponentManager = class {
9805
10769
  stateRoot;
9806
10770
  logRoot;
9807
10771
  stagingRoot;
10772
+ stateDirectory;
9808
10773
  platform;
9809
10774
  arch;
9810
10775
  release;
@@ -9814,8 +10779,9 @@ var CloudflaredComponentManager = class {
9814
10779
  errorCode;
9815
10780
  queue = Promise.resolve();
9816
10781
  constructor(options) {
10782
+ if (!isAbsolute(options.stateDirectory)) throw new Error("cloudflared state directory must be absolute");
9817
10783
  const stateDirectory = resolve(options.stateDirectory);
9818
- if (!isAbsolute(stateDirectory)) throw new Error("cloudflared state directory must be absolute");
10784
+ this.stateDirectory = stateDirectory;
9819
10785
  this.platform = options.platform ?? process.platform;
9820
10786
  this.arch = options.arch ?? process.arch;
9821
10787
  this.release = lookupRelease(this.platform, this.arch);
@@ -9839,9 +10805,16 @@ var CloudflaredComponentManager = class {
9839
10805
  /** Inspect the managed binary without using any global cloudflared state. */
9840
10806
  async initialize() {
9841
10807
  const release = this.release;
10808
+ this.installed = false;
9842
10809
  this.errorCode = void 0;
9843
- this.installed = release !== void 0 && await regularFile$1(this.executable, release.executableBytes);
9844
- if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.executableSha256) {
10810
+ try {
10811
+ await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
10812
+ this.installed = release !== void 0 && await regularFile$1(this.executable, release.executableBytes);
10813
+ if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.executableSha256) {
10814
+ this.installed = false;
10815
+ this.errorCode = "cloudflared_component_invalid";
10816
+ }
10817
+ } catch (_error) {
9845
10818
  this.installed = false;
9846
10819
  this.errorCode = "cloudflared_component_invalid";
9847
10820
  }
@@ -9867,10 +10840,8 @@ var CloudflaredComponentManager = class {
9867
10840
  return this.enqueue(async () => {
9868
10841
  const release = this.release;
9869
10842
  if (release === void 0) throw new Error("cloudflared_component_unsupported");
9870
- await mkdir(this.stagingRoot, {
9871
- recursive: true,
9872
- mode: 448
9873
- });
10843
+ await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
10844
+ await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "cloudflared");
9874
10845
  const staging = await mkdtemp(join(this.stagingRoot, "install-"));
9875
10846
  try {
9876
10847
  const controller = new AbortController();
@@ -9906,48 +10877,43 @@ var CloudflaredComponentManager = class {
9906
10877
  await chmod(staged, 448);
9907
10878
  if (!await regularFile$1(staged, release.executableBytes) || await sha256$1(staged) !== release.executableSha256) throw new Error("cloudflared_executable_hash_mismatch");
9908
10879
  const candidate = join(this.componentRoot, `.install-${randomBytes(12).toString("hex")}`);
9909
- await mkdir(candidate, {
9910
- recursive: true,
9911
- mode: 448
9912
- });
9913
- await copyFile(staged, join(candidate, release.executableName));
9914
- await chmod(join(candidate, release.executableName), 448);
9915
- await rm(this.componentStorage, {
9916
- recursive: true,
9917
- force: true
9918
- });
9919
- await rename(candidate, this.componentStorage);
10880
+ await ensureManagedDirectory(this.stateDirectory, candidate, "cloudflared");
10881
+ try {
10882
+ await copyFile(staged, join(candidate, release.executableName));
10883
+ await chmod(join(candidate, release.executableName), 448);
10884
+ await replaceManagedDirectory(this.stateDirectory, this.componentStorage, candidate, "cloudflared");
10885
+ } catch (error) {
10886
+ try {
10887
+ await removeManagedTree(this.stateDirectory, candidate, "cloudflared");
10888
+ } catch (_cleanupError) {
10889
+ process.emitWarning("cloudflared candidate cleanup failed", { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
10890
+ }
10891
+ throw error;
10892
+ }
9920
10893
  this.installed = true;
9921
10894
  this.errorCode = void 0;
10895
+ } catch (error) {
10896
+ try {
10897
+ await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
10898
+ this.installed = await regularFile$1(this.executable, release.executableBytes) && await sha256$1(this.executable) === release.executableSha256;
10899
+ } catch (_inspectionError) {
10900
+ this.installed = false;
10901
+ }
10902
+ throw error;
9922
10903
  } finally {
9923
- await rm(staging, {
9924
- recursive: true,
9925
- force: true
9926
- });
10904
+ await removeManagedTree(this.stateDirectory, staging, "cloudflared");
9927
10905
  }
9928
10906
  });
9929
10907
  }
9930
10908
  /** Remove every cloudflared file owned by DSH Mobile without touching global state. */
9931
10909
  purge() {
9932
10910
  return this.enqueue(async () => {
9933
- await Promise.all([
9934
- rm(this.componentRoot, {
9935
- recursive: true,
9936
- force: true
9937
- }),
9938
- rm(this.stateRoot, {
9939
- recursive: true,
9940
- force: true
9941
- }),
9942
- rm(this.logRoot, {
9943
- recursive: true,
9944
- force: true
9945
- }),
9946
- rm(this.stagingRoot, {
9947
- recursive: true,
9948
- force: true
9949
- })
9950
- ]);
10911
+ await removeManagedPaths(this.stateDirectory, [
10912
+ this.componentRoot,
10913
+ this.stateRoot,
10914
+ this.logRoot,
10915
+ this.stagingRoot
10916
+ ], "cloudflared");
9951
10917
  this.installed = false;
9952
10918
  this.errorCode = void 0;
9953
10919
  });
@@ -10589,34 +11555,9 @@ function mergeSavedCloudflaredTunnelSettings(partial, saved) {
10589
11555
  port
10590
11556
  });
10591
11557
  }
10592
- async function atomicPrivateWrite(file, body) {
10593
- const directory = dirname(file);
10594
- await mkdir(directory, {
10595
- recursive: true,
10596
- mode: 448
10597
- });
10598
- try {
10599
- const current = await lstat(file);
10600
- if (!current.isFile() || current.isSymbolicLink()) throw new Error("cloudflared_tunnel_target_invalid");
10601
- } catch (error) {
10602
- if (error.code !== "ENOENT") throw error;
10603
- }
10604
- const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
10605
- try {
10606
- await writeFile(temporary, body, {
10607
- encoding: "utf8",
10608
- flag: "wx",
10609
- mode: 384
10610
- });
10611
- await rename(temporary, file);
10612
- await restrictPrivateFile(file);
10613
- } catch (error) {
10614
- await rm(temporary, { force: true });
10615
- throw error;
10616
- }
10617
- }
10618
11558
  /** Owns the private cloudflared tunnel configuration for one DSH installation. */
10619
11559
  var CloudflaredTunnelStore = class {
11560
+ ownedRoot;
10620
11561
  stateRoot;
10621
11562
  settingsFile;
10622
11563
  settingsValue = Object.freeze({
@@ -10624,13 +11565,25 @@ var CloudflaredTunnelStore = class {
10624
11565
  mode: "quick"
10625
11566
  });
10626
11567
  errorCode;
10627
- constructor(stateDirectory) {
11568
+ constructor(stateDirectory, ownedRoot = stateDirectory) {
11569
+ this.ownedRoot = ownedRoot;
10628
11570
  if (!isAbsolute(stateDirectory)) throw new Error("cloudflared tunnel state directory must be absolute");
10629
11571
  this.stateRoot = resolve(stateDirectory);
10630
11572
  this.settingsFile = join(this.stateRoot, "tunnel.json");
10631
11573
  }
10632
11574
  /** Load private settings while rejecting links, oversized files and unknown fields. */
10633
11575
  async initialize() {
11576
+ this.settingsValue = Object.freeze({
11577
+ version: 1,
11578
+ mode: "quick"
11579
+ });
11580
+ this.errorCode = void 0;
11581
+ try {
11582
+ await assertManagedParents(this.ownedRoot, this.settingsFile, "cloudflared");
11583
+ } catch (_error) {
11584
+ this.errorCode = "cloudflared_tunnel_config_invalid";
11585
+ return;
11586
+ }
10634
11587
  let entry;
10635
11588
  try {
10636
11589
  entry = await lstat(this.settingsFile);
@@ -10675,15 +11628,20 @@ var CloudflaredTunnelStore = class {
10675
11628
  /** Atomically replace the tunnel configuration. */
10676
11629
  async configure(value) {
10677
11630
  const settings = parseCloudflaredTunnelSettings(value);
10678
- if (settings.mode === "quick") await rm(this.settingsFile, { force: true });
10679
- else await atomicPrivateWrite(this.settingsFile, `${JSON.stringify(settings)}\n`);
11631
+ if (settings.mode === "quick") await removeManagedTree(this.ownedRoot, this.settingsFile, "cloudflared");
11632
+ else try {
11633
+ await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, `${JSON.stringify(settings)}\n`, "cloudflared");
11634
+ } catch (error) {
11635
+ if (error instanceof Error && error.message === "cloudflared_config_target_invalid") throw new Error("cloudflared_tunnel_target_invalid", { cause: error });
11636
+ throw error;
11637
+ }
10680
11638
  this.settingsValue = settings;
10681
11639
  this.errorCode = void 0;
10682
11640
  return this.status();
10683
11641
  }
10684
11642
  /** Forget a named tunnel and its connector token. */
10685
11643
  async purge() {
10686
- await rm(this.settingsFile, { force: true });
11644
+ await removeManagedTree(this.ownedRoot, this.settingsFile, "cloudflared");
10687
11645
  this.settingsValue = Object.freeze({
10688
11646
  version: 1,
10689
11647
  mode: "quick"
@@ -11242,7 +12200,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
11242
12200
  function buildMobileGuide(state) {
11243
12201
  const styleLine = state.hasCustomCss ? "存在(当前生效的自定义样式)" : "不存在(使用内置默认样式)";
11244
12202
  const scriptLine = state.hasCustomJs ? "存在(当前生效的自定义脚本)" : "不存在(无自定义脚本)";
11245
- const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})`).join("\n");
12203
+ const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})${entry.executionMode === void 0 ? "" : ` · ${entry.executionMode}${entry.executionState === "unavailable" ? " · 当前不可用,需要显式重启扩展" : ""}`}`).join("\n");
11246
12204
  const failureLine = state.failedExtensionCount > 0 ? `注意:${state.failedExtensionCount} 个扩展的电脑端 host 激活失败,如改动相关扩展请先检查其 host.mjs 与 extension.json。` : "";
11247
12205
  return `${`## 手机端当前状态(改动前必读,避免覆盖已有定制)
11248
12206
 
@@ -11276,16 +12234,18 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
11276
12234
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
11277
12235
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
11278
12236
  - host.mjs:电脑端 Node.js 代码(可信本地代码,可读写文件、执行命令)。导出默认函数 (api) => { ... },用 api.action('名称', { input, run }) 注册动作、api.route({ method, path, handle }) 注册路由、api.effect(fn) 注册清理。input 可以直接使用 api.schema.object(...) 等 Schemastery schema,也可以传入带 parse(value) 的适配器;两种形式都会在动作执行前校验并规范化输入。
12237
+ - 执行方式见上方实际状态。默认 in-process;插件配置可按扩展选择 worker。Worker 中 api.context 只有 logger,不能调用 Cordis 服务;api.manifest、schema、action、route、effect 仍可用。不要把需要 Cordis 的扩展偷偷改成 Worker,也不要在 extension.json 中添加执行模式字段。
11279
12238
  - 动作与路由的执行有时间预算:默认 30 秒(输入校验也计入),可用 api.action('名称', { input, run, timeoutMs }) 或 api.route({ ..., timeoutMs }) 按操作覆盖(1–300000 的整数毫秒)。超时或调用方取消后,调用方收到 500 extension_action_timeout / extension_route_timeout(或取消原因);这只表示调用方不再等待,底层代码可能仍在运行并已产生副作用,请在 run / handle 中响应 signal 实现协作式取消。路由的预算到 handle() 返回响应为止,不影响已返回流的存活。若某扩展存在调用方已离开(超时或取消)但仍未结束的操作,后续对该扩展的新调用会收到 503 extension_busy,直到该操作真正结束。
11280
12239
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
11281
12240
  - mobile.css:手机端样式(可选)
11282
12241
  - assets/:手机端静态资源(可选)
11283
12242
  - mobile.js 里用 api.host.invoke('动作名', 输入) 调 host.mjs 的 action(请求会按 application/json 发送),api.host.fetch('/路由路径') 调 route,api.host.assetUrl('相对路径') 生成与当前版本绑定的资源地址
11284
- - 也可以先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改
12243
+ - DSH Web 可先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改;Desktop 请在上方实际定制目录创建文件,不要误改 Web profile
11285
12244
 
11286
12245
  安全约束:
11287
12246
  - host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
11288
- - host.mjs 的同步阻塞(如 while (true) {} 或长时间同步循环)会卡死整个电脑端网关进程,超时与取消机制都无法打断;绝不编写同步阻塞代码
12247
+ - in-process 的同步阻塞会卡住网关事件循环,超时不能打断;Worker 可约束 JavaScript 阻塞,但不是安全沙箱,不约束全部原生内存,也不回滚副作用。两种模式都应响应 signal,避免编写不可取消的同步循环
12248
+ - Worker 终止后其他未完成调用会失败,已开始的操作不会自动重试。相同内容的停止实例需要操作者在电脑端“移动访问 → 扩展需要重启”中确认恢复;不要通过无意义改文件或自动重放原动作实现恢复
11289
12249
  - 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
11290
12250
 
11291
12251
  完成前请自检:
@@ -11313,10 +12273,16 @@ const LINUX_ARTIFACTS = Object.freeze({
11313
12273
  var VpsSshError = class extends Error {
11314
12274
  stdout;
11315
12275
  stderr;
12276
+ timedOut;
12277
+ exitCode;
12278
+ signal;
11316
12279
  constructor(message, stdout, stderr, options) {
11317
12280
  super(message, options);
11318
12281
  this.stdout = stdout;
11319
12282
  this.stderr = stderr;
12283
+ this.timedOut = options?.timedOut ?? false;
12284
+ this.exitCode = options?.exitCode;
12285
+ this.signal = options?.signal;
11320
12286
  }
11321
12287
  };
11322
12288
  function shellQuote(value) {
@@ -11427,7 +12393,7 @@ function sshSessionOptions(knownHostsFile) {
11427
12393
  `-o UserKnownHostsFile=${knownHostsFile}`
11428
12394
  ];
11429
12395
  }
11430
- /** Lenient scan probe: garbage fails the whole fetch, comment-only output falls back. */
12396
+ /** Recognize usable public-key scan output without authenticating to the server. */
11431
12397
  function tryParseKeyscanOutput(output) {
11432
12398
  try {
11433
12399
  return parseKeyscanOutput(output);
@@ -11466,58 +12432,20 @@ async function defaultRunKeyscan(input, serverAddress) {
11466
12432
  }
11467
12433
  return first?.stdout ?? "";
11468
12434
  }
11469
- /**
11470
- * Read the server's public host keys over an authenticated connection with a
11471
- * throwaway known_hosts file. Fallback for keyscan binaries that cannot
11472
- * negotiate with modern servers; output feeds the same confirm-and-pin pipeline.
11473
- */
11474
- async function defaultRunSshFetch(input, serverAddress) {
11475
- const ssh = process.platform === "win32" ? "ssh.exe" : "ssh";
11476
- const sshUser = validSshUser(input.sshUser);
11477
- const sshPort = validSshPort(input.sshPort);
11478
- const sshKeyPath = validSshKeyPath(input.sshKeyPath);
11479
- const { stdout } = await runProcess(ssh, [
11480
- "-o",
11481
- "BatchMode=yes",
11482
- "-o",
11483
- "ConnectTimeout=15",
11484
- "-o",
11485
- "StrictHostKeyChecking=no",
11486
- `-o UserKnownHostsFile=${process.platform === "win32" ? "NUL" : "/dev/null"}`,
11487
- ...sshKeyPath === void 0 ? [] : ["-i", sshKeyPath],
11488
- "-p",
11489
- String(sshPort),
11490
- `${sshUser}@${serverAddress}`,
11491
- "cat /etc/ssh/ssh_host_*_key.pub"
11492
- ], void 0, 3e4).catch((error) => {
11493
- throw new VpsSshError("vps_host_key_unavailable", "", error instanceof Error ? error.message : String(error));
11494
- });
11495
- const lines = [];
11496
- for (const line of stdout.split(/\r?\n/u)) {
11497
- const match = /^(ssh-rsa|ecdsa-sha2-nistp\d+|ssh-ed25519)\s+([A-Za-z0-9+/]+={0,2})(\s|$)/u.exec(line.trim());
11498
- if (match?.[1] !== void 0 && match[2] !== void 0) lines.push(`${serverAddress} ${match[1]} ${match[2]}`);
11499
- }
11500
- return lines.length === 0 ? stdout : `${lines.join("\n")}\n`;
11501
- }
11502
- /**
11503
- * Scan host keys with keyscan first, then fall back to an authenticated read
11504
- * when the local keyscan binary cannot negotiate with the server. Both paths
11505
- * feed the same confirm-and-pin pipeline, so a fallback never weakens the
11506
- * user-confirmation gate.
11507
- */
12435
+ /** Obtain public host keys without using a private key, agent or unverified authenticated connection. */
11508
12436
  async function scanHostKeys(input, serverAddress, options) {
11509
- const scanned = await (options.runKeyscan ?? defaultRunKeyscan)({
11510
- sshUser: input.sshUser,
11511
- sshPort: input.sshPort
11512
- }, serverAddress).catch(() => "");
11513
- if (tryParseKeyscanOutput(scanned)?.length) return scanned;
11514
- options.log?.("host-keys-keyscan-empty", { serverAddress });
11515
- const fetched = await (options.runSshFetch ?? defaultRunSshFetch)(input, serverAddress);
11516
- if (tryParseKeyscanOutput(fetched)?.length) {
11517
- options.log?.("host-keys-ssh-fallback", { serverAddress });
11518
- return fetched;
11519
- }
11520
- throw new Error("vps_host_key_unavailable");
12437
+ const runKeyscan = options.runKeyscan ?? defaultRunKeyscan;
12438
+ let scanned;
12439
+ try {
12440
+ scanned = await runKeyscan({
12441
+ sshUser: input.sshUser,
12442
+ sshPort: input.sshPort
12443
+ }, serverAddress);
12444
+ } catch (error) {
12445
+ throw new Error("vps_host_key_unavailable", { cause: error });
12446
+ }
12447
+ if (parseKeyscanOutput(scanned).length === 0) throw new Error("vps_host_key_unavailable");
12448
+ return scanned;
11521
12449
  }
11522
12450
  /**
11523
12451
  * Fetch the server's current host keys and return them with OpenSSH-style
@@ -11880,7 +12808,8 @@ check caddy ok "Caddy 已加载 ${publicHost}。"
11880
12808
  echo DSH_MOBILE_DEPLOYMENT_OK
11881
12809
  `;
11882
12810
  }
11883
- async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
12811
+ /** Run one owned SSH/transfer utility; reject only after its process and stdio have closed. */
12812
+ async function runVpsProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11884
12813
  return new Promise((resolveRun, rejectRun) => {
11885
12814
  const child = spawn(command, args, {
11886
12815
  windowsHide: true,
@@ -11893,9 +12822,23 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11893
12822
  let stdout = "";
11894
12823
  let stderr = "";
11895
12824
  const append = (current, chunk) => `${current}${chunk.toString("utf8")}`.slice(-98304);
11896
- const timer = setTimeout(() => {
12825
+ let timedOut = false;
12826
+ let failure;
12827
+ let failureCode = "vps_ssh_unavailable";
12828
+ let forceTimer;
12829
+ const terminate = () => {
11897
12830
  child.kill();
11898
- rejectRun(new VpsSshError("vps_ssh_timeout", stdout, stderr));
12831
+ forceTimer ??= setTimeout(() => {
12832
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
12833
+ child.stdin.destroy();
12834
+ child.stdout.destroy();
12835
+ child.stderr.destroy();
12836
+ }, 1e3);
12837
+ forceTimer.unref();
12838
+ };
12839
+ const timer = setTimeout(() => {
12840
+ timedOut = true;
12841
+ terminate();
11899
12842
  }, timeoutMs);
11900
12843
  timer.unref();
11901
12844
  child.stdout.on("data", (chunk) => {
@@ -11905,12 +12848,32 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11905
12848
  stderr = append(stderr, Buffer.from(chunk));
11906
12849
  });
11907
12850
  child.once("error", (error) => {
11908
- clearTimeout(timer);
11909
- rejectRun(new VpsSshError("vps_ssh_unavailable", stdout, stderr, { cause: error }));
12851
+ failure = error;
11910
12852
  });
11911
- child.once("close", (code) => {
12853
+ child.stdin.on("error", (error) => {
12854
+ failure = error;
12855
+ failureCode = "vps_ssh_input_failed";
12856
+ terminate();
12857
+ });
12858
+ const failedOutput = (error) => {
12859
+ failure = error;
12860
+ failureCode = "vps_ssh_transport_failed";
12861
+ terminate();
12862
+ };
12863
+ child.stdout.on("error", failedOutput);
12864
+ child.stderr.on("error", failedOutput);
12865
+ child.once("close", (code, signal) => {
11912
12866
  clearTimeout(timer);
11913
- if (code !== 0) rejectRun(new VpsSshError(stderr.includes("Permission denied") ? "vps_ssh_auth_failed" : "vps_deploy_failed", stdout, stderr));
12867
+ if (forceTimer !== void 0) clearTimeout(forceTimer);
12868
+ const facts = {
12869
+ timedOut,
12870
+ exitCode: code,
12871
+ signal,
12872
+ ...failure === void 0 ? {} : { cause: failure }
12873
+ };
12874
+ if (timedOut) rejectRun(new VpsSshError("vps_ssh_timeout", stdout, stderr, facts));
12875
+ else if (failure !== void 0) rejectRun(new VpsSshError(child.pid === void 0 ? "vps_ssh_unavailable" : failureCode, stdout, stderr, facts));
12876
+ else if (code !== 0) rejectRun(new VpsSshError(stderr.includes("Permission denied") ? "vps_ssh_auth_failed" : "vps_deploy_failed", stdout, stderr, facts));
11914
12877
  else resolveRun({
11915
12878
  stdout,
11916
12879
  stderr
@@ -11919,8 +12882,10 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11919
12882
  child.stdin.end(stdin, "utf8");
11920
12883
  });
11921
12884
  }
12885
+ const runProcess = runVpsProcess;
11922
12886
  async function downloadArtifact(artifact, file) {
11923
- await runProcess(process.platform === "win32" ? "curl.exe" : "curl", [
12887
+ const curl = process.platform === "win32" ? "curl.exe" : "curl";
12888
+ await runProcess(curl, [
11924
12889
  ...process.platform === "win32" ? ["--ipv4"] : [],
11925
12890
  "--fail",
11926
12891
  "--location",
@@ -12375,12 +13340,27 @@ var FunnelController = class {
12375
13340
  });
12376
13341
  queue = Promise.resolve();
12377
13342
  startTimer;
13343
+ ownedRoot;
12378
13344
  constructor(options) {
12379
13345
  this.options = options;
12380
13346
  if (!isAbsolute(options.executable) || !isAbsolute(options.stateDirectory)) throw new Error("Funnel paths must be absolute");
13347
+ this.ownedRoot = options.ownedRoot ?? dirname(resolve(options.stateDirectory));
13348
+ if (!isAbsolute(this.ownedRoot) || resolve(this.ownedRoot) === resolve(options.stateDirectory)) throw new Error("Funnel state must be a child of its owned root");
12381
13349
  }
12382
13350
  /** Restore the remote switch without coupling it to LAN availability. */
12383
13351
  async initialize() {
13352
+ try {
13353
+ await this.assertStateParents();
13354
+ } catch (_error) {
13355
+ this.enabled = false;
13356
+ this.initialized = true;
13357
+ this.publish({
13358
+ enabled: false,
13359
+ state: "unavailable",
13360
+ errorCode: "funnel_state_invalid"
13361
+ });
13362
+ return;
13363
+ }
12384
13364
  const state = await this.options.store.load();
12385
13365
  this.enabled = state.enabled;
12386
13366
  this.initialized = true;
@@ -12404,6 +13384,17 @@ var FunnelController = class {
12404
13384
  await this.enqueue(async () => {
12405
13385
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
12406
13386
  if (!enabled) await this.stop();
13387
+ try {
13388
+ await this.assertStateParents();
13389
+ } catch (error) {
13390
+ this.enabled = false;
13391
+ this.publish({
13392
+ enabled: false,
13393
+ state: "unavailable",
13394
+ errorCode: "funnel_state_invalid"
13395
+ });
13396
+ throw error;
13397
+ }
12407
13398
  this.enabled = enabled;
12408
13399
  await this.options.store.save({
12409
13400
  version: 1,
@@ -12421,6 +13412,17 @@ var FunnelController = class {
12421
13412
  async reconnect() {
12422
13413
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
12423
13414
  await this.enqueue(async () => {
13415
+ await this.stop();
13416
+ try {
13417
+ await this.assertStateParents();
13418
+ } catch (error) {
13419
+ this.publish({
13420
+ enabled: this.enabled,
13421
+ state: "unavailable",
13422
+ errorCode: "funnel_state_invalid"
13423
+ });
13424
+ throw error;
13425
+ }
12424
13426
  if (!this.enabled) {
12425
13427
  this.enabled = true;
12426
13428
  await this.options.store.save({
@@ -12428,7 +13430,6 @@ var FunnelController = class {
12428
13430
  enabled: true
12429
13431
  });
12430
13432
  }
12431
- await this.stop();
12432
13433
  await this.start();
12433
13434
  });
12434
13435
  return this.status();
@@ -12439,14 +13440,21 @@ var FunnelController = class {
12439
13440
  await this.enqueue(async () => {
12440
13441
  await this.stop();
12441
13442
  this.enabled = false;
12442
- await this.options.store.save({
12443
- version: 1,
12444
- enabled: false
12445
- });
12446
- await rm(resolve(this.options.stateDirectory), {
12447
- recursive: true,
12448
- force: true
12449
- });
13443
+ try {
13444
+ await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
13445
+ await this.assertStateParents();
13446
+ await this.options.store.save({
13447
+ version: 1,
13448
+ enabled: false
13449
+ });
13450
+ } catch (error) {
13451
+ this.publish({
13452
+ enabled: false,
13453
+ state: "off",
13454
+ errorCode: "funnel_state_invalid"
13455
+ });
13456
+ throw error;
13457
+ }
12450
13458
  this.publish({
12451
13459
  enabled: false,
12452
13460
  state: "off"
@@ -12471,6 +13479,15 @@ var FunnelController = class {
12471
13479
  this.options.onStatus?.(this.status());
12472
13480
  } catch {}
12473
13481
  }
13482
+ async assertStateParents() {
13483
+ await assertManagedParents(this.ownedRoot, this.options.stateDirectory, "funnel");
13484
+ try {
13485
+ const entry = await lstat(this.options.stateDirectory);
13486
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("funnel_path_invalid");
13487
+ } catch (error) {
13488
+ if (error.code !== "ENOENT") throw error;
13489
+ }
13490
+ }
12474
13491
  async start() {
12475
13492
  const generation = ++this.generation;
12476
13493
  let entry;
@@ -12492,6 +13509,17 @@ var FunnelController = class {
12492
13509
  });
12493
13510
  return;
12494
13511
  }
13512
+ try {
13513
+ await this.assertStateParents();
13514
+ await ensureManagedDirectory(this.ownedRoot, this.options.stateDirectory, "funnel");
13515
+ } catch (_error) {
13516
+ this.publish({
13517
+ enabled: true,
13518
+ state: "unavailable",
13519
+ errorCode: "funnel_state_invalid"
13520
+ });
13521
+ return;
13522
+ }
12495
13523
  this.buffer = "";
12496
13524
  this.publish({
12497
13525
  enabled: true,
@@ -13147,20 +14175,19 @@ async function pathExists(path) {
13147
14175
  /** Replace only this version's verified binary, retaining the previous directory until promotion succeeds. */
13148
14176
  async function publishVerifiedCpolarExecutable(extracted, componentRoot, componentStorage, executableName, operations = {}) {
13149
14177
  if (dirname(componentStorage) !== componentRoot || !inside(componentRoot, componentStorage) || basename(executableName) !== executableName || executableName === "." || executableName === "..") throw new Error("cpolar component storage escaped its private root");
13150
- const move = operations.move ?? rename;
13151
- const remove = operations.remove ?? ((path) => rm(path, {
13152
- recursive: true,
13153
- force: true
13154
- }));
14178
+ const move = async (from, to) => {
14179
+ await assertManagedParents(componentRoot, from, "cpolar");
14180
+ await assertManagedParents(componentRoot, to, "cpolar");
14181
+ if (operations.move === void 0) await renameManagedPath(componentRoot, from, to, "cpolar");
14182
+ else await operations.move(from, to);
14183
+ };
14184
+ const remove = operations.remove ?? ((path) => removeManagedTree(componentRoot, path, "cpolar"));
13155
14185
  const candidate = join(componentRoot, `.install-${randomBytes(12).toString("hex")}`);
13156
14186
  const backup = join(componentRoot, `.previous-${randomBytes(12).toString("hex")}`);
13157
14187
  let backupContainsPrevious = false;
13158
14188
  let replacementInstalled = false;
13159
14189
  try {
13160
- await mkdir(candidate, {
13161
- recursive: true,
13162
- mode: 448
13163
- });
14190
+ await ensureManagedDirectory(componentRoot, candidate, "cpolar");
13164
14191
  await copyFile(extracted, join(candidate, executableName));
13165
14192
  await chmod(join(candidate, executableName), 448);
13166
14193
  if (await pathExists(componentStorage)) {
@@ -13194,10 +14221,7 @@ async function publishVerifiedCpolarExecutable(extracted, componentRoot, compone
13194
14221
  }
13195
14222
  }
13196
14223
  /** Remove only this install's staging directory without replacing a preceding failure. */
13197
- async function cleanupCpolarInstallStaging(staging, installFailed, remove = (path) => rm(path, {
13198
- recursive: true,
13199
- force: true
13200
- })) {
14224
+ async function cleanupCpolarInstallStaging(staging, installFailed, remove = (path) => removeManagedTree(dirname(path), path, "cpolar")) {
13201
14225
  try {
13202
14226
  await remove(staging);
13203
14227
  } catch (error) {
@@ -13291,6 +14315,7 @@ var CpolarComponentManager = class {
13291
14315
  stateRoot;
13292
14316
  logRoot;
13293
14317
  stagingRoot;
14318
+ stateDirectory;
13294
14319
  platform;
13295
14320
  arch;
13296
14321
  release;
@@ -13301,8 +14326,9 @@ var CpolarComponentManager = class {
13301
14326
  errorCode;
13302
14327
  queue = Promise.resolve();
13303
14328
  constructor(options) {
14329
+ if (!isAbsolute(options.stateDirectory)) throw new Error("cpolar state directory must be absolute");
13304
14330
  const stateDirectory = resolve(options.stateDirectory);
13305
- if (!isAbsolute(stateDirectory)) throw new Error("cpolar state directory must be absolute");
14331
+ this.stateDirectory = stateDirectory;
13306
14332
  this.platform = options.platform ?? process.platform;
13307
14333
  this.arch = options.arch ?? process.arch;
13308
14334
  this.release = CPOLAR_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
@@ -13330,13 +14356,24 @@ var CpolarComponentManager = class {
13330
14356
  /** Inspect the managed binary and configuration without using global cpolar state. */
13331
14357
  async initialize() {
13332
14358
  const release = this.release;
13333
- this.installed = release !== void 0 && await regularFile(this.executable, release.executableBytes);
13334
- if (this.installed && release !== void 0 && await sha256(this.executable) !== release.executableSha256) {
14359
+ this.installed = false;
14360
+ this.configured = false;
14361
+ this.errorCode = void 0;
14362
+ try {
14363
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
14364
+ await assertManagedParents(this.stateDirectory, this.configFile, "cpolar");
14365
+ this.installed = release !== void 0 && await regularFile(this.executable, release.executableBytes);
14366
+ if (this.installed && release !== void 0 && await sha256(this.executable) !== release.executableSha256) {
14367
+ this.installed = false;
14368
+ this.errorCode = "cpolar_component_invalid";
14369
+ }
14370
+ this.configured = await regularFile(this.configFile);
14371
+ if (this.configured) await restrictPrivateFile(this.configFile);
14372
+ } catch (_error) {
13335
14373
  this.installed = false;
14374
+ this.configured = false;
13336
14375
  this.errorCode = "cpolar_component_invalid";
13337
14376
  }
13338
- this.configured = await regularFile(this.configFile);
13339
- if (this.configured) await restrictPrivateFile(this.configFile);
13340
14377
  }
13341
14378
  /** Return a safe status that never includes the account token. */
13342
14379
  status() {
@@ -13362,11 +14399,9 @@ var CpolarComponentManager = class {
13362
14399
  return this.enqueue(async () => {
13363
14400
  const release = this.release;
13364
14401
  if (release === void 0) throw new Error("cpolar_component_unsupported");
14402
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
13365
14403
  const staging = await installStep("cpolar_storage_failed", async () => {
13366
- await mkdir(this.stagingRoot, {
13367
- recursive: true,
13368
- mode: 448
13369
- });
14404
+ await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "cpolar");
13370
14405
  return mkdtemp(join(this.stagingRoot, "install-"));
13371
14406
  });
13372
14407
  let installFailed = false;
@@ -13394,19 +14429,24 @@ var CpolarComponentManager = class {
13394
14429
  await installStep("cpolar_extract_failed", () => this.extractArtifact(archive, staging));
13395
14430
  const extracted = join(staging, release.executableName);
13396
14431
  if (!await installStep("cpolar_extract_failed", async () => await regularFile(extracted, release.executableBytes) && await sha256(extracted) === release.executableSha256)) throw new Error("cpolar_executable_hash_mismatch");
13397
- await installStep("cpolar_storage_failed", () => publishVerifiedCpolarExecutable(extracted, this.componentRoot, this.componentStorage, release.executableName));
14432
+ await installStep("cpolar_storage_failed", async () => {
14433
+ await ensureManagedDirectory(this.stateDirectory, this.componentRoot, "cpolar");
14434
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
14435
+ await publishVerifiedCpolarExecutable(extracted, this.componentRoot, this.componentStorage, release.executableName);
14436
+ });
13398
14437
  this.installed = true;
13399
14438
  this.errorCode = void 0;
13400
14439
  } catch (error) {
13401
14440
  installFailed = true;
13402
14441
  try {
14442
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
13403
14443
  this.installed = await regularFile(this.executable, release.executableBytes) && await sha256(this.executable) === release.executableSha256;
13404
14444
  } catch (_inspectionError) {
13405
14445
  this.installed = false;
13406
14446
  }
13407
14447
  throw error;
13408
14448
  } finally {
13409
- await cleanupCpolarInstallStaging(staging, installFailed);
14449
+ await cleanupCpolarInstallStaging(staging, installFailed, (path) => removeManagedTree(this.stateDirectory, path, "cpolar"));
13410
14450
  }
13411
14451
  });
13412
14452
  }
@@ -13414,24 +14454,8 @@ var CpolarComponentManager = class {
13414
14454
  configure(authtoken) {
13415
14455
  return this.enqueue(async () => {
13416
14456
  const token = validateCpolarAuthtoken(authtoken);
13417
- await mkdir(this.stateRoot, {
13418
- recursive: true,
13419
- mode: 448
13420
- });
13421
- const temporary = join(this.stateRoot, `.cpolar.${randomBytes(12).toString("hex")}.tmp`);
13422
14457
  const body = `authtoken: ${JSON.stringify(token)}\nconsole_ui: false\nupdate: false\ninspect_db_size: -1\n`;
13423
- try {
13424
- await writeFile(temporary, body, {
13425
- encoding: "utf8",
13426
- flag: "wx",
13427
- mode: 384
13428
- });
13429
- await rename(temporary, this.configFile);
13430
- await restrictPrivateFile(this.configFile);
13431
- } catch (error) {
13432
- await rm(temporary, { force: true });
13433
- throw error;
13434
- }
14458
+ await writeManagedPrivateFile(this.stateDirectory, this.configFile, body, "cpolar");
13435
14459
  this.configured = true;
13436
14460
  this.errorCode = void 0;
13437
14461
  });
@@ -13439,24 +14463,12 @@ var CpolarComponentManager = class {
13439
14463
  /** Remove every cpolar file owned by DSH Mobile without touching global state. */
13440
14464
  purge() {
13441
14465
  return this.enqueue(async () => {
13442
- await Promise.all([
13443
- rm(this.componentRoot, {
13444
- recursive: true,
13445
- force: true
13446
- }),
13447
- rm(this.stateRoot, {
13448
- recursive: true,
13449
- force: true
13450
- }),
13451
- rm(this.logRoot, {
13452
- recursive: true,
13453
- force: true
13454
- }),
13455
- rm(this.stagingRoot, {
13456
- recursive: true,
13457
- force: true
13458
- })
13459
- ]);
14466
+ await removeManagedPaths(this.stateDirectory, [
14467
+ this.componentRoot,
14468
+ this.stateRoot,
14469
+ this.logRoot,
14470
+ this.stagingRoot
14471
+ ], "cpolar");
13460
14472
  this.installed = false;
13461
14473
  this.configured = false;
13462
14474
  this.errorCode = void 0;
@@ -13470,82 +14482,25 @@ var CpolarComponentManager = class {
13470
14482
  };
13471
14483
  //#endregion
13472
14484
  //#region src/caddy-files.ts
13473
- /** Refuse link-shaped ancestors within the owned state root before filesystem operations. */
14485
+ /** Refuse link-shaped ancestors within Caddy's owned state root. */
13474
14486
  async function assertCaddyParents(root, target) {
13475
- const parent = resolve(root);
13476
- const candidate = resolve(target);
13477
- const offset = relative(parent, candidate);
13478
- if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error("caddy_path_invalid");
13479
- const paths = [parent];
13480
- let cursor = parent;
13481
- for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
13482
- cursor = join(cursor, segment);
13483
- paths.push(cursor);
13484
- }
13485
- for (const path of paths) {
13486
- let entry;
13487
- try {
13488
- entry = await lstat(path);
13489
- } catch (error) {
13490
- if (error.code === "ENOENT") continue;
13491
- throw error;
13492
- }
13493
- if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13494
- }
14487
+ await assertManagedParents(root, target, "caddy");
13495
14488
  }
13496
- /** Create a private real directory, never traversing a link owned by the component. */
14489
+ /** Move a Caddy-owned path, preserving bounded Windows retries and ownership checks. */
14490
+ async function renameCaddyPath(root, source, destination) {
14491
+ await renameManagedPath(root, source, destination, "caddy");
14492
+ }
14493
+ /** Create a real Caddy directory, securing fresh directories and preserving existing ACLs. */
13497
14494
  async function ensureCaddyDirectory(root, directory) {
13498
- await assertCaddyParents(root, directory);
13499
- await mkdir(directory, {
13500
- recursive: true,
13501
- mode: 448
13502
- });
13503
- const entry = await lstat(directory);
13504
- if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13505
- await restrictPrivateFile(directory, 448);
14495
+ await ensureManagedDirectory(root, directory, "caddy");
13506
14496
  }
13507
- /** Atomically replace one private configuration file under a real owned directory. */
14497
+ /** Atomically replace one private Caddy configuration file. */
13508
14498
  async function writeCaddyPrivateFile(root, file, body) {
13509
- await ensureCaddyDirectory(root, dirname(file));
13510
- try {
13511
- const entry = await lstat(file);
13512
- if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("caddy_config_target_invalid");
13513
- } catch (error) {
13514
- if (error.code !== "ENOENT") throw error;
13515
- }
13516
- const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
13517
- try {
13518
- await writeFile(temporary, body, {
13519
- encoding: "utf8",
13520
- flag: "wx",
13521
- mode: 384
13522
- });
13523
- await restrictPrivateFile(temporary);
13524
- await rename(temporary, file);
13525
- } finally {
13526
- await rm(temporary, { force: true });
13527
- }
14499
+ await writeManagedPrivateFile(root, file, body, "caddy");
13528
14500
  }
13529
- /** Delete only owned entries; junctions and symlinks are unlinked, never descended into. */
14501
+ /** Delete only Caddy-owned children; unlink junctions and symlinks themselves. */
13530
14502
  async function removeCaddyTree(root, target) {
13531
- await assertCaddyParents(root, target);
13532
- let entry;
13533
- try {
13534
- entry = await lstat(target);
13535
- } catch (error) {
13536
- if (error.code === "ENOENT") return;
13537
- throw error;
13538
- }
13539
- if (entry.isSymbolicLink()) {
13540
- await unlink(target);
13541
- return;
13542
- }
13543
- if (!entry.isDirectory()) {
13544
- await unlink(target);
13545
- return;
13546
- }
13547
- for (const name of await readdir(target)) await removeCaddyTree(root, join(target, name));
13548
- await rmdir(target);
14503
+ await removeManagedTree(root, target, "caddy");
13549
14504
  }
13550
14505
  //#endregion
13551
14506
  //#region src/caddy-component.ts
@@ -13622,7 +14577,7 @@ var CaddyComponentManager = class {
13622
14577
  errorPrefix: "caddy"
13623
14578
  }));
13624
14579
  this.inspectExecutable = options.inspectExecutable ?? inspect;
13625
- this.promote = options.promoteDirectory ?? rename;
14580
+ this.promote = options.promoteDirectory ?? ((source, destination) => renameCaddyPath(this.stateDirectory, source, destination));
13626
14581
  }
13627
14582
  /** Recheck a pinned executable before launching; no unverified local binary can run. */
13628
14583
  async ensureExecutable() {
@@ -13673,6 +14628,7 @@ var CaddyComponentManager = class {
13673
14628
  const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
13674
14629
  let previous = false;
13675
14630
  let promoted = false;
14631
+ let failed = false;
13676
14632
  try {
13677
14633
  const controller = new AbortController();
13678
14634
  const timeout = setTimeout(() => {
@@ -13699,7 +14655,7 @@ var CaddyComponentManager = class {
13699
14655
  try {
13700
14656
  const entry = await lstat(this.componentStorage);
13701
14657
  if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13702
- await rename(this.componentStorage, backup);
14658
+ await renameCaddyPath(this.stateDirectory, this.componentStorage, backup);
13703
14659
  previous = true;
13704
14660
  } catch (error) {
13705
14661
  if (error.code !== "ENOENT") throw error;
@@ -13708,18 +14664,33 @@ var CaddyComponentManager = class {
13708
14664
  await this.promote(candidate, this.componentStorage);
13709
14665
  promoted = true;
13710
14666
  } catch (error) {
13711
- if (previous) {
13712
- await rename(backup, this.componentStorage);
14667
+ if (previous) try {
14668
+ await renameCaddyPath(this.stateDirectory, backup, this.componentStorage);
13713
14669
  previous = false;
14670
+ } catch (rollbackError) {
14671
+ this.installed = false;
14672
+ this.errorCode = "caddy_component_invalid";
14673
+ throw new AggregateError([error, rollbackError], "caddy_component_rollback_failed", { cause: error });
13714
14674
  }
13715
14675
  throw error;
13716
14676
  }
13717
14677
  this.installed = true;
13718
14678
  this.errorCode = void 0;
14679
+ } catch (error) {
14680
+ failed = true;
14681
+ throw error;
13719
14682
  } finally {
13720
- await removeCaddyTree(this.stateDirectory, staging);
13721
- await removeCaddyTree(this.stateDirectory, candidate);
13722
- if (previous && promoted) await removeCaddyTree(this.stateDirectory, backup);
14683
+ let cleanupError;
14684
+ for (const path of [
14685
+ staging,
14686
+ candidate,
14687
+ ...previous && promoted ? [backup] : []
14688
+ ]) try {
14689
+ await removeCaddyTree(this.stateDirectory, path);
14690
+ } catch (error) {
14691
+ cleanupError ??= error;
14692
+ }
14693
+ if (!failed && cleanupError !== void 0) throw cleanupError;
13723
14694
  }
13724
14695
  });
13725
14696
  }
@@ -14832,24 +15803,69 @@ var PluginReleaseManager = class {
14832
15803
  //#endregion
14833
15804
  //#region src/file-logger.ts
14834
15805
  const MAX_LOG_BYTES = 5242880;
14835
- /** Install a plugin-scoped Cordis exporter backed by a private UTF-8 log file. */
14836
- async function installMobileFileLogger(ctx, stateDirectory) {
15806
+ const MAX_QUEUED_BYTES = 262144;
15807
+ const PATH_PREFIX = "mobile_log";
15808
+ async function regularLogFile(file) {
15809
+ try {
15810
+ const entry = await lstat(file);
15811
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("mobile_log_target_invalid");
15812
+ return entry;
15813
+ } catch (error) {
15814
+ if (error.code === "ENOENT") return void 0;
15815
+ throw error;
15816
+ }
15817
+ }
15818
+ /** Open a private append-only file before registering a safely disposable Cordis exporter. */
15819
+ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
14837
15820
  const directory = join(stateDirectory, "logs");
14838
15821
  const file = join(directory, "dsh-mobile.log");
14839
15822
  const previous = `${file}.1`;
14840
- await mkdir(directory, {
14841
- recursive: true,
14842
- mode: 448
14843
- });
14844
- const entry = await lstat(file).catch(() => void 0);
14845
- if (entry !== void 0 && entry.isFile() && !entry.isSymbolicLink() && entry.size >= MAX_LOG_BYTES) {
14846
- await rm(previous, { force: true });
14847
- await rename(file, previous);
15823
+ await ensureManagedDirectory(stateDirectory, directory, PATH_PREFIX);
15824
+ const entry = await regularLogFile(file);
15825
+ if (entry !== void 0 && entry.size >= MAX_LOG_BYTES) {
15826
+ await regularLogFile(previous);
15827
+ await removeManagedTree(stateDirectory, previous, PATH_PREFIX);
15828
+ await renameManagedPath(stateDirectory, file, previous, PATH_PREFIX);
15829
+ await regularLogFile(previous);
15830
+ await restrictPrivateFile(previous);
15831
+ }
15832
+ await assertManagedParents(stateDirectory, file, PATH_PREFIX);
15833
+ await regularLogFile(file);
15834
+ const handle = await open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0), 384);
15835
+ let stream;
15836
+ try {
15837
+ const opened = await handle.stat();
15838
+ const actual = await regularLogFile(file);
15839
+ if (!opened.isFile() || actual === void 0 || actual.dev !== opened.dev || actual.ino !== opened.ino) throw new Error("mobile_log_target_invalid");
15840
+ await assertManagedParents(stateDirectory, file, PATH_PREFIX);
15841
+ await handle.chmod(384);
15842
+ await restrictPrivateFile(file);
15843
+ stream = options.createStream?.(handle) ?? handle.createWriteStream({
15844
+ encoding: "utf8",
15845
+ autoClose: true
15846
+ });
15847
+ } catch (error) {
15848
+ await handle.close();
15849
+ throw error;
14848
15850
  }
14849
- const stream = createWriteStream(file, {
14850
- flags: "a",
14851
- encoding: "utf8",
14852
- mode: 384
15851
+ let stopped = false;
15852
+ let removeExporter;
15853
+ const stopLogging = (error) => {
15854
+ if (stopped) return;
15855
+ stopped = true;
15856
+ try {
15857
+ Promise.resolve(removeExporter?.()).catch(() => void 0);
15858
+ } catch (disposeError) {}
15859
+ stream.destroy();
15860
+ const candidate = error;
15861
+ const code = candidate !== null && typeof candidate === "object" && typeof candidate.code === "string" ? candidate.code : "write_failed";
15862
+ try {
15863
+ ctx.logger("dsh-mobile").warn("file logging disabled (%s); other logging remains available", code);
15864
+ } catch (loggerError) {}
15865
+ };
15866
+ stream.on("error", stopLogging);
15867
+ const closed = finished$1(stream, { cleanup: true }).catch((error) => {
15868
+ stopLogging(error);
14853
15869
  });
14854
15870
  const exporter = {
14855
15871
  colors: false,
@@ -14859,20 +15875,40 @@ async function installMobileFileLogger(ctx, stateDirectory) {
14859
15875
  "dsh-mobile": 3
14860
15876
  },
14861
15877
  export(message) {
14862
- if (message.name !== "dsh-mobile") return;
14863
- const record = {
14864
- timestamp: new Date(message.ts).toISOString(),
14865
- level: message.type,
14866
- logger: message.name,
14867
- message: Logger.format(exporter, message)
14868
- };
14869
- stream.write(`${JSON.stringify(record)}\n`);
15878
+ if (stopped || message.name !== "dsh-mobile") return;
15879
+ try {
15880
+ const record = {
15881
+ timestamp: new Date(message.ts).toISOString(),
15882
+ level: message.type,
15883
+ logger: message.name,
15884
+ message: Logger.format(exporter, message)
15885
+ };
15886
+ const line = `${JSON.stringify(record)}\n`;
15887
+ if (stream.writableLength + Buffer.byteLength(line) > MAX_QUEUED_BYTES) {
15888
+ stopLogging(Object.assign(/* @__PURE__ */ new Error("file log queue exhausted"), { code: "backpressure" }));
15889
+ return;
15890
+ }
15891
+ stream.write(line);
15892
+ } catch (error) {
15893
+ stopLogging(error);
15894
+ }
14870
15895
  }
14871
15896
  };
14872
- ctx.logger.exporter(exporter);
14873
- ctx.effect(() => () => {
14874
- stream.end();
14875
- }, "dsh-mobile file logger");
15897
+ try {
15898
+ removeExporter = ctx.logger.exporter(exporter);
15899
+ ctx.effect(() => async () => {
15900
+ stopped = true;
15901
+ await removeExporter?.();
15902
+ stream.end();
15903
+ await closed;
15904
+ }, "dsh-mobile file logger");
15905
+ } catch (error) {
15906
+ stopped = true;
15907
+ stream.destroy();
15908
+ await closed;
15909
+ await removeExporter?.();
15910
+ throw error;
15911
+ }
14876
15912
  return file;
14877
15913
  }
14878
15914
  //#endregion
@@ -15034,6 +16070,7 @@ function installedDshVersion() {
15034
16070
  }
15035
16071
  function mapAdminError(error) {
15036
16072
  if (error instanceof HttpError) return error;
16073
+ if (error instanceof MobileExtensionError) return new HttpError(error.status, error.code);
15037
16074
  const code = error.code;
15038
16075
  if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
15039
16076
  if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
@@ -15210,10 +16247,14 @@ function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenP
15210
16247
  * An unreadable self-signed CA fails explicitly: falling back to the system
15211
16248
  * trust store would hide a broken pairing identity behind a start timeout.
15212
16249
  */
15213
- async function readFrpIngressTrustAnchor(settings, stateFile) {
16250
+ async function readFrpIngressTrustAnchor(settings, stateFile, ownedRoot = dirname(stateFile)) {
15214
16251
  if (!isFrpSelfSignedIngress(settings)) return void 0;
15215
16252
  try {
15216
- return await readFile(frpIngressPaths(stateFile).caCertFile, "utf8");
16253
+ const file = frpIngressPaths(stateFile).caCertFile;
16254
+ await assertManagedParents(ownedRoot, file, "frp_ingress");
16255
+ const entry = await lstat(file);
16256
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
16257
+ return await readFile(file, "utf8");
15217
16258
  } catch (error) {
15218
16259
  throw new Error("frp_ingress_ca_invalid", { cause: error });
15219
16260
  }
@@ -15306,15 +16347,15 @@ async function apply(ctx, config) {
15306
16347
  const frpComponent = new FrpComponentManager({ stateDirectory });
15307
16348
  await frpComponent.initialize();
15308
16349
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
15309
- const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity));
16350
+ const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
15310
16351
  await frpConfig.initialize();
15311
- const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
16352
+ const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"), stateDirectory);
15312
16353
  await originConfig.initialize();
15313
16354
  const caddyComponent = new CaddyComponentManager({ stateDirectory });
15314
16355
  await caddyComponent.initialize();
15315
16356
  const caddyConfig = new CaddyConfigStore(stateDirectory);
15316
16357
  await caddyConfig.initialize();
15317
- const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
16358
+ const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"), stateDirectory);
15318
16359
  await cloudflaredTunnel.initialize();
15319
16360
  const unregisterBuiltin = mobileAccess.registerExtension({
15320
16361
  schemaVersion: 1,
@@ -15433,7 +16474,7 @@ async function apply(ctx, config) {
15433
16474
  */
15434
16475
  const createFrpGateway = async (publicOrigin, settings) => {
15435
16476
  if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
15436
- const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
16477
+ const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), void 0, stateDirectory);
15437
16478
  const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
15438
16479
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
15439
16480
  try {
@@ -15482,6 +16523,7 @@ async function apply(ctx, config) {
15482
16523
  store: tailscaleStore,
15483
16524
  executable: funnelExecutable(import.meta.url),
15484
16525
  stateDirectory: join(remoteDirectory, "tailscale"),
16526
+ ownedRoot: stateDirectory,
15485
16527
  hostname: `dsh-${instanceId.slice(0, 12)}`,
15486
16528
  createGateway: createRemoteGateway
15487
16529
  }),
@@ -15509,9 +16551,9 @@ async function apply(ctx, config) {
15509
16551
  * Only this module knows where the ingress material lives, so the anchor is
15510
16552
  * read here from the ingress directory instead of being guessed downstream.
15511
16553
  */
15512
- resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
16554
+ resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile, stateDirectory),
15513
16555
  maintainIngressCertificate: async (settings, gateway) => {
15514
- await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
16556
+ await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId, stateDirectory);
15515
16557
  await gateway.refreshProvidedTls();
15516
16558
  }
15517
16559
  }),
@@ -15590,7 +16632,7 @@ async function apply(ctx, config) {
15590
16632
  const frpGateway = remoteControllers.frp.gateway();
15591
16633
  const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
15592
16634
  try {
15593
- const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
16635
+ const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile, stateDirectory);
15594
16636
  if (trustAnchorPem === void 0) return { state: "unreachable" };
15595
16637
  const started = performance.now();
15596
16638
  return await defaultProbeDiscovery({
@@ -15713,6 +16755,17 @@ async function apply(ctx, config) {
15713
16755
  sendJson(response, 200, await releaseManager.status(), false);
15714
16756
  return;
15715
16757
  }
16758
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/extensions/hosts`) {
16759
+ sendJson(response, 200, mobileAccess.hostStatus(), false);
16760
+ return;
16761
+ }
16762
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/extensions/recover`) {
16763
+ const body = await readJsonObject(request, 4096);
16764
+ if (typeof body.id !== "string" || body.confirm !== true) throw new HttpError(400, "bad_request");
16765
+ await mobileAccess.recoverExtension(body.id);
16766
+ sendJson(response, 200, mobileAccess.hostStatus(), false);
16767
+ return;
16768
+ }
15716
16769
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/release/update`) {
15717
16770
  await readJsonObject(request, 4096);
15718
16771
  sendJson(response, 200, await releaseManager.update(), false);
@@ -15946,7 +16999,7 @@ async function apply(ctx, config) {
15946
16999
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
15947
17000
  const settings = frpConfig.settings();
15948
17001
  if (settings === void 0) throw new HttpError(409, "frp_config_missing");
15949
- const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
17002
+ const check = await frpIngressSelfCheck(settings, remoteDeviceFile, stateDirectory);
15950
17003
  const entry = resolveFrpEntryProbe(settings);
15951
17004
  const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(entry.host, entry.port)]);
15952
17005
  sendJson(response, 200, {
@@ -16061,10 +17114,10 @@ async function apply(ctx, config) {
16061
17114
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
16062
17115
  await remoteProviders.mutate(async () => {
16063
17116
  await remoteControllers.frp.setEnabled(false);
16064
- await Promise.all([
16065
- frpComponent.purge(),
16066
- frpConfig.purge(),
16067
- purgeFrpIngressCertificates(remoteDeviceFile)
17117
+ await settleCleanupSteps([
17118
+ () => frpComponent.purge().then(() => void 0),
17119
+ () => frpConfig.purge().then(() => void 0),
17120
+ () => purgeFrpIngressCertificates(remoteDeviceFile, stateDirectory)
16068
17121
  ]);
16069
17122
  });
16070
17123
  sendJson(response, 200, remotePayload(), false);
@@ -16130,15 +17183,23 @@ async function apply(ctx, config) {
16130
17183
  kind: "error",
16131
17184
  text: "请带上需求,例如:/mobile 把手机端改成深色主题"
16132
17185
  };
17186
+ const hostStates = new Map(mobileAccess.hostStatus().hosts.map((host) => [host.id, host]));
16133
17187
  const guide = buildMobileGuide({
16134
17188
  directory: stateDirectory,
16135
17189
  hasCustomCss: await existsRegularFile(template.customCssFile),
16136
17190
  hasCustomJs: await existsRegularFile(template.customScriptFile),
16137
- extensions: mobileAccess.manifest().map((entry) => ({
16138
- id: entry.id,
16139
- name: entry.name,
16140
- version: entry.version
16141
- })),
17191
+ extensions: mobileAccess.manifest().map((entry) => {
17192
+ const host = hostStates.get(entry.id);
17193
+ return {
17194
+ id: entry.id,
17195
+ name: entry.name,
17196
+ version: entry.version,
17197
+ ...host === void 0 ? {} : {
17198
+ executionMode: host.mode,
17199
+ executionState: host.state
17200
+ }
17201
+ };
17202
+ }),
16142
17203
  failedExtensionCount: mobileAccess.status().failed
16143
17204
  });
16144
17205
  agent.steer(createUserMessage({
@@ -16159,7 +17220,7 @@ async function apply(ctx, config) {
16159
17220
  }
16160
17221
  });
16161
17222
  try {
16162
- await mobileAccess.startLocal(template.extensionsDir, ctx);
17223
+ await mobileAccess.startLocal(template.extensionsDir, ctx, { hostExecution: template.hostExecution });
16163
17224
  await lanController.initialize();
16164
17225
  const stores = {
16165
17226
  tailscale: tailscaleStore,
@@ -16211,6 +17272,6 @@ async function apply(ctx, config) {
16211
17272
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
16212
17273
  }
16213
17274
  //#endregion
16214
- export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
17275
+ export { AUTH_PREFIX, AccessController, AccessError, BlockedUpgradePathLog, BoundedRateLimiter, CERT_EXPIRING_DAYS, CLOUDFLARED_COMPONENT_RELEASE, CLOUDFLARED_COMPONENT_RELEASES, CSRF_COOKIE, CSRF_HEADER, ClientModuleConflictError, ClientModulePreferenceStore, CloudflaredComponentManager, CloudflaredController, CloudflaredTunnelStore, Config, DEFAULT_ORIGIN_LISTEN_PORT, FRP_VHOST_HTTP_PORT as DEFAULT_VHOST_HTTP_PORT, FRP_VHOST_HTTP_PORT, DEVICE_COOKIE, EXTENSION_LIMITS, FRP_ATTACH_DISCOVERY_PATH, FRP_ATTACH_LOCAL_DECLARATION, FRP_ATTACH_SELF_SIGNED_DECLARATION, FRP_ATTACH_VPS_DECLARATION, FRP_CADDY_IMPORT_LINE, FRP_CADDY_SNIPPET_MARKER, FRP_CADDY_SNIPPET_PATH, FRP_COMPONENT_RELEASES, FRP_DEFAULT_PROXY_NAME, FRP_DEFAULT_PUBLIC_PORT, FRP_RESERVED_PORTS, FrpComponentManager, FrpConfigStore, FrpController, JsonDeviceStore, JsonMobileAccessControlStore, JsonRemoteProviderStore, LOCAL_ADMIN_PREFIX, MAX_BLOCKED_UPGRADE_PATHS, MAX_EXTRA_WEBSOCKET_PATHS, MAX_WEBSOCKET_PATH_LENGTH, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, MobileAccessService, MobileExtensionError, OriginConfigStore, OriginController, REMOTE_PROVIDERS, RequestTrustPolicy, SESSION_COOKIE, TASK_EVENT_DEBOUNCE_MS, TaskEventHub, WS_PATHS, WebSocketPathStore, addressAllowed, apply, assertExtensionId, configuredRemoteProvider, createCaddySite, createFrpAttachPlan, createFrpAttachTemplate, createFrpAttachTemplateParts, createFrpServerTemplate, createFrpcToml, createMobileAccessService, createRestrictedFrpServerTemplate, ensureFrpIngressCertificate, evaluateCertificateLifetime, frpAttachVpsParts, frpIngressPaths, frpIngressSelfCheck, frpProxyName, inject, isCloudflaredRegistration, isFrpSelfSignedIngress, isGloballyRoutableIpv4, isLoopbackAddress, issueServerCertificate, mergeSavedCloudflaredTunnelSettings, mergeSavedFrpSettings, mergeSavedFrpTarget, name, normalizeWebSocketPaths, originGatewayConfig, parseAuthority, parseCidr, parseCloudflaredOrigin, parseCloudflaredTunnelSettings, parseControlFile, parseDeviceSnapshot, parseExtensionManifest, parseFrpSettings, parseGatewayConfig, parseMobileAccessControlState, parseOriginSettings, parseRemoteProviderState, probeOriginCertificate, readCertificateRenewal, resolveAuthority, resolveFrpEntryTls, resolveFrpMode, resolveFrpPublicPort, resolveFrpVhostHttpPort, resolveHostExecution, rewriteMobileIndex, validateAttachSettings, validateCloudflaredTunnelHostname, validateCloudflaredTunnelPort, validateCloudflaredTunnelToken, validateFrpEntryTls, validateFrpMode, validateFrpProxyName, validateFrpPublicOrigin, validateFrpPublicPort, validateFrpServerAddress, validateFrpServerPort, validateFrpToken, validateFrpVhostHttpPort, validateOriginAllowedCidrs, validateOriginListenHost, validateOriginListenPort, validateOriginPublicOrigin, validateWebSocketPath, watchTaskCompletions };
16215
17276
 
16216
17277
  //# sourceMappingURL=index.mjs.map