pinggy 0.6.0 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -236,6 +236,9 @@ function getTunnelLogPath(tunnelId, origin, name) {
236
236
  function getDaemonLogPath() {
237
237
  return import_path2.default.join(getPinggyLogDir(), "daemon.log");
238
238
  }
239
+ function getDeviceConfigPath() {
240
+ return import_path2.default.join(getPinggyConfigDir(), "device.json");
241
+ }
239
242
  function ensurePinggyConfigDir() {
240
243
  const dir = getPinggyConfigDir();
241
244
  import_fs2.default.mkdirSync(dir, { recursive: true });
@@ -521,6 +524,121 @@ var init_rotateLog = __esm({
521
524
  }
522
525
  });
523
526
 
527
+ // src/types.ts
528
+ function isErrorResponse(obj) {
529
+ return typeof obj === "object" && obj !== null && "code" in obj && "message" in obj && typeof obj.message === "string" && Object.values(ErrorCode).includes(obj.code);
530
+ }
531
+ function newErrorResponse(codeOrError, message) {
532
+ if (typeof codeOrError === "object") {
533
+ return codeOrError;
534
+ }
535
+ return {
536
+ code: codeOrError,
537
+ message
538
+ };
539
+ }
540
+ function NewResponseObject(data) {
541
+ const encoder = new TextEncoder();
542
+ const bytes = encoder.encode(JSON.stringify(data));
543
+ return {
544
+ response: bytes,
545
+ requestid: "",
546
+ command: "",
547
+ error: false,
548
+ errorresponse: {}
549
+ };
550
+ }
551
+ function NewErrorResponseObject(errorResponse) {
552
+ return {
553
+ response: new Uint8Array(),
554
+ requestid: "",
555
+ command: "",
556
+ error: true,
557
+ errorresponse: errorResponse
558
+ };
559
+ }
560
+ function newStatus(tunnelState, errorCode, errorMsg) {
561
+ let assignedState = tunnelState;
562
+ if (tunnelState === "live" /* Live */) {
563
+ assignedState = "running" /* Running */;
564
+ } else if (tunnelState === "idle" /* New */) {
565
+ assignedState = "idle" /* New */;
566
+ } else if (tunnelState === "closed" /* Closed */) {
567
+ assignedState = "exited" /* Exited */;
568
+ }
569
+ const now = (/* @__PURE__ */ new Date()).toISOString();
570
+ return {
571
+ state: assignedState,
572
+ errorcode: errorCode,
573
+ errormsg: errorMsg,
574
+ createdtimestamp: now,
575
+ starttimestamp: now,
576
+ endtimestamp: now,
577
+ warnings: []
578
+ };
579
+ }
580
+ function newStats() {
581
+ return {
582
+ numLiveConnections: 0,
583
+ numTotalConnections: 0,
584
+ numTotalReqBytes: 0,
585
+ numTotalResBytes: 0,
586
+ numTotalTxBytes: 0,
587
+ elapsedTime: 0
588
+ };
589
+ }
590
+ var TunnelStateType, TunnelErrorCodeType, TunnelWarningCode, ErrorCode, RemoteManagementStatus;
591
+ var init_types = __esm({
592
+ "src/types.ts"() {
593
+ "use strict";
594
+ init_cjs_shims();
595
+ TunnelStateType = /* @__PURE__ */ ((TunnelStateType2) => {
596
+ TunnelStateType2["New"] = "idle";
597
+ TunnelStateType2["Starting"] = "starting";
598
+ TunnelStateType2["Running"] = "running";
599
+ TunnelStateType2["Live"] = "live";
600
+ TunnelStateType2["Closed"] = "closed";
601
+ TunnelStateType2["Exited"] = "exited";
602
+ return TunnelStateType2;
603
+ })(TunnelStateType || {});
604
+ TunnelErrorCodeType = /* @__PURE__ */ ((TunnelErrorCodeType2) => {
605
+ TunnelErrorCodeType2["NonResponsive"] = "non_responsive";
606
+ TunnelErrorCodeType2["FailedToConnect"] = "failed_to_connect";
607
+ TunnelErrorCodeType2["ErrorInAdditionalForwarding"] = "additional_forwarding_error";
608
+ TunnelErrorCodeType2["WebdebuggerError"] = "webdebugger_error";
609
+ TunnelErrorCodeType2["NoError"] = "";
610
+ return TunnelErrorCodeType2;
611
+ })(TunnelErrorCodeType || {});
612
+ TunnelWarningCode = /* @__PURE__ */ ((TunnelWarningCode3) => {
613
+ TunnelWarningCode3["InvalidTunnelServePath"] = "INVALID_TUNNEL_SERVE_PATH";
614
+ TunnelWarningCode3["UnknownWarning"] = "UNKNOWN_WARNING";
615
+ return TunnelWarningCode3;
616
+ })(TunnelWarningCode || {});
617
+ ErrorCode = {
618
+ InvalidRequestMethodError: "INVALID_REQUEST_METHOD",
619
+ InvalidRequestBodyError: "COULD_NOT_READ_BODY",
620
+ InternalServerError: "INTERNAL_SERVER_ERROR",
621
+ InvalidBodyFormatError: "INVALID_DATA_FORMAT",
622
+ ErrorStartingTunnel: "ERROR_STARTING_TUNNEL",
623
+ TunnelNotFound: "TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND",
624
+ TunnelAlreadyRunningError: "TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING",
625
+ WebsocketUpgradeFailError: "WEBSOCKET_UPGRADE_FAILED",
626
+ RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING",
627
+ RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING",
628
+ RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED",
629
+ DeviceNotEnrolled: "DEVICE_NOT_ENROLLED"
630
+ };
631
+ RemoteManagementStatus = {
632
+ Connecting: "CONNECTING",
633
+ Disconnecting: "DISCONNECTING",
634
+ Reconnecting: "RECONNECTING",
635
+ Running: "RUNNING",
636
+ NotRunning: "NOT_RUNNING",
637
+ Error: "ERROR"
638
+ };
639
+ }
640
+ });
641
+
524
642
  // src/utils/util.ts
525
643
  function getRandomId() {
526
644
  return (0, import_crypto.randomUUID)();
@@ -587,14 +705,15 @@ var init_fileServerMessages = __esm({
587
705
  var TunnelManager_exports = {};
588
706
  __export(TunnelManager_exports, {
589
707
  TunnelAlreadyRunningError: () => TunnelAlreadyRunningError,
590
- TunnelManager: () => TunnelManager
708
+ TunnelManager: () => TunnelManager,
709
+ TunnelRebuildInProgressError: () => TunnelRebuildInProgressError
591
710
  });
592
711
  function mapToSdkLogLevel(level) {
593
712
  if (level === "debug") return import_pinggy2.LogLevel.DEBUG;
594
713
  if (level === "error") return import_pinggy2.LogLevel.ERROR;
595
714
  return import_pinggy2.LogLevel.INFO;
596
715
  }
597
- var import_pinggy2, import_node_path, import_node_worker_threads, import_node_url, __filename3, __dirname2, STATS_HISTORY_LIMIT, TunnelAlreadyRunningError, TunnelManager;
716
+ var import_pinggy2, import_node_path, import_node_worker_threads, import_node_url, __filename3, __dirname2, START_FAILED_MESSAGE, STATS_HISTORY_LIMIT, TunnelAlreadyRunningError, TunnelRebuildInProgressError, TunnelManager;
598
717
  var init_TunnelManager = __esm({
599
718
  "src/tunnel_manager/TunnelManager.ts"() {
600
719
  "use strict";
@@ -603,6 +722,7 @@ var init_TunnelManager = __esm({
603
722
  init_logger();
604
723
  init_tunnelLogger();
605
724
  init_rotateLog();
725
+ init_types();
606
726
  import_node_path = __toESM(require("path"), 1);
607
727
  import_node_worker_threads = require("worker_threads");
608
728
  import_node_url = require("url");
@@ -612,6 +732,7 @@ var init_TunnelManager = __esm({
612
732
  init_fileServerMessages();
613
733
  __filename3 = (0, import_node_url.fileURLToPath)(importMetaUrl);
614
734
  __dirname2 = import_node_path.default.dirname(__filename3);
735
+ START_FAILED_MESSAGE = "Failed to start tunnel";
615
736
  STATS_HISTORY_LIMIT = 100;
616
737
  TunnelAlreadyRunningError = class extends Error {
617
738
  constructor(configId, existingTunnelId) {
@@ -621,6 +742,13 @@ var init_TunnelManager = __esm({
621
742
  this.name = "TunnelAlreadyRunningError";
622
743
  }
623
744
  };
745
+ TunnelRebuildInProgressError = class extends Error {
746
+ constructor(tunnelId) {
747
+ super(`Tunnel "${tunnelId}" is already restarting`);
748
+ this.tunnelId = tunnelId;
749
+ this.name = "TunnelRebuildInProgressError";
750
+ }
751
+ };
624
752
  TunnelManager = class _TunnelManager {
625
753
  constructor() {
626
754
  this.tunnelsByTunnelId = /* @__PURE__ */ new Map();
@@ -665,7 +793,7 @@ var init_TunnelManager = __esm({
665
793
  throw new Error("configId is required and must be a non-empty string");
666
794
  }
667
795
  const existing = this.tunnelsByConfigId.get(configId);
668
- if (existing && !existing.isStopped) {
796
+ if (existing && (!existing.isStopped || existing.rebuild)) {
669
797
  throw new TunnelAlreadyRunningError(configId, existing.tunnelid);
670
798
  }
671
799
  return this._createTunnelWithProcessedConfig({
@@ -687,14 +815,15 @@ var init_TunnelManager = __esm({
687
815
  * @private
688
816
  */
689
817
  async _createTunnelWithProcessedConfig(params) {
690
- const tunnelLogName = params.tunnelName || params.originalConfig?.name;
818
+ const tunnelConfig = { ...params.originalConfig, maxReconnectAttempts: 0 };
819
+ const tunnelLogName = params.tunnelName || tunnelConfig?.name;
691
820
  const tunnelLogPath = getTunnelLogPath(params.tunnelid, params.origin, tunnelLogName);
692
821
  maybeRotate(tunnelLogPath);
693
822
  attachTunnelLogger(params.tunnelid, params.origin, tunnelLogName);
694
823
  let instance;
695
824
  try {
696
- logger.debug("Creating tunnel instance with processed config", params.originalConfig);
697
- instance = await import_pinggy2.TunnelInstance.create(params.originalConfig, {
825
+ logger.debug("Creating tunnel instance with processed config", tunnelConfig);
826
+ instance = await import_pinggy2.TunnelInstance.create(tunnelConfig, {
698
827
  enabled: true,
699
828
  logLevel: mapToSdkLogLevel(getLogLevel()),
700
829
  logFilePath: tunnelLogPath,
@@ -712,7 +841,7 @@ var init_TunnelManager = __esm({
712
841
  tunnelName: params.tunnelName,
713
842
  origin: params.origin,
714
843
  instance,
715
- tunnelConfig: params.originalConfig,
844
+ tunnelConfig,
716
845
  serve: params.serve,
717
846
  warnings: [],
718
847
  isStopped: false,
@@ -752,11 +881,12 @@ var init_TunnelManager = __esm({
752
881
  try {
753
882
  urls = await managed.instance.start();
754
883
  } catch (error) {
755
- logger.warn("Failed to start tunnel", { tunnelId, error });
884
+ const reason = errorMessage(error) || START_FAILED_MESSAGE;
885
+ logger.warn("Failed to start tunnel", { tunnelId, error: reason });
756
886
  managed.isStopped = true;
757
887
  managed.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
758
888
  managed.lastError = {
759
- message: "Failed to start tunnel",
889
+ message: reason,
760
890
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
761
891
  isFatal: true
762
892
  };
@@ -927,6 +1057,9 @@ var init_TunnelManager = __esm({
927
1057
  logger.error(`Tunnel "${tunnelId}" not found when fetching status`);
928
1058
  throw new Error(`Tunnel "${tunnelId}" not found`);
929
1059
  }
1060
+ if (managed.rebuild?.startsAfter) {
1061
+ return "starting" /* Starting */;
1062
+ }
930
1063
  if (managed.isStopped) {
931
1064
  return "exited";
932
1065
  }
@@ -977,6 +1110,10 @@ var init_TunnelManager = __esm({
977
1110
  logger.warn("Attempted to remove tunnel that is not stopped", { tunnelId });
978
1111
  return false;
979
1112
  }
1113
+ if (managed.rebuild) {
1114
+ logger.warn("Attempted to remove tunnel that is restarting", { tunnelId });
1115
+ return false;
1116
+ }
980
1117
  this._cleanupTunnelRecords(managed);
981
1118
  logger.info("Removed stopped tunnel records", { tunnelId, configId: managed.configId });
982
1119
  return true;
@@ -1056,17 +1193,27 @@ var init_TunnelManager = __esm({
1056
1193
  if (!managed) {
1057
1194
  return Promise.reject(new Error(`Tunnel with configId "${configId}" not found`));
1058
1195
  }
1059
- return Promise.resolve(managed.instance.getConfig());
1196
+ return this.currentConfig(managed);
1060
1197
  }
1061
1198
  if (tunnelId) {
1062
1199
  const managed = this.tunnelsByTunnelId.get(tunnelId);
1063
1200
  if (!managed) {
1064
1201
  return Promise.reject(new Error(`Tunnel with tunnelId "${tunnelId}" not found`));
1065
1202
  }
1066
- return Promise.resolve(managed.instance.getConfig());
1203
+ return this.currentConfig(managed);
1067
1204
  }
1068
1205
  return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
1069
1206
  }
1207
+ /**
1208
+ * The instance's config, asked of its worker. A stopped instance has let its worker go, so a stopped
1209
+ * tunnel, including one a restart is replacing, answers with the config it was built from.
1210
+ */
1211
+ currentConfig(managed) {
1212
+ if (managed.isStopped) {
1213
+ return Promise.resolve(managed.tunnelConfig);
1214
+ }
1215
+ return Promise.resolve(managed.instance.getConfig());
1216
+ }
1070
1217
  /**
1071
1218
  * Stop a tunnel instance and wait for the stop to settle, capped by a
1072
1219
  * timeout. Failure is logged, not thrown: the caller replaces the
@@ -1090,6 +1237,46 @@ var init_TunnelManager = __esm({
1090
1237
  if (timer) clearTimeout(timer);
1091
1238
  }
1092
1239
  }
1240
+ /**
1241
+ * Marks the entry a restart or config update replaces. It stays in both maps until
1242
+ * `_createTunnelWithProcessedConfig` sets the new entry under the same ids. When the new entry
1243
+ * starts, the entry reports `starting`, and an error from an earlier run no longer describes it.
1244
+ */
1245
+ beginRebuild(managed, startsAfter) {
1246
+ if (managed.rebuild) {
1247
+ throw new TunnelRebuildInProgressError(managed.tunnelid);
1248
+ }
1249
+ managed.rebuild = { startsAfter };
1250
+ if (startsAfter) {
1251
+ managed.lastError = {};
1252
+ }
1253
+ }
1254
+ /** Stops the instance being replaced, and marks it stopped so no reader calls it again. */
1255
+ async stopForRebuild(managed) {
1256
+ if (managed.isStopped) {
1257
+ return;
1258
+ }
1259
+ await this.stopInstanceGuarded(managed);
1260
+ managed.isStopped = true;
1261
+ managed.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
1262
+ }
1263
+ /**
1264
+ * Ends a failed rebuild. When the old entry is still in the maps, it stays listed as stopped with
1265
+ * the reason. A new entry that failed to start already holds its own error.
1266
+ */
1267
+ failRebuild(managed, error) {
1268
+ managed.rebuild = void 0;
1269
+ if (this.tunnelsByTunnelId.get(managed.tunnelid) !== managed) {
1270
+ return;
1271
+ }
1272
+ managed.isStopped = true;
1273
+ managed.stoppedAt = managed.stoppedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1274
+ managed.lastError = {
1275
+ message: errorMessage(error) || START_FAILED_MESSAGE,
1276
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1277
+ isFatal: true
1278
+ };
1279
+ }
1093
1280
  /**
1094
1281
  * Restarts a tunnel with its current configuration.
1095
1282
  * This function will stop the tunnel if it's running and start it again.
@@ -1100,6 +1287,7 @@ var init_TunnelManager = __esm({
1100
1287
  if (!existingTunnel) {
1101
1288
  throw new Error(`Tunnel "${tunnelid}" not found`);
1102
1289
  }
1290
+ this.beginRebuild(existingTunnel, true);
1103
1291
  logger.info("Initiating tunnel restart", {
1104
1292
  tunnelId: tunnelid,
1105
1293
  configId: existingTunnel.configId
@@ -1111,11 +1299,7 @@ var init_TunnelManager = __esm({
1111
1299
  const currentServe = existingTunnel.serve;
1112
1300
  const autoReconnect = existingTunnel.autoReconnect || false;
1113
1301
  const currentOrigin = existingTunnel.origin;
1114
- if (!existingTunnel.isStopped) {
1115
- await this.stopInstanceGuarded(existingTunnel);
1116
- }
1117
- this.tunnelsByTunnelId.delete(tunnelid);
1118
- this.tunnelsByConfigId.delete(existingTunnel.configId);
1302
+ await this.stopForRebuild(existingTunnel);
1119
1303
  this.tunnelStats.delete(tunnelid);
1120
1304
  this.tunnelStatsListeners.delete(tunnelid);
1121
1305
  this.tunnelErrorListeners.delete(tunnelid);
@@ -1141,6 +1325,7 @@ var init_TunnelManager = __esm({
1141
1325
  }
1142
1326
  await this.startTunnel(newTunnel.tunnelid);
1143
1327
  } catch (error) {
1328
+ this.failRebuild(existingTunnel, error);
1144
1329
  logger.error("Failed to restart tunnel", {
1145
1330
  tunnelid,
1146
1331
  error: error instanceof Error ? error.message : "Unknown error"
@@ -1198,12 +1383,9 @@ var init_TunnelManager = __esm({
1198
1383
  const currentStartedAt = existingTunnel.startedAt;
1199
1384
  const currentStoppedAt = existingTunnel.stoppedAt;
1200
1385
  const requestedServe = this.resolveServePath(newConfig);
1386
+ this.beginRebuild(existingTunnel, !isStopped);
1201
1387
  try {
1202
- if (!isStopped) {
1203
- await this.stopInstanceGuarded(existingTunnel);
1204
- }
1205
- this.tunnelsByTunnelId.delete(currentTunnelId);
1206
- this.tunnelsByConfigId.delete(currentTunnelConfigId);
1388
+ await this.stopForRebuild(existingTunnel);
1207
1389
  const mergedBaseConfig = {
1208
1390
  ...newConfig,
1209
1391
  configId,
@@ -1264,6 +1446,7 @@ var init_TunnelManager = __esm({
1264
1446
  error: errorMessage(restoreError)
1265
1447
  });
1266
1448
  }
1449
+ this.failRebuild(existingTunnel, error);
1267
1450
  throw error;
1268
1451
  }
1269
1452
  }
@@ -2074,120 +2257,6 @@ var init_TunnelManager = __esm({
2074
2257
  }
2075
2258
  });
2076
2259
 
2077
- // src/types.ts
2078
- function isErrorResponse(obj) {
2079
- return typeof obj === "object" && obj !== null && "code" in obj && "message" in obj && typeof obj.message === "string" && Object.values(ErrorCode).includes(obj.code);
2080
- }
2081
- function newErrorResponse(codeOrError, message) {
2082
- if (typeof codeOrError === "object") {
2083
- return codeOrError;
2084
- }
2085
- return {
2086
- code: codeOrError,
2087
- message
2088
- };
2089
- }
2090
- function NewResponseObject(data) {
2091
- const encoder = new TextEncoder();
2092
- const bytes = encoder.encode(JSON.stringify(data));
2093
- return {
2094
- response: bytes,
2095
- requestid: "",
2096
- command: "",
2097
- error: false,
2098
- errorresponse: {}
2099
- };
2100
- }
2101
- function NewErrorResponseObject(errorResponse) {
2102
- return {
2103
- response: new Uint8Array(),
2104
- requestid: "",
2105
- command: "",
2106
- error: true,
2107
- errorresponse: errorResponse
2108
- };
2109
- }
2110
- function newStatus(tunnelState, errorCode, errorMsg) {
2111
- let assignedState = tunnelState;
2112
- if (tunnelState === "live" /* Live */) {
2113
- assignedState = "running" /* Running */;
2114
- } else if (tunnelState === "idle" /* New */) {
2115
- assignedState = "idle" /* New */;
2116
- } else if (tunnelState === "closed" /* Closed */) {
2117
- assignedState = "exited" /* Exited */;
2118
- }
2119
- const now = (/* @__PURE__ */ new Date()).toISOString();
2120
- return {
2121
- state: assignedState,
2122
- errorcode: errorCode,
2123
- errormsg: errorMsg,
2124
- createdtimestamp: now,
2125
- starttimestamp: now,
2126
- endtimestamp: now,
2127
- warnings: []
2128
- };
2129
- }
2130
- function newStats() {
2131
- return {
2132
- numLiveConnections: 0,
2133
- numTotalConnections: 0,
2134
- numTotalReqBytes: 0,
2135
- numTotalResBytes: 0,
2136
- numTotalTxBytes: 0,
2137
- elapsedTime: 0
2138
- };
2139
- }
2140
- var TunnelStateType, TunnelErrorCodeType, TunnelWarningCode, ErrorCode, RemoteManagementStatus;
2141
- var init_types = __esm({
2142
- "src/types.ts"() {
2143
- "use strict";
2144
- init_cjs_shims();
2145
- TunnelStateType = /* @__PURE__ */ ((TunnelStateType2) => {
2146
- TunnelStateType2["New"] = "idle";
2147
- TunnelStateType2["Starting"] = "starting";
2148
- TunnelStateType2["Running"] = "running";
2149
- TunnelStateType2["Live"] = "live";
2150
- TunnelStateType2["Closed"] = "closed";
2151
- TunnelStateType2["Exited"] = "exited";
2152
- return TunnelStateType2;
2153
- })(TunnelStateType || {});
2154
- TunnelErrorCodeType = /* @__PURE__ */ ((TunnelErrorCodeType2) => {
2155
- TunnelErrorCodeType2["NonResponsive"] = "non_responsive";
2156
- TunnelErrorCodeType2["FailedToConnect"] = "failed_to_connect";
2157
- TunnelErrorCodeType2["ErrorInAdditionalForwarding"] = "additional_forwarding_error";
2158
- TunnelErrorCodeType2["WebdebuggerError"] = "webdebugger_error";
2159
- TunnelErrorCodeType2["NoError"] = "";
2160
- return TunnelErrorCodeType2;
2161
- })(TunnelErrorCodeType || {});
2162
- TunnelWarningCode = /* @__PURE__ */ ((TunnelWarningCode2) => {
2163
- TunnelWarningCode2["InvalidTunnelServePath"] = "INVALID_TUNNEL_SERVE_PATH";
2164
- TunnelWarningCode2["UnknownWarning"] = "UNKNOWN_WARNING";
2165
- return TunnelWarningCode2;
2166
- })(TunnelWarningCode || {});
2167
- ErrorCode = {
2168
- InvalidRequestMethodError: "INVALID_REQUEST_METHOD",
2169
- InvalidRequestBodyError: "COULD_NOT_READ_BODY",
2170
- InternalServerError: "INTERNAL_SERVER_ERROR",
2171
- InvalidBodyFormatError: "INVALID_DATA_FORMAT",
2172
- ErrorStartingTunnel: "ERROR_STARTING_TUNNEL",
2173
- TunnelNotFound: "TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND",
2174
- TunnelAlreadyRunningError: "TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING",
2175
- WebsocketUpgradeFailError: "WEBSOCKET_UPGRADE_FAILED",
2176
- RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING",
2177
- RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING",
2178
- RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED"
2179
- };
2180
- RemoteManagementStatus = {
2181
- Connecting: "CONNECTING",
2182
- Disconnecting: "DISCONNECTING",
2183
- Reconnecting: "RECONNECTING",
2184
- Running: "RUNNING",
2185
- NotRunning: "NOT_RUNNING",
2186
- Error: "ERROR"
2187
- };
2188
- }
2189
- });
2190
-
2191
2260
  // src/remote_management/remote_schema.ts
2192
2261
  function pinggyOptionsToTunnelConfigV1(opts, configStoredInCli) {
2193
2262
  const parsedTokens = opts.bearerTokenAuth ? Array.isArray(opts.bearerTokenAuth) ? opts.bearerTokenAuth : JSON.parse(opts.bearerTokenAuth) : [];
@@ -2504,6 +2573,9 @@ var init_handler = __esm({
2504
2573
  }
2505
2574
  if (managed?.lastError) {
2506
2575
  status.lastError = managed.lastError;
2576
+ if (managed.lastError.isFatal) {
2577
+ status.errormsg = managed.lastError.message;
2578
+ }
2507
2579
  }
2508
2580
  } catch (e) {
2509
2581
  }
@@ -2568,6 +2640,14 @@ var init_handler = __esm({
2568
2640
  message: err instanceof Error ? err.message : fallback
2569
2641
  });
2570
2642
  }
2643
+ /**
2644
+ * A restart or update of a tunnel another one is still rebuilding. A no-wait call checks this before
2645
+ * it answers, because a throw from the call it does not await is only logged.
2646
+ */
2647
+ rebuildInProgress(tunnelid) {
2648
+ const err = new TunnelRebuildInProgressError(tunnelid);
2649
+ return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message);
2650
+ }
2571
2651
  // --- Operations ---
2572
2652
  async handleStart(config, noWait = false, origin = "cli") {
2573
2653
  try {
@@ -2630,6 +2710,7 @@ var init_handler = __esm({
2630
2710
  if (noWait) {
2631
2711
  const existing = this.tunnelManager.getManagedTunnel(config.configid);
2632
2712
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2713
+ if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
2633
2714
  if (!existing.isStopped) {
2634
2715
  this.tunnelManager.updateConfig(updateOpts).catch((err) => {
2635
2716
  logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
@@ -2650,6 +2731,7 @@ var init_handler = __esm({
2650
2731
  if (noWait) {
2651
2732
  const existing = this.tunnelManager.getManagedTunnel(config.configId);
2652
2733
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2734
+ if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
2653
2735
  if (!existing.isStopped) {
2654
2736
  this.tunnelManager.updateConfig(config).catch((err) => {
2655
2737
  logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
@@ -2748,6 +2830,7 @@ var init_handler = __esm({
2748
2830
  if (noWait) {
2749
2831
  const managed2 = this.tunnelManager.getManagedTunnel("", tunnelid);
2750
2832
  if (!managed2?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
2833
+ if (managed2.rebuild) return this.rebuildInProgress(tunnelid);
2751
2834
  this.tunnelManager.restartTunnel(tunnelid).catch((err) => {
2752
2835
  logger.error("No-wait restartTunnel failed", { tunnelid, err: String(err) });
2753
2836
  });
@@ -2758,6 +2841,9 @@ var init_handler = __esm({
2758
2841
  if (!managed?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
2759
2842
  return this.buildTunnelResponse(tunnelid, managed.tunnelConfig, managed.configId, managed.tunnelName, managed.serve);
2760
2843
  } catch (err) {
2844
+ if (err instanceof TunnelRebuildInProgressError) {
2845
+ return this.rebuildInProgress(tunnelid);
2846
+ }
2761
2847
  return this.error(ErrorCode.TunnelNotFound, err, "Failed to restart tunnel");
2762
2848
  }
2763
2849
  }
@@ -2800,7 +2886,7 @@ var init_handler = __esm({
2800
2886
  });
2801
2887
 
2802
2888
  // src/daemon/ipc/ipcRoutes.ts
2803
- var IPC_VERSION, Route, DaemonHost, ShutdownStatus, SessionMode;
2889
+ var IPC_VERSION, Route, DaemonHost, ShutdownStatus, DeviceAgentState, SessionMode;
2804
2890
  var init_ipcRoutes = __esm({
2805
2891
  "src/daemon/ipc/ipcRoutes.ts"() {
2806
2892
  "use strict";
@@ -2823,7 +2909,10 @@ var init_ipcRoutes = __esm({
2823
2909
  SetLogLevel: "POST /loglevel",
2824
2910
  GetTunnelLogging: "GET /config/tunnel-logging",
2825
2911
  SetTunnelLogging: "POST /config/tunnel-logging",
2826
- GetLogPaths: "GET /logs/paths"
2912
+ GetLogPaths: "GET /logs/paths",
2913
+ GetDeviceAgent: "GET /device",
2914
+ StartDeviceAgent: "POST /device/start",
2915
+ StopDeviceAgent: "POST /device/stop"
2827
2916
  };
2828
2917
  DaemonHost = {
2829
2918
  CLI: "cli",
@@ -2833,6 +2922,12 @@ var init_ipcRoutes = __esm({
2833
2922
  ShuttingDown: "shutting_down",
2834
2923
  RefusedInApp: "refused_in_app"
2835
2924
  };
2925
+ DeviceAgentState = {
2926
+ Stopped: "stopped",
2927
+ Connecting: "connecting",
2928
+ Connected: "connected",
2929
+ Reconnecting: "reconnecting"
2930
+ };
2836
2931
  SessionMode = {
2837
2932
  Foreground: "foreground",
2838
2933
  Detached: "detached"
@@ -2843,9 +2938,11 @@ var init_ipcRoutes = __esm({
2843
2938
  // src/daemon/ipc/ipcClient.ts
2844
2939
  var ipcClient_exports = {};
2845
2940
  __export(ipcClient_exports, {
2846
- IPCClient: () => IPCClient
2941
+ HTTP_NOT_FOUND: () => HTTP_NOT_FOUND,
2942
+ IPCClient: () => IPCClient,
2943
+ IPCHttpError: () => IPCHttpError
2847
2944
  });
2848
- var import_node_http, REQUEST_TIMEOUT_MS, IPCClient;
2945
+ var import_node_http, REQUEST_TIMEOUT_MS, IPCHttpError, HTTP_NOT_FOUND, IPCClient;
2849
2946
  var init_ipcClient = __esm({
2850
2947
  "src/daemon/ipc/ipcClient.ts"() {
2851
2948
  "use strict";
@@ -2853,6 +2950,14 @@ var init_ipcClient = __esm({
2853
2950
  import_node_http = __toESM(require("http"), 1);
2854
2951
  init_ipcRoutes();
2855
2952
  REQUEST_TIMEOUT_MS = 1e4;
2953
+ IPCHttpError = class extends Error {
2954
+ constructor(statusCode, message) {
2955
+ super(message);
2956
+ this.statusCode = statusCode;
2957
+ this.name = "IPCHttpError";
2958
+ }
2959
+ };
2960
+ HTTP_NOT_FOUND = 404;
2856
2961
  IPCClient = class {
2857
2962
  constructor(port, origin = "cli") {
2858
2963
  this.port = port;
@@ -2919,6 +3024,15 @@ var init_ipcClient = __esm({
2919
3024
  async resolveLogPath(q) {
2920
3025
  return this.request("GET", `/logs/resolve?q=${encodeURIComponent(q)}`);
2921
3026
  }
3027
+ async getDeviceAgentStatus(timeoutMs) {
3028
+ return this.call(Route.GetDeviceAgent, void 0, timeoutMs);
3029
+ }
3030
+ async startDeviceAgent() {
3031
+ return this.call(Route.StartDeviceAgent, {});
3032
+ }
3033
+ async stopDeviceAgent() {
3034
+ return this.call(Route.StopDeviceAgent, {});
3035
+ }
2922
3036
  /**
2923
3037
  * Get the WebSocket URL for event streaming.
2924
3038
  */
@@ -2931,11 +3045,11 @@ var init_ipcClient = __esm({
2931
3045
  call(key, body, timeoutMs) {
2932
3046
  const spaceIdx = key.indexOf(" ");
2933
3047
  const method = key.slice(0, spaceIdx);
2934
- const path11 = key.slice(spaceIdx + 1);
3048
+ const path12 = key.slice(spaceIdx + 1);
2935
3049
  const payload = method === "GET" ? void 0 : JSON.stringify(body ?? {});
2936
- return this.request(method, path11, payload, timeoutMs);
3050
+ return this.request(method, path12, payload, timeoutMs);
2937
3051
  }
2938
- request(method, path11, body, timeoutMs) {
3052
+ request(method, path12, body, timeoutMs) {
2939
3053
  return new Promise((resolve, reject) => {
2940
3054
  const headers = {
2941
3055
  "X-Pinggy-Origin": this.origin
@@ -2948,7 +3062,7 @@ var init_ipcClient = __esm({
2948
3062
  {
2949
3063
  hostname: "127.0.0.1",
2950
3064
  port: this.port,
2951
- path: path11,
3065
+ path: path12,
2952
3066
  method,
2953
3067
  headers,
2954
3068
  timeout: timeoutMs ?? REQUEST_TIMEOUT_MS
@@ -2960,7 +3074,10 @@ var init_ipcClient = __esm({
2960
3074
  const text = Buffer.concat(chunks).toString("utf-8");
2961
3075
  const statusCode = res.statusCode ?? 0;
2962
3076
  if (statusCode < 200 || statusCode >= 300) {
2963
- reject(new Error(`Daemon returned HTTP ${statusCode}: ${text.slice(0, 200)}`));
3077
+ reject(new IPCHttpError(
3078
+ statusCode,
3079
+ `Daemon returned HTTP ${statusCode}: ${text.slice(0, 200)}`
3080
+ ));
2964
3081
  return;
2965
3082
  }
2966
3083
  try {
@@ -3767,42 +3884,42 @@ var init_websocket_handlers = __esm({
3767
3884
  const cmd = (req.command || "").toLowerCase();
3768
3885
  const raw = this.safeParse(req.data);
3769
3886
  try {
3770
- let response;
3887
+ let response2;
3771
3888
  switch (cmd) {
3772
3889
  case WsCommand.Start: {
3773
- response = await this.handleStartReq(req, raw);
3890
+ response2 = await this.handleStartReq(req, raw);
3774
3891
  break;
3775
3892
  }
3776
3893
  case WsCommand.StartV2: {
3777
- response = await this.handleStartV2Req(req, raw);
3894
+ response2 = await this.handleStartV2Req(req, raw);
3778
3895
  break;
3779
3896
  }
3780
3897
  case WsCommand.Stop: {
3781
- response = await this.handleStopReq(req, raw);
3898
+ response2 = await this.handleStopReq(req, raw);
3782
3899
  break;
3783
3900
  }
3784
3901
  case WsCommand.Get: {
3785
- response = await this.handleGetReq(req, raw);
3902
+ response2 = await this.handleGetReq(req, raw);
3786
3903
  break;
3787
3904
  }
3788
3905
  case WsCommand.Restart: {
3789
- response = await this.handleRestartReq(req, raw);
3906
+ response2 = await this.handleRestartReq(req, raw);
3790
3907
  break;
3791
3908
  }
3792
3909
  case WsCommand.UpdateConfig: {
3793
- response = await this.handleUpdateConfigReq(req, raw);
3910
+ response2 = await this.handleUpdateConfigReq(req, raw);
3794
3911
  break;
3795
3912
  }
3796
3913
  case WsCommand.UpdateConfigV2: {
3797
- response = await this.handleUpdateConfigV2Req(req, raw);
3914
+ response2 = await this.handleUpdateConfigV2Req(req, raw);
3798
3915
  break;
3799
3916
  }
3800
3917
  case WsCommand.List: {
3801
- response = await this.handleListReq(req);
3918
+ response2 = await this.handleListReq(req);
3802
3919
  break;
3803
3920
  }
3804
3921
  case WsCommand.ListV2: {
3805
- response = await this.handleListV2Req(req);
3922
+ response2 = await this.handleListV2Req(req);
3806
3923
  break;
3807
3924
  }
3808
3925
  case WsCommand.GetVersion: {
@@ -3815,8 +3932,8 @@ var init_websocket_handlers = __esm({
3815
3932
  }
3816
3933
  return this.sendError(ws, req, "Invalid command");
3817
3934
  }
3818
- logger.debug("Sending response", { command: response.command, requestid: response.requestid });
3819
- this.sendResponse(ws, response);
3935
+ logger.debug("Sending response", { command: response2.command, requestid: response2.requestid });
3936
+ this.sendResponse(ws, response2);
3820
3937
  } catch (e) {
3821
3938
  if (e instanceof import_zod2.default.ZodError) {
3822
3939
  logger.warn("Validation failed", { cmd, issues: e.issues });
@@ -4013,7 +4130,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
4013
4130
  resolve(getRemoteManagementState());
4014
4131
  }
4015
4132
  };
4016
- const runLoop = async () => {
4133
+ const runLoop2 = async () => {
4017
4134
  const sigintHandler = () => {
4018
4135
  _stopRequested = true;
4019
4136
  };
@@ -4041,7 +4158,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
4041
4158
  process.removeListener("SIGINT", sigintHandler);
4042
4159
  logger.info("Remote management stopped.");
4043
4160
  };
4044
- runLoop().catch((err) => settleOnce(err instanceof Error ? err : new Error(String(err))));
4161
+ runLoop2().catch((err) => settleOnce(err instanceof Error ? err : new Error(String(err))));
4045
4162
  });
4046
4163
  }
4047
4164
  function getRemoteManagementState() {
@@ -4622,10 +4739,10 @@ function parseArgs2(finalConfig, remainingPositionals) {
4622
4739
  }
4623
4740
  function storeJson(config, saveconf) {
4624
4741
  if (saveconf) {
4625
- const path11 = saveconf;
4742
+ const path12 = saveconf;
4626
4743
  try {
4627
- import_fs6.default.writeFileSync(path11, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
4628
- logger.info(`Configuration saved to ${path11}`);
4744
+ import_fs6.default.writeFileSync(path12, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
4745
+ logger.info(`Configuration saved to ${path12}`);
4629
4746
  } catch (err) {
4630
4747
  const msg = err instanceof Error ? err.message : String(err);
4631
4748
  logger.error("Error loading configuration:", msg);
@@ -5134,7 +5251,11 @@ var init_configStore = __esm({
5134
5251
  DaemonAlias: "d",
5135
5252
  Logs: "logs",
5136
5253
  Log: "log",
5137
- Restart: "restart"
5254
+ Restart: "restart",
5255
+ Device: "device",
5256
+ // The old spelling, refused with a hint. As a subcommand it never reaches tunnel mode, where `--token` is the
5257
+ // tunnel token and a device token would go to the tunnel server.
5258
+ Devices: "devices"
5138
5259
  };
5139
5260
  ConfigVerb = {
5140
5261
  List: "list",
@@ -6764,7 +6885,7 @@ async function printAlreadyRunning(client, configId, label) {
6764
6885
  async function startTunnel(client, config, opts) {
6765
6886
  const result = await client.handleStartV2(config, false, opts.mode);
6766
6887
  const prefix = opts.label ? `[${opts.label}] ` : "";
6767
- const fail = (reason) => {
6888
+ const fail2 = (reason) => {
6768
6889
  const msg = `${prefix}Failed to start tunnel: ${reason}`;
6769
6890
  if (opts.onError === "fatal") {
6770
6891
  printer_default.error(msg);
@@ -6779,11 +6900,11 @@ async function startTunnel(client, config, opts) {
6779
6900
  await printAlreadyRunning(client, config.configId, opts.label);
6780
6901
  return null;
6781
6902
  }
6782
- return fail(result.message);
6903
+ return fail2(result.message);
6783
6904
  }
6784
6905
  const lastError = result.status?.lastError;
6785
6906
  if (lastError?.isFatal) {
6786
- return fail(lastError.message);
6907
+ return fail2(lastError.message);
6787
6908
  }
6788
6909
  return result;
6789
6910
  }
@@ -7409,106 +7530,3177 @@ function uninstallWindows() {
7409
7530
  console.log("No scheduled task found to remove.");
7410
7531
  }
7411
7532
  }
7412
- function installService() {
7413
- const platform2 = import_node_os.default.platform();
7414
- const bin = resolveBinary();
7415
- if (!("pkg" in process) && bin.program !== process.execPath) {
7416
- console.warn(
7417
- "Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
7418
- );
7533
+ function installService() {
7534
+ const platform2 = import_node_os.default.platform();
7535
+ const bin = resolveBinary();
7536
+ if (!("pkg" in process) && bin.program !== process.execPath) {
7537
+ console.warn(
7538
+ "Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
7539
+ );
7540
+ }
7541
+ switch (platform2) {
7542
+ case "linux":
7543
+ installSystemd();
7544
+ break;
7545
+ case "darwin":
7546
+ installLaunchd();
7547
+ break;
7548
+ case "win32":
7549
+ installWindows();
7550
+ break;
7551
+ default:
7552
+ console.error(`Unsupported platform: ${platform2}`);
7553
+ process.exit(1);
7554
+ }
7555
+ }
7556
+ function uninstallService() {
7557
+ const platform2 = import_node_os.default.platform();
7558
+ switch (platform2) {
7559
+ case "linux":
7560
+ uninstallSystemd();
7561
+ break;
7562
+ case "darwin":
7563
+ uninstallLaunchd();
7564
+ break;
7565
+ case "win32":
7566
+ uninstallWindows();
7567
+ break;
7568
+ default:
7569
+ console.error(`Unsupported platform: ${platform2}`);
7570
+ process.exit(1);
7571
+ }
7572
+ }
7573
+ var import_node_os, import_node_fs3, import_node_path2, import_node_child_process, SERVICE_LABEL, SYSTEMD_SERVICE_NAME, WINDOWS_TASK_NAME;
7574
+ var init_serviceInstaller = __esm({
7575
+ "src/daemon/lifecycle/serviceInstaller.ts"() {
7576
+ "use strict";
7577
+ init_cjs_shims();
7578
+ import_node_os = __toESM(require("os"), 1);
7579
+ import_node_fs3 = __toESM(require("fs"), 1);
7580
+ import_node_path2 = __toESM(require("path"), 1);
7581
+ import_node_child_process = require("child_process");
7582
+ init_logger();
7583
+ SERVICE_LABEL = "io.pinggy.agent";
7584
+ SYSTEMD_SERVICE_NAME = "pinggy";
7585
+ WINDOWS_TASK_NAME = "PinggyDaemon";
7586
+ }
7587
+ });
7588
+
7589
+ // src/utils/helpMessages.ts
7590
+ function printDaemonHelp() {
7591
+ console.log("\nUsage: pinggy daemon <command>");
7592
+ console.log(" pinggy d <command>\n");
7593
+ console.log("Commands:");
7594
+ console.log(" start Start the daemon process");
7595
+ console.log(" stop Stop the daemon (stops all tunnels)");
7596
+ console.log(" status Show daemon PID and uptime");
7597
+ console.log("Tunnel operations:");
7598
+ console.log(" pinggy ps List running tunnels");
7599
+ console.log(" pinggy stop <name|id> Stop a specific tunnel");
7600
+ console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
7601
+ }
7602
+ function printLogHelp() {
7603
+ console.log("\nUsage: pinggy log <verb> [options]\n");
7604
+ console.log("Commands:");
7605
+ console.log(" level Print current log level");
7606
+ console.log(" level debug|info|error Set log level");
7607
+ console.log(" path Print daemon log path");
7608
+ console.log(" path <name|id> Print tunnel log path\n");
7609
+ }
7610
+ function printConfigHelp() {
7611
+ console.log("\nUsage: pinggy config <command> [name] [options]\n");
7612
+ console.log("Commands:");
7613
+ console.log(" list List all saved configs");
7614
+ console.log(" show <name> Show config details");
7615
+ console.log(" save <name> [tunnel flags] Save a tunnel config");
7616
+ console.log(" update <name> [tunnel flags] Update a saved config");
7617
+ console.log(" delete <name> Delete a saved config");
7618
+ console.log(" auto <name> Enable auto-start");
7619
+ console.log(" noauto <name> Disable auto-start\n");
7620
+ }
7621
+ function printStartHelp() {
7622
+ console.log("\nUsage: pinggy start <name> [options]\n");
7623
+ console.log("Examples:");
7624
+ console.log(" pinggy start my-tunnel Start a saved tunnel");
7625
+ console.log(" pinggy start my-tunnel -l 4000 Start with override");
7626
+ console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
7627
+ console.log(" pinggy start --all Start all auto-start tunnels\n");
7628
+ }
7629
+ function printDeviceHelp() {
7630
+ console.log("\nUsage: pinggy device <command> [options]\n");
7631
+ console.log("Run this machine as a Pinggy device.\n");
7632
+ console.log("Commands:");
7633
+ console.log(" login Sign this machine in with your email, then stay connected");
7634
+ console.log(" connect --token <TOKEN> Enrol, then stay connected until interrupted");
7635
+ console.log(" stop Stop the agent running in the background");
7636
+ console.log(" status Show the enrolment, and the agent running in the background");
7637
+ console.log(" remove Forget the local credential (does not delete the device)\n");
7638
+ console.log("Options:");
7639
+ console.log(" -b login and connect: run the agent in the background, in the daemon.");
7640
+ console.log(" It stays connected after this terminal closes");
7641
+ console.log(" --token <TOKEN> Device token from the dashboard");
7642
+ console.log(" --manage <HOST> Dashboard address. Default: dashboard.pinggy.io");
7643
+ console.log(" --name <NAME> login only: the device's name. Default: this machine's hostname\n");
7644
+ console.log("Environment:");
7645
+ console.log(" DEVICE_AGENT_TOKEN connect only: the device token, when --token is not given. With no");
7646
+ console.log(" --manage, it dials dashboard.pinggy.io. Removed once read\n");
7647
+ }
7648
+ var init_helpMessages = __esm({
7649
+ "src/utils/helpMessages.ts"() {
7650
+ "use strict";
7651
+ init_cjs_shims();
7652
+ }
7653
+ });
7654
+
7655
+ // src/devices/deviceIdentity.ts
7656
+ function readDeviceIdentity() {
7657
+ const filePath = getDeviceConfigPath();
7658
+ if (!import_fs8.default.existsSync(filePath)) {
7659
+ return null;
7660
+ }
7661
+ try {
7662
+ return JSON.parse(import_fs8.default.readFileSync(filePath, "utf-8"));
7663
+ } catch (e) {
7664
+ logger.warn("Could not read device identity file", { error: String(e) });
7665
+ return null;
7666
+ }
7667
+ }
7668
+ function writeDeviceIdentity(identity) {
7669
+ ensurePinggyConfigDir();
7670
+ const filePath = getDeviceConfigPath();
7671
+ import_fs8.default.writeFileSync(filePath, JSON.stringify(identity, null, 2), {
7672
+ encoding: "utf-8",
7673
+ mode: SECRET_FILE_MODE
7674
+ });
7675
+ import_fs8.default.chmodSync(filePath, SECRET_FILE_MODE);
7676
+ }
7677
+ function clearDeviceIdentity() {
7678
+ const filePath = getDeviceConfigPath();
7679
+ if (!import_fs8.default.existsSync(filePath)) {
7680
+ return false;
7681
+ }
7682
+ import_fs8.default.unlinkSync(filePath);
7683
+ return true;
7684
+ }
7685
+ function maskToken(token) {
7686
+ return token.length <= 8 ? "***" : `${token.slice(0, 8)}...${token.slice(-4)}`;
7687
+ }
7688
+ var import_fs8, SECRET_FILE_MODE;
7689
+ var init_deviceIdentity = __esm({
7690
+ "src/devices/deviceIdentity.ts"() {
7691
+ "use strict";
7692
+ init_cjs_shims();
7693
+ import_fs8 = __toESM(require("fs"), 1);
7694
+ init_configDir();
7695
+ init_logger();
7696
+ SECRET_FILE_MODE = 384;
7697
+ }
7698
+ });
7699
+
7700
+ // src/devices/envelope.ts
7701
+ function nowSeconds() {
7702
+ return Math.floor(Date.now() / 1e3);
7703
+ }
7704
+ function request(ch, op, payload) {
7705
+ return { v: PROTOCOL_VERSION, kind: "req", ch, op, id: (0, import_crypto2.randomUUID)(), seq: 0, ts: nowSeconds(), payload };
7706
+ }
7707
+ function response(to, op, payload) {
7708
+ return { v: PROTOCOL_VERSION, kind: "res", ch: to.ch, op, id: to.id, seq: 0, ts: nowSeconds(), payload };
7709
+ }
7710
+ function event(ch, op, payload) {
7711
+ return { v: PROTOCOL_VERSION, kind: "event", ch, op, id: "", seq: 0, ts: nowSeconds(), payload };
7712
+ }
7713
+ function parseEnvelope(raw) {
7714
+ try {
7715
+ const parsed = JSON.parse(raw);
7716
+ if (typeof parsed !== "object" || parsed === null) return null;
7717
+ if (typeof parsed.ch !== "string" || typeof parsed.op !== "string") return null;
7718
+ return parsed;
7719
+ } catch {
7720
+ return null;
7721
+ }
7722
+ }
7723
+ var import_crypto2, PROTOCOL_VERSION, CHANNEL_SYSTEM, CHANNEL_DEVICE, OP_HELLO, OP_WELCOME, OP_HEARTBEAT, OP_DISCONNECT, OP_INFO, OP_METRICS, OP_TUNNELS, CHANNEL_TUNNEL, OP_START, OP_STOP, OP_RESTART, OP_UPDATE;
7724
+ var init_envelope = __esm({
7725
+ "src/devices/envelope.ts"() {
7726
+ "use strict";
7727
+ init_cjs_shims();
7728
+ import_crypto2 = require("crypto");
7729
+ PROTOCOL_VERSION = 1;
7730
+ CHANNEL_SYSTEM = "system";
7731
+ CHANNEL_DEVICE = "device";
7732
+ OP_HELLO = "hello";
7733
+ OP_WELCOME = "welcome";
7734
+ OP_HEARTBEAT = "heartbeat";
7735
+ OP_DISCONNECT = "disconnect";
7736
+ OP_INFO = "info";
7737
+ OP_METRICS = "metrics";
7738
+ OP_TUNNELS = "tunnels";
7739
+ CHANNEL_TUNNEL = "tunnel";
7740
+ OP_START = "start";
7741
+ OP_STOP = "stop";
7742
+ OP_RESTART = "restart";
7743
+ OP_UPDATE = "update";
7744
+ }
7745
+ });
7746
+
7747
+ // src/devices/device_schema.ts
7748
+ var import_zod3, WelcomeSchema, ErrorPayloadSchema, DisconnectSchema, TunnelActionSchema;
7749
+ var init_device_schema = __esm({
7750
+ "src/devices/device_schema.ts"() {
7751
+ "use strict";
7752
+ init_cjs_shims();
7753
+ import_zod3 = require("zod");
7754
+ WelcomeSchema = import_zod3.z.object({
7755
+ device_agent_id: import_zod3.z.string(),
7756
+ accepted_proto: import_zod3.z.number(),
7757
+ heartbeat_interval_seconds: import_zod3.z.number(),
7758
+ stats_interval_seconds: import_zod3.z.number(),
7759
+ server_time: import_zod3.z.number(),
7760
+ max_frame_bytes: import_zod3.z.number(),
7761
+ // Optional: a dashboard that predates terminals sends none of these.
7762
+ terminal_enabled: import_zod3.z.boolean().optional(),
7763
+ max_terminals_per_device: import_zod3.z.number().optional(),
7764
+ // No default on the agent. Without it, terminals stay unavailable rather than unbraked.
7765
+ terminal_window_bytes: import_zod3.z.number().optional(),
7766
+ // No default either. Without them the agent runs no expiry timers, and the dashboard's sweep
7767
+ // still ends an expired shell, only later.
7768
+ terminal_idle_timeout_seconds: import_zod3.z.number().optional(),
7769
+ terminal_max_session_seconds: import_zod3.z.number().optional(),
7770
+ // Optional: a dashboard older than these sends neither, and the agent prints only the device id.
7771
+ email: import_zod3.z.string().optional(),
7772
+ device_url: import_zod3.z.string().optional()
7773
+ });
7774
+ ErrorPayloadSchema = import_zod3.z.object({
7775
+ error: import_zod3.z.object({
7776
+ code: import_zod3.z.string(),
7777
+ message: import_zod3.z.string()
7778
+ })
7779
+ });
7780
+ DisconnectSchema = import_zod3.z.object({
7781
+ reason: import_zod3.z.string()
7782
+ });
7783
+ TunnelActionSchema = import_zod3.z.object({
7784
+ tunnel_id: import_zod3.z.string().min(1).max(128).optional(),
7785
+ source: import_zod3.z.enum(["device", "dashboard"]).optional(),
7786
+ config_id: import_zod3.z.string().min(1).max(128).optional(),
7787
+ config: import_zod3.z.record(import_zod3.z.string(), import_zod3.z.unknown()).optional()
7788
+ });
7789
+ }
7790
+ });
7791
+
7792
+ // src/devices/deviceAgentReporter.ts
7793
+ function printConnected(welcome) {
7794
+ printer_default.success(`Connected as device ${welcome.device_agent_id}`);
7795
+ if (welcome.email) {
7796
+ printer_default.print(`Signed in as ${welcome.email}`);
7797
+ }
7798
+ if (welcome.device_url) {
7799
+ printer_default.print("Open the device in a browser:");
7800
+ printer_default.print(` ${welcome.device_url}`);
7801
+ }
7802
+ }
7803
+ function formatSeconds(millis) {
7804
+ return (millis / 1e3).toFixed(1);
7805
+ }
7806
+ var printingReporter;
7807
+ var init_deviceAgentReporter = __esm({
7808
+ "src/devices/deviceAgentReporter.ts"() {
7809
+ "use strict";
7810
+ init_cjs_shims();
7811
+ init_printer();
7812
+ printingReporter = {
7813
+ connecting: (server) => printer_default.print(`Connecting to ${server}`),
7814
+ connected: printConnected,
7815
+ reconnecting: (delayMillis, attempt) => printer_default.warn(
7816
+ `Disconnected. Reconnecting in ${formatSeconds(delayMillis)} seconds (attempt ${attempt}).`
7817
+ ),
7818
+ warn: (message) => printer_default.warn(message),
7819
+ failed: (error) => printer_default.error(error.message),
7820
+ replacingDevice: (oldDeviceAgentId) => printer_default.print(`This machine was device ${oldDeviceAgentId}. Connecting it with the token from the command.`)
7821
+ };
7822
+ }
7823
+ });
7824
+
7825
+ // src/devices/collectors/systemInfo.ts
7826
+ function collectSystemInfo() {
7827
+ const cpus = import_os3.default.cpus();
7828
+ return {
7829
+ hostname: import_os3.default.hostname(),
7830
+ os: import_os3.default.platform(),
7831
+ os_version: import_os3.default.release(),
7832
+ // os.machine() reports what uname does (x86_64, arm64, aarch64). os.arch() reports the Node
7833
+ // build target (x64), which is not what an operator reads on the machine itself.
7834
+ arch: import_os3.default.machine(),
7835
+ kernel: import_os3.default.type(),
7836
+ // os.cpus() is empty on some Android and container builds.
7837
+ cpu_model: cpus[0]?.model?.trim() || UNKNOWN_CPU_MODEL,
7838
+ cpu_cores: cpus.length,
7839
+ total_memory_bytes: import_os3.default.totalmem()
7840
+ };
7841
+ }
7842
+ var import_os3, UNKNOWN_CPU_MODEL;
7843
+ var init_systemInfo = __esm({
7844
+ "src/devices/collectors/systemInfo.ts"() {
7845
+ "use strict";
7846
+ init_cjs_shims();
7847
+ import_os3 = __toESM(require("os"), 1);
7848
+ UNKNOWN_CPU_MODEL = "unknown";
7849
+ }
7850
+ });
7851
+
7852
+ // src/devices/collectors/metrics.ts
7853
+ function sumCpuTicks(cpus) {
7854
+ let idleTicks = 0;
7855
+ let totalTicks = 0;
7856
+ for (const cpu of cpus) {
7857
+ const { user, nice, sys, idle, irq } = cpu.times;
7858
+ idleTicks += idle;
7859
+ totalTicks += user + nice + sys + idle + irq;
7860
+ }
7861
+ return { idleTicks, totalTicks };
7862
+ }
7863
+ function cpuPercentBetween(earlier, later) {
7864
+ const totalDelta = later.totalTicks - earlier.totalTicks;
7865
+ if (totalDelta <= 0) {
7866
+ return 0;
7867
+ }
7868
+ const busyDelta = totalDelta - (later.idleTicks - earlier.idleTicks);
7869
+ const percent = Math.min(100, Math.max(0, 100 * busyDelta / totalDelta));
7870
+ return Math.round(percent * 10) / 10;
7871
+ }
7872
+ function buildMetrics(readings) {
7873
+ const [oneMinute = 0, fiveMinutes = 0, fifteenMinutes = 0] = readings.loadAverages;
7874
+ return {
7875
+ cpu_percent: readings.cpuPercent,
7876
+ load_avg_1m: oneMinute,
7877
+ load_avg_5m: fiveMinutes,
7878
+ load_avg_15m: fifteenMinutes,
7879
+ memory_used_bytes: readings.totalMemoryBytes - readings.freeMemoryBytes,
7880
+ memory_total_bytes: readings.totalMemoryBytes,
7881
+ uptime_seconds: Math.floor(readings.uptimeSeconds),
7882
+ collected_at: readings.collectedAtEpochSeconds
7883
+ };
7884
+ }
7885
+ async function collectMetrics() {
7886
+ const earlier = sumCpuTicks(import_os4.default.cpus());
7887
+ await new Promise((resolve) => setTimeout(resolve, CPU_SAMPLE_WINDOW_MS));
7888
+ const later = sumCpuTicks(import_os4.default.cpus());
7889
+ return buildMetrics({
7890
+ cpuPercent: cpuPercentBetween(earlier, later),
7891
+ loadAverages: import_os4.default.loadavg(),
7892
+ totalMemoryBytes: import_os4.default.totalmem(),
7893
+ freeMemoryBytes: import_os4.default.freemem(),
7894
+ uptimeSeconds: import_os4.default.uptime(),
7895
+ collectedAtEpochSeconds: Math.floor(Date.now() / 1e3)
7896
+ });
7897
+ }
7898
+ var import_os4, CPU_SAMPLE_WINDOW_MS;
7899
+ var init_metrics = __esm({
7900
+ "src/devices/collectors/metrics.ts"() {
7901
+ "use strict";
7902
+ init_cjs_shims();
7903
+ import_os4 = __toESM(require("os"), 1);
7904
+ CPU_SAMPLE_WINDOW_MS = 200;
7905
+ }
7906
+ });
7907
+
7908
+ // src/devices/tunnels/tunnelList.ts
7909
+ async function readDaemonOnPort(port) {
7910
+ try {
7911
+ const answer = await new IPCClient(port, "cli").listTunnels();
7912
+ return { running: true, tunnels: Array.isArray(answer) ? answer : [] };
7913
+ } catch (err) {
7914
+ logger.debug("Device agent could not list the daemon's tunnels", { error: String(err) });
7915
+ return { running: false, reason: REASON_DAEMON_UNREACHABLE };
7916
+ }
7917
+ }
7918
+ function selfTunnelListSource(port) {
7919
+ return {
7920
+ readDaemon: () => readDaemonOnPort(port),
7921
+ readSavedConfigs: listSavedConfigs
7922
+ };
7923
+ }
7924
+ function isObject(value) {
7925
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7926
+ }
7927
+ function stringOrNull(value) {
7928
+ return typeof value === "string" && value !== "" ? value : null;
7929
+ }
7930
+ function toEpochSeconds(value) {
7931
+ if (typeof value !== "string" || value === "") return null;
7932
+ const millis = Date.parse(value);
7933
+ return Number.isNaN(millis) ? null : Math.floor(millis / 1e3);
7934
+ }
7935
+ function sanitizeErrorMessage(value) {
7936
+ if (typeof value !== "string") return null;
7937
+ const printable = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
7938
+ return printable === "" ? null : printable.slice(0, ERROR_MESSAGE_MAX_LENGTH);
7939
+ }
7940
+ function forwardingType(value) {
7941
+ return typeof value === "string" && FORWARDING_TYPES.has(value) ? value : DEFAULT_FORWARDING_TYPE;
7942
+ }
7943
+ function toForwarding(forwarding) {
7944
+ if (typeof forwarding === "string") {
7945
+ if (forwarding.trim() === "") return [];
7946
+ const schemeMatch = /^([a-z]+):\/\/(.+)$/i.exec(forwarding.trim());
7947
+ if (!schemeMatch) {
7948
+ return [{ type: DEFAULT_FORWARDING_TYPE, local_address: forwarding.trim() }];
7949
+ }
7950
+ const scheme = schemeMatch[1].toLowerCase();
7951
+ return [{
7952
+ type: scheme === "https" ? DEFAULT_FORWARDING_TYPE : forwardingType(scheme),
7953
+ local_address: schemeMatch[2]
7954
+ }];
7955
+ }
7956
+ if (!Array.isArray(forwarding)) return [];
7957
+ return forwarding.filter(isObject).filter((entry) => typeof entry.address === "string").map((entry) => ({ type: forwardingType(entry.type), local_address: entry.address }));
7958
+ }
7959
+ function toFullConfig(rawConfig) {
7960
+ const parsed = TunnelConfigV1Schema.safeParse(rawConfig);
7961
+ return parsed.success ? parsed.data : null;
7962
+ }
7963
+ function toTunnelRow(daemonTunnel) {
7964
+ if (!isObject(daemonTunnel) || typeof daemonTunnel.tunnelid !== "string") return null;
7965
+ const config = isObject(daemonTunnel.tunnelconfig) ? daemonTunnel.tunnelconfig : {};
7966
+ const status = isObject(daemonTunnel.status) ? daemonTunnel.status : {};
7967
+ const remoteUrls = Array.isArray(daemonTunnel.remoteurls) ? daemonTunnel.remoteurls.filter((url) => typeof url === "string") : [];
7968
+ return {
7969
+ tunnel_id: daemonTunnel.tunnelid,
7970
+ config_id: stringOrNull(config.configId),
7971
+ name: stringOrNull(config.name),
7972
+ state: typeof status.state === "string" && STATES.has(status.state) ? status.state : "idle" /* New */,
7973
+ error_message: sanitizeErrorMessage(status.errormsg),
7974
+ remote_urls: remoteUrls,
7975
+ forwarding: toForwarding(config.forwarding),
7976
+ mode: typeof daemonTunnel.mode === "string" && MODES.has(daemonTunnel.mode) ? daemonTunnel.mode : null,
7977
+ created_at: toEpochSeconds(status.createdtimestamp),
7978
+ started_at: toEpochSeconds(status.starttimestamp),
7979
+ tunnel_config: toFullConfig(daemonTunnel.tunnelconfig)
7980
+ };
7981
+ }
7982
+ function newestFirst(rows, timeOf) {
7983
+ return [...rows].sort((a, b) => (timeOf(b) ?? 0) - (timeOf(a) ?? 0));
7984
+ }
7985
+ function buildTunnelList(reading, savedConfigs, collectedAtEpochSeconds) {
7986
+ const allTunnels = reading.running ? newestFirst(
7987
+ reading.tunnels.map(toTunnelRow).filter((row) => row !== null),
7988
+ (row) => row.created_at
7989
+ ) : [];
7990
+ const runningConfigIds = new Set(allTunnels.filter((row) => row.config_id !== null && !STOPPED_STATES.has(row.state)).map((row) => row.config_id));
7991
+ const allSaved = newestFirst(savedConfigs, (config) => toEpochSeconds(config.createdAt)).map((config) => ({
7992
+ config_id: config.configId,
7993
+ name: config.name,
7994
+ running: runningConfigIds.has(config.configId),
7995
+ // The same shape tunnelActions.ts sends the daemon to start this exact config.
7996
+ tunnel_config: { ...config.tunnelConfig, configId: config.configId, name: config.name }
7997
+ }));
7998
+ return {
7999
+ daemon_running: reading.running,
8000
+ daemon_unavailable_reason: reading.running ? null : reading.reason,
8001
+ tunnels: allTunnels.slice(0, MAX_TUNNELS),
8002
+ saved_configs: allSaved.slice(0, MAX_SAVED_CONFIGS),
8003
+ truncated: allTunnels.length > MAX_TUNNELS || allSaved.length > MAX_SAVED_CONFIGS,
8004
+ collected_at: collectedAtEpochSeconds
8005
+ };
8006
+ }
8007
+ async function collectTunnelList(source = machineTunnelListSource) {
8008
+ const reading = await source.readDaemon();
8009
+ let savedConfigs = [];
8010
+ try {
8011
+ savedConfigs = source.readSavedConfigs();
8012
+ } catch (err) {
8013
+ logger.debug("Device agent could not read saved tunnel configs", { error: String(err) });
8014
+ }
8015
+ return buildTunnelList(reading, savedConfigs, Math.floor(Date.now() / 1e3));
8016
+ }
8017
+ function changeKey(list) {
8018
+ const { collected_at: _collectedAt, ...comparable } = list;
8019
+ return JSON.stringify(comparable);
8020
+ }
8021
+ function startTunnelReporting(collect, send, intervalMillis = TUNNEL_POLL_INTERVAL_MILLIS) {
8022
+ let stopped = false;
8023
+ let inFlight = false;
8024
+ let pollAgain = false;
8025
+ let lastSentKey = null;
8026
+ const poll = () => {
8027
+ if (stopped) return;
8028
+ if (inFlight) {
8029
+ pollAgain = true;
8030
+ return;
8031
+ }
8032
+ inFlight = true;
8033
+ collect().then((list) => {
8034
+ if (stopped) return;
8035
+ const key = changeKey(list);
8036
+ if (key === lastSentKey) return;
8037
+ lastSentKey = key;
8038
+ send(list);
8039
+ }).catch((err) => logger.warn("Device tunnel list collection failed", { error: String(err) })).finally(() => {
8040
+ inFlight = false;
8041
+ if (pollAgain) {
8042
+ pollAgain = false;
8043
+ poll();
8044
+ }
8045
+ });
8046
+ };
8047
+ poll();
8048
+ const timer = setInterval(poll, intervalMillis);
8049
+ const stop = () => {
8050
+ stopped = true;
8051
+ clearInterval(timer);
8052
+ };
8053
+ return Object.assign(stop, { pollNow: poll });
8054
+ }
8055
+ var TUNNEL_POLL_INTERVAL_MILLIS, MAX_TUNNELS, MAX_SAVED_CONFIGS, ERROR_MESSAGE_MAX_LENGTH, REASON_IPC_VERSION_MISMATCH, REASON_DAEMON_UNREACHABLE, FORWARDING_TYPES, DEFAULT_FORWARDING_TYPE, MODES, STATES, STOPPED_STATES, machineTunnelListSource;
8056
+ var init_tunnelList = __esm({
8057
+ "src/devices/tunnels/tunnelList.ts"() {
8058
+ "use strict";
8059
+ init_cjs_shims();
8060
+ init_logger();
8061
+ init_configStore();
8062
+ init_daemonManager();
8063
+ init_ipcClient();
8064
+ init_remote_schema();
8065
+ init_types();
8066
+ TUNNEL_POLL_INTERVAL_MILLIS = 5e3;
8067
+ MAX_TUNNELS = 50;
8068
+ MAX_SAVED_CONFIGS = 100;
8069
+ ERROR_MESSAGE_MAX_LENGTH = 256;
8070
+ REASON_IPC_VERSION_MISMATCH = "ipc_version_mismatch";
8071
+ REASON_DAEMON_UNREACHABLE = "daemon_unreachable";
8072
+ FORWARDING_TYPES = /* @__PURE__ */ new Set(["http", "tcp", "udp", "tls", "tlstcp"]);
8073
+ DEFAULT_FORWARDING_TYPE = "http";
8074
+ MODES = /* @__PURE__ */ new Set(["foreground", "detached"]);
8075
+ STATES = new Set(Object.values(TunnelStateType));
8076
+ STOPPED_STATES = /* @__PURE__ */ new Set(["closed" /* Closed */, "exited" /* Exited */]);
8077
+ machineTunnelListSource = {
8078
+ async readDaemon() {
8079
+ const info = getDaemonInfo();
8080
+ if (!info) {
8081
+ return { running: false, reason: null };
8082
+ }
8083
+ if (!isIpcCompatible(info)) {
8084
+ return { running: false, reason: REASON_IPC_VERSION_MISMATCH };
8085
+ }
8086
+ return readDaemonOnPort(info.port);
8087
+ },
8088
+ readSavedConfigs: listSavedConfigs
8089
+ };
8090
+ }
8091
+ });
8092
+
8093
+ // src/devices/tunnels/tunnelActions.ts
8094
+ function selfTunnelActionDependencies(port) {
8095
+ return {
8096
+ daemon: () => Promise.resolve(new IPCClient(port, "device")),
8097
+ readSavedConfigs: machineTunnelActionDependencies.readSavedConfigs,
8098
+ writeSavedConfig: machineTunnelActionDependencies.writeSavedConfig
8099
+ };
8100
+ }
8101
+ function failure(code, message) {
8102
+ return { error: { code, message } };
8103
+ }
8104
+ function actionFailed(message) {
8105
+ return failure(ERROR_TUNNEL_ACTION_FAILED, sanitizeErrorMessage(message) ?? "The tunnel action failed.");
8106
+ }
8107
+ function toResult(daemonAnswer, fallbackTunnelId) {
8108
+ if (isErrorResponse(daemonAnswer)) {
8109
+ if (daemonAnswer.code === ErrorCode.TunnelNotFound) {
8110
+ return failure(ERROR_TUNNEL_NOT_FOUND, sanitizeErrorMessage(daemonAnswer.message) ?? "No such tunnel.");
8111
+ }
8112
+ return actionFailed(daemonAnswer.message);
8113
+ }
8114
+ const answer = typeof daemonAnswer === "object" && daemonAnswer !== null ? daemonAnswer : {};
8115
+ const tunnelId = typeof answer.tunnelid === "string" && answer.tunnelid !== "" ? answer.tunnelid : fallbackTunnelId;
8116
+ if (!tunnelId) {
8117
+ return actionFailed("The daemon did not name the tunnel.");
8118
+ }
8119
+ const state = typeof answer.status?.state === "string" ? answer.status.state : STATE_STARTING;
8120
+ return { tunnel_id: tunnelId, state };
8121
+ }
8122
+ function withoutNulls(config) {
8123
+ return Object.fromEntries(Object.entries(config).filter(([, value]) => value !== null));
8124
+ }
8125
+ function targetOf(op, action) {
8126
+ const namesTunnel = action.tunnel_id !== void 0;
8127
+ if (op === OP_UPDATE) {
8128
+ if (namesTunnel || action.config === void 0) {
8129
+ return null;
8130
+ }
8131
+ if (action.source === "dashboard" && action.config_id === void 0) {
8132
+ return { kind: "dashboard", config: action.config };
8133
+ }
8134
+ if (action.source === "device" && action.config_id !== void 0) {
8135
+ return { kind: "savedEdit", configId: action.config_id, config: action.config };
8136
+ }
8137
+ return null;
8138
+ }
8139
+ if (op !== OP_START) {
8140
+ return namesTunnel ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
8141
+ }
8142
+ if (namesTunnel) {
8143
+ return action.source === void 0 && action.config_id === void 0 && action.config === void 0 ? { kind: "tunnel", tunnelId: action.tunnel_id } : null;
8144
+ }
8145
+ if (action.source === "device" && action.config_id !== void 0 && action.config === void 0) {
8146
+ return { kind: "saved", configId: action.config_id };
8147
+ }
8148
+ if (action.source === "dashboard" && action.config !== void 0 && action.config_id === void 0) {
8149
+ return { kind: "dashboard", config: action.config };
8150
+ }
8151
+ return null;
8152
+ }
8153
+ async function perform(op, action, dependencies) {
8154
+ const target = targetOf(op, action);
8155
+ if (!target) {
8156
+ return failure(ERROR_INVALID_PAYLOAD, "The tunnel request names no tunnel or config.");
8157
+ }
8158
+ if (target.kind === "tunnel") {
8159
+ const client2 = await dependencies.daemon(false);
8160
+ if (!client2) {
8161
+ return NO_DAEMON;
8162
+ }
8163
+ const answer = op === OP_STOP ? await client2.stopTunnel(target.tunnelId) : await client2.restartTunnel(target.tunnelId, true);
8164
+ return toResult(answer, target.tunnelId);
8165
+ }
8166
+ if (target.kind === "savedEdit") {
8167
+ return rewriteSavedConfig(target.configId, target.config, dependencies);
8168
+ }
8169
+ let config;
8170
+ if (target.kind === "saved") {
8171
+ const saved = dependencies.readSavedConfigs().find((candidate) => candidate.configId === target.configId);
8172
+ if (!saved) {
8173
+ return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
8174
+ }
8175
+ config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name };
8176
+ } else {
8177
+ const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(target.config));
8178
+ if (!parsed.success) {
8179
+ return failure(ERROR_INVALID_PAYLOAD, "The dashboard config could not be read.");
8180
+ }
8181
+ config = parsed.data;
8182
+ }
8183
+ if (op === OP_UPDATE) {
8184
+ return updateTunnel(config, dependencies);
8185
+ }
8186
+ const client = await dependencies.daemon(true);
8187
+ if (!client) {
8188
+ return actionFailed("The tunnel daemon could not be started.");
8189
+ }
8190
+ return toResult(await client.startTunnelWithConfig(config, SessionMode.Detached, true), null);
8191
+ }
8192
+ function rewriteSavedConfig(configId, rawConfig, dependencies) {
8193
+ const parsed = TunnelConfigV1Schema.safeParse(withoutNulls(rawConfig));
8194
+ if (!parsed.success) {
8195
+ return failure(ERROR_INVALID_PAYLOAD, "The config could not be read.");
8196
+ }
8197
+ const savedConfigs = dependencies.readSavedConfigs();
8198
+ const saved = savedConfigs.find((candidate) => candidate.configId === configId);
8199
+ if (!saved) {
8200
+ return failure(ERROR_SAVED_CONFIG_NOT_FOUND, "No config with that id is saved on this machine.");
8201
+ }
8202
+ const name = parsed.data.name || saved.name;
8203
+ if (name !== saved.name) {
8204
+ const nameError = validateNameStrict(name);
8205
+ if (nameError) {
8206
+ return failure(ERROR_INVALID_PAYLOAD, nameError.message);
8207
+ }
8208
+ if (savedConfigs.some((candidate) => candidate.name === name)) {
8209
+ return failure(ERROR_SAVED_CONFIG_NAME_TAKEN, `A config named "${name}" is already saved on this machine.`);
8210
+ }
8211
+ }
8212
+ dependencies.writeSavedConfig({
8213
+ ...saved,
8214
+ name,
8215
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
8216
+ tunnelConfig: { ...parsed.data, configId, name }
8217
+ });
8218
+ return { config_id: configId };
8219
+ }
8220
+ async function updateTunnel(config, dependencies) {
8221
+ const client = await dependencies.daemon(false);
8222
+ if (!client) {
8223
+ return NO_DAEMON;
8224
+ }
8225
+ const listed = await client.listTunnels();
8226
+ if (!Array.isArray(listed)) {
8227
+ return actionFailed(isErrorResponse(listed) ? listed.message : "The daemon did not list its tunnels.");
8228
+ }
8229
+ const running = listed.find((candidate) => candidate.tunnelconfig?.configId === config.configId);
8230
+ if (!running) {
8231
+ return failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel on this machine runs that config.");
8232
+ }
8233
+ return toResult(await client.updateConfigV2(config, true), running.tunnelid);
8234
+ }
8235
+ async function handleTunnelAction(envelope, send, dependencies = machineTunnelActionDependencies) {
8236
+ if (envelope.kind !== "req") {
8237
+ return;
8238
+ }
8239
+ const op = envelope.op;
8240
+ if (op !== OP_START && op !== OP_STOP && op !== OP_RESTART && op !== OP_UPDATE) {
8241
+ send(response(envelope, op, failure(ERROR_UNSUPPORTED_OP, `Unsupported operation: tunnel/${op}`)));
8242
+ return;
8243
+ }
8244
+ const parsed = TunnelActionSchema.safeParse(envelope.payload);
8245
+ if (!parsed.success) {
8246
+ send(response(envelope, op, failure(ERROR_INVALID_PAYLOAD, "The tunnel request could not be read.")));
8247
+ return;
8248
+ }
8249
+ let result;
8250
+ try {
8251
+ result = await perform(op, parsed.data, dependencies);
8252
+ } catch (err) {
8253
+ result = actionFailed(err instanceof Error ? err.message : String(err));
8254
+ }
8255
+ logger.info("Device agent tunnel action", {
8256
+ op,
8257
+ tunnelId: parsed.data.tunnel_id ?? null,
8258
+ configId: parsed.data.config_id ?? null,
8259
+ source: parsed.data.source ?? null,
8260
+ outcome: "error" in result ? result.error.code : "accepted"
8261
+ });
8262
+ send(response(envelope, op, result));
8263
+ }
8264
+ var ERROR_INVALID_PAYLOAD, ERROR_UNSUPPORTED_OP, ERROR_TUNNEL_NOT_FOUND, ERROR_SAVED_CONFIG_NOT_FOUND, ERROR_SAVED_CONFIG_NAME_TAKEN, ERROR_TUNNEL_ACTION_FAILED, STATE_STARTING, DaemonUnusableError, machineTunnelActionDependencies, NO_DAEMON;
8265
+ var init_tunnelActions = __esm({
8266
+ "src/devices/tunnels/tunnelActions.ts"() {
8267
+ "use strict";
8268
+ init_cjs_shims();
8269
+ init_logger();
8270
+ init_configStore();
8271
+ init_daemonManager();
8272
+ init_ipcClient();
8273
+ init_ipcRoutes();
8274
+ init_remote_schema();
8275
+ init_types();
8276
+ init_envelope();
8277
+ init_device_schema();
8278
+ init_tunnelList();
8279
+ ERROR_INVALID_PAYLOAD = "invalid_payload";
8280
+ ERROR_UNSUPPORTED_OP = "unsupported_op";
8281
+ ERROR_TUNNEL_NOT_FOUND = "tunnel_not_found";
8282
+ ERROR_SAVED_CONFIG_NOT_FOUND = "saved_config_not_found";
8283
+ ERROR_SAVED_CONFIG_NAME_TAKEN = "saved_config_name_taken";
8284
+ ERROR_TUNNEL_ACTION_FAILED = "tunnel_action_failed";
8285
+ STATE_STARTING = "starting";
8286
+ DaemonUnusableError = class extends Error {
8287
+ };
8288
+ machineTunnelActionDependencies = {
8289
+ async daemon(startIfMissing) {
8290
+ const info = getDaemonInfo();
8291
+ if (info) {
8292
+ if (!isIpcCompatible(info)) {
8293
+ throw new DaemonUnusableError(ipcMismatchMessage(info));
8294
+ }
8295
+ return new IPCClient(info.port, "device");
8296
+ }
8297
+ if (!startIfMissing) {
8298
+ return null;
8299
+ }
8300
+ const started = await ensureDaemonRunning();
8301
+ return new IPCClient(started.port, "device");
8302
+ },
8303
+ readSavedConfigs: listSavedConfigs,
8304
+ writeSavedConfig: (saved) => {
8305
+ upsertConfig(saved);
8306
+ }
8307
+ };
8308
+ NO_DAEMON = failure(ERROR_TUNNEL_NOT_FOUND, "No tunnel daemon is running on this machine.");
8309
+ }
8310
+ });
8311
+
8312
+ // src/devices/reconnect.ts
8313
+ function backoffCeilingMillis(attempt) {
8314
+ const uncapped = INITIAL_DELAY_MILLIS * 2 ** Math.max(0, attempt);
8315
+ return Math.min(uncapped, MAX_DELAY_MILLIS);
8316
+ }
8317
+ function fullJitterMillis(ceilingMillis, random) {
8318
+ return Math.floor(random() * ceilingMillis);
8319
+ }
8320
+ var INITIAL_DELAY_MILLIS, MAX_DELAY_MILLIS, STABLE_CONNECTION_MILLIS, MAX_ATTEMPT, ReconnectPolicy;
8321
+ var init_reconnect = __esm({
8322
+ "src/devices/reconnect.ts"() {
8323
+ "use strict";
8324
+ init_cjs_shims();
8325
+ INITIAL_DELAY_MILLIS = 1e3;
8326
+ MAX_DELAY_MILLIS = 6e4;
8327
+ STABLE_CONNECTION_MILLIS = 6e4;
8328
+ MAX_ATTEMPT = Math.ceil(Math.log2(MAX_DELAY_MILLIS / INITIAL_DELAY_MILLIS));
8329
+ ReconnectPolicy = class {
8330
+ constructor(options = {}) {
8331
+ this.attemptCount = 0;
8332
+ this.connectedAtMillis = null;
8333
+ this.now = options.now ?? Date.now;
8334
+ this.random = options.random ?? Math.random;
8335
+ }
8336
+ /** Attempts taken since the last reset. Exposed for logging and for tests. */
8337
+ get attempt() {
8338
+ return this.attemptCount;
8339
+ }
8340
+ /** The ceiling the next delay will be drawn from, before jitter. */
8341
+ get nextCeilingMillis() {
8342
+ return backoffCeilingMillis(this.attemptCount);
8343
+ }
8344
+ markConnected() {
8345
+ this.connectedAtMillis = this.now();
8346
+ }
8347
+ /**
8348
+ * Ends the current connection. Returns true when it had held long enough to reset the
8349
+ * schedule, which the caller logs.
8350
+ */
8351
+ markDisconnected() {
8352
+ const heldMillis = this.connectedAtMillis === null ? 0 : this.now() - this.connectedAtMillis;
8353
+ this.connectedAtMillis = null;
8354
+ const wasStable = heldMillis >= STABLE_CONNECTION_MILLIS;
8355
+ if (wasStable) {
8356
+ this.attemptCount = 0;
8357
+ }
8358
+ return wasStable;
8359
+ }
8360
+ /** The delay before the next dial, and advances the schedule. */
8361
+ nextDelayMillis() {
8362
+ const delay = fullJitterMillis(this.nextCeilingMillis, this.random);
8363
+ this.attemptCount = Math.min(this.attemptCount + 1, MAX_ATTEMPT);
8364
+ return delay;
8365
+ }
8366
+ };
8367
+ }
8368
+ });
8369
+
8370
+ // src/devices/terminal/terminal_schema.ts
8371
+ function clampGrid(requested, fallback) {
8372
+ if (requested === void 0 || !Number.isFinite(requested)) return fallback;
8373
+ return Math.max(MIN_GRID, Math.min(MAX_GRID, Math.trunc(requested)));
8374
+ }
8375
+ var import_zod4, CHANNEL_TERMINAL, OP_OPEN, OP_OPENED, OP_CLOSE, OP_DATA, OP_ACK, OP_EXIT, OP_RESIZE, OP_SIGNAL, OP_CONTEXT, OP_SNAPSHOT, TERMINAL_SIGNALS, MIN_GRID, MAX_GRID, CLOSE_REASON_USER_CLOSED, CLOSE_REASON_IDLE_TIMEOUT, CLOSE_REASON_MAX_SESSION, ERROR_INVALID_PAYLOAD2, ERROR_TERMINAL_LIMIT_REACHED, ERROR_TERMINAL_DISABLED, ERROR_SHELL_NOT_ALLOWED, ERROR_SPAWN_FAILED, TerminalOpenSchema, TerminalCloseSchema, TerminalResizeSchema, TerminalInputSchema, TerminalSignalSchema, TerminalAckSchema, TerminalSnapshotRequestSchema;
8376
+ var init_terminal_schema = __esm({
8377
+ "src/devices/terminal/terminal_schema.ts"() {
8378
+ "use strict";
8379
+ init_cjs_shims();
8380
+ import_zod4 = require("zod");
8381
+ CHANNEL_TERMINAL = "terminal";
8382
+ OP_OPEN = "open";
8383
+ OP_OPENED = "opened";
8384
+ OP_CLOSE = "close";
8385
+ OP_DATA = "data";
8386
+ OP_ACK = "ack";
8387
+ OP_EXIT = "exit";
8388
+ OP_RESIZE = "resize";
8389
+ OP_SIGNAL = "signal";
8390
+ OP_CONTEXT = "context";
8391
+ OP_SNAPSHOT = "snapshot";
8392
+ TERMINAL_SIGNALS = ["INT", "TERM", "QUIT", "HUP"];
8393
+ MIN_GRID = 1;
8394
+ MAX_GRID = 1e3;
8395
+ CLOSE_REASON_USER_CLOSED = "user_closed";
8396
+ CLOSE_REASON_IDLE_TIMEOUT = "idle_timeout";
8397
+ CLOSE_REASON_MAX_SESSION = "max_session";
8398
+ ERROR_INVALID_PAYLOAD2 = "invalid_payload";
8399
+ ERROR_TERMINAL_LIMIT_REACHED = "terminal_limit_reached";
8400
+ ERROR_TERMINAL_DISABLED = "terminal_disabled";
8401
+ ERROR_SHELL_NOT_ALLOWED = "shell_not_allowed";
8402
+ ERROR_SPAWN_FAILED = "spawn_failed";
8403
+ TerminalOpenSchema = import_zod4.z.object({
8404
+ terminal_id: import_zod4.z.string().min(1),
8405
+ cols: import_zod4.z.number().int().optional(),
8406
+ rows: import_zod4.z.number().int().optional(),
8407
+ shell: import_zod4.z.string().nullable().optional(),
8408
+ cwd: import_zod4.z.string().nullable().optional()
8409
+ });
8410
+ TerminalCloseSchema = import_zod4.z.object({
8411
+ terminal_id: import_zod4.z.string().min(1),
8412
+ reason: import_zod4.z.string().optional()
8413
+ });
8414
+ TerminalResizeSchema = import_zod4.z.object({
8415
+ terminal_id: import_zod4.z.string().min(1),
8416
+ cols: import_zod4.z.number().int(),
8417
+ rows: import_zod4.z.number().int()
8418
+ });
8419
+ TerminalInputSchema = import_zod4.z.object({
8420
+ terminal_id: import_zod4.z.string().min(1),
8421
+ data: import_zod4.z.string()
8422
+ });
8423
+ TerminalSignalSchema = import_zod4.z.object({
8424
+ terminal_id: import_zod4.z.string().min(1),
8425
+ signal: import_zod4.z.enum(TERMINAL_SIGNALS)
8426
+ });
8427
+ TerminalAckSchema = import_zod4.z.object({
8428
+ terminal_id: import_zod4.z.string().min(1),
8429
+ ack_seq: import_zod4.z.number().optional(),
8430
+ ack_bytes: import_zod4.z.number()
8431
+ });
8432
+ TerminalSnapshotRequestSchema = import_zod4.z.object({
8433
+ terminal_id: import_zod4.z.string().min(1),
8434
+ snapshot_id: import_zod4.z.string().min(1)
8435
+ });
8436
+ }
8437
+ });
8438
+
8439
+ // src/devices/terminal/flowWindow.ts
8440
+ var FlowWindow;
8441
+ var init_flowWindow = __esm({
8442
+ "src/devices/terminal/flowWindow.ts"() {
8443
+ "use strict";
8444
+ init_cjs_shims();
8445
+ FlowWindow = class {
8446
+ constructor(windowBytes) {
8447
+ this.sentBytes = 0;
8448
+ this.ackedBytes = 0;
8449
+ if (!Number.isFinite(windowBytes) || windowBytes <= 0) {
8450
+ throw new Error("A terminal flow window needs a positive size from welcome");
8451
+ }
8452
+ this.windowBytes = Math.trunc(windowBytes);
8453
+ }
8454
+ /** How many more raw bytes may go out before the browser has to catch up. */
8455
+ remaining() {
8456
+ return Math.max(0, this.windowBytes - (this.sentBytes - this.ackedBytes));
8457
+ }
8458
+ /** False means stop reading the pty. Nothing else is an acceptable response. */
8459
+ isOpen() {
8460
+ return this.remaining() > 0;
8461
+ }
8462
+ recordSent(byteCount) {
8463
+ if (!Number.isFinite(byteCount) || byteCount <= 0) return;
8464
+ this.sentBytes += Math.trunc(byteCount);
8465
+ }
8466
+ /**
8467
+ * Cumulative totals, never increments, so a dropped ack self-heals on the next one.
8468
+ *
8469
+ * A browser is not a trusted input. An ack beyond what was sent is clamped rather than believed,
8470
+ * and one that arrives out of order never moves the counter backwards.
8471
+ */
8472
+ recordAck(ackBytes) {
8473
+ if (!Number.isFinite(ackBytes)) return;
8474
+ const clamped = Math.min(Math.max(Math.trunc(ackBytes), 0), this.sentBytes);
8475
+ if (clamped > this.ackedBytes) {
8476
+ this.ackedBytes = clamped;
8477
+ }
8478
+ }
8479
+ get sent() {
8480
+ return this.sentBytes;
8481
+ }
8482
+ get acked() {
8483
+ return this.ackedBytes;
8484
+ }
8485
+ get size() {
8486
+ return this.windowBytes;
8487
+ }
8488
+ };
8489
+ }
8490
+ });
8491
+
8492
+ // src/devices/terminal/frameSplitter.ts
8493
+ function rawBundleBytes(maxFrameBytes) {
8494
+ const available = maxFrameBytes - ENVELOPE_OVERHEAD_BYTES;
8495
+ if (available <= 0) {
8496
+ throw new Error("max_frame_bytes leaves no room for a terminal payload");
8497
+ }
8498
+ return Math.floor(available * BASE64_NUMERATOR / BASE64_DENOMINATOR);
8499
+ }
8500
+ var ENVELOPE_OVERHEAD_BYTES, BASE64_NUMERATOR, BASE64_DENOMINATOR, BUNDLE_MILLIS, FrameSplitter;
8501
+ var init_frameSplitter = __esm({
8502
+ "src/devices/terminal/frameSplitter.ts"() {
8503
+ "use strict";
8504
+ init_cjs_shims();
8505
+ ENVELOPE_OVERHEAD_BYTES = 512;
8506
+ BASE64_NUMERATOR = 3;
8507
+ BASE64_DENOMINATOR = 4;
8508
+ BUNDLE_MILLIS = 8;
8509
+ FrameSplitter = class {
8510
+ constructor(emit, bundleBytes, bundleMillis = BUNDLE_MILLIS) {
8511
+ this.emit = emit;
8512
+ this.bundleBytes = bundleBytes;
8513
+ this.bundleMillis = bundleMillis;
8514
+ this.pending = [];
8515
+ this.pendingBytes = 0;
8516
+ if (!Number.isFinite(bundleBytes) || bundleBytes <= 0) {
8517
+ throw new Error("A frame splitter needs a positive bundle size");
8518
+ }
8519
+ }
8520
+ /** A chunk over the cap is split across frames, never truncated. */
8521
+ push(chunk) {
8522
+ if (chunk.length === 0) return;
8523
+ this.pending.push(chunk);
8524
+ this.pendingBytes += chunk.length;
8525
+ while (this.pendingBytes >= this.bundleBytes) {
8526
+ this.emitBytes(this.bundleBytes);
8527
+ }
8528
+ if (this.pendingBytes > 0) {
8529
+ this.arm();
8530
+ } else {
8531
+ this.disarm();
8532
+ }
8533
+ }
8534
+ /** Sends whatever is held, if anything. Used when the terminal closes and by the timer. */
8535
+ flush() {
8536
+ this.disarm();
8537
+ if (this.pendingBytes > 0) {
8538
+ this.emitBytes(this.pendingBytes);
8539
+ }
8540
+ }
8541
+ dispose() {
8542
+ this.disarm();
8543
+ this.pending = [];
8544
+ this.pendingBytes = 0;
8545
+ }
8546
+ get buffered() {
8547
+ return this.pendingBytes;
8548
+ }
8549
+ emitBytes(byteCount) {
8550
+ const joined = Buffer.concat(this.pending, this.pendingBytes);
8551
+ const frame = joined.subarray(0, byteCount);
8552
+ const rest = joined.subarray(byteCount);
8553
+ this.pending = rest.length > 0 ? [rest] : [];
8554
+ this.pendingBytes = rest.length;
8555
+ this.emit(frame);
8556
+ }
8557
+ arm() {
8558
+ if (this.timer !== void 0) return;
8559
+ this.timer = setTimeout(() => {
8560
+ this.timer = void 0;
8561
+ this.flush();
8562
+ }, this.bundleMillis);
8563
+ this.timer.unref?.();
8564
+ }
8565
+ disarm() {
8566
+ if (this.timer === void 0) return;
8567
+ clearTimeout(this.timer);
8568
+ this.timer = void 0;
8569
+ }
8570
+ };
8571
+ }
8572
+ });
8573
+
8574
+ // src/devices/terminal/shellContext.ts
8575
+ function processTableReader(platform2 = process.platform) {
8576
+ if (platform2 === "linux") return readFromProc;
8577
+ if (platform2 === "darwin") return readFromPsAndLsof;
8578
+ return null;
8579
+ }
8580
+ function programName(rawName) {
8581
+ const text = cleanText(rawName, Number.MAX_SAFE_INTEGER);
8582
+ if (text === null) return null;
8583
+ const firstWord = text.split(/\s+/, 1)[0];
8584
+ const baseName = firstWord.slice(firstWord.lastIndexOf("/") + 1).replace(/:+$/, "");
8585
+ return baseName === "" ? null : truncate(baseName, MAX_COMMAND_CHARACTERS);
8586
+ }
8587
+ function displayCwd(rawCwd, resolvedHome) {
8588
+ const cwd = cleanText(rawCwd, Number.MAX_SAFE_INTEGER);
8589
+ if (cwd === null) return null;
8590
+ let shown = cwd;
8591
+ if (resolvedHome && cwd === resolvedHome) {
8592
+ shown = HOME_PREFIX;
8593
+ } else if (resolvedHome && cwd.startsWith(resolvedHome + "/")) {
8594
+ shown = HOME_PREFIX + cwd.slice(resolvedHome.length);
8595
+ }
8596
+ return truncate(shown, MAX_CWD_CHARACTERS);
8597
+ }
8598
+ function resolvedHomeDirectory() {
8599
+ try {
8600
+ return import_fs9.default.realpathSync(import_os5.default.homedir());
8601
+ } catch {
8602
+ return null;
8603
+ }
8604
+ }
8605
+ function cleanText(raw, maxCharacters) {
8606
+ if (raw === null || raw === void 0) return null;
8607
+ const text = raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").trim();
8608
+ return text === "" ? null : truncate(text, maxCharacters);
8609
+ }
8610
+ function truncate(text, maxCharacters) {
8611
+ const codePoints = Array.from(text);
8612
+ return codePoints.length <= maxCharacters ? text : codePoints.slice(0, maxCharacters).join("");
8613
+ }
8614
+ async function readFromProc(shellPids) {
8615
+ const contexts = /* @__PURE__ */ new Map();
8616
+ await Promise.all(shellPids.map(async (shellPid) => {
8617
+ const groups = await readProcGroups(shellPid);
8618
+ if (!groups) return;
8619
+ const cwd = await readOrNull(() => import_fs9.default.promises.readlink(`/proc/${shellPid}/cwd`));
8620
+ const foregroundName = groups.foregroundGroupId === groups.shellGroupId ? null : await readOrNull(() => import_fs9.default.promises.readFile(`/proc/${groups.foregroundGroupId}/comm`, "utf8"));
8621
+ contexts.set(shellPid, { cwd, foregroundName });
8622
+ }));
8623
+ return contexts;
8624
+ }
8625
+ async function readProcGroups(shellPid) {
8626
+ const stat = await readOrNull(() => import_fs9.default.promises.readFile(`/proc/${shellPid}/stat`, "utf8"));
8627
+ if (stat === null) return null;
8628
+ const afterName = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
8629
+ const shellGroupId = Number.parseInt(afterName[2], 10);
8630
+ const foregroundGroupId = Number.parseInt(afterName[5], 10);
8631
+ if (!(shellGroupId > 0) || !(foregroundGroupId > 0)) return null;
8632
+ return { shellGroupId, foregroundGroupId };
8633
+ }
8634
+ async function readFromPsAndLsof(shellPids) {
8635
+ const contexts = /* @__PURE__ */ new Map();
8636
+ if (shellPids.length === 0) return contexts;
8637
+ const groups = parseColumns(await run("ps", ["-o", "pid=,pgid=,tpgid=", "-p", shellPids.join(",")]));
8638
+ const foregroundGroupIds = [...groups.values()].filter(([shellGroupId, foregroundGroupId]) => foregroundGroupId > 0 && foregroundGroupId !== shellGroupId).map(([, foregroundGroupId]) => foregroundGroupId);
8639
+ const [names, cwds] = await Promise.all([
8640
+ foregroundGroupIds.length === 0 ? Promise.resolve(/* @__PURE__ */ new Map()) : run("ps", ["-o", "pid=,comm=", "-p", foregroundGroupIds.join(",")]).then(parseNames),
8641
+ run("lsof", ["-a", "-d", "cwd", "-Fpn", "-p", shellPids.join(",")]).then(parseLsofCwds)
8642
+ ]);
8643
+ for (const shellPid of shellPids) {
8644
+ const group = groups.get(shellPid);
8645
+ if (!group) continue;
8646
+ const [shellGroupId, foregroundGroupId] = group;
8647
+ contexts.set(shellPid, {
8648
+ cwd: cwds.get(shellPid) ?? null,
8649
+ foregroundName: foregroundGroupId === shellGroupId ? null : names.get(foregroundGroupId) ?? null
8650
+ });
8651
+ }
8652
+ return contexts;
8653
+ }
8654
+ function parseColumns(stdout) {
8655
+ const columns = /* @__PURE__ */ new Map();
8656
+ for (const line of (stdout ?? "").split("\n")) {
8657
+ const [pid, groupId, foregroundGroupId] = line.trim().split(/\s+/).map((field) => Number.parseInt(field, 10));
8658
+ if (pid > 0 && groupId > 0) columns.set(pid, [groupId, foregroundGroupId]);
8659
+ }
8660
+ return columns;
8661
+ }
8662
+ function parseNames(stdout) {
8663
+ const names = /* @__PURE__ */ new Map();
8664
+ for (const line of (stdout ?? "").split("\n")) {
8665
+ const match = /^\s*(\d+)\s+(.*)$/.exec(line);
8666
+ if (match) names.set(Number.parseInt(match[1], 10), match[2]);
8667
+ }
8668
+ return names;
8669
+ }
8670
+ function parseLsofCwds(stdout) {
8671
+ const cwds = /* @__PURE__ */ new Map();
8672
+ let pid = 0;
8673
+ for (const line of (stdout ?? "").split("\n")) {
8674
+ if (line.startsWith("p")) pid = Number.parseInt(line.slice(1), 10);
8675
+ else if (line.startsWith("n") && pid > 0) cwds.set(pid, line.slice(1));
8676
+ }
8677
+ return cwds;
8678
+ }
8679
+ function run(program, args) {
8680
+ return new Promise((resolve) => {
8681
+ (0, import_child_process2.execFile)(program, args, { timeout: PROCESS_READ_TIMEOUT_MILLIS }, (err, stdout) => {
8682
+ const output = String(stdout ?? "");
8683
+ resolve(err && output === "" ? null : output);
8684
+ });
8685
+ });
8686
+ }
8687
+ async function readOrNull(read) {
8688
+ try {
8689
+ return await read();
8690
+ } catch {
8691
+ return null;
8692
+ }
8693
+ }
8694
+ var import_child_process2, import_fs9, import_os5, SHELL_CONTEXT_POLL_MILLIS, MAX_CWD_CHARACTERS, MAX_COMMAND_CHARACTERS, PROCESS_READ_TIMEOUT_MILLIS, HOME_PREFIX, ShellContextTracker;
8695
+ var init_shellContext = __esm({
8696
+ "src/devices/terminal/shellContext.ts"() {
8697
+ "use strict";
8698
+ init_cjs_shims();
8699
+ import_child_process2 = require("child_process");
8700
+ import_fs9 = __toESM(require("fs"), 1);
8701
+ import_os5 = __toESM(require("os"), 1);
8702
+ SHELL_CONTEXT_POLL_MILLIS = 5e3;
8703
+ MAX_CWD_CHARACTERS = 1024;
8704
+ MAX_COMMAND_CHARACTERS = 64;
8705
+ PROCESS_READ_TIMEOUT_MILLIS = 2e3;
8706
+ HOME_PREFIX = "~";
8707
+ ShellContextTracker = class {
8708
+ constructor() {
8709
+ this.known = /* @__PURE__ */ new Map();
8710
+ }
8711
+ /** The event to send, or null when nothing changed since the last one. */
8712
+ observe(terminalId, raw, resolvedHome) {
8713
+ const previous = this.known.get(terminalId);
8714
+ const command = programName(raw.foregroundName);
8715
+ const previousCommand = previous?.command ?? null;
8716
+ const lastCommand = previousCommand !== null && previousCommand !== command ? previousCommand : previous?.last_command ?? null;
8717
+ const next = {
8718
+ terminal_id: terminalId,
8719
+ cwd: displayCwd(raw.cwd, resolvedHome),
8720
+ command,
8721
+ last_command: lastCommand
8722
+ };
8723
+ if (previous && previous.cwd === next.cwd && previous.command === next.command && previous.last_command === next.last_command) {
8724
+ return null;
8725
+ }
8726
+ this.known.set(terminalId, next);
8727
+ return next;
8728
+ }
8729
+ /** What was last sent for every shell, to send again after a reconnect. */
8730
+ current() {
8731
+ return [...this.known.values()];
8732
+ }
8733
+ forget(terminalId) {
8734
+ this.known.delete(terminalId);
8735
+ }
8736
+ clear() {
8737
+ this.known.clear();
8738
+ }
8739
+ };
8740
+ }
8741
+ });
8742
+
8743
+ // src/devices/terminal/screenMirror.ts
8744
+ function loadModules() {
8745
+ if (loadedModules !== void 0) return loadedModules;
8746
+ try {
8747
+ const headless = require2("@xterm/headless");
8748
+ const serialize = require2("@xterm/addon-serialize");
8749
+ loadedModules = { Terminal: headless.Terminal, SerializeAddon: serialize.SerializeAddon };
8750
+ } catch (err) {
8751
+ logger.warn("Screen restore unavailable: the headless terminal did not load", { error: errorName(err) });
8752
+ loadedModules = null;
8753
+ }
8754
+ return loadedModules;
8755
+ }
8756
+ function isScreenMirrorSupported() {
8757
+ return loadModules() !== null;
8758
+ }
8759
+ function errorName(err) {
8760
+ return err instanceof Error ? err.name : typeof err;
8761
+ }
8762
+ var import_module, SCROLLBACK_LINES, MAX_SNAPSHOT_BYTES, SCROLLBACK_LINES_TO_TRY, require2, loadedModules, ScreenMirror;
8763
+ var init_screenMirror = __esm({
8764
+ "src/devices/terminal/screenMirror.ts"() {
8765
+ "use strict";
8766
+ init_cjs_shims();
8767
+ import_module = require("module");
8768
+ init_logger();
8769
+ SCROLLBACK_LINES = 1e3;
8770
+ MAX_SNAPSHOT_BYTES = 1024 * 1024;
8771
+ SCROLLBACK_LINES_TO_TRY = [SCROLLBACK_LINES, 200, 0];
8772
+ require2 = (0, import_module.createRequire)(importMetaUrl);
8773
+ ScreenMirror = class _ScreenMirror {
8774
+ constructor(terminal, serializer) {
8775
+ this.terminal = terminal;
8776
+ this.serializer = serializer;
8777
+ this.disposed = false;
8778
+ }
8779
+ /** Null when the headless terminal cannot load. The shell then works as before, without restore. */
8780
+ static create(cols, rows) {
8781
+ const modules = loadModules();
8782
+ if (!modules) return null;
8783
+ const terminal = new modules.Terminal({ cols, rows, scrollback: SCROLLBACK_LINES, allowProposedApi: true });
8784
+ const serializer = new modules.SerializeAddon();
8785
+ terminal.loadAddon(serializer);
8786
+ return new _ScreenMirror(terminal, serializer);
8787
+ }
8788
+ /** Bytes exactly as they were sent. xterm parses them later, in order, and joins split characters. */
8789
+ feed(bytes) {
8790
+ if (!this.disposed) this.terminal.write(bytes);
8791
+ }
8792
+ resize(cols, rows) {
8793
+ if (!this.disposed) this.terminal.resize(cols, rows);
8794
+ }
8795
+ /**
8796
+ * The screen after everything fed before this call, and nothing fed after it. Null when the
8797
+ * mirror was disposed first.
8798
+ *
8799
+ * xterm parses writes in batches, on a timer. An empty write queued now has its callback run
8800
+ * after every earlier chunk is parsed and before the next one is, so serialising inside it sees
8801
+ * exactly what was fed before this call.
8802
+ */
8803
+ snapshot() {
8804
+ return new Promise((resolve) => {
8805
+ if (this.disposed) {
8806
+ resolve(null);
8807
+ return;
8808
+ }
8809
+ this.terminal.write("", () => resolve(this.disposed ? null : this.serialise()));
8810
+ });
8811
+ }
8812
+ dispose() {
8813
+ if (this.disposed) return;
8814
+ this.disposed = true;
8815
+ this.terminal.dispose();
8816
+ }
8817
+ /** Null when even the screen alone is over the cap. The tab then waits out its fallback. */
8818
+ serialise() {
8819
+ for (const scrollback of SCROLLBACK_LINES_TO_TRY) {
8820
+ const text = this.serializer.serialize({ scrollback });
8821
+ if (Buffer.byteLength(text, "utf-8") <= MAX_SNAPSHOT_BYTES) {
8822
+ return { text, cols: this.terminal.cols, rows: this.terminal.rows };
8823
+ }
8824
+ }
8825
+ return null;
8826
+ }
8827
+ };
8828
+ }
8829
+ });
8830
+
8831
+ // src/devices/terminal/terminalHandler.ts
8832
+ function positiveMillis(seconds) {
8833
+ return seconds !== void 0 && Number.isFinite(seconds) && seconds > 0 ? seconds * MILLIS_PER_SECOND : void 0;
8834
+ }
8835
+ function splitIntoParts(bytes, partBytes) {
8836
+ const parts = [];
8837
+ for (let offset = 0; offset < bytes.length; offset += partBytes) {
8838
+ parts.push(bytes.subarray(offset, offset + partBytes));
8839
+ }
8840
+ return parts.length > 0 ? parts : [Buffer.alloc(0)];
8841
+ }
8842
+ function errorName2(err) {
8843
+ return err instanceof Error ? err.name : typeof err;
8844
+ }
8845
+ function signalName(signal) {
8846
+ for (const [name, number] of Object.entries(import_os6.default.constants.signals)) {
8847
+ if (number === signal) return name.replace(/^SIG/, "");
8848
+ }
8849
+ return String(signal);
8850
+ }
8851
+ var import_os6, DEFAULT_COLS, DEFAULT_ROWS, EXPIRY_CHECK_MILLIS, MILLIS_PER_SECOND, TerminalHandler;
8852
+ var init_terminalHandler = __esm({
8853
+ "src/devices/terminal/terminalHandler.ts"() {
8854
+ "use strict";
8855
+ init_cjs_shims();
8856
+ import_os6 = __toESM(require("os"), 1);
8857
+ init_logger();
8858
+ init_envelope();
8859
+ init_terminal_schema();
8860
+ init_flowWindow();
8861
+ init_frameSplitter();
8862
+ init_shellContext();
8863
+ init_screenMirror();
8864
+ DEFAULT_COLS = 80;
8865
+ DEFAULT_ROWS = 24;
8866
+ EXPIRY_CHECK_MILLIS = 5e3;
8867
+ MILLIS_PER_SECOND = 1e3;
8868
+ TerminalHandler = class {
8869
+ constructor(dependencies) {
8870
+ this.dependencies = dependencies;
8871
+ this.enabled = true;
8872
+ this.suspended = false;
8873
+ this.expiryTimer = null;
8874
+ this.contextTimer = null;
8875
+ this.contextPollInFlight = false;
8876
+ this.contextTracker = new ShellContextTracker();
8877
+ this.streams = /* @__PURE__ */ new Map();
8878
+ }
8879
+ /**
8880
+ * From welcome. An older dashboard sends no terminal fields, and `terminal_enabled` and the
8881
+ * per-device ceiling keep their defaults.
8882
+ *
8883
+ * The window has no default. A compiled-in one keeps working after the dashboard stops sending
8884
+ * the field, and nobody notices until a node falls over, so an agent without one refuses to open
8885
+ * a terminal at all.
8886
+ */
8887
+ configure(terminalEnabled, maxTerminalsPerDevice, terminalWindowBytes, maxFrameBytes, idleTimeoutSeconds, maxSessionSeconds) {
8888
+ if (terminalEnabled !== void 0) this.enabled = terminalEnabled;
8889
+ if (maxTerminalsPerDevice !== void 0) this.dependencies.registry.setMaxTerminals(maxTerminalsPerDevice);
8890
+ this.windowBytes = terminalWindowBytes;
8891
+ this.bundleBytes = rawBundleBytes(maxFrameBytes);
8892
+ if (terminalWindowBytes === void 0) {
8893
+ logger.info("Terminals unavailable: welcome carried no terminal_window_bytes");
8894
+ }
8895
+ this.idleTimeoutMillis = positiveMillis(idleTimeoutSeconds);
8896
+ this.maxSessionMillis = positiveMillis(maxSessionSeconds);
8897
+ this.startExpiryTimer();
8898
+ }
8899
+ /**
8900
+ * Ends every shell past its idle timeout or its max-session cap. Runs on a timer; public so a test
8901
+ * decides when. The cap goes first: a shell both idle and too old is closed for the rule typing
8902
+ * cannot reset.
8903
+ */
8904
+ expireShells() {
8905
+ if (this.suspended) return;
8906
+ const nowMillis = this.nowMillis();
8907
+ for (const [terminalId, stream] of [...this.streams.entries()]) {
8908
+ if (this.maxSessionMillis !== void 0 && nowMillis - stream.openedAtMillis >= this.maxSessionMillis) {
8909
+ this.expire(terminalId, CLOSE_REASON_MAX_SESSION);
8910
+ } else if (this.idleTimeoutMillis !== void 0 && nowMillis - stream.lastActivityMillis >= this.idleTimeoutMillis) {
8911
+ this.expire(terminalId, CLOSE_REASON_IDLE_TIMEOUT);
8912
+ }
8913
+ }
8914
+ }
8915
+ handle(envelope) {
8916
+ if (envelope.op === OP_OPEN) {
8917
+ this.open(envelope);
8918
+ } else if (envelope.op === OP_CLOSE) {
8919
+ this.closeFromDashboard(envelope);
8920
+ } else if (envelope.op === OP_ACK) {
8921
+ this.acknowledge(envelope);
8922
+ } else if (envelope.op === OP_RESIZE) {
8923
+ this.resize(envelope);
8924
+ } else if (envelope.op === OP_DATA) {
8925
+ this.input(envelope);
8926
+ } else if (envelope.op === OP_SIGNAL) {
8927
+ this.signal(envelope);
8928
+ } else if (envelope.op === OP_SNAPSHOT) {
8929
+ void this.snapshot(envelope);
8930
+ } else {
8931
+ logger.debug("Ignoring unhandled terminal op", { op: envelope.op });
8932
+ }
8933
+ }
8934
+ /**
8935
+ * The agent is stopping: interrupted, revoked, or refused for good. Every shell dies with it.
8936
+ * A dropped socket is not this; it is `suspend`.
8937
+ */
8938
+ closeAll() {
8939
+ this.stopExpiryTimer();
8940
+ this.stopContextTimer();
8941
+ this.contextTracker.clear();
8942
+ this.suspended = false;
8943
+ for (const stream of this.streams.values()) {
8944
+ stream.splitter.dispose();
8945
+ stream.mirror?.dispose();
8946
+ }
8947
+ this.streams.clear();
8948
+ this.dependencies.registry.killAll();
8949
+ }
8950
+ /**
8951
+ * The socket dropped and the agent will redial. Every shell stops being read and keeps running.
8952
+ * Whatever it prints waits in the kernel, and the shell blocks once that buffer is full.
8953
+ */
8954
+ suspend() {
8955
+ if (this.suspended) return;
8956
+ this.suspended = true;
8957
+ for (const [terminalId, session] of this.dependencies.registry.entries()) {
8958
+ try {
8959
+ session.pause();
8960
+ } catch (err) {
8961
+ logger.warn("Terminal pause failed", { terminal_id: terminalId, error: errorName2(err) });
8962
+ }
8963
+ }
8964
+ logger.info("Terminals suspended until the dashboard is back", { count: this.dependencies.registry.size() });
8965
+ }
8966
+ /**
8967
+ * `welcome` arrived. Every shell the dashboard did not close is read again, except one whose
8968
+ * window is still full. That one resumes on the `ack` that reopens it.
8969
+ */
8970
+ resumeAll() {
8971
+ if (!this.suspended) return;
8972
+ this.suspended = false;
8973
+ for (const [terminalId, session] of this.dependencies.registry.entries()) {
8974
+ const stream = this.streams.get(terminalId);
8975
+ if (stream) {
8976
+ for (const chunk of stream.heldBundles.splice(0)) {
8977
+ this.sendData(terminalId, chunk);
8978
+ }
8979
+ }
8980
+ if (stream?.paused) continue;
8981
+ try {
8982
+ session.resume();
8983
+ } catch (err) {
8984
+ logger.warn("Terminal resume failed", { terminal_id: terminalId, error: errorName2(err) });
8985
+ }
8986
+ }
8987
+ for (const context of this.contextTracker.current()) {
8988
+ if (this.streams.has(context.terminal_id)) {
8989
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, context));
8990
+ }
8991
+ }
8992
+ }
8993
+ /**
8994
+ * Reads every open shell's directory and foreground program, and sends `context` for each that
8995
+ * changed. Runs on a timer while any shell is open; public so a test decides when. Never throws,
8996
+ * and never moves a shell's idle clock.
8997
+ */
8998
+ async pollShellContexts() {
8999
+ const read = this.dependencies.readShellContexts;
9000
+ if (!read || this.suspended || this.contextPollInFlight || this.streams.size === 0) return;
9001
+ this.contextPollInFlight = true;
9002
+ try {
9003
+ const terminalIdsByPid = /* @__PURE__ */ new Map();
9004
+ for (const [terminalId, stream] of this.streams) {
9005
+ terminalIdsByPid.set(stream.session.pid, terminalId);
9006
+ }
9007
+ const contexts = await read([...terminalIdsByPid.keys()]);
9008
+ if (this.suspended) return;
9009
+ for (const [shellPid, raw] of contexts) {
9010
+ const terminalId = terminalIdsByPid.get(shellPid);
9011
+ if (!terminalId || !this.streams.has(terminalId)) continue;
9012
+ const changed = this.contextTracker.observe(terminalId, raw, this.dependencies.resolvedHome ?? null);
9013
+ if (changed) this.dependencies.send(event(CHANNEL_TERMINAL, OP_CONTEXT, changed));
9014
+ }
9015
+ } catch (err) {
9016
+ logger.debug("Shell context poll failed", { error: errorName2(err) });
9017
+ } finally {
9018
+ this.contextPollInFlight = false;
9019
+ }
9020
+ }
9021
+ /** What `hello` lists: every shell still held, so the dashboard can keep it. */
9022
+ held() {
9023
+ return this.dependencies.registry.entries().map(([terminalId, session]) => ({
9024
+ terminal_id: terminalId,
9025
+ pid: session.pid,
9026
+ shell: session.shell,
9027
+ cols: session.cols,
9028
+ rows: session.rows
9029
+ }));
9030
+ }
9031
+ open(envelope) {
9032
+ const parsed = TerminalOpenSchema.safeParse(envelope.payload);
9033
+ if (!parsed.success) {
9034
+ this.refuse(envelope, null, ERROR_INVALID_PAYLOAD2, "open payload could not be read.");
9035
+ return;
9036
+ }
9037
+ const request2 = parsed.data;
9038
+ const { registry } = this.dependencies;
9039
+ if (!this.enabled || this.windowBytes === void 0 || this.bundleBytes === void 0) {
9040
+ this.refuse(envelope, request2.terminal_id, ERROR_TERMINAL_DISABLED, "Terminals are disabled.");
9041
+ return;
9042
+ }
9043
+ if (registry.has(request2.terminal_id) || registry.isFull()) {
9044
+ this.refuse(
9045
+ envelope,
9046
+ request2.terminal_id,
9047
+ ERROR_TERMINAL_LIMIT_REACHED,
9048
+ "Too many terminals are already open on this machine."
9049
+ );
9050
+ return;
9051
+ }
9052
+ const resolution = this.dependencies.resolveShell(request2.shell);
9053
+ if ("error" in resolution) {
9054
+ this.refuse(envelope, request2.terminal_id, ERROR_SHELL_NOT_ALLOWED, resolution.error);
9055
+ return;
9056
+ }
9057
+ const cols = clampGrid(request2.cols, DEFAULT_COLS);
9058
+ const rows = clampGrid(request2.rows, DEFAULT_ROWS);
9059
+ let session;
9060
+ try {
9061
+ session = this.dependencies.spawn({ shell: resolution.shell, cwd: request2.cwd, cols, rows });
9062
+ } catch (err) {
9063
+ logger.warn("Terminal spawn failed", { terminal_id: request2.terminal_id, error: errorName2(err) });
9064
+ this.refuse(envelope, request2.terminal_id, ERROR_SPAWN_FAILED, "The shell could not be started.");
9065
+ return;
9066
+ }
9067
+ if (!registry.add(request2.terminal_id, session)) {
9068
+ session.kill();
9069
+ this.refuse(
9070
+ envelope,
9071
+ request2.terminal_id,
9072
+ ERROR_TERMINAL_LIMIT_REACHED,
9073
+ "Too many terminals are already open on this machine."
9074
+ );
9075
+ return;
9076
+ }
9077
+ this.startStream(request2.terminal_id, session, this.windowBytes, this.bundleBytes);
9078
+ logger.info("Terminal opened", { terminal_id: request2.terminal_id, pid: session.pid });
9079
+ const opened = {
9080
+ terminal_id: request2.terminal_id,
9081
+ pid: session.pid,
9082
+ shell: session.shell,
9083
+ cols,
9084
+ rows
9085
+ };
9086
+ this.dependencies.send(response(envelope, OP_OPENED, opened));
9087
+ }
9088
+ startStream(terminalId, session, windowBytes, bundleBytes) {
9089
+ const openedAtMillis = this.nowMillis();
9090
+ const stream = {
9091
+ session,
9092
+ window: new FlowWindow(windowBytes),
9093
+ splitter: new FrameSplitter((chunk) => this.sendData(terminalId, chunk), bundleBytes),
9094
+ mirror: ScreenMirror.create(session.cols, session.rows),
9095
+ paused: false,
9096
+ sentSeq: 0,
9097
+ heldBundles: [],
9098
+ openedAtMillis,
9099
+ lastActivityMillis: openedAtMillis
9100
+ };
9101
+ this.streams.set(terminalId, stream);
9102
+ this.startContextTimer();
9103
+ session.onData((chunk) => {
9104
+ stream.lastActivityMillis = this.nowMillis();
9105
+ stream.splitter.push(chunk);
9106
+ });
9107
+ session.onExit((exitCode, signal) => {
9108
+ if (this.dependencies.registry.remove(terminalId) === void 0) return;
9109
+ stream.splitter.flush();
9110
+ stream.splitter.dispose();
9111
+ this.forgetStream(terminalId);
9112
+ logger.info("Terminal shell exited", { terminal_id: terminalId, exit_code: exitCode, signal });
9113
+ const exit = signal ? { terminal_id: terminalId, exit_code: null, signal: signalName(signal) } : { terminal_id: terminalId, exit_code: exitCode, signal: null };
9114
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_EXIT, exit));
9115
+ this.dependencies.send(event(
9116
+ CHANNEL_TERMINAL,
9117
+ OP_CLOSE,
9118
+ { terminal_id: terminalId, reason: CLOSE_REASON_USER_CLOSED }
9119
+ ));
9120
+ });
9121
+ }
9122
+ /**
9123
+ * 1 bundle leaves, and the window narrows by what it carried.
9124
+ *
9125
+ * The pause happens after the send rather than before. These bytes are already out of the pty,
9126
+ * and holding them is the buffering this whole mechanism exists to avoid.
9127
+ *
9128
+ * While the socket is down the bundle is held instead, and neither counts against the window nor
9129
+ * takes a seq. Sent into no socket, it would be a seq the dashboard never sees and bytes nobody
9130
+ * can ack, and the window would never reopen.
9131
+ *
9132
+ * The mirror is fed here, in the same tick as the seq, and nowhere else. A snapshot then holds
9133
+ * exactly the bundles up to its `after_seq`. Fed when the pty is read, a bundle still being cut
9134
+ * would be in the screen and drawn a second time when it is sent.
9135
+ */
9136
+ sendData(terminalId, chunk) {
9137
+ const stream = this.streams.get(terminalId);
9138
+ if (!stream) return;
9139
+ if (this.suspended) {
9140
+ stream.heldBundles.push(chunk);
9141
+ return;
9142
+ }
9143
+ stream.sentSeq += 1;
9144
+ stream.mirror?.feed(chunk);
9145
+ const data = { terminal_id: terminalId, data: chunk.toString("base64") };
9146
+ this.dependencies.send({ ...event(CHANNEL_TERMINAL, OP_DATA, data), seq: stream.sentSeq });
9147
+ stream.window.recordSent(chunk.length);
9148
+ if (!stream.window.isOpen() && !stream.paused) {
9149
+ stream.paused = true;
9150
+ stream.session.pause();
9151
+ logger.debug("Terminal window full, reads paused", { terminal_id: terminalId });
9152
+ }
9153
+ }
9154
+ acknowledge(envelope) {
9155
+ const parsed = TerminalAckSchema.safeParse(envelope.payload);
9156
+ if (!parsed.success) return;
9157
+ const stream = this.streams.get(parsed.data.terminal_id);
9158
+ if (!stream) return;
9159
+ stream.window.recordAck(parsed.data.ack_bytes);
9160
+ if (stream.paused && stream.window.isOpen()) {
9161
+ stream.paused = false;
9162
+ if (this.suspended) return;
9163
+ stream.session.resume();
9164
+ logger.debug("Terminal window reopened, reads resumed", { terminal_id: parsed.data.terminal_id });
9165
+ }
9166
+ }
9167
+ closeFromDashboard(envelope) {
9168
+ const parsed = TerminalCloseSchema.safeParse(envelope.payload);
9169
+ if (!parsed.success) return;
9170
+ const session = this.dependencies.registry.remove(parsed.data.terminal_id);
9171
+ if (!session) return;
9172
+ const stream = this.streams.get(parsed.data.terminal_id);
9173
+ this.forgetStream(parsed.data.terminal_id);
9174
+ stream?.splitter.dispose();
9175
+ logger.info("Terminal closed by the dashboard", { terminal_id: parsed.data.terminal_id, pid: session.pid });
9176
+ try {
9177
+ session.kill();
9178
+ } catch {
9179
+ }
9180
+ }
9181
+ /**
9182
+ * The tabs watching this shell changed size. Clamped like `open`, and a gone shell is ignored.
9183
+ * The pty is resized only on an actual change: every call is a `TIOCSWINSZ` and a SIGWINCH, and
9184
+ * a full-screen program redraws on each one.
9185
+ */
9186
+ resize(envelope) {
9187
+ const parsed = TerminalResizeSchema.safeParse(envelope.payload);
9188
+ if (!parsed.success) return;
9189
+ const session = this.dependencies.registry.get(parsed.data.terminal_id);
9190
+ if (!session) return;
9191
+ this.markActive(parsed.data.terminal_id);
9192
+ const cols = clampGrid(parsed.data.cols, session.cols);
9193
+ const rows = clampGrid(parsed.data.rows, session.rows);
9194
+ if (cols === session.cols && rows === session.rows) return;
9195
+ try {
9196
+ session.resize(cols, rows);
9197
+ this.streams.get(parsed.data.terminal_id)?.mirror?.resize(cols, rows);
9198
+ } catch (err) {
9199
+ logger.debug("Terminal resize failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
9200
+ }
9201
+ }
9202
+ /** Keystrokes, written as raw bytes. A gone shell is ignored. Never logged: this is what a person typed. */
9203
+ input(envelope) {
9204
+ const parsed = TerminalInputSchema.safeParse(envelope.payload);
9205
+ if (!parsed.success) return;
9206
+ const session = this.dependencies.registry.get(parsed.data.terminal_id);
9207
+ if (!session) return;
9208
+ this.markActive(parsed.data.terminal_id);
9209
+ try {
9210
+ session.write(Buffer.from(parsed.data.data, "base64"));
9211
+ } catch (err) {
9212
+ logger.debug("Terminal write failed", { terminal_id: parsed.data.terminal_id, error: errorName2(err) });
9213
+ }
9214
+ }
9215
+ /** 1 allowlisted signal to the foreground process group. Anything else fails to parse and is dropped. */
9216
+ signal(envelope) {
9217
+ const parsed = TerminalSignalSchema.safeParse(envelope.payload);
9218
+ if (!parsed.success) {
9219
+ logger.debug("Ignoring a terminal signal outside the allowlist");
9220
+ return;
9221
+ }
9222
+ const session = this.dependencies.registry.get(parsed.data.terminal_id);
9223
+ if (!session) return;
9224
+ this.markActive(parsed.data.terminal_id);
9225
+ session.signalForeground(parsed.data.signal);
9226
+ }
9227
+ markActive(terminalId) {
9228
+ const stream = this.streams.get(terminalId);
9229
+ if (stream) stream.lastActivityMillis = this.nowMillis();
9230
+ }
9231
+ /**
9232
+ * Removed from the registry before the kill, so its exit callback finds nothing and sends no
9233
+ * `user_closed`. The output already read goes out first, then `close` with the reason.
9234
+ */
9235
+ expire(terminalId, reason) {
9236
+ const session = this.dependencies.registry.remove(terminalId);
9237
+ const stream = this.streams.get(terminalId);
9238
+ this.forgetStream(terminalId);
9239
+ stream?.splitter.flush();
9240
+ stream?.splitter.dispose();
9241
+ if (!session) return;
9242
+ logger.info("Terminal expired", { terminal_id: terminalId, pid: session.pid, reason });
9243
+ try {
9244
+ session.kill();
9245
+ } catch {
9246
+ }
9247
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_CLOSE, { terminal_id: terminalId, reason }));
9248
+ }
9249
+ /**
9250
+ * A tab attached and asked for the screen it missed (slice T6). Answered in parts, each 1 frame,
9251
+ * which the dashboard routes to that tab alone.
9252
+ *
9253
+ * `after_seq` and `after_bytes` are read in the same tick as the request, before the mirror is
9254
+ * asked, so they name exactly the bundles the screen holds. The parts go out even with the window
9255
+ * closed: they are not pty output, so they take no seq and the window never counts them. A request
9256
+ * for a shell that is gone, or a screen too big to send, is not answered; the tab falls back.
9257
+ */
9258
+ async snapshot(envelope) {
9259
+ const parsed = TerminalSnapshotRequestSchema.safeParse(envelope.payload);
9260
+ if (!parsed.success || this.bundleBytes === void 0) return;
9261
+ const { terminal_id: terminalId, snapshot_id: snapshotId } = parsed.data;
9262
+ const stream = this.streams.get(terminalId);
9263
+ if (!stream?.mirror) return;
9264
+ const afterSeq = stream.sentSeq;
9265
+ const afterBytes = stream.window.sent;
9266
+ const screen = await stream.mirror.snapshot();
9267
+ if (!screen || this.suspended || this.streams.get(terminalId) !== stream) return;
9268
+ const parts = splitIntoParts(Buffer.from(screen.text, "utf-8"), this.bundleBytes);
9269
+ parts.forEach((part, index) => {
9270
+ const payload = {
9271
+ terminal_id: terminalId,
9272
+ snapshot_id: snapshotId,
9273
+ part: index,
9274
+ last: index === parts.length - 1,
9275
+ after_seq: afterSeq,
9276
+ after_bytes: afterBytes,
9277
+ cols: screen.cols,
9278
+ rows: screen.rows,
9279
+ data: part.toString("base64")
9280
+ };
9281
+ this.dependencies.send(event(CHANNEL_TERMINAL, OP_SNAPSHOT, payload));
9282
+ });
9283
+ logger.debug("Terminal screen sent", { terminal_id: terminalId, parts: parts.length, after_seq: afterSeq });
9284
+ }
9285
+ /** A shell is gone. The context poll stops with the last one, so an idle agent spawns nothing. */
9286
+ forgetStream(terminalId) {
9287
+ this.streams.get(terminalId)?.mirror?.dispose();
9288
+ this.streams.delete(terminalId);
9289
+ this.contextTracker.forget(terminalId);
9290
+ if (this.streams.size === 0) this.stopContextTimer();
9291
+ }
9292
+ startContextTimer() {
9293
+ if (this.contextTimer || !this.dependencies.readShellContexts) return;
9294
+ this.contextTimer = setInterval(() => void this.pollShellContexts(), SHELL_CONTEXT_POLL_MILLIS);
9295
+ this.contextTimer.unref();
9296
+ }
9297
+ stopContextTimer() {
9298
+ if (this.contextTimer) {
9299
+ clearInterval(this.contextTimer);
9300
+ this.contextTimer = null;
9301
+ }
9302
+ }
9303
+ startExpiryTimer() {
9304
+ if (this.expiryTimer || this.idleTimeoutMillis === void 0 && this.maxSessionMillis === void 0) {
9305
+ return;
9306
+ }
9307
+ this.expiryTimer = setInterval(() => this.expireShells(), EXPIRY_CHECK_MILLIS);
9308
+ this.expiryTimer.unref();
9309
+ }
9310
+ stopExpiryTimer() {
9311
+ if (this.expiryTimer) {
9312
+ clearInterval(this.expiryTimer);
9313
+ this.expiryTimer = null;
9314
+ }
9315
+ }
9316
+ nowMillis() {
9317
+ return this.dependencies.now ? this.dependencies.now() : Date.now();
9318
+ }
9319
+ refuse(envelope, terminalId, code, message) {
9320
+ logger.info("Terminal open refused", { terminal_id: terminalId, code });
9321
+ const refused = { terminal_id: terminalId, error: { code, message } };
9322
+ this.dependencies.send(response(envelope, OP_OPENED, refused));
9323
+ }
9324
+ };
9325
+ }
9326
+ });
9327
+
9328
+ // src/devices/terminal/terminalRegistry.ts
9329
+ var DEFAULT_MAX_TERMINALS, TerminalRegistry;
9330
+ var init_terminalRegistry = __esm({
9331
+ "src/devices/terminal/terminalRegistry.ts"() {
9332
+ "use strict";
9333
+ init_cjs_shims();
9334
+ DEFAULT_MAX_TERMINALS = 3;
9335
+ TerminalRegistry = class {
9336
+ constructor() {
9337
+ this.handlesById = /* @__PURE__ */ new Map();
9338
+ this.maxTerminals = DEFAULT_MAX_TERMINALS;
9339
+ }
9340
+ setMaxTerminals(maxTerminals) {
9341
+ if (Number.isInteger(maxTerminals) && maxTerminals > 0) {
9342
+ this.maxTerminals = maxTerminals;
9343
+ }
9344
+ }
9345
+ has(terminalId) {
9346
+ return this.handlesById.has(terminalId);
9347
+ }
9348
+ isFull() {
9349
+ return this.handlesById.size >= this.maxTerminals;
9350
+ }
9351
+ /** False when the id is already taken or the ceiling is reached. The caller then kills the handle. */
9352
+ add(terminalId, handle) {
9353
+ if (this.handlesById.has(terminalId) || this.isFull()) return false;
9354
+ this.handlesById.set(terminalId, handle);
9355
+ return true;
9356
+ }
9357
+ get(terminalId) {
9358
+ return this.handlesById.get(terminalId);
9359
+ }
9360
+ /** Removes and returns the handle. Undefined the second time, so a close runs at most once. */
9361
+ remove(terminalId) {
9362
+ const handle = this.handlesById.get(terminalId);
9363
+ this.handlesById.delete(terminalId);
9364
+ return handle;
9365
+ }
9366
+ /** Every held shell with its id, in the order it was added. */
9367
+ entries() {
9368
+ return [...this.handlesById.entries()];
9369
+ }
9370
+ size() {
9371
+ return this.handlesById.size;
9372
+ }
9373
+ /** Removes every handle before killing any, so no exit callback finds itself still registered. */
9374
+ killAll() {
9375
+ const handles = [...this.handlesById.values()];
9376
+ this.handlesById.clear();
9377
+ for (const handle of handles) {
9378
+ try {
9379
+ handle.kill();
9380
+ } catch {
9381
+ }
9382
+ }
9383
+ }
9384
+ };
9385
+ }
9386
+ });
9387
+
9388
+ // src/devices/terminal/nodePtyRuntime.ts
9389
+ function isPackagedBinary() {
9390
+ return Boolean(process.pkg);
9391
+ }
9392
+ function isInsideAsarArchive(packageRoot) {
9393
+ if (!process.versions.electron) return false;
9394
+ return packageRoot.split(import_path8.default.sep).some((segment) => segment.endsWith(ASAR_ARCHIVE_SUFFIX));
9395
+ }
9396
+ function resolveNodePtyRoot() {
9397
+ const resolvedRoot = import_path8.default.dirname(require3.resolve(`${PACKAGE_NAME}/package.json`));
9398
+ if (!isPackagedBinary() && !isInsideAsarArchive(resolvedRoot)) return resolvedRoot;
9399
+ return unpackNodePty(resolvedRoot);
9400
+ }
9401
+ function requireNodePty(packageRoot) {
9402
+ return require3(packageRoot);
9403
+ }
9404
+ function spawnHelperPaths(packageRoot) {
9405
+ if (process.platform === "win32") return [];
9406
+ return NATIVE_DIRECTORIES.map((directory) => import_path8.default.join(packageRoot, directory, SPAWN_HELPER_FILE));
9407
+ }
9408
+ function hasExecutableSpawnHelper(packageRoot) {
9409
+ const helpers = spawnHelperPaths(packageRoot);
9410
+ if (helpers.length === 0) return true;
9411
+ return helpers.some((helper) => {
9412
+ try {
9413
+ const stat = import_fs10.default.statSync(helper);
9414
+ return stat.isFile() && (stat.mode & EXECUTE_BITS) !== 0;
9415
+ } catch {
9416
+ return false;
9417
+ }
9418
+ });
9419
+ }
9420
+ function makeSpawnHelperExecutable(packageRoot) {
9421
+ for (const helper of spawnHelperPaths(packageRoot)) {
9422
+ try {
9423
+ const stat = import_fs10.default.statSync(helper);
9424
+ if ((stat.mode & EXECUTE_BITS) === 0) {
9425
+ import_fs10.default.chmodSync(helper, EXECUTABLE_MODE);
9426
+ }
9427
+ } catch {
9428
+ }
9429
+ }
9430
+ }
9431
+ function unpackNodePty(snapshotRoot) {
9432
+ const unpackedRoot = unpackedRootPath(snapshotRoot);
9433
+ if (isLoadable(unpackedRoot)) return unpackedRoot;
9434
+ const stagingRoot = `${unpackedRoot}.${process.pid}`;
9435
+ import_fs10.default.rmSync(stagingRoot, { recursive: true, force: true });
9436
+ try {
9437
+ copyRuntimeFiles(snapshotRoot, stagingRoot);
9438
+ makeSpawnHelperExecutable(stagingRoot);
9439
+ import_fs10.default.renameSync(stagingRoot, unpackedRoot);
9440
+ } catch (error) {
9441
+ import_fs10.default.rmSync(stagingRoot, { recursive: true, force: true });
9442
+ if (!isLoadable(unpackedRoot)) throw error;
9443
+ }
9444
+ return unpackedRoot;
9445
+ }
9446
+ function unpackedRootPath(snapshotRoot) {
9447
+ const version = readPackageVersion(snapshotRoot);
9448
+ const directoryName = `${PACKAGE_NAME}-${version}-${process.platform}-${process.arch}`;
9449
+ return import_path8.default.join(getPinggyConfigDir(), RUNTIME_DIR_NAME, directoryName);
9450
+ }
9451
+ function readPackageVersion(packageRoot) {
9452
+ const manifest = JSON.parse(import_fs10.default.readFileSync(import_path8.default.join(packageRoot, "package.json"), "utf8"));
9453
+ return String(manifest.version);
9454
+ }
9455
+ function isLoadable(packageRoot) {
9456
+ if (!import_fs10.default.existsSync(import_path8.default.join(packageRoot, "package.json"))) return false;
9457
+ return NATIVE_DIRECTORIES.some((directory) => import_fs10.default.existsSync(import_path8.default.join(packageRoot, directory, NATIVE_FILE)));
9458
+ }
9459
+ function copyRuntimeFiles(sourceRoot, targetRoot) {
9460
+ import_fs10.default.mkdirSync(targetRoot, { recursive: true });
9461
+ copyFile(import_path8.default.join(sourceRoot, "package.json"), import_path8.default.join(targetRoot, "package.json"));
9462
+ copyDirectory(import_path8.default.join(sourceRoot, "lib"), import_path8.default.join(targetRoot, "lib"));
9463
+ for (const directory of NATIVE_DIRECTORIES) {
9464
+ const source = import_path8.default.join(sourceRoot, directory);
9465
+ if (import_fs10.default.existsSync(source)) {
9466
+ copyDirectory(source, import_path8.default.join(targetRoot, directory));
9467
+ }
9468
+ }
9469
+ }
9470
+ function copyDirectory(sourceDirectory, targetDirectory) {
9471
+ import_fs10.default.mkdirSync(targetDirectory, { recursive: true });
9472
+ for (const entry of import_fs10.default.readdirSync(sourceDirectory, { withFileTypes: true })) {
9473
+ const source = import_path8.default.join(sourceDirectory, entry.name);
9474
+ const target = import_path8.default.join(targetDirectory, entry.name);
9475
+ if (entry.isDirectory()) {
9476
+ copyDirectory(source, target);
9477
+ continue;
9478
+ }
9479
+ copyFile(source, target);
9480
+ }
9481
+ }
9482
+ function copyFile(source, target) {
9483
+ import_fs10.default.writeFileSync(target, import_fs10.default.readFileSync(source));
9484
+ }
9485
+ var import_fs10, import_path8, import_module2, require3, PACKAGE_NAME, NATIVE_FILE, SPAWN_HELPER_FILE, EXECUTABLE_MODE, EXECUTE_BITS, RUNTIME_DIR_NAME, ASAR_ARCHIVE_SUFFIX, NATIVE_DIRECTORIES;
9486
+ var init_nodePtyRuntime = __esm({
9487
+ "src/devices/terminal/nodePtyRuntime.ts"() {
9488
+ "use strict";
9489
+ init_cjs_shims();
9490
+ import_fs10 = __toESM(require("fs"), 1);
9491
+ import_path8 = __toESM(require("path"), 1);
9492
+ import_module2 = require("module");
9493
+ init_configDir();
9494
+ require3 = (0, import_module2.createRequire)(importMetaUrl);
9495
+ PACKAGE_NAME = "node-pty";
9496
+ NATIVE_FILE = "pty.node";
9497
+ SPAWN_HELPER_FILE = "spawn-helper";
9498
+ EXECUTABLE_MODE = 493;
9499
+ EXECUTE_BITS = 73;
9500
+ RUNTIME_DIR_NAME = "runtime";
9501
+ ASAR_ARCHIVE_SUFFIX = ".asar";
9502
+ NATIVE_DIRECTORIES = [
9503
+ import_path8.default.join("build", "Release"),
9504
+ import_path8.default.join("prebuilds", `${process.platform}-${process.arch}`)
9505
+ ];
9506
+ }
9507
+ });
9508
+
9509
+ // src/devices/terminal/ptySession.ts
9510
+ function foregroundProcessGroup(shellPid) {
9511
+ return new Promise((resolve) => {
9512
+ (0, import_child_process3.execFile)("ps", ["-o", "tpgid=", "-p", String(shellPid)], (err, stdout) => {
9513
+ const processGroupId = err ? NaN : Number.parseInt(String(stdout).trim(), 10);
9514
+ resolve(Number.isInteger(processGroupId) && processGroupId > 0 ? processGroupId : null);
9515
+ });
9516
+ });
9517
+ }
9518
+ async function signalForegroundGroup(shellPid, signal) {
9519
+ if (process.platform === "win32") {
9520
+ logger.debug("Terminal signal ignored on Windows", { pid: shellPid, signal });
9521
+ return;
9522
+ }
9523
+ const processGroupId = await foregroundProcessGroup(shellPid) ?? shellPid;
9524
+ try {
9525
+ process.kill(-processGroupId, `SIG${signal}`);
9526
+ } catch (err) {
9527
+ logger.debug("Terminal signal not delivered", {
9528
+ pid: shellPid,
9529
+ signal,
9530
+ error: err instanceof Error ? err.name : typeof err
9531
+ });
9532
+ }
9533
+ }
9534
+ function loadNodePty() {
9535
+ if (loadedPty !== void 0) return loadedPty;
9536
+ try {
9537
+ const packageRoot = resolveNodePtyRoot();
9538
+ const pty = requireNodePty(packageRoot);
9539
+ ensureSpawnHelperExecutable(packageRoot);
9540
+ loadedPty = hasExecutableSpawnHelper(packageRoot) ? pty : null;
9541
+ } catch {
9542
+ loadedPty = null;
9543
+ }
9544
+ return loadedPty;
9545
+ }
9546
+ function isTerminalSupported() {
9547
+ return loadNodePty() !== null;
9548
+ }
9549
+ function ensureSpawnHelperExecutable(packageRoot) {
9550
+ makeSpawnHelperExecutable(packageRoot ?? resolveNodePtyRoot());
9551
+ }
9552
+ function resolveCwd(requested) {
9553
+ if (requested) {
9554
+ try {
9555
+ if (import_fs11.default.statSync(requested).isDirectory()) return requested;
9556
+ } catch {
9557
+ }
9558
+ }
9559
+ return import_os7.default.homedir();
9560
+ }
9561
+ function spawnPty(request2) {
9562
+ const pty = loadNodePty();
9563
+ if (!pty) {
9564
+ throw new Error("node-pty is not available on this machine");
9565
+ }
9566
+ const process_ = pty.spawn(request2.shell, [], {
9567
+ name: TERM_NAME,
9568
+ cols: request2.cols,
9569
+ rows: request2.rows,
9570
+ cwd: resolveCwd(request2.cwd),
9571
+ env: { ...process.env, TERM: TERM_NAME },
9572
+ // Buffers rather than strings. node-pty decodes per read otherwise, and a read that ends
9573
+ // mid-character yields a replacement character the browser can never recover.
9574
+ encoding: null
9575
+ });
9576
+ return {
9577
+ pid: process_.pid,
9578
+ shell: request2.shell,
9579
+ get cols() {
9580
+ return process_.cols;
9581
+ },
9582
+ get rows() {
9583
+ return process_.rows;
9584
+ },
9585
+ kill: () => process_.kill(),
9586
+ onExit: (listener) => {
9587
+ process_.onExit(({ exitCode, signal }) => listener(exitCode, signal));
9588
+ },
9589
+ onData: (listener) => {
9590
+ process_.onData((chunk) => {
9591
+ listener(typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk);
9592
+ });
9593
+ },
9594
+ pause: () => process_.pause(),
9595
+ resume: () => process_.resume(),
9596
+ resize: (cols, rows) => process_.resize(cols, rows),
9597
+ write: (bytes) => process_.write(bytes),
9598
+ signalForeground: (signal) => {
9599
+ void signalForegroundGroup(process_.pid, signal);
9600
+ }
9601
+ };
9602
+ }
9603
+ var import_child_process3, import_fs11, import_os7, loadedPty, TERM_NAME;
9604
+ var init_ptySession = __esm({
9605
+ "src/devices/terminal/ptySession.ts"() {
9606
+ "use strict";
9607
+ init_cjs_shims();
9608
+ import_child_process3 = require("child_process");
9609
+ import_fs11 = __toESM(require("fs"), 1);
9610
+ import_os7 = __toESM(require("os"), 1);
9611
+ init_logger();
9612
+ init_nodePtyRuntime();
9613
+ TERM_NAME = "xterm-256color";
9614
+ }
9615
+ });
9616
+
9617
+ // src/devices/terminal/shellAllowlist.ts
9618
+ function readShellEnvironment() {
9619
+ let etcShells = null;
9620
+ try {
9621
+ etcShells = import_fs12.default.readFileSync(ETC_SHELLS_PATH, "utf8");
9622
+ } catch {
9623
+ etcShells = null;
9624
+ }
9625
+ return {
9626
+ platform: process.platform,
9627
+ loginShell: process.env.SHELL,
9628
+ etcShells,
9629
+ exists: (path12) => {
9630
+ try {
9631
+ return import_fs12.default.statSync(path12).isFile();
9632
+ } catch {
9633
+ return false;
9634
+ }
9635
+ }
9636
+ };
9637
+ }
9638
+ function parseEtcShells(contents) {
9639
+ return contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith("/"));
9640
+ }
9641
+ function allowedShells(environment) {
9642
+ if (environment.platform === "win32") return WINDOWS_SHELLS;
9643
+ const listed = environment.etcShells === null ? [] : parseEtcShells(environment.etcShells);
9644
+ return listed.length > 0 ? listed : FALLBACK_UNIX_SHELLS;
9645
+ }
9646
+ function resolveShell(requested, environment) {
9647
+ const allowed = allowedShells(environment);
9648
+ const usable = (shell) => environment.platform === "win32" || environment.exists(shell);
9649
+ if (requested !== null && requested !== void 0) {
9650
+ if (allowed.includes(requested) && usable(requested)) {
9651
+ return { shell: requested };
9652
+ }
9653
+ return { error: "That shell is not allowed on this machine." };
9654
+ }
9655
+ const loginShell = environment.loginShell;
9656
+ if (loginShell && allowed.includes(loginShell) && usable(loginShell)) {
9657
+ return { shell: loginShell };
9658
+ }
9659
+ const firstUsable = allowed.find(usable);
9660
+ return firstUsable ? { shell: firstUsable } : { error: "No allowed shell exists on this machine." };
9661
+ }
9662
+ var import_fs12, FALLBACK_UNIX_SHELLS, WINDOWS_SHELLS, ETC_SHELLS_PATH;
9663
+ var init_shellAllowlist = __esm({
9664
+ "src/devices/terminal/shellAllowlist.ts"() {
9665
+ "use strict";
9666
+ init_cjs_shims();
9667
+ import_fs12 = __toESM(require("fs"), 1);
9668
+ FALLBACK_UNIX_SHELLS = ["/bin/bash", "/bin/zsh", "/bin/sh", "/usr/bin/bash", "/usr/bin/zsh"];
9669
+ WINDOWS_SHELLS = ["powershell.exe", "cmd.exe"];
9670
+ ETC_SHELLS_PATH = "/etc/shells";
9671
+ }
9672
+ });
9673
+
9674
+ // src/devices/deviceAgent.ts
9675
+ function buildCapabilities(terminalSupported, screenRestoreSupported = false) {
9676
+ if (!terminalSupported) return BASE_CAPABILITIES;
9677
+ return screenRestoreSupported ? [...BASE_CAPABILITIES, CAPABILITY_TERMINAL, CAPABILITY_TERMINAL_SNAPSHOT] : [...BASE_CAPABILITIES, CAPABILITY_TERMINAL];
9678
+ }
9679
+ function buildDeviceAgentWsUrl(manage) {
9680
+ let baseUrl = (manage || DEFAULT_SERVER).trim();
9681
+ if (!(baseUrl.startsWith("ws://") || baseUrl.startsWith("wss://"))) {
9682
+ baseUrl = "wss://" + baseUrl;
9683
+ }
9684
+ return `${baseUrl.replace(/\/$/, "")}/backend/api/v1/device-agent/ws/connect`;
9685
+ }
9686
+ function sleep2(millis, state) {
9687
+ return new Promise((resolve) => {
9688
+ const timer = setTimeout(() => {
9689
+ state.cancelPendingSleep = null;
9690
+ resolve();
9691
+ }, millis);
9692
+ state.cancelPendingSleep = () => {
9693
+ clearTimeout(timer);
9694
+ state.cancelPendingSleep = null;
9695
+ resolve();
9696
+ };
9697
+ });
9698
+ }
9699
+ function buildHello(terminalHandler) {
9700
+ return {
9701
+ agent_version: getVersion(),
9702
+ os: import_os8.default.platform(),
9703
+ hostname: import_os8.default.hostname(),
9704
+ capabilities: buildCapabilities(isTerminalSupported(), isScreenMirrorSupported()),
9705
+ terminals: terminalHandler.held()
9706
+ };
9707
+ }
9708
+ function startMetricsReporting(intervalSeconds, collect, send) {
9709
+ let stopped = false;
9710
+ const report = () => {
9711
+ collect().then((metrics) => {
9712
+ if (!stopped) send(metrics);
9713
+ }).catch((err) => logger.warn("Device metrics collection failed", { error: String(err) }));
9714
+ };
9715
+ report();
9716
+ const timer = setInterval(report, intervalSeconds * 1e3);
9717
+ return () => {
9718
+ stopped = true;
9719
+ clearInterval(timer);
9720
+ };
9721
+ }
9722
+ function identityForRun(token, server, stored) {
9723
+ if (stored?.token === token) {
9724
+ return { ...stored, server };
9725
+ }
9726
+ return { device_agent_id: null, token, server, enrolled_at: null };
9727
+ }
9728
+ function startDeviceAgent(token, manage, options = {}) {
9729
+ const server = manage || DEFAULT_SERVER;
9730
+ const reporter = options.reporter ?? printingReporter;
9731
+ const stored = readDeviceIdentity();
9732
+ if (stored?.device_agent_id && stored.token !== token) {
9733
+ reporter.replacingDevice(stored.device_agent_id);
9734
+ }
9735
+ const connection = { send: null, close: null };
9736
+ const context = {
9737
+ wsUrl: buildDeviceAgentWsUrl(manage),
9738
+ server,
9739
+ identity: identityForRun(token, server, stored),
9740
+ reconnectPolicy: options.reconnectPolicy ?? new ReconnectPolicy(),
9741
+ handshakeTimeoutMillis: options.handshakeTimeoutMillis ?? HANDSHAKE_TIMEOUT_MILLIS,
9742
+ terminalHandler: new TerminalHandler({
9743
+ send: (frame) => connection.send?.(frame),
9744
+ spawn: spawnPty,
9745
+ resolveShell: (requested) => resolveShell(requested, readShellEnvironment()),
9746
+ registry: new TerminalRegistry(),
9747
+ readShellContexts: processTableReader(),
9748
+ resolvedHome: resolvedHomeDirectory()
9749
+ }),
9750
+ connection,
9751
+ reporter,
9752
+ writeIdentity: options.writeIdentity ?? writeDeviceIdentity,
9753
+ options,
9754
+ state: { stopRequested: false, cancelPendingSleep: null, error: null, welcomed: false }
9755
+ };
9756
+ const done = runLoop(context);
9757
+ return {
9758
+ done,
9759
+ stop: () => {
9760
+ context.state.stopRequested = true;
9761
+ context.state.cancelPendingSleep?.();
9762
+ connection.close?.();
9763
+ return done;
9764
+ }
9765
+ };
9766
+ }
9767
+ async function runDeviceAgent(token, manage, options = {}) {
9768
+ const run2 = startDeviceAgent(token, manage, options);
9769
+ const sigintHandler = () => {
9770
+ void run2.stop();
9771
+ };
9772
+ process.once("SIGINT", sigintHandler);
9773
+ try {
9774
+ await run2.done;
9775
+ } finally {
9776
+ process.removeListener("SIGINT", sigintHandler);
9777
+ }
9778
+ }
9779
+ async function runLoop(context) {
9780
+ const { state, reporter, reconnectPolicy } = context;
9781
+ while (!state.stopRequested) {
9782
+ reporter.connecting(context.server);
9783
+ const outcome = await connectOnce(context);
9784
+ if (outcome === "terminal" || state.stopRequested) {
9785
+ break;
9786
+ }
9787
+ if (context.options.verifyOnly) {
9788
+ state.error = {
9789
+ code: AGENT_ERROR_NOT_CONNECTED,
9790
+ message: `Could not connect to ${context.server}. Check the address and the network, then try again.`
9791
+ };
9792
+ break;
9793
+ }
9794
+ const delayMillis = reconnectPolicy.nextDelayMillis();
9795
+ reporter.reconnecting(delayMillis, reconnectPolicy.attempt);
9796
+ await sleep2(delayMillis, state);
9797
+ }
9798
+ context.terminalHandler.closeAll();
9799
+ return { error: state.error, welcomed: state.welcomed };
9800
+ }
9801
+ function fail(context, error) {
9802
+ context.state.error = error;
9803
+ context.reporter.failed(error);
9804
+ return "terminal";
9805
+ }
9806
+ function connectOnce(context) {
9807
+ const { identity, reconnectPolicy, terminalHandler, connection, reporter, state, options } = context;
9808
+ return new Promise((resolve) => {
9809
+ const ws = new import_ws3.default(context.wsUrl, { headers: { [TOKEN_HEADER]: identity.token } });
9810
+ let heartbeat = null;
9811
+ let stopMetrics = null;
9812
+ let stopTunnels = null;
9813
+ let pingTimer = null;
9814
+ let pongDeadline = null;
9815
+ let handshakeDeadline = null;
9816
+ let closeDeadline = null;
9817
+ let settled = false;
9818
+ const stopTimers = () => {
9819
+ if (heartbeat) clearInterval(heartbeat);
9820
+ heartbeat = null;
9821
+ stopMetrics?.();
9822
+ stopMetrics = null;
9823
+ stopTunnels?.();
9824
+ stopTunnels = null;
9825
+ if (pingTimer) clearInterval(pingTimer);
9826
+ pingTimer = null;
9827
+ if (pongDeadline) clearTimeout(pongDeadline);
9828
+ pongDeadline = null;
9829
+ if (handshakeDeadline) clearTimeout(handshakeDeadline);
9830
+ handshakeDeadline = null;
9831
+ };
9832
+ const finish = (outcome) => {
9833
+ if (settled) return;
9834
+ settled = true;
9835
+ stopTimers();
9836
+ if (closeDeadline) clearTimeout(closeDeadline);
9837
+ closeDeadline = null;
9838
+ if (reconnectPolicy.markDisconnected()) {
9839
+ logger.info("Connection held long enough to reset the reconnect schedule");
9840
+ }
9841
+ connection.send = null;
9842
+ connection.close = null;
9843
+ terminalHandler.suspend();
9844
+ resolve(outcome);
9845
+ };
9846
+ const sendFrame = (frame) => {
9847
+ if (ws.readyState === import_ws3.default.OPEN) {
9848
+ ws.send(JSON.stringify(frame));
9849
+ }
9850
+ };
9851
+ const dropDeadSocket = (message) => {
9852
+ logger.warn(message);
9853
+ reporter.warn(message);
9854
+ ws.terminate();
9855
+ };
9856
+ connection.close = () => {
9857
+ if (ws.readyState === import_ws3.default.CONNECTING) {
9858
+ ws.terminate();
9859
+ return;
9860
+ }
9861
+ ws.close(CLOSE_CODE_NORMAL);
9862
+ closeDeadline = setTimeout(() => ws.terminate(), CLOSE_TIMEOUT_MILLIS);
9863
+ };
9864
+ ws.once("open", () => {
9865
+ logger.info("Device agent socket open, sending hello");
9866
+ connection.send = sendFrame;
9867
+ sendFrame(request(CHANNEL_SYSTEM, OP_HELLO, buildHello(terminalHandler)));
9868
+ handshakeDeadline = setTimeout(() => {
9869
+ dropDeadSocket("No welcome from the dashboard. Reconnecting.");
9870
+ }, context.handshakeTimeoutMillis);
9871
+ });
9872
+ const startPongWatchdog = (intervalSeconds) => {
9873
+ const intervalMillis = intervalSeconds * 1e3;
9874
+ const graceMillis = intervalMillis * PONG_GRACE_INTERVALS;
9875
+ const armDeadline = () => {
9876
+ if (pongDeadline) clearTimeout(pongDeadline);
9877
+ pongDeadline = setTimeout(() => {
9878
+ dropDeadSocket("Connection stopped answering. Reconnecting.");
9879
+ }, graceMillis);
9880
+ };
9881
+ ws.on("pong", armDeadline);
9882
+ pingTimer = setInterval(() => {
9883
+ if (ws.readyState === import_ws3.default.OPEN) {
9884
+ ws.ping();
9885
+ }
9886
+ }, intervalMillis);
9887
+ armDeadline();
9888
+ };
9889
+ const startHeartbeat = (intervalSeconds) => {
9890
+ heartbeat = setInterval(
9891
+ () => sendFrame(
9892
+ event(CHANNEL_SYSTEM, OP_HEARTBEAT, { uptime_seconds: Math.floor(import_os8.default.uptime()) })
9893
+ ),
9894
+ intervalSeconds * 1e3
9895
+ );
9896
+ };
9897
+ const onWelcome = (welcome) => {
9898
+ stopTimers();
9899
+ if (options.verifyOnly) {
9900
+ state.stopRequested = true;
9901
+ connection.close?.();
9902
+ return;
9903
+ }
9904
+ reconnectPolicy.markConnected();
9905
+ terminalHandler.configure(
9906
+ welcome.terminal_enabled,
9907
+ welcome.max_terminals_per_device,
9908
+ welcome.terminal_window_bytes,
9909
+ welcome.max_frame_bytes,
9910
+ welcome.terminal_idle_timeout_seconds,
9911
+ welcome.terminal_max_session_seconds
9912
+ );
9913
+ terminalHandler.resumeAll();
9914
+ startHeartbeat(welcome.heartbeat_interval_seconds);
9915
+ startPongWatchdog(welcome.heartbeat_interval_seconds);
9916
+ sendFrame(event(CHANNEL_DEVICE, OP_INFO, collectSystemInfo()));
9917
+ stopMetrics = startMetricsReporting(
9918
+ welcome.stats_interval_seconds,
9919
+ collectMetrics,
9920
+ (metrics) => sendFrame(event(CHANNEL_DEVICE, OP_METRICS, metrics))
9921
+ );
9922
+ stopTunnels = startTunnelReporting(
9923
+ () => collectTunnelList(options.tunnelListSource),
9924
+ (tunnels) => sendFrame(event(CHANNEL_DEVICE, OP_TUNNELS, tunnels))
9925
+ );
9926
+ };
9927
+ ws.on("message", (data) => {
9928
+ const envelope = parseEnvelope(data.toString("utf8"));
9929
+ if (!envelope) {
9930
+ logger.debug("Ignoring unparseable frame");
9931
+ return;
9932
+ }
9933
+ if (envelope.ch === CHANNEL_TERMINAL) {
9934
+ terminalHandler.handle(envelope);
9935
+ return;
9936
+ }
9937
+ if (envelope.ch === CHANNEL_TUNNEL) {
9938
+ void handleTunnelAction(envelope, (frame) => connection.send?.(frame), options.tunnelActions).then(() => stopTunnels?.pollNow());
9939
+ return;
9940
+ }
9941
+ const outcome = handleFrame(envelope, context, onWelcome);
9942
+ if (outcome === "terminal") {
9943
+ finish("terminal");
9944
+ ws.close();
9945
+ }
9946
+ });
9947
+ ws.on("unexpected-response", (_req, res) => {
9948
+ if (res.statusCode === 401) {
9949
+ finish(fail(context, {
9950
+ code: AGENT_ERROR_UNAUTHORIZED,
9951
+ message: "Unauthorized. This device token is not valid. Re-enrol this machine with: pinggy device login"
9952
+ }));
9953
+ } else {
9954
+ reporter.warn(`Unexpected HTTP ${res.statusCode}.`);
9955
+ finish("retry");
9956
+ }
9957
+ ws.close();
9958
+ });
9959
+ ws.on("close", (code, reason) => {
9960
+ logger.info("Device agent socket closed", { code, reason: reason.toString() });
9961
+ finish(code === CLOSE_CODE_REVOKED ? "terminal" : "retry");
9962
+ });
9963
+ ws.on("error", (err) => {
9964
+ logger.warn("Device agent socket error", { error: err.message });
9965
+ if (!state.stopRequested) {
9966
+ reporter.warn(err.message);
9967
+ }
9968
+ finish("retry");
9969
+ });
9970
+ });
9971
+ }
9972
+ function handleFrame(envelope, context, onWelcome) {
9973
+ const { identity, reporter } = context;
9974
+ if (envelope.ch !== CHANNEL_SYSTEM) {
9975
+ logger.debug("Ignoring frame on unknown channel", { channel: envelope.ch });
9976
+ return "retry";
9977
+ }
9978
+ if (envelope.op === OP_WELCOME) {
9979
+ const error = ErrorPayloadSchema.safeParse(envelope.payload);
9980
+ if (error.success) {
9981
+ if (error.data.error.code === ERROR_ALREADY_CONNECTED) {
9982
+ reporter.warn("This device is connected elsewhere. Retrying.");
9983
+ return "retry";
9984
+ }
9985
+ return fail(context, {
9986
+ code: AGENT_ERROR_HANDSHAKE_REFUSED,
9987
+ message: `Handshake refused: ${error.data.error.message}`
9988
+ });
9989
+ }
9990
+ const welcome = WelcomeSchema.safeParse(envelope.payload);
9991
+ if (!welcome.success) {
9992
+ logger.warn("Welcome payload could not be read");
9993
+ return "retry";
9994
+ }
9995
+ identity.device_agent_id = welcome.data.device_agent_id;
9996
+ identity.enrolled_at = identity.enrolled_at ?? (/* @__PURE__ */ new Date()).toISOString();
9997
+ context.writeIdentity(identity);
9998
+ context.state.welcomed = true;
9999
+ reporter.connected(welcome.data);
10000
+ onWelcome(welcome.data);
10001
+ return "retry";
10002
+ }
10003
+ if (envelope.op === OP_DISCONNECT) {
10004
+ const disconnect = DisconnectSchema.safeParse(envelope.payload);
10005
+ const reason = disconnect.success ? disconnect.data.reason : "unknown";
10006
+ const { outcome, message } = describeDisconnect(reason);
10007
+ if (outcome === "retry") {
10008
+ reporter.warn(message);
10009
+ return "retry";
10010
+ }
10011
+ return fail(context, { code: disconnectErrorCode(reason), message });
10012
+ }
10013
+ logger.debug("Ignoring unhandled system op", { op: envelope.op });
10014
+ return "retry";
10015
+ }
10016
+ function disconnectErrorCode(reason) {
10017
+ return reason in DISCONNECT_MESSAGES ? reason : AGENT_ERROR_DISCONNECTED;
10018
+ }
10019
+ function describeDisconnect(reason) {
10020
+ if (reason === DISCONNECT_REASON_SHUTDOWN) {
10021
+ return { outcome: "retry", message: "Server is shutting down. Reconnecting." };
10022
+ }
10023
+ return {
10024
+ outcome: "terminal",
10025
+ message: DISCONNECT_MESSAGES[reason] ?? `Disconnected by the dashboard: ${reason}. Re-enrol this device to continue.`
10026
+ };
10027
+ }
10028
+ var import_ws3, import_os8, CLOSE_CODE_REVOKED, CLOSE_CODE_NORMAL, CLOSE_TIMEOUT_MILLIS, TOKEN_HEADER, DEFAULT_SERVER, ERROR_ALREADY_CONNECTED, AGENT_ERROR_UNAUTHORIZED, AGENT_ERROR_HANDSHAKE_REFUSED, AGENT_ERROR_DISCONNECTED, AGENT_ERROR_NOT_CONNECTED, HANDSHAKE_TIMEOUT_MILLIS, PONG_GRACE_INTERVALS, BASE_CAPABILITIES, CAPABILITY_TERMINAL, CAPABILITY_TERMINAL_SNAPSHOT, DISCONNECT_REASON_SHUTDOWN, DISCONNECT_MESSAGES;
10029
+ var init_deviceAgent = __esm({
10030
+ "src/devices/deviceAgent.ts"() {
10031
+ "use strict";
10032
+ init_cjs_shims();
10033
+ import_ws3 = __toESM(require("ws"), 1);
10034
+ import_os8 = __toESM(require("os"), 1);
10035
+ init_logger();
10036
+ init_util();
10037
+ init_envelope();
10038
+ init_device_schema();
10039
+ init_deviceIdentity();
10040
+ init_deviceAgentReporter();
10041
+ init_systemInfo();
10042
+ init_metrics();
10043
+ init_tunnelList();
10044
+ init_tunnelActions();
10045
+ init_reconnect();
10046
+ init_terminal_schema();
10047
+ init_terminalHandler();
10048
+ init_screenMirror();
10049
+ init_terminalRegistry();
10050
+ init_ptySession();
10051
+ init_shellAllowlist();
10052
+ init_shellContext();
10053
+ init_deviceAgentReporter();
10054
+ CLOSE_CODE_REVOKED = 4001;
10055
+ CLOSE_CODE_NORMAL = 1e3;
10056
+ CLOSE_TIMEOUT_MILLIS = 3e3;
10057
+ TOKEN_HEADER = "X-Pinggy-Device-Token";
10058
+ DEFAULT_SERVER = "dashboard.pinggy.io";
10059
+ ERROR_ALREADY_CONNECTED = "already_connected";
10060
+ AGENT_ERROR_UNAUTHORIZED = "unauthorized";
10061
+ AGENT_ERROR_HANDSHAKE_REFUSED = "handshake_refused";
10062
+ AGENT_ERROR_DISCONNECTED = "disconnected";
10063
+ AGENT_ERROR_NOT_CONNECTED = "not_connected";
10064
+ HANDSHAKE_TIMEOUT_MILLIS = 3e4;
10065
+ PONG_GRACE_INTERVALS = 2;
10066
+ BASE_CAPABILITIES = ["tunnel", "stats", "tunnel_control", "tunnel_update", "saved_config_update"];
10067
+ CAPABILITY_TERMINAL = "terminal";
10068
+ CAPABILITY_TERMINAL_SNAPSHOT = "terminal_snapshot";
10069
+ DISCONNECT_REASON_SHUTDOWN = "shutdown";
10070
+ DISCONNECT_MESSAGES = {
10071
+ revoked: "Credential revoked. Copy the new install command from the dashboard and run it to reconnect.",
10072
+ deleted: "Device deleted from the dashboard. Add it again to reconnect this machine.",
10073
+ replaced: "This device connected again from another session. Stopping this one."
10074
+ };
10075
+ }
10076
+ });
10077
+
10078
+ // src/devices/login/login_schema.ts
10079
+ var import_zod5, LoginStartedSchema, LoginStatus, LoginAnswerSchema, LoginErrorSchema, LoginErrorType;
10080
+ var init_login_schema = __esm({
10081
+ "src/devices/login/login_schema.ts"() {
10082
+ "use strict";
10083
+ init_cjs_shims();
10084
+ import_zod5 = require("zod");
10085
+ LoginStartedSchema = import_zod5.z.object({
10086
+ device_code: import_zod5.z.string().min(1),
10087
+ match_code: import_zod5.z.string().min(1).optional(),
10088
+ expires_in_seconds: import_zod5.z.number().int().positive(),
10089
+ poll_interval_seconds: import_zod5.z.number().int().positive()
10090
+ });
10091
+ LoginStatus = {
10092
+ Pending: "pending",
10093
+ MfaRequired: "mfa_required",
10094
+ Approved: "approved"
10095
+ };
10096
+ LoginAnswerSchema = import_zod5.z.object({
10097
+ status: import_zod5.z.enum([LoginStatus.Pending, LoginStatus.MfaRequired, LoginStatus.Approved]),
10098
+ token: import_zod5.z.string().min(1).optional(),
10099
+ device_agent_id: import_zod5.z.string().min(1).optional(),
10100
+ device_name: import_zod5.z.string().optional(),
10101
+ open_url: import_zod5.z.string().optional(),
10102
+ device_url: import_zod5.z.string().optional()
10103
+ });
10104
+ LoginErrorSchema = import_zod5.z.object({
10105
+ error_type: import_zod5.z.string(),
10106
+ errors: import_zod5.z.array(import_zod5.z.string()).default([])
10107
+ });
10108
+ LoginErrorType = {
10109
+ /** A wrong code or authenticator code, with attempts left. Asked again. */
10110
+ InvalidCode: "device_login_invalid_code",
10111
+ /** Expired, ended after too many wrong codes, or already used. */
10112
+ Expired: "device_login_expired",
10113
+ RateLimited: "device_login_rate_limited",
10114
+ InvalidClient: "device_login_invalid_client"
10115
+ };
10116
+ }
10117
+ });
10118
+
10119
+ // src/devices/login/loginApi.ts
10120
+ function buildDashboardHttpUrl(manage) {
10121
+ const address = (manage || DEFAULT_DASHBOARD).trim().replace(/\/+$/, "");
10122
+ const match = SCHEME_PATTERN.exec(address);
10123
+ const host = match ? address.slice(match[0].length) : address;
10124
+ const scheme = match && PLAIN_SCHEMES.has(match[1].toLowerCase()) ? "http" : "https";
10125
+ return `${scheme}://${host}${LOGIN_PATH}`;
10126
+ }
10127
+ function createDeviceLoginApi(manage, fetchImpl = fetch) {
10128
+ const baseUrl = buildDashboardHttpUrl(manage);
10129
+ async function post(path12, body, schema) {
10130
+ const response2 = await fetchImpl(`${baseUrl}/${path12}`, {
10131
+ method: "POST",
10132
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
10133
+ body: JSON.stringify(body)
10134
+ });
10135
+ const json = await readJson(response2);
10136
+ if (!response2.ok) {
10137
+ const refusal = LoginErrorSchema.safeParse(json);
10138
+ if (refusal.success && refusal.data.errors.length > 0) {
10139
+ throw new DeviceLoginApiError(response2.status, refusal.data.error_type, refusal.data.errors[0]);
10140
+ }
10141
+ throw new DeviceLoginApiError(
10142
+ response2.status,
10143
+ `http_${response2.status}`,
10144
+ `The dashboard answered HTTP ${response2.status}.`
10145
+ );
10146
+ }
10147
+ const answer = schema.safeParse(json);
10148
+ if (!answer.success) {
10149
+ throw new DeviceLoginApiError(
10150
+ response2.status,
10151
+ UNREADABLE_ANSWER,
10152
+ "The dashboard sent an answer this pinggy cannot read. Update pinggy and try again."
10153
+ );
10154
+ }
10155
+ return answer.data;
10156
+ }
10157
+ return {
10158
+ start: (request2) => post("start", request2, LoginStartedSchema),
10159
+ verifyCode: (deviceCode, code) => post("verify-code", { device_code: deviceCode, code }, LoginAnswerSchema),
10160
+ verifyMfa: (deviceCode, totp) => post("verify-mfa", { device_code: deviceCode, totp }, LoginAnswerSchema),
10161
+ poll: (deviceCode) => post("poll", { device_code: deviceCode }, LoginAnswerSchema)
10162
+ };
10163
+ }
10164
+ async function readJson(response2) {
10165
+ const text = await response2.text();
10166
+ if (!text) {
10167
+ return void 0;
10168
+ }
10169
+ try {
10170
+ return JSON.parse(text);
10171
+ } catch {
10172
+ return void 0;
10173
+ }
10174
+ }
10175
+ var DEVICE_LOGIN_CLIENT_TOKEN, DEFAULT_DASHBOARD, LOGIN_PATH, SCHEME_PATTERN, PLAIN_SCHEMES, DeviceLoginApiError, UNREADABLE_ANSWER;
10176
+ var init_loginApi = __esm({
10177
+ "src/devices/login/loginApi.ts"() {
10178
+ "use strict";
10179
+ init_cjs_shims();
10180
+ init_login_schema();
10181
+ DEVICE_LOGIN_CLIENT_TOKEN = "pinggy-cli-1";
10182
+ DEFAULT_DASHBOARD = "dashboard.pinggy.io";
10183
+ LOGIN_PATH = "/backend/api/v1/device-agent/login";
10184
+ SCHEME_PATTERN = /^(wss?|https?):\/\//i;
10185
+ PLAIN_SCHEMES = /* @__PURE__ */ new Set(["ws", "http"]);
10186
+ DeviceLoginApiError = class extends Error {
10187
+ constructor(status, errorType, message) {
10188
+ super(message);
10189
+ this.status = status;
10190
+ this.errorType = errorType;
10191
+ this.name = "DeviceLoginApiError";
10192
+ }
10193
+ };
10194
+ UNREADABLE_ANSWER = "invalid_answer";
10195
+ }
10196
+ });
10197
+
10198
+ // src/devices/login/prompts.ts
10199
+ function createTerminalPrompter(input, output) {
10200
+ return {
10201
+ async ask(question, signal) {
10202
+ const readline = (0, import_promises.createInterface)({ input, output, terminal: true });
10203
+ try {
10204
+ return await readline.question(question, signal ? { signal } : {});
10205
+ } finally {
10206
+ readline.close();
10207
+ }
10208
+ },
10209
+ async askHidden(question, signal) {
10210
+ output.write(question);
10211
+ const readline = (0, import_promises.createInterface)({ input, output: discardingOutput(), terminal: true });
10212
+ try {
10213
+ const answer = await readline.question("", signal ? { signal } : {});
10214
+ output.write("\n");
10215
+ return answer;
10216
+ } finally {
10217
+ readline.close();
10218
+ }
10219
+ }
10220
+ };
10221
+ }
10222
+ function isInteractiveTerminal() {
10223
+ return process.stdin.isTTY === true;
10224
+ }
10225
+ function discardingOutput() {
10226
+ return new import_stream.Writable({
10227
+ write(_chunk, _encoding, callback) {
10228
+ callback();
10229
+ }
10230
+ });
10231
+ }
10232
+ var import_promises, import_stream, terminalPrompter;
10233
+ var init_prompts = __esm({
10234
+ "src/devices/login/prompts.ts"() {
10235
+ "use strict";
10236
+ init_cjs_shims();
10237
+ import_promises = require("readline/promises");
10238
+ import_stream = require("stream");
10239
+ terminalPrompter = createTerminalPrompter(process.stdin, process.stdout);
10240
+ }
10241
+ });
10242
+
10243
+ // src/devices/login/deviceLogin.ts
10244
+ function machineDeviceLoginDependencies() {
10245
+ return {
10246
+ createApi: (manage) => createDeviceLoginApi(manage),
10247
+ prompter: terminalPrompter,
10248
+ isInteractive: isInteractiveTerminal,
10249
+ readIdentity: readDeviceIdentity,
10250
+ writeIdentity: writeDeviceIdentity,
10251
+ runAgent: (token, manage) => runDeviceAgent(token, manage),
10252
+ renderQr: async (url) => (await createQrCodes([url]))[0],
10253
+ machine: () => ({
10254
+ hostname: import_os9.default.hostname(),
10255
+ os: import_os9.default.platform(),
10256
+ arch: import_os9.default.arch(),
10257
+ agent_version: getVersion()
10258
+ }),
10259
+ print: (message) => printer_default.print(message),
10260
+ printError: (message) => printer_default.error(message),
10261
+ sleep: sleepUnlessAborted
10262
+ };
10263
+ }
10264
+ async function runDeviceLogin(options, dependencies = machineDeviceLoginDependencies()) {
10265
+ if (!dependencies.isInteractive()) {
10266
+ dependencies.printError(NO_TERMINAL_MESSAGE);
10267
+ return EXIT_FAILED;
10268
+ }
10269
+ const stored = dependencies.readIdentity();
10270
+ const manage = options.manage ?? stored?.server;
10271
+ if (stored) {
10272
+ const answer = await dependencies.prompter.ask(
10273
+ `This machine is already enrolled as ${stored.device_agent_id ?? "a device"} on ${stored.server}. Add it again as a new device? [y/N] `
10274
+ );
10275
+ if (!YES_ANSWER.test(answer.trim())) {
10276
+ return await dependencies.runAgent(stored.token, manage) ?? EXIT_OK;
10277
+ }
10278
+ }
10279
+ const email = (await dependencies.prompter.ask("Email: ")).trim();
10280
+ if (!email) {
10281
+ dependencies.printError("An email is required.");
10282
+ return EXIT_FAILED;
10283
+ }
10284
+ const api = dependencies.createApi(manage);
10285
+ let approved;
10286
+ let linkMinutes;
10287
+ try {
10288
+ const started = await api.start({
10289
+ email,
10290
+ client_token: DEVICE_LOGIN_CLIENT_TOKEN,
10291
+ ...dependencies.machine(),
10292
+ ...options.name ? { name: options.name } : {}
10293
+ });
10294
+ linkMinutes = Math.round(started.expires_in_seconds / SECONDS_PER_MINUTE);
10295
+ dependencies.print(`We sent a 6-digit code to ${email}. It works for ${linkMinutes} minutes.`);
10296
+ if (started.match_code) {
10297
+ dependencies.print("Type it below, or click Approve sign-in in the email.");
10298
+ dependencies.print(`If you use the email: approve only if the page shows ${started.match_code}. A different code is someone else's sign-in.`);
10299
+ }
10300
+ approved = await waitForApproval(api, started.device_code, started.poll_interval_seconds, dependencies);
10301
+ } catch (err) {
10302
+ dependencies.printError(messageOf(err, manage));
10303
+ return EXIT_FAILED;
10304
+ }
10305
+ if (!approved.token || !approved.device_agent_id) {
10306
+ dependencies.printError("The dashboard approved the sign-in without a device token. Update pinggy and try again.");
10307
+ return EXIT_FAILED;
10308
+ }
10309
+ dependencies.writeIdentity({
10310
+ device_agent_id: approved.device_agent_id,
10311
+ token: approved.token,
10312
+ server: manage || DEFAULT_SERVER2,
10313
+ enrolled_at: null
10314
+ });
10315
+ dependencies.print(`Signed in as ${email}. Added device ${approved.device_name ?? approved.device_agent_id}.`);
10316
+ await printDeviceLink(approved, linkMinutes, dependencies);
10317
+ return await dependencies.runAgent(approved.token, manage) ?? EXIT_OK;
10318
+ }
10319
+ async function waitForApproval(api, deviceCode, pollIntervalSeconds, dependencies) {
10320
+ const controller = new AbortController();
10321
+ const prompt = { showing: false };
10322
+ const typed = typeTheCode(api, deviceCode, controller.signal, prompt, dependencies);
10323
+ const polled = pollUntilApproved(
10324
+ api,
10325
+ deviceCode,
10326
+ pollIntervalSeconds * MILLIS_PER_SECOND2,
10327
+ controller.signal,
10328
+ dependencies
10329
+ );
10330
+ typed.catch(() => void 0);
10331
+ polled.catch(() => void 0);
10332
+ try {
10333
+ return await Promise.race([typed, polled]);
10334
+ } finally {
10335
+ controller.abort();
10336
+ if (prompt.showing) {
10337
+ dependencies.print("");
10338
+ }
10339
+ }
10340
+ }
10341
+ async function typeTheCode(api, deviceCode, signal, prompt, dependencies) {
10342
+ const ask = async (question, hidden = false) => {
10343
+ prompt.showing = true;
10344
+ try {
10345
+ const answer2 = hidden ? dependencies.prompter.askHidden(question, signal) : dependencies.prompter.ask(question, signal);
10346
+ return (await answer2).trim();
10347
+ } finally {
10348
+ prompt.showing = false;
10349
+ }
10350
+ };
10351
+ let answer = null;
10352
+ while (answer === null) {
10353
+ const code = await ask("Code from the email: ");
10354
+ if (code) {
10355
+ answer = await askAgainOnWrongAnswer(() => api.verifyCode(deviceCode, code), dependencies);
10356
+ }
10357
+ }
10358
+ while (answer.status === LoginStatus.MfaRequired) {
10359
+ const totp = await ask("Authenticator code: ", true);
10360
+ if (totp) {
10361
+ answer = await askAgainOnWrongAnswer(() => api.verifyMfa(deviceCode, totp), dependencies) ?? answer;
10362
+ }
10363
+ }
10364
+ if (answer.status === LoginStatus.Pending) {
10365
+ dependencies.print("Approved from the email link. Waiting for the dashboard to finish.");
10366
+ return waitUntilAborted(signal);
10367
+ }
10368
+ if (answer.status !== LoginStatus.Approved) {
10369
+ throw new DeviceLoginApiError(
10370
+ 0,
10371
+ "invalid_answer",
10372
+ "The dashboard answered the code with something this pinggy cannot use. Update pinggy and try again."
10373
+ );
10374
+ }
10375
+ return answer;
10376
+ }
10377
+ async function askAgainOnWrongAnswer(verify, dependencies) {
10378
+ try {
10379
+ return await verify();
10380
+ } catch (err) {
10381
+ if (err instanceof DeviceLoginApiError && err.errorType === LoginErrorType.InvalidCode) {
10382
+ dependencies.printError(err.message);
10383
+ return null;
10384
+ }
10385
+ throw err;
10386
+ }
10387
+ }
10388
+ async function pollUntilApproved(api, deviceCode, intervalMillis, signal, dependencies) {
10389
+ while (!signal.aborted) {
10390
+ await dependencies.sleep(intervalMillis, signal);
10391
+ if (signal.aborted) {
10392
+ break;
10393
+ }
10394
+ try {
10395
+ const answer = await api.poll(deviceCode);
10396
+ if (answer.status === LoginStatus.Approved) {
10397
+ return answer;
10398
+ }
10399
+ } catch (err) {
10400
+ if (err instanceof DeviceLoginApiError && err.status > 0 && err.status < FIRST_SERVER_ERROR_STATUS) {
10401
+ throw err;
10402
+ }
10403
+ }
10404
+ }
10405
+ throw signal.reason ?? new Error("The poll was stopped.");
10406
+ }
10407
+ async function printDeviceLink(approved, linkMinutes, dependencies) {
10408
+ let link;
10409
+ if (approved.open_url) {
10410
+ link = approved.open_url;
10411
+ dependencies.print(`Open the device in a browser. This link signs you in once, within ${linkMinutes} minutes:`);
10412
+ } else if (approved.device_url) {
10413
+ link = approved.device_url;
10414
+ dependencies.print("Open the device in a browser:");
10415
+ } else {
10416
+ return;
10417
+ }
10418
+ dependencies.print(` ${link}`);
10419
+ try {
10420
+ dependencies.print(await dependencies.renderQr(link));
10421
+ } catch {
10422
+ }
10423
+ }
10424
+ function messageOf(err, manage) {
10425
+ if (err instanceof DeviceLoginApiError) {
10426
+ return err.message;
10427
+ }
10428
+ const cause = err?.cause;
10429
+ const causeText = cause instanceof Error ? `: ${cause.message}` : "";
10430
+ const detail = err instanceof Error ? `${err.message}${causeText}` : String(err);
10431
+ return `Could not reach the dashboard at ${buildDashboardHttpUrl(manage)} (${detail}).`;
10432
+ }
10433
+ function waitUntilAborted(signal) {
10434
+ return new Promise((_resolve, reject) => {
10435
+ const stop = () => reject(signal.reason ?? new Error("The wait was stopped."));
10436
+ if (signal.aborted) {
10437
+ stop();
10438
+ return;
10439
+ }
10440
+ signal.addEventListener("abort", stop, { once: true });
10441
+ });
10442
+ }
10443
+ function sleepUnlessAborted(millis, signal) {
10444
+ return new Promise((resolve) => {
10445
+ if (signal.aborted) {
10446
+ resolve();
10447
+ return;
10448
+ }
10449
+ const onAbort = () => {
10450
+ clearTimeout(timer);
10451
+ resolve();
10452
+ };
10453
+ const timer = setTimeout(() => {
10454
+ signal.removeEventListener("abort", onAbort);
10455
+ resolve();
10456
+ }, millis);
10457
+ signal.addEventListener("abort", onAbort, { once: true });
10458
+ });
10459
+ }
10460
+ var import_os9, EXIT_OK, EXIT_FAILED, DEFAULT_SERVER2, YES_ANSWER, SECONDS_PER_MINUTE, MILLIS_PER_SECOND2, FIRST_SERVER_ERROR_STATUS, NO_TERMINAL_MESSAGE;
10461
+ var init_deviceLogin = __esm({
10462
+ "src/devices/login/deviceLogin.ts"() {
10463
+ "use strict";
10464
+ init_cjs_shims();
10465
+ import_os9 = __toESM(require("os"), 1);
10466
+ init_printer();
10467
+ init_util();
10468
+ init_qrCodeGenerator();
10469
+ init_deviceAgent();
10470
+ init_deviceIdentity();
10471
+ init_loginApi();
10472
+ init_login_schema();
10473
+ init_prompts();
10474
+ EXIT_OK = 0;
10475
+ EXIT_FAILED = 1;
10476
+ DEFAULT_SERVER2 = "dashboard.pinggy.io";
10477
+ YES_ANSWER = /^y(es)?$/i;
10478
+ SECONDS_PER_MINUTE = 60;
10479
+ MILLIS_PER_SECOND2 = 1e3;
10480
+ FIRST_SERVER_ERROR_STATUS = 500;
10481
+ NO_TERMINAL_MESSAGE = "pinggy device login needs a terminal to type the email and the code. On a machine without one, add the device in the dashboard and run: pinggy device connect --token <TOKEN>";
10482
+ }
10483
+ });
10484
+
10485
+ // src/devices/backgroundAgent.ts
10486
+ function machineBackgroundAgentDependencies() {
10487
+ return {
10488
+ readIdentity: readDeviceIdentity,
10489
+ findDaemon: getDaemonInfo,
10490
+ ensureDaemon: ensureDaemonRunning,
10491
+ client: (port) => new IPCClient(port, "cli"),
10492
+ verify: (token, manage) => startDeviceAgent(token, manage, {
10493
+ verifyOnly: true,
10494
+ reporter: verifyingReporter
10495
+ }).done,
10496
+ sleep: (millis) => new Promise((resolve) => setTimeout(resolve, millis)),
10497
+ now: () => Date.now(),
10498
+ print: (message) => printer_default.print(message),
10499
+ printSuccess: (message) => printer_default.success(message),
10500
+ printWarning: (message) => printer_default.warn(message),
10501
+ printError: (message) => printer_default.error(message),
10502
+ daemonLogPath: getDaemonLogPath
10503
+ };
10504
+ }
10505
+ function isAgentRunning(status) {
10506
+ return status.state !== DeviceAgentState.Stopped;
10507
+ }
10508
+ function differsFromStored(target, stored) {
10509
+ if (!stored?.token) return true;
10510
+ return stored.token !== target.token || stored.server !== (target.manage || DEFAULT_SERVER);
10511
+ }
10512
+ function olderDaemonMessage(daemon) {
10513
+ if (daemon.host === DaemonHost.APP) {
10514
+ return "The Pinggy app runs an older daemon. Update the Pinggy app, or quit it and run this again.";
10515
+ }
10516
+ return "The running daemon predates background devices. Run pinggy daemon stop, then retry. This stops all running tunnels.";
10517
+ }
10518
+ async function readBackgroundAgent(dependencies) {
10519
+ const daemon = dependencies.findDaemon();
10520
+ if (!daemon || !isIpcCompatible(daemon)) {
10521
+ return null;
10522
+ }
10523
+ try {
10524
+ return { daemon, status: await dependencies.client(daemon.port).getDeviceAgentStatus() };
10525
+ } catch (err) {
10526
+ logger.debug("No background device agent to read", { error: errorMessage(err) });
10527
+ return null;
10528
+ }
10529
+ }
10530
+ async function startAgentInBackground(target, dependencies) {
10531
+ if (differsFromStored(target, dependencies.readIdentity())) {
10532
+ const verified = await dependencies.verify(target.token, target.manage);
10533
+ if (verified.error || !verified.welcomed) {
10534
+ dependencies.printError(verified.error?.message ?? "The device did not connect. Nothing changed.");
10535
+ return EXIT_FAILED;
10536
+ }
10537
+ }
10538
+ dependencies.print("Starting the device agent in the background.");
10539
+ let daemon;
10540
+ try {
10541
+ daemon = await dependencies.ensureDaemon();
10542
+ } catch (err) {
10543
+ dependencies.printError(errorMessage(err));
10544
+ return EXIT_FAILED;
10545
+ }
10546
+ const client = dependencies.client(daemon.port);
10547
+ try {
10548
+ const started = await client.startDeviceAgent();
10549
+ if (isErrorResponse(started)) {
10550
+ dependencies.printError(started.message);
10551
+ return EXIT_FAILED;
10552
+ }
10553
+ } catch (err) {
10554
+ dependencies.printError(daemonFailureMessage(err, daemon));
10555
+ return EXIT_FAILED;
10556
+ }
10557
+ return waitUntilConnected(client, daemon, dependencies);
10558
+ }
10559
+ async function stopAgentInBackground(dependencies) {
10560
+ const reading = await readBackgroundAgent(dependencies);
10561
+ if (!reading || !isAgentRunning(reading.status)) {
10562
+ dependencies.print("No device agent is running in the background.");
10563
+ return EXIT_OK;
10564
+ }
10565
+ if (!await stopReadAgent(reading, dependencies)) {
10566
+ return EXIT_FAILED;
10567
+ }
10568
+ dependencies.printSuccess("Stopped. The device shows offline in the dashboard.");
10569
+ return EXIT_OK;
10570
+ }
10571
+ async function stopAgentBeforeRemove(dependencies) {
10572
+ const reading = await readBackgroundAgent(dependencies);
10573
+ if (!reading || !isAgentRunning(reading.status)) {
10574
+ return true;
10575
+ }
10576
+ return stopReadAgent(reading, dependencies);
10577
+ }
10578
+ async function foregroundRefusal(dependencies) {
10579
+ const reading = await readBackgroundAgent(dependencies);
10580
+ if (!reading || !isAgentRunning(reading.status)) {
10581
+ return null;
7419
10582
  }
7420
- switch (platform2) {
7421
- case "linux":
7422
- installSystemd();
7423
- break;
7424
- case "darwin":
7425
- installLaunchd();
7426
- break;
7427
- case "win32":
7428
- installWindows();
7429
- break;
7430
- default:
7431
- console.error(`Unsupported platform: ${platform2}`);
7432
- process.exit(1);
10583
+ const device = reading.status.deviceAgentId ? `device ${reading.status.deviceAgentId}` : "a device";
10584
+ return `This machine already runs as ${device} in the background. Stop it first: pinggy device stop`;
10585
+ }
10586
+ async function printBackgroundStatus(dependencies) {
10587
+ const reading = await readBackgroundAgent(dependencies);
10588
+ if (!reading) {
10589
+ return;
10590
+ }
10591
+ dependencies.print(` Background ${describeAgent(reading)}`);
10592
+ if (reading.status.lastError) {
10593
+ dependencies.print(` Last error ${reading.status.lastError.message}`);
7433
10594
  }
7434
10595
  }
7435
- function uninstallService() {
7436
- const platform2 = import_node_os.default.platform();
7437
- switch (platform2) {
7438
- case "linux":
7439
- uninstallSystemd();
7440
- break;
7441
- case "darwin":
7442
- uninstallLaunchd();
7443
- break;
7444
- case "win32":
7445
- uninstallWindows();
7446
- break;
10596
+ function describeAgentForDaemonStatus(status) {
10597
+ switch (status.state) {
10598
+ case DeviceAgentState.Connected:
10599
+ return `connected as ${status.deviceAgentId}`;
10600
+ case DeviceAgentState.Connecting:
10601
+ return "connecting";
10602
+ case DeviceAgentState.Reconnecting:
10603
+ return "reconnecting";
7447
10604
  default:
7448
- console.error(`Unsupported platform: ${platform2}`);
7449
- process.exit(1);
10605
+ return "not running";
10606
+ }
10607
+ }
10608
+ function describeAgent({ daemon, status }) {
10609
+ const host = daemon.host === DaemonHost.APP ? "inside the Pinggy app" : `in the daemon, PID ${daemon.pid}`;
10610
+ switch (status.state) {
10611
+ case DeviceAgentState.Connected:
10612
+ return `connected since ${status.connectedAt}, ${host}`;
10613
+ case DeviceAgentState.Connecting:
10614
+ return `connecting, ${host}`;
10615
+ case DeviceAgentState.Reconnecting:
10616
+ return `reconnecting, ${host}`;
10617
+ default:
10618
+ return "not running";
7450
10619
  }
7451
10620
  }
7452
- var import_node_os, import_node_fs3, import_node_path2, import_node_child_process, SERVICE_LABEL, SYSTEMD_SERVICE_NAME, WINDOWS_TASK_NAME;
7453
- var init_serviceInstaller = __esm({
7454
- "src/daemon/lifecycle/serviceInstaller.ts"() {
7455
- "use strict";
7456
- init_cjs_shims();
7457
- import_node_os = __toESM(require("os"), 1);
7458
- import_node_fs3 = __toESM(require("fs"), 1);
7459
- import_node_path2 = __toESM(require("path"), 1);
7460
- import_node_child_process = require("child_process");
7461
- init_logger();
7462
- SERVICE_LABEL = "io.pinggy.agent";
7463
- SYSTEMD_SERVICE_NAME = "pinggy";
7464
- WINDOWS_TASK_NAME = "PinggyDaemon";
10621
+ async function stopReadAgent(reading, dependencies) {
10622
+ try {
10623
+ await dependencies.client(reading.daemon.port).stopDeviceAgent();
10624
+ return true;
10625
+ } catch (err) {
10626
+ dependencies.printError(`Could not stop the background device agent: ${errorMessage(err)}`);
10627
+ return false;
7465
10628
  }
7466
- });
7467
-
7468
- // src/utils/helpMessages.ts
7469
- function printDaemonHelp() {
7470
- console.log("\nUsage: pinggy daemon <command>");
7471
- console.log(" pinggy d <command>\n");
7472
- console.log("Commands:");
7473
- console.log(" start Start the daemon process");
7474
- console.log(" stop Stop the daemon (stops all tunnels)");
7475
- console.log(" status Show daemon PID and uptime");
7476
- console.log("Tunnel operations:");
7477
- console.log(" pinggy ps List running tunnels");
7478
- console.log(" pinggy stop <name|id> Stop a specific tunnel");
7479
- console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
7480
10629
  }
7481
- function printLogHelp() {
7482
- console.log("\nUsage: pinggy log <verb> [options]\n");
7483
- console.log("Commands:");
7484
- console.log(" level Print current log level");
7485
- console.log(" level debug|info|error Set log level");
7486
- console.log(" path Print daemon log path");
7487
- console.log(" path <name|id> Print tunnel log path\n");
10630
+ function daemonFailureMessage(err, daemon) {
10631
+ if (err instanceof IPCHttpError && err.statusCode === HTTP_NOT_FOUND) {
10632
+ return olderDaemonMessage(daemon);
10633
+ }
10634
+ return `Could not reach the daemon: ${errorMessage(err)}`;
7488
10635
  }
7489
- function printConfigHelp() {
7490
- console.log("\nUsage: pinggy config <command> [name] [options]\n");
7491
- console.log("Commands:");
7492
- console.log(" list List all saved configs");
7493
- console.log(" show <name> Show config details");
7494
- console.log(" save <name> [tunnel flags] Save a tunnel config");
7495
- console.log(" update <name> [tunnel flags] Update a saved config");
7496
- console.log(" delete <name> Delete a saved config");
7497
- console.log(" auto <name> Enable auto-start");
7498
- console.log(" noauto <name> Disable auto-start\n");
10636
+ async function waitUntilConnected(client, daemon, dependencies) {
10637
+ const deadline = dependencies.now() + CONNECT_WAIT_MILLIS;
10638
+ for (; ; ) {
10639
+ let status;
10640
+ try {
10641
+ status = await client.getDeviceAgentStatus();
10642
+ } catch (err) {
10643
+ dependencies.printError(daemonFailureMessage(err, daemon));
10644
+ return EXIT_FAILED;
10645
+ }
10646
+ if (status.state === DeviceAgentState.Connected) {
10647
+ printRunning(status, daemon, dependencies);
10648
+ return EXIT_OK;
10649
+ }
10650
+ if (status.state === DeviceAgentState.Stopped) {
10651
+ dependencies.printError(status.lastError?.message ?? "The device agent stopped before it connected.");
10652
+ return EXIT_FAILED;
10653
+ }
10654
+ if (dependencies.now() >= deadline) {
10655
+ dependencies.printWarning("Still connecting in the background. Check with: pinggy device status");
10656
+ return EXIT_OK;
10657
+ }
10658
+ await dependencies.sleep(POLL_INTERVAL_MILLIS);
10659
+ }
7499
10660
  }
7500
- function printStartHelp() {
7501
- console.log("\nUsage: pinggy start <name> [options]\n");
7502
- console.log("Examples:");
7503
- console.log(" pinggy start my-tunnel Start a saved tunnel");
7504
- console.log(" pinggy start my-tunnel -l 4000 Start with override");
7505
- console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
7506
- console.log(" pinggy start --all Start all auto-start tunnels\n");
10661
+ function printRunning(status, daemon, dependencies) {
10662
+ dependencies.printSuccess(`Running in the background as device ${status.deviceAgentId}`);
10663
+ if (status.email) {
10664
+ dependencies.print(`Signed in as ${status.email}`);
10665
+ }
10666
+ if (status.deviceUrl) {
10667
+ dependencies.print("Open the device in a browser:");
10668
+ dependencies.print(` ${status.deviceUrl}`);
10669
+ }
10670
+ if (daemon.host === DaemonHost.APP) {
10671
+ dependencies.print("It runs inside the Pinggy app, and stops when the app quits.");
10672
+ }
10673
+ dependencies.print(`Logs: ${dependencies.daemonLogPath()}`);
10674
+ dependencies.print("Stop it with: pinggy device stop");
7507
10675
  }
7508
- var init_helpMessages = __esm({
7509
- "src/utils/helpMessages.ts"() {
10676
+ var POLL_INTERVAL_MILLIS, CONNECT_WAIT_MILLIS, verifyingReporter;
10677
+ var init_backgroundAgent = __esm({
10678
+ "src/devices/backgroundAgent.ts"() {
7510
10679
  "use strict";
7511
10680
  init_cjs_shims();
10681
+ init_logger();
10682
+ init_printer();
10683
+ init_configDir();
10684
+ init_util();
10685
+ init_types();
10686
+ init_daemonManager();
10687
+ init_ipcClient();
10688
+ init_ipcRoutes();
10689
+ init_deviceIdentity();
10690
+ init_deviceAgent();
10691
+ init_deviceAgentReporter();
10692
+ init_deviceLogin();
10693
+ POLL_INTERVAL_MILLIS = 500;
10694
+ CONNECT_WAIT_MILLIS = 15e3;
10695
+ verifyingReporter = {
10696
+ connecting: () => void 0,
10697
+ connected: () => void 0,
10698
+ reconnecting: () => void 0,
10699
+ warn: (message) => printer_default.warn(message),
10700
+ // The caller prints the error the run ends with.
10701
+ failed: () => void 0,
10702
+ replacingDevice: printingReporter.replacingDevice
10703
+ };
7512
10704
  }
7513
10705
  });
7514
10706
 
@@ -7527,7 +10719,7 @@ async function handleDaemon(args) {
7527
10719
  await handleDaemonStop();
7528
10720
  return;
7529
10721
  case "status":
7530
- handleDaemonStatus();
10722
+ await handleDaemonStatus();
7531
10723
  return;
7532
10724
  case "service-install":
7533
10725
  installService();
@@ -7573,16 +10765,20 @@ async function handleDaemonStop() {
7573
10765
  printer_default.print(import_picocolors6.default.yellow("No daemon is running."));
7574
10766
  return;
7575
10767
  }
10768
+ const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
7576
10769
  const result = await stopDaemon();
7577
10770
  if (result.ok) {
7578
10771
  printer_default.success("Daemon stopped.");
10772
+ if (agent && isAgentRunning(agent.status)) {
10773
+ printer_default.print("The background device agent stopped too. The device shows offline in the dashboard.");
10774
+ }
7579
10775
  } else if (result.reason === DaemonHost.APP) {
7580
10776
  printer_default.print(import_picocolors6.default.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
7581
10777
  } else {
7582
10778
  printer_default.error(result.error);
7583
10779
  }
7584
10780
  }
7585
- function handleDaemonStatus() {
10781
+ async function handleDaemonStatus() {
7586
10782
  const info = getDaemonInfo();
7587
10783
  if (!info) {
7588
10784
  printer_default.print(import_picocolors6.default.yellow("No daemon is running. Start with: pinggy daemon start"));
@@ -7603,6 +10799,10 @@ function handleDaemonStatus() {
7603
10799
  if (!isIpcCompatible(info)) {
7604
10800
  printer_default.print(import_picocolors6.default.yellow(` Warning: ${ipcMismatchMessage(info)}`));
7605
10801
  }
10802
+ const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
10803
+ if (agent) {
10804
+ printer_default.print(` Device: ${describeAgentForDaemonStatus(agent.status)}`);
10805
+ }
7606
10806
  }
7607
10807
  var import_picocolors6;
7608
10808
  var init_daemonCommandsHandler = __esm({
@@ -7617,6 +10817,7 @@ var init_daemonCommandsHandler = __esm({
7617
10817
  init_serviceInstaller();
7618
10818
  init_helpMessages();
7619
10819
  init_util();
10820
+ init_backgroundAgent();
7620
10821
  }
7621
10822
  });
7622
10823
 
@@ -7882,8 +11083,8 @@ async function followFile(filePath) {
7882
11083
  stream.on("error", () => {
7883
11084
  });
7884
11085
  };
7885
- watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (event) => {
7886
- if (event === "change") {
11086
+ watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (event2) => {
11087
+ if (event2 === "change") {
7887
11088
  const stat = import_node_fs4.default.existsSync(filePath) ? import_node_fs4.default.statSync(filePath) : null;
7888
11089
  if (!stat || stat.size < fileSize) {
7889
11090
  fileSize = 0;
@@ -8059,6 +11260,161 @@ var init_restartCommand = __esm({
8059
11260
  }
8060
11261
  });
8061
11262
 
11263
+ // src/cli/subcommand/handlers/deviceCommand.ts
11264
+ function takeEnvironmentToken() {
11265
+ const value = process.env[DEVICE_AGENT_TOKEN_VARIABLE]?.trim();
11266
+ delete process.env[DEVICE_AGENT_TOKEN_VARIABLE];
11267
+ return value || void 0;
11268
+ }
11269
+ function resolveConnectTarget(flagToken, environmentToken, manage, stored) {
11270
+ if (flagToken) {
11271
+ return { token: flagToken, manage: manage ?? stored?.server };
11272
+ }
11273
+ if (environmentToken) {
11274
+ return { token: environmentToken, manage };
11275
+ }
11276
+ if (stored?.token) {
11277
+ return { token: stored.token, manage: manage ?? stored.server };
11278
+ }
11279
+ return null;
11280
+ }
11281
+ async function handleDevice(args, background = machineBackgroundAgentDependencies()) {
11282
+ const environmentToken = takeEnvironmentToken();
11283
+ if (args.length === 0) {
11284
+ printDeviceHelp();
11285
+ return;
11286
+ }
11287
+ const verb = args[0];
11288
+ const flagArgs = args.slice(1);
11289
+ const { values } = parseCliArgs(cliOptions, flagArgs);
11290
+ configureLogger(values);
11291
+ const inBackground = Boolean(values.b);
11292
+ switch (verb) {
11293
+ case DeviceVerb.Login:
11294
+ exitUnlessOk(await handleDeviceLogin(values.manage, values.name, inBackground, background));
11295
+ return;
11296
+ case DeviceVerb.Connect:
11297
+ exitUnlessOk(await handleDeviceConnect(
11298
+ values.token,
11299
+ environmentToken,
11300
+ values.manage,
11301
+ inBackground,
11302
+ background
11303
+ ));
11304
+ return;
11305
+ case DeviceVerb.Stop:
11306
+ exitUnlessOk(await stopAgentInBackground(background));
11307
+ return;
11308
+ case DeviceVerb.Status:
11309
+ await handleDeviceStatus(background);
11310
+ return;
11311
+ case DeviceVerb.Remove:
11312
+ exitUnlessOk(await handleDeviceRemove(background));
11313
+ return;
11314
+ default:
11315
+ if (verb === "help" || verb === "--help" || verb === "-h") {
11316
+ printDeviceHelp();
11317
+ return;
11318
+ }
11319
+ printer_default.error(`Unknown command "${verb}". Valid commands: login, connect, stop, status, remove.`);
11320
+ process.exit(1);
11321
+ }
11322
+ }
11323
+ function exitUnlessOk(exitCode) {
11324
+ if (exitCode !== EXIT_OK) {
11325
+ process.exit(exitCode);
11326
+ }
11327
+ }
11328
+ function refuseDevicesSpelling(args) {
11329
+ printer_default.error(devicesSpellingMessage(args));
11330
+ process.exit(1);
11331
+ }
11332
+ function devicesSpellingMessage(args) {
11333
+ const verbs = Object.values(DeviceVerb);
11334
+ const suggestion = verbs.includes(args[0]) ? args[0] : "--help";
11335
+ return `"pinggy devices" is now "pinggy device". Run: pinggy device ${suggestion}`;
11336
+ }
11337
+ async function mayRunInForeground(background) {
11338
+ const refusal = await foregroundRefusal(background);
11339
+ if (refusal) {
11340
+ printer_default.error(refusal);
11341
+ return false;
11342
+ }
11343
+ return true;
11344
+ }
11345
+ async function handleDeviceLogin(manage, name, inBackground, background) {
11346
+ if (!inBackground) {
11347
+ if (!await mayRunInForeground(background)) return EXIT_FAILED;
11348
+ return runDeviceLogin({ manage, name });
11349
+ }
11350
+ return runDeviceLogin({ manage, name }, {
11351
+ ...machineDeviceLoginDependencies(),
11352
+ runAgent: (token, agentManage) => startAgentInBackground({ token, manage: agentManage }, background)
11353
+ });
11354
+ }
11355
+ async function handleDeviceConnect(flagToken, environmentToken, manage, inBackground, background) {
11356
+ const target = resolveConnectTarget(flagToken, environmentToken, manage, background.readIdentity());
11357
+ if (!target) {
11358
+ printer_default.error("This machine is not enrolled. Sign it in with your email:\n pinggy device login\nOr add a device in the dashboard, then run:\n pinggy device connect --token <TOKEN>");
11359
+ return EXIT_FAILED;
11360
+ }
11361
+ if (inBackground) {
11362
+ return startAgentInBackground(target, background);
11363
+ }
11364
+ if (!await mayRunInForeground(background)) return EXIT_FAILED;
11365
+ await runDeviceAgent(target.token, target.manage);
11366
+ return EXIT_OK;
11367
+ }
11368
+ async function handleDeviceStatus(background) {
11369
+ const identity = background.readIdentity();
11370
+ if (!identity) {
11371
+ printer_default.print("This machine is not enrolled. Run: pinggy device login");
11372
+ return;
11373
+ }
11374
+ printer_default.print(` Device id ${identity.device_agent_id ?? import_picocolors9.default.gray("not yet assigned")}`);
11375
+ printer_default.print(` Server ${identity.server}`);
11376
+ printer_default.print(` Token ${maskToken(identity.token)}`);
11377
+ printer_default.print(` Enrolled ${identity.enrolled_at ?? import_picocolors9.default.gray("never")}`);
11378
+ await printBackgroundStatus(background);
11379
+ }
11380
+ async function handleDeviceRemove(background) {
11381
+ if (!await stopAgentBeforeRemove(background)) {
11382
+ return EXIT_FAILED;
11383
+ }
11384
+ if (clearDeviceIdentity()) {
11385
+ printer_default.success("Local device credential removed.");
11386
+ printer_default.print(import_picocolors9.default.gray("The device still exists in the dashboard. Delete it there to revoke it."));
11387
+ } else {
11388
+ printer_default.print("This machine is not enrolled.");
11389
+ }
11390
+ return EXIT_OK;
11391
+ }
11392
+ var import_picocolors9, DeviceVerb, DEVICE_AGENT_TOKEN_VARIABLE;
11393
+ var init_deviceCommand = __esm({
11394
+ "src/cli/subcommand/handlers/deviceCommand.ts"() {
11395
+ "use strict";
11396
+ init_cjs_shims();
11397
+ import_picocolors9 = __toESM(require("picocolors"), 1);
11398
+ init_options();
11399
+ init_parseArgs();
11400
+ init_logger();
11401
+ init_printer();
11402
+ init_helpMessages();
11403
+ init_deviceAgent();
11404
+ init_deviceIdentity();
11405
+ init_deviceLogin();
11406
+ init_backgroundAgent();
11407
+ DeviceVerb = {
11408
+ Login: "login",
11409
+ Connect: "connect",
11410
+ Stop: "stop",
11411
+ Status: "status",
11412
+ Remove: "remove"
11413
+ };
11414
+ DEVICE_AGENT_TOKEN_VARIABLE = "DEVICE_AGENT_TOKEN";
11415
+ }
11416
+ });
11417
+
8062
11418
  // src/cli/subcommand/subcommands.ts
8063
11419
  function isSubcommand(rawArgs) {
8064
11420
  return rawArgs.length > 0 && SUBCOMMAND_SET.has(rawArgs[0]);
@@ -8098,6 +11454,12 @@ async function handleSubcommand(rawArgs) {
8098
11454
  case Subcommand.Restart:
8099
11455
  await handleRestart(rest);
8100
11456
  return;
11457
+ case Subcommand.Device:
11458
+ await handleDevice(rest);
11459
+ return;
11460
+ case Subcommand.Devices:
11461
+ refuseDevicesSpelling(rest);
11462
+ return;
8101
11463
  }
8102
11464
  }
8103
11465
  function handleConfig(args) {
@@ -8338,6 +11700,7 @@ var init_subcommands = __esm({
8338
11700
  init_logsCommand();
8339
11701
  init_logCommand();
8340
11702
  init_restartCommand();
11703
+ init_deviceCommand();
8341
11704
  init_tunnelClient();
8342
11705
  init_startCli();
8343
11706
  init_helpMessages();
@@ -8391,7 +11754,7 @@ async function main() {
8391
11754
  printer_default.fatal(error);
8392
11755
  }
8393
11756
  }
8394
- var import_url2, import_process, import_fs8, currentFile, entryFile;
11757
+ var import_url2, import_process, import_fs13, currentFile, entryFile;
8395
11758
  var init_main = __esm({
8396
11759
  "src/main.ts"() {
8397
11760
  "use strict";
@@ -8406,7 +11769,7 @@ var init_main = __esm({
8406
11769
  init_handler();
8407
11770
  import_url2 = require("url");
8408
11771
  import_process = require("process");
8409
- import_fs8 = require("fs");
11772
+ import_fs13 = require("fs");
8410
11773
  init_remoteManagement();
8411
11774
  init_buildAndStartTunnel();
8412
11775
  init_subcommands();
@@ -8416,7 +11779,7 @@ var init_main = __esm({
8416
11779
  currentFile = (0, import_url2.fileURLToPath)(importMetaUrl);
8417
11780
  entryFile = null;
8418
11781
  try {
8419
- entryFile = import_process.argv[1] ? (0, import_fs8.realpathSync)(import_process.argv[1]) : null;
11782
+ entryFile = import_process.argv[1] ? (0, import_fs13.realpathSync)(import_process.argv[1]) : null;
8420
11783
  } catch (e) {
8421
11784
  entryFile = null;
8422
11785
  }
@@ -8427,8 +11790,8 @@ var init_main = __esm({
8427
11790
  });
8428
11791
 
8429
11792
  // src/daemon/ws/wsProtocol.ts
8430
- function createTunnelEvent(tunnelId, event, payload) {
8431
- return { type: "tunnel_event", tunnelId, event, payload };
11793
+ function createTunnelEvent(tunnelId, event2, payload) {
11794
+ return { type: "tunnel_event", tunnelId, event: event2, payload };
8432
11795
  }
8433
11796
  function parseClientMessage(raw) {
8434
11797
  try {
@@ -8540,7 +11903,7 @@ function parseTunnelLogFilename(filename) {
8540
11903
  const name = parts.slice(1, -1).join("__");
8541
11904
  return { origin, name, tunnelId };
8542
11905
  }
8543
- var import_node_http2, import_node_fs6, import_node_path4, import_ws3, VALID_ORIGINS, UUID_RE, IPCServer;
11906
+ var import_node_http2, import_node_fs6, import_node_path4, import_ws4, VALID_ORIGINS, UUID_RE, IPCServer;
8544
11907
  var init_ipcServer = __esm({
8545
11908
  "src/daemon/ipc/ipcServer.ts"() {
8546
11909
  "use strict";
@@ -8548,7 +11911,7 @@ var init_ipcServer = __esm({
8548
11911
  import_node_http2 = __toESM(require("http"), 1);
8549
11912
  import_node_fs6 = __toESM(require("fs"), 1);
8550
11913
  import_node_path4 = __toESM(require("path"), 1);
8551
- import_ws3 = require("ws");
11914
+ import_ws4 = require("ws");
8552
11915
  init_main();
8553
11916
  init_logger();
8554
11917
  init_tunnelLogger();
@@ -8560,7 +11923,7 @@ var init_ipcServer = __esm({
8560
11923
  init_types();
8561
11924
  init_daemonChild();
8562
11925
  init_stateStore();
8563
- VALID_ORIGINS = ["app", "cli", "remote"];
11926
+ VALID_ORIGINS = ["app", "cli", "remote", "device"];
8564
11927
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8565
11928
  IPCServer = class {
8566
11929
  constructor() {
@@ -8568,10 +11931,11 @@ var init_ipcServer = __esm({
8568
11931
  this.sessionCounter = 0;
8569
11932
  this.onSessionDisconnect = null;
8570
11933
  this.sessionTracker = null;
11934
+ this.deviceAgentHost = null;
8571
11935
  this.ops = new TunnelOperations();
8572
11936
  this.startedAt = Date.now();
8573
11937
  this.server = import_node_http2.default.createServer(this.handleRequest.bind(this));
8574
- this.wss = new import_ws3.WebSocketServer({ noServer: true });
11938
+ this.wss = new import_ws4.WebSocketServer({ noServer: true });
8575
11939
  this.routes = this.buildRoutes();
8576
11940
  this.setupWebSocket();
8577
11941
  }
@@ -8585,6 +11949,13 @@ var init_ipcServer = __esm({
8585
11949
  setSessionTracker(st) {
8586
11950
  this.sessionTracker = st;
8587
11951
  }
11952
+ setDeviceAgentHost(host) {
11953
+ this.deviceAgentHost = host;
11954
+ }
11955
+ requireDeviceAgentHost() {
11956
+ if (!this.deviceAgentHost) throw new Error("The device agent is not available in this daemon yet");
11957
+ return this.deviceAgentHost;
11958
+ }
8588
11959
  /**
8589
11960
  * Get all active sessions (for SessionTracker inspection).
8590
11961
  */
@@ -8717,6 +12088,10 @@ var init_ipcServer = __esm({
8717
12088
  }
8718
12089
  return { daemon: daemonPath, tunnels };
8719
12090
  },
12091
+ // The request body is never read: a token or a server in it must not reach the agent.
12092
+ [Route.GetDeviceAgent]: () => this.requireDeviceAgentHost().status(),
12093
+ [Route.StartDeviceAgent]: (_req, ctx) => this.requireDeviceAgentHost().start(ctx.origin),
12094
+ [Route.StopDeviceAgent]: () => this.requireDeviceAgentHost().stop(),
8720
12095
  [Route.Shutdown]: () => {
8721
12096
  logger.info("Daemon shutdown requested via IPC");
8722
12097
  if (getDaemonHost() === DaemonHost.APP) {
@@ -8733,6 +12108,9 @@ var init_ipcServer = __esm({
8733
12108
  logger.error(`Shutdown step "${label}" failed`, { error: msg });
8734
12109
  }
8735
12110
  };
12111
+ step("stopDeviceAgent", () => {
12112
+ void this.deviceAgentHost?.close();
12113
+ });
8736
12114
  step("removeDaemonInfo", removeDaemonInfo);
8737
12115
  step("clearDaemonState", clearDaemonState);
8738
12116
  step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
@@ -8765,8 +12143,8 @@ var init_ipcServer = __esm({
8765
12143
  if (paramMatch) {
8766
12144
  try {
8767
12145
  await this.readBody(req);
8768
- const response = await paramMatch.invoke();
8769
- this.sendJson(res, 200, response);
12146
+ const response2 = await paramMatch.invoke();
12147
+ this.sendJson(res, 200, response2);
8770
12148
  } catch (err) {
8771
12149
  const msg = errorMessage(err);
8772
12150
  logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
@@ -8999,9 +12377,9 @@ var init_ipcServer = __esm({
8999
12377
  session.subscriptions.clear();
9000
12378
  this.sessions.delete(session.id);
9001
12379
  }
9002
- sendEvent(session, tunnelId, event, payload) {
9003
- if (session.ws.readyState === import_ws3.WebSocket.OPEN) {
9004
- const msg = createTunnelEvent(tunnelId, event, payload);
12380
+ sendEvent(session, tunnelId, event2, payload) {
12381
+ if (session.ws.readyState === import_ws4.WebSocket.OPEN) {
12382
+ const msg = createTunnelEvent(tunnelId, event2, payload);
9005
12383
  session.ws.send(JSON.stringify(msg));
9006
12384
  }
9007
12385
  }
@@ -9192,6 +12570,210 @@ var init_sessionTracker = __esm({
9192
12570
  }
9193
12571
  });
9194
12572
 
12573
+ // src/daemon/deviceAgentHost.ts
12574
+ function stoppedStatus() {
12575
+ return {
12576
+ state: DeviceAgentState.Stopped,
12577
+ deviceAgentId: null,
12578
+ server: null,
12579
+ email: null,
12580
+ deviceUrl: null,
12581
+ startedAt: null,
12582
+ connectedAt: null,
12583
+ startedBy: null,
12584
+ lastError: null
12585
+ };
12586
+ }
12587
+ function writeIdentityWhileOwned(identity) {
12588
+ const stored = readDeviceIdentity();
12589
+ if (stored && stored.token !== identity.token) {
12590
+ logger.info("Device agent left a newer device.json in place");
12591
+ return;
12592
+ }
12593
+ writeDeviceIdentity(identity);
12594
+ }
12595
+ function machineDeviceAgentHostDependencies(port, record) {
12596
+ return {
12597
+ readIdentity: readDeviceIdentity,
12598
+ startRun: (identity, reporter) => startDeviceAgent(identity.token, identity.server, {
12599
+ reporter,
12600
+ tunnelListSource: selfTunnelListSource(port),
12601
+ tunnelActions: selfTunnelActionDependencies(port),
12602
+ writeIdentity: writeIdentityWhileOwned
12603
+ }),
12604
+ recordRunning: record.recordRunning,
12605
+ recordStopped: record.recordStopped,
12606
+ now: () => /* @__PURE__ */ new Date()
12607
+ };
12608
+ }
12609
+ var NOT_ENROLLED_MESSAGE, DeviceAgentHost;
12610
+ var init_deviceAgentHost = __esm({
12611
+ "src/daemon/deviceAgentHost.ts"() {
12612
+ "use strict";
12613
+ init_cjs_shims();
12614
+ init_logger();
12615
+ init_deviceIdentity();
12616
+ init_deviceAgent();
12617
+ init_tunnelList();
12618
+ init_tunnelActions();
12619
+ init_types();
12620
+ init_ipcRoutes();
12621
+ NOT_ENROLLED_MESSAGE = "This machine is not enrolled. Sign it in first: pinggy device login";
12622
+ DeviceAgentHost = class {
12623
+ constructor(dependencies) {
12624
+ this.dependencies = dependencies;
12625
+ this.current = null;
12626
+ this.statusValue = stoppedStatus();
12627
+ /** Bumped on every start and stop. A run's reporter whose number is old belongs to a run that ended. */
12628
+ this.generation = 0;
12629
+ /** Set once the daemon shuts down. Nothing starts and nothing more reaches daemon-state.json. */
12630
+ this.closed = false;
12631
+ /** Start and stop chain on this, so 1 finishes before the next begins. */
12632
+ this.queue = Promise.resolve();
12633
+ }
12634
+ status() {
12635
+ return { ...this.statusValue };
12636
+ }
12637
+ /**
12638
+ * Starts the agent on what device.json holds now. A run on the same token and server is left alone.
12639
+ * A run on another is stopped first.
12640
+ */
12641
+ start(startedBy) {
12642
+ return this.exclusive(async () => {
12643
+ if (this.closed) {
12644
+ return this.status();
12645
+ }
12646
+ const identity = this.dependencies.readIdentity();
12647
+ if (!identity?.token) {
12648
+ return newErrorResponse(ErrorCode.DeviceNotEnrolled, NOT_ENROLLED_MESSAGE);
12649
+ }
12650
+ const current = this.current;
12651
+ if (current && current.token === identity.token && current.server === identity.server) {
12652
+ return this.status();
12653
+ }
12654
+ await this.stopCurrent();
12655
+ this.launch(identity, startedBy);
12656
+ return this.status();
12657
+ });
12658
+ }
12659
+ /** Stops the run: the socket closes with 1000 and every shell ends. */
12660
+ stop() {
12661
+ return this.exclusive(async () => {
12662
+ await this.stopCurrent();
12663
+ return this.status();
12664
+ });
12665
+ }
12666
+ /**
12667
+ * The daemon is shutting down. Stops the run at once, outside the queue, and writes nothing to
12668
+ * daemon-state.json: the shutdown deletes that file, and a write after it would bring the file back.
12669
+ */
12670
+ close() {
12671
+ this.closed = true;
12672
+ const current = this.current;
12673
+ this.current = null;
12674
+ this.generation += 1;
12675
+ this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
12676
+ return current ? current.run.stop().then(() => void 0) : Promise.resolve();
12677
+ }
12678
+ exclusive(operation) {
12679
+ const result = this.queue.then(operation);
12680
+ this.queue = result.catch(() => void 0);
12681
+ return result;
12682
+ }
12683
+ launch(identity, startedBy) {
12684
+ this.generation += 1;
12685
+ const generation = this.generation;
12686
+ this.statusValue = {
12687
+ state: DeviceAgentState.Connecting,
12688
+ deviceAgentId: identity.device_agent_id,
12689
+ server: identity.server,
12690
+ email: null,
12691
+ deviceUrl: null,
12692
+ startedAt: this.dependencies.now().toISOString(),
12693
+ connectedAt: null,
12694
+ startedBy,
12695
+ lastError: null
12696
+ };
12697
+ const run2 = this.dependencies.startRun(identity, this.reporterFor(generation));
12698
+ this.current = { run: run2, token: identity.token, server: identity.server };
12699
+ this.dependencies.recordRunning(startedBy);
12700
+ logger.info("Device agent started in the daemon", { startedBy });
12701
+ void run2.done.then((end) => this.onRunEnded(generation, end));
12702
+ }
12703
+ async stopCurrent() {
12704
+ const current = this.current;
12705
+ if (!current) {
12706
+ return;
12707
+ }
12708
+ this.current = null;
12709
+ this.generation += 1;
12710
+ this.statusValue = { ...this.statusValue, state: DeviceAgentState.Stopped, connectedAt: null };
12711
+ if (!this.closed) {
12712
+ this.dependencies.recordStopped();
12713
+ }
12714
+ await current.run.stop();
12715
+ logger.info("Device agent stopped in the daemon");
12716
+ }
12717
+ /** The run ended on its own: the dashboard refused the credential or ended the device. */
12718
+ onRunEnded(generation, end) {
12719
+ if (generation !== this.generation) {
12720
+ return;
12721
+ }
12722
+ this.current = null;
12723
+ this.statusValue = {
12724
+ ...this.statusValue,
12725
+ state: DeviceAgentState.Stopped,
12726
+ connectedAt: null,
12727
+ lastError: end.error
12728
+ };
12729
+ if (!this.closed) {
12730
+ this.dependencies.recordStopped();
12731
+ }
12732
+ }
12733
+ /** Logs every event, and keeps the latest for `GET /device`. Never logs the email or a payload. */
12734
+ reporterFor(generation) {
12735
+ const isCurrent = () => generation === this.generation;
12736
+ let welcomedOnce = false;
12737
+ const notConnected = () => welcomedOnce ? DeviceAgentState.Reconnecting : DeviceAgentState.Connecting;
12738
+ return {
12739
+ connecting: (server) => {
12740
+ if (!isCurrent()) return;
12741
+ this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
12742
+ logger.info("Device agent connecting", { server });
12743
+ },
12744
+ connected: (welcome) => {
12745
+ if (!isCurrent()) return;
12746
+ welcomedOnce = true;
12747
+ this.statusValue = {
12748
+ ...this.statusValue,
12749
+ state: DeviceAgentState.Connected,
12750
+ deviceAgentId: welcome.device_agent_id,
12751
+ email: welcome.email ?? null,
12752
+ deviceUrl: welcome.device_url ?? null,
12753
+ connectedAt: this.dependencies.now().toISOString()
12754
+ };
12755
+ logger.info("Device agent connected", { deviceAgentId: welcome.device_agent_id });
12756
+ },
12757
+ reconnecting: (delayMillis, attempt) => {
12758
+ if (!isCurrent()) return;
12759
+ this.statusValue = { ...this.statusValue, state: notConnected(), connectedAt: null };
12760
+ logger.warn("Device agent disconnected", { delayMillis, attempt });
12761
+ },
12762
+ warn: (message) => {
12763
+ logger.warn("Device agent warning", { message });
12764
+ },
12765
+ failed: (error) => {
12766
+ logger.error("Device agent ended", { code: error.code });
12767
+ },
12768
+ replacingDevice: (oldDeviceAgentId) => {
12769
+ logger.info("Device agent connects a new device in place of a stored one", { oldDeviceAgentId });
12770
+ }
12771
+ };
12772
+ }
12773
+ };
12774
+ }
12775
+ });
12776
+
9195
12777
  // src/daemon/lifecycle/daemonChild.ts
9196
12778
  var daemonChild_exports = {};
9197
12779
  __export(daemonChild_exports, {
@@ -9200,6 +12782,8 @@ __export(daemonChild_exports, {
9200
12782
  removeDaemonInfo: () => removeDaemonInfo,
9201
12783
  runDaemonChild: () => runDaemonChild,
9202
12784
  setDaemonSessionTracker: () => setDaemonSessionTracker,
12785
+ trackDeviceAgentStart: () => trackDeviceAgentStart,
12786
+ trackDeviceAgentStop: () => trackDeviceAgentStop,
9203
12787
  trackIPCTunnelStart: () => trackIPCTunnelStart,
9204
12788
  trackTunnelStart: () => trackTunnelStart,
9205
12789
  trackTunnelStop: () => trackTunnelStop
@@ -9245,6 +12829,29 @@ function trackTunnelStop(tunnelId) {
9245
12829
  persistDaemonState(daemonState);
9246
12830
  sessionTrackerRef?.removeTunnel(tunnelId);
9247
12831
  }
12832
+ function trackDeviceAgentStart(startedBy) {
12833
+ daemonState.deviceAgent = { startedAt: (/* @__PURE__ */ new Date()).toISOString(), startedBy };
12834
+ persistDaemonState(daemonState);
12835
+ }
12836
+ function trackDeviceAgentStop() {
12837
+ if (!daemonState.deviceAgent) return;
12838
+ delete daemonState.deviceAgent;
12839
+ persistDaemonState(daemonState);
12840
+ }
12841
+ async function restoreCrashedDeviceAgent(host, saved) {
12842
+ try {
12843
+ const result = await host.start(saved.startedBy);
12844
+ if (isErrorResponse(result)) {
12845
+ logger.warn("Crash recovery: device agent not restored", { reason: result.message });
12846
+ trackDeviceAgentStop();
12847
+ return;
12848
+ }
12849
+ logger.info("Crash recovery: device agent restored");
12850
+ } catch (err) {
12851
+ logger.error("Crash recovery: device agent failed to start", { error: errorMessage(err) });
12852
+ trackDeviceAgentStop();
12853
+ }
12854
+ }
9248
12855
  function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
9249
12856
  if (mode === SessionMode.Foreground) return;
9250
12857
  const manager = TunnelManager.getInstance();
@@ -9358,11 +12965,16 @@ async function runDaemonChild(opts = {}) {
9358
12965
  });
9359
12966
  ipcServer.setSessionTracker(sessionTracker);
9360
12967
  setDaemonSessionTracker(sessionTracker);
12968
+ let deviceAgentHost = null;
9361
12969
  let cleanedUp = false;
9362
12970
  const cleanup = () => {
9363
12971
  if (cleanedUp) return;
9364
12972
  cleanedUp = true;
9365
12973
  logger.info("Daemon shutting down");
12974
+ try {
12975
+ void deviceAgentHost?.close();
12976
+ } catch {
12977
+ }
9366
12978
  try {
9367
12979
  removeDaemonInfo();
9368
12980
  } catch {
@@ -9408,6 +13020,15 @@ async function runDaemonChild(opts = {}) {
9408
13020
  }
9409
13021
  try {
9410
13022
  const port = await ipcServer.listen();
13023
+ deviceAgentHost = new DeviceAgentHost(machineDeviceAgentHostDependencies(port, {
13024
+ recordRunning: trackDeviceAgentStart,
13025
+ recordStopped: trackDeviceAgentStop
13026
+ }));
13027
+ ipcServer.setDeviceAgentHost(deviceAgentHost);
13028
+ const crashedDeviceAgent = loadDaemonState()?.deviceAgent;
13029
+ if (crashedDeviceAgent) {
13030
+ daemonState.deviceAgent = crashedDeviceAgent;
13031
+ }
9411
13032
  const info = {
9412
13033
  pid: process.pid,
9413
13034
  port,
@@ -9431,6 +13052,9 @@ async function runDaemonChild(opts = {}) {
9431
13052
  } else {
9432
13053
  logger.info("No auto-start tunnels configured");
9433
13054
  }
13055
+ if (crashedDeviceAgent) {
13056
+ await restoreCrashedDeviceAgent(deviceAgentHost, crashedDeviceAgent);
13057
+ }
9434
13058
  logger.info("Daemon ready", { pid: process.pid, port });
9435
13059
  return {
9436
13060
  pid: process.pid,
@@ -9455,12 +13079,14 @@ var init_daemonChild = __esm({
9455
13079
  init_ipcServer();
9456
13080
  init_sessionTracker();
9457
13081
  init_stateStore();
13082
+ init_deviceAgentHost();
9458
13083
  init_TunnelManager();
9459
13084
  init_logger();
9460
13085
  init_configDir();
9461
13086
  init_tunnelLogger();
9462
13087
  init_configStore();
9463
13088
  init_daemonConfig();
13089
+ init_types();
9464
13090
  init_util();
9465
13091
  init_ipcRoutes();
9466
13092
  daemonState = { tunnels: [], lastUpdated: "" };
@@ -9687,12 +13313,12 @@ var init_daemonManager = __esm({
9687
13313
  });
9688
13314
 
9689
13315
  // src/daemon/ws/wsStream.ts
9690
- var import_ws4, WS_NORMAL_CLOSE, WsStream;
13316
+ var import_ws5, WS_NORMAL_CLOSE, WsStream;
9691
13317
  var init_wsStream = __esm({
9692
13318
  "src/daemon/ws/wsStream.ts"() {
9693
13319
  "use strict";
9694
13320
  init_cjs_shims();
9695
- import_ws4 = require("ws");
13321
+ import_ws5 = require("ws");
9696
13322
  init_logger();
9697
13323
  init_util();
9698
13324
  init_ipcRoutes();
@@ -9721,11 +13347,11 @@ var init_wsStream = __esm({
9721
13347
  }
9722
13348
  // Lifecycle
9723
13349
  async ensureOpen() {
9724
- if (this.ws && this.ws.readyState === import_ws4.WebSocket.OPEN) return;
13350
+ if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) return;
9725
13351
  this.wsReady = new Promise((resolve) => {
9726
13352
  this.wsResolve = resolve;
9727
13353
  });
9728
- this.ws = new import_ws4.WebSocket(this.getWsUrl());
13354
+ this.ws = new import_ws5.WebSocket(this.getWsUrl());
9729
13355
  this.ws.on("open", () => {
9730
13356
  if (this.wsResolve) {
9731
13357
  this.wsResolve();
@@ -9782,7 +13408,7 @@ var init_wsStream = __esm({
9782
13408
  this.subscribedTunnels.clear();
9783
13409
  }
9784
13410
  isOpen() {
9785
- return !!this.ws && this.ws.readyState === import_ws4.WebSocket.OPEN;
13411
+ return !!this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN;
9786
13412
  }
9787
13413
  // Subscriptions
9788
13414
  async subscribe(tunnelId, mode = SessionMode.Foreground) {
@@ -9800,7 +13426,7 @@ var init_wsStream = __esm({
9800
13426
  unsubscribe(tunnelId) {
9801
13427
  const wasSubscribed = this.subscribedTunnels.delete(tunnelId);
9802
13428
  if (!wasSubscribed) return false;
9803
- if (this.ws && this.ws.readyState === import_ws4.WebSocket.OPEN) {
13429
+ if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) {
9804
13430
  try {
9805
13431
  const msg = { type: "unsubscribe", tunnelId };
9806
13432
  this.ws.send(JSON.stringify(msg));
@@ -9894,8 +13520,8 @@ var init_wsStream = __esm({
9894
13520
  return;
9895
13521
  }
9896
13522
  if (msg.type !== "tunnel_event") return;
9897
- const { tunnelId, event, payload } = msg;
9898
- switch (event) {
13523
+ const { tunnelId, event: event2, payload } = msg;
13524
+ switch (event2) {
9899
13525
  case "stats":
9900
13526
  for (const cb of this.callbacks.stats) cb(tunnelId, payload.stats);
9901
13527
  break;
@@ -9939,7 +13565,7 @@ var init_wsStream = __esm({
9939
13565
  });
9940
13566
 
9941
13567
  // src/daemon/daemonHealth.ts
9942
- var RECONNECT_ATTEMPTS, RECONNECT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HEARTBEAT_FAILURE_THRESHOLD, sleep2, DaemonHealth;
13568
+ var RECONNECT_ATTEMPTS, RECONNECT_INTERVAL_MS, HEARTBEAT_INTERVAL_MS, HEARTBEAT_TIMEOUT_MS, HEARTBEAT_FAILURE_THRESHOLD, sleep3, DaemonHealth;
9943
13569
  var init_daemonHealth = __esm({
9944
13570
  "src/daemon/daemonHealth.ts"() {
9945
13571
  "use strict";
@@ -9953,7 +13579,7 @@ var init_daemonHealth = __esm({
9953
13579
  HEARTBEAT_INTERVAL_MS = 5e3;
9954
13580
  HEARTBEAT_TIMEOUT_MS = 2e3;
9955
13581
  HEARTBEAT_FAILURE_THRESHOLD = 2;
9956
- sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
13582
+ sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
9957
13583
  DaemonHealth = class {
9958
13584
  constructor(getIpc, stream) {
9959
13585
  this.getIpc = getIpc;
@@ -10032,7 +13658,7 @@ var init_daemonHealth = __esm({
10032
13658
  } catch {
10033
13659
  }
10034
13660
  }
10035
- await sleep2(RECONNECT_INTERVAL_MS);
13661
+ await sleep3(RECONNECT_INTERVAL_MS);
10036
13662
  if (this.lost) return;
10037
13663
  const info = getDaemonInfo();
10038
13664
  if (!info) {
@@ -10240,6 +13866,20 @@ var init_tunnelClient = __esm({
10240
13866
  this.assertClient();
10241
13867
  await this.ipc.restartTunnel(tunnelId);
10242
13868
  }
13869
+ // Device agent (`pinggy device connect -b`). The daemon reads the token and the server from
13870
+ // device.json, so none of these takes either.
13871
+ async getDeviceAgentStatus() {
13872
+ this.assertClient();
13873
+ return this.ipc.getDeviceAgentStatus();
13874
+ }
13875
+ async startDeviceAgent() {
13876
+ this.assertClient();
13877
+ return this.ipc.startDeviceAgent();
13878
+ }
13879
+ async stopDeviceAgent() {
13880
+ this.assertClient();
13881
+ return this.ipc.stopDeviceAgent();
13882
+ }
10243
13883
  // Streaming — delegate to WsStream, guarded by daemon-lost
10244
13884
  async attach(tunnelId, mode = SessionMode.Foreground) {
10245
13885
  if (this.health.isLost()) return;
@@ -10379,6 +14019,7 @@ var init_tunnelClient = __esm({
10379
14019
  // src/index.ts
10380
14020
  var index_exports = {};
10381
14021
  __export(index_exports, {
14022
+ DeviceAgentState: () => DeviceAgentState,
10382
14023
  RemoteManagementUnauthorizedError: () => RemoteManagementUnauthorizedError,
10383
14024
  TUNNEL_CONFIG_V1_KEYS: () => TUNNEL_CONFIG_V1_KEYS,
10384
14025
  TunnelClient: () => TunnelClient,
@@ -10505,6 +14146,7 @@ init_TunnelManager();
10505
14146
  init_handler();
10506
14147
  init_tunnelClient();
10507
14148
  init_daemonManager();
14149
+ init_ipcRoutes();
10508
14150
  init_logger();
10509
14151
  init_remoteManagement();
10510
14152
  init_types();
@@ -10529,6 +14171,7 @@ verifyAndLoad().catch((err) => {
10529
14171
  });
10530
14172
  // Annotate the CommonJS export names for ESM import in node:
10531
14173
  0 && (module.exports = {
14174
+ DeviceAgentState,
10532
14175
  RemoteManagementUnauthorizedError,
10533
14176
  TUNNEL_CONFIG_V1_KEYS,
10534
14177
  TunnelClient,