dsh-mobile 0.6.2 → 0.6.4

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 (44) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CONTRIBUTORS.md +19 -4
  3. package/README.en.md +16 -8
  4. package/README.md +16 -8
  5. package/SECURITY.md +3 -2
  6. package/THIRD_PARTY_NOTICES.md +29 -1
  7. package/apps/mobile/README.md +25 -5
  8. package/apps/mobile/release.json +3 -3
  9. package/cordis.patch.yml +7 -3
  10. package/docs/ATTACH_EXISTING_FRPS.en.md +1 -1
  11. package/docs/ATTACH_EXISTING_FRPS.md +2 -2
  12. package/docs/CADDY_MANAGED.en.md +15 -1
  13. package/docs/CADDY_MANAGED.md +15 -1
  14. package/docs/CLOUDFLARE_TUNNEL.en.md +2 -2
  15. package/docs/CLOUDFLARE_TUNNEL.md +3 -3
  16. package/docs/EXTENSION_WORKERS.en.md +49 -0
  17. package/docs/EXTENSION_WORKERS.md +49 -0
  18. package/docs/MOBILE_COMPOSER_TOOLBAR.en.md +27 -0
  19. package/docs/MOBILE_COMPOSER_TOOLBAR.md +27 -0
  20. package/docs/README.md +2 -0
  21. package/docs/SELF_HOSTED_FRP.en.md +4 -2
  22. package/docs/SELF_HOSTED_FRP.md +4 -2
  23. package/docs/SLOW_CONNECTIONS.en.md +6 -0
  24. package/docs/SLOW_CONNECTIONS.md +6 -0
  25. package/docs/TRUSTED_NETWORKS.en.md +1 -1
  26. package/docs/TRUSTED_NETWORKS.md +1 -1
  27. package/lib/cli.js +140 -19
  28. package/lib/cli.js.map +1 -1
  29. package/lib/client.js +1264 -106
  30. package/lib/client.js.map +1 -1
  31. package/lib/extension-worker-runtime.mjs +1646 -0
  32. package/lib/extension-worker-runtime.mjs.map +1 -0
  33. package/lib/index.d.mts +209 -12
  34. package/lib/index.mjs +2152 -811
  35. package/lib/index.mjs.map +1 -1
  36. package/lib/mobile-layout.js.map +1 -1
  37. package/package.json +17 -6
  38. package/packages/question-fixes/LICENSE +201 -0
  39. package/packages/question-fixes/package.json +9 -2
  40. package/node_modules/dsh-mobile-question-fixes/lib/client.js +0 -78
  41. package/node_modules/dsh-mobile-question-fixes/lib/client.js.map +0 -1
  42. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs +0 -14
  43. package/node_modules/dsh-mobile-question-fixes/lib/index.mjs.map +0 -1
  44. package/node_modules/dsh-mobile-question-fixes/package.json +0 -25
package/lib/index.mjs CHANGED
@@ -1,28 +1,30 @@
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
+ import { performance as performance$1 } from "node:perf_hooks";
8
9
  import { execFile, spawn } from "node:child_process";
9
10
  import { createSocket } from "node:dgram";
10
11
  import { homedir, hostname, networkInterfaces, tmpdir } from "node:os";
11
12
  import { createServer as createServer$1, request } from "node:http";
12
13
  import { createServer as createServer$2, request as request$1 } from "node:https";
13
14
  import { Readable, Transform, finished } from "node:stream";
14
- import { pipeline } from "node:stream/promises";
15
+ import { finished as finished$1, pipeline } from "node:stream/promises";
15
16
  import { promisify } from "node:util";
16
17
  import { createGzip, gzip } from "node:zlib";
17
18
  import Bonjour from "bonjour-service";
18
19
  import * as QRCode from "qrcode";
19
20
  import WebSocket, { WebSocketServer } from "ws";
20
21
  import { Logger, Service } from "@deepseek-ai/cordis";
22
+ import { Worker } from "node:worker_threads";
21
23
  import { connect as connect$1 } from "node:tls";
22
24
  import { generate } from "selfsigned";
23
25
  import { boundContextSummary, createUserMessage } from "@deepseek-ai/dsh-llm/message";
24
26
  import { lookup } from "node:dns/promises";
25
- import { createWriteStream } from "node:fs";
27
+ import { constants } from "node:fs";
26
28
  //#region src/access.ts
27
29
  /** Stable error categories converted to deliberately terse HTTP responses. */
28
30
  var AccessError = class extends Error {
@@ -860,9 +862,69 @@ var WebSocketPathStore = class {
860
862
  }
861
863
  };
862
864
  //#endregion
