pinggy 0.6.1 → 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({
@@ -753,11 +881,12 @@ var init_TunnelManager = __esm({
753
881
  try {
754
882
  urls = await managed.instance.start();
755
883
  } catch (error) {
756
- 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 });
757
886
  managed.isStopped = true;
758
887
  managed.stoppedAt = (/* @__PURE__ */ new Date()).toISOString();
759
888
  managed.lastError = {
760
- message: "Failed to start tunnel",
889
+ message: reason,
761
890
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
762
891
  isFatal: true
763
892
  };
@@ -928,6 +1057,9 @@ var init_TunnelManager = __esm({
928
1057
  logger.error(`Tunnel "${tunnelId}" not found when fetching status`);
929
1058
  throw new Error(`Tunnel "${tunnelId}" not found`);
930
1059
  }
1060
+ if (managed.rebuild?.startsAfter) {
1061
+ return "starting" /* Starting */;
1062
+ }
931
1063
  if (managed.isStopped) {
932
1064
  return "exited";
933
1065
  }
@@ -978,6 +1110,10 @@ var init_TunnelManager = __esm({
978
1110
  logger.warn("Attempted to remove tunnel that is not stopped", { tunnelId });
979
1111
  return false;
980
1112
  }
1113
+ if (managed.rebuild) {
1114
+ logger.warn("Attempted to remove tunnel that is restarting", { tunnelId });
1115
+ return false;
1116
+ }
981
1117
  this._cleanupTunnelRecords(managed);
982
1118
  logger.info("Removed stopped tunnel records", { tunnelId, configId: managed.configId });
983
1119
  return true;
@@ -1057,17 +1193,27 @@ var init_TunnelManager = __esm({
1057
1193
  if (!managed) {
1058
1194
  return Promise.reject(new Error(`Tunnel with configId "${configId}" not found`));
1059
1195
  }
1060
- return Promise.resolve(managed.instance.getConfig());
1196
+ return this.currentConfig(managed);
1061
1197
  }
1062
1198
  if (tunnelId) {
1063
1199
  const managed = this.tunnelsByTunnelId.get(tunnelId);
1064
1200
  if (!managed) {
1065
1201
  return Promise.reject(new Error(`Tunnel with tunnelId "${tunnelId}" not found`));
1066
1202
  }
1067
- return Promise.resolve(managed.instance.getConfig());
1203
+ return this.currentConfig(managed);
1068
1204
  }
1069
1205
  return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
1070
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
+ }
1071
1217
  /**
1072
1218
  * Stop a tunnel instance and wait for the stop to settle, capped by a
1073
1219
  * timeout. Failure is logged, not thrown: the caller replaces the
@@ -1091,6 +1237,46 @@ var init_TunnelManager = __esm({
1091
1237
  if (timer) clearTimeout(timer);
1092
1238
  }
1093
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
+ }
1094
1280
  /**
1095
1281
  * Restarts a tunnel with its current configuration.
1096
1282
  * This function will stop the tunnel if it's running and start it again.
@@ -1101,6 +1287,7 @@ var init_TunnelManager = __esm({
1101
1287
  if (!existingTunnel) {
1102
1288
  throw new Error(`Tunnel "${tunnelid}" not found`);
1103
1289
  }
1290
+ this.beginRebuild(existingTunnel, true);
1104
1291
  logger.info("Initiating tunnel restart", {
1105
1292
  tunnelId: tunnelid,
1106
1293
  configId: existingTunnel.configId
@@ -1112,11 +1299,7 @@ var init_TunnelManager = __esm({
1112
1299
  const currentServe = existingTunnel.serve;
1113
1300
  const autoReconnect = existingTunnel.autoReconnect || false;
1114
1301
  const currentOrigin = existingTunnel.origin;
1115
- if (!existingTunnel.isStopped) {
1116
- await this.stopInstanceGuarded(existingTunnel);
1117
- }
1118
- this.tunnelsByTunnelId.delete(tunnelid);
1119
- this.tunnelsByConfigId.delete(existingTunnel.configId);
1302
+ await this.stopForRebuild(existingTunnel);
1120
1303
  this.tunnelStats.delete(tunnelid);
1121
1304
  this.tunnelStatsListeners.delete(tunnelid);
1122
1305
  this.tunnelErrorListeners.delete(tunnelid);
@@ -1142,6 +1325,7 @@ var init_TunnelManager = __esm({
1142
1325
  }
1143
1326
  await this.startTunnel(newTunnel.tunnelid);
1144
1327
  } catch (error) {
1328
+ this.failRebuild(existingTunnel, error);
1145
1329
  logger.error("Failed to restart tunnel", {
1146
1330
  tunnelid,
1147
1331
  error: error instanceof Error ? error.message : "Unknown error"
@@ -1199,12 +1383,9 @@ var init_TunnelManager = __esm({
1199
1383
  const currentStartedAt = existingTunnel.startedAt;
1200
1384
  const currentStoppedAt = existingTunnel.stoppedAt;
1201
1385
  const requestedServe = this.resolveServePath(newConfig);
1386
+ this.beginRebuild(existingTunnel, !isStopped);
1202
1387
  try {
1203
- if (!isStopped) {
1204
- await this.stopInstanceGuarded(existingTunnel);
1205
- }
1206
- this.tunnelsByTunnelId.delete(currentTunnelId);
1207
- this.tunnelsByConfigId.delete(currentTunnelConfigId);
1388
+ await this.stopForRebuild(existingTunnel);
1208
1389
  const mergedBaseConfig = {
1209
1390
  ...newConfig,
1210
1391
  configId,
@@ -1265,6 +1446,7 @@ var init_TunnelManager = __esm({
1265
1446
  error: errorMessage(restoreError)
1266
1447
  });
1267
1448
  }
1449
+ this.failRebuild(existingTunnel, error);
1268
1450
  throw error;
1269
1451
  }
1270
1452
  }
@@ -2075,120 +2257,6 @@ var init_TunnelManager = __esm({
2075
2257
  }
2076
2258
  });
2077
2259
 
2078
- // src/types.ts
2079
- function isErrorResponse(obj) {
2080
- return typeof obj === "object" && obj !== null && "code" in obj && "message" in obj && typeof obj.message === "string" && Object.values(ErrorCode).includes(obj.code);
2081
- }
2082
- function newErrorResponse(codeOrError, message) {
2083
- if (typeof codeOrError === "object") {
2084
- return codeOrError;
2085
- }
2086
- return {
2087
- code: codeOrError,
2088
- message
2089
- };
2090
- }
2091
- function NewResponseObject(data) {
2092
- const encoder = new TextEncoder();
2093
- const bytes = encoder.encode(JSON.stringify(data));
2094
- return {
2095
- response: bytes,
2096
- requestid: "",
2097
- command: "",
2098
- error: false,
2099
- errorresponse: {}
2100
- };
2101
- }
2102
- function NewErrorResponseObject(errorResponse) {
2103
- return {
2104
- response: new Uint8Array(),
2105
- requestid: "",
2106
- command: "",
2107
- error: true,
2108
- errorresponse: errorResponse
2109
- };
2110
- }
2111
- function newStatus(tunnelState, errorCode, errorMsg) {
2112
- let assignedState = tunnelState;
2113
- if (tunnelState === "live" /* Live */) {
2114
- assignedState = "running" /* Running */;
2115
- } else if (tunnelState === "idle" /* New */) {
2116
- assignedState = "idle" /* New */;
2117
- } else if (tunnelState === "closed" /* Closed */) {
2118
- assignedState = "exited" /* Exited */;
2119
- }
2120
- const now = (/* @__PURE__ */ new Date()).toISOString();
2121
- return {
2122
- state: assignedState,
2123
- errorcode: errorCode,
2124
- errormsg: errorMsg,
2125
- createdtimestamp: now,
2126
- starttimestamp: now,
2127
- endtimestamp: now,
2128
- warnings: []
2129
- };
2130
- }
2131
- function newStats() {
2132
- return {
2133
- numLiveConnections: 0,
2134
- numTotalConnections: 0,
2135
- numTotalReqBytes: 0,
2136
- numTotalResBytes: 0,
2137
- numTotalTxBytes: 0,
2138
- elapsedTime: 0
2139
- };
2140
- }
2141
- var TunnelStateType, TunnelErrorCodeType, TunnelWarningCode, ErrorCode, RemoteManagementStatus;
2142
- var init_types = __esm({
2143
- "src/types.ts"() {
2144
- "use strict";
2145
- init_cjs_shims();
2146
- TunnelStateType = /* @__PURE__ */ ((TunnelStateType2) => {
2147
- TunnelStateType2["New"] = "idle";
2148
- TunnelStateType2["Starting"] = "starting";
2149
- TunnelStateType2["Running"] = "running";
2150
- TunnelStateType2["Live"] = "live";
2151
- TunnelStateType2["Closed"] = "closed";
2152
- TunnelStateType2["Exited"] = "exited";
2153
- return TunnelStateType2;
2154
- })(TunnelStateType || {});
2155
- TunnelErrorCodeType = /* @__PURE__ */ ((TunnelErrorCodeType2) => {
2156
- TunnelErrorCodeType2["NonResponsive"] = "non_responsive";
2157
- TunnelErrorCodeType2["FailedToConnect"] = "failed_to_connect";
2158
- TunnelErrorCodeType2["ErrorInAdditionalForwarding"] = "additional_forwarding_error";
2159
- TunnelErrorCodeType2["WebdebuggerError"] = "webdebugger_error";
2160
- TunnelErrorCodeType2["NoError"] = "";
2161
- return TunnelErrorCodeType2;
2162
- })(TunnelErrorCodeType || {});
2163
- TunnelWarningCode = /* @__PURE__ */ ((TunnelWarningCode2) => {
2164
- TunnelWarningCode2["InvalidTunnelServePath"] = "INVALID_TUNNEL_SERVE_PATH";
2165
- TunnelWarningCode2["UnknownWarning"] = "UNKNOWN_WARNING";
2166
- return TunnelWarningCode2;
2167
- })(TunnelWarningCode || {});
2168
- ErrorCode = {
2169
- InvalidRequestMethodError: "INVALID_REQUEST_METHOD",
2170
- InvalidRequestBodyError: "COULD_NOT_READ_BODY",
2171
- InternalServerError: "INTERNAL_SERVER_ERROR",
2172
- InvalidBodyFormatError: "INVALID_DATA_FORMAT",
2173
- ErrorStartingTunnel: "ERROR_STARTING_TUNNEL",
2174
- TunnelNotFound: "TUNNEL_WITH_ID_OR_CONFIG_ID_NOT_FOUND",
2175
- TunnelAlreadyRunningError: "TUNNEL_WITH_ID_OR_CONFIG_ID_ALREADY_RUNNING",
2176
- WebsocketUpgradeFailError: "WEBSOCKET_UPGRADE_FAILED",
2177
- RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING",
2178
- RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING",
2179
- RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED"
2180
- };
2181
- RemoteManagementStatus = {
2182
- Connecting: "CONNECTING",
2183
- Disconnecting: "DISCONNECTING",
2184
- Reconnecting: "RECONNECTING",
2185
- Running: "RUNNING",
2186
- NotRunning: "NOT_RUNNING",
2187
- Error: "ERROR"
2188
- };
2189
- }
2190
- });
2191
-
2192
2260
  // src/remote_management/remote_schema.ts
2193
2261
  function pinggyOptionsToTunnelConfigV1(opts, configStoredInCli) {
2194
2262
  const parsedTokens = opts.bearerTokenAuth ? Array.isArray(opts.bearerTokenAuth) ? opts.bearerTokenAuth : JSON.parse(opts.bearerTokenAuth) : [];
@@ -2505,6 +2573,9 @@ var init_handler = __esm({
2505
2573
  }
2506
2574
  if (managed?.lastError) {
2507
2575
  status.lastError = managed.lastError;
2576
+ if (managed.lastError.isFatal) {
2577
+ status.errormsg = managed.lastError.message;
2578
+ }
2508
2579
  }
2509
2580
  } catch (e) {
2510
2581
  }
@@ -2569,6 +2640,14 @@ var init_handler = __esm({
2569
2640
  message: err instanceof Error ? err.message : fallback
2570
2641
  });
2571
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
+ }
2572
2651
  // --- Operations ---
2573
2652
  async handleStart(config, noWait = false, origin = "cli") {
2574
2653
  try {
@@ -2631,6 +2710,7 @@ var init_handler = __esm({
2631
2710
  if (noWait) {
2632
2711
  const existing = this.tunnelManager.getManagedTunnel(config.configid);
2633
2712
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2713
+ if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
2634
2714
  if (!existing.isStopped) {
2635
2715
  this.tunnelManager.updateConfig(updateOpts).catch((err) => {
2636
2716
  logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
@@ -2651,6 +2731,7 @@ var init_handler = __esm({
2651
2731
  if (noWait) {
2652
2732
  const existing = this.tunnelManager.getManagedTunnel(config.configId);
2653
2733
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2734
+ if (existing.rebuild) return this.rebuildInProgress(existing.tunnelid);
2654
2735
  if (!existing.isStopped) {
2655
2736
  this.tunnelManager.updateConfig(config).catch((err) => {
2656
2737
  logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
@@ -2749,6 +2830,7 @@ var init_handler = __esm({
2749
2830
  if (noWait) {
2750
2831
  const managed2 = this.tunnelManager.getManagedTunnel("", tunnelid);
2751
2832
  if (!managed2?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
2833
+ if (managed2.rebuild) return this.rebuildInProgress(tunnelid);
2752
2834
  this.tunnelManager.restartTunnel(tunnelid).catch((err) => {
2753
2835
  logger.error("No-wait restartTunnel failed", { tunnelid, err: String(err) });
2754
2836
  });
@@ -2759,6 +2841,9 @@ var init_handler = __esm({
2759
2841
  if (!managed?.tunnelConfig) throw new Error(`Tunnel config for ID "${tunnelid}" not found`);
2760
2842
  return this.buildTunnelResponse(tunnelid, managed.tunnelConfig, managed.configId, managed.tunnelName, managed.serve);
2761
2843
  } catch (err) {
2844
+ if (err instanceof TunnelRebuildInProgressError) {
2845
+ return this.rebuildInProgress(tunnelid);
2846
+ }
2762
2847
  return this.error(ErrorCode.TunnelNotFound, err, "Failed to restart tunnel");
2763
2848
  }
2764
2849
  }
@@ -2801,7 +2886,7 @@ var init_handler = __esm({
2801
2886
  });
2802
2887
 
2803
2888
  // src/daemon/ipc/ipcRoutes.ts
2804
- var IPC_VERSION, Route, DaemonHost, ShutdownStatus, SessionMode;
2889
+ var IPC_VERSION, Route, DaemonHost, ShutdownStatus, DeviceAgentState, SessionMode;
2805
2890
  var init_ipcRoutes = __esm({
2806
2891
  "src/daemon/ipc/ipcRoutes.ts"() {
2807
2892
  "use strict";
@@ -2824,7 +2909,10 @@ var init_ipcRoutes = __esm({
2824
2909
  SetLogLevel: "POST /loglevel",
2825
2910
  GetTunnelLogging: "GET /config/tunnel-logging",
2826
2911
  SetTunnelLogging: "POST /config/tunnel-logging",
2827
- GetLogPaths: "GET /logs/paths"
2912
+ GetLogPaths: "GET /logs/paths",
2913
+ GetDeviceAgent: "GET /device",
2914
+ StartDeviceAgent: "POST /device/start",
2915
+ StopDeviceAgent: "POST /device/stop"
2828
2916
  };
2829
2917
  DaemonHost = {
2830
2918
  CLI: "cli",
@@ -2834,6 +2922,12 @@ var init_ipcRoutes = __esm({
2834
2922
  ShuttingDown: "shutting_down",
2835
2923
  RefusedInApp: "refused_in_app"
2836
2924
  };
2925
+ DeviceAgentState = {
2926
+ Stopped: "stopped",
2927
+ Connecting: "connecting",
2928
+ Connected: "connected",
2929
+ Reconnecting: "reconnecting"
2930
+ };
2837
2931
  SessionMode = {
2838
2932
  Foreground: "foreground",
2839
2933
  Detached: "detached"
@@ -2844,9 +2938,11 @@ var init_ipcRoutes = __esm({
2844
2938
  // src/daemon/ipc/ipcClient.ts
2845
2939
  var ipcClient_exports = {};
2846
2940
  __export(ipcClient_exports, {
2847
- IPCClient: () => IPCClient
2941
+ HTTP_NOT_FOUND: () => HTTP_NOT_FOUND,
2942
+ IPCClient: () => IPCClient,
2943
+ IPCHttpError: () => IPCHttpError
2848
2944
  });
2849
- var import_node_http, REQUEST_TIMEOUT_MS, IPCClient;
2945
+ var import_node_http, REQUEST_TIMEOUT_MS, IPCHttpError, HTTP_NOT_FOUND, IPCClient;
2850
2946
  var init_ipcClient = __esm({
2851
2947
  "src/daemon/ipc/ipcClient.ts"() {
2852
2948
  "use strict";
@@ -2854,6 +2950,14 @@ var init_ipcClient = __esm({
2854
2950
  import_node_http = __toESM(require("http"), 1);
2855
2951
  init_ipcRoutes();
2856
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;
2857
2961
  IPCClient = class {
2858
2962
  constructor(port, origin = "cli") {
2859
2963
  this.port = port;
@@ -2920,6 +3024,15 @@ var init_ipcClient = __esm({
2920
3024
  async resolveLogPath(q) {
2921
3025
  return this.request("GET", `/logs/resolve?q=${encodeURIComponent(q)}`);
2922
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
+ }
2923
3036
  /**
2924
3037
  * Get the WebSocket URL for event streaming.
2925
3038
  */
@@ -2932,11 +3045,11 @@ var init_ipcClient = __esm({
2932
3045
  call(key, body, timeoutMs) {
2933
3046
  const spaceIdx = key.indexOf(" ");
2934
3047
  const method = key.slice(0, spaceIdx);
2935
- const path11 = key.slice(spaceIdx + 1);
3048
+ const path12 = key.slice(spaceIdx + 1);
2936
3049
  const payload = method === "GET" ? void 0 : JSON.stringify(body ?? {});
2937
- return this.request(method, path11, payload, timeoutMs);
3050
+ return this.request(method, path12, payload, timeoutMs);
2938
3051
  }
2939
- request(method, path11, body, timeoutMs) {
3052
+ request(method, path12, body, timeoutMs) {
2940
3053
  return new Promise((resolve, reject) => {
2941
3054
  const headers = {
2942
3055
  "X-Pinggy-Origin": this.origin
@@ -2949,7 +3062,7 @@ var init_ipcClient = __esm({
2949
3062
  {
2950
3063
  hostname: "127.0.0.1",
2951
3064
  port: this.port,
2952
- path: path11,
3065
+ path: path12,
2953
3066
  method,
2954
3067
  headers,
2955
3068
  timeout: timeoutMs ?? REQUEST_TIMEOUT_MS
@@ -2961,7 +3074,10 @@ var init_ipcClient = __esm({
2961
3074
  const text = Buffer.concat(chunks).toString("utf-8");
2962
3075
  const statusCode = res.statusCode ?? 0;
2963
3076
  if (statusCode < 200 || statusCode >= 300) {
2964
- 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
+ ));
2965
3081
  return;
2966
3082
  }
2967
3083
  try {
@@ -3768,42 +3884,42 @@ var init_websocket_handlers = __esm({
3768
3884
  const cmd = (req.command || "").toLowerCase();
3769
3885
  const raw = this.safeParse(req.data);
3770
3886
  try {
3771
- let response;
3887
+ let response2;
3772
3888
  switch (cmd) {
3773
3889
  case WsCommand.Start: {
3774
- response = await this.handleStartReq(req, raw);
3890
+ response2 = await this.handleStartReq(req, raw);
3775
3891
  break;
3776
3892
  }
3777
3893
  case WsCommand.StartV2: {
3778
- response = await this.handleStartV2Req(req, raw);
3894
+ response2 = await this.handleStartV2Req(req, raw);
3779
3895
  break;
3780
3896
  }
3781
3897
  case WsCommand.Stop: {
3782
- response = await this.handleStopReq(req, raw);
3898
+ response2 = await this.handleStopReq(req, raw);
3783
3899
  break;
3784
3900
  }
3785
3901
  case WsCommand.Get: {
3786
- response = await this.handleGetReq(req, raw);
3902
+ response2 = await this.handleGetReq(req, raw);
3787
3903
  break;
3788
3904
  }
3789
3905
  case WsCommand.Restart: {
3790
- response = await this.handleRestartReq(req, raw);
3906
+ response2 = await this.handleRestartReq(req, raw);
3791
3907
  break;
3792
3908
  }
3793
3909
  case WsCommand.UpdateConfig: {
3794
- response = await this.handleUpdateConfigReq(req, raw);
3910
+ response2 = await this.handleUpdateConfigReq(req, raw);
3795
3911
  break;
3796
3912
  }
3797
3913
  case WsCommand.UpdateConfigV2: {
3798
- response = await this.handleUpdateConfigV2Req(req, raw);
3914
+ response2 = await this.handleUpdateConfigV2Req(req, raw);
3799
3915
  break;
3800
3916
  }
3801
3917
  case WsCommand.List: {
3802
- response = await this.handleListReq(req);
3918
+ response2 = await this.handleListReq(req);
3803
3919
  break;
3804
3920
  }
3805
3921
  case WsCommand.ListV2: {
3806
- response = await this.handleListV2Req(req);
3922
+ response2 = await this.handleListV2Req(req);
3807
3923
  break;
3808
3924
  }
3809
3925
  case WsCommand.GetVersion: {
@@ -3816,8 +3932,8 @@ var init_websocket_handlers = __esm({
3816
3932
  }
3817
3933
  return this.sendError(ws, req, "Invalid command");
3818
3934
  }
3819
- logger.debug("Sending response", { command: response.command, requestid: response.requestid });
3820
- this.sendResponse(ws, response);
3935
+ logger.debug("Sending response", { command: response2.command, requestid: response2.requestid });
3936
+ this.sendResponse(ws, response2);
3821
3937
  } catch (e) {
3822
3938
  if (e instanceof import_zod2.default.ZodError) {
3823
3939
  logger.warn("Validation failed", { cmd, issues: e.issues });
@@ -4014,7 +4130,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
4014
4130
  resolve(getRemoteManagementState());
4015
4131
  }
4016
4132
  };
4017
- const runLoop = async () => {
4133
+ const runLoop2 = async () => {
4018
4134
  const sigintHandler = () => {
4019
4135
  _stopRequested = true;
4020
4136
  };
@@ -4042,7 +4158,7 @@ function startRemoteManagement(remoteManagementConfig, tunnelHandler) {
4042
4158
  process.removeListener("SIGINT", sigintHandler);
4043
4159
  logger.info("Remote management stopped.");
4044
4160
  };
4045
- 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))));
4046
4162
  });
4047
4163
  }
4048
4164
  function getRemoteManagementState() {
@@ -4623,10 +4739,10 @@ function parseArgs2(finalConfig, remainingPositionals) {
4623
4739
  }
4624
4740
  function storeJson(config, saveconf) {
4625
4741
  if (saveconf) {
4626
- const path11 = saveconf;
4742
+ const path12 = saveconf;
4627
4743
  try {
4628
- import_fs6.default.writeFileSync(path11, JSON.stringify(config, null, 2), { encoding: "utf-8", flag: "w" });
4629
- 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}`);
4630
4746
  } catch (err) {
4631
4747
  const msg = err instanceof Error ? err.message : String(err);
4632
4748
  logger.error("Error loading configuration:", msg);
@@ -5135,7 +5251,11 @@ var init_configStore = __esm({
5135
5251
  DaemonAlias: "d",
5136
5252
  Logs: "logs",
5137
5253
  Log: "log",
5138
- 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"
5139
5259
  };
5140
5260
  ConfigVerb = {
5141
5261
  List: "list",
@@ -6765,7 +6885,7 @@ async function printAlreadyRunning(client, configId, label) {
6765
6885
  async function startTunnel(client, config, opts) {
6766
6886
  const result = await client.handleStartV2(config, false, opts.mode);
6767
6887
  const prefix = opts.label ? `[${opts.label}] ` : "";
6768
- const fail = (reason) => {
6888
+ const fail2 = (reason) => {
6769
6889
  const msg = `${prefix}Failed to start tunnel: ${reason}`;
6770
6890
  if (opts.onError === "fatal") {
6771
6891
  printer_default.error(msg);
@@ -6780,11 +6900,11 @@ async function startTunnel(client, config, opts) {
6780
6900
  await printAlreadyRunning(client, config.configId, opts.label);
6781
6901
  return null;
6782
6902
  }
6783
- return fail(result.message);
6903
+ return fail2(result.message);
6784
6904
  }
6785
6905
  const lastError = result.status?.lastError;
6786
6906
  if (lastError?.isFatal) {
6787
- return fail(lastError.message);
6907
+ return fail2(lastError.message);
6788
6908
  }
6789
6909
  return result;
6790
6910
  }
@@ -7410,106 +7530,3177 @@ function uninstallWindows() {
7410
7530
  console.log("No scheduled task found to remove.");
7411
7531
  }
7412
7532
  }
7413
- function installService() {
7414
- const platform2 = import_node_os.default.platform();
7415
- const bin = resolveBinary();
7416
- if (!("pkg" in process) && bin.program !== process.execPath) {
7417
- console.warn(
7418
- "Warning: Using npm-installed binary. The path may change if Node.js is updated.\nConsider using a standalone binary (pkg) for system services."
7419
- );
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;
7420
10582
  }
7421
- switch (platform2) {
7422
- case "linux":
7423
- installSystemd();
7424
- break;
7425
- case "darwin":
7426
- installLaunchd();
7427
- break;
7428
- case "win32":
7429
- installWindows();
7430
- break;
7431
- default:
7432
- console.error(`Unsupported platform: ${platform2}`);
7433
- 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}`);
7434
10594
  }
7435
10595
  }
7436
- function uninstallService() {
7437
- const platform2 = import_node_os.default.platform();
7438
- switch (platform2) {
7439
- case "linux":
7440
- uninstallSystemd();
7441
- break;
7442
- case "darwin":
7443
- uninstallLaunchd();
7444
- break;
7445
- case "win32":
7446
- uninstallWindows();
7447
- 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";
7448
10604
  default:
7449
- console.error(`Unsupported platform: ${platform2}`);
7450
- 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";
7451
10619
  }
7452
10620
  }
7453
- var import_node_os, import_node_fs3, import_node_path2, import_node_child_process, SERVICE_LABEL, SYSTEMD_SERVICE_NAME, WINDOWS_TASK_NAME;
7454
- var init_serviceInstaller = __esm({
7455
- "src/daemon/lifecycle/serviceInstaller.ts"() {
7456
- "use strict";
7457
- init_cjs_shims();
7458
- import_node_os = __toESM(require("os"), 1);
7459
- import_node_fs3 = __toESM(require("fs"), 1);
7460
- import_node_path2 = __toESM(require("path"), 1);
7461
- import_node_child_process = require("child_process");
7462
- init_logger();
7463
- SERVICE_LABEL = "io.pinggy.agent";
7464
- SYSTEMD_SERVICE_NAME = "pinggy";
7465
- 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;
7466
10628
  }
7467
- });
7468
-
7469
- // src/utils/helpMessages.ts
7470
- function printDaemonHelp() {
7471
- console.log("\nUsage: pinggy daemon <command>");
7472
- console.log(" pinggy d <command>\n");
7473
- console.log("Commands:");
7474
- console.log(" start Start the daemon process");
7475
- console.log(" stop Stop the daemon (stops all tunnels)");
7476
- console.log(" status Show daemon PID and uptime");
7477
- console.log("Tunnel operations:");
7478
- console.log(" pinggy ps List running tunnels");
7479
- console.log(" pinggy stop <name|id> Stop a specific tunnel");
7480
- console.log(" pinggy attach <name|id> Re-attach TUI to a tunnel\n");
7481
10629
  }
7482
- function printLogHelp() {
7483
- console.log("\nUsage: pinggy log <verb> [options]\n");
7484
- console.log("Commands:");
7485
- console.log(" level Print current log level");
7486
- console.log(" level debug|info|error Set log level");
7487
- console.log(" path Print daemon log path");
7488
- 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)}`;
7489
10635
  }
7490
- function printConfigHelp() {
7491
- console.log("\nUsage: pinggy config <command> [name] [options]\n");
7492
- console.log("Commands:");
7493
- console.log(" list List all saved configs");
7494
- console.log(" show <name> Show config details");
7495
- console.log(" save <name> [tunnel flags] Save a tunnel config");
7496
- console.log(" update <name> [tunnel flags] Update a saved config");
7497
- console.log(" delete <name> Delete a saved config");
7498
- console.log(" auto <name> Enable auto-start");
7499
- 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
+ }
7500
10660
  }
7501
- function printStartHelp() {
7502
- console.log("\nUsage: pinggy start <name> [options]\n");
7503
- console.log("Examples:");
7504
- console.log(" pinggy start my-tunnel Start a saved tunnel");
7505
- console.log(" pinggy start my-tunnel -l 4000 Start with override");
7506
- console.log(" pinggy start tunnela tunnelb Start multiple tunnels");
7507
- 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");
7508
10675
  }
7509
- var init_helpMessages = __esm({
7510
- "src/utils/helpMessages.ts"() {
10676
+ var POLL_INTERVAL_MILLIS, CONNECT_WAIT_MILLIS, verifyingReporter;
10677
+ var init_backgroundAgent = __esm({
10678
+ "src/devices/backgroundAgent.ts"() {
7511
10679
  "use strict";
7512
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
+ };
7513
10704
  }
7514
10705
  });
7515
10706
 
@@ -7528,7 +10719,7 @@ async function handleDaemon(args) {
7528
10719
  await handleDaemonStop();
7529
10720
  return;
7530
10721
  case "status":
7531
- handleDaemonStatus();
10722
+ await handleDaemonStatus();
7532
10723
  return;
7533
10724
  case "service-install":
7534
10725
  installService();
@@ -7574,16 +10765,20 @@ async function handleDaemonStop() {
7574
10765
  printer_default.print(import_picocolors6.default.yellow("No daemon is running."));
7575
10766
  return;
7576
10767
  }
10768
+ const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
7577
10769
  const result = await stopDaemon();
7578
10770
  if (result.ok) {
7579
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
+ }
7580
10775
  } else if (result.reason === DaemonHost.APP) {
7581
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."));
7582
10777
  } else {
7583
10778
  printer_default.error(result.error);
7584
10779
  }
7585
10780
  }
7586
- function handleDaemonStatus() {
10781
+ async function handleDaemonStatus() {
7587
10782
  const info = getDaemonInfo();
7588
10783
  if (!info) {
7589
10784
  printer_default.print(import_picocolors6.default.yellow("No daemon is running. Start with: pinggy daemon start"));
@@ -7604,6 +10799,10 @@ function handleDaemonStatus() {
7604
10799
  if (!isIpcCompatible(info)) {
7605
10800
  printer_default.print(import_picocolors6.default.yellow(` Warning: ${ipcMismatchMessage(info)}`));
7606
10801
  }
10802
+ const agent = await readBackgroundAgent(machineBackgroundAgentDependencies());
10803
+ if (agent) {
10804
+ printer_default.print(` Device: ${describeAgentForDaemonStatus(agent.status)}`);
10805
+ }
7607
10806
  }
7608
10807
  var import_picocolors6;
7609
10808
  var init_daemonCommandsHandler = __esm({
@@ -7618,6 +10817,7 @@ var init_daemonCommandsHandler = __esm({
7618
10817
  init_serviceInstaller();
7619
10818
  init_helpMessages();
7620
10819
  init_util();
10820
+ init_backgroundAgent();
7621
10821
  }
7622
10822
  });
7623
10823
 
@@ -7883,8 +11083,8 @@ async function followFile(filePath) {
7883
11083
  stream.on("error", () => {
7884
11084
  });
7885
11085
  };
7886
- watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (event) => {
7887
- if (event === "change") {
11086
+ watcher = import_node_fs4.default.watch(filePath, { persistent: true }, (event2) => {
11087
+ if (event2 === "change") {
7888
11088
  const stat = import_node_fs4.default.existsSync(filePath) ? import_node_fs4.default.statSync(filePath) : null;
7889
11089
  if (!stat || stat.size < fileSize) {
7890
11090
  fileSize = 0;
@@ -8060,6 +11260,161 @@ var init_restartCommand = __esm({
8060
11260
  }
8061
11261
  });
8062
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
+
8063
11418
  // src/cli/subcommand/subcommands.ts
8064
11419
  function isSubcommand(rawArgs) {
8065
11420
  return rawArgs.length > 0 && SUBCOMMAND_SET.has(rawArgs[0]);
@@ -8099,6 +11454,12 @@ async function handleSubcommand(rawArgs) {
8099
11454
  case Subcommand.Restart:
8100
11455
  await handleRestart(rest);
8101
11456
  return;
11457
+ case Subcommand.Device:
11458
+ await handleDevice(rest);
11459
+ return;
11460
+ case Subcommand.Devices:
11461
+ refuseDevicesSpelling(rest);
11462
+ return;
8102
11463
  }
8103
11464
  }
8104
11465
  function handleConfig(args) {
@@ -8339,6 +11700,7 @@ var init_subcommands = __esm({
8339
11700
  init_logsCommand();
8340
11701
  init_logCommand();
8341
11702
  init_restartCommand();
11703
+ init_deviceCommand();
8342
11704
  init_tunnelClient();
8343
11705
  init_startCli();
8344
11706
  init_helpMessages();
@@ -8392,7 +11754,7 @@ async function main() {
8392
11754
  printer_default.fatal(error);
8393
11755
  }
8394
11756
  }
8395
- var import_url2, import_process, import_fs8, currentFile, entryFile;
11757
+ var import_url2, import_process, import_fs13, currentFile, entryFile;
8396
11758
  var init_main = __esm({
8397
11759
  "src/main.ts"() {
8398
11760
  "use strict";
@@ -8407,7 +11769,7 @@ var init_main = __esm({
8407
11769
  init_handler();
8408
11770
  import_url2 = require("url");
8409
11771
  import_process = require("process");
8410
- import_fs8 = require("fs");
11772
+ import_fs13 = require("fs");
8411
11773
  init_remoteManagement();
8412
11774
  init_buildAndStartTunnel();
8413
11775
  init_subcommands();
@@ -8417,7 +11779,7 @@ var init_main = __esm({
8417
11779
  currentFile = (0, import_url2.fileURLToPath)(importMetaUrl);
8418
11780
  entryFile = null;
8419
11781
  try {
8420
- 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;
8421
11783
  } catch (e) {
8422
11784
  entryFile = null;
8423
11785
  }
@@ -8428,8 +11790,8 @@ var init_main = __esm({
8428
11790
  });
8429
11791
 
8430
11792
  // src/daemon/ws/wsProtocol.ts
8431
- function createTunnelEvent(tunnelId, event, payload) {
8432
- return { type: "tunnel_event", tunnelId, event, payload };
11793
+ function createTunnelEvent(tunnelId, event2, payload) {
11794
+ return { type: "tunnel_event", tunnelId, event: event2, payload };
8433
11795
  }
8434
11796
  function parseClientMessage(raw) {
8435
11797
  try {
@@ -8541,7 +11903,7 @@ function parseTunnelLogFilename(filename) {
8541
11903
  const name = parts.slice(1, -1).join("__");
8542
11904
  return { origin, name, tunnelId };
8543
11905
  }
8544
- 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;
8545
11907
  var init_ipcServer = __esm({
8546
11908
  "src/daemon/ipc/ipcServer.ts"() {
8547
11909
  "use strict";
@@ -8549,7 +11911,7 @@ var init_ipcServer = __esm({
8549
11911
  import_node_http2 = __toESM(require("http"), 1);
8550
11912
  import_node_fs6 = __toESM(require("fs"), 1);
8551
11913
  import_node_path4 = __toESM(require("path"), 1);
8552
- import_ws3 = require("ws");
11914
+ import_ws4 = require("ws");
8553
11915
  init_main();
8554
11916
  init_logger();
8555
11917
  init_tunnelLogger();
@@ -8561,7 +11923,7 @@ var init_ipcServer = __esm({
8561
11923
  init_types();
8562
11924
  init_daemonChild();
8563
11925
  init_stateStore();
8564
- VALID_ORIGINS = ["app", "cli", "remote"];
11926
+ VALID_ORIGINS = ["app", "cli", "remote", "device"];
8565
11927
  UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8566
11928
  IPCServer = class {
8567
11929
  constructor() {
@@ -8569,10 +11931,11 @@ var init_ipcServer = __esm({
8569
11931
  this.sessionCounter = 0;
8570
11932
  this.onSessionDisconnect = null;
8571
11933
  this.sessionTracker = null;
11934
+ this.deviceAgentHost = null;
8572
11935
  this.ops = new TunnelOperations();
8573
11936
  this.startedAt = Date.now();
8574
11937
  this.server = import_node_http2.default.createServer(this.handleRequest.bind(this));
8575
- this.wss = new import_ws3.WebSocketServer({ noServer: true });
11938
+ this.wss = new import_ws4.WebSocketServer({ noServer: true });
8576
11939
  this.routes = this.buildRoutes();
8577
11940
  this.setupWebSocket();
8578
11941
  }
@@ -8586,6 +11949,13 @@ var init_ipcServer = __esm({
8586
11949
  setSessionTracker(st) {
8587
11950
  this.sessionTracker = st;
8588
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
+ }
8589
11959
  /**
8590
11960
  * Get all active sessions (for SessionTracker inspection).
8591
11961
  */
@@ -8718,6 +12088,10 @@ var init_ipcServer = __esm({
8718
12088
  }
8719
12089
  return { daemon: daemonPath, tunnels };
8720
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(),
8721
12095
  [Route.Shutdown]: () => {
8722
12096
  logger.info("Daemon shutdown requested via IPC");
8723
12097
  if (getDaemonHost() === DaemonHost.APP) {
@@ -8734,6 +12108,9 @@ var init_ipcServer = __esm({
8734
12108
  logger.error(`Shutdown step "${label}" failed`, { error: msg });
8735
12109
  }
8736
12110
  };
12111
+ step("stopDeviceAgent", () => {
12112
+ void this.deviceAgentHost?.close();
12113
+ });
8737
12114
  step("removeDaemonInfo", removeDaemonInfo);
8738
12115
  step("clearDaemonState", clearDaemonState);
8739
12116
  step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
@@ -8766,8 +12143,8 @@ var init_ipcServer = __esm({
8766
12143
  if (paramMatch) {
8767
12144
  try {
8768
12145
  await this.readBody(req);
8769
- const response = await paramMatch.invoke();
8770
- this.sendJson(res, 200, response);
12146
+ const response2 = await paramMatch.invoke();
12147
+ this.sendJson(res, 200, response2);
8771
12148
  } catch (err) {
8772
12149
  const msg = errorMessage(err);
8773
12150
  logger.error("IPC handler error", { route: `${method} ${url}`, error: msg });
@@ -9000,9 +12377,9 @@ var init_ipcServer = __esm({
9000
12377
  session.subscriptions.clear();
9001
12378
  this.sessions.delete(session.id);
9002
12379
  }
9003
- sendEvent(session, tunnelId, event, payload) {
9004
- if (session.ws.readyState === import_ws3.WebSocket.OPEN) {
9005
- 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);
9006
12383
  session.ws.send(JSON.stringify(msg));
9007
12384
  }
9008
12385
  }
@@ -9193,6 +12570,210 @@ var init_sessionTracker = __esm({
9193
12570
  }
9194
12571
  });
9195
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
+
9196
12777
  // src/daemon/lifecycle/daemonChild.ts
9197
12778
  var daemonChild_exports = {};
9198
12779
  __export(daemonChild_exports, {
@@ -9201,6 +12782,8 @@ __export(daemonChild_exports, {
9201
12782
  removeDaemonInfo: () => removeDaemonInfo,
9202
12783
  runDaemonChild: () => runDaemonChild,
9203
12784
  setDaemonSessionTracker: () => setDaemonSessionTracker,
12785
+ trackDeviceAgentStart: () => trackDeviceAgentStart,
12786
+ trackDeviceAgentStop: () => trackDeviceAgentStop,
9204
12787
  trackIPCTunnelStart: () => trackIPCTunnelStart,
9205
12788
  trackTunnelStart: () => trackTunnelStart,
9206
12789
  trackTunnelStop: () => trackTunnelStop
@@ -9246,6 +12829,29 @@ function trackTunnelStop(tunnelId) {
9246
12829
  persistDaemonState(daemonState);
9247
12830
  sessionTrackerRef?.removeTunnel(tunnelId);
9248
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
+ }
9249
12855
  function trackIPCTunnelStart(tunnelId, origin, mode = SessionMode.Detached) {
9250
12856
  if (mode === SessionMode.Foreground) return;
9251
12857
  const manager = TunnelManager.getInstance();
@@ -9359,11 +12965,16 @@ async function runDaemonChild(opts = {}) {
9359
12965
  });
9360
12966
  ipcServer.setSessionTracker(sessionTracker);
9361
12967
  setDaemonSessionTracker(sessionTracker);
12968
+ let deviceAgentHost = null;
9362
12969
  let cleanedUp = false;
9363
12970
  const cleanup = () => {
9364
12971
  if (cleanedUp) return;
9365
12972
  cleanedUp = true;
9366
12973
  logger.info("Daemon shutting down");
12974
+ try {
12975
+ void deviceAgentHost?.close();
12976
+ } catch {
12977
+ }
9367
12978
  try {
9368
12979
  removeDaemonInfo();
9369
12980
  } catch {
@@ -9409,6 +13020,15 @@ async function runDaemonChild(opts = {}) {
9409
13020
  }
9410
13021
  try {
9411
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
+ }
9412
13032
  const info = {
9413
13033
  pid: process.pid,
9414
13034
  port,
@@ -9432,6 +13052,9 @@ async function runDaemonChild(opts = {}) {
9432
13052
  } else {
9433
13053
  logger.info("No auto-start tunnels configured");
9434
13054
  }
13055
+ if (crashedDeviceAgent) {
13056
+ await restoreCrashedDeviceAgent(deviceAgentHost, crashedDeviceAgent);
13057
+ }
9435
13058
  logger.info("Daemon ready", { pid: process.pid, port });
9436
13059
  return {
9437
13060
  pid: process.pid,
@@ -9456,12 +13079,14 @@ var init_daemonChild = __esm({
9456
13079
  init_ipcServer();
9457
13080
  init_sessionTracker();
9458
13081
  init_stateStore();
13082
+ init_deviceAgentHost();
9459
13083
  init_TunnelManager();
9460
13084
  init_logger();
9461
13085
  init_configDir();
9462
13086
  init_tunnelLogger();
9463
13087
  init_configStore();
9464
13088
  init_daemonConfig();
13089
+ init_types();
9465
13090
  init_util();
9466
13091
  init_ipcRoutes();
9467
13092
  daemonState = { tunnels: [], lastUpdated: "" };
@@ -9688,12 +13313,12 @@ var init_daemonManager = __esm({
9688
13313
  });
9689
13314
 
9690
13315
  // src/daemon/ws/wsStream.ts
9691
- var import_ws4, WS_NORMAL_CLOSE, WsStream;
13316
+ var import_ws5, WS_NORMAL_CLOSE, WsStream;
9692
13317
  var init_wsStream = __esm({
9693
13318
  "src/daemon/ws/wsStream.ts"() {
9694
13319
  "use strict";
9695
13320
  init_cjs_shims();
9696
- import_ws4 = require("ws");
13321
+ import_ws5 = require("ws");
9697
13322
  init_logger();
9698
13323
  init_util();
9699
13324
  init_ipcRoutes();
@@ -9722,11 +13347,11 @@ var init_wsStream = __esm({
9722
13347
  }
9723
13348
  // Lifecycle
9724
13349
  async ensureOpen() {
9725
- if (this.ws && this.ws.readyState === import_ws4.WebSocket.OPEN) return;
13350
+ if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) return;
9726
13351
  this.wsReady = new Promise((resolve) => {
9727
13352
  this.wsResolve = resolve;
9728
13353
  });
9729
- this.ws = new import_ws4.WebSocket(this.getWsUrl());
13354
+ this.ws = new import_ws5.WebSocket(this.getWsUrl());
9730
13355
  this.ws.on("open", () => {
9731
13356
  if (this.wsResolve) {
9732
13357
  this.wsResolve();
@@ -9783,7 +13408,7 @@ var init_wsStream = __esm({
9783
13408
  this.subscribedTunnels.clear();
9784
13409
  }
9785
13410
  isOpen() {
9786
- return !!this.ws && this.ws.readyState === import_ws4.WebSocket.OPEN;
13411
+ return !!this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN;
9787
13412
  }
9788
13413
  // Subscriptions
9789
13414
  async subscribe(tunnelId, mode = SessionMode.Foreground) {
@@ -9801,7 +13426,7 @@ var init_wsStream = __esm({
9801
13426
  unsubscribe(tunnelId) {
9802
13427
  const wasSubscribed = this.subscribedTunnels.delete(tunnelId);
9803
13428
  if (!wasSubscribed) return false;
9804
- if (this.ws && this.ws.readyState === import_ws4.WebSocket.OPEN) {
13429
+ if (this.ws && this.ws.readyState === import_ws5.WebSocket.OPEN) {
9805
13430
  try {
9806
13431
  const msg = { type: "unsubscribe", tunnelId };
9807
13432
  this.ws.send(JSON.stringify(msg));
@@ -9895,8 +13520,8 @@ var init_wsStream = __esm({
9895
13520
  return;
9896
13521
  }
9897
13522
  if (msg.type !== "tunnel_event") return;
9898
- const { tunnelId, event, payload } = msg;
9899
- switch (event) {
13523
+ const { tunnelId, event: event2, payload } = msg;
13524
+ switch (event2) {
9900
13525
  case "stats":
9901
13526
  for (const cb of this.callbacks.stats) cb(tunnelId, payload.stats);
9902
13527
  break;
@@ -9940,7 +13565,7 @@ var init_wsStream = __esm({
9940
13565
  });
9941
13566
 
9942
13567
  // src/daemon/daemonHealth.ts
9943
- 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;
9944
13569
  var init_daemonHealth = __esm({
9945
13570
  "src/daemon/daemonHealth.ts"() {
9946
13571
  "use strict";
@@ -9954,7 +13579,7 @@ var init_daemonHealth = __esm({
9954
13579
  HEARTBEAT_INTERVAL_MS = 5e3;
9955
13580
  HEARTBEAT_TIMEOUT_MS = 2e3;
9956
13581
  HEARTBEAT_FAILURE_THRESHOLD = 2;
9957
- sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
13582
+ sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
9958
13583
  DaemonHealth = class {
9959
13584
  constructor(getIpc, stream) {
9960
13585
  this.getIpc = getIpc;
@@ -10033,7 +13658,7 @@ var init_daemonHealth = __esm({
10033
13658
  } catch {
10034
13659
  }
10035
13660
  }
10036
- await sleep2(RECONNECT_INTERVAL_MS);
13661
+ await sleep3(RECONNECT_INTERVAL_MS);
10037
13662
  if (this.lost) return;
10038
13663
  const info = getDaemonInfo();
10039
13664
  if (!info) {
@@ -10241,6 +13866,20 @@ var init_tunnelClient = __esm({
10241
13866
  this.assertClient();
10242
13867
  await this.ipc.restartTunnel(tunnelId);
10243
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
+ }
10244
13883
  // Streaming — delegate to WsStream, guarded by daemon-lost
10245
13884
  async attach(tunnelId, mode = SessionMode.Foreground) {
10246
13885
  if (this.health.isLost()) return;
@@ -10380,6 +14019,7 @@ var init_tunnelClient = __esm({
10380
14019
  // src/index.ts
10381
14020
  var index_exports = {};
10382
14021
  __export(index_exports, {
14022
+ DeviceAgentState: () => DeviceAgentState,
10383
14023
  RemoteManagementUnauthorizedError: () => RemoteManagementUnauthorizedError,
10384
14024
  TUNNEL_CONFIG_V1_KEYS: () => TUNNEL_CONFIG_V1_KEYS,
10385
14025
  TunnelClient: () => TunnelClient,
@@ -10506,6 +14146,7 @@ init_TunnelManager();
10506
14146
  init_handler();
10507
14147
  init_tunnelClient();
10508
14148
  init_daemonManager();
14149
+ init_ipcRoutes();
10509
14150
  init_logger();
10510
14151
  init_remoteManagement();
10511
14152
  init_types();
@@ -10530,6 +14171,7 @@ verifyAndLoad().catch((err) => {
10530
14171
  });
10531
14172
  // Annotate the CommonJS export names for ESM import in node:
10532
14173
  0 && (module.exports = {
14174
+ DeviceAgentState,
10533
14175
  RemoteManagementUnauthorizedError,
10534
14176
  TUNNEL_CONFIG_V1_KEYS,
10535
14177
  TunnelClient,