dsh-mobile 0.1.0-alpha.2 → 0.1.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1,12 +1,20 @@
1
- import { X509Certificate, createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
1
+ import { createRequire } from "node:module";
2
+ import { X509Certificate, createHash, createPrivateKey, createPublicKey, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
3
5
  import z from "@deepseek-ai/schemastery";
4
6
  import { connect, isIP } from "node:net";
5
- import { chmod, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
7
+ import { chmod, lstat, mkdir, opendir, readFile, rename, rm, writeFile } from "node:fs/promises";
8
+ import { createSocket } from "node:dgram";
9
+ import { homedir, hostname, networkInterfaces } from "node:os";
6
10
  import { createServer, request } from "node:http";
7
11
  import { createServer as createServer$1 } from "node:https";
8
12
  import { Transform } from "node:stream";
9
13
  import { pipeline } from "node:stream/promises";
14
+ import Bonjour from "bonjour-service";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import { generate } from "selfsigned";
10
18
  //#region src/access.ts
11
19
  /** Stable error categories converted to deliberately terse HTTP responses. */
12
20
  var AccessError = class extends Error {
@@ -541,6 +549,9 @@ const Config = z.object({
541
549
  controlFile: z.string().hidden().required(),
542
550
  customCssFile: z.string().hidden(),
543
551
  customScriptFile: z.string().hidden(),
552
+ mobileLayoutFile: z.string().hidden(),
553
+ instanceId: z.string().hidden(),
554
+ pairingCaFile: z.string().hidden(),
544
555
  initiallyEnabled: z.boolean().hidden().required(),
545
556
  tls: z.object({
546
557
  mode: z.union([z.const("provided"), z.const("disabled")]),
@@ -662,6 +673,11 @@ function parseGatewayConfig(raw) {
662
673
  stateFile: absoluteFile(value.stateFile, "stateFile"),
663
674
  customCssFile: value.customCssFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.css") : absoluteFile(value.customCssFile, "customCssFile"),
664
675
  customScriptFile: value.customScriptFile === void 0 ? join(dirname(absoluteFile(value.stateFile, "stateFile")), "mobile.js") : absoluteFile(value.customScriptFile, "customScriptFile"),
676
+ mobileLayoutFile: value.mobileLayoutFile === void 0 ? fileURLToPath(new URL("./mobile-layout.js", import.meta.url)) : absoluteFile(value.mobileLayoutFile, "mobileLayoutFile"),
677
+ instanceId: value.instanceId === void 0 ? createHash("sha256").update(absoluteFile(value.stateFile, "stateFile")).digest("hex") : /^[a-f\d]{64}$/u.test(value.instanceId) ? value.instanceId : (() => {
678
+ throw new Error("instanceId must be a lowercase SHA-256 value");
679
+ })(),
680
+ ...value.pairingCaFile === void 0 ? {} : { pairingCaFile: absoluteFile(value.pairingCaFile, "pairingCaFile") },
665
681
  tls,
666
682
  pairingTtlMs: integer(value.pairingTtlMs, "pairingTtlMs", 12e4, 1e4, 6e5),
667
683
  deviceTtlMs,
@@ -679,7 +695,73 @@ function parseGatewayConfig(raw) {
679
695
  });
680
696
  }
681
697
  //#endregion
698
+ //#region src/compatibility.ts
699
+ /** DeepSeek Harness prereleases verified by this plugin release. */
700
+ const SUPPORTED_DSH_VERSIONS = Object.freeze(["0.1.0-rc.5", "0.1.0-rc.6"]);
701
+ /**
702
+ * Reject an unverified DeepSeek Harness Host before opening the LAN listener.
703
+ * @param version - Version reported by the installed DSH WebServer package.
704
+ */
705
+ function assertSupportedDshVersion(version) {
706
+ if (typeof version === "string" && SUPPORTED_DSH_VERSIONS.some((candidate) => candidate === version)) return;
707
+ throw new Error(`unsupported DeepSeek Harness version ${typeof version === "string" ? version : "(unknown)"}; supported versions: ${SUPPORTED_DSH_VERSIONS.join(", ")}`);
708
+ }
709
+ //#endregion
682
710
  //#region src/control.ts
711
+ /** Keeps one runtime aligned with a changing network selection. */
712
+ var FollowingMobileAccessRuntime = class {
713
+ select;
714
+ onRefreshError;
715
+ runtime;
716
+ key;
717
+ queue = Promise.resolve();
718
+ closed = false;
719
+ timer;
720
+ constructor(select, onRefreshError) {
721
+ this.select = select;
722
+ this.onRefreshError = onRefreshError;
723
+ }
724
+ /** Start the current selection and optionally poll for later changes. */
725
+ async initialize(refreshIntervalMs) {
726
+ await this.refresh();
727
+ if (refreshIntervalMs === void 0) return;
728
+ this.timer = setInterval(() => {
729
+ this.refresh().catch(this.onRefreshError);
730
+ }, refreshIntervalMs);
731
+ this.timer.unref();
732
+ }
733
+ /** Reconcile the active runtime with the latest selection. */
734
+ refresh() {
735
+ return this.enqueue(async () => {
736
+ if (this.closed) return;
737
+ const selection = await this.select();
738
+ if (this.closed || this.runtime !== void 0 && this.key === selection.key) return;
739
+ const previous = this.runtime;
740
+ this.runtime = void 0;
741
+ this.key = void 0;
742
+ if (previous !== void 0) await previous.close();
743
+ this.runtime = await selection.start();
744
+ this.key = selection.key;
745
+ });
746
+ }
747
+ /** Stop polling and close the most recently selected runtime. */
748
+ close() {
749
+ if (this.closed) return this.queue;
750
+ this.closed = true;
751
+ if (this.timer !== void 0) clearInterval(this.timer);
752
+ return this.enqueue(async () => {
753
+ const runtime = this.runtime;
754
+ this.runtime = void 0;
755
+ this.key = void 0;
756
+ if (runtime !== void 0) await runtime.close();
757
+ });
758
+ }
759
+ enqueue(operation) {
760
+ const run = this.queue.then(operation, operation);
761
+ this.queue = run.then(() => {}, () => {});
762
+ return run;
763
+ }
764
+ };
683
765
  /** Validate control state loaded across the filesystem boundary. */
684
766
  function parseMobileAccessControlState(value) {
685
767
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile-access control state must be an object");
@@ -889,7 +971,7 @@ function setSecurityHeaders(response, tls) {
889
971
  "object-src 'none'",
890
972
  "frame-ancestors 'none'",
891
973
  "form-action 'self'",
892
- "script-src 'self' 'unsafe-inline'",
974
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
893
975
  "style-src 'self' 'unsafe-inline'",
894
976
  "img-src 'self' data: blob:",
895
977
  "font-src 'self' data:",
@@ -1010,9 +1092,92 @@ function assertLocalAdminTrust(request, requireBrowserOrigin) {
1010
1092
  if (requireBrowserOrigin && site !== void 0 && (origin === void 0 || site !== "same-origin")) throw new HttpError(403, "forbidden");
1011
1093
  }
1012
1094
  //#endregion
1095
+ //#region src/computer-images.ts
1096
+ const MAX_ENTRIES = 500;
1097
+ const MAX_IMAGE_BYTES = 20971520;
1098
+ const IMAGE_TYPES = Object.freeze({
1099
+ ".gif": "image/gif",
1100
+ ".jpeg": "image/jpeg",
1101
+ ".jpg": "image/jpeg",
1102
+ ".png": "image/png",
1103
+ ".webp": "image/webp"
1104
+ });
1105
+ /** Normalize an optional mobile-browser path without rebasing relative input. */
1106
+ function resolveComputerImagePath(path) {
1107
+ if (path === null || path === "") return homedir();
1108
+ if (!isAbsolute(path) || path.includes("\0")) throw new HttpError(400, "bad_path");
1109
+ return resolve(path);
1110
+ }
1111
+ /** List folders and supported image files without following symbolic links. */
1112
+ async function listComputerImages(path) {
1113
+ const target = resolveComputerImagePath(path);
1114
+ const rows = [];
1115
+ let truncated = false;
1116
+ let directory;
1117
+ try {
1118
+ directory = await opendir(target);
1119
+ for await (const entry of directory) {
1120
+ if (entry.isSymbolicLink()) continue;
1121
+ const kind = entry.isDirectory() ? "directory" : IMAGE_TYPES[extname(entry.name).toLowerCase()] === void 0 ? void 0 : "image";
1122
+ if (kind === void 0) continue;
1123
+ if (rows.length === MAX_ENTRIES) {
1124
+ truncated = true;
1125
+ break;
1126
+ }
1127
+ rows.push({
1128
+ kind,
1129
+ name: entry.name,
1130
+ path: resolve(target, entry.name)
1131
+ });
1132
+ }
1133
+ } catch {
1134
+ throw new HttpError(404, "directory_unavailable");
1135
+ } finally {
1136
+ await directory?.close().catch(() => void 0);
1137
+ }
1138
+ rows.sort((left, right) => left.kind === right.kind ? left.name.localeCompare(right.name) : left.kind === "directory" ? -1 : 1);
1139
+ const parent = dirname(target);
1140
+ return Object.freeze({
1141
+ path: target,
1142
+ ...parent === target ? {} : { parent },
1143
+ entries: Object.freeze(rows),
1144
+ truncated
1145
+ });
1146
+ }
1147
+ /** Read one bounded regular image file selected by an authenticated device. */
1148
+ async function readComputerImage(path) {
1149
+ const target = resolveComputerImagePath(path);
1150
+ const contentType = IMAGE_TYPES[extname(target).toLowerCase()];
1151
+ if (contentType === void 0) throw new HttpError(415, "unsupported_file_type");
1152
+ let info;
1153
+ try {
1154
+ info = await lstat(target);
1155
+ } catch {
1156
+ throw new HttpError(404, "file_unavailable");
1157
+ }
1158
+ if (!info.isFile() || info.isSymbolicLink()) throw new HttpError(404, "file_unavailable");
1159
+ if (info.size > MAX_IMAGE_BYTES) throw new HttpError(413, "file_too_large");
1160
+ try {
1161
+ return {
1162
+ body: await readFile(target),
1163
+ contentType,
1164
+ name: basename(target)
1165
+ };
1166
+ } catch {
1167
+ throw new HttpError(404, "file_unavailable");
1168
+ }
1169
+ }
1170
+ //#endregion
1013
1171
  //#region src/gateway.ts
1014
1172
  const MAX_CONTROL_BODY_BYTES = 16384;
1015
1173
  const MAX_HEADER_BYTES = 16384;
1174
+ const DISCOVERY_QUERY = Buffer.from("DSH_MOBILE_DISCOVER_V1", "ascii");
1175
+ const DISCOVERY_PROTOCOL = 1;
1176
+ const DISCOVERY_INTERVAL_MS = 3e3;
1177
+ const MDNS_SERVICE_TYPE = "dsh-mobile";
1178
+ const MOBILE_LAYOUT_MODULE = "@deepseek-ai/dsh-client-ui-layout";
1179
+ const MOBILE_LAYOUT_PATH = `${AUTH_PREFIX}/mobile-layout.js`;
1180
+ const MOBILE_LAYOUT_DEPENDENCIES = Object.freeze(["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-theme"]);
1016
1181
  const PAIR_PAGE = `<!doctype html>
1017
1182
  <html lang="en">
1018
1183
  <meta charset="utf-8">
@@ -1030,6 +1195,25 @@ const PAIR_PAGE = `<!doctype html>
1030
1195
  <script src="/mobile-access/pair.js" defer><\/script>
1031
1196
  </html>
1032
1197
  `;
1198
+ /** Replace only DSH's layout client module while retaining its complete plugin graph. */
1199
+ function rewriteMobileIndex(html) {
1200
+ const assignment = "window.__DSH_BOOT__ = ";
1201
+ const start = html.indexOf(assignment);
1202
+ if (start < 0) throw new Error("upstream DSH index has no boot manifest");
1203
+ const valueStart = start + 22;
1204
+ const scriptEnd = html.indexOf("<\/script>", valueStart);
1205
+ if (scriptEnd < 0) throw new Error("upstream DSH boot manifest script is incomplete");
1206
+ const source = html.slice(valueStart, scriptEnd).trim().replace(/;$/u, "");
1207
+ const parsed = JSON.parse(source);
1208
+ if (typeof parsed.rev !== "string" || !Array.isArray(parsed.entries)) throw new Error("upstream DSH boot manifest is malformed");
1209
+ const layout = parsed.entries.filter((entry) => entry !== null && typeof entry === "object" && entry.id === MOBILE_LAYOUT_MODULE);
1210
+ if (layout.length !== 1 || typeof layout[0]?.url !== "string" || typeof layout[0].rev !== "string") throw new Error("upstream DSH boot manifest has no unique layout module");
1211
+ if (!Array.isArray(layout[0].inject) || MOBILE_LAYOUT_DEPENDENCIES.some((dependency) => !layout[0]?.inject?.includes(dependency))) throw new Error("upstream DSH layout module has unsupported dependencies");
1212
+ layout[0].url = MOBILE_LAYOUT_PATH;
1213
+ layout[0].rev = "dsh-mobile-layout-v1";
1214
+ const replacement = `window.__DSH_MOBILE_FRONTEND__="dedicated";${assignment}${JSON.stringify(parsed)};`;
1215
+ return `${html.slice(0, start)}${replacement}${html.slice(scriptEnd)}`;
1216
+ }
1033
1217
  const PAIR_SCRIPT = `(() => {
1034
1218
  const form = document.getElementById('pair-form')
1035
1219
  const token = document.getElementById('pair-token')
@@ -1279,6 +1463,29 @@ function mapError(error) {
1279
1463
  if (error instanceof AccessError) return new HttpError(error.status, error.code);
1280
1464
  return new HttpError(500, "internal_error");
1281
1465
  }
1466
+ function discoveryDeviceName() {
1467
+ const value = hostname().trim().replaceAll(/[\u0000-\u001f\u007f]/gu, "");
1468
+ return (value === "" ? "DeepSeek Harness" : value).slice(0, 63);
1469
+ }
1470
+ function discoveryMdnsHost(instanceId) {
1471
+ const label = hostname().toLowerCase().replaceAll(/[^a-z0-9-]/gu, "-").replaceAll(/^-+|-+$/gu, "").slice(0, 40);
1472
+ return `${label === "" ? "dsh" : label}-${instanceId.slice(0, 8)}.local`;
1473
+ }
1474
+ function discoveryBroadcastTargets(cidrs) {
1475
+ const targets = /* @__PURE__ */ new Set(["255.255.255.255"]);
1476
+ for (const cidr of cidrs) {
1477
+ if (cidr.bits !== 32 || cidr.prefix >= 32) continue;
1478
+ const hostBits = BigInt(32 - cidr.prefix);
1479
+ const broadcast = cidr.network | (1n << hostBits) - 1n;
1480
+ targets.add([
1481
+ 24n,
1482
+ 16n,
1483
+ 8n,
1484
+ 0n
1485
+ ].map((shift) => Number(broadcast >> shift & 255n)).join("."));
1486
+ }
1487
+ return [...targets];
1488
+ }
1282
1489
  /** Authenticated TLS edge in front of the ordinary loopback-only DSH Web server. */
1283
1490
  var MobileAccessGateway = class {
1284
1491
  config;
@@ -1286,6 +1493,10 @@ var MobileAccessGateway = class {
1286
1493
  tlsEnabled;
1287
1494
  policy;
1288
1495
  server;
1496
+ discoverySocket;
1497
+ discoveryTimer;
1498
+ bonjour;
1499
+ pairingCaCertificate;
1289
1500
  listenerPort;
1290
1501
  connectedSockets = /* @__PURE__ */ new Set();
1291
1502
  activeRequests = /* @__PURE__ */ new Map();
@@ -1320,6 +1531,12 @@ var MobileAccessGateway = class {
1320
1531
  this.started = true;
1321
1532
  await this.access.initialize();
1322
1533
  try {
1534
+ if (this.config.pairingCaFile !== void 0) {
1535
+ const certificate = new X509Certificate(await readFile(this.config.pairingCaFile));
1536
+ const fingerprint = certificate.fingerprint256.replaceAll(":", "").toLowerCase();
1537
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey) || fingerprint !== this.config.instanceId) throw new Error("pairingCaFile must be the self-signed CA identified by instanceId");
1538
+ this.pairingCaCertificate = certificate.raw.toString("base64");
1539
+ }
1323
1540
  const handler = (request, response) => {
1324
1541
  this.handleExternalRequest(request, response).catch((error) => {
1325
1542
  const mapped = mapError(error);
@@ -1370,12 +1587,70 @@ var MobileAccessGateway = class {
1370
1587
  if (address === null || typeof address === "string") throw new Error("gateway listener has no TCP address");
1371
1588
  this.listenerPort = address.port;
1372
1589
  this.policy = new RequestTrustPolicy(this.config.authorities, address.port, this.config.allowedCidrs, this.tlsEnabled);
1590
+ await this.startDiscovery(address.port);
1373
1591
  } catch (error) {
1374
1592
  await this.closeFailedStart();
1375
1593
  throw error;
1376
1594
  }
1377
1595
  }
1596
+ async startDiscovery(port) {
1597
+ const socket = createSocket("udp4");
1598
+ this.discoverySocket = socket;
1599
+ const announcement = this.discoveryAnnouncement(port);
1600
+ socket.on("message", (message, remote) => {
1601
+ if (this.closing || !message.equals(DISCOVERY_QUERY) || !addressAllowed(remote.address, this.config.allowedCidrs)) return;
1602
+ socket.send(announcement, remote.port, remote.address, () => void 0);
1603
+ });
1604
+ await new Promise((resolve, reject) => {
1605
+ const failed = (error) => {
1606
+ reject(error);
1607
+ };
1608
+ socket.once("error", failed);
1609
+ socket.bind(port, "0.0.0.0", () => {
1610
+ socket.off("error", failed);
1611
+ socket.setBroadcast(true);
1612
+ resolve();
1613
+ });
1614
+ });
1615
+ const announce = () => {
1616
+ for (const target of discoveryBroadcastTargets(this.config.allowedCidrs)) socket.send(announcement, port, target, () => void 0);
1617
+ };
1618
+ announce();
1619
+ this.discoveryTimer = setInterval(announce, DISCOVERY_INTERVAL_MS);
1620
+ this.discoveryTimer.unref();
1621
+ const deviceName = discoveryDeviceName();
1622
+ const bonjour = new Bonjour({ disableIPv6: true });
1623
+ this.bonjour = bonjour;
1624
+ bonjour.publish({
1625
+ name: `${deviceName} (${this.config.instanceId.slice(0, 8)})`,
1626
+ type: MDNS_SERVICE_TYPE,
1627
+ protocol: "tcp",
1628
+ port,
1629
+ host: discoveryMdnsHost(this.config.instanceId),
1630
+ disableIPv6: true,
1631
+ txt: {
1632
+ deviceName,
1633
+ origin: this.address().origin,
1634
+ instanceId: this.config.instanceId,
1635
+ protocol: String(DISCOVERY_PROTOCOL)
1636
+ }
1637
+ });
1638
+ }
1639
+ discoveryAnnouncement(port) {
1640
+ return Buffer.from(JSON.stringify({
1641
+ deviceName: discoveryDeviceName(),
1642
+ origin: this.address().origin,
1643
+ port,
1644
+ protocol: DISCOVERY_PROTOCOL,
1645
+ instanceId: this.config.instanceId
1646
+ }), "utf8");
1647
+ }
1378
1648
  async closeFailedStart() {
1649
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
1650
+ this.discoveryTimer = void 0;
1651
+ await this.closeBonjour();
1652
+ this.discoverySocket?.close();
1653
+ this.discoverySocket = void 0;
1379
1654
  for (const socket of this.connectedSockets) socket.destroy();
1380
1655
  const server = this.server;
1381
1656
  this.server = void 0;
@@ -1384,6 +1659,16 @@ var MobileAccessGateway = class {
1384
1659
  });
1385
1660
  await this.access.close();
1386
1661
  }
1662
+ async closeBonjour() {
1663
+ const bonjour = this.bonjour;
1664
+ this.bonjour = void 0;
1665
+ if (bonjour === void 0) return;
1666
+ await new Promise((resolve) => {
1667
+ bonjour.unpublishAll(() => {
1668
+ bonjour.destroy(() => resolve());
1669
+ });
1670
+ });
1671
+ }
1387
1672
  /** Actual bound address, available after start and safe for loopback status output. */
1388
1673
  address() {
1389
1674
  if (this.listenerPort === void 0 || this.policy === void 0) throw new Error("gateway is not listening");
@@ -1467,6 +1752,33 @@ var MobileAccessGateway = class {
1467
1752
  sessionExpiresAt: result.sessionExpiresAt
1468
1753
  }, this.tlsEnabled);
1469
1754
  }
1755
+ async handleNativePair(request, response) {
1756
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
1757
+ if (typeof body.token !== "string" || body.label !== void 0 && typeof body.label !== "string") throw new HttpError(400, "bad_request");
1758
+ const result = await this.access.pair(request.socket.remoteAddress ?? "unknown", body.token, body.label);
1759
+ sendJson(response, 201, {
1760
+ instanceId: this.config.instanceId,
1761
+ deviceId: result.deviceId,
1762
+ deviceToken: result.deviceToken,
1763
+ deviceExpiresAt: result.deviceExpiresAt,
1764
+ sessionToken: result.sessionToken,
1765
+ csrfToken: result.csrfToken,
1766
+ sessionExpiresAt: result.sessionExpiresAt
1767
+ }, this.tlsEnabled);
1768
+ }
1769
+ async handleNativeRenew(request, response) {
1770
+ if (!this.renewLimiter.take(request.socket.remoteAddress ?? "unknown", Date.now())) throw new HttpError(429, "rate_limited");
1771
+ const body = await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
1772
+ if (typeof body.deviceToken !== "string") throw new HttpError(400, "bad_request");
1773
+ const result = await this.access.renew(body.deviceToken);
1774
+ sendJson(response, 200, {
1775
+ instanceId: this.config.instanceId,
1776
+ deviceId: result.deviceId,
1777
+ sessionToken: result.sessionToken,
1778
+ csrfToken: result.csrfToken,
1779
+ sessionExpiresAt: result.sessionExpiresAt
1780
+ }, this.tlsEnabled);
1781
+ }
1470
1782
  async handleLogout(request, response) {
1471
1783
  await readJsonObject(request, MAX_CONTROL_BODY_BYTES);
1472
1784
  const authorization = this.authorize(request);
@@ -1494,6 +1806,28 @@ var MobileAccessGateway = class {
1494
1806
  sendJson(response, 200, { ok: true }, this.tlsEnabled);
1495
1807
  return;
1496
1808
  }
1809
+ if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/discovery`) {
1810
+ sendJson(response, 200, {
1811
+ deviceName: discoveryDeviceName(),
1812
+ origin: this.address().origin,
1813
+ port: this.address().port,
1814
+ protocol: DISCOVERY_PROTOCOL,
1815
+ instanceId: this.config.instanceId
1816
+ }, this.tlsEnabled);
1817
+ return;
1818
+ }
1819
+ if (target.search === "" && request.method === "GET" && target.decodedPathname === `/mobile-access/ca.cer`) {
1820
+ if (this.pairingCaCertificate === void 0) throw new HttpError(404, "not_found");
1821
+ const body = Buffer.from(this.pairingCaCertificate, "base64");
1822
+ setSecurityHeaders(response, this.tlsEnabled);
1823
+ response.writeHead(200, {
1824
+ "Content-Type": "application/pkix-cert",
1825
+ "Content-Length": body.length,
1826
+ "Cache-Control": "no-store"
1827
+ });
1828
+ response.end(body);
1829
+ return;
1830
+ }
1497
1831
  if (target.search === "" && request.method === "GET" && (target.decodedPathname === `/mobile-access/pair` || target.decodedPathname === `/mobile-access/pair.js`)) {
1498
1832
  if (!this.access.pairingStatus().open) throw new HttpError(404, "not_found");
1499
1833
  setSecurityHeaders(response, this.tlsEnabled);
@@ -1524,10 +1858,20 @@ var MobileAccessGateway = class {
1524
1858
  await this.handleRenew(request, response);
1525
1859
  return;
1526
1860
  }
1861
+ if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-pair`) {
1862
+ await this.handleNativePair(request, response);
1863
+ return;
1864
+ }
1865
+ if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/native-renew`) {
1866
+ await this.handleNativeRenew(request, response);
1867
+ return;
1868
+ }
1527
1869
  if (target.search === "" && request.method === "POST" && target.decodedPathname === `/mobile-access/auth/logout`) {
1528
1870
  await this.handleLogout(request, response);
1529
1871
  return;
1530
1872
  }
1873
+ const computerImages = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-images`;
1874
+ const computerImage = request.method === "GET" && target.decodedPathname === `/mobile-access/computer-image`;
1531
1875
  const customAsset = request.method === "GET" ? target.decodedPathname === `/mobile-access/custom.css` ? {
1532
1876
  file: this.config.customCssFile,
1533
1877
  contentType: "text/css; charset=utf-8",
@@ -1536,8 +1880,12 @@ var MobileAccessGateway = class {
1536
1880
  file: this.config.customScriptFile,
1537
1881
  contentType: "text/javascript; charset=utf-8",
1538
1882
  fallback: "window.dshMobile?.register(() => undefined)\n"
1883
+ } : target.decodedPathname === MOBILE_LAYOUT_PATH ? {
1884
+ file: this.config.mobileLayoutFile,
1885
+ contentType: "text/javascript; charset=utf-8",
1886
+ fallback: void 0
1539
1887
  } : void 0 : void 0;
1540
- if (customAsset === void 0 && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
1888
+ if (customAsset === void 0 && !computerImages && !computerImage && (target.decodedPathname === "/mobile-access" || target.decodedPathname.startsWith(`/mobile-access/`))) throw new HttpError(404, "not_found");
1541
1889
  if (request.method !== "GET" && request.method !== "HEAD" && request.method !== "POST") throw new HttpError(405, "method_not_allowed");
1542
1890
  if (request.method === "POST" && target.decodedPathname !== "/api" && !target.decodedPathname.startsWith("/api/")) throw new HttpError(405, "method_not_allowed");
1543
1891
  let authorization;
@@ -1565,6 +1913,7 @@ var MobileAccessGateway = class {
1565
1913
  body = await readFile(customAsset.file);
1566
1914
  } catch (error) {
1567
1915
  if (error.code !== "ENOENT") throw error;
1916
+ if (customAsset.fallback === void 0) throw new HttpError(503, "mobile_frontend_unavailable");
1568
1917
  body = Buffer.from(customAsset.fallback);
1569
1918
  }
1570
1919
  if (body.byteLength > 262144) throw new HttpError(413, "payload_too_large");
@@ -1577,8 +1926,91 @@ var MobileAccessGateway = class {
1577
1926
  response.end(body);
1578
1927
  return;
1579
1928
  }
1929
+ if (computerImages) {
1930
+ const query = new URL(target.raw, this.address().origin).searchParams;
1931
+ sendJson(response, 200, await listComputerImages(query.get("path")), this.tlsEnabled);
1932
+ return;
1933
+ }
1934
+ if (computerImage) {
1935
+ const query = new URL(target.raw, this.address().origin).searchParams;
1936
+ const image = await readComputerImage(query.get("path"));
1937
+ setSecurityHeaders(response, this.tlsEnabled);
1938
+ response.writeHead(200, {
1939
+ "Content-Type": image.contentType,
1940
+ "Content-Length": image.body.byteLength,
1941
+ "Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(image.name)}`
1942
+ });
1943
+ response.end(image.body);
1944
+ return;
1945
+ }
1946
+ const stockFrontend = new URL(target.raw, this.address().origin).searchParams.get("frontend") === "stock";
1947
+ const acceptsHtml = request.headers.accept?.split(",").some((value) => value.trim().split(";", 1)[0] === "text/html") ?? false;
1948
+ if (request.method === "GET" && target.decodedPathname === "/" && acceptsHtml && !stockFrontend) {
1949
+ await this.proxyMobileIndex(request, response, authorization);
1950
+ return;
1951
+ }
1952
+ if (stockFrontend && target.decodedPathname === "/") request.url = "/";
1580
1953
  await this.proxyHttp(request, response, authorization);
1581
1954
  }
1955
+ async proxyMobileIndex(request$1, response, authorization) {
1956
+ const holder = {};
1957
+ const operation = this.allocateRequest(authorization, response, holder);
1958
+ try {
1959
+ const upstreamHeaders = sanitizeRequestHeaders(request$1, this.config.upstreamOrigin);
1960
+ upstreamHeaders["accept-encoding"] = "identity";
1961
+ const proxied = await new Promise((resolve, reject) => {
1962
+ const upstreamRequest = request({
1963
+ protocol: "http:",
1964
+ hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
1965
+ port: Number(this.config.upstreamOrigin.port),
1966
+ method: "GET",
1967
+ path: "/",
1968
+ headers: upstreamHeaders,
1969
+ agent: false
1970
+ });
1971
+ holder.request = upstreamRequest;
1972
+ upstreamRequest.setTimeout(this.config.upstreamTimeoutMs, () => {
1973
+ upstreamRequest.destroy(/* @__PURE__ */ new Error("upstream timeout"));
1974
+ });
1975
+ upstreamRequest.once("response", resolve);
1976
+ upstreamRequest.once("error", reject);
1977
+ upstreamRequest.end();
1978
+ });
1979
+ if ((proxied.statusCode ?? 502) !== 200) throw new HttpError(502, "upstream_unavailable");
1980
+ const chunks = [];
1981
+ let bytes = 0;
1982
+ for await (const chunk of proxied) {
1983
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1984
+ bytes += buffer.byteLength;
1985
+ if (bytes > 4194304) throw new HttpError(502, "upstream_unavailable");
1986
+ chunks.push(buffer);
1987
+ }
1988
+ let body;
1989
+ try {
1990
+ body = Buffer.from(rewriteMobileIndex(Buffer.concat(chunks).toString("utf8")));
1991
+ } catch {
1992
+ throw new HttpError(502, "upstream_unavailable");
1993
+ }
1994
+ const headers = sanitizeResponseHeaders(proxied.headers, this.config.upstreamOrigin);
1995
+ delete headers["content-length"];
1996
+ delete headers["content-encoding"];
1997
+ delete headers.etag;
1998
+ setSecurityHeaders(response, this.tlsEnabled);
1999
+ response.writeHead(200, {
2000
+ ...headers,
2001
+ "Content-Type": "text/html; charset=utf-8",
2002
+ "Content-Length": body.byteLength
2003
+ });
2004
+ response.end(body);
2005
+ } catch (error) {
2006
+ holder.request?.destroy();
2007
+ if (error instanceof HttpError) throw error;
2008
+ if (response.headersSent) response.destroy();
2009
+ else throw new HttpError(502, "upstream_unavailable");
2010
+ } finally {
2011
+ operation.release();
2012
+ }
2013
+ }
1582
2014
  allocateRequest(authorization, response, upstream) {
1583
2015
  if (this.activeRequests.size >= this.config.maxActiveRequests) throw new HttpError(429, "busy");
1584
2016
  const id = this.nextOperationId++;
@@ -1602,8 +2034,8 @@ var MobileAccessGateway = class {
1602
2034
  }
1603
2035
  };
1604
2036
  }
1605
- async proxyHttp(request$1, response, authorization) {
1606
- const declared = request$1.headers["content-length"];
2037
+ async proxyHttp(request$2, response, authorization) {
2038
+ const declared = request$2.headers["content-length"];
1607
2039
  if (declared !== void 0 && (!/^\d+$/u.test(declared) || Number(declared) > this.config.maxBodyBytes)) throw new HttpError(413, "payload_too_large");
1608
2040
  const holder = {};
1609
2041
  const operation = this.allocateRequest(authorization, response, holder);
@@ -1614,9 +2046,9 @@ var MobileAccessGateway = class {
1614
2046
  protocol: "http:",
1615
2047
  hostname: stripIpv6Brackets(this.config.upstreamOrigin.hostname),
1616
2048
  port: Number(this.config.upstreamOrigin.port),
1617
- method: request$1.method,
1618
- path: request$1.url,
1619
- headers: sanitizeRequestHeaders(request$1, this.config.upstreamOrigin),
2049
+ method: request$2.method,
2050
+ path: request$2.url,
2051
+ headers: sanitizeRequestHeaders(request$2, this.config.upstreamOrigin),
1620
2052
  agent: false
1621
2053
  });
1622
2054
  holder.request = upstreamRequest;
@@ -1625,7 +2057,7 @@ var MobileAccessGateway = class {
1625
2057
  });
1626
2058
  upstreamRequest.once("response", resolve);
1627
2059
  upstreamRequest.once("error", reject);
1628
- bodyDone = pipeline(request$1, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
2060
+ bodyDone = pipeline(request$2, new ByteLimitTransform(this.config.maxBodyBytes), upstreamRequest);
1629
2061
  bodyDone.catch(reject);
1630
2062
  });
1631
2063
  setSecurityHeaders(response, this.tlsEnabled);
@@ -1720,7 +2152,9 @@ var MobileAccessGateway = class {
1720
2152
  }
1721
2153
  async handleUpgrade(request, client, head) {
1722
2154
  const target = parseRequestTarget(request.url);
1723
- assertExternalTrust(request, this.requirePolicy(), true);
2155
+ const policy = this.requirePolicy();
2156
+ assertExternalTrust(request, policy, false);
2157
+ if (!policy.acceptsOrigin(request.headers.origin)) throw new HttpError(403, "forbidden");
1724
2158
  if (target.search !== "" || !WS_PATHS.has(target.decodedPathname)) throw new HttpError(404, "not_found");
1725
2159
  if (request.method !== "GET" || headerValue(request.headers, "upgrade")?.toLowerCase() !== "websocket" || !hasToken(headerValue(request.headers, "connection"), "upgrade")) throw new HttpError(400, "bad_request");
1726
2160
  const key = headerValue(request.headers, "sec-websocket-key");
@@ -1835,6 +2269,7 @@ var MobileAccessGateway = class {
1835
2269
  const opened = await this.access.openPairing(body.ttlMs);
1836
2270
  sendJson(response, 201, {
1837
2271
  ...opened,
2272
+ appKey: `dsh1.${this.config.instanceId}.${opened.token}`,
1838
2273
  pairUrl: `${this.address().origin}/mobile-access/pair#token=${opened.token}`
1839
2274
  }, false);
1840
2275
  return;
@@ -1877,6 +2312,14 @@ var MobileAccessGateway = class {
1877
2312
  websocket.upstream.destroy();
1878
2313
  }
1879
2314
  for (const socket of this.connectedSockets) socket.destroy();
2315
+ if (this.discoveryTimer !== void 0) clearInterval(this.discoveryTimer);
2316
+ this.discoveryTimer = void 0;
2317
+ await this.closeBonjour();
2318
+ const discoverySocket = this.discoverySocket;
2319
+ this.discoverySocket = void 0;
2320
+ if (discoverySocket !== void 0) await new Promise((resolve) => {
2321
+ discoverySocket.close(() => resolve());
2322
+ });
1880
2323
  const server = this.server;
1881
2324
  this.server = void 0;
1882
2325
  if (server !== void 0 && server.listening) {
@@ -2015,14 +2458,225 @@ var MemoryDeviceStore = class {
2015
2458
  return structuredClone(this.snapshot);
2016
2459
  }
2017
2460
  };
2461
+ promisify(execFile);
2462
+ const VIRTUAL_INTERFACE_MARKERS = [
2463
+ "bridge",
2464
+ "docker",
2465
+ "hyper-v",
2466
+ "mihomo",
2467
+ "radmin",
2468
+ "tailscale",
2469
+ "tap",
2470
+ "tun",
2471
+ "utun",
2472
+ "vbox",
2473
+ "veth",
2474
+ "virtual",
2475
+ "vmware",
2476
+ "vpn",
2477
+ "vethernet",
2478
+ "wsl",
2479
+ "zerotier"
2480
+ ];
2481
+ function requiredString(value, name) {
2482
+ if (typeof value !== "string" || value.length === 0) throw new Error(`${name} must be a non-empty string`);
2483
+ return value;
2484
+ }
2485
+ /** Validate the durable managed setup before it controls network and filesystem operations. */
2486
+ function parseManagedSetup(value) {
2487
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("mobile setup file must be an object");
2488
+ const record = value;
2489
+ if (record.version !== 2 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || ![
2490
+ "version",
2491
+ "networkInterface",
2492
+ "listenPort",
2493
+ "upstreamOrigin",
2494
+ "tls"
2495
+ ].includes(key))) throw new Error("mobile setup file has an unsupported format");
2496
+ if (!Number.isSafeInteger(record.listenPort) || record.listenPort < 1024 || record.listenPort > 65535) throw new Error("mobile setup listenPort must be from 1024 through 65535");
2497
+ if (typeof record.tls !== "object" || record.tls === null || Array.isArray(record.tls)) throw new Error("mobile setup tls must be an object");
2498
+ const tls = record.tls;
2499
+ if (tls.mode !== "managed" || Reflect.ownKeys(tls).some((key) => typeof key !== "string" || ![
2500
+ "mode",
2501
+ "caCertFile",
2502
+ "caKeyFile",
2503
+ "certFile",
2504
+ "keyFile"
2505
+ ].includes(key))) throw new Error("mobile setup tls has an unsupported format");
2506
+ return Object.freeze({
2507
+ version: 2,
2508
+ networkInterface: requiredString(record.networkInterface, "mobile setup networkInterface"),
2509
+ listenPort: record.listenPort,
2510
+ upstreamOrigin: requiredString(record.upstreamOrigin, "mobile setup upstreamOrigin"),
2511
+ tls: Object.freeze({
2512
+ mode: "managed",
2513
+ caCertFile: requiredString(tls.caCertFile, "mobile setup tls.caCertFile"),
2514
+ caKeyFile: requiredString(tls.caKeyFile, "mobile setup tls.caKeyFile"),
2515
+ certFile: requiredString(tls.certFile, "mobile setup tls.certFile"),
2516
+ keyFile: requiredString(tls.keyFile, "mobile setup tls.keyFile")
2517
+ })
2518
+ });
2519
+ }
2520
+ function privateIpv4(value) {
2521
+ const parts = value.split(".").map(Number);
2522
+ return parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255) && (parts[0] === 10 || parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31 || parts[0] === 192 && parts[1] === 168);
2523
+ }
2524
+ function networkCidr(address, cidr) {
2525
+ const prefix = Number(cidr.slice(cidr.lastIndexOf("/") + 1));
2526
+ const network = (address.split(".").reduce((total, part) => (total << 8 | Number(part)) >>> 0, 0) & (prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0)) >>> 0;
2527
+ return `${[
2528
+ 24,
2529
+ 16,
2530
+ 8,
2531
+ 0
2532
+ ].map((shift) => network >>> shift & 255).join(".")}/${String(prefix)}`;
2533
+ }
2534
+ /** List current private IPv4 candidates with their interface identity. */
2535
+ function availableLanNetworks(table = networkInterfaces()) {
2536
+ const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? []).filter((entry) => entry.family === "IPv4" && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null).map((entry) => ({
2537
+ name,
2538
+ address: entry.address,
2539
+ cidr: networkCidr(entry.address, entry.cidr)
2540
+ })));
2541
+ return [...new Map(candidates.map((entry) => [`${entry.name}\0${entry.address}`, entry])).values()];
2542
+ }
2543
+ function likelyVirtualInterface(name) {
2544
+ const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, " ");
2545
+ return VIRTUAL_INTERFACE_MARKERS.some((marker) => normalized.includes(marker.replaceAll("-", " "))) || /^(?:br|wg)\d*\b/u.test(normalized);
2546
+ }
2547
+ /** Select an active LAN, optionally by address or by a previously saved interface name. */
2548
+ function selectLanNetwork(requestedAddress, requestedInterface, table, preferredInterfaces = []) {
2549
+ const candidates = availableLanNetworks(table);
2550
+ if (requestedAddress !== void 0) {
2551
+ const match = candidates.find((candidate) => candidate.address === requestedAddress);
2552
+ if (match === void 0) throw new Error(`--address ${requestedAddress} is not an active private LAN address`);
2553
+ return match;
2554
+ }
2555
+ if (requestedInterface !== void 0) {
2556
+ const matches = candidates.filter((candidate) => candidate.name === requestedInterface);
2557
+ if (matches.length === 1) return matches[0];
2558
+ if (matches.length === 0) throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`);
2559
+ throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`);
2560
+ }
2561
+ if (candidates.length === 1) return candidates[0];
2562
+ if (candidates.length === 0) throw new Error("no active private LAN address was found; connect to Wi-Fi or Ethernet");
2563
+ for (const name of preferredInterfaces) {
2564
+ const matches = candidates.filter((candidate) => candidate.name === name);
2565
+ if (matches.length === 1) return matches[0];
2566
+ }
2567
+ const physicalCandidates = candidates.filter((candidate) => !likelyVirtualInterface(candidate.name));
2568
+ if (physicalCandidates.length === 1) return physicalCandidates[0];
2569
+ throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map((entry) => `${entry.name}=${entry.address}`).join(", ")}`);
2570
+ }
2571
+ function assertMatchingCa(certPem, keyPem) {
2572
+ const certificate = new X509Certificate(certPem);
2573
+ if (!certificate.ca || certificate.subject !== certificate.issuer || !certificate.verify(certificate.publicKey)) throw new Error("managed TLS CA must be a self-signed CA certificate");
2574
+ const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({
2575
+ format: "der",
2576
+ type: "spki"
2577
+ });
2578
+ const certificatePublic = certificate.publicKey.export({
2579
+ format: "der",
2580
+ type: "spki"
2581
+ });
2582
+ if (!privatePublic.equals(certificatePublic)) throw new Error("managed TLS CA certificate and key do not match");
2583
+ if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) throw new Error("managed TLS CA certificate is not currently valid");
2584
+ return certificate;
2585
+ }
2586
+ async function atomicWrite(file, contents) {
2587
+ const directory = dirname(file);
2588
+ await mkdir(directory, {
2589
+ recursive: true,
2590
+ mode: 448
2591
+ });
2592
+ const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`);
2593
+ await writeFile(temporary, contents, { mode: 384 });
2594
+ await chmod(temporary, 384);
2595
+ await rename(temporary, file);
2596
+ }
2597
+ /** Sign and atomically install a server leaf for the interface's current address. */
2598
+ async function refreshManagedServerCertificate(setup, address) {
2599
+ const [caCert, caKey] = await Promise.all([readFile(setup.tls.caCertFile, "utf8"), readFile(setup.tls.caKeyFile, "utf8")]);
2600
+ assertMatchingCa(caCert, caKey);
2601
+ const now = /* @__PURE__ */ new Date();
2602
+ const notAfter = new Date(now);
2603
+ notAfter.setDate(notAfter.getDate() + 397);
2604
+ const server = await generate([{
2605
+ name: "commonName",
2606
+ value: "DeepSeek Harness Mobile"
2607
+ }], {
2608
+ keyType: "ec",
2609
+ curve: "P-256",
2610
+ algorithm: "sha256",
2611
+ notBeforeDate: /* @__PURE__ */ new Date(now.getTime() - 3e5),
2612
+ notAfterDate: notAfter,
2613
+ ca: {
2614
+ cert: caCert,
2615
+ key: caKey
2616
+ },
2617
+ extensions: [
2618
+ {
2619
+ name: "basicConstraints",
2620
+ cA: false,
2621
+ critical: true
2622
+ },
2623
+ {
2624
+ name: "keyUsage",
2625
+ digitalSignature: true,
2626
+ critical: true
2627
+ },
2628
+ {
2629
+ name: "extKeyUsage",
2630
+ serverAuth: true
2631
+ },
2632
+ {
2633
+ name: "subjectAltName",
2634
+ altNames: [{
2635
+ type: 7,
2636
+ ip: address
2637
+ }]
2638
+ }
2639
+ ]
2640
+ });
2641
+ await Promise.all([atomicWrite(setup.tls.certFile, server.cert), atomicWrite(setup.tls.keyFile, server.private)]);
2642
+ }
2643
+ /** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */
2644
+ async function materializeManagedSetup(setup, table) {
2645
+ const network = selectLanNetwork(void 0, setup.networkInterface, table);
2646
+ await refreshManagedServerCertificate(setup, network.address);
2647
+ const ca = new X509Certificate(await readFile(setup.tls.caCertFile, "utf8"));
2648
+ return {
2649
+ publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,
2650
+ listenHost: network.address,
2651
+ upstreamOrigin: setup.upstreamOrigin,
2652
+ allowedCidrs: [network.cidr],
2653
+ instanceId: ca.fingerprint256.replaceAll(":", "").toLowerCase(),
2654
+ pairingCaFile: setup.tls.caCertFile,
2655
+ tls: {
2656
+ mode: "provided",
2657
+ certFile: setup.tls.certFile,
2658
+ keyFile: setup.tls.keyFile
2659
+ }
2660
+ };
2661
+ }
2018
2662
  //#endregion
2019
2663
  //#region src/plugin.ts
2020
2664
  /** Stable Cordis plugin name. */
2021
2665
  const name = "dsh-mobile";
2022
2666
  /** The stock WebServer is the only DSH Host service this plugin requires. */
2023
2667
  const inject = ["webServer"];
2668
+ function installedDshVersion() {
2669
+ const manifest = createRequire(import.meta.url)("@deepseek-ai/dsh-host-webserver/package.json");
2670
+ if (manifest === null || typeof manifest !== "object") return void 0;
2671
+ return manifest.version;
2672
+ }
2024
2673
  function mapAdminError(error) {
2025
- return error instanceof HttpError ? error : new HttpError(500, "internal_error");
2674
+ if (error instanceof HttpError) return error;
2675
+ const code = error.code;
2676
+ if (code === "EADDRNOTAVAIL") return new HttpError(409, "network_address_changed");
2677
+ if (code === "EADDRINUSE") return new HttpError(409, "listen_port_in_use");
2678
+ if (error instanceof Error && error.message.startsWith("saved LAN interface ")) return new HttpError(409, "network_interface_unavailable");
2679
+ return new HttpError(500, "internal_error");
2026
2680
  }
2027
2681
  const SETUP_KEYS = /* @__PURE__ */ new Set([
2028
2682
  "version",
@@ -2032,16 +2686,29 @@ const SETUP_KEYS = /* @__PURE__ */ new Set([
2032
2686
  "upstreamOrigin",
2033
2687
  "publicAuthorities",
2034
2688
  "allowedCidrs",
2689
+ "instanceId",
2690
+ "pairingCaFile",
2035
2691
  "tls"
2036
2692
  ]);
2693
+ function withoutSetupKeys(config) {
2694
+ const merged = { ...config };
2695
+ for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2696
+ return merged;
2697
+ }
2037
2698
  async function loadSetup(config) {
2038
- if (config.setupFile === void 0) return config;
2699
+ if (config.setupFile === void 0) return {
2700
+ kind: "fixed",
2701
+ config
2702
+ };
2039
2703
  if (!isAbsolute(config.setupFile)) throw new Error("setupFile must be an absolute file path");
2040
2704
  let source;
2041
2705
  try {
2042
2706
  source = await readFile(resolve(config.setupFile), "utf8");
2043
2707
  } catch (error) {
2044
- if (error.code === "ENOENT") return config;
2708
+ if (error.code === "ENOENT") return {
2709
+ kind: "fixed",
2710
+ config
2711
+ };
2045
2712
  throw error;
2046
2713
  }
2047
2714
  let parsed;
@@ -2052,20 +2719,28 @@ async function loadSetup(config) {
2052
2719
  }
2053
2720
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("mobile setup file must be an object");
2054
2721
  const record = parsed;
2722
+ if (record.version === 2) return {
2723
+ kind: "managed",
2724
+ config: withoutSetupKeys(config),
2725
+ setup: parseManagedSetup(record)
2726
+ };
2055
2727
  if (record.version !== 1 || Reflect.ownKeys(record).some((key) => typeof key !== "string" || !SETUP_KEYS.has(key))) throw new Error("mobile setup file has an unsupported format");
2056
2728
  const { version: _version, ...setup } = record;
2057
- const merged = { ...config };
2058
- for (const key of SETUP_KEYS) if (key !== "version") delete merged[key];
2059
2729
  return {
2060
- ...merged,
2061
- ...setup
2730
+ kind: "fixed",
2731
+ config: {
2732
+ ...withoutSetupKeys(config),
2733
+ ...setup
2734
+ }
2062
2735
  };
2063
2736
  }
2064
2737
  /** Mount the resident control route and its optional authenticated LAN gateway. */
2065
2738
  async function apply(ctx, config) {
2066
- const resolved = parseGatewayConfig(await loadSetup(config));
2739
+ assertSupportedDshVersion(installedDshVersion());
2740
+ const loaded = await loadSetup(config);
2067
2741
  let gateway;
2068
- const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), async () => {
2742
+ const startGateway = async (candidateConfig) => {
2743
+ const resolved = parseGatewayConfig(candidateConfig);
2069
2744
  const candidate = new MobileAccessGateway(resolved, new JsonDeviceStore(resolved.stateFile, resolved.maxDevices));
2070
2745
  await candidate.start();
2071
2746
  gateway = candidate;
@@ -2073,7 +2748,25 @@ async function apply(ctx, config) {
2073
2748
  if (gateway === candidate) gateway = void 0;
2074
2749
  await candidate.close();
2075
2750
  } };
2076
- });
2751
+ };
2752
+ const startRuntime = async () => {
2753
+ if (loaded.kind === "fixed") return startGateway(loaded.config);
2754
+ const following = new FollowingMobileAccessRuntime(async () => {
2755
+ const network = selectLanNetwork(void 0, loaded.setup.networkInterface);
2756
+ return {
2757
+ key: `${network.name}\0${network.address}\0${network.cidr}`,
2758
+ start: async () => startGateway({
2759
+ ...loaded.config,
2760
+ ...await materializeManagedSetup(loaded.setup)
2761
+ })
2762
+ };
2763
+ }, (error) => {
2764
+ process.emitWarning(`DSH Mobile could not follow the current LAN address: ${error instanceof Error ? error.message : String(error)}`, { code: "DSH_MOBILE_NETWORK_REFRESH" });
2765
+ });
2766
+ await following.initialize(2e3);
2767
+ return following;
2768
+ };
2769
+ const controller = new MobileAccessGatewayController(new JsonMobileAccessControlStore(parseControlFile(config.controlFile), config.initiallyEnabled), startRuntime);
2077
2770
  const adminRoute = {
2078
2771
  kind: "prefix",
2079
2772
  path: LOCAL_ADMIN_PREFIX,
@@ -2124,6 +2817,6 @@ async function apply(ctx, config) {
2124
2817
  }, "dsh-mobile: local control and authenticated LAN gateway");
2125
2818
  }
2126
2819
  //#endregion
2127
- export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, RequestTrustPolicy, SESSION_COOKIE, WS_PATHS, addressAllowed, apply, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority };
2820
+ export { AUTH_PREFIX, AccessController, AccessError, BoundedRateLimiter, CSRF_COOKIE, CSRF_HEADER, Config, DEVICE_COOKIE, JsonDeviceStore, JsonMobileAccessControlStore, LOCAL_ADMIN_PREFIX, MemoryDeviceStore, MobileAccessGateway, MobileAccessGatewayController, RequestTrustPolicy, SESSION_COOKIE, SUPPORTED_DSH_VERSIONS, WS_PATHS, addressAllowed, apply, assertSupportedDshVersion, inject, isLoopbackAddress, name, parseAuthority, parseCidr, parseControlFile, parseDeviceSnapshot, parseGatewayConfig, parseMobileAccessControlState, resolveAuthority, rewriteMobileIndex };
2128
2821
 
2129
2822
  //# sourceMappingURL=index.mjs.map