865
+ //#region src/extension-worker-config.ts
866
+ function integer$1(value, name, fallback, minimum, maximum) {
867
+ const resolved = value ?? fallback;
868
+ if (typeof resolved !== "number" || !Number.isSafeInteger(resolved) || resolved < minimum || resolved > maximum) throw new Error(`hostExecution.${name} must be an integer from ${minimum} through ${maximum}`);
869
+ return resolved;
870
+ }
871
+ /** Resolve configuration once; reject malformed modes, ids and incoherent stream windows. */
872
+ function resolveHostExecution(raw) {
873
+ if (raw !== void 0 && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new Error("hostExecution must be an object");
874
+ const value = raw ?? {};
875
+ const mode = value.mode ?? "in-process";
876
+ if (mode !== "worker" && mode !== "in-process") throw new Error("hostExecution.mode is invalid");
877
+ 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");
878
+ const streamWindowBytes = integer$1(value.streamWindowBytes, "streamWindowBytes", 262144, 1024, 4194304);
879
+ const streamChunkBytes = integer$1(value.streamChunkBytes, "streamChunkBytes", 65536, 1024, streamWindowBytes);
880
+ const streamAggregateBytes = integer$1(value.streamAggregateBytes, "streamAggregateBytes", 1048576, streamWindowBytes, 33554432);
881
+ return Object.freeze({
882
+ mode,
883
+ extensions: value.extensions === void 0 ? void 0 : Object.freeze([...value.extensions]),
884
+ maxWorkers: integer$1(value.maxWorkers, "maxWorkers", 8, 1, 128),
885
+ maxRegistrations: integer$1(value.maxRegistrations, "maxRegistrations", 256, 1, 4096),
886
+ maxOldGenerationSizeMb: integer$1(value.maxOldGenerationSizeMb, "maxOldGenerationSizeMb", 128, 16, 4096),
887
+ maxYoungGenerationSizeMb: integer$1(value.maxYoungGenerationSizeMb, "maxYoungGenerationSizeMb", 16, 1, 512),
888
+ stackSizeMb: integer$1(value.stackSizeMb, "stackSizeMb", 4, 1, 64),
889
+ activationTimeoutMs: integer$1(value.activationTimeoutMs, "activationTimeoutMs", 5e3, 1, 3e5),
890
+ operationTimeoutMs: integer$1(value.operationTimeoutMs, "operationTimeoutMs", 3e4, 1, 3e5),
891
+ cancelGraceMs: integer$1(value.cancelGraceMs, "cancelGraceMs", 250, 1, 3e4),
892
+ streamCancelGraceMs: integer$1(value.streamCancelGraceMs, "streamCancelGraceMs", 1e3, 1, 3e4),
893
+ disposeGraceMs: integer$1(value.disposeGraceMs, "disposeGraceMs", 2e3, 1, 3e4),
894
+ resultMaxBytes: integer$1(value.resultMaxBytes, "resultMaxBytes", 4194304, 1024, 4194304),
895
+ streamWindowBytes,
896
+ streamChunkBytes,
897
+ streamAggregateBytes,
898
+ logWindowMs: integer$1(value.logWindowMs, "logWindowMs", 1e3, 1, 6e4),
899
+ logMessagesPerWindow: integer$1(value.logMessagesPerWindow, "logMessagesPerWindow", 64, 1, 1024),
900
+ logMessageBytes: integer$1(value.logMessageBytes, "logMessageBytes", 4096, 128, 65536)
901
+ });
902
+ }
903
+ //#endregion
863
904
  //#region src/config.ts
864
905
  /** Loader-facing defaults; {@link parseGatewayConfig} enforces cross-field security rules. */
865
906
  const Config = z.object({
907
+ hostExecution: z.object({
908
+ mode: z.union([z.const("in-process"), z.const("worker")]),
909
+ extensions: z.union([z.const(void 0), z.array(String)]),
910
+ maxWorkers: z.natural(),
911
+ maxRegistrations: z.natural(),
912
+ maxOldGenerationSizeMb: z.natural(),
913
+ maxYoungGenerationSizeMb: z.natural(),
914
+ stackSizeMb: z.natural(),
915
+ activationTimeoutMs: z.natural(),
916
+ operationTimeoutMs: z.natural(),
917
+ cancelGraceMs: z.natural(),
918
+ streamCancelGraceMs: z.natural(),
919
+ disposeGraceMs: z.natural(),
920
+ resultMaxBytes: z.natural(),
921
+ streamWindowBytes: z.natural(),
922
+ streamChunkBytes: z.natural(),
923
+ streamAggregateBytes: z.natural(),
924
+ logWindowMs: z.natural(),
925
+ logMessagesPerWindow: z.natural(),
926
+ logMessageBytes: z.natural()
927
+ }),
866
928
  setupFile: z.string().hidden(),
867
929
  publicOrigin: z.string(),
868
930
  listenHost: z.string(),
@@ -977,7 +1039,9 @@ function parsePublicOrigin$1(value) {
977
1039
  });
978
1040
  }
979
1041
  function parseTls(value, listenHost) {
1042
+ if (value !== void 0 && (value === null || typeof value !== "object" || Array.isArray(value))) throw new Error("tls must be an object");
980
1043
  const mode = value?.mode ?? "provided";
1044
+ if (value?.mode === null || mode !== "provided" && mode !== "disabled") throw new Error("tls.mode must be provided or disabled");
981
1045
  if (mode === "disabled") {
982
1046
  if (!isLoopbackAddress(listenHost)) throw new Error("TLS may be disabled only on an IP loopback listener");
983
1047
  return Object.freeze({ mode });
@@ -1024,6 +1088,7 @@ function parseGatewayConfig(raw) {
1024
1088
  if (sessionTtlMs > deviceTtlMs) throw new Error("sessionTtlMs must not exceed deviceTtlMs");
1025
1089
  return Object.freeze({
1026
1090
  listenHost,
1091
+ hostExecution: resolveHostExecution(value.hostExecution),
1027
1092
  listenPort,
1028
1093
  upstreamOrigin,
1029
1094
  authorities: Object.freeze(authorities),
@@ -1087,24 +1152,75 @@ function execFileText(file, args, options = {}) {
1087
1152
  //#endregion
1088
1153
  //#region src/private-file.ts
1089
1154
  let userSidTask;
1155
+ function commandFailureDetails(error) {
1156
+ if (error === null || typeof error !== "object") return {};
1157
+ let descriptors;
1158
+ try {
1159
+ descriptors = Object.getOwnPropertyDescriptors(error);
1160
+ } catch (_error) {
1161
+ return {};
1162
+ }
1163
+ const code = descriptors.code?.value;
1164
+ const signal = descriptors.signal?.value;
1165
+ const killed = descriptors.killed?.value;
1166
+ return {
1167
+ ...code === null || typeof code === "number" && Number.isSafeInteger(code) || typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/u.test(code) ? { code } : {},
1168
+ ...signal === null || typeof signal === "string" && /^SIG[A-Z0-9]{1,16}$/u.test(signal) ? { signal } : {},
1169
+ ...typeof killed === "boolean" ? { killed } : {}
1170
+ };
1171
+ }
1172
+ /** Phase and full await duration, including subprocess creation, without arguments, output or private paths. */
1173
+ var PrivateFilePermissionError = class extends Error {
1174
+ stage;
1175
+ elapsedMs;
1176
+ code;
1177
+ signal;
1178
+ killed;
1179
+ constructor(stage, elapsedMs, error) {
1180
+ const details = commandFailureDetails(error);
1181
+ const fields = [
1182
+ ...details.code === void 0 ? [] : [`code=${String(details.code)}`],
1183
+ ...details.signal === void 0 ? [] : [`signal=${String(details.signal)}`],
1184
+ ...details.killed === void 0 ? [] : [`killed=${String(details.killed)}`]
1185
+ ];
1186
+ const summary = `Windows private-file ${stage} failed after ${String(elapsedMs)} ms${fields.length === 0 ? "" : ` (${fields.join(", ")})`}`;
1187
+ const cause = Object.assign(/* @__PURE__ */ new Error("Windows permission operation failed"), details);
1188
+ super(summary, { cause });
1189
+ this.stage = stage;
1190
+ this.elapsedMs = elapsedMs;
1191
+ this.name = "PrivateFilePermissionError";
1192
+ this.code = details.code;
1193
+ this.signal = details.signal;
1194
+ this.killed = details.killed;
1195
+ }
1196
+ };
1197
+ async function permissionOperation(stage, operation) {
1198
+ const started = performance$1.now();
1199
+ try {
1200
+ return await operation();
1201
+ } catch (error) {
1202
+ throw new PrivateFilePermissionError(stage, Math.max(0, Math.round(performance$1.now() - started)), error);
1203
+ }
1204
+ }
1090
1205
  function windowsSystemTool(name) {
1091
1206
  const systemRoot = process.env.SystemRoot ?? process.env.WINDIR;
1092
- if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw new Error("Windows system root is unavailable");
1207
+ if (systemRoot === void 0 || !win32.isAbsolute(systemRoot)) throw Object.assign(/* @__PURE__ */ new Error("Windows system root is unavailable"), { code: "WINDOWS_SYSTEM_ROOT_UNAVAILABLE" });
1093
1208
  return win32.join(systemRoot, "System32", name);
1094
1209
  }
1095
1210
  async function currentWindowsUserSid() {
1096
- userSidTask ??= execFileText(windowsSystemTool("whoami.exe"), [
1097
- "/user",
1098
- "/fo",
1099
- "csv",
1100
- "/nh"
1101
- ], {
1102
- encoding: "utf8",
1103
- windowsHide: true,
1104
- timeout: 1e4
1105
- }).then(({ stdout }) => {
1211
+ userSidTask ??= permissionOperation("sid-query", async () => {
1212
+ const { stdout } = await execFileText(windowsSystemTool("whoami.exe"), [
1213
+ "/user",
1214
+ "/fo",
1215
+ "csv",
1216
+ "/nh"
1217
+ ], {
1218
+ encoding: "utf8",
1219
+ windowsHide: true,
1220
+ timeout: 1e4
1221
+ });
1106
1222
  const match = /,"(S-\d(?:-\d+)+)"\s*$/u.exec(stdout.trim());
1107
- if (match?.[1] === void 0) throw new Error("unable to resolve the current Windows user SID");
1223
+ if (match?.[1] === void 0) throw Object.assign(/* @__PURE__ */ new Error("unable to resolve the current Windows user SID"), { code: "WINDOWS_USER_SID_INVALID" });
1108
1224
  return match[1];
1109
1225
  }).catch((error) => {
1110
1226
  userSidTask = void 0;
@@ -1112,12 +1228,12 @@ async function currentWindowsUserSid() {
1112
1228
  });
1113
1229
  return userSidTask;
1114
1230
  }
1115
- /** Restrict a sensitive regular file to the current user and Windows administrators. */
1231
+ /** Restrict a sensitive regular file to the current user and Windows administrators; reject Windows permission failures with redacted phase and duration. */
1116
1232
  async function restrictPrivateFile(file, mode = 384) {
1117
1233
  await chmod(file, mode);
1118
1234
  if (process.platform !== "win32") return;
1119
1235
  const userSid = await currentWindowsUserSid();
1120
- await execFileText(windowsSystemTool("icacls.exe"), [
1236
+ await permissionOperation("acl-apply", () => execFileText(windowsSystemTool("icacls.exe"), [
1121
1237
  file,
1122
1238
  "/inheritance:r",
1123
1239
  "/grant:r",
@@ -1132,7 +1248,7 @@ async function restrictPrivateFile(file, mode = 384) {
1132
1248
  encoding: "utf8",
1133
1249
  windowsHide: true,
1134
1250
  timeout: 1e4
1135
- });
1251
+ }));
1136
1252
  }
1137
1253
  //#endregion
1138
1254
  //#region src/client-module-preferences.ts
@@ -1393,24 +1509,23 @@ var JsonMobileAccessControlStore = class {
1393
1509
  this.file = file;
1394
1510
  this.initiallyEnabled = initiallyEnabled;
1395
1511
  }
1396
- /** Avoid writing an absent already-disabled state without changing recovery of existing files. */
1512
+ /** Disable an existing preference, returning the state only after private-file preparation completes. */
1397
1513
  async disableIfPresent() {
1514
+ const disabled = Object.freeze({
1515
+ version: 1,
1516
+ enabled: false
1517
+ });
1398
1518
  try {
1399
1519
  await lstat(this.file);
1400
1520
  } catch (error) {
1401
1521
  if (error.code === "ENOENT") {
1402
- if (this.initiallyEnabled) await this.save({
1403
- version: 1,
1404
- enabled: false
1405
- });
1406
- return;
1522
+ if (this.initiallyEnabled) await this.save(disabled);
1523
+ return disabled;
1407
1524
  }
1408
1525
  throw error;
1409
1526
  }
1410
- await this.save({
1411
- version: 1,
1412
- enabled: false
1413
- });
1527
+ await this.save(disabled);
1528
+ return disabled;
1414
1529
  }
1415
1530
  async load() {
1416
1531
  let stat;
@@ -1453,8 +1568,8 @@ var JsonMobileAccessControlStore = class {
1453
1568
  flag: "wx",
1454
1569
  mode: 384
1455
1570
  });
1571
+ await restrictPrivateFile(temporary);
1456
1572
  await rename(temporary, this.file);
1457
- await restrictPrivateFile(this.file);
1458
1573
  } catch (error) {
1459
1574
  try {
1460
1575
  await rm(temporary, { force: true });
@@ -1896,6 +2011,44 @@ function ensureMobileCompatibility(html) {
1896
2011
  return `${html.slice(0, first.index)}${script}${html.slice(first.index)}`;
1897
2012
  }
1898
2013
  //#endregion
2014
+ //#region src/http-cache.ts
2015
+ /** Entity-tag handling for authenticated GET and HEAD cache validation. */
2016
+ const ENTITY_TAG = /^[\t ]*(?:W\/)?("[\x21\x23-\x7e\x80-\xff]*")[\t ]*$/u;
2017
+ const ENTITY_TAG_LIST_ITEM = /(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"/gu;
2018
+ /**
2019
+ * Compare a GET or HEAD If-None-Match field with an existing representation.
2020
+ * Invalid fields do not match; empty list elements are ignored per HTTP list syntax.
2021
+ * @param field - The received conditional request field.
2022
+ * @param etag - The selected representation's valid entity tag.
2023
+ * @returns Whether the representation is unchanged under weak comparison.
2024
+ */
2025
+ function matchesIfNoneMatch(field, etag) {
2026
+ if (field === void 0) return false;
2027
+ const current = ENTITY_TAG.exec(etag)?.[1];
2028
+ if (current === void 0) return false;
2029
+ if (/^[\t ]*\*[\t ]*$/u.test(field)) return true;
2030
+ let cursor = 0;
2031
+ let count = 0;
2032
+ let matches = false;
2033
+ for (const item of field.matchAll(ENTITY_TAG_LIST_ITEM)) {
2034
+ const gap = field.slice(cursor, item.index);
2035
+ if (!(count === 0 ? /^[\t ,]*$/u : /^[\t ]*,[\t ,]*$/u).test(gap)) return false;
2036
+ if ((item[0].startsWith("W/") ? item[0].slice(2) : item[0]) === current) matches = true;
2037
+ cursor = item.index + item[0].length;
2038
+ count++;
2039
+ }
2040
+ return count > 0 && /^[\t ,]*$/u.test(field.slice(cursor)) && matches;
2041
+ }
2042
+ /**
2043
+ * Keep an upstream validator usable after lossless content encoding changes.
2044
+ * @param etag - An upstream entity tag, if supplied.
2045
+ * @returns A weak quoted entity tag, or undefined for an absent or invalid tag.
2046
+ */
2047
+ function weakenEntityTag(etag) {
2048
+ const opaque = etag === void 0 ? void 0 : ENTITY_TAG.exec(etag)?.[1];
2049
+ return opaque === void 0 ? void 0 : `W/${opaque}`;
2050
+ }
2051
+ //#endregion
1899
2052
  //#region src/browser-auth-cookies.ts
1900
2053
  const HOST_SESSION_COOKIE = "__Host-dsh_ma_session";
1901
2054
  const HOST_CSRF_COOKIE = "__Host-dsh_ma_csrf";
@@ -2258,6 +2411,660 @@ async function readComputerImage(path, signal) {
2258
2411
  }
2259
2412
  }
2260
2413
  //#endregion
2414
+ //#region src/extension-worker.ts
2415
+ /** Supervise opt-in local hosts without executing extension code on the DSH thread. */
2416
+ /** JSON bytes serialized and size-checked inside the worker. */
2417
+ var PreparedJsonResult = class {
2418
+ bytes;
2419
+ constructor(bytes) {
2420
+ this.bytes = bytes;
2421
+ }
2422
+ };
2423
+ /** Identify worker results without invoking an extension serializer in the gateway. */
2424
+ function isPreparedJson(value) {
2425
+ return value instanceof PreparedJsonResult;
2426
+ }
2427
+ /** Resolve the installed standalone entry adjacent to the plugin bundle. */
2428
+ function defaultExtensionWorkerModule() {
2429
+ return new URL("./extension-worker-runtime.mjs", import.meta.url);
2430
+ }
2431
+ /** Every thread counts until actual exit, including activation, retirement and termination. */
2432
+ var ExtensionWorkerBudget = class {
2433
+ maximum;
2434
+ count = 0;
2435
+ constructor(maximum) {
2436
+ this.maximum = maximum;
2437
+ }
2438
+ get current() {
2439
+ return this.count;
2440
+ }
2441
+ reserve() {
2442
+ if (this.count >= this.maximum) throw new MobileExtensionError("extension_worker_limit", "local extension worker limit reached", 503);
2443
+ this.count += 1;
2444
+ let released = false;
2445
+ return () => {
2446
+ if (!released) {
2447
+ released = true;
2448
+ this.count -= 1;
2449
+ }
2450
+ };
2451
+ }
2452
+ };
2453
+ /** Diagnostic count; production admission uses an instance-owned budget. */
2454
+ const activeWorkerCount = { current: 0 };
2455
+ const LIFECYCLE_ID = "lifecycle";
2456
+ /** Node accepts quoted NODE_OPTIONS words; inspect option names without exposing their values. */
2457
+ function nodeOptionWords(value) {
2458
+ const words = [];
2459
+ let word = "";
2460
+ let quoted = false;
2461
+ let escaped = false;
2462
+ for (const character of value) {
2463
+ if (escaped) {
2464
+ word += character;
2465
+ escaped = false;
2466
+ continue;
2467
+ }
2468
+ if (character === "\\") {
2469
+ escaped = true;
2470
+ continue;
2471
+ }
2472
+ if (character === "\"") {
2473
+ quoted = !quoted;
2474
+ continue;
2475
+ }
2476
+ if (/\s/u.test(character) && !quoted) {
2477
+ if (word !== "") {
2478
+ words.push(word);
2479
+ word = "";
2480
+ }
2481
+ continue;
2482
+ }
2483
+ word += character;
2484
+ }
2485
+ if (word !== "") words.push(word);
2486
+ return words;
2487
+ }
2488
+ /** Known parent V8 heap flags take precedence over Worker resourceLimits. */
2489
+ function parentWorkerHeapOverrides(argv, nodeOptions) {
2490
+ const heapFlags = /* @__PURE__ */ new Set([
2491
+ "--max-old-space-size",
2492
+ "--max-semi-space-size",
2493
+ "--max-heap-size",
2494
+ "--max-old-space-size-percentage"
2495
+ ]);
2496
+ const found = /* @__PURE__ */ new Set();
2497
+ for (const argument of [...argv, ...nodeOptionWords(nodeOptions ?? "")]) {
2498
+ const name = argument.split("=", 1)[0].replaceAll("_", "-");
2499
+ if (heapFlags.has(name)) found.add(name);
2500
+ }
2501
+ return Object.freeze([...found]);
2502
+ }
2503
+ const initialNodeOptions = process.env.NODE_OPTIONS;
2504
+ var ExtensionWorkerHost = class {
2505
+ options;
2506
+ limits;
2507
+ worker;
2508
+ pending = /* @__PURE__ */ new Map();
2509
+ routePending = /* @__PURE__ */ new Map();
2510
+ streams = /* @__PURE__ */ new Map();
2511
+ detachedRoutes = /* @__PURE__ */ new Set();
2512
+ executionGrace = /* @__PURE__ */ new Map();
2513
+ exited;
2514
+ unavailableController = new AbortController();
2515
+ termination;
2516
+ activation;
2517
+ dead = false;
2518
+ disposed = false;
2519
+ maxBufferedBytes = 0;
2520
+ cancelledStreams = 0;
2521
+ logWindowStart = Date.now();
2522
+ logCount = 0;
2523
+ stdioBytes = 0;
2524
+ stdioResume;
2525
+ constructor(options) {
2526
+ this.options = options;
2527
+ this.limits = resolveHostExecution(options.limits);
2528
+ const overrides = new Set(parentWorkerHeapOverrides(process.execArgv, initialNodeOptions));
2529
+ for (const flag of parentWorkerHeapOverrides([], process.env.NODE_OPTIONS)) overrides.add(flag);
2530
+ if (overrides.size > 0) throw new MobileExtensionError("extension_worker_heap_override", `worker heap limits require launching DSH without ${[...overrides].join(", ")}`, 503);
2531
+ const release = options.budget?.reserve() ?? (() => void 0);
2532
+ try {
2533
+ this.worker = new Worker(options.workerModule, {
2534
+ workerData: { limits: this.limits },
2535
+ stdout: true,
2536
+ stderr: true,
2537
+ resourceLimits: {
2538
+ maxOldGenerationSizeMb: this.limits.maxOldGenerationSizeMb,
2539
+ maxYoungGenerationSizeMb: this.limits.maxYoungGenerationSizeMb,
2540
+ stackSizeMb: this.limits.stackSizeMb
2541
+ }
2542
+ });
2543
+ } catch (error) {
2544
+ release();
2545
+ throw error;
2546
+ }
2547
+ const drainConsole = (chunk) => {
2548
+ this.stdioBytes += chunk.byteLength;
2549
+ if (this.stdioBytes < this.limits.logMessageBytes * this.limits.logMessagesPerWindow || this.stdioResume !== void 0) return;
2550
+ this.worker.stdout?.pause();
2551
+ this.worker.stderr?.pause();
2552
+ this.stdioResume = setTimeout(() => {
2553
+ this.stdioResume = void 0;
2554
+ this.stdioBytes = 0;
2555
+ if (!this.dead) {
2556
+ this.worker.stdout?.resume();
2557
+ this.worker.stderr?.resume();
2558
+ }
2559
+ }, this.limits.logWindowMs);
2560
+ this.stdioResume.unref();
2561
+ };
2562
+ this.worker.stdout?.on("data", drainConsole);
2563
+ this.worker.stderr?.on("data", drainConsole);
2564
+ this.worker.unref();
2565
+ activeWorkerCount.current += 1;
2566
+ this.exited = new Promise((resolve) => {
2567
+ this.worker.once("exit", () => {
2568
+ this.dead = true;
2569
+ if (this.stdioResume !== void 0) clearTimeout(this.stdioResume);
2570
+ this.markUnavailable();
2571
+ release();
2572
+ activeWorkerCount.current -= 1;
2573
+ for (const timer of this.executionGrace.values()) clearTimeout(timer);
2574
+ this.executionGrace.clear();
2575
+ const error = this.unavailable();
2576
+ for (const entry of this.pending.values()) {
2577
+ entry.cleanup();
2578
+ entry.reject(error);
2579
+ }
2580
+ for (const entry of this.routePending.values()) {
2581
+ entry.cleanup();
2582
+ entry.reject(error);
2583
+ }
2584
+ this.pending.clear();
2585
+ this.routePending.clear();
2586
+ this.detachedRoutes.clear();
2587
+ for (const live of this.streams.values()) {
2588
+ if (live.cancelTimer !== void 0) clearTimeout(live.cancelTimer);
2589
+ live.bridge.destroy(error);
2590
+ }
2591
+ this.streams.clear();
2592
+ resolve();
2593
+ });
2594
+ });
2595
+ this.worker.on("message", (raw) => {
2596
+ try {
2597
+ this.handleMessage(raw);
2598
+ } catch (error) {
2599
+ this.terminate("invalid-worker-message");
2600
+ }
2601
+ });
2602
+ this.worker.on("error", () => {
2603
+ this.terminate("worker-error");
2604
+ });
2605
+ }
2606
+ /** A stopped generation never silently respawns. */
2607
+ available() {
2608
+ return !this.dead && this.termination === void 0 && !this.disposed && !this.unavailableController.signal.aborted;
2609
+ }
2610
+ /** Activate once and receive executable-free registration metadata. */
2611
+ async activate() {
2612
+ let timer;
2613
+ try {
2614
+ const reply = new Promise((resolve, reject) => {
2615
+ this.pending.set(LIFECYCLE_ID, {
2616
+ resolve,
2617
+ reject,
2618
+ cleanup: () => void 0
2619
+ });
2620
+ this.post({
2621
+ kind: "activate",
2622
+ id: LIFECYCLE_ID,
2623
+ hostFile: this.options.hostFile,
2624
+ generation: this.options.generation,
2625
+ manifest: this.options.manifest
2626
+ });
2627
+ });
2628
+ await Promise.race([reply, new Promise((_, reject) => {
2629
+ timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", "extension worker activation timed out", 500)), this.options.activationTimeoutMs ?? this.limits.activationTimeoutMs);
2630
+ })]);
2631
+ if (this.activation === void 0) throw new MobileExtensionError("host_activation_failed", "extension worker activation failed", 500);
2632
+ return this.activation;
2633
+ } catch (error) {
2634
+ await this.terminate("activation-failed");
2635
+ throw error;
2636
+ } finally {
2637
+ if (timer !== void 0) clearTimeout(timer);
2638
+ }
2639
+ }
2640
+ actionMetadata() {
2641
+ return this.activation?.actions ?? [];
2642
+ }
2643
+ routeMetadata() {
2644
+ return this.activation?.routes ?? [];
2645
+ }
2646
+ /** Underlying action: settles only when execution completes or the thread exits. */
2647
+ invokeExecution(action, input, caller) {
2648
+ if (!this.available()) return Promise.reject(this.unavailable());
2649
+ if (caller.signal.aborted) return Promise.reject(caller.signal.reason);
2650
+ const id = randomUUID();
2651
+ return new Promise((resolve, reject) => {
2652
+ const onAbort = () => {
2653
+ this.post({
2654
+ kind: "cancel",
2655
+ id
2656
+ });
2657
+ this.armExecutionGrace(id);
2658
+ };
2659
+ caller.signal.addEventListener("abort", onAbort, { once: true });
2660
+ this.pending.set(id, {
2661
+ resolve,
2662
+ reject,
2663
+ cleanup: () => {
2664
+ caller.signal.removeEventListener("abort", onAbort);
2665
+ this.settleExecutionGrace(id);
2666
+ }
2667
+ });
2668
+ try {
2669
+ this.post({
2670
+ kind: "invoke",
2671
+ id,
2672
+ action,
2673
+ deviceId: caller.deviceId,
2674
+ input
2675
+ });
2676
+ } catch (error) {
2677
+ this.pending.get(id)?.cleanup();
2678
+ this.pending.delete(id);
2679
+ reject(error instanceof Error ? error : new Error(String(error)));
2680
+ }
2681
+ });
2682
+ }
2683
+ /** Direct caller budget; service dispatch observes invokeExecution separately. */
2684
+ invoke(action, input, caller) {
2685
+ return this.callerBudget((signal) => this.invokeExecution(action, input, {
2686
+ ...caller,
2687
+ signal
2688
+ }), caller.signal, this.actionMetadata().find((entry) => entry.name === action)?.timeoutMs ?? this.limits.operationTimeoutMs, "extension_action_timeout");
2689
+ }
2690
+ /** Underlying route: late streams are cancelled and observed to confirmed end. */
2691
+ handleRouteExecution(routeIndex, request) {
2692
+ if (!this.available()) return Promise.reject(this.unavailable());
2693
+ if (request.signal.aborted) return Promise.reject(request.signal.reason);
2694
+ const id = randomUUID();
2695
+ return new Promise((resolve, reject) => {
2696
+ const onAbort = () => {
2697
+ this.post({
2698
+ kind: "cancel",
2699
+ id
2700
+ });
2701
+ if (this.routePending.has(id)) {
2702
+ this.detachedRoutes.add(id);
2703
+ this.armExecutionGrace(id);
2704
+ }
2705
+ this.cancelStream(id);
2706
+ };
2707
+ request.signal.addEventListener("abort", onAbort, { once: true });
2708
+ this.routePending.set(id, {
2709
+ resolve,
2710
+ reject,
2711
+ cleanup: () => {
2712
+ request.signal.removeEventListener("abort", onAbort);
2713
+ this.settleExecutionGrace(id);
2714
+ }
2715
+ });
2716
+ try {
2717
+ this.post({
2718
+ kind: "route",
2719
+ id,
2720
+ routeIndex,
2721
+ method: request.method,
2722
+ path: request.pathname,
2723
+ query: [...request.query.entries()],
2724
+ headers: request.headers,
2725
+ body: new Uint8Array(request.body),
2726
+ deviceId: request.deviceId
2727
+ });
2728
+ } catch (error) {
2729
+ this.routePending.get(id)?.cleanup();
2730
+ this.routePending.delete(id);
2731
+ reject(error instanceof Error ? error : new Error(String(error)));
2732
+ }
2733
+ });
2734
+ }
2735
+ /** Handler budget ends at stream-start; stream lifetime remains cancellation-bound. */
2736
+ handleRoute(routeIndex, request) {
2737
+ return this.callerBudget((signal) => this.handleRouteExecution(routeIndex, {
2738
+ ...request,
2739
+ signal
2740
+ }), request.signal, this.routeMetadata()[routeIndex]?.timeoutMs ?? this.limits.operationTimeoutMs, "extension_route_timeout", true);
2741
+ }
2742
+ async callerBudget(start, external, timeoutMs, code, holdForStream = false) {
2743
+ const controller = new AbortController();
2744
+ let timer;
2745
+ let detach;
2746
+ const onAbort = () => {
2747
+ controller.abort(external.reason);
2748
+ detach?.(external.reason ?? new MobileExtensionError("extension_cancelled", "caller cancelled", 409));
2749
+ };
2750
+ const onUnavailable = () => {
2751
+ detach?.(this.unavailableController.signal.reason);
2752
+ };
2753
+ if (external.aborted) throw external.reason;
2754
+ if (!this.available()) throw this.unavailable();
2755
+ external.addEventListener("abort", onAbort, { once: true });
2756
+ this.unavailableController.signal.addEventListener("abort", onUnavailable, { once: true });
2757
+ const release = () => {
2758
+ external.removeEventListener("abort", onAbort);
2759
+ this.unavailableController.signal.removeEventListener("abort", onUnavailable);
2760
+ if (timer !== void 0) clearTimeout(timer);
2761
+ };
2762
+ let retained = false;
2763
+ try {
2764
+ const result = await Promise.race([start(controller.signal), new Promise((_, reject) => {
2765
+ detach = reject;
2766
+ timer = setTimeout(() => {
2767
+ const error = new MobileExtensionError(code, "extension operation timed out", 500);
2768
+ controller.abort(error);
2769
+ reject(error);
2770
+ }, timeoutMs);
2771
+ })]);
2772
+ if (holdForStream && result !== null && typeof result === "object" && "body" in result && result.body instanceof Readable) {
2773
+ retained = true;
2774
+ result.body.once("close", release);
2775
+ result.body.once("end", release);
2776
+ }
2777
+ return result;
2778
+ } finally {
2779
+ if (timer !== void 0) clearTimeout(timer);
2780
+ if (!retained) release();
2781
+ }
2782
+ }
2783
+ streamStats() {
2784
+ return {
2785
+ activeStreams: [...this.streams.values()].filter((live) => !live.ended).length,
2786
+ maxBufferedBytes: this.maxBufferedBytes,
2787
+ cancelledStreams: this.cancelledStreams,
2788
+ bufferedBytes: this.totalUnacked()
2789
+ };
2790
+ }
2791
+ /** Graceful effects cleanup followed by termination; completion requires actual exit. */
2792
+ async dispose() {
2793
+ if (!this.available()) {
2794
+ await this.terminate("dispose-unavailable");
2795
+ return;
2796
+ }
2797
+ this.disposed = true;
2798
+ let timer;
2799
+ try {
2800
+ const reply = new Promise((resolve, reject) => {
2801
+ this.pending.set(LIFECYCLE_ID, {
2802
+ resolve,
2803
+ reject,
2804
+ cleanup: () => void 0
2805
+ });
2806
+ this.post({
2807
+ kind: "dispose",
2808
+ id: LIFECYCLE_ID
2809
+ });
2810
+ });
2811
+ await Promise.race([reply, new Promise((resolve) => {
2812
+ timer = setTimeout(resolve, this.limits.disposeGraceMs);
2813
+ })]);
2814
+ } finally {
2815
+ if (timer !== void 0) clearTimeout(timer);
2816
+ await this.terminate("disposed");
2817
+ }
2818
+ }
2819
+ whenExited() {
2820
+ return this.exited;
2821
+ }
2822
+ /** Stop admission now, retain execution/budget ownership until confirmed thread exit. */
2823
+ terminate(reason) {
2824
+ this.markUnavailable();
2825
+ this.termination ??= (async () => {
2826
+ if (!this.dead) await this.worker.terminate();
2827
+ await this.exited;
2828
+ })();
2829
+ this.termination.catch(() => void 0);
2830
+ return this.termination;
2831
+ }
2832
+ unavailable() {
2833
+ return new MobileExtensionError("extension_host_unavailable", `extension ${this.options.manifest.id} worker is unavailable; explicit recovery is required`, 503);
2834
+ }
2835
+ markUnavailable() {
2836
+ if (this.unavailableController.signal.aborted) return;
2837
+ const error = this.unavailable();
2838
+ this.unavailableController.abort(error);
2839
+ try {
2840
+ this.options.onUnavailable?.(error);
2841
+ } catch (cause) {}
2842
+ }
2843
+ armExecutionGrace(id) {
2844
+ if (this.executionGrace.has(id) || !this.available()) return;
2845
+ const timer = setTimeout(() => {
2846
+ this.executionGrace.delete(id);
2847
+ if (this.pending.has(id) || this.routePending.has(id)) this.terminate("cancel-unresponsive");
2848
+ }, this.limits.cancelGraceMs);
2849
+ timer.unref();
2850
+ this.executionGrace.set(id, timer);
2851
+ }
2852
+ settleExecutionGrace(id) {
2853
+ const timer = this.executionGrace.get(id);
2854
+ if (timer !== void 0) clearTimeout(timer);
2855
+ this.executionGrace.delete(id);
2856
+ }
2857
+ post(message) {
2858
+ if (!this.dead && this.termination === void 0) this.worker.postMessage(message);
2859
+ }
2860
+ handleMessage(raw) {
2861
+ if (this.dead || raw === null || typeof raw !== "object") return;
2862
+ const message = raw;
2863
+ if (message.kind === "log") {
2864
+ if (message.level !== "debug" && message.level !== "info" && message.level !== "warn" && message.level !== "error") return;
2865
+ if (!Array.isArray(message.args) || message.args.some((arg) => typeof arg !== "string")) return;
2866
+ if (Date.now() - this.logWindowStart >= this.limits.logWindowMs) {
2867
+ this.logWindowStart = Date.now();
2868
+ this.logCount = 0;
2869
+ }
2870
+ if (++this.logCount > this.limits.logMessagesPerWindow || Buffer.byteLength(message.args.join("")) > this.limits.logMessageBytes) return;
2871
+ try {
2872
+ this.options.logger[message.level](...message.args);
2873
+ } catch (error) {}
2874
+ return;
2875
+ }
2876
+ if (typeof message.runtimeId !== "string") return;
2877
+ if (this.activation !== void 0 && message.runtimeId !== this.activation.runtimeId) return;
2878
+ if (message.kind === "activated") {
2879
+ if (this.activation !== void 0 || !Array.isArray(message.actions) || !Array.isArray(message.routes)) throw new Error("invalid activation metadata");
2880
+ const actions = message.actions;
2881
+ const routes = message.routes;
2882
+ if (actions.length + routes.length > this.limits.maxRegistrations) throw new Error("worker registration limit exceeded");
2883
+ 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");
2884
+ for (const route of routes) if (typeof route?.method !== "string" || typeof route.path !== "string") throw new Error("invalid route metadata");
2885
+ this.activation = Object.freeze({
2886
+ runtimeId: message.runtimeId,
2887
+ actions: Object.freeze(actions),
2888
+ routes: Object.freeze(routes)
2889
+ });
2890
+ const pending = this.pending.get(LIFECYCLE_ID);
2891
+ this.pending.delete(LIFECYCLE_ID);
2892
+ pending?.cleanup();
2893
+ pending?.resolve(new PreparedJsonResult(Buffer.alloc(0)));
2894
+ return;
2895
+ }
2896
+ if (typeof message.id !== "string") return;
2897
+ const id = message.id;
2898
+ if (message.kind === "result" || message.kind === "error") {
2899
+ const pending = this.pending.get(id);
2900
+ this.pending.delete(id);
2901
+ pending?.cleanup();
2902
+ if (message.kind === "result") {
2903
+ if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.resultMaxBytes) {
2904
+ pending?.reject(new MobileExtensionError("extension_failed", "invalid worker result", 500));
2905
+ return;
2906
+ }
2907
+ pending?.resolve(new PreparedJsonResult(Buffer.from(message.bytes)));
2908
+ } else {
2909
+ 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);
2910
+ pending?.reject(error);
2911
+ const route = this.routePending.get(id);
2912
+ this.routePending.delete(id);
2913
+ this.detachedRoutes.delete(id);
2914
+ route?.cleanup();
2915
+ route?.reject(error);
2916
+ }
2917
+ return;
2918
+ }
2919
+ if (message.kind === "route-response" || message.kind === "stream-start") {
2920
+ const route = this.routePending.get(id);
2921
+ if (route === void 0) {
2922
+ if (message.kind === "stream-start") this.post({
2923
+ kind: "stream-cancel",
2924
+ id
2925
+ });
2926
+ return;
2927
+ }
2928
+ if (message.kind === "stream-start" && this.detachedRoutes.has(id)) {
2929
+ this.cancelledStreams += 1;
2930
+ this.post({
2931
+ kind: "stream-cancel",
2932
+ id
2933
+ });
2934
+ return;
2935
+ }
2936
+ const response = responseMetadata(message);
2937
+ this.routePending.delete(id);
2938
+ this.detachedRoutes.delete(id);
2939
+ if (message.kind === "route-response") {
2940
+ route.cleanup();
2941
+ if (!(message.bytes instanceof Uint8Array) || message.bytes.byteLength > this.limits.resultMaxBytes) {
2942
+ route.reject(new MobileExtensionError("invalid_route_response", "invalid worker route response", 500));
2943
+ return;
2944
+ }
2945
+ route.resolve({
2946
+ ...response,
2947
+ body: Buffer.from(message.bytes)
2948
+ });
2949
+ } else {
2950
+ this.settleExecutionGrace(id);
2951
+ const live = this.openStream(id, route.cleanup);
2952
+ route.resolve({
2953
+ ...response,
2954
+ body: live.bridge
2955
+ });
2956
+ }
2957
+ return;
2958
+ }
2959
+ if (message.kind === "stream-chunk") {
2960
+ const live = this.streams.get(id);
2961
+ if (live === void 0 || live.ended || live.bridge.destroyed) return;
2962
+ 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");
2963
+ live.unacked += message.bytes.byteLength;
2964
+ this.maxBufferedBytes = Math.max(this.maxBufferedBytes, this.totalUnacked());
2965
+ live.bridge.push(Buffer.from(message.bytes));
2966
+ return;
2967
+ }
2968
+ if (message.kind === "stream-end" || message.kind === "stream-error") {
2969
+ this.settleExecutionGrace(id);
2970
+ const detached = this.routePending.get(id);
2971
+ if (this.detachedRoutes.delete(id)) {
2972
+ this.routePending.delete(id);
2973
+ detached?.cleanup();
2974
+ detached?.reject(new MobileExtensionError("extension_cancelled", "caller detached", 409));
2975
+ }
2976
+ const live = this.streams.get(id);
2977
+ if (live === void 0) return;
2978
+ live.ended = true;
2979
+ if (live.cancelTimer !== void 0) clearTimeout(live.cancelTimer);
2980
+ if (message.kind === "stream-error") live.bridge.destroy(new Error(typeof message.message === "string" ? message.message.slice(0, 500) : "worker stream failed"));
2981
+ else if (live.bridge.destroyed) this.streams.delete(id);
2982
+ else live.bridge.push(null);
2983
+ }
2984
+ }
2985
+ totalUnacked() {
2986
+ let total = 0;
2987
+ for (const live of this.streams.values()) total += live.unacked;
2988
+ return total;
2989
+ }
2990
+ openStream(id, cleanup) {
2991
+ const live = {
2992
+ bridge: new WorkerStreamBridge((bytes) => {
2993
+ live.unacked -= bytes;
2994
+ this.post({
2995
+ kind: "stream-ack",
2996
+ id,
2997
+ bytes
2998
+ });
2999
+ }),
3000
+ unacked: 0,
3001
+ ended: false
3002
+ };
3003
+ live.bridge.on("error", () => void 0);
3004
+ const settle = () => {
3005
+ cleanup();
3006
+ if (live.ended) {
3007
+ if (live.unacked > 0) this.post({
3008
+ kind: "stream-cancel",
3009
+ id
3010
+ });
3011
+ this.streams.delete(id);
3012
+ } else this.cancelStream(id);
3013
+ };
3014
+ live.bridge.once("end", settle);
3015
+ live.bridge.once("close", settle);
3016
+ this.streams.set(id, live);
3017
+ return live;
3018
+ }
3019
+ cancelStream(id) {
3020
+ const live = this.streams.get(id);
3021
+ if (live === void 0 || live.ended || live.cancelTimer !== void 0) return;
3022
+ this.cancelledStreams += 1;
3023
+ this.post({
3024
+ kind: "stream-cancel",
3025
+ id
3026
+ });
3027
+ live.cancelTimer = setTimeout(() => {
3028
+ if (!live.ended) this.terminate("stream-cancel-unresponsive");
3029
+ }, this.limits.streamCancelGraceMs);
3030
+ live.cancelTimer.unref();
3031
+ live.bridge.destroy();
3032
+ }
3033
+ };
3034
+ function responseMetadata(message) {
3035
+ const headers = {};
3036
+ let bytes = typeof message.contentType === "string" ? Buffer.byteLength(message.contentType) : 0;
3037
+ if (message.headers !== void 0) {
3038
+ if (message.headers === null || typeof message.headers !== "object" || Array.isArray(message.headers)) throw new Error("invalid response headers");
3039
+ const entries = Object.entries(message.headers);
3040
+ if (entries.length > 3) throw new Error("invalid response header count");
3041
+ for (const [key, value] of entries) {
3042
+ if (typeof value !== "string" || !/^(?:content-disposition|cache-control|etag)$/iu.test(key)) throw new Error("invalid response header");
3043
+ bytes += Buffer.byteLength(key) + Buffer.byteLength(value);
3044
+ if (bytes > 16384) throw new Error("invalid response header bytes");
3045
+ headers[key] = value;
3046
+ }
3047
+ }
3048
+ return {
3049
+ ...typeof message.status === "number" ? { status: message.status } : {},
3050
+ ...typeof message.contentType === "string" ? { contentType: message.contentType } : {},
3051
+ ...message.headers === void 0 ? {} : { headers }
3052
+ };
3053
+ }
3054
+ /** Credit belongs to bytes actually removed by the consumer, including reads after source EOF. */
3055
+ var WorkerStreamBridge = class extends Readable {
3056
+ consume;
3057
+ constructor(consume) {
3058
+ super({ highWaterMark: 16384 });
3059
+ this.consume = consume;
3060
+ }
3061
+ _read() {}
3062
+ emit(event, ...args) {
3063
+ if (event === "data" && Buffer.isBuffer(args[0])) this.consume(args[0].byteLength);
3064
+ return super.emit(event, ...args);
3065
+ }
3066
+ };
3067
+ //#endregion
2261
3068
  //#region src/extensions.ts
2262
3069
  /** Maximum sizes enforced at the local-extension filesystem boundary. */
2263
3070
  const EXTENSION_LIMITS = Object.freeze({
@@ -2275,15 +3082,15 @@ const HOST_ACTIVATION_TIMEOUT_MS = 5e3;
2275
3082
  const RETIRED_GENERATION_TTL_MS = 6e5;
2276
3083
  /** Extension teardown is advisory and must never stop watcher progress. */
2277
3084
  const HOST_TEARDOWN_TIMEOUT_MS = 2e3;
2278
- async function withActivationTimeout(promise, id, signal) {
3085
+ async function withActivationTimeout(promise, id, signal, timeoutMs) {
2279
3086
  let timer;
2280
3087
  let onAbort;
2281
3088
  try {
2282
3089
  return await Promise.race([
2283
3090
  promise,
2284
- new Promise((_, reject) => {
2285
- timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS);
2286
- }),
3091
+ ...timeoutMs === void 0 ? [] : [new Promise((_, reject) => {
3092
+ timer = setTimeout(() => reject(new MobileExtensionError("host_load_timeout", `extension ${id} activation timed out`, 500)), timeoutMs);
3093
+ })],
2287
3094
  new Promise((_, reject) => {
2288
3095
  const abort = () => {
2289
3096
  reject(new MobileExtensionError("host_activation_closed", `extension ${id} activation is closed`, 409));
@@ -2599,6 +3406,11 @@ var MobileAccessService = class extends Service {
2599
3406
  localRefreshAbort;
2600
3407
  localLifecycle = 0;
2601
3408
  localClosed = true;
3409
+ localExecution;
3410
+ localExecutionKey;
3411
+ workerBudget;
3412
+ disposal = /* @__PURE__ */ new Set();
3413
+ recoveries = /* @__PURE__ */ new Map();
2602
3414
  constructor(ctx) {
2603
3415
  super(ctx, "mobileAccess");
2604
3416
  }
@@ -2660,10 +3472,65 @@ var MobileAccessService = class extends Service {
2660
3472
  }
2661
3473
  /** Return loaded and failed local extension counts without exposing host errors. */
2662
3474
  status() {
3475
+ const unavailable = [...this.local.values()].filter((entry) => entry.worker !== void 0 && !entry.worker.available()).length;
2663
3476
  return Object.freeze({
2664
- loaded: this.registered.size + this.local.size,
2665
- failed: this.failures.size
3477
+ loaded: this.registered.size + this.local.size - unavailable,
3478
+ failed: this.failures.size + unavailable
3479
+ });
3480
+ }
3481
+ /** Local Host status does not reveal filesystem paths, credentials or extension errors. */
3482
+ hostStatus() {
3483
+ const limits = resolveHostExecution(this.localExecution?.hostExecution);
3484
+ return {
3485
+ hosts: [...this.local.values()].map((entry) => ({
3486
+ id: entry.manifest.id,
3487
+ name: entry.manifest.name,
3488
+ mode: entry.worker === void 0 ? "in-process" : "worker",
3489
+ state: entry.worker === void 0 || entry.worker.available() ? "ready" : "unavailable",
3490
+ generation: entry.digest
3491
+ })),
3492
+ limits: {
3493
+ maxWorkers: limits.maxWorkers,
3494
+ workers: this.workerBudget?.current ?? 0
3495
+ }
3496
+ };
3497
+ }
3498
+ /** Explicitly replace one unavailable worker without replaying requests or changing its files. */
3499
+ recoverExtension(id) {
3500
+ assertExtensionId(id);
3501
+ const existing = this.recoveries.get(id);
3502
+ if (existing !== void 0) return existing;
3503
+ const recovering = (async () => {
3504
+ if (this.localRefreshing !== void 0) await this.localRefreshing;
3505
+ const previous = this.local.get(id);
3506
+ if (this.localClosed || previous?.worker === void 0 || this.localContext === void 0) throw new MobileExtensionError("extension_recovery_unavailable", "local worker recovery is unavailable", 409);
3507
+ if (previous.worker.available()) throw new MobileExtensionError("extension_host_running", "extension worker is already running", 409);
3508
+ await previous.worker.terminate("explicit-recovery");
3509
+ const lifecycle = this.localLifecycle;
3510
+ const fingerprint = await extensionFingerprint(previous.directory);
3511
+ const fresh = await loadLocalExtension(previous.directory, this.localContext, fingerprint, void 0, this.localExecution);
3512
+ try {
3513
+ const confirmed = await extensionFingerprint(previous.directory);
3514
+ 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);
3515
+ } catch (error) {
3516
+ await abortAndDisposeLocal([fresh]);
3517
+ throw error;
3518
+ }
3519
+ this.local.set(id, fresh);
3520
+ this.failures.delete(id);
3521
+ this.queueDisposal([previous]);
3522
+ this.updateContentHash();
3523
+ })().finally(() => {
3524
+ if (this.recoveries.get(id) === recovering) this.recoveries.delete(id);
3525
+ });
3526
+ this.recoveries.set(id, recovering);
3527
+ return recovering;
3528
+ }
3529
+ queueDisposal(entries) {
3530
+ const pending = abortAndDisposeLocal(entries).finally(() => {
3531
+ this.disposal.delete(pending);
2666
3532
  });
3533
+ this.disposal.add(pending);
2667
3534
  }
2668
3535
  /** Locate one active extension. */
2669
3536
  extension(id, generation) {
@@ -2753,7 +3620,7 @@ var MobileAccessService = class extends Service {
2753
3620
  });
2754
3621
  }, {
2755
3622
  external: external?.signal ?? context.signal,
2756
- timeoutMs: action.timeoutMs ?? 3e4,
3623
+ timeoutMs: action.timeoutMs ?? ("host" in extension && extension.worker !== void 0 ? resolveHostExecution(this.localExecution?.hostExecution).operationTimeoutMs : 3e4),
2757
3624
  timeoutError: () => new MobileExtensionError("extension_action_timeout", `extension ${id} action ${actionName} timed out`, 500),
2758
3625
  onDetach: () => {
2759
3626
  this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
@@ -2776,7 +3643,7 @@ var MobileAccessService = class extends Service {
2776
3643
  if (extension === void 0) throw new MobileExtensionError("extension_not_found", "extension not found", 404);
2777
3644
  const route = ("host" in extension ? extension.host : extension).routes?.find((candidate) => {
2778
3645
  if (candidate.method !== method) return false;
2779
- return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
3646
+ return (candidate.kind ?? "exact") === "exact" ? candidate.path === pathname : candidate.path === "/" || pathname === candidate.path || pathname.startsWith(`${candidate.path}/`);
2780
3647
  });
2781
3648
  if (route === void 0) throw new MobileExtensionError("route_not_found", "route not found", 404);
2782
3649
  if ("host" in extension) extension.controller.signal.throwIfAborted();
@@ -2791,7 +3658,7 @@ var MobileAccessService = class extends Service {
2791
3658
  signal
2792
3659
  }), {
2793
3660
  external: external?.signal ?? request.signal,
2794
- timeoutMs: route.timeoutMs ?? 3e4,
3661
+ timeoutMs: route.timeoutMs ?? ("host" in extension && extension.worker !== void 0 ? resolveHostExecution(this.localExecution?.hostExecution).operationTimeoutMs : 3e4),
2795
3662
  timeoutError: () => new MobileExtensionError("extension_route_timeout", `extension ${id} route ${method} ${pathname} timed out`, 500),
2796
3663
  onDetach: () => {
2797
3664
  this.abandoned.set(id, (this.abandoned.get(id) ?? 0) + 1);
@@ -2823,13 +3690,24 @@ var MobileAccessService = class extends Service {
2823
3690
  }
2824
3691
  }
2825
3692
  /** Start the local directory watcher; an absent directory is intentionally inert. */
2826
- async startLocal(root, context) {
3693
+ async startLocal(root, context, options) {
2827
3694
  const targetRoot = resolve(root);
2828
- if (this.localRoot !== void 0 && resolve(this.localRoot) !== targetRoot) await this.stopLocal();
3695
+ const limits = resolveHostExecution(options?.hostExecution);
3696
+ const executionKey = JSON.stringify({
3697
+ limits,
3698
+ module: options?.hostExecution?.workerModule
3699
+ });
3700
+ if (this.localRoot !== void 0 && (resolve(this.localRoot) !== targetRoot || this.localExecutionKey !== executionKey)) await this.stopLocal();
2829
3701
  if (this.localTimer !== void 0) clearInterval(this.localTimer);
2830
3702
  const lifecycle = ++this.localLifecycle;
3703
+ this.workerBudget ??= new ExtensionWorkerBudget(limits.maxWorkers);
2831
3704
  this.localRoot = targetRoot;
2832
3705
  this.localContext = context;
3706
+ this.localExecution = {
3707
+ ...options,
3708
+ workerBudget: this.workerBudget
3709
+ };
3710
+ this.localExecutionKey = executionKey;
2833
3711
  this.localClosed = false;
2834
3712
  await mkdir(this.localRoot, { recursive: true });
2835
3713
  if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return;
@@ -2863,6 +3741,8 @@ var MobileAccessService = class extends Service {
2863
3741
  this.failures.clear();
2864
3742
  this.updateContentHash();
2865
3743
  await abortAndDisposeLocal(late);
3744
+ await Promise.allSettled([...this.disposal, ...this.recoveries.values()]);
3745
+ this.workerBudget = void 0;
2866
3746
  if (this.localTimer !== void 0) clearInterval(this.localTimer);
2867
3747
  this.localTimer = void 0;
2868
3748
  }
@@ -2906,7 +3786,7 @@ var MobileAccessService = class extends Service {
2906
3786
  const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : void 0;
2907
3787
  if (previous?.digest === fingerprint.digest) staged.push(previous);
2908
3788
  else {
2909
- const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal);
3789
+ const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal, this.localExecution);
2910
3790
  try {
2911
3791
  signal.throwIfAborted();
2912
3792
  if ((await extensionFingerprint(directory)).digest !== fingerprint.digest) throw new MobileExtensionError("extension_changed_during_activation", `extension ${fingerprint.manifest.id} changed during activation`, 409);
@@ -2957,13 +3837,15 @@ var MobileAccessService = class extends Service {
2957
3837
  for (const name of names) this.failures.delete(name);
2958
3838
  for (const failure of this.failures.keys()) if (failure !== "local" && !names.includes(failure)) this.failures.delete(failure);
2959
3839
  this.failures.delete("local");
2960
- if (removed.length > 0) abortAndDisposeLocal(removed);
3840
+ if (removed.length > 0) this.queueDisposal(removed);
2961
3841
  this.updateContentHash();
2962
3842
  } catch (error) {
2963
3843
  await abortAndDisposeLocal(stagedFresh);
2964
3844
  if (this.localClosed || signal.aborted) return;
2965
3845
  const message = error instanceof Error ? error.message : String(error);
3846
+ const changedFailure = this.failures.get(failingName) !== message;
2966
3847
  this.failures.set(failingName, message);
3848
+ if (changedFailure && error instanceof MobileExtensionError && error.code === "extension_worker_heap_override") this.ctx.logger.warn("local extension worker refused: %s", error.message);
2967
3849
  if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
2968
3850
  }
2969
3851
  }
@@ -2973,12 +3855,12 @@ var MobileAccessService = class extends Service {
2973
3855
  if (previous !== void 0) {
2974
3856
  clearTimeout(previous.timer);
2975
3857
  this.retired.delete(active.manifest.id);
2976
- abortAndDisposeLocal([previous.active]);
3858
+ this.queueDisposal([previous.active]);
2977
3859
  }
2978
3860
  const timer = setTimeout(() => {
2979
3861
  if (this.retired.get(active.manifest.id)?.active !== active) return;
2980
3862
  this.retired.delete(active.manifest.id);
2981
- abortAndDisposeLocal([active]);
3863
+ this.queueDisposal([active]);
2982
3864
  }, RETIRED_GENERATION_TTL_MS);
2983
3865
  timer.unref();
2984
3866
  this.retired.set(active.manifest.id, {
@@ -3100,8 +3982,9 @@ async function abortAndDisposeLocal(entries) {
3100
3982
  pending.push(...invokeCleanups(entry.cleanups));
3101
3983
  }
3102
3984
  await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS);
3985
+ await Promise.all(entries.flatMap((entry) => entry.worker === void 0 ? [] : [entry.worker.dispose()]));
3103
3986
  }
3104
- async function loadLocalExtension(directory, context, known, parentSignal) {
3987
+ async function loadLocalExtension(directory, context, known, parentSignal, execution) {
3105
3988
  const root = await realExtensionRoot(directory);
3106
3989
  const manifestFile = await regularFile$3(join(root, "extension.json"), EXTENSION_LIMITS.manifest, "extension.json");
3107
3990
  const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, "utf8")));
@@ -3153,28 +4036,73 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
3153
4036
  }
3154
4037
  };
3155
4038
  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");
3160
- 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);
3166
- }
3167
- controller.signal.throwIfAborted();
3168
- if (imported.default !== void 0) await imported.default(api);
4039
+ const limits = resolveHostExecution(execution?.hostExecution);
4040
+ const worker = limits.mode === "worker" && (limits.extensions === void 0 || limits.extensions.includes(manifest.id)) && hostFile !== void 0 ? new ExtensionWorkerHost({
4041
+ workerModule: execution?.hostExecution?.workerModule ?? defaultExtensionWorkerModule(),
4042
+ hostFile,
4043
+ manifest,
4044
+ generation: known?.digest ?? createHash("sha256").update(manifest.id).digest("hex"),
4045
+ logger: workerLoggerAdapter(context),
4046
+ limits,
4047
+ ...execution?.workerBudget === void 0 ? {} : { budget: execution.workerBudget },
4048
+ onUnavailable: (error) => {
4049
+ controller.abort(error);
3169
4050
  }
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
- });
4051
+ }) : void 0;
4052
+ let host;
4053
+ if (worker !== void 0) {
4054
+ try {
4055
+ await withActivationTimeout(worker.activate(), manifest.id, controller.signal, void 0);
4056
+ } catch (error) {
4057
+ await worker.terminate("activation-failed");
4058
+ throw error;
4059
+ }
4060
+ const proxyActions = {};
4061
+ for (const metadata of worker.actionMetadata()) proxyActions[metadata.name] = {
4062
+ ...metadata.timeoutMs === void 0 ? {} : { timeoutMs: metadata.timeoutMs },
4063
+ run: (actionContext, input) => worker.invokeExecution(metadata.name, input, actionContext)
4064
+ };
4065
+ const proxyRoutes = worker.routeMetadata().map((metadata, index) => ({
4066
+ method: metadata.method,
4067
+ path: metadata.path,
4068
+ ...metadata.kind === void 0 ? {} : { kind: metadata.kind },
4069
+ ...metadata.timeoutMs === void 0 ? {} : { timeoutMs: metadata.timeoutMs },
4070
+ handle: (request) => worker.handleRouteExecution(index, request)
4071
+ }));
4072
+ try {
4073
+ host = validateDefinition({
4074
+ ...manifest,
4075
+ ...Object.keys(proxyActions).length === 0 ? {} : { actions: proxyActions },
4076
+ ...proxyRoutes.length === 0 ? {} : { routes: proxyRoutes }
4077
+ });
4078
+ } catch (error) {
4079
+ await worker.terminate("definition-invalid");
4080
+ throw error;
4081
+ }
4082
+ } else {
4083
+ const activate = async () => {
4084
+ controller.signal.throwIfAborted();
4085
+ if (hostFile !== void 0) {
4086
+ const digest = createHash("sha256").update(await readFile(hostFile)).digest("hex");
4087
+ controller.signal.throwIfAborted();
4088
+ let imported;
4089
+ try {
4090
+ imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`);
4091
+ } catch {
4092
+ throw new MobileExtensionError("host_load_failed", `could not load ${manifest.id}/host.mjs`, 500);
4093
+ }
4094
+ controller.signal.throwIfAborted();
4095
+ if (imported.default !== void 0) await imported.default(api);
4096
+ }
4097
+ await Promise.all(pendingEffects);
4098
+ };
4099
+ await withActivationTimeout(activate(), manifest.id, controller.signal, HOST_ACTIVATION_TIMEOUT_MS);
4100
+ host = validateDefinition({
4101
+ ...manifest,
4102
+ actions,
4103
+ routes
4104
+ });
4105
+ }
3178
4106
  activationOpen = false;
3179
4107
  const digest = known?.digest ?? createHash("sha256").update(manifest.id).digest("hex");
3180
4108
  return Object.freeze({
@@ -3186,7 +4114,8 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
3186
4114
  host,
3187
4115
  controller,
3188
4116
  cleanups: Object.freeze(cleanups),
3189
- digest
4117
+ digest,
4118
+ ...worker === void 0 ? {} : { worker }
3190
4119
  });
3191
4120
  } catch (error) {
3192
4121
  activationOpen = false;
@@ -3198,6 +4127,23 @@ async function loadLocalExtension(directory, context, known, parentSignal) {
3198
4127
  parentSignal?.removeEventListener("abort", onParentAbort);
3199
4128
  }
3200
4129
  }
4130
+ /** Forward the worker's logger-only context adapter into the plugin logger. */
4131
+ function workerLoggerAdapter(context) {
4132
+ return {
4133
+ debug: (...args) => {
4134
+ context.logger.debug(...args);
4135
+ },
4136
+ info: (...args) => {
4137
+ context.logger.info(...args);
4138
+ },
4139
+ warn: (...args) => {
4140
+ context.logger.warn(...args);
4141
+ },
4142
+ error: (...args) => {
4143
+ context.logger.error(...args);
4144
+ }
4145
+ };
4146
+ }
3201
4147
  /** Construct the service in a Cordis plugin without importing DSH internals. */
3202
4148
  function createMobileAccessService(ctx) {
3203
4149
  return new MobileAccessService(ctx);
@@ -3213,8 +4159,14 @@ const COPY = {
3213
4159
  deviceName: "Device name",
3214
4160
  pair: "Pair",
3215
4161
  pairing: "Pairing…",
3216
- pairFailed: "Pairing failed",
3217
- unavailable: "The computer is unavailable.",
4162
+ pairingCodeHint: "Use the 43-character pairing code shown on the computer. Keys starting with dsh1. or dsh2. are for the Android app.",
4163
+ invalidPairingCode: "Enter the 43-character pairing code from the current pairing window on the computer.",
4164
+ appKeyNotCode: "This is an Android app key. Use it in the app, or copy the browser pairing code from the computer.",
4165
+ pairFailed: "Pairing failed. Check the code or generate a new one on the computer.",
4166
+ pairingExpired: "The pairing code is invalid or has expired. Generate a new code on the computer and try again.",
4167
+ pairingLimited: "Too many pairing attempts. Wait a moment and try again.",
4168
+ deviceLimit: "The paired-device limit has been reached. Remove an unused device and generate a new pairing code on the computer.",
4169
+ unavailable: "The computer is unavailable. Check the connection and try again.",
3218
4170
  reconnectTitle: "Reconnect DSH mobile access",
3219
4171
  reconnectHeading: "Reconnect this device",
3220
4172
  restoring: "Restoring the secure Session…",
@@ -3229,8 +4181,14 @@ const COPY = {
3229
4181
  deviceName: "设备名称",
3230
4182
  pair: "配对",
3231
4183
  pairing: "正在配对…",
3232
- pairFailed: "配对失败",
3233
- unavailable: "电脑当前不可用。",
4184
+ pairingCodeHint: "请输入电脑端显示的 43 位配对码。以 dsh1. 或 dsh2. 开头的密钥用于 Android App。",
4185
+ invalidPairingCode: "请输入电脑端当前配对窗口中的 43 位配对码。",
4186
+ appKeyNotCode: "这是 Android App 密钥。请在 App 中使用,或回到电脑端复制浏览器配对码。",
4187
+ pairFailed: "配对失败,请检查输入或在电脑端重新生成配对码。",
4188
+ pairingExpired: "配对码无效或已过期,请在电脑端重新生成后重试。",
4189
+ pairingLimited: "配对尝试过于频繁,请稍后再试。",
4190
+ deviceLimit: "已达到配对设备上限,请在电脑端移除不需要的设备后重新生成配对码。",
4191
+ unavailable: "电脑当前不可用,请检查连接后重试。",
3234
4192
  reconnectTitle: "重新连接 DSH 移动访问",
3235
4193
  reconnectHeading: "重新连接此设备",
3236
4194
  restoring: "正在恢复安全会话…",
@@ -3245,8 +4203,14 @@ const COPY = {
3245
4203
  deviceName: "Nome dispositivo",
3246
4204
  pair: "Abbina",
3247
4205
  pairing: "Abbinamento…",
3248
- pairFailed: "Abbinamento non riuscito",
3249
- unavailable: "Il computer non è disponibile.",
4206
+ pairingCodeHint: "Usa il codice di abbinamento di 43 caratteri mostrato sul computer. Le chiavi che iniziano con dsh1. o dsh2. sono per l’app Android.",
4207
+ invalidPairingCode: "Inserisci il codice di 43 caratteri dalla finestra di abbinamento attuale sul computer.",
4208
+ appKeyNotCode: "Questa è una chiave per l’app Android. Usala nell’app oppure copia il codice di abbinamento per il browser dal computer.",
4209
+ pairFailed: "Abbinamento non riuscito. Controlla il codice o generane uno nuovo sul computer.",
4210
+ pairingExpired: "Il codice non è valido o è scaduto. Genera un nuovo codice sul computer e riprova.",
4211
+ pairingLimited: "Troppi tentativi di abbinamento. Attendi un momento e riprova.",
4212
+ deviceLimit: "È stato raggiunto il limite di dispositivi abbinati. Rimuovi un dispositivo inutilizzato e genera un nuovo codice sul computer.",
4213
+ unavailable: "Il computer non è disponibile. Controlla la connessione e riprova.",
3250
4214
  reconnectTitle: "Riconnetti accesso mobile DSH",
3251
4215
  reconnectHeading: "Riconnetti questo dispositivo",
3252
4216
  restoring: "Ripristino della sessione sicura…",
@@ -3285,7 +4249,8 @@ function renderPairPage(locale) {
3285
4249
  <main>
3286
4250
  <h1>${copy.pairHeading}</h1>
3287
4251
  <form id="pair-form">
3288
- <label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" required></label>
4252
+ <label>${copy.pairingCode} <input id="pair-token" autocomplete="one-time-code" autocapitalize="off" spellcheck="false" aria-describedby="pair-token-hint pair-status" required></label>
4253
+ <p id="pair-token-hint">${copy.pairingCodeHint}</p>
3289
4254
  <label>${copy.deviceName} <input id="device-label" maxlength="64" autocomplete="off"></label>
3290
4255
  <button type="submit">${copy.pair}</button>
3291
4256
  <output id="pair-status" aria-live="polite"></output>
@@ -3303,27 +4268,54 @@ function renderPairScript(locale) {
3303
4268
  const token = document.getElementById('pair-token')
3304
4269
  const label = document.getElementById('device-label')
3305
4270
  const status = document.getElementById('pair-status')
4271
+ const submit = form.querySelector('button[type="submit"]')
4272
+ let submitting = false
3306
4273
  const fragment = new URLSearchParams(location.hash.slice(1))
3307
4274
  const supplied = fragment.get('token')
3308
4275
  history.replaceState(null, '', location.pathname)
3309
4276
  if (supplied) token.value = supplied
4277
+ token.addEventListener('input', () => {
4278
+ token.removeAttribute('aria-invalid')
4279
+ if (!submitting) status.value = ''
4280
+ })
3310
4281
  form.addEventListener('submit', async (event) => {
3311
4282
  event.preventDefault()
4283
+ if (submitting) return
4284
+ const code = token.value.trim()
4285
+ token.removeAttribute('aria-invalid')
4286
+ if (/^dsh[12]\\./u.test(code) || !/^[A-Za-z0-9_-]{43}$/u.test(code)) {
4287
+ status.value = /^dsh[12]\\./u.test(code)
4288
+ ? ${JSON.stringify(copy.appKeyNotCode)} : ${JSON.stringify(copy.invalidPairingCode)}
4289
+ token.setAttribute('aria-invalid', 'true')
4290
+ token.focus()
4291
+ return
4292
+ }
4293
+ submitting = true
4294
+ submit.disabled = true
3312
4295
  status.value = ${JSON.stringify(copy.pairing)}
3313
4296
  try {
3314
4297
  const response = await fetch('/mobile-access/auth/pair', {
3315
4298
  method: 'POST',
3316
4299
  credentials: 'same-origin',
3317
4300
  headers: { 'content-type': 'application/json' },
3318
- body: JSON.stringify({ token: token.value, label: label.value || undefined }),
4301
+ body: JSON.stringify({ token: code, label: label.value || undefined }),
3319
4302
  })
3320
4303
  if (!response.ok) {
3321
- status.value = ${JSON.stringify(copy.pairFailed)}
4304
+ if (token.value.trim() !== code) { status.value = ''; return }
4305
+ status.value = response.status === 401 ? ${JSON.stringify(copy.pairingExpired)}
4306
+ : response.status === 429 ? ${JSON.stringify(copy.pairingLimited)}
4307
+ : response.status === 409 ? ${JSON.stringify(copy.deviceLimit)}
4308
+ : response.status >= 500 ? ${JSON.stringify(copy.unavailable)}
4309
+ : ${JSON.stringify(copy.pairFailed)}
4310
+ if (response.status === 401) token.setAttribute('aria-invalid', 'true')
3322
4311
  return
3323
4312
  }
3324
4313
  location.replace('/')
3325
4314
  } catch {
3326
- status.value = ${JSON.stringify(copy.unavailable)}
4315
+ status.value = token.value.trim() === code ? ${JSON.stringify(copy.unavailable)} : ''
4316
+ } finally {
4317
+ submitting = false
4318
+ submit.disabled = false
3327
4319
  }
3328
4320
  })
3329
4321
  })()
@@ -3936,6 +4928,7 @@ function sanitizeRequestHeaders(request, upstream) {
3936
4928
  "if-match",
3937
4929
  "if-modified-since",
3938
4930
  "if-none-match",
4931
+ "if-range",
3939
4932
  "if-unmodified-since",
3940
4933
  "range",
3941
4934
  "user-agent"
@@ -4015,13 +5008,13 @@ function isCompressibleContentType(value) {
4015
5008
  const mediaType = contentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
4016
5009
  return mediaType.startsWith("text/") || /^(?:application\/(?:javascript|json|xml|x-javascript)|image\/svg\+xml)$/u.test(mediaType);
4017
5010
  }
4018
- function shouldCompressResponse(request, response) {
5011
+ function canCompressResponse(request, response) {
4019
5012
  const pathname = request.url?.split("?", 1)[0] ?? "";
4020
- return request.method === "GET" && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && acceptsGzip(request.headers["accept-encoding"]) && isCompressibleContentType(response.headers["content-type"]);
5013
+ return (request.method === "GET" || request.method === "HEAD") && (pathname.startsWith("/plugins/") || pathname.startsWith("/assets/")) && response.statusCode === 200 && request.headers.range === void 0 && response.headers["content-range"] === void 0 && response.headers["content-encoding"] === void 0 && isCompressibleContentType(response.headers["content-type"]);
4021
5014
  }
4022
5015
  function revisionedStaticCacheControl(request, statusCode) {
4023
5016
  if (request.method !== "GET" && request.method !== "HEAD") return void 0;
4024
- if (statusCode !== 200) return void 0;
5017
+ if (statusCode !== 200 && statusCode !== 304) return void 0;
4025
5018
  let target;
4026
5019
  try {
4027
5020
  target = new URL(request.url ?? "/", "https://dsh-mobile.invalid");
@@ -4832,7 +5825,7 @@ var MobileAccessGateway = class {
4832
5825
  const clientModules = target.decodedPathname === `${AUTH_PREFIX}/client-modules`;
4833
5826
  const requestedExtension = extensionTarget(target.decodedPathname);
4834
5827
  const requestedMobileBootBatch = mobileBootBatchKey(target.decodedPathname);
4835
- const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
5828
+ const customAsset = request.method === "GET" || request.method === "HEAD" ? target.decodedPathname === `/mobile-access/custom.css` ? {
4836
5829
  file: this.config.customCssFile,
4837
5830
  contentType: "text/css; charset=utf-8",
4838
5831
  fallback: CUSTOM_STYLE_FALLBACK
@@ -4941,23 +5934,23 @@ var MobileAccessGateway = class {
4941
5934
  body = Buffer.from(customAsset.fallback);
4942
5935
  }
4943
5936
  if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
4944
- const etag = createHash("sha256").update(body).digest("hex");
4945
- const ifNoneMatch = headerValue(request.headers, "if-none-match");
4946
- if (ifNoneMatch !== void 0 && ifNoneMatch === etag) {
5937
+ const etag = `"${createHash("sha256").update(body).digest("hex")}"`;
5938
+ const responseHeaders = { ETag: etag };
5939
+ if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
5940
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
4947
5941
  setSecurityHeaders(response, this.tlsEnabled);
4948
- response.writeHead(304);
5942
+ response.writeHead(304, responseHeaders);
4949
5943
  response.end();
4950
5944
  return;
4951
5945
  }
4952
5946
  setSecurityHeaders(response, this.tlsEnabled);
4953
- const responseHeaders = {
5947
+ response.writeHead(200, {
5948
+ ...responseHeaders,
4954
5949
  "Content-Type": customAsset.contentType,
4955
- "Content-Length": body.byteLength,
4956
- "ETag": etag
4957
- };
4958
- if (mtime !== void 0) responseHeaders["Last-Modified"] = mtime.toUTCString();
4959
- response.writeHead(200, responseHeaders);
4960
- response.end(body);
5950
+ "Content-Length": body.byteLength
5951
+ });
5952
+ if (request.method === "HEAD") response.end();
5953
+ else response.end(body);
4961
5954
  return;
4962
5955
  } finally {
4963
5956
  operation.release();
@@ -4992,7 +5985,8 @@ var MobileAccessGateway = class {
4992
5985
  }
4993
5986
  const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
4994
5987
  const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
4995
- if (request.method === "GET" && acceptsHtml && !stockFrontend) {
5988
+ const frontendDocument = target.decodedPathname === "/" || target.decodedPathname === "/index.html";
5989
+ if (request.method === "GET" && frontendDocument && acceptsHtml && !stockFrontend) {
4996
5990
  await this.proxyMobileIndex(request, response, authorization);
4997
5991
  return;
4998
5992
  }
@@ -5032,10 +6026,10 @@ var MobileAccessGateway = class {
5032
6026
  styleRevision
5033
6027
  }
5034
6028
  }));
5035
- const etag = createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex");
5036
- if (headerValue(request.headers, "if-none-match") === etag) {
6029
+ const etag = `"${createHash("sha256").update(body).update(extensions.contentDigest()).digest("hex")}"`;
6030
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
5037
6031
  setSecurityHeaders(response, this.tlsEnabled);
5038
- response.writeHead(304);
6032
+ response.writeHead(304, { ETag: etag });
5039
6033
  response.end();
5040
6034
  return;
5041
6035
  }
@@ -5058,9 +6052,10 @@ var MobileAccessGateway = class {
5058
6052
  const operation = this.allocateRequest(authorization, response, {});
5059
6053
  try {
5060
6054
  const file = targetInfo.kind === "script" ? await extensions.readClientFile(targetInfo.id, "script", operation.signal, generation) : targetInfo.kind === "style" ? await extensions.readClientFile(targetInfo.id, "style", operation.signal, generation) : await extensions.readAsset(targetInfo.id, targetInfo.path ?? "", operation.signal, generation);
5061
- if (headerValue(request.headers, "if-none-match") === file.digest) {
6055
+ const etag = `"${file.digest}"`;
6056
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
5062
6057
  setSecurityHeaders(response, this.tlsEnabled);
5063
- response.writeHead(304);
6058
+ response.writeHead(304, { ETag: etag });
5064
6059
  response.end();
5065
6060
  return;
5066
6061
  }
@@ -5069,7 +6064,7 @@ var MobileAccessGateway = class {
5069
6064
  response.writeHead(200, {
5070
6065
  "Content-Type": contentType,
5071
6066
  "Content-Length": file.body.byteLength,
5072
- ETag: file.digest
6067
+ ETag: etag
5073
6068
  });
5074
6069
  if (request.method === "HEAD") response.end();
5075
6070
  else response.end(file.body);
@@ -5101,14 +6096,27 @@ var MobileAccessGateway = class {
5101
6096
  signal: abort.signal,
5102
6097
  deviceId: authorization.deviceId
5103
6098
  }, 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);
6099
+ if (isPreparedJson(result)) {
6100
+ if (result.bytes.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
6101
+ if (!response.headersSent && !response.destroyed) {
6102
+ const body = Buffer.concat([result.bytes, Buffer.from("\n")]);
6103
+ setSecurityHeaders(response, this.tlsEnabled);
6104
+ response.writeHead(200, {
6105
+ "Content-Type": "application/json; charset=utf-8",
6106
+ "Content-Length": body.byteLength
6107
+ });
6108
+ response.end(body);
6109
+ }
6110
+ } else {
6111
+ let serialized;
6112
+ try {
6113
+ serialized = Buffer.from(JSON.stringify(result));
6114
+ } catch {
6115
+ throw new MobileExtensionError("extension_failed", "extension action failed", 500);
6116
+ }
6117
+ if (serialized.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension result is too large", 500);
6118
+ sendJson(response, 200, result, this.tlsEnabled);
5109
6119
  }
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
6120
  } finally {
5113
6121
  generationSignal?.removeEventListener("abort", onGenerationAbort);
5114
6122
  abort.abort();
@@ -5163,36 +6171,41 @@ var MobileAccessGateway = class {
5163
6171
  }
5164
6172
  }
5165
6173
  async sendExtensionResponse(response, result, head) {
5166
- const status = result.status ?? 200;
5167
- if (!Number.isSafeInteger(status) || status < 200 || status > 599) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid HTTP status", 500);
5168
- const contentType = result.contentType ?? "application/octet-stream";
5169
- if (contentType.length > 1024 || !/^[\x20-\x7e]+$/u.test(contentType) || !/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\x20-\x7e]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
5170
- const safeHeaders = {};
5171
- for (const [name, value] of Object.entries(result.headers ?? {})) {
5172
- if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
5173
- safeHeaders[name] = value;
5174
- }
5175
- setSecurityHeaders(response, this.tlsEnabled);
5176
- if (typeof result.body === "string" || result.body instanceof Uint8Array) {
5177
- const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
5178
- if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
6174
+ try {
6175
+ const status = result.status ?? 200;
6176
+ if (!Number.isSafeInteger(status) || status < 200 || status > 599) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid HTTP status", 500);
6177
+ const contentType = result.contentType ?? "application/octet-stream";
6178
+ if (contentType.length > 1024 || !/^[\x20-\x7e]+$/u.test(contentType) || !/^[\w!#$&+.^-]+\/[\w!#$&+.^-]+(?:;[\x20-\x7e]*)?$/u.test(contentType)) throw new MobileExtensionError("invalid_route_response", "extension returned an invalid content type", 500);
6179
+ const safeHeaders = {};
6180
+ for (const [name, value] of Object.entries(result.headers ?? {})) {
6181
+ if (!/^(?:content-disposition|cache-control|etag)$/iu.test(name) || /[\r\n]/u.test(value)) continue;
6182
+ safeHeaders[name] = value;
6183
+ }
6184
+ setSecurityHeaders(response, this.tlsEnabled);
6185
+ if (typeof result.body === "string" || result.body instanceof Uint8Array) {
6186
+ const body = typeof result.body === "string" ? Buffer.from(result.body) : Buffer.from(result.body);
6187
+ if (body.byteLength > 4194304) throw new MobileExtensionError("extension_result_too_large", "extension response is too large", 500);
6188
+ response.writeHead(status, {
6189
+ ...safeHeaders,
6190
+ "Content-Type": contentType,
6191
+ "Content-Length": body.byteLength
6192
+ });
6193
+ if (head) response.end();
6194
+ else response.end(body);
6195
+ return;
6196
+ }
5179
6197
  response.writeHead(status, {
5180
6198
  ...safeHeaders,
5181
- "Content-Type": contentType,
5182
- "Content-Length": body.byteLength
6199
+ "Content-Type": contentType
5183
6200
  });
5184
- if (head) response.end();
5185
- else response.end(body);
5186
- return;
5187
- }
5188
- response.writeHead(status, {
5189
- ...safeHeaders,
5190
- "Content-Type": contentType
5191
- });
5192
- if (head) {
5193
- result.body.destroy();
5194
- response.end();
5195
- return;
6201
+ if (head) {
6202
+ result.body.destroy();
6203
+ response.end();
6204
+ return;
6205
+ }
6206
+ } catch (error) {
6207
+ disposeLateExtensionResponse(result);
6208
+ throw error;
5196
6209
  }
5197
6210
  await pipeline(result.body, new ByteLimitTransform(4194304), response);
5198
6211
  }
@@ -5371,7 +6384,7 @@ var MobileAccessGateway = class {
5371
6384
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
5372
6385
  port: Number(this.config.upstreamOrigin.port),
5373
6386
  method: "GET",
5374
- path: "/",
6387
+ path: incoming?.url ?? "/",
5375
6388
  headers,
5376
6389
  agent: false
5377
6390
  });
@@ -5608,7 +6621,7 @@ var MobileAccessGateway = class {
5608
6621
  const assembled = stored.body;
5609
6622
  if (assembled === void 0) throw new HttpError(502, "upstream_unavailable");
5610
6623
  const body = compressed ? stored.gzipBody ??= await gzipBuffer(assembled) : assembled;
5611
- const etag = compressed ? `${stored.etag}-gzip` : stored.etag;
6624
+ const etag = `"${compressed ? `${stored.etag}-gzip` : stored.etag}"`;
5612
6625
  const headers = {
5613
6626
  "Content-Type": "text/javascript; charset=utf-8",
5614
6627
  "Content-Length": body.byteLength,
@@ -5617,7 +6630,7 @@ var MobileAccessGateway = class {
5617
6630
  };
5618
6631
  if (compressed) headers["Content-Encoding"] = "gzip";
5619
6632
  addVaryAcceptEncoding(headers);
5620
- if (headerValue(request.headers, "if-none-match") === etag) {
6633
+ if (matchesIfNoneMatch(headerValue(request.headers, "if-none-match"), etag)) {
5621
6634
  setSecurityHeaders(response, this.tlsEnabled);
5622
6635
  response.writeHead(304, {
5623
6636
  ETag: etag,
@@ -5864,16 +6877,28 @@ var MobileAccessGateway = class {
5864
6877
  const statusCode = proxied.statusCode ?? 502;
5865
6878
  const cacheControl = revisionedStaticCacheControl(request$2, statusCode);
5866
6879
  if (cacheControl !== void 0) headers["cache-control"] = cacheControl;
5867
- const compressed = shouldCompressResponse(request$2, proxied);
6880
+ const compressible = canCompressResponse(request$2, proxied);
6881
+ const compressed = compressible && acceptsGzip(request$2.headers["accept-encoding"]);
6882
+ if (compressible) addVaryAcceptEncoding(headers);
5868
6883
  if (compressed) {
5869
6884
  delete headers["accept-ranges"];
5870
6885
  delete headers["content-length"];
5871
- delete headers.etag;
6886
+ const etag = weakenEntityTag(proxied.headers.etag);
6887
+ if (etag === void 0) delete headers.etag;
6888
+ else headers.etag = etag;
5872
6889
  headers["content-encoding"] = "gzip";
6890
+ } else if (statusCode === 304 && (request$2.method === "GET" || request$2.method === "HEAD") && request$2.headers.range === void 0 && (request$2.url?.split("?", 1)[0]?.startsWith("/plugins/") === true || request$2.url?.split("?", 1)[0]?.startsWith("/assets/") === true)) {
6891
+ if (proxied.headers["content-encoding"] === void 0 && acceptsGzip(request$2.headers["accept-encoding"])) {
6892
+ delete headers["content-length"];
6893
+ delete headers["accept-ranges"];
6894
+ const etag = weakenEntityTag(proxied.headers.etag);
6895
+ if (etag === void 0) delete headers.etag;
6896
+ else headers.etag = etag;
6897
+ }
5873
6898
  addVaryAcceptEncoding(headers);
5874
6899
  }
5875
6900
  response.writeHead(statusCode, headers);
5876
- await Promise.all([bodyDone, compressed ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
6901
+ await Promise.all([bodyDone, compressed && request$2.method !== "HEAD" ? pipeline(proxied, createGzip(), response) : pipeline(proxied, response)]);
5877
6902
  } catch (error) {
5878
6903
  holder.request?.destroy();
5879
6904
  await bodyDone?.catch(() => void 0);
@@ -6436,6 +7461,286 @@ var MemoryDeviceStore = class {
6436
7461
  }
6437
7462
  };
6438
7463
  //#endregion
7464
+ //#region src/managed-files.ts
7465
+ /** Refuse link-shaped ancestors inside an owned state root before accessing a child. */
7466
+ async function assertManagedParents(root, target, prefix) {
7467
+ const parent = resolve(root);
7468
+ const candidate = resolve(target);
7469
+ const offset = relative(parent, candidate);
7470
+ if (!isAbsolute(root) || offset !== "" && (offset.startsWith("..") || isAbsolute(offset))) throw new Error(`${prefix}_path_invalid`);
7471
+ const paths = [parent];
7472
+ let cursor = parent;
7473
+ if (offset !== "") for (const segment of relative(parent, dirname(candidate)).split(/[\\/]/u).filter(Boolean)) {
7474
+ cursor = join(cursor, segment);
7475
+ paths.push(cursor);
7476
+ }
7477
+ for (const path of paths) {
7478
+ let entry;
7479
+ try {
7480
+ entry = await lstat(path);
7481
+ } catch (error) {
7482
+ if (error.code === "ENOENT") continue;
7483
+ throw error;
7484
+ }
7485
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
7486
+ }
7487
+ }
7488
+ /** Retry Windows handle contention while checking ownership before every mutation. */
7489
+ async function mutateManagedPath(validate, mutate) {
7490
+ let original;
7491
+ for (let attempt = 0;; attempt++) {
7492
+ await validate();
7493
+ try {
7494
+ await mutate();
7495
+ return;
7496
+ } catch (error) {
7497
+ const code = error.code;
7498
+ if (process.platform !== "win32" || code !== "EPERM" && code !== "EBUSY") throw error;
7499
+ if (attempt === 0) original = error;
7500
+ if (attempt === 7) throw original;
7501
+ await new Promise((resolve) => setTimeout(resolve, Math.min(50 * 2 ** attempt, 400)));
7502
+ }
7503
+ }
7504
+ }
7505
+ /** Move only owned paths, retaining parent checks on every Windows retry. */
7506
+ async function renameManagedPath(root, source, destination, prefix) {
7507
+ if (resolve(source) === resolve(root) || resolve(destination) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7508
+ await mutateManagedPath(async () => {
7509
+ await assertManagedParents(root, source, prefix);
7510
+ await assertManagedParents(root, destination, prefix);
7511
+ }, () => rename(source, destination));
7512
+ }
7513
+ /** Create a real owned directory; secure new directories without changing existing child ACLs. */
7514
+ async function ensureManagedDirectory(root, directory, prefix) {
7515
+ await assertManagedParents(root, directory, prefix);
7516
+ const created = await mkdir(directory, {
7517
+ recursive: true,
7518
+ mode: 448
7519
+ });
7520
+ const entry = await lstat(directory);
7521
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
7522
+ if (created !== void 0) await restrictPrivateFile(directory, 448);
7523
+ }
7524
+ /** Delete an owned child tree; remove links themselves and never delete the root. */
7525
+ async function removeManagedTree(root, target, prefix) {
7526
+ if (resolve(target) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7527
+ await assertManagedParents(root, target, prefix);
7528
+ let entry;
7529
+ try {
7530
+ entry = await lstat(target);
7531
+ } catch (error) {
7532
+ if (error.code === "ENOENT") return;
7533
+ throw error;
7534
+ }
7535
+ if (entry.isSymbolicLink() || !entry.isDirectory()) {
7536
+ await mutateManagedPath(() => assertManagedParents(root, target, prefix), () => unlink(target));
7537
+ return;
7538
+ }
7539
+ for (const name of await readdir(target)) await removeManagedTree(root, join(target, name), prefix);
7540
+ await mutateManagedPath(() => assertManagedParents(root, target, prefix), () => rmdir(target));
7541
+ }
7542
+ /** Validate every selected child, then await all deletion work even when one fails. */
7543
+ async function removeManagedPaths(root, paths, prefix) {
7544
+ for (const path of paths) {
7545
+ if (resolve(path) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7546
+ await assertManagedParents(root, path, prefix);
7547
+ }
7548
+ const errors = (await Promise.allSettled(paths.map((path) => removeManagedTree(root, path, prefix)))).filter((result) => result.status === "rejected").map((result) => result.reason);
7549
+ if (errors.length === 1) throw errors[0];
7550
+ if (errors.length > 1) throw new AggregateError(errors, `${prefix}_cleanup_failed`);
7551
+ }
7552
+ /** Atomically replace one sensitive file beneath real owned directories. */
7553
+ async function writeManagedPrivateFile(root, file, body, prefix) {
7554
+ await ensureManagedDirectory(root, dirname(file), prefix);
7555
+ try {
7556
+ const entry = await lstat(file);
7557
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error(`${prefix}_config_target_invalid`);
7558
+ } catch (error) {
7559
+ if (error.code !== "ENOENT") throw error;
7560
+ }
7561
+ const temporary = join(dirname(file), "." + basename(file) + "." + randomBytes(12).toString("hex") + ".tmp");
7562
+ let failed = false;
7563
+ try {
7564
+ await writeFile(temporary, body, {
7565
+ encoding: "utf8",
7566
+ flag: "wx",
7567
+ mode: 384
7568
+ });
7569
+ await restrictPrivateFile(temporary);
7570
+ await renameManagedPath(root, temporary, file, prefix);
7571
+ } catch (error) {
7572
+ failed = true;
7573
+ throw error;
7574
+ } finally {
7575
+ try {
7576
+ await removeManagedTree(root, temporary, prefix);
7577
+ } catch (error) {
7578
+ if (!failed) throw error;
7579
+ }
7580
+ }
7581
+ }
7582
+ /** Promote a verified directory, restoring the previous version if promotion fails. */
7583
+ async function replaceManagedDirectory(root, target, candidate, prefix, operations = {}) {
7584
+ if (resolve(target) === resolve(root) || resolve(candidate) === resolve(root)) throw new Error(`${prefix}_path_invalid`);
7585
+ const move = async (source, destination) => {
7586
+ await assertManagedParents(root, source, prefix);
7587
+ await assertManagedParents(root, destination, prefix);
7588
+ if (operations.move === void 0) await renameManagedPath(root, source, destination, prefix);
7589
+ else await operations.move(source, destination);
7590
+ };
7591
+ const remove = operations.remove ?? ((path) => removeManagedTree(root, path, prefix));
7592
+ const backup = join(dirname(target), `.${basename(target)}.previous-${randomBytes(12).toString("hex")}`);
7593
+ let previous = false;
7594
+ let promoted = false;
7595
+ try {
7596
+ await assertManagedParents(root, join(candidate, "publication-check"), prefix);
7597
+ await assertManagedParents(root, target, prefix);
7598
+ try {
7599
+ const entry = await lstat(target);
7600
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`${prefix}_path_invalid`);
7601
+ await move(target, backup);
7602
+ previous = true;
7603
+ } catch (error) {
7604
+ if (error.code !== "ENOENT") throw error;
7605
+ }
7606
+ try {
7607
+ await move(candidate, target);
7608
+ promoted = true;
7609
+ } catch (error) {
7610
+ if (previous) try {
7611
+ await move(backup, target);
7612
+ previous = false;
7613
+ } catch (restoreError) {
7614
+ throw new AggregateError([error, restoreError], `${prefix}_component_replace_failed`);
7615
+ }
7616
+ throw error;
7617
+ }
7618
+ } finally {
7619
+ try {
7620
+ await remove(candidate);
7621
+ } catch (_error) {
7622
+ process.emitWarning(`${prefix} candidate cleanup failed`, { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
7623
+ }
7624
+ if (promoted && previous) try {
7625
+ await remove(backup);
7626
+ } catch (_error) {
7627
+ process.emitWarning(`${prefix} previous component cleanup failed`, { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
7628
+ }
7629
+ }
7630
+ }
7631
+ //#endregion
7632
+ //#region src/component-download.ts
7633
+ /**
7634
+ * Hosts a pinned GitHub release download may redirect to.
7635
+ *
7636
+ * `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
7637
+ * answers 302 with a signed URL on a release-asset host. A fetch configured with
7638
+ * `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
7639
+ * before a single byte is transferred, which is exactly how the cloudflared component could never
7640
+ * install. Following one validated hop keeps the origin restriction while allowing the shape
7641
+ * every GitHub release download uses.
7642
+ */
7643
+ const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
7644
+ /** Release an unused body without replacing a download or validation failure. */
7645
+ async function discardBody(body) {
7646
+ if (body === null) return;
7647
+ try {
7648
+ await body.cancel();
7649
+ } catch (_error) {}
7650
+ }
7651
+ /** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
7652
+ async function followValidatedRedirect(response, request) {
7653
+ let target;
7654
+ try {
7655
+ const location = response.headers.get("location");
7656
+ if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
7657
+ try {
7658
+ target = new URL(location, request.url);
7659
+ } catch {
7660
+ throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
7661
+ }
7662
+ const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
7663
+ if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
7664
+ } finally {
7665
+ await discardBody(response.body);
7666
+ }
7667
+ return fetch(target, {
7668
+ redirect: "error",
7669
+ signal: request.signal
7670
+ });
7671
+ }
7672
+ /** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
7673
+ * real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
7674
+ * which is exactly the confusing "nothing happened" the user saw. */
7675
+ const DOWNLOAD_ATTEMPTS = 2;
7676
+ const RETRY_DELAY_MS = 750;
7677
+ /** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
7678
+ function isRetryable(error) {
7679
+ if (error instanceof TypeError) return true;
7680
+ if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
7681
+ return false;
7682
+ }
7683
+ function delay(ms) {
7684
+ return new Promise((resolve) => {
7685
+ setTimeout(resolve, ms);
7686
+ });
7687
+ }
7688
+ async function attemptDownload(request) {
7689
+ const first = await fetch(request.url, {
7690
+ redirect: "manual",
7691
+ signal: request.signal
7692
+ });
7693
+ const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
7694
+ let reader;
7695
+ let ended = false;
7696
+ try {
7697
+ if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
7698
+ const contentLength = response.headers.get("content-length");
7699
+ if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7700
+ if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7701
+ const bytes = new Uint8Array(request.expectedBytes);
7702
+ reader = response.body.getReader();
7703
+ let received = 0;
7704
+ while (true) {
7705
+ const chunk = await reader.read();
7706
+ if (chunk.done) {
7707
+ ended = true;
7708
+ break;
7709
+ }
7710
+ if (chunk.value.byteLength > bytes.byteLength - received) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7711
+ bytes.set(chunk.value, received);
7712
+ received += chunk.value.byteLength;
7713
+ }
7714
+ if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
7715
+ return bytes;
7716
+ } finally {
7717
+ if (reader === void 0) await discardBody(response.body);
7718
+ else try {
7719
+ if (!ended) await discardBody(reader);
7720
+ } finally {
7721
+ reader.releaseLock();
7722
+ }
7723
+ }
7724
+ }
7725
+ /**
7726
+ * Download a pinned artifact, following at most one validated redirect, and check its exact length.
7727
+ * A transport failure is retried once; a length or redirect verdict is not. The caller still
7728
+ * verifies the SHA-256 before publishing anything.
7729
+ * @param request - the pinned URL, its exact byte length and the caller's error prefix.
7730
+ * @returns the artifact bytes.
7731
+ */
7732
+ async function downloadPinnedArtifact(request) {
7733
+ let lastError;
7734
+ for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
7735
+ return await attemptDownload(request);
7736
+ } catch (error) {
7737
+ lastError = error;
7738
+ if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
7739
+ await delay(RETRY_DELAY_MS);
7740
+ }
7741
+ throw lastError;
7742
+ }
7743
+ //#endregion
6439
7744
  //#region src/frp-component.ts
6440
7745
  const FRP_VERSION$1 = "0.70.1";
6441
7746
  const MAX_ARCHIVE_ENTRIES$1 = 128;
@@ -6510,37 +7815,6 @@ async function regularFile$2(file) {
6510
7815
  throw error;
6511
7816
  }
6512
7817
  }
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
7818
  function sha256$2(bytes) {
6545
7819
  return createHash("sha256").update(bytes).digest("hex");
6546
7820
  }
@@ -6597,39 +7871,12 @@ async function defaultExtractArtifact$2(archive, destination, executableName) {
6597
7871
  await copyFile(extracted, join(destination, executableName));
6598
7872
  }
6599
7873
  async function defaultFetchArtifact$2(artifact, signal) {
6600
- const response = await fetch(artifact.downloadUrl, {
6601
- redirect: "follow",
7874
+ return downloadPinnedArtifact({
7875
+ url: artifact.downloadUrl,
7876
+ expectedBytes: artifact.downloadBytes,
7877
+ errorPrefix: "frp",
6602
7878
  signal
6603
7879
  });
6604
- if (!response.ok) throw new Error(`frp_download_http_${String(response.status)}`);
6605
- const finalUrl = new URL(response.url);
6606
- const officialHost = finalUrl.hostname === "github.com" || finalUrl.hostname.endsWith(".githubusercontent.com");
6607
- if (finalUrl.protocol !== "https:" || !officialHost) throw new Error("frp_download_origin_invalid");
6608
- const lengthHeader = response.headers.get("content-length");
6609
- const declaredLength = lengthHeader === null ? void 0 : Number(lengthHeader);
6610
- if (declaredLength !== void 0 && (!Number.isFinite(declaredLength) || declaredLength !== artifact.downloadBytes)) throw new Error("frp_download_size_mismatch");
6611
- if (response.body === null) throw new Error("frp_download_empty");
6612
- const chunks = [];
6613
- let received = 0;
6614
- const reader = response.body.getReader();
6615
- while (true) {
6616
- const result = await reader.read();
6617
- if (result.done) break;
6618
- received += result.value.byteLength;
6619
- if (received > artifact.downloadBytes) {
6620
- await reader.cancel();
6621
- throw new Error("frp_download_size_mismatch");
6622
- }
6623
- chunks.push(result.value);
6624
- }
6625
- if (received !== artifact.downloadBytes) throw new Error("frp_download_size_mismatch");
6626
- const bytes = new Uint8Array(received);
6627
- let offset = 0;
6628
- for (const chunk of chunks) {
6629
- bytes.set(chunk, offset);
6630
- offset += chunk.byteLength;
6631
- }
6632
- return bytes;
6633
7880
  }
6634
7881
  async function defaultInspectExecutable(executable) {
6635
7882
  return (await runCapture$1(executable, ["--version"])).trim();
@@ -6641,6 +7888,7 @@ var FrpComponentManager = class {
6641
7888
  componentStorage;
6642
7889
  logRoot;
6643
7890
  stagingRoot;
7891
+ stateDirectory;
6644
7892
  artifact;
6645
7893
  fetchArtifact;
6646
7894
  extractArtifact;
@@ -6650,8 +7898,9 @@ var FrpComponentManager = class {
6650
7898
  errorCode;
6651
7899
  queue = Promise.resolve();
6652
7900
  constructor(options) {
7901
+ if (!isAbsolute(options.stateDirectory)) throw new Error("frp state directory must be absolute");
6653
7902
  const stateDirectory = resolve(options.stateDirectory);
6654
- if (!isAbsolute(stateDirectory)) throw new Error("frp state directory must be absolute");
7903
+ this.stateDirectory = stateDirectory;
6655
7904
  const platform = options.platform ?? process.platform;
6656
7905
  const arch = options.arch ?? process.arch;
6657
7906
  this.artifact = FRP_COMPONENT_RELEASES[`${platform}-${arch}`];
@@ -6672,13 +7921,19 @@ var FrpComponentManager = class {
6672
7921
  }
6673
7922
  /** Inspect the managed executable without relying on global FRP installations. */
6674
7923
  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 {
7924
+ this.installed = false;
7925
+ this.installedBytes = 0;
7926
+ this.errorCode = void 0;
7927
+ try {
7928
+ await assertManagedParents(this.stateDirectory, this.executable, "frp");
7929
+ this.installed = await regularFile$2(this.executable);
7930
+ this.installedBytes = this.installed ? (await stat(this.executable)).size : 0;
7931
+ if (this.installed) {
7932
+ if (await this.inspectExecutable(this.executable) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
7933
+ }
7934
+ } catch (_error) {
6681
7935
  this.installed = false;
7936
+ this.installedBytes = 0;
6682
7937
  this.errorCode = "frp_component_invalid";
6683
7938
  }
6684
7939
  }
@@ -6701,10 +7956,8 @@ var FrpComponentManager = class {
6701
7956
  return this.enqueue(async () => {
6702
7957
  const artifact = this.artifact;
6703
7958
  if (artifact === void 0) throw new Error("frp_component_unsupported");
6704
- await mkdir(this.stagingRoot, {
6705
- recursive: true,
6706
- mode: 448
6707
- });
7959
+ await assertManagedParents(this.stateDirectory, this.executable, "frp");
7960
+ await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "frp");
6708
7961
  const staging = await mkdtemp(join(this.stagingRoot, "install-"));
6709
7962
  try {
6710
7963
  const controller = new AbortController();
@@ -6731,42 +7984,36 @@ var FrpComponentManager = class {
6731
7984
  await chmod(extracted, 448);
6732
7985
  if (await this.inspectExecutable(extracted) !== FRP_VERSION$1) throw new Error("frp_component_version_mismatch");
6733
7986
  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);
7987
+ await ensureManagedDirectory(this.stateDirectory, candidate, "frp");
7988
+ try {
7989
+ const candidateExecutable = join(candidate, artifact.executableName);
7990
+ await copyFile(extracted, candidateExecutable);
7991
+ await chmod(candidateExecutable, 448);
7992
+ await replaceManagedDirectory(this.stateDirectory, this.componentStorage, candidate, "frp");
7993
+ } catch (error) {
7994
+ try {
7995
+ await removeManagedTree(this.stateDirectory, candidate, "frp");
7996
+ } catch (_cleanupError) {
7997
+ process.emitWarning("FRP candidate cleanup failed", { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
7998
+ }
7999
+ throw error;
8000
+ }
6742
8001
  this.installed = true;
6743
8002
  this.installedBytes = (await stat(this.executable)).size;
6744
8003
  this.errorCode = void 0;
6745
8004
  } finally {
6746
- await rm(staging, {
6747
- recursive: true,
6748
- force: true
6749
- });
8005
+ await removeManagedTree(this.stateDirectory, staging, "frp");
6750
8006
  }
6751
8007
  });
6752
8008
  }
6753
8009
  /** Remove all FRP executable, staging, and log files owned by DSH Mobile. */
6754
8010
  purge() {
6755
8011
  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
- ]);
8012
+ await removeManagedPaths(this.stateDirectory, [
8013
+ this.componentRoot,
8014
+ this.logRoot,
8015
+ this.stagingRoot
8016
+ ], "frp");
6770
8017
  this.installed = false;
6771
8018
  this.installedBytes = 0;
6772
8019
  this.errorCode = void 0;
@@ -7188,34 +8435,9 @@ function createFrpServerTemplate(settings) {
7188
8435
  entryTls: resolveFrpEntryTls(settings)
7189
8436
  });
7190
8437
  }
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
8438
  /** Owns private FRP settings and generation-specific frpc configuration. */
7218
8439
  var FrpConfigStore = class {
8440
+ ownedRoot;
7219
8441
  stateRoot;
7220
8442
  settingsFile;
7221
8443
  runtimeConfigFile;
@@ -7229,7 +8451,8 @@ var FrpConfigStore = class {
7229
8451
  proxyName;
7230
8452
  settingsValue;
7231
8453
  errorCode;
7232
- constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME) {
8454
+ constructor(stateDirectory, proxyName = FRP_DEFAULT_PROXY_NAME, ownedRoot = stateDirectory) {
8455
+ this.ownedRoot = ownedRoot;
7233
8456
  if (!isAbsolute(stateDirectory)) throw new Error("frp config state directory must be absolute");
7234
8457
  this.stateRoot = resolve(stateDirectory);
7235
8458
  this.settingsFile = join(this.stateRoot, "settings.json");
@@ -7238,6 +8461,14 @@ var FrpConfigStore = class {
7238
8461
  }
7239
8462
  /** Load private settings while rejecting links, oversized files, and unknown fields. */
7240
8463
  async initialize() {
8464
+ this.settingsValue = void 0;
8465
+ this.errorCode = void 0;
8466
+ try {
8467
+ await assertManagedParents(this.ownedRoot, this.settingsFile, "frp");
8468
+ } catch (_error) {
8469
+ this.errorCode = "frp_config_invalid";
8470
+ return;
8471
+ }
7241
8472
  let entry;
7242
8473
  try {
7243
8474
  entry = await lstat(this.settingsFile);
@@ -7292,8 +8523,8 @@ var FrpConfigStore = class {
7292
8523
  /** Atomically replace private FRP settings. */
7293
8524
  async configure(value) {
7294
8525
  const settings = parseFrpSettings(value);
7295
- await atomicPrivateWrite$2(this.settingsFile, `${JSON.stringify(settings)}\n`);
7296
- await rm(this.runtimeConfigFile, { force: true });
8526
+ await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, `${JSON.stringify(settings)}\n`, "frp");
8527
+ await removeManagedTree(this.ownedRoot, this.runtimeConfigFile, "frp");
7297
8528
  this.settingsValue = settings;
7298
8529
  this.errorCode = void 0;
7299
8530
  return this.status();
@@ -7302,15 +8533,12 @@ var FrpConfigStore = class {
7302
8533
  async writeRuntimeConfig(localPort) {
7303
8534
  const settings = this.settingsValue;
7304
8535
  if (settings === void 0) throw new Error("frp_config_missing");
7305
- await atomicPrivateWrite$2(this.runtimeConfigFile, createFrpcToml(settings, localPort, this.proxyName));
8536
+ await writeManagedPrivateFile(this.ownedRoot, this.runtimeConfigFile, createFrpcToml(settings, localPort, this.proxyName), "frp");
7306
8537
  return this.runtimeConfigFile;
7307
8538
  }
7308
8539
  /** Remove only configuration files owned by the FRP provider. */
7309
8540
  async purge() {
7310
- await rm(this.stateRoot, {
7311
- recursive: true,
7312
- force: true
7313
- });
8541
+ await removeManagedPaths(this.ownedRoot, [this.settingsFile, this.runtimeConfigFile], "frp");
7314
8542
  this.settingsValue = void 0;
7315
8543
  this.errorCode = void 0;
7316
8544
  return this.status();
@@ -7995,7 +9223,7 @@ async function readManagedCa(setup) {
7995
9223
  const [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, "utf8"), readFile(setup.caKeyFile, "utf8")]);
7996
9224
  return assertMatchingCa(certPem, keyPem);
7997
9225
  }
7998
- async function atomicWrite(file, contents) {
9226
+ async function stagePrivateFile(file, contents) {
7999
9227
  const directory = dirname(file);
8000
9228
  await mkdir(directory, {
8001
9229
  recursive: true,
@@ -8003,15 +9231,83 @@ async function atomicWrite(file, contents) {
8003
9231
  });
8004
9232
  const temporary = join(directory, `.${basename(file)}.${randomBytes(12).toString("hex")}.tmp`);
8005
9233
  try {
8006
- await writeFile(temporary, contents, {
9234
+ await writeFile(temporary, "", {
8007
9235
  mode: 384,
8008
9236
  flag: "wx"
8009
9237
  });
8010
9238
  await restrictPrivateFile(temporary);
9239
+ await writeFile(temporary, contents);
9240
+ return temporary;
9241
+ } catch (error) {
9242
+ await rm(temporary, { force: true });
9243
+ throw error;
9244
+ }
9245
+ }
9246
+ async function atomicWrite(file, contents) {
9247
+ const temporary = await stagePrivateFile(file, contents);
9248
+ try {
8011
9249
  await rename(temporary, file);
8012
9250
  await restrictPrivateFile(file);
8013
9251
  } finally {
8014
- await rm(temporary, { force: true });
9252
+ await rm(temporary, { force: true });
9253
+ }
9254
+ }
9255
+ const certificatePublications = /* @__PURE__ */ new Map();
9256
+ /** Stage both files before promotion and retain a private rollback copy until the pair is installed. */
9257
+ async function installServerCertificatePair(output, cert, key) {
9258
+ const identity = `${output.certFile}\0${output.keyFile}`;
9259
+ const publication = (certificatePublications.get(identity) ?? Promise.resolve()).catch(() => void 0).then(async () => {
9260
+ const files = [output.certFile, output.keyFile];
9261
+ const contents = [cert, key];
9262
+ const staged = [];
9263
+ const backups = [];
9264
+ let promoted = 0;
9265
+ let restored = true;
9266
+ const failures = [];
9267
+ try {
9268
+ for (let index = 0; index < files.length; index += 1) {
9269
+ const file = files[index];
9270
+ staged.push(await stagePrivateFile(file, contents[index]));
9271
+ let old;
9272
+ try {
9273
+ const metadata = await lstat(file);
9274
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error("managed TLS output is not a regular file");
9275
+ old = await readFile(file);
9276
+ } catch (error) {
9277
+ if (error.code !== "ENOENT") throw error;
9278
+ }
9279
+ backups.push(old === void 0 ? void 0 : await stagePrivateFile(file, old));
9280
+ }
9281
+ for (let index = 0; index < files.length; index += 1) {
9282
+ await rename(staged[index], files[index]);
9283
+ promoted += 1;
9284
+ }
9285
+ } catch (error) {
9286
+ failures.push(error);
9287
+ for (let index = promoted - 1; index >= 0; index -= 1) try {
9288
+ const backup = backups[index];
9289
+ if (backup === void 0) await rm(files[index], { force: true });
9290
+ else await rename(backup, files[index]);
9291
+ } catch (restoreError) {
9292
+ restored = false;
9293
+ failures.push(restoreError);
9294
+ }
9295
+ } finally {
9296
+ const cleanup = await Promise.allSettled(staged.map((file) => rm(file, { force: true })));
9297
+ for (const result of cleanup) if (result.status === "rejected") failures.push(result.reason);
9298
+ if (restored) {
9299
+ const backupCleanup = await Promise.allSettled(backups.filter((file) => file !== void 0).map((file) => rm(file, { force: true })));
9300
+ for (const result of backupCleanup) if (result.status === "rejected") failures.push(result.reason);
9301
+ }
9302
+ }
9303
+ if (failures.length > 1) throw new AggregateError(failures, "managed TLS certificate replacement, rollback or cleanup failed");
9304
+ if (failures.length === 1) throw failures[0];
9305
+ });
9306
+ certificatePublications.set(identity, publication);
9307
+ try {
9308
+ await publication;
9309
+ } finally {
9310
+ if (certificatePublications.get(identity) === publication) certificatePublications.delete(identity);
8015
9311
  }
8016
9312
  }
8017
9313
  /** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */
@@ -8128,7 +9424,7 @@ async function issueServerCertificate(authority, target, output, lifetimeDays =
8128
9424
  }
8129
9425
  ]
8130
9426
  });
8131
- await Promise.all([atomicWrite(output.certFile, server.cert), atomicWrite(output.keyFile, server.private)]);
9427
+ await installServerCertificatePair(output, server.cert, server.private);
8132
9428
  }
8133
9429
  /** Sign and atomically install a server leaf for the interface's current address. */
8134
9430
  async function refreshManagedServerCertificate(setup, address) {
@@ -8197,25 +9493,11 @@ async function readIngressIdentity(file) {
8197
9493
  throw new Error("frp_ingress_ca_invalid", { cause: error });
8198
9494
  }
8199
9495
  }
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
- }
9496
+ async function writeIngressIdentity(file, fingerprint, ownedRoot) {
9497
+ await writeManagedPrivateFile(ownedRoot, file, `${JSON.stringify({
9498
+ version: 1,
9499
+ caFingerprint: fingerprint
9500
+ })}\n`, "frp_ingress");
8219
9501
  }
8220
9502
  function coversPublicIp(certificate, address) {
8221
9503
  return (certificate.subjectAltName ?? "").split(",").some((entry) => entry.trim() === `IP Address:${address}`);
@@ -8227,9 +9509,11 @@ function coversPublicIp(certificate, address) {
8227
9509
  * replaced silently. The leaf is re-signed when expiring or no longer valid for
8228
9510
  * the public IPv4, and the running gateway reloads it after a renewal check.
8229
9511
  */
8230
- async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint) {
9512
+ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now(), expectedCaFingerprint, ownedRoot = dirname(stateFile)) {
8231
9513
  if (resolveFrpMode(settings) !== "attach" || !isFrpSelfSignedIngress(settings)) throw new Error("frp_entry_tls_invalid");
8232
9514
  const paths = frpIngressPaths(stateFile);
9515
+ await assertManagedParents(ownedRoot, paths.statusFile, "frp_ingress");
9516
+ await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
8233
9517
  try {
8234
9518
  const directory = await lstat(paths.directory);
8235
9519
  if (!directory.isDirectory() || directory.isSymbolicLink()) throw new Error("frp_ingress_directory_invalid");
@@ -8246,10 +9530,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8246
9530
  if (caCertExists !== caKeyExists || !caCertExists && (previousFingerprint !== void 0 || leafCertExists || leafKeyExists)) throw new Error("frp_ingress_ca_invalid");
8247
9531
  if (caCertExists && (await readCertificateRenewal(paths.caCertFile, now)).state === "expired") throw new Error("frp_ingress_ca_expired");
8248
9532
  if (previousFingerprint === void 0) {
8249
- await mkdir(dirname(paths.statusFile), {
8250
- recursive: true,
8251
- mode: 448
8252
- });
9533
+ await ensureManagedDirectory(ownedRoot, dirname(paths.statusFile), "frp_ingress");
8253
9534
  try {
8254
9535
  await writeFile(paths.statusFile, "{\"version\":1,\"pending\":true}\n", {
8255
9536
  flag: "wx",
@@ -8269,6 +9550,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8269
9550
  };
8270
9551
  let ca;
8271
9552
  try {
9553
+ await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
8272
9554
  ca = expectedCaFingerprint === void 0 ? await ensureManagedCa(caFiles) : await readManagedCa(caFiles);
8273
9555
  } catch (error) {
8274
9556
  throw new Error("frp_ingress_ca_invalid", { cause: error });
@@ -8276,15 +9558,16 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8276
9558
  const caFingerprint = ca.fingerprint256.replaceAll(":", "").toLowerCase();
8277
9559
  if (previousFingerprint !== void 0 && caFingerprint !== previousFingerprint) throw new Error("frp_ingress_ca_changed");
8278
9560
  if (expectedCaFingerprint !== void 0 && caFingerprint !== expectedCaFingerprint) throw new Error("frp_ingress_ca_changed");
8279
- if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint);
9561
+ if (previousFingerprint === void 0) await writeIngressIdentity(paths.statusFile, caFingerprint, ownedRoot);
8280
9562
  const publicHost = new URL(settings.publicOrigin).hostname;
8281
9563
  let leaf;
9564
+ let matchingKey = false;
8282
9565
  try {
8283
9566
  leaf = new X509Certificate(await readFile(paths.certFile));
8284
- } catch {
8285
- leaf = void 0;
8286
- }
8287
- if (!(leaf !== void 0 && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
9567
+ if (leafKeyExists) matchingKey = leaf.checkPrivateKey(createPrivateKey(await readFile(paths.keyFile)));
9568
+ } catch (_error) {}
9569
+ if (!(leaf !== void 0 && matchingKey && Date.parse(leaf.validFrom) <= now && Date.parse(leaf.validTo) > now + 864e5 && coversPublicIp(leaf, publicHost) && leaf.verify(ca.publicKey))) {
9570
+ await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
8288
9571
  await issueServerCertificate({
8289
9572
  caCertFile: paths.caCertFile,
8290
9573
  caKeyFile: paths.caKeyFile
@@ -8314,7 +9597,7 @@ async function ensureFrpIngressCertificate(settings, stateFile, now = Date.now()
8314
9597
  * No secret ever leaves this function: the CA is reported by fingerprint only,
8315
9598
  * and the token is never read.
8316
9599
  */
8317
- async function frpIngressSelfCheck(settings, stateFile) {
9600
+ async function frpIngressSelfCheck(settings, stateFile, ownedRoot = dirname(stateFile)) {
8318
9601
  const paths = frpIngressPaths(stateFile);
8319
9602
  const base = {
8320
9603
  mode: resolveFrpMode(settings),
@@ -8331,6 +9614,8 @@ async function frpIngressSelfCheck(settings, stateFile) {
8331
9614
  }
8332
9615
  };
8333
9616
  if (!isFrpSelfSignedIngress(settings)) return Object.freeze(base);
9617
+ await assertManagedParents(ownedRoot, paths.caCertFile, "frp_ingress");
9618
+ await assertManagedParents(ownedRoot, paths.certFile, "frp_ingress");
8334
9619
  let caFingerprint;
8335
9620
  try {
8336
9621
  caFingerprint = new X509Certificate(await readFile(paths.caCertFile)).fingerprint256.replaceAll(":", "").toLowerCase();
@@ -8345,8 +9630,10 @@ async function frpIngressSelfCheck(settings, stateFile) {
8345
9630
  });
8346
9631
  }
8347
9632
  /** Remove only the five files owned by the local self-signed FRP entry. */
8348
- async function purgeFrpIngressCertificates(stateFile) {
9633
+ async function purgeFrpIngressCertificates(stateFile, ownedRoot = dirname(stateFile)) {
8349
9634
  const paths = frpIngressPaths(stateFile);
9635
+ await assertManagedParents(ownedRoot, paths.statusFile, "frp_ingress");
9636
+ await assertManagedParents(ownedRoot, paths.directory, "frp_ingress");
8350
9637
  try {
8351
9638
  const marker = await lstat(paths.statusFile);
8352
9639
  if (!marker.isFile() && !marker.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
@@ -8372,6 +9659,7 @@ async function purgeFrpIngressCertificates(stateFile) {
8372
9659
  paths.certFile,
8373
9660
  paths.keyFile
8374
9661
  ]) {
9662
+ await assertManagedParents(ownedRoot, file, "frp_ingress");
8375
9663
  let entry;
8376
9664
  try {
8377
9665
  entry = await lstat(file);
@@ -8383,6 +9671,7 @@ async function purgeFrpIngressCertificates(stateFile) {
8383
9671
  await unlink(file);
8384
9672
  }
8385
9673
  try {
9674
+ await assertManagedParents(ownedRoot, paths.directory, "frp_ingress");
8386
9675
  await rmdir(paths.directory);
8387
9676
  } catch (error) {
8388
9677
  if (error.code === "ENOENT") return;
@@ -8403,6 +9692,18 @@ function aggregateErrors(errors, message) {
8403
9692
  if (errors.length === 1 && errors[0] instanceof Error) return errors[0];
8404
9693
  return new AggregateError(errors, message);
8405
9694
  }
9695
+ /**
9696
+ * Prepare each inactive provider's disabled state and settle every write before reporting a failure.
9697
+ * @param selected - Provider whose startup must still read its durable preference.
9698
+ * @param stores - The same stores supplied to the corresponding provider controllers.
9699
+ * @returns Fresh disabled states for this initialization only; no permission-success cache is retained.
9700
+ */
9701
+ async function prepareDisabledRemoteProviders(selected, stores) {
9702
+ const results = await Promise.allSettled(REMOTE_PROVIDERS.filter((provider) => provider !== selected).map(async (provider) => [provider, await stores[provider].disableIfPresent()]));
9703
+ const failure = aggregateErrors(results.filter((result) => result.status === "rejected").map((result) => result.reason), "remote provider control preparation failed");
9704
+ if (failure !== void 0) throw failure;
9705
+ return new Map(results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []));
9706
+ }
8406
9707
  /** Settle independent remote cleanup work before reporting any collected failure. */
8407
9708
  async function settleRemoteResources(steps, message = "remote resource cleanup failed") {
8408
9709
  const failure = aggregateErrors((await Promise.allSettled(steps.map(async (step) => step()))).filter((result) => result.status === "rejected").map((result) => result.reason), message);
@@ -8855,8 +10156,8 @@ var FrpController = class {
8855
10156
  if (options.ingressCertificateCheckMs !== void 0 && (!Number.isSafeInteger(options.ingressCertificateCheckMs) || options.ingressCertificateCheckMs < 1 || options.ingressCertificateCheckMs > 2147483647)) throw new Error("frp_ingress_check_interval_invalid");
8856
10157
  }
8857
10158
  /** Restore the remembered FRP switch without changing LAN or other providers. */
8858
- async initialize() {
8859
- const state = await this.options.store.load();
10159
+ async initialize(initialState) {
10160
+ const state = initialState ?? await this.options.store.load();
8860
10161
  this.enabled = state.enabled;
8861
10162
  this.initialized = true;
8862
10163
  if (this.enabled) await this.start();
@@ -8878,12 +10179,12 @@ var FrpController = class {
8878
10179
  if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
8879
10180
  await this.enqueue(async () => {
8880
10181
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
8881
- if (!enabled) await this.stop();
8882
- this.enabled = enabled;
8883
10182
  await this.options.store.save({
8884
10183
  version: 1,
8885
10184
  enabled
8886
10185
  });
10186
+ this.enabled = enabled;
10187
+ if (!enabled) await this.stop();
8887
10188
  if (enabled) await this.start();
8888
10189
  else this.publish({
8889
10190
  enabled: false,
@@ -8897,11 +10198,11 @@ var FrpController = class {
8897
10198
  if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
8898
10199
  await this.enqueue(async () => {
8899
10200
  if (!this.enabled) {
8900
- this.enabled = true;
8901
10201
  await this.options.store.save({
8902
10202
  version: 1,
8903
10203
  enabled: true
8904
10204
  });
10205
+ this.enabled = true;
8905
10206
  }
8906
10207
  await this.stop();
8907
10208
  await this.start();
@@ -8912,12 +10213,12 @@ var FrpController = class {
8912
10213
  async reset() {
8913
10214
  if (!this.initialized || this.disposed) throw new Error("FRP controller is unavailable");
8914
10215
  await this.enqueue(async () => {
8915
- await this.stop();
8916
- this.enabled = false;
8917
10216
  await this.options.store.save({
8918
10217
  version: 1,
8919
10218
  enabled: false
8920
10219
  });
10220
+ this.enabled = false;
10221
+ await this.stop();
8921
10222
  this.publish({
8922
10223
  enabled: false,
8923
10224
  state: "off"
@@ -9303,39 +10604,15 @@ function parseOriginSettings(value) {
9303
10604
  allowedCidrs: validateOriginAllowedCidrs(record.allowedCidrs, listenHost)
9304
10605
  });
9305
10606
  }
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
10607
  /** Owns only origin settings; shared paired-device storage is never removed. */
9333
10608
  var OriginConfigStore = class {
10609
+ ownedRoot;
9334
10610
  stateRoot;
9335
10611
  settingsFile;
9336
10612
  settingsValue;
9337
10613
  errorCode;
9338
- constructor(stateDirectory) {
10614
+ constructor(stateDirectory, ownedRoot = stateDirectory) {
10615
+ this.ownedRoot = ownedRoot;
9339
10616
  if (!isAbsolute(stateDirectory)) throw new Error("origin config state directory must be absolute");
9340
10617
  this.stateRoot = resolve(stateDirectory);
9341
10618
  this.settingsFile = join(this.stateRoot, "settings.json");
@@ -9343,6 +10620,12 @@ var OriginConfigStore = class {
9343
10620
  async initialize() {
9344
10621
  this.settingsValue = void 0;
9345
10622
  this.errorCode = void 0;
10623
+ try {
10624
+ await assertManagedParents(this.ownedRoot, this.settingsFile, "origin");
10625
+ } catch (_error) {
10626
+ this.errorCode = "origin_config_invalid";
10627
+ return;
10628
+ }
9346
10629
  let entry;
9347
10630
  try {
9348
10631
  entry = await lstat(this.settingsFile);
@@ -9381,13 +10664,13 @@ var OriginConfigStore = class {
9381
10664
  }
9382
10665
  async configure(value) {
9383
10666
  const settings = parseOriginSettings(value);
9384
- await atomicPrivateWrite$1(this.settingsFile, JSON.stringify(settings) + "\n");
10667
+ await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, JSON.stringify(settings) + "\n", "origin");
9385
10668
  this.settingsValue = settings;
9386
10669
  this.errorCode = void 0;
9387
10670
  return this.status();
9388
10671
  }
9389
10672
  async purge() {
9390
- await rm(this.settingsFile, { force: true });
10673
+ await removeManagedTree(this.ownedRoot, this.settingsFile, "origin");
9391
10674
  this.settingsValue = void 0;
9392
10675
  this.errorCode = void 0;
9393
10676
  return this.status();
@@ -9545,96 +10828,6 @@ var OriginController = class {
9545
10828
  }
9546
10829
  };
9547
10830
  //#endregion
9548
- //#region src/component-download.ts
9549
- /**
9550
- * Hosts a pinned GitHub release download may redirect to.
9551
- *
9552
- * `github.com/<owner>/<repo>/releases/download/<tag>/<asset>` does not serve the bytes itself: it
9553
- * answers 302 with a signed URL on a release-asset host. A fetch configured with
9554
- * `redirect: 'error'` fails that hop as `TypeError: fetch failed` (cause: `unexpected redirect`)
9555
- * before a single byte is transferred, which is exactly how the cloudflared component could never
9556
- * install. Following one validated hop keeps the origin restriction while allowing the shape
9557
- * every GitHub release download uses.
9558
- */
9559
- const RELEASE_ASSET_HOSTS = Object.freeze(["release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
9560
- /** Follow one redirect hop after checking its scheme and host. A second hop is refused. */
9561
- async function followValidatedRedirect(response, request) {
9562
- const location = response.headers.get("location");
9563
- if (location === null) throw new Error(`${request.errorPrefix}_download_redirect_missing`);
9564
- let target;
9565
- try {
9566
- target = new URL(location, request.url);
9567
- } catch {
9568
- throw new Error(`${request.errorPrefix}_download_redirect_invalid`);
9569
- }
9570
- const hosts = request.redirectHosts ?? RELEASE_ASSET_HOSTS;
9571
- if (target.protocol !== "https:" || !hosts.includes(target.hostname)) throw new Error(`${request.errorPrefix}_download_redirect_rejected`);
9572
- return fetch(target, {
9573
- redirect: "error",
9574
- signal: request.signal
9575
- });
9576
- }
9577
- /** Attempts per install. A 55 MB transfer through a TUN proxy can reset mid-stream: the first
9578
- * real run in the development environment failed after 1.5 MB and succeeded on the next attempt,
9579
- * which is exactly the confusing "nothing happened" the user saw. */
9580
- const DOWNLOAD_ATTEMPTS = 2;
9581
- const RETRY_DELAY_MS = 750;
9582
- /** Whether a failure is a transport problem worth a second attempt rather than a verdict. */
9583
- function isRetryable(error) {
9584
- if (error instanceof TypeError) return true;
9585
- if (error instanceof Error && /_download_http_5\d\d$/u.test(error.message)) return true;
9586
- return false;
9587
- }
9588
- function delay(ms) {
9589
- return new Promise((resolve) => {
9590
- setTimeout(resolve, ms);
9591
- });
9592
- }
9593
- async function attemptDownload(request) {
9594
- const first = await fetch(request.url, {
9595
- redirect: "manual",
9596
- signal: request.signal
9597
- });
9598
- const response = first.status >= 300 && first.status < 400 ? await followValidatedRedirect(first, request) : first;
9599
- if (!response.ok) throw new Error(`${request.errorPrefix}_download_http_${String(response.status)}`);
9600
- const contentLength = response.headers.get("content-length");
9601
- if (contentLength !== null && (!/^\d+$/u.test(contentLength) || Number(contentLength) !== request.expectedBytes)) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
9602
- if (response.body === null) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
9603
- const bytes = new Uint8Array(request.expectedBytes);
9604
- const reader = response.body.getReader();
9605
- let received = 0;
9606
- while (true) {
9607
- const chunk = await reader.read();
9608
- if (chunk.done) break;
9609
- if (chunk.value.byteLength > bytes.byteLength - received) {
9610
- await reader.cancel();
9611
- throw new Error(`${request.errorPrefix}_download_size_mismatch`);
9612
- }
9613
- bytes.set(chunk.value, received);
9614
- received += chunk.value.byteLength;
9615
- }
9616
- if (received !== request.expectedBytes) throw new Error(`${request.errorPrefix}_download_size_mismatch`);
9617
- return bytes;
9618
- }
9619
- /**
9620
- * Download a pinned artifact, following at most one validated redirect, and check its exact length.
9621
- * A transport failure is retried once; a length or redirect verdict is not. The caller still
9622
- * verifies the SHA-256 before publishing anything.
9623
- * @param request - the pinned URL, its exact byte length and the caller's error prefix.
9624
- * @returns the artifact bytes.
9625
- */
9626
- async function downloadPinnedArtifact(request) {
9627
- let lastError;
9628
- for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) try {
9629
- return await attemptDownload(request);
9630
- } catch (error) {
9631
- lastError = error;
9632
- if (attempt === DOWNLOAD_ATTEMPTS || !isRetryable(error)) throw error;
9633
- await delay(RETRY_DELAY_MS);
9634
- }
9635
- throw lastError;
9636
- }
9637
- //#endregion
9638
10831
  //#region src/cloudflared-component.ts
9639
10832
  /** Upper bound for `tar -t` output; a pinned release listing is a few lines. */
9640
10833
  const MAX_ARCHIVE_LIST_BYTES = 1048576;
@@ -9805,6 +10998,7 @@ var CloudflaredComponentManager = class {
9805
10998
  stateRoot;
9806
10999
  logRoot;
9807
11000
  stagingRoot;
11001
+ stateDirectory;
9808
11002
  platform;
9809
11003
  arch;
9810
11004
  release;
@@ -9814,8 +11008,9 @@ var CloudflaredComponentManager = class {
9814
11008
  errorCode;
9815
11009
  queue = Promise.resolve();
9816
11010
  constructor(options) {
11011
+ if (!isAbsolute(options.stateDirectory)) throw new Error("cloudflared state directory must be absolute");
9817
11012
  const stateDirectory = resolve(options.stateDirectory);
9818
- if (!isAbsolute(stateDirectory)) throw new Error("cloudflared state directory must be absolute");
11013
+ this.stateDirectory = stateDirectory;
9819
11014
  this.platform = options.platform ?? process.platform;
9820
11015
  this.arch = options.arch ?? process.arch;
9821
11016
  this.release = lookupRelease(this.platform, this.arch);
@@ -9839,9 +11034,16 @@ var CloudflaredComponentManager = class {
9839
11034
  /** Inspect the managed binary without using any global cloudflared state. */
9840
11035
  async initialize() {
9841
11036
  const release = this.release;
11037
+ this.installed = false;
9842
11038
  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) {
11039
+ try {
11040
+ await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
11041
+ this.installed = release !== void 0 && await regularFile$1(this.executable, release.executableBytes);
11042
+ if (this.installed && release !== void 0 && await sha256$1(this.executable) !== release.executableSha256) {
11043
+ this.installed = false;
11044
+ this.errorCode = "cloudflared_component_invalid";
11045
+ }
11046
+ } catch (_error) {
9845
11047
  this.installed = false;
9846
11048
  this.errorCode = "cloudflared_component_invalid";
9847
11049
  }
@@ -9867,10 +11069,8 @@ var CloudflaredComponentManager = class {
9867
11069
  return this.enqueue(async () => {
9868
11070
  const release = this.release;
9869
11071
  if (release === void 0) throw new Error("cloudflared_component_unsupported");
9870
- await mkdir(this.stagingRoot, {
9871
- recursive: true,
9872
- mode: 448
9873
- });
11072
+ await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
11073
+ await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "cloudflared");
9874
11074
  const staging = await mkdtemp(join(this.stagingRoot, "install-"));
9875
11075
  try {
9876
11076
  const controller = new AbortController();
@@ -9906,48 +11106,43 @@ var CloudflaredComponentManager = class {
9906
11106
  await chmod(staged, 448);
9907
11107
  if (!await regularFile$1(staged, release.executableBytes) || await sha256$1(staged) !== release.executableSha256) throw new Error("cloudflared_executable_hash_mismatch");
9908
11108
  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);
11109
+ await ensureManagedDirectory(this.stateDirectory, candidate, "cloudflared");
11110
+ try {
11111
+ await copyFile(staged, join(candidate, release.executableName));
11112
+ await chmod(join(candidate, release.executableName), 448);
11113
+ await replaceManagedDirectory(this.stateDirectory, this.componentStorage, candidate, "cloudflared");
11114
+ } catch (error) {
11115
+ try {
11116
+ await removeManagedTree(this.stateDirectory, candidate, "cloudflared");
11117
+ } catch (_cleanupError) {
11118
+ process.emitWarning("cloudflared candidate cleanup failed", { code: "DSH_MOBILE_COMPONENT_CLEANUP_FAILED" });
11119
+ }
11120
+ throw error;
11121
+ }
9920
11122
  this.installed = true;
9921
11123
  this.errorCode = void 0;
11124
+ } catch (error) {
11125
+ try {
11126
+ await assertManagedParents(this.stateDirectory, this.executable, "cloudflared");
11127
+ this.installed = await regularFile$1(this.executable, release.executableBytes) && await sha256$1(this.executable) === release.executableSha256;
11128
+ } catch (_inspectionError) {
11129
+ this.installed = false;
11130
+ }
11131
+ throw error;
9922
11132
  } finally {
9923
- await rm(staging, {
9924
- recursive: true,
9925
- force: true
9926
- });
11133
+ await removeManagedTree(this.stateDirectory, staging, "cloudflared");
9927
11134
  }
9928
11135
  });
9929
11136
  }
9930
11137
  /** Remove every cloudflared file owned by DSH Mobile without touching global state. */
9931
11138
  purge() {
9932
11139
  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
- ]);
11140
+ await removeManagedPaths(this.stateDirectory, [
11141
+ this.componentRoot,
11142
+ this.stateRoot,
11143
+ this.logRoot,
11144
+ this.stagingRoot
11145
+ ], "cloudflared");
9951
11146
  this.installed = false;
9952
11147
  this.errorCode = void 0;
9953
11148
  });
@@ -10125,8 +11320,8 @@ var CloudflaredController = class {
10125
11320
  if (!isAbsolute(options.executable)) throw new Error("cloudflared executable path must be absolute");
10126
11321
  }
10127
11322
  /** Restore the remembered cloudflared switch independently from LAN and other providers. */
10128
- async initialize() {
10129
- const state = await this.options.store.load();
11323
+ async initialize(initialState) {
11324
+ const state = initialState ?? await this.options.store.load();
10130
11325
  this.enabled = state.enabled;
10131
11326
  this.initialized = true;
10132
11327
  if (this.enabled) await this.start();
@@ -10148,12 +11343,12 @@ var CloudflaredController = class {
10148
11343
  if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
10149
11344
  await this.enqueue(async () => {
10150
11345
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
10151
- if (!enabled) await this.stop();
10152
- this.enabled = enabled;
10153
11346
  await this.options.store.save({
10154
11347
  version: 1,
10155
11348
  enabled
10156
11349
  });
11350
+ this.enabled = enabled;
11351
+ if (!enabled) await this.stop();
10157
11352
  if (enabled) await this.start();
10158
11353
  else this.publish({
10159
11354
  enabled: false,
@@ -10167,11 +11362,11 @@ var CloudflaredController = class {
10167
11362
  if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
10168
11363
  await this.enqueue(async () => {
10169
11364
  if (!this.enabled) {
10170
- this.enabled = true;
10171
11365
  await this.options.store.save({
10172
11366
  version: 1,
10173
11367
  enabled: true
10174
11368
  });
11369
+ this.enabled = true;
10175
11370
  }
10176
11371
  await this.stop();
10177
11372
  await this.start();
@@ -10182,12 +11377,12 @@ var CloudflaredController = class {
10182
11377
  async reset() {
10183
11378
  if (!this.initialized || this.disposed) throw new Error("cloudflared controller is unavailable");
10184
11379
  await this.enqueue(async () => {
10185
- await this.stop();
10186
- this.enabled = false;
10187
11380
  await this.options.store.save({
10188
11381
  version: 1,
10189
11382
  enabled: false
10190
11383
  });
11384
+ this.enabled = false;
11385
+ await this.stop();
10191
11386
  this.publish({
10192
11387
  enabled: false,
10193
11388
  state: "off"
@@ -10589,34 +11784,9 @@ function mergeSavedCloudflaredTunnelSettings(partial, saved) {
10589
11784
  port
10590
11785
  });
10591
11786
  }
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
11787
  /** Owns the private cloudflared tunnel configuration for one DSH installation. */
10619
11788
  var CloudflaredTunnelStore = class {
11789
+ ownedRoot;
10620
11790
  stateRoot;
10621
11791
  settingsFile;
10622
11792
  settingsValue = Object.freeze({
@@ -10624,13 +11794,25 @@ var CloudflaredTunnelStore = class {
10624
11794
  mode: "quick"
10625
11795
  });
10626
11796
  errorCode;
10627
- constructor(stateDirectory) {
11797
+ constructor(stateDirectory, ownedRoot = stateDirectory) {
11798
+ this.ownedRoot = ownedRoot;
10628
11799
  if (!isAbsolute(stateDirectory)) throw new Error("cloudflared tunnel state directory must be absolute");
10629
11800
  this.stateRoot = resolve(stateDirectory);
10630
11801
  this.settingsFile = join(this.stateRoot, "tunnel.json");
10631
11802
  }
10632
11803
  /** Load private settings while rejecting links, oversized files and unknown fields. */
10633
11804
  async initialize() {
11805
+ this.settingsValue = Object.freeze({
11806
+ version: 1,
11807
+ mode: "quick"
11808
+ });
11809
+ this.errorCode = void 0;
11810
+ try {
11811
+ await assertManagedParents(this.ownedRoot, this.settingsFile, "cloudflared");
11812
+ } catch (_error) {
11813
+ this.errorCode = "cloudflared_tunnel_config_invalid";
11814
+ return;
11815
+ }
10634
11816
  let entry;
10635
11817
  try {
10636
11818
  entry = await lstat(this.settingsFile);
@@ -10675,15 +11857,20 @@ var CloudflaredTunnelStore = class {
10675
11857
  /** Atomically replace the tunnel configuration. */
10676
11858
  async configure(value) {
10677
11859
  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`);
11860
+ if (settings.mode === "quick") await removeManagedTree(this.ownedRoot, this.settingsFile, "cloudflared");
11861
+ else try {
11862
+ await writeManagedPrivateFile(this.ownedRoot, this.settingsFile, `${JSON.stringify(settings)}\n`, "cloudflared");
11863
+ } catch (error) {
11864
+ if (error instanceof Error && error.message === "cloudflared_config_target_invalid") throw new Error("cloudflared_tunnel_target_invalid", { cause: error });
11865
+ throw error;
11866
+ }
10680
11867
  this.settingsValue = settings;
10681
11868
  this.errorCode = void 0;
10682
11869
  return this.status();
10683
11870
  }
10684
11871
  /** Forget a named tunnel and its connector token. */
10685
11872
  async purge() {
10686
- await rm(this.settingsFile, { force: true });
11873
+ await removeManagedTree(this.ownedRoot, this.settingsFile, "cloudflared");
10687
11874
  this.settingsValue = Object.freeze({
10688
11875
  version: 1,
10689
11876
  mode: "quick"
@@ -11242,7 +12429,7 @@ async function collectConnectionDiagnostics(snapshot, probes = {}) {
11242
12429
  function buildMobileGuide(state) {
11243
12430
  const styleLine = state.hasCustomCss ? "存在(当前生效的自定义样式)" : "不存在(使用内置默认样式)";
11244
12431
  const scriptLine = state.hasCustomJs ? "存在(当前生效的自定义脚本)" : "不存在(无自定义脚本)";
11245
- const extensionLines = state.extensions.length === 0 ? "(无)" : state.extensions.map((entry) => `- ${entry.id}(${entry.name} v${entry.version})`).join("\n");
12432
+ 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
12433
  const failureLine = state.failedExtensionCount > 0 ? `注意:${state.failedExtensionCount} 个扩展的电脑端 host 激活失败,如改动相关扩展请先检查其 host.mjs 与 extension.json。` : "";
11247
12434
  return `${`## 手机端当前状态(改动前必读,避免覆盖已有定制)
11248
12435
 
@@ -11276,16 +12463,18 @@ const MOBILE_CUSTOMIZATION_GUIDE_BODY = `你在为用户定制 DSH Mobile 的手
11276
12463
  - 目录:$DSH_HOME/mobile-access/extensions/<id>/,id 用小写字母数字和连字符(如 media-remote)
11277
12464
  - extension.json:{"schemaVersion":1,"id":"<id>","name":"显示名","version":"0.1.0","description":"说明"}
11278
12465
  - 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) 的适配器;两种形式都会在动作执行前校验并规范化输入。
12466
+ - 执行方式见上方实际状态。默认 in-process;插件配置可按扩展选择 worker。Worker 中 api.context 只有 logger,不能调用 Cordis 服务;api.manifest、schema、action、route、effect 仍可用。不要把需要 Cordis 的扩展偷偷改成 Worker,也不要在 extension.json 中添加执行模式字段。
11279
12467
  - 动作与路由的执行有时间预算:默认 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
12468
  - mobile.js:手机端脚本,用 window.dshMobile.define({ apiVersion:1, id:'<id>', activate(api) { ... } }),activate 返回清理函数
11281
12469
  - mobile.css:手机端样式(可选)
11282
12470
  - assets/:手机端静态资源(可选)
11283
12471
  - 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 "<名称>",再在模板上改
12472
+ - DSH Web 可先用命令生成模板:dsh plugin --profile web exec dsh-mobile extension create <id> --name "<名称>",再在模板上改;Desktop 请在上方实际定制目录创建文件,不要误改 Web profile
11285
12473
 
11286
12474
  安全约束:
11287
12475
  - host.mjs 拥有电脑用户的完整权限,绝不能放入不可信代码,也不要让手机端无条件执行任意命令
11288
- - host.mjs 的同步阻塞(如 while (true) {} 或长时间同步循环)会卡死整个电脑端网关进程,超时与取消机制都无法打断;绝不编写同步阻塞代码
12476
+ - in-process 的同步阻塞会卡住网关事件循环,超时不能打断;Worker 可约束 JavaScript 阻塞,但不是安全沙箱,不约束全部原生内存,也不回滚副作用。两种模式都应响应 signal,避免编写不可取消的同步循环
12477
+ - Worker 终止后其他未完成调用会失败,已开始的操作不会自动重试。相同内容的停止实例需要操作者在电脑端“移动访问 → 扩展需要重启”中确认恢复;不要通过无意义改文件或自动重放原动作实现恢复
11289
12478
  - 所有改动只限 $DSH_HOME/mobile-access/,不要动 DeepSeek Harness 源码
11290
12479
 
11291
12480
  完成前请自检:
@@ -11313,10 +12502,16 @@ const LINUX_ARTIFACTS = Object.freeze({
11313
12502
  var VpsSshError = class extends Error {
11314
12503
  stdout;
11315
12504
  stderr;
12505
+ timedOut;
12506
+ exitCode;
12507
+ signal;
11316
12508
  constructor(message, stdout, stderr, options) {
11317
12509
  super(message, options);
11318
12510
  this.stdout = stdout;
11319
12511
  this.stderr = stderr;
12512
+ this.timedOut = options?.timedOut ?? false;
12513
+ this.exitCode = options?.exitCode;
12514
+ this.signal = options?.signal;
11320
12515
  }
11321
12516
  };
11322
12517
  function shellQuote(value) {
@@ -11427,7 +12622,7 @@ function sshSessionOptions(knownHostsFile) {
11427
12622
  `-o UserKnownHostsFile=${knownHostsFile}`
11428
12623
  ];
11429
12624
  }
11430
- /** Lenient scan probe: garbage fails the whole fetch, comment-only output falls back. */
12625
+ /** Recognize usable public-key scan output without authenticating to the server. */
11431
12626
  function tryParseKeyscanOutput(output) {
11432
12627
  try {
11433
12628
  return parseKeyscanOutput(output);
@@ -11466,58 +12661,20 @@ async function defaultRunKeyscan(input, serverAddress) {
11466
12661
  }
11467
12662
  return first?.stdout ?? "";
11468
12663
  }
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
- */
12664
+ /** Obtain public host keys without using a private key, agent or unverified authenticated connection. */
11508
12665
  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");
12666
+ const runKeyscan = options.runKeyscan ?? defaultRunKeyscan;
12667
+ let scanned;
12668
+ try {
12669
+ scanned = await runKeyscan({
12670
+ sshUser: input.sshUser,
12671
+ sshPort: input.sshPort
12672
+ }, serverAddress);
12673
+ } catch (error) {
12674
+ throw new Error("vps_host_key_unavailable", { cause: error });
12675
+ }
12676
+ if (parseKeyscanOutput(scanned).length === 0) throw new Error("vps_host_key_unavailable");
12677
+ return scanned;
11521
12678
  }
11522
12679
  /**
11523
12680
  * Fetch the server's current host keys and return them with OpenSSH-style
@@ -11767,17 +12924,6 @@ else
11767
12924
  printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
11768
12925
  chmod 0644 /etc/caddy/Caddyfile
11769
12926
  caddyfile_ready=true
11770
- elif grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
11771
- # Legacy whole-file layout: the entire file is ours by construction.
11772
- printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
11773
- chmod 0644 /etc/caddy/Caddyfile
11774
- caddyfile_ready=true
11775
- elif grep -q '^:80[[:space:]]*{' /etc/caddy/Caddyfile \
11776
- && grep -q 'root [*] /usr/share/caddy' /etc/caddy/Caddyfile \
11777
- && grep -q '^[[:space:]]*file_server[[:space:]]*$' /etc/caddy/Caddyfile; then
11778
- printf '%s\n' "$caddy_import" > /etc/caddy/Caddyfile
11779
- chmod 0644 /etc/caddy/Caddyfile
11780
- caddyfile_ready=true
11781
12927
  fi
11782
12928
  fi
11783
12929
  if [ "$caddyfile_ready" != true ]; then
@@ -11880,7 +13026,8 @@ check caddy ok "Caddy 已加载 ${publicHost}。"
11880
13026
  echo DSH_MOBILE_DEPLOYMENT_OK
11881
13027
  `;
11882
13028
  }
11883
- async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
13029
+ /** Run one owned SSH/transfer utility; reject only after its process and stdio have closed. */
13030
+ async function runVpsProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11884
13031
  return new Promise((resolveRun, rejectRun) => {
11885
13032
  const child = spawn(command, args, {
11886
13033
  windowsHide: true,
@@ -11893,9 +13040,23 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11893
13040
  let stdout = "";
11894
13041
  let stderr = "";
11895
13042
  const append = (current, chunk) => `${current}${chunk.toString("utf8")}`.slice(-98304);
11896
- const timer = setTimeout(() => {
13043
+ let timedOut = false;
13044
+ let failure;
13045
+ let failureCode = "vps_ssh_unavailable";
13046
+ let forceTimer;
13047
+ const terminate = () => {
11897
13048
  child.kill();
11898
- rejectRun(new VpsSshError("vps_ssh_timeout", stdout, stderr));
13049
+ forceTimer ??= setTimeout(() => {
13050
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
13051
+ child.stdin.destroy();
13052
+ child.stdout.destroy();
13053
+ child.stderr.destroy();
13054
+ }, 1e3);
13055
+ forceTimer.unref();
13056
+ };
13057
+ const timer = setTimeout(() => {
13058
+ timedOut = true;
13059
+ terminate();
11899
13060
  }, timeoutMs);
11900
13061
  timer.unref();
11901
13062
  child.stdout.on("data", (chunk) => {
@@ -11905,12 +13066,32 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11905
13066
  stderr = append(stderr, Buffer.from(chunk));
11906
13067
  });
11907
13068
  child.once("error", (error) => {
11908
- clearTimeout(timer);
11909
- rejectRun(new VpsSshError("vps_ssh_unavailable", stdout, stderr, { cause: error }));
13069
+ failure = error;
11910
13070
  });
11911
- child.once("close", (code) => {
13071
+ child.stdin.on("error", (error) => {
13072
+ failure = error;
13073
+ failureCode = "vps_ssh_input_failed";
13074
+ terminate();
13075
+ });
13076
+ const failedOutput = (error) => {
13077
+ failure = error;
13078
+ failureCode = "vps_ssh_transport_failed";
13079
+ terminate();
13080
+ };
13081
+ child.stdout.on("error", failedOutput);
13082
+ child.stderr.on("error", failedOutput);
13083
+ child.once("close", (code, signal) => {
11912
13084
  clearTimeout(timer);
11913
- if (code !== 0) rejectRun(new VpsSshError(stderr.includes("Permission denied") ? "vps_ssh_auth_failed" : "vps_deploy_failed", stdout, stderr));
13085
+ if (forceTimer !== void 0) clearTimeout(forceTimer);
13086
+ const facts = {
13087
+ timedOut,
13088
+ exitCode: code,
13089
+ signal,
13090
+ ...failure === void 0 ? {} : { cause: failure }
13091
+ };
13092
+ if (timedOut) rejectRun(new VpsSshError("vps_ssh_timeout", stdout, stderr, facts));
13093
+ else if (failure !== void 0) rejectRun(new VpsSshError(child.pid === void 0 ? "vps_ssh_unavailable" : failureCode, stdout, stderr, facts));
13094
+ else if (code !== 0) rejectRun(new VpsSshError(stderr.includes("Permission denied") ? "vps_ssh_auth_failed" : "vps_deploy_failed", stdout, stderr, facts));
11914
13095
  else resolveRun({
11915
13096
  stdout,
11916
13097
  stderr
@@ -11919,8 +13100,10 @@ async function runProcess(command, args, stdin, timeoutMs = SSH_TIMEOUT_MS) {
11919
13100
  child.stdin.end(stdin, "utf8");
11920
13101
  });
11921
13102
  }
13103
+ const runProcess = runVpsProcess;
11922
13104
  async function downloadArtifact(artifact, file) {
11923
- await runProcess(process.platform === "win32" ? "curl.exe" : "curl", [
13105
+ const curl = process.platform === "win32" ? "curl.exe" : "curl";
13106
+ await runProcess(curl, [
11924
13107
  ...process.platform === "win32" ? ["--ipv4"] : [],
11925
13108
  "--fail",
11926
13109
  "--location",
@@ -12161,13 +13344,8 @@ if grep -Eq '^[[:space:]]*import[[:space:]]+/etc/caddy/dsh-mobile-dsh\.caddy([[:
12161
13344
  fi
12162
13345
  check caddy ok "已移除 DSH Mobile 站点引入;其余 Caddy 配置保持原样。"
12163
13346
  elif [ -f /etc/caddy/Caddyfile ] && grep -q '^# Managed by DSH Mobile$' /etc/caddy/Caddyfile; then
12164
- # Legacy whole-file layout (pre-snippet releases): the entire file is ours.
12165
- printf '# DSH Mobile removed its site. Restore your own Caddyfile or reinstall the Caddy defaults.\\n' > /etc/caddy/Caddyfile
12166
- chmod 0644 /etc/caddy/Caddyfile
12167
- if command -v caddy >/dev/null 2>&1; then
12168
- caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy.service || systemctl restart caddy.service || true
12169
- fi
12170
- check caddy ok "已清空旧版 DSH Mobile 管理的 Caddy 站点;请按需恢复自己的配置。"
13347
+ # A legacy marker does not establish ownership of subsequently added sites.
13348
+ check caddy warning "已保留旧版 Caddyfile;请手动移除 DSH Mobile 站点,不要删除其他站点。"
12171
13349
  else
12172
13350
  check caddy ok "Caddyfile 非 DSH Mobile 管理,保持原样。"
12173
13351
  fi
@@ -12374,14 +13552,30 @@ var FunnelController = class {
12374
13552
  state: "off"
12375
13553
  });
12376
13554
  queue = Promise.resolve();
13555
+ closeTask;
12377
13556
  startTimer;
13557
+ ownedRoot;
12378
13558
  constructor(options) {
12379
13559
  this.options = options;
12380
13560
  if (!isAbsolute(options.executable) || !isAbsolute(options.stateDirectory)) throw new Error("Funnel paths must be absolute");
13561
+ this.ownedRoot = options.ownedRoot ?? dirname(resolve(options.stateDirectory));
13562
+ if (!isAbsolute(this.ownedRoot) || resolve(this.ownedRoot) === resolve(options.stateDirectory)) throw new Error("Funnel state must be a child of its owned root");
12381
13563
  }
12382
13564
  /** Restore the remote switch without coupling it to LAN availability. */
12383
- async initialize() {
12384
- const state = await this.options.store.load();
13565
+ async initialize(initialState) {
13566
+ try {
13567
+ await this.assertStateParents();
13568
+ } catch (_error) {
13569
+ this.enabled = false;
13570
+ this.initialized = true;
13571
+ this.publish({
13572
+ enabled: false,
13573
+ state: "unavailable",
13574
+ errorCode: "funnel_state_invalid"
13575
+ });
13576
+ return;
13577
+ }
13578
+ const state = initialState ?? await this.options.store.load();
12385
13579
  this.enabled = state.enabled;
12386
13580
  this.initialized = true;
12387
13581
  if (this.enabled) await this.start();
@@ -12402,18 +13596,67 @@ var FunnelController = class {
12402
13596
  async setEnabled(enabled) {
12403
13597
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
12404
13598
  await this.enqueue(async () => {
12405
- if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
12406
- if (!enabled) await this.stop();
12407
- this.enabled = enabled;
13599
+ const ownsResources = this.child !== void 0 || this.gatewayValue !== void 0;
13600
+ if (this.enabled === enabled && (enabled ? this.child !== void 0 : !ownsResources && this.latest.state === "off")) return;
13601
+ if (!enabled) {
13602
+ try {
13603
+ await this.assertStateParents();
13604
+ } catch (error) {
13605
+ try {
13606
+ await this.stop();
13607
+ } catch (cleanupError) {
13608
+ this.publish({
13609
+ enabled: this.enabled,
13610
+ state: "error",
13611
+ errorCode: "funnel_cleanup_failed"
13612
+ });
13613
+ throw new AggregateError([error, cleanupError], "Funnel state validation and cleanup failed");
13614
+ }
13615
+ this.publish({
13616
+ enabled: this.enabled,
13617
+ state: "unavailable",
13618
+ errorCode: "funnel_state_invalid"
13619
+ });
13620
+ throw error;
13621
+ }
13622
+ await this.options.store.save({
13623
+ version: 1,
13624
+ enabled: false
13625
+ });
13626
+ this.enabled = false;
13627
+ try {
13628
+ await this.stop();
13629
+ } catch (error) {
13630
+ this.publish({
13631
+ enabled: false,
13632
+ state: "error",
13633
+ errorCode: "funnel_cleanup_failed"
13634
+ });
13635
+ throw error;
13636
+ }
13637
+ this.publish({
13638
+ enabled: false,
13639
+ state: "off"
13640
+ });
13641
+ return;
13642
+ }
13643
+ try {
13644
+ await this.assertStateParents();
13645
+ } catch (error) {
13646
+ this.publish({
13647
+ enabled: this.enabled,
13648
+ state: "unavailable",
13649
+ errorCode: "funnel_state_invalid"
13650
+ });
13651
+ throw error;
13652
+ }
12408
13653
  await this.options.store.save({
12409
13654
  version: 1,
12410
- enabled
12411
- });
12412
- if (enabled) await this.start();
12413
- else this.publish({
12414
- enabled: false,
12415
- state: "off"
13655
+ enabled: true
12416
13656
  });
13657
+ this.enabled = true;
13658
+ if (ownsResources) await this.stop();
13659
+ await this.start();
12417
13660
  });
12418
13661
  return this.status();
12419
13662
  }
@@ -12421,12 +13664,22 @@ var FunnelController = class {
12421
13664
  async reconnect() {
12422
13665
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
12423
13666
  await this.enqueue(async () => {
13667
+ try {
13668
+ await this.assertStateParents();
13669
+ } catch (error) {
13670
+ this.publish({
13671
+ enabled: this.enabled,
13672
+ state: "unavailable",
13673
+ errorCode: "funnel_state_invalid"
13674
+ });
13675
+ throw error;
13676
+ }
12424
13677
  if (!this.enabled) {
12425
- this.enabled = true;
12426
13678
  await this.options.store.save({
12427
13679
  version: 1,
12428
13680
  enabled: true
12429
13681
  });
13682
+ this.enabled = true;
12430
13683
  }
12431
13684
  await this.stop();
12432
13685
  await this.start();
@@ -12437,16 +13690,41 @@ var FunnelController = class {
12437
13690
  async reset() {
12438
13691
  if (!this.initialized || this.disposed) throw new Error("Funnel controller is unavailable");
12439
13692
  await this.enqueue(async () => {
12440
- await this.stop();
12441
- this.enabled = false;
13693
+ try {
13694
+ await assertManagedParents(this.ownedRoot, this.options.stateDirectory, "funnel");
13695
+ const selected = await lstat(this.options.stateDirectory).catch((error) => {
13696
+ if (error.code !== "ENOENT") throw error;
13697
+ });
13698
+ if (selected !== void 0 && (selected.isSymbolicLink() || !selected.isDirectory())) await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
13699
+ } catch (error) {
13700
+ this.publish({
13701
+ enabled: this.enabled,
13702
+ state: "off",
13703
+ errorCode: "funnel_state_invalid"
13704
+ });
13705
+ throw error;
13706
+ }
12442
13707
  await this.options.store.save({
12443
13708
  version: 1,
12444
13709
  enabled: false
12445
13710
  });
12446
- await rm(resolve(this.options.stateDirectory), {
12447
- recursive: true,
12448
- force: true
12449
- });
13711
+ this.enabled = false;
13712
+ await this.stop();
13713
+ try {
13714
+ await removeManagedTree(this.ownedRoot, this.options.stateDirectory, "funnel");
13715
+ await this.assertStateParents();
13716
+ await this.options.store.save({
13717
+ version: 1,
13718
+ enabled: false
13719
+ });
13720
+ } catch (error) {
13721
+ this.publish({
13722
+ enabled: false,
13723
+ state: "off",
13724
+ errorCode: "funnel_state_invalid"
13725
+ });
13726
+ throw error;
13727
+ }
12450
13728
  this.publish({
12451
13729
  enabled: false,
12452
13730
  state: "off"
@@ -12455,10 +13733,15 @@ var FunnelController = class {
12455
13733
  return this.status();
12456
13734
  }
12457
13735
  /** Stop all remote resources without modifying the remembered switch. */
12458
- async close() {
12459
- if (this.disposed) return;
13736
+ close() {
13737
+ if (this.closeTask !== void 0) return this.closeTask;
12460
13738
  this.disposed = true;
12461
- await this.enqueue(() => this.stop());
13739
+ const task = this.enqueue(() => this.stop());
13740
+ this.closeTask = task.catch((error) => {
13741
+ this.closeTask = void 0;
13742
+ throw error;
13743
+ });
13744
+ return this.closeTask;
12462
13745
  }
12463
13746
  enqueue(operation) {
12464
13747
  const task = this.queue.then(operation, operation);
@@ -12471,6 +13754,15 @@ var FunnelController = class {
12471
13754
  this.options.onStatus?.(this.status());
12472
13755
  } catch {}
12473
13756
  }
13757
+ async assertStateParents() {
13758
+ await assertManagedParents(this.ownedRoot, this.options.stateDirectory, "funnel");
13759
+ try {
13760
+ const entry = await lstat(this.options.stateDirectory);
13761
+ if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("funnel_path_invalid");
13762
+ } catch (error) {
13763
+ if (error.code !== "ENOENT") throw error;
13764
+ }
13765
+ }
12474
13766
  async start() {
12475
13767
  const generation = ++this.generation;
12476
13768
  let entry;
@@ -12492,6 +13784,17 @@ var FunnelController = class {
12492
13784
  });
12493
13785
  return;
12494
13786
  }
13787
+ try {
13788
+ await this.assertStateParents();
13789
+ await ensureManagedDirectory(this.ownedRoot, this.options.stateDirectory, "funnel");
13790
+ } catch (_error) {
13791
+ this.publish({
13792
+ enabled: true,
13793
+ state: "unavailable",
13794
+ errorCode: "funnel_state_invalid"
13795
+ });
13796
+ return;
13797
+ }
12495
13798
  this.buffer = "";
12496
13799
  this.publish({
12497
13800
  enabled: true,
@@ -12628,13 +13931,17 @@ var FunnelController = class {
12628
13931
  async stopProcessAndGateway() {
12629
13932
  this.clearStartTimer();
12630
13933
  const child = this.child;
12631
- this.child = void 0;
12632
13934
  const gateway = this.gatewayValue;
12633
- this.gatewayValue = void 0;
12634
13935
  await settleRemoteResources([async () => {
12635
13936
  child?.stdin.end();
12636
13937
  if (child !== void 0 && child.exitCode === null) await terminateRemoteProcess(child);
12637
- }, () => gateway?.close()], "Funnel resource cleanup failed");
13938
+ if (this.child === child) this.child = void 0;
13939
+ }, async () => {
13940
+ if (gateway !== void 0) {
13941
+ await gateway.close();
13942
+ if (this.gatewayValue === gateway) this.gatewayValue = void 0;
13943
+ }
13944
+ }], "Funnel resource cleanup failed");
12638
13945
  }
12639
13946
  clearStartTimer() {
12640
13947
  if (this.startTimer === void 0) return;
@@ -12760,8 +14067,8 @@ var CpolarController = class {
12760
14067
  if (options.region !== void 0 && !/^[a-z][a-z0-9_]{0,31}$/u.test(options.region)) throw new Error("cpolar region is invalid");
12761
14068
  }
12762
14069
  /** Restore the remembered cpolar switch independently from LAN and Funnel state. */
12763
- async initialize() {
12764
- const state = await this.options.store.load();
14070
+ async initialize(initialState) {
14071
+ const state = initialState ?? await this.options.store.load();
12765
14072
  this.enabled = state.enabled;
12766
14073
  this.initialized = true;
12767
14074
  if (this.enabled) await this.start();
@@ -12783,12 +14090,12 @@ var CpolarController = class {
12783
14090
  if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
12784
14091
  await this.enqueue(async () => {
12785
14092
  if (this.enabled === enabled && (enabled === false || this.child !== void 0)) return;
12786
- if (!enabled) await this.stop();
12787
- this.enabled = enabled;
12788
14093
  await this.options.store.save({
12789
14094
  version: 1,
12790
14095
  enabled
12791
14096
  });
14097
+ this.enabled = enabled;
14098
+ if (!enabled) await this.stop();
12792
14099
  if (enabled) await this.start();
12793
14100
  else this.publish({
12794
14101
  enabled: false,
@@ -12802,11 +14109,11 @@ var CpolarController = class {
12802
14109
  if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
12803
14110
  await this.enqueue(async () => {
12804
14111
  if (!this.enabled) {
12805
- this.enabled = true;
12806
14112
  await this.options.store.save({
12807
14113
  version: 1,
12808
14114
  enabled: true
12809
14115
  });
14116
+ this.enabled = true;
12810
14117
  }
12811
14118
  await this.stop();
12812
14119
  await this.start();
@@ -12817,12 +14124,12 @@ var CpolarController = class {
12817
14124
  async reset() {
12818
14125
  if (!this.initialized || this.disposed) throw new Error("cpolar controller is unavailable");
12819
14126
  await this.enqueue(async () => {
12820
- await this.stop();
12821
- this.enabled = false;
12822
14127
  await this.options.store.save({
12823
14128
  version: 1,
12824
14129
  enabled: false
12825
14130
  });
14131
+ this.enabled = false;
14132
+ await this.stop();
12826
14133
  this.publish({
12827
14134
  enabled: false,
12828
14135
  state: "off"
@@ -13147,20 +14454,19 @@ async function pathExists(path) {
13147
14454
  /** Replace only this version's verified binary, retaining the previous directory until promotion succeeds. */
13148
14455
  async function publishVerifiedCpolarExecutable(extracted, componentRoot, componentStorage, executableName, operations = {}) {
13149
14456
  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
- }));
14457
+ const move = async (from, to) => {
14458
+ await assertManagedParents(componentRoot, from, "cpolar");
14459
+ await assertManagedParents(componentRoot, to, "cpolar");
14460
+ if (operations.move === void 0) await renameManagedPath(componentRoot, from, to, "cpolar");
14461
+ else await operations.move(from, to);
14462
+ };
14463
+ const remove = operations.remove ?? ((path) => removeManagedTree(componentRoot, path, "cpolar"));
13155
14464
  const candidate = join(componentRoot, `.install-${randomBytes(12).toString("hex")}`);
13156
14465
  const backup = join(componentRoot, `.previous-${randomBytes(12).toString("hex")}`);
13157
14466
  let backupContainsPrevious = false;
13158
14467
  let replacementInstalled = false;
13159
14468
  try {
13160
- await mkdir(candidate, {
13161
- recursive: true,
13162
- mode: 448
13163
- });
14469
+ await ensureManagedDirectory(componentRoot, candidate, "cpolar");
13164
14470
  await copyFile(extracted, join(candidate, executableName));
13165
14471
  await chmod(join(candidate, executableName), 448);
13166
14472
  if (await pathExists(componentStorage)) {
@@ -13194,10 +14500,7 @@ async function publishVerifiedCpolarExecutable(extracted, componentRoot, compone
13194
14500
  }
13195
14501
  }
13196
14502
  /** 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
- })) {
14503
+ async function cleanupCpolarInstallStaging(staging, installFailed, remove = (path) => removeManagedTree(dirname(path), path, "cpolar")) {
13201
14504
  try {
13202
14505
  await remove(staging);
13203
14506
  } catch (error) {
@@ -13291,6 +14594,7 @@ var CpolarComponentManager = class {
13291
14594
  stateRoot;
13292
14595
  logRoot;
13293
14596
  stagingRoot;
14597
+ stateDirectory;
13294
14598
  platform;
13295
14599
  arch;
13296
14600
  release;
@@ -13301,8 +14605,9 @@ var CpolarComponentManager = class {
13301
14605
  errorCode;
13302
14606
  queue = Promise.resolve();
13303
14607
  constructor(options) {
14608
+ if (!isAbsolute(options.stateDirectory)) throw new Error("cpolar state directory must be absolute");
13304
14609
  const stateDirectory = resolve(options.stateDirectory);
13305
- if (!isAbsolute(stateDirectory)) throw new Error("cpolar state directory must be absolute");
14610
+ this.stateDirectory = stateDirectory;
13306
14611
  this.platform = options.platform ?? process.platform;
13307
14612
  this.arch = options.arch ?? process.arch;
13308
14613
  this.release = CPOLAR_COMPONENT_RELEASES[`${this.platform}-${this.arch}`];
@@ -13330,13 +14635,24 @@ var CpolarComponentManager = class {
13330
14635
  /** Inspect the managed binary and configuration without using global cpolar state. */
13331
14636
  async initialize() {
13332
14637
  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) {
14638
+ this.installed = false;
14639
+ this.configured = false;
14640
+ this.errorCode = void 0;
14641
+ try {
14642
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
14643
+ await assertManagedParents(this.stateDirectory, this.configFile, "cpolar");
14644
+ this.installed = release !== void 0 && await regularFile(this.executable, release.executableBytes);
14645
+ if (this.installed && release !== void 0 && await sha256(this.executable) !== release.executableSha256) {
14646
+ this.installed = false;
14647
+ this.errorCode = "cpolar_component_invalid";
14648
+ }
14649
+ this.configured = await regularFile(this.configFile);
14650
+ if (this.configured) await restrictPrivateFile(this.configFile);
14651
+ } catch (_error) {
13335
14652
  this.installed = false;
14653
+ this.configured = false;
13336
14654
  this.errorCode = "cpolar_component_invalid";
13337
14655
  }
13338
- this.configured = await regularFile(this.configFile);
13339
- if (this.configured) await restrictPrivateFile(this.configFile);
13340
14656
  }
13341
14657
  /** Return a safe status that never includes the account token. */
13342
14658
  status() {
@@ -13362,11 +14678,9 @@ var CpolarComponentManager = class {
13362
14678
  return this.enqueue(async () => {
13363
14679
  const release = this.release;
13364
14680
  if (release === void 0) throw new Error("cpolar_component_unsupported");
14681
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
13365
14682
  const staging = await installStep("cpolar_storage_failed", async () => {
13366
- await mkdir(this.stagingRoot, {
13367
- recursive: true,
13368
- mode: 448
13369
- });
14683
+ await ensureManagedDirectory(this.stateDirectory, this.stagingRoot, "cpolar");
13370
14684
  return mkdtemp(join(this.stagingRoot, "install-"));
13371
14685
  });
13372
14686
  let installFailed = false;
@@ -13394,19 +14708,24 @@ var CpolarComponentManager = class {
13394
14708
  await installStep("cpolar_extract_failed", () => this.extractArtifact(archive, staging));
13395
14709
  const extracted = join(staging, release.executableName);
13396
14710
  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));
14711
+ await installStep("cpolar_storage_failed", async () => {
14712
+ await ensureManagedDirectory(this.stateDirectory, this.componentRoot, "cpolar");
14713
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
14714
+ await publishVerifiedCpolarExecutable(extracted, this.componentRoot, this.componentStorage, release.executableName);
14715
+ });
13398
14716
  this.installed = true;
13399
14717
  this.errorCode = void 0;
13400
14718
  } catch (error) {
13401
14719
  installFailed = true;
13402
14720
  try {
14721
+ await assertManagedParents(this.stateDirectory, this.executable, "cpolar");
13403
14722
  this.installed = await regularFile(this.executable, release.executableBytes) && await sha256(this.executable) === release.executableSha256;
13404
14723
  } catch (_inspectionError) {
13405
14724
  this.installed = false;
13406
14725
  }
13407
14726
  throw error;
13408
14727
  } finally {
13409
- await cleanupCpolarInstallStaging(staging, installFailed);
14728
+ await cleanupCpolarInstallStaging(staging, installFailed, (path) => removeManagedTree(this.stateDirectory, path, "cpolar"));
13410
14729
  }
13411
14730
  });
13412
14731
  }
@@ -13414,24 +14733,8 @@ var CpolarComponentManager = class {
13414
14733
  configure(authtoken) {
13415
14734
  return this.enqueue(async () => {
13416
14735
  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
14736
  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
- }
14737
+ await writeManagedPrivateFile(this.stateDirectory, this.configFile, body, "cpolar");
13435
14738
  this.configured = true;
13436
14739
  this.errorCode = void 0;
13437
14740
  });
@@ -13439,24 +14742,12 @@ var CpolarComponentManager = class {
13439
14742
  /** Remove every cpolar file owned by DSH Mobile without touching global state. */
13440
14743
  purge() {
13441
14744
  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
- ]);
14745
+ await removeManagedPaths(this.stateDirectory, [
14746
+ this.componentRoot,
14747
+ this.stateRoot,
14748
+ this.logRoot,
14749
+ this.stagingRoot
14750
+ ], "cpolar");
13460
14751
  this.installed = false;
13461
14752
  this.configured = false;
13462
14753
  this.errorCode = void 0;
@@ -13470,82 +14761,25 @@ var CpolarComponentManager = class {
13470
14761
  };
13471
14762
  //#endregion
13472
14763
  //#region src/caddy-files.ts
13473
- /** Refuse link-shaped ancestors within the owned state root before filesystem operations. */
14764
+ /** Refuse link-shaped ancestors within Caddy's owned state root. */
13474
14765
  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
- }
14766
+ await assertManagedParents(root, target, "caddy");
14767
+ }
14768
+ /** Move a Caddy-owned path, preserving bounded Windows retries and ownership checks. */
14769
+ async function renameCaddyPath(root, source, destination) {
14770
+ await renameManagedPath(root, source, destination, "caddy");
13495
14771
  }
13496
- /** Create a private real directory, never traversing a link owned by the component. */
14772
+ /** Create a real Caddy directory, securing fresh directories and preserving existing ACLs. */
13497
14773
  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);
14774
+ await ensureManagedDirectory(root, directory, "caddy");
13506
14775
  }
13507
- /** Atomically replace one private configuration file under a real owned directory. */
14776
+ /** Atomically replace one private Caddy configuration file. */
13508
14777
  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
- }
14778
+ await writeManagedPrivateFile(root, file, body, "caddy");
13528
14779
  }
13529
- /** Delete only owned entries; junctions and symlinks are unlinked, never descended into. */
14780
+ /** Delete only Caddy-owned children; unlink junctions and symlinks themselves. */
13530
14781
  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);
14782
+ await removeManagedTree(root, target, "caddy");
13549
14783
  }
13550
14784
  //#endregion
13551
14785
  //#region src/caddy-component.ts
@@ -13622,7 +14856,7 @@ var CaddyComponentManager = class {
13622
14856
  errorPrefix: "caddy"
13623
14857
  }));
13624
14858
  this.inspectExecutable = options.inspectExecutable ?? inspect;
13625
- this.promote = options.promoteDirectory ?? rename;
14859
+ this.promote = options.promoteDirectory ?? ((source, destination) => renameCaddyPath(this.stateDirectory, source, destination));
13626
14860
  }
13627
14861
  /** Recheck a pinned executable before launching; no unverified local binary can run. */
13628
14862
  async ensureExecutable() {
@@ -13673,6 +14907,7 @@ var CaddyComponentManager = class {
13673
14907
  const backup = join(this.componentRoot, ".previous-" + randomBytes(12).toString("hex"));
13674
14908
  let previous = false;
13675
14909
  let promoted = false;
14910
+ let failed = false;
13676
14911
  try {
13677
14912
  const controller = new AbortController();
13678
14913
  const timeout = setTimeout(() => {
@@ -13699,7 +14934,7 @@ var CaddyComponentManager = class {
13699
14934
  try {
13700
14935
  const entry = await lstat(this.componentStorage);
13701
14936
  if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("caddy_path_invalid");
13702
- await rename(this.componentStorage, backup);
14937
+ await renameCaddyPath(this.stateDirectory, this.componentStorage, backup);
13703
14938
  previous = true;
13704
14939
  } catch (error) {
13705
14940
  if (error.code !== "ENOENT") throw error;
@@ -13708,18 +14943,33 @@ var CaddyComponentManager = class {
13708
14943
  await this.promote(candidate, this.componentStorage);
13709
14944
  promoted = true;
13710
14945
  } catch (error) {
13711
- if (previous) {
13712
- await rename(backup, this.componentStorage);
14946
+ if (previous) try {
14947
+ await renameCaddyPath(this.stateDirectory, backup, this.componentStorage);
13713
14948
  previous = false;
14949
+ } catch (rollbackError) {
14950
+ this.installed = false;
14951
+ this.errorCode = "caddy_component_invalid";
14952
+ throw new AggregateError([error, rollbackError], "caddy_component_rollback_failed", { cause: error });
13714
14953
  }
13715
14954
  throw error;
13716
14955
  }
13717
14956
  this.installed = true;
13718
14957
  this.errorCode = void 0;
14958
+ } catch (error) {
14959
+ failed = true;
14960
+ throw error;
13719
14961
  } finally {
13720
- await removeCaddyTree(this.stateDirectory, staging);
13721
- await removeCaddyTree(this.stateDirectory, candidate);
13722
- if (previous && promoted) await removeCaddyTree(this.stateDirectory, backup);
14962
+ let cleanupError;
14963
+ for (const path of [
14964
+ staging,
14965
+ candidate,
14966
+ ...previous && promoted ? [backup] : []
14967
+ ]) try {
14968
+ await removeCaddyTree(this.stateDirectory, path);
14969
+ } catch (error) {
14970
+ cleanupError ??= error;
14971
+ }
14972
+ if (!failed && cleanupError !== void 0) throw cleanupError;
13723
14973
  }
13724
14974
  });
13725
14975
  }
@@ -14321,12 +15571,12 @@ var ManagedOriginController = class {
14321
15571
  gateway() {
14322
15572
  return this.enabled ? this.controller().gateway() : void 0;
14323
15573
  }
14324
- initialize() {
15574
+ initialize(initialState) {
14325
15575
  return this.enqueue(async () => {
14326
15576
  if (this.initialized) return;
14327
15577
  if (this.disposed) throw new Error("origin_controller_unavailable");
14328
15578
  this.selected = (await this.options.modeStore.load()).mode;
14329
- this.enabled = (await this.options.store.load()).enabled;
15579
+ this.enabled = (initialState ?? await this.options.store.load()).enabled;
14330
15580
  await this.options.external.initialize();
14331
15581
  await this.options.managed.initialize();
14332
15582
  await this.options.external.setEnabled(false);
@@ -14365,11 +15615,11 @@ var ManagedOriginController = class {
14365
15615
  if (!enabled) this.options.managed.cancelPendingStart?.();
14366
15616
  return this.enqueue(async () => {
14367
15617
  this.assertAvailable();
14368
- if (!enabled) await this.stopBoth();
14369
15618
  await this.options.store.save({
14370
15619
  version: 1,
14371
15620
  enabled
14372
15621
  });
15622
+ if (!enabled) await this.stopBoth();
14373
15623
  this.enabled = enabled;
14374
15624
  if (enabled && !this.disposed) try {
14375
15625
  await this.controller().setEnabled(true);
@@ -14383,11 +15633,11 @@ var ManagedOriginController = class {
14383
15633
  this.options.managed.cancelPendingStart?.();
14384
15634
  return this.enqueue(async () => {
14385
15635
  this.assertAvailable();
14386
- await this.stopBoth();
14387
15636
  await this.options.store.save({
14388
15637
  version: 1,
14389
15638
  enabled: true
14390
15639
  });
15640
+ await this.stopBoth();
14391
15641
  this.enabled = true;
14392
15642
  if (!this.disposed) try {
14393
15643
  await this.controller().reconnect();
@@ -14832,24 +16082,69 @@ var PluginReleaseManager = class {
14832
16082
  //#endregion
14833
16083
  //#region src/file-logger.ts
14834
16084
  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) {
16085
+ const MAX_QUEUED_BYTES = 262144;
16086
+ const PATH_PREFIX = "mobile_log";
16087
+ async function regularLogFile(file) {
16088
+ try {
16089
+ const entry = await lstat(file);
16090
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("mobile_log_target_invalid");
16091
+ return entry;
16092
+ } catch (error) {
16093
+ if (error.code === "ENOENT") return void 0;
16094
+ throw error;
16095
+ }
16096
+ }
16097
+ /** Open a private append-only file before registering a safely disposable Cordis exporter. */
16098
+ async function installMobileFileLogger(ctx, stateDirectory, options = {}) {
14837
16099
  const directory = join(stateDirectory, "logs");
14838
16100
  const file = join(directory, "dsh-mobile.log");
14839
16101
  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);
16102
+ await ensureManagedDirectory(stateDirectory, directory, PATH_PREFIX);
16103
+ const entry = await regularLogFile(file);
16104
+ if (entry !== void 0 && entry.size >= MAX_LOG_BYTES) {
16105
+ await regularLogFile(previous);
16106
+ await removeManagedTree(stateDirectory, previous, PATH_PREFIX);
16107
+ await renameManagedPath(stateDirectory, file, previous, PATH_PREFIX);
16108
+ await regularLogFile(previous);
16109
+ await restrictPrivateFile(previous);
16110
+ }
16111
+ await assertManagedParents(stateDirectory, file, PATH_PREFIX);
16112
+ await regularLogFile(file);
16113
+ const handle = await open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0), 384);
16114
+ let stream;
16115
+ try {
16116
+ const opened = await handle.stat();
16117
+ const actual = await regularLogFile(file);
16118
+ if (!opened.isFile() || actual === void 0 || actual.dev !== opened.dev || actual.ino !== opened.ino) throw new Error("mobile_log_target_invalid");
16119
+ await assertManagedParents(stateDirectory, file, PATH_PREFIX);
16120
+ await handle.chmod(384);
16121
+ await restrictPrivateFile(file);
16122
+ stream = options.createStream?.(handle) ?? handle.createWriteStream({
16123
+ encoding: "utf8",
16124
+ autoClose: true
16125
+ });
16126
+ } catch (error) {
16127
+ await handle.close();
16128
+ throw error;
14848
16129
  }
14849
- const stream = createWriteStream(file, {
14850
- flags: "a",
14851
- encoding: "utf8",
14852
- mode: 384
16130
+ let stopped = false;
16131
+ let removeExporter;
16132
+ const stopLogging = (error) => {
16133
+ if (stopped) return;
16134
+ stopped = true;
16135
+ try {
16136
+ Promise.resolve(removeExporter?.()).catch(() => void 0);
16137
+ } catch (disposeError) {}
16138
+ stream.destroy();
16139
+ const candidate = error;
16140
+ const code = candidate !== null && typeof candidate === "object" && typeof candidate.code === "string" ? candidate.code : "write_failed";
16141
+ try {
16142
+ ctx.logger("dsh-mobile").warn("file logging disabled (%s); other logging remains available", code);
16143
+ } catch (loggerError) {}
16144
+ };
16145
+ stream.on("error", stopLogging);
16146
+ const closed = finished$1(stream, { cleanup: true }).catch((error) => {
16147
+ stopLogging(error);
14853
16148
  });
14854
16149
  const exporter = {
14855
16150
  colors: false,
@@ -14859,20 +16154,40 @@ async function installMobileFileLogger(ctx, stateDirectory) {
14859
16154
  "dsh-mobile": 3
14860
16155
  },
14861
16156
  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`);
16157
+ if (stopped || message.name !== "dsh-mobile") return;
16158
+ try {
16159
+ const record = {
16160
+ timestamp: new Date(message.ts).toISOString(),
16161
+ level: message.type,
16162
+ logger: message.name,
16163
+ message: Logger.format(exporter, message)
16164
+ };
16165
+ const line = `${JSON.stringify(record)}\n`;
16166
+ if (stream.writableLength + Buffer.byteLength(line) > MAX_QUEUED_BYTES) {
16167
+ stopLogging(Object.assign(/* @__PURE__ */ new Error("file log queue exhausted"), { code: "backpressure" }));
16168
+ return;
16169
+ }
16170
+ stream.write(line);
16171
+ } catch (error) {
16172
+ stopLogging(error);
16173
+ }
14870
16174
  }
14871
16175
  };
14872
- ctx.logger.exporter(exporter);
14873
- ctx.effect(() => () => {
14874
- stream.end();
14875
- }, "dsh-mobile file logger");
16176
+ try {
16177
+ removeExporter = ctx.logger.exporter(exporter);
16178
+ ctx.effect(() => async () => {
16179
+ stopped = true;
16180
+ await removeExporter?.();
16181
+ stream.end();
16182
+ await closed;
16183
+ }, "dsh-mobile file logger");
16184
+ } catch (error) {
16185
+ stopped = true;
16186
+ stream.destroy();
16187
+ await closed;
16188
+ await removeExporter?.();
16189
+ throw error;
16190
+ }
14876
16191
  return file;
14877
16192
  }
14878
16193
  //#endregion
@@ -15034,6 +16349,7 @@ function installedDshVersion() {
15034
16349
  }
15035
16350
  function mapAdminError(error) {
15036
16351
  if (error instanceof HttpError) return error;
16352
+ if (error instanceof MobileExtensionError) return new HttpError(error.status, error.code);
15037
16353
  const code = error.code;
15038
16354
  if (error instanceof Error && error.message.includes("spawn UNKNOWN")) return new HttpError(409, "frp_component_launch_failed");
15039
16355
  if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
@@ -15210,10 +16526,14 @@ function frpIngressGatewayConfig(template, settings, stateFile, ingress, listenP
15210
16526
  * An unreadable self-signed CA fails explicitly: falling back to the system
15211
16527
  * trust store would hide a broken pairing identity behind a start timeout.
15212
16528
  */
15213
- async function readFrpIngressTrustAnchor(settings, stateFile) {
16529
+ async function readFrpIngressTrustAnchor(settings, stateFile, ownedRoot = dirname(stateFile)) {
15214
16530
  if (!isFrpSelfSignedIngress(settings)) return void 0;
15215
16531
  try {
15216
- return await readFile(frpIngressPaths(stateFile).caCertFile, "utf8");
16532
+ const file = frpIngressPaths(stateFile).caCertFile;
16533
+ await assertManagedParents(ownedRoot, file, "frp_ingress");
16534
+ const entry = await lstat(file);
16535
+ if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("frp_ingress_file_invalid");
16536
+ return await readFile(file, "utf8");
15217
16537
  } catch (error) {
15218
16538
  throw new Error("frp_ingress_ca_invalid", { cause: error });
15219
16539
  }
@@ -15306,15 +16626,15 @@ async function apply(ctx, config) {
15306
16626
  const frpComponent = new FrpComponentManager({ stateDirectory });
15307
16627
  await frpComponent.initialize();
15308
16628
  const frpIdentity = await loadOrCreateInstallationId(join(stateDirectory, "installation-id"));
15309
- const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity));
16629
+ const frpConfig = new FrpConfigStore(join(remoteDirectory, "frp", "config"), frpProxyName(frpIdentity), stateDirectory);
15310
16630
  await frpConfig.initialize();
15311
- const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"));
16631
+ const originConfig = new OriginConfigStore(join(remoteDirectory, "origin", "config"), stateDirectory);
15312
16632
  await originConfig.initialize();
15313
16633
  const caddyComponent = new CaddyComponentManager({ stateDirectory });
15314
16634
  await caddyComponent.initialize();
15315
16635
  const caddyConfig = new CaddyConfigStore(stateDirectory);
15316
16636
  await caddyConfig.initialize();
15317
- const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"));
16637
+ const cloudflaredTunnel = new CloudflaredTunnelStore(join(remoteDirectory, "cloudflared"), stateDirectory);
15318
16638
  await cloudflaredTunnel.initialize();
15319
16639
  const unregisterBuiltin = mobileAccess.registerExtension({
15320
16640
  schemaVersion: 1,
@@ -15433,7 +16753,7 @@ async function apply(ctx, config) {
15433
16753
  */
15434
16754
  const createFrpGateway = async (publicOrigin, settings) => {
15435
16755
  if (!isFrpSelfSignedIngress(settings)) return createRemoteGateway(publicOrigin);
15436
- const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile);
16756
+ const ingress = await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), void 0, stateDirectory);
15437
16757
  const resolved = frpIngressGatewayConfig(template, settings, remoteDeviceFile, ingress);
15438
16758
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices), mobileAccess, upstreamLoginUrl, webSocketPaths, blockedUpgradePaths, void 0, clientModulePreferences);
15439
16759
  try {
@@ -15482,6 +16802,7 @@ async function apply(ctx, config) {
15482
16802
  store: tailscaleStore,
15483
16803
  executable: funnelExecutable(import.meta.url),
15484
16804
  stateDirectory: join(remoteDirectory, "tailscale"),
16805
+ ownedRoot: stateDirectory,
15485
16806
  hostname: `dsh-${instanceId.slice(0, 12)}`,
15486
16807
  createGateway: createRemoteGateway
15487
16808
  }),
@@ -15509,9 +16830,9 @@ async function apply(ctx, config) {
15509
16830
  * Only this module knows where the ingress material lives, so the anchor is
15510
16831
  * read here from the ingress directory instead of being guessed downstream.
15511
16832
  */
15512
- resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile),
16833
+ resolveDiscoveryTrustAnchor: (settings) => readFrpIngressTrustAnchor(settings, remoteDeviceFile, stateDirectory),
15513
16834
  maintainIngressCertificate: async (settings, gateway) => {
15514
- await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId);
16835
+ await ensureFrpIngressCertificate(settings, remoteDeviceFile, Date.now(), gateway.config.instanceId, stateDirectory);
15515
16836
  await gateway.refreshProvidedTls();
15516
16837
  }
15517
16838
  }),
@@ -15590,7 +16911,7 @@ async function apply(ctx, config) {
15590
16911
  const frpGateway = remoteControllers.frp.gateway();
15591
16912
  const pinnedFrpProbe = frpSettings !== void 0 && isFrpSelfSignedIngress(frpSettings) && frpGateway !== void 0 && frpOrigin !== void 0 ? async () => {
15592
16913
  try {
15593
- const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile);
16914
+ const trustAnchorPem = await readFrpIngressTrustAnchor(frpSettings, remoteDeviceFile, stateDirectory);
15594
16915
  if (trustAnchorPem === void 0) return { state: "unreachable" };
15595
16916
  const started = performance.now();
15596
16917
  return await defaultProbeDiscovery({
@@ -15713,6 +17034,17 @@ async function apply(ctx, config) {
15713
17034
  sendJson(response, 200, await releaseManager.status(), false);
15714
17035
  return;
15715
17036
  }
17037
+ if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/extensions/hosts`) {
17038
+ sendJson(response, 200, mobileAccess.hostStatus(), false);
17039
+ return;
17040
+ }
17041
+ if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/extensions/recover`) {
17042
+ const body = await readJsonObject(request, 4096);
17043
+ if (typeof body.id !== "string" || body.confirm !== true) throw new HttpError(400, "bad_request");
17044
+ await mobileAccess.recoverExtension(body.id);
17045
+ sendJson(response, 200, mobileAccess.hostStatus(), false);
17046
+ return;
17047
+ }
15716
17048
  if (request.method === "POST" && target.decodedPathname === `/api/mobile-access/release/update`) {
15717
17049
  await readJsonObject(request, 4096);
15718
17050
  sendJson(response, 200, await releaseManager.update(), false);
@@ -15946,7 +17278,7 @@ async function apply(ctx, config) {
15946
17278
  if (request.method === "GET" && target.decodedPathname === `/api/mobile-access/remote/frp/self-check`) {
15947
17279
  const settings = frpConfig.settings();
15948
17280
  if (settings === void 0) throw new HttpError(409, "frp_config_missing");
15949
- const check = await frpIngressSelfCheck(settings, remoteDeviceFile);
17281
+ const check = await frpIngressSelfCheck(settings, remoteDeviceFile, stateDirectory);
15950
17282
  const entry = resolveFrpEntryProbe(settings);
15951
17283
  const [frpsReachable, entryReachable] = await Promise.all([probeTcpReachable(settings.serverAddress, settings.serverPort), probeTcpReachable(entry.host, entry.port)]);
15952
17284
  sendJson(response, 200, {
@@ -16061,10 +17393,10 @@ async function apply(ctx, config) {
16061
17393
  if ((await readJsonObject(request, 4096)).confirm !== true) throw new HttpError(400, "bad_request");
16062
17394
  await remoteProviders.mutate(async () => {
16063
17395
  await remoteControllers.frp.setEnabled(false);
16064
- await Promise.all([
16065
- frpComponent.purge(),
16066
- frpConfig.purge(),
16067
- purgeFrpIngressCertificates(remoteDeviceFile)
17396
+ await settleCleanupSteps([
17397
+ () => frpComponent.purge().then(() => void 0),
17398
+ () => frpConfig.purge().then(() => void 0),
17399
+ () => purgeFrpIngressCertificates(remoteDeviceFile, stateDirectory)
16068
17400
  ]);
16069
17401
  });
16070
17402
  sendJson(response, 200, remotePayload(), false);
@@ -16130,15 +17462,23 @@ async function apply(ctx, config) {
16130
17462
  kind: "error",
16131
17463
  text: "请带上需求,例如:/mobile 把手机端改成深色主题"
16132
17464
  };
17465
+ const hostStates = new Map(mobileAccess.hostStatus().hosts.map((host) => [host.id, host]));
16133
17466
  const guide = buildMobileGuide({
16134
17467
  directory: stateDirectory,
16135
17468
  hasCustomCss: await existsRegularFile(template.customCssFile),
16136
17469
  hasCustomJs: await existsRegularFile(template.customScriptFile),
16137
- extensions: mobileAccess.manifest().map((entry) => ({
16138
- id: entry.id,
16139
- name: entry.name,
16140
- version: entry.version
16141
- })),
17470
+ extensions: mobileAccess.manifest().map((entry) => {
17471
+ const host = hostStates.get(entry.id);
17472
+ return {
17473
+ id: entry.id,
17474
+ name: entry.name,
17475
+ version: entry.version,
17476
+ ...host === void 0 ? {} : {
17477
+ executionMode: host.mode,
17478
+ executionState: host.state
17479
+ }
17480
+ };
17481
+ }),
16142
17482
  failedExtensionCount: mobileAccess.status().failed
16143
17483
  });
16144
17484
  agent.steer(createUserMessage({
@@ -16159,7 +17499,7 @@ async function apply(ctx, config) {
16159
17499
  }
16160
17500
  });
16161
17501
  try {
16162
- await mobileAccess.startLocal(template.extensionsDir, ctx);
17502
+ await mobileAccess.startLocal(template.extensionsDir, ctx, { hostExecution: template.hostExecution });
16163
17503
  await lanController.initialize();
16164
17504
  const stores = {
16165
17505
  tailscale: tailscaleStore,
@@ -16168,8 +17508,9 @@ async function apply(ctx, config) {
16168
17508
  frp: frpStore,
16169
17509
  origin: originStore
16170
17510
  };
16171
- await Promise.all(Object.keys(stores).filter((provider) => provider !== remoteProviders.selected).map((provider) => stores[provider].disableIfPresent()));
16172
- for (const provider of REMOTE_PROVIDERS) await remoteControllers[provider].initialize();
17511
+ const prepared = await prepareDisabledRemoteProviders(remoteProviders.selected, stores);
17512
+ for (const provider of REMOTE_PROVIDERS) if (provider === remoteProviders.selected) await remoteControllers[provider].initialize();
17513
+ else await remoteControllers[provider].initialize(prepared.get(provider));
16173
17514
  const lanDiscovery = lanGateway?.discoveryStatus();
16174
17515
  if (lanDiscovery !== void 0 && !lanDiscovery.broadcast) logger.warn("broadcast discovery is unavailable (%s); mDNS is still published, so pair manually or free the UDP port", lanDiscovery.errorCode ?? "unknown_cause");
16175
17516
  if (lanDiscovery?.mobileAssetsErrorCode !== void 0) logger.warn("a bundled mobile asset is missing (%s); the phone frontend will serve without it", lanDiscovery.mobileAssetsErrorCode);
@@ -16211,6 +17552,6 @@ async function apply(ctx, config) {
16211
17552
  }, "dsh-mobile: independent LAN and selectable remote providers with /mobile command");
16212
17553
  }
16213
17554
  //#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 };
17555
+ 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
17556
 
16216
17557
  //# sourceMappingURL=index.mjs.map