@lensmcp/cluster 1.16.23 → 1.16.25

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/basic-ssl.d.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  export declare const CA_CERT_FILE = "lensmcp-local-ca.crt";
2
+ /** The machine-level CA directory: `~/.lensmcp/ca` (override home via `LENSMCP_HOME` — tests). */
3
+ export declare function caDir(): string;
4
+ /** Absolute path of the trust-once, machine-level CA certificate. */
5
+ export declare function caCertPath(): string;
2
6
  export declare function getCertificateSync(cacheDir: string, name?: string, domains?: string[]): string;
3
- /** Absolute path of the trust-once CA certificate inside `cacheDir`. */
4
- export declare function caCertPath(cacheDir: string): string;
5
- /** Mint the local CA if it doesn't exist yet; returns the CA cert path. */
6
- export declare function ensureCaSync(cacheDir: string): string;
7
+ /**
8
+ * Ensure the machine-level CA exists (promoting `legacyCacheDir`'s per-workspace
9
+ * CA when the machine store is still empty); returns the machine CA cert path.
10
+ */
11
+ export declare function ensureCaSync(legacyCacheDir?: string): string;
7
12
  //# sourceMappingURL=basic-ssl.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"basic-ssl.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/basic-ssl.ts"],"names":[],"mappings":"AA6BA,eAAO,MAAM,YAAY,yBAAyB,CAAC;AAKnD,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,SAAgB,EAAE,OAAO,GAAE,MAAM,EAAO,GAAG,MAAM,CA0CzG;AAED,wEAAwE;AACxE,wBAAgB,UAAU,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED,2EAA2E;AAC3E,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAMrD"}
1
+ {"version":3,"file":"basic-ssl.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/basic-ssl.ts"],"names":[],"mappings":"AA4CA,eAAO,MAAM,YAAY,yBAAyB,CAAC;AAKnD,kGAAkG;AAClG,wBAAgB,KAAK,IAAI,MAAM,CAG9B;AAED,qEAAqE;AACrE,wBAAgB,UAAU,IAAI,MAAM,CAEnC;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,SAAgB,EAAE,OAAO,GAAE,MAAM,EAAO,GAAG,MAAM,CA8CzG;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,MAAM,CAI5D"}
package/basic-ssl.js CHANGED
@@ -1,18 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.CA_CERT_FILE = void 0;
4
- exports.getCertificateSync = getCertificateSync;
4
+ exports.caDir = caDir;
5
5
  exports.caCertPath = caCertPath;
6
+ exports.getCertificateSync = getCertificateSync;
6
7
  exports.ensureCaSync = ensureCaSync;
7
8
  const tslib_1 = require("tslib");
8
9
  /**
9
10
  * Dev HTTPS certificates, mkcert-style: a long-lived LOCAL CA (10 years,
10
- * minted once per project, trusted once in the OS keychain) signs short-lived
11
+ * minted once per MACHINE, trusted once in the OS keychain) signs short-lived
11
12
  * leaf certs (30 days, re-minted per SAN set). Because the browser chains the
12
13
  * leaf to the trusted CA, cert rotation and new gateway hostnames never show
13
14
  * an interstitial again — unlike a bare self-signed cert, which would need
14
15
  * re-trusting on every change.
15
16
  *
17
+ * The CA is MACHINE-LEVEL — `~/.lensmcp/ca/` (override the home via
18
+ * `LENSMCP_HOME`, same convention as the workspace registry) — so every
19
+ * workspace/daemon on the machine serves chains anchored to ONE CA:
20
+ * - the OS keychain holds exactly one "lensmcp local dev CA" (no same-CN
21
+ * collisions between per-workspace CAs shadowing each other in `trust`),
22
+ * - a pod's `NODE_EXTRA_CA_CERTS` stays valid across daemon handovers
23
+ * (guest workspace → own daemon), because every daemon signs with the
24
+ * same anchor.
25
+ * The first machine-CA resolution PROMOTES a pre-existing per-workspace CA
26
+ * (the pre-machine-CA layout) when one exists, so keychain trust already
27
+ * granted to it carries over without another sudo prompt. LEAF certs stay
28
+ * per-workspace in `<root>/node_modules/.cache/davnx-webpack` and are reused
29
+ * only when they chain to the CURRENT machine CA (the issuer pin below).
30
+ *
16
31
  * `getCertificateSync` returns ONE concatenated PEM (leaf key + leaf cert +
17
32
  * CA cert). Node's `https.createServer({ key: pem, cert: pem })` accepts it:
18
33
  * `key` takes the first private-key block, `cert` takes every certificate
@@ -20,10 +35,11 @@ const tslib_1 = require("tslib");
20
35
  *
21
36
  * Trust once (macOS):
22
37
  * sudo security add-trusted-cert -d -r trustRoot \
23
- * -k /Library/Keychains/System.keychain <cacheDir>/lensmcp-local-ca.crt
38
+ * -k /Library/Keychains/System.keychain ~/.lensmcp/ca/lensmcp-local-ca.crt
24
39
  */
25
40
  const crypto = tslib_1.__importStar(require("node:crypto"));
26
41
  const fs = tslib_1.__importStar(require("node:fs"));
42
+ const os = tslib_1.__importStar(require("node:os"));
27
43
  const path = tslib_1.__importStar(require("node:path"));
28
44
  /** Re-mint the leaf when the cached one is older than this (TTL is 30 days). */
29
45
  const LEAF_MAX_AGE_MS = 25 * 24 * 60 * 60 * 1000;
@@ -34,16 +50,22 @@ exports.CA_CERT_FILE = 'lensmcp-local-ca.crt';
34
50
  /** The legacy (pre-rebrand) CA filename — swept on mint so a stale `davnx-local-ca.crt` never lingers. */
35
51
  const LEGACY_CA_CERT_FILE = 'davnx-local-ca.crt';
36
52
  const CA_KEY_FILE = '_ca-key.pem';
53
+ /** The machine-level CA directory: `~/.lensmcp/ca` (override home via `LENSMCP_HOME` — tests). */
54
+ function caDir() {
55
+ const home = process.env['LENSMCP_HOME'] || os.homedir();
56
+ return path.join(home, '.lensmcp', 'ca');
57
+ }
58
+ /** Absolute path of the trust-once, machine-level CA certificate. */
59
+ function caCertPath() {
60
+ return path.join(caDir(), exports.CA_CERT_FILE);
61
+ }
37
62
  function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
38
- // Resolve the CA FIRST: loadOrCreateCa mints a fresh CA (and sweeps every OLD-CA leaf) whenever the CA
39
- // cert is missing/expired e.g. after a rebrand renamed the CA file. Doing this BEFORE the leaf-cache read
40
- // is what makes a CA change actually take effect: otherwise a stale, old-CA-signed leaf short-circuits the
41
- // mint and the rebrand never applies (the bug that needed a manual cache clear). On a normal run the CA is
42
- // unchanged, nothing is swept, and the SAN-keyed leaf cache below still fast-paths.
43
- // eslint-disable-next-line @typescript-eslint/no-require-imports
63
+ // Resolve the CA FIRST (machine-level; promotes this workspace's legacy CA on first touch). The current
64
+ // CA pem then gates the leaf cache below an issuer pin, see there.
44
65
  const forge = require('node-forge');
45
66
  fs.mkdirSync(cacheDir, { recursive: true });
46
- const ca = loadOrCreateCa(cacheDir, forge);
67
+ const ca = loadOrCreateCa(forge, cacheDir);
68
+ const caPem = forge.pki.certificateToPem(ca.cert);
47
69
  // The SAN set is part of the cache key: adding a gateway hostname must
48
70
  // mint a fresh leaf, not serve a cached one that lacks the new SAN.
49
71
  const sanKey = crypto
@@ -55,7 +77,13 @@ function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
55
77
  try {
56
78
  const stat = fs.statSync(leafPath);
57
79
  if (Date.now() - stat.ctimeMs < LEAF_MAX_AGE_MS) {
58
- return fs.readFileSync(leafPath, 'utf8'); // reached only when the CA was NOT just re-minted (a mint sweeps leaves)
80
+ const cached = fs.readFileSync(leafPath, 'utf8');
81
+ // ISSUER PIN: the cached pem embeds the CA it chained to — serve it only when that is the CURRENT
82
+ // machine CA. With the CA machine-level, a stale leaf in THIS workspace can't be swept by a CA mint
83
+ // that happened elsewhere (another workspace, or pre-machine-CA code), so the old "a mint sweeps
84
+ // this dir's leaves" guarantee no longer covers every path; the pin does.
85
+ if (cached.includes(caPem))
86
+ return cached;
59
87
  }
60
88
  }
61
89
  catch {
@@ -64,11 +92,13 @@ function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
64
92
  const pem = createLeafCertificate(forge, ca, name, domains);
65
93
  fs.writeFileSync(leafPath, pem);
66
94
  // Stale leaves and the pre-1.3.4 self-signed cache just rot here — sweep
67
- // them so the dir holds only the active leaf + the CA pair.
95
+ // them so the dir holds only the active leaf (+ any legacy CA pair, kept
96
+ // readable for older-code daemons that still anchor to it).
68
97
  for (const f of fs.readdirSync(cacheDir)) {
69
98
  const stale = (/^_leaf-.*\.pem$/.test(f) && f !== `_leaf-${sanKey}.pem`) ||
70
99
  /^_cert.*\.pem$/.test(f) ||
71
- /-dev\.crt$/.test(f);
100
+ /-dev\.crt$/.test(f) ||
101
+ f === LEGACY_CA_CERT_FILE;
72
102
  if (stale) {
73
103
  try {
74
104
  fs.unlinkSync(path.join(cacheDir, f));
@@ -78,31 +108,79 @@ function getCertificateSync(cacheDir, name = 'lensmcp.dev', domains = []) {
78
108
  }
79
109
  return pem;
80
110
  }
81
- /** Absolute path of the trust-once CA certificate inside `cacheDir`. */
82
- function caCertPath(cacheDir) {
83
- return path.join(cacheDir, exports.CA_CERT_FILE);
84
- }
85
- /** Mint the local CA if it doesn't exist yet; returns the CA cert path. */
86
- function ensureCaSync(cacheDir) {
87
- // eslint-disable-next-line @typescript-eslint/no-require-imports
111
+ /**
112
+ * Ensure the machine-level CA exists (promoting `legacyCacheDir`'s per-workspace
113
+ * CA when the machine store is still empty); returns the machine CA cert path.
114
+ */
115
+ function ensureCaSync(legacyCacheDir) {
88
116
  const forge = require('node-forge');
89
- fs.mkdirSync(cacheDir, { recursive: true });
90
- loadOrCreateCa(cacheDir, forge);
91
- return caCertPath(cacheDir);
117
+ loadOrCreateCa(forge, legacyCacheDir);
118
+ return caCertPath();
92
119
  }
93
- function loadOrCreateCa(cacheDir, forge) {
94
- const certPath = path.join(cacheDir, exports.CA_CERT_FILE);
95
- const keyPath = path.join(cacheDir, CA_KEY_FILE);
120
+ /** Read a CA pair from `dir`; undefined when absent, unreadable, or too close to expiry to reuse. */
121
+ function readCaPair(dir, forge) {
96
122
  try {
97
- const cert = forge.pki.certificateFromPem(fs.readFileSync(certPath, 'utf8'));
98
- const key = forge.pki.privateKeyFromPem(fs.readFileSync(keyPath, 'utf8'));
123
+ const cert = forge.pki.certificateFromPem(fs.readFileSync(path.join(dir, exports.CA_CERT_FILE), 'utf8'));
124
+ const key = forge.pki.privateKeyFromPem(fs.readFileSync(path.join(dir, CA_KEY_FILE), 'utf8'));
99
125
  if (cert.validity.notAfter.getTime() - Date.now() > CA_MIN_REMAINING_MS) {
100
126
  return { cert, key };
101
127
  }
102
128
  }
103
129
  catch {
104
- /* no CA yet (or unreadable) — mint one */
130
+ /* absent or unreadable */
131
+ }
132
+ return undefined;
133
+ }
134
+ function loadOrCreateCa(forge, promoteFromDir) {
135
+ const dir = caDir();
136
+ fs.mkdirSync(dir, { recursive: true });
137
+ const existing = readCaPair(dir, forge);
138
+ if (existing)
139
+ return existing;
140
+ // Files present but unusable (expired / half-written) — clear them so the `wx` claim below can win.
141
+ // Rotation this races is a once-per-decade event; last write wins is fine at dev grade.
142
+ for (const f of [CA_KEY_FILE, exports.CA_CERT_FILE]) {
143
+ try {
144
+ fs.unlinkSync(path.join(dir, f));
145
+ }
146
+ catch { /* absent */ }
147
+ }
148
+ // Candidate pair: PROMOTE the pre-machine-CA workspace pair when one exists — the exact cert the user
149
+ // already trusted in the keychain simply becomes the machine CA (no new sudo prompt). The workspace copy
150
+ // stays in place for any older-code daemon still anchoring to it. Otherwise mint fresh.
151
+ const promoted = promoteFromDir && promoteFromDir !== dir ? readCaPair(promoteFromDir, forge) : undefined;
152
+ const pair = promoted ?? mintCa(forge);
153
+ // Atomic claim on the KEY file (`wx`): concurrent daemons can race the first-ever machine resolution;
154
+ // exactly one pair may land on disk. The winner writes the cert right after; a loser adopts the winner's
155
+ // pair (bounded wait for that cert write).
156
+ try {
157
+ fs.writeFileSync(path.join(dir, CA_KEY_FILE), forge.pki.privateKeyToPem(pair.key), { mode: 0o600, flag: 'wx' });
158
+ }
159
+ catch (e) {
160
+ if (e.code !== 'EEXIST')
161
+ throw e;
162
+ const winner = spinReadCaPair(dir, forge);
163
+ if (winner)
164
+ return winner;
165
+ throw new Error(`machine dev CA at ${dir} is claimed but unreadable — remove the directory and retry`, { cause: e });
105
166
  }
167
+ fs.writeFileSync(path.join(dir, exports.CA_CERT_FILE), forge.pki.certificateToPem(pair.cert));
168
+ if (promoted) {
169
+ console.log(`[ssl] promoted the existing workspace dev CA to the machine store (${dir}) — keychain trust carries over.`);
170
+ }
171
+ return pair;
172
+ }
173
+ /** Bounded sync wait for a concurrent claimer's cert write to land (claim→cert is two writes apart). */
174
+ function spinReadCaPair(dir, forge) {
175
+ const deadline = Date.now() + 2000;
176
+ for (;;) {
177
+ const pair = readCaPair(dir, forge);
178
+ if (pair || Date.now() >= deadline)
179
+ return pair;
180
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25); // sync 25ms sleep
181
+ }
182
+ }
183
+ function mintCa(forge) {
106
184
  const keys = forge.pki.rsa.generateKeyPair(2048);
107
185
  const cert = forge.pki.createCertificate();
108
186
  cert.publicKey = keys.publicKey;
@@ -123,19 +201,6 @@ function loadOrCreateCa(cacheDir, forge) {
123
201
  { name: 'subjectKeyIdentifier' },
124
202
  ]);
125
203
  cert.sign(keys.privateKey, forge.md.sha256.create());
126
- fs.writeFileSync(certPath, forge.pki.certificateToPem(cert));
127
- fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(keys.privateKey), { mode: 0o600 });
128
- // A freshly-minted CA invalidates every cached LEAF (they were signed by the OLD CA) — sweep them so the
129
- // next getCertificateSync re-mints against THIS CA (otherwise the SAN-keyed leaf cache serves a stale,
130
- // old-CA-signed cert). Also drop the legacy `davnx-local-ca.crt` so the lensmcp rebrand leaves nothing behind.
131
- for (const f of fs.readdirSync(cacheDir)) {
132
- if (/^_leaf-.*\.pem$/.test(f) || f === LEGACY_CA_CERT_FILE) {
133
- try {
134
- fs.unlinkSync(path.join(cacheDir, f));
135
- }
136
- catch { /* best effort */ }
137
- }
138
- }
139
204
  return { cert, key: keys.privateKey };
140
205
  }
141
206
  function createLeafCertificate(forge, ca, name, domains, ttlDays = 30) {
@@ -6,8 +6,9 @@ export interface ControlServerDeps {
6
6
  rt: GatewayRuntime;
7
7
  /** Reap a fragment's spawned devservers when it unregisters (the svcLayer's `killSpawned`). */
8
8
  killService: (svc: WorkspaceFragment['services'][number], reason: string) => void;
9
- /** Hooks so the daemon can host/tear-down a workspace's dashboard around the route merge (P3d). */
10
- onRegister?: (fragment: WorkspaceFragment) => void;
9
+ /** Hooks so the daemon can host/tear-down a workspace's dashboard around the route merge (P3d).
10
+ * `onRegister` may be async (port probing) the register handler AWAITS it before the rebuild. */
11
+ onRegister?: (fragment: WorkspaceFragment) => void | Promise<void>;
11
12
  onUnregister?: (fragment: WorkspaceFragment) => void;
12
13
  }
13
14
  export interface ControlServer {
@@ -1 +1 @@
1
- {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/control.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,+GAA+G;AAC/G,wBAAgB,iBAAiB,IAAI,MAAM,CAG1C;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,cAAc,CAAC;IACnB,+FAA+F;IAC/F,WAAW,EAAE,CAAC,GAAG,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClF,mGAAmG;IACnG,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACnD,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAsBD;qFACqF;AACrF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAwEzE"}
1
+ {"version":3,"file":"control.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/control.ts"],"names":[],"mappings":"AAiBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,+GAA+G;AAC/G,wBAAgB,iBAAiB,IAAI,MAAM,CAG1C;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,cAAc,CAAC;IACnB,+FAA+F;IAC/F,WAAW,EAAE,CAAC,GAAG,EAAE,iBAAiB,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClF;wGACoG;IACpG,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACtD;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,IAAI,CAAC;CACnB;AAsBD;qFACqF;AACrF,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,iBAAiB,GAAG,aAAa,CAwEzE"}
@@ -90,7 +90,7 @@ function startControlServer(deps) {
90
90
  const { routes, services } = (0, discovery_1.discoverRoutes)(body.root, body.projects ?? {}, undefined, body.wsKey);
91
91
  const fragment = { wsKey: body.wsKey, root: body.root, routes, services };
92
92
  rt.registry.register(fragment);
93
- deps.onRegister?.(fragment); // hosts the dashboard + APPENDS its route to the fragment BEFORE the rebuild
93
+ await deps.onRegister?.(fragment); // hosts the dashboard + APPENDS its route to the fragment BEFORE the rebuild
94
94
  rt.rebuildRoutes();
95
95
  return send(res, 200, { ok: true, wsKey: body.wsKey, routes: routes.length, services: services.length, workspaces: rt.registry.keys() });
96
96
  }
@@ -2,22 +2,31 @@ import { type SpawnChild } from '@lensmcp/nx-plugin/lens-frontend';
2
2
  import type { WorkspaceFragment } from './route-registry';
3
3
  import type { GatewayRuntime, GatewayRuntimeOptions } from './types';
4
4
  import { type Observability } from './observability';
5
+ /** True when `port` can actually be BOUND on this machine right now. The in-process `used*Ports` sets only
6
+ * know what THIS daemon claimed — an ORPHANED child of a CRASHED daemon (or any foreign process) still
7
+ * holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
8
+ * we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
9
+ * dashboard redirects to foodguard" bug). */
10
+ export declare function probePortFree(port: number): Promise<boolean>;
5
11
  export interface LensChildren {
6
12
  spawnManagedChild: SpawnChild;
7
13
  /** Boot the dashboard + MCP singletons and every `lens:true` app. Pushes the
8
- * dashboard route onto `rt.routes` — MUST run before TLS SAN derivation. */
9
- bootSingletonsAndApps(): void;
14
+ * dashboard route onto `rt.routes` — MUST run before TLS SAN derivation.
15
+ * Async: every claimed port is OS-probed first (an orphan may hold it). */
16
+ bootSingletonsAndApps(): Promise<void>;
10
17
  /** Host a REGISTERED workspace's dashboard (P3d): spawn its dashboard child on its OWN events file + a
11
18
  * distinct port, append its `lensmcp.local/<key>` route to its fragment. Returns a reaper (kills the
12
19
  * child WITHOUT auto-heal respawn) for the daemon to call on unregister — or undefined if no bundle. */
13
- hostWorkspaceDashboard(fragment: WorkspaceFragment): (() => void) | undefined;
20
+ hostWorkspaceDashboard(fragment: WorkspaceFragment): Promise<(() => void) | undefined>;
14
21
  /** Host a REGISTERED workspace's `lens:true` FRONTEND apps (completes model-A "the daemon hosts
15
22
  * everything"): spawn each app's vite (+ capture) in the workspace's OWN root + event bus, on a
16
23
  * collision-safe port, and point its route target at that port. Returns a reaper for unregister. */
17
- hostWorkspaceApps(fragment: WorkspaceFragment): () => void;
24
+ hostWorkspaceApps(fragment: WorkspaceFragment): Promise<() => void>;
18
25
  /** Reap all managed children (called from `stop()`): SIGTERM, a grace window, then SIGKILL any survivor
19
26
  * (vite ignores SIGTERM → would orphan when the daemon exits). Async so `stop()` awaits the escalation. */
20
27
  reapAll(): Promise<void>;
21
28
  }
22
- export declare function createLensChildren(rt: GatewayRuntime, obs: Observability, options: GatewayRuntimeOptions): LensChildren;
29
+ export declare function createLensChildren(rt: GatewayRuntime, obs: Observability, options: GatewayRuntimeOptions, deps?: {
30
+ isPortFree?: (port: number) => Promise<boolean>;
31
+ }): LensChildren;
23
32
  //# sourceMappingURL=lens-children.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AAQA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAuB,MAAM,SAAS,CAAC;AAQ1F,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;iFAC6E;IAC7E,qBAAqB,IAAI,IAAI,CAAC;IAC9B;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC;IAC9E;;yGAEqG;IACrG,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,IAAI,CAAC;IAC3D;gHAC4G;IAC5G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,GAAG,YAAY,CAiUvH"}
1
+ {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AASA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAE1D,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAuB,MAAM,SAAS,CAAC;AAQ1F,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E;;;;8CAI8C;AAC9C,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO5D;AAED,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;;gFAE4E;IAC5E,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC;;6GAEyG;IACzG,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,CAAC,MAAM,IAAI,CAAC,GAAG,SAAS,CAAC,CAAC;IACvF;;yGAEqG;IACrG,iBAAiB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC;IACpE;gHAC4G;IAC5G,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,wBAAgB,kBAAkB,CAChC,EAAE,EAAE,cAAc,EAClB,GAAG,EAAE,aAAa,EAClB,OAAO,EAAE,qBAAqB,EAE9B,IAAI,CAAC,EAAE;IAAE,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,GACzD,YAAY,CAgWd"}
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.probePortFree = probePortFree;
3
4
  exports.createLensChildren = createLensChildren;
4
5
  const tslib_1 = require("tslib");
5
6
  /**
@@ -8,6 +9,7 @@ const tslib_1 = require("tslib");
8
9
  * EVERY managed child AUTO-HEALS — restarted with exponential backoff when it
9
10
  * exits while the gateway is up — and a dead child NEVER kills the cluster.
10
11
  */
12
+ const net = tslib_1.__importStar(require("node:net"));
11
13
  const path = tslib_1.__importStar(require("node:path"));
12
14
  const node_child_process_1 = require("node:child_process");
13
15
  const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
@@ -19,8 +21,24 @@ const observability_1 = require("./observability");
19
21
  const HEAL_BASE_MS = 1_500; // first restart delay
20
22
  const HEAL_MAX_MS = 30_000; // backoff cap — a true boot-loop settles here, never hot-loops
21
23
  const HEAL_HEALTHY_MS = 20_000; // ran at least this long ⇒ a FRESH crash ⇒ reset the backoff budget
22
- function createLensChildren(rt, obs, options) {
24
+ /** True when `port` can actually be BOUND on this machine right now. The in-process `used*Ports` sets only
25
+ * know what THIS daemon claimed — an ORPHANED child of a CRASHED daemon (or any foreign process) still
26
+ * holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
27
+ * we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
28
+ * dashboard redirects to foodguard" bug). */
29
+ function probePortFree(port) {
30
+ return new Promise((resolve) => {
31
+ const probe = net.createServer();
32
+ probe.unref();
33
+ probe.once('error', () => resolve(false));
34
+ probe.listen(port, () => probe.close(() => resolve(true)));
35
+ });
36
+ }
37
+ function createLensChildren(rt, obs, options,
38
+ // Injectable OS-level port probe (unit tests stub it; real ports are machine-dependent).
39
+ deps) {
23
40
  const { emit } = obs;
41
+ const isPortFree = deps?.isPortFree ?? probePortFree;
24
42
  const lensChildren = [];
25
43
  // Auto-heal bookkeeping per child label: consecutive restart attempts (drives the exponential
26
44
  // backoff) so a boot-loop settles instead of hammering, reset once a child has run healthily.
@@ -42,6 +60,18 @@ function createLensChildren(rt, obs, options) {
42
60
  // registered workspace whose app declares a port already in use gets offset (vite's --strictPort would
43
61
  // otherwise exit-loop). Keyed by the actual bound port.
44
62
  const usedAppPorts = new Set();
63
+ // The FIRST port at/above `desired` that is neither claimed in-process nor held at the OS level (an
64
+ // orphaned child of a crashed daemon squats its old port — see probePortFree). Claims it in `claimed`.
65
+ const claimFreePort = async (desired, claimed) => {
66
+ let port = desired;
67
+ while (claimed.has(port) || !(await isPortFree(port)))
68
+ port += 1;
69
+ claimed.add(port);
70
+ if (port !== desired) {
71
+ console.warn(`[gateway] port ${desired} is unavailable (another process holds it — an orphan from a crashed gateway?) — using ${port} instead.`);
72
+ }
73
+ return port;
74
+ };
45
75
  // A `SpawnChild` over the gateway's managed-children array — the SAME shape `agent-dev` passes to
46
76
  // `spawnLensFrontend`. EVERY gateway-managed child AUTO-HEALS: the dashboard + MCP singletons AND each
47
77
  // `lens:true` vite app are restarted when they exit while the gateway is up, with exponential backoff.
@@ -56,7 +86,14 @@ function createLensChildren(rt, obs, options) {
56
86
  void optional;
57
87
  void respawn; // legacy knobs — auto-heal is now universal (signature kept for compat)
58
88
  const spawnedAt = Date.now();
59
- const child = (0, node_child_process_1.spawn)(bin, args, { cwd: cwd ?? rt.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
89
+ // LENSMCP_PARENT_PID: our own bundles (dashboard/MCP) run a ppid watchdog against it and SELF-EXIT when
90
+ // this gateway dies ungracefully (crash/SIGKILL — reapAll never ran), so they don't orphan and squat
91
+ // their ports for the next gateway to route into.
92
+ const child = (0, node_child_process_1.spawn)(bin, args, {
93
+ cwd: cwd ?? rt.root,
94
+ env: { ...process.env, ...(env ?? {}), LENSMCP_PARENT_PID: String(process.pid) },
95
+ stdio: 'inherit',
96
+ });
60
97
  lensChildren.push(child);
61
98
  labeledChildren.set(label, child); // the CURRENT handle for this label (a per-workspace dashboard reap targets it)
62
99
  // Track a `lens:true` app's vite dev server (label 'vite', or `<ws>:<proj>:vite` for a registered
@@ -115,7 +152,7 @@ function createLensChildren(rt, obs, options) {
115
152
  }, Math.min(types_1.POD_STALE_SCAN_MS, 15_000));
116
153
  lensSweeper.unref?.();
117
154
  };
118
- const bootSingletonsAndApps = () => {
155
+ const bootSingletonsAndApps = async () => {
119
156
  const lensApps = [...new Map(rt.routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
120
157
  const wantDashboard = options.dashboard !== false;
121
158
  const wantMcp = options.mcp !== false && lensApps.length > 0;
@@ -130,8 +167,11 @@ function createLensChildren(rt, obs, options) {
130
167
  // OWNS its base path (LENSMCP_DASHBOARD_BASE) and history-routes under it, so the
131
168
  // gateway routes pass-through (matchRoute on the prefix; the proxy never strips).
132
169
  const scope = (0, scope_1.readLensScope)(rt.root);
133
- const dashboardPort = options.dashboardPort ?? scope.dashboardPort;
134
- usedDashboardPorts.add(dashboardPort); // reserve the daemon's own dashboard port so a registered workspace's differs
170
+ // The per-workspace scoped port (`.lensmcp/config.json` ports.dashboard) unless explicitly
171
+ // overridden then OS-probed: an orphaned dashboard from a crashed gateway may still hold it, and
172
+ // routing there serves ANOTHER workspace's dashboard. Claiming also reserves it in-process so a
173
+ // registered workspace's dashboard differs.
174
+ const dashboardPort = await claimFreePort(options.dashboardPort ?? scope.dashboardPort, usedDashboardPorts);
135
175
  const lensHost = options.lensHost ?? 'lensmcp.local';
136
176
  const lensBase = scope.basePath; // e.g. /tetros
137
177
  // Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)), so lensmcp.local
@@ -172,7 +212,20 @@ function createLensChildren(rt, obs, options) {
172
212
  if (app.started)
173
213
  continue;
174
214
  app.started = true;
175
- usedAppPorts.add(app.port); // claim the daemon's own lens-app ports so a registered workspace offsets off them
215
+ // Claim the app's port OS-probed (an orphaned vite from a crashed daemon may hold it — vite's
216
+ // --strictPort would exit-loop while the route proxies into the stale one). On a step-up, point every
217
+ // route of this project at the ACTUAL port (route objects are shared with the registry fragment, so
218
+ // the rewrite survives rebuilds — mirrors hostWorkspaceApps).
219
+ const appPort = await claimFreePort(app.port, usedAppPorts);
220
+ if (appPort !== app.port) {
221
+ for (const r of rt.routes) {
222
+ if (r.lens?.project === app.project) {
223
+ r.lens.port = appPort;
224
+ r.target = `http://localhost:${appPort}`;
225
+ }
226
+ }
227
+ app.port = appPort;
228
+ }
176
229
  try {
177
230
  (0, lens_frontend_1.spawnLensFrontend)({
178
231
  projectRoot: app.projectRoot,
@@ -199,17 +252,15 @@ function createLensChildren(rt, obs, options) {
199
252
  // `lensmcp.local/<key>` route on its fragment. The dashboard BUNDLE is the daemon's (rt.root); only the
200
253
  // events file + base + port are the registered workspace's. Returns a reaper the control endpoint calls
201
254
  // on unregister (marks the label removed so auto-heal won't respawn it, then kills the child).
202
- const hostWorkspaceDashboard = (fragment) => {
255
+ const hostWorkspaceDashboard = async (fragment) => {
203
256
  const bundle = (0, lens_frontend_1.findDashboardBundle)(rt.root);
204
257
  if (!bundle)
205
258
  return undefined;
206
259
  const scope = (0, scope_1.readLensScope)(fragment.root);
207
260
  const lensHost = options.lensHost ?? 'lensmcp.local';
208
261
  const lensBase = scope.basePath; // /<registered key>
209
- let port = scope.dashboardPort;
210
- while (usedDashboardPorts.has(port))
211
- port += 1; // distinct from every other workspace's dashboard
212
- usedDashboardPorts.add(port);
262
+ // Distinct from every other workspace's dashboard AND actually bindable (OS-probed for orphans).
263
+ const port = await claimFreePort(scope.dashboardPort, usedDashboardPorts);
213
264
  const label = `lens-dashboard:${fragment.wsKey}`;
214
265
  const eventFile = path.join(fragment.root, '.lensmcp', 'events.jsonl'); // the registered workspace's OWN bus
215
266
  removedLabels.delete(label); // a fresh registration after a prior unregister → allow auto-heal again
@@ -250,7 +301,7 @@ function createLensChildren(rt, obs, options) {
250
301
  // TARGET to that port so the merged table (rebuilt right after this in the control handler) upstreams
251
302
  // correctly. Unique labels (`<ws>:<proj>:vite`) keep auto-heal + the reaper from touching the daemon's own
252
303
  // identically-named vites. Returns a reaper the control endpoint calls on unregister.
253
- const hostWorkspaceApps = (fragment) => {
304
+ const hostWorkspaceApps = async (fragment) => {
254
305
  // Group a project's (possibly several host) routes so one spawned vite serves them all.
255
306
  const byProject = new Map();
256
307
  for (const r of fragment.routes) {
@@ -269,11 +320,9 @@ function createLensChildren(rt, obs, options) {
269
320
  const lens0 = routes[0].lens;
270
321
  if (lens0.started)
271
322
  continue;
272
- // Collision-safe port: keep the declared port unless already claimed, else step up.
273
- let port = lens0.port;
274
- while (usedAppPorts.has(port))
275
- port += 1;
276
- usedAppPorts.add(port);
323
+ // Collision-safe port: keep the declared port unless already claimed in-process OR held at the OS
324
+ // level (an orphaned vite from a crashed daemon), else step up.
325
+ const port = await claimFreePort(lens0.port, usedAppPorts);
277
326
  ports.push(port);
278
327
  // Point every route of this project at the actual bound port (all share the one vite).
279
328
  for (const r of routes) {
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAapE,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,iBAAiB,CAAC;AA4C/D;;sGAEsG;AACtG,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,oBAAoB,CAAC,EACpG,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,gBAAgB,GACpB,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAMT;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CAkRvF"}
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAapE,OAAO,KAAK,EAAE,aAAa,EAAY,MAAM,iBAAiB,CAAC;AA8C/D;;sGAEsG;AACtG,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,oBAAoB,CAAC,EACpG,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,gBAAgB,GACpB,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAMT;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CAkRvF"}
@@ -15,13 +15,14 @@ const fs = tslib_1.__importStar(require("node:fs"));
15
15
  const http = tslib_1.__importStar(require("node:http"));
16
16
  const path = tslib_1.__importStar(require("node:path"));
17
17
  const discovery_1 = require("./discovery");
18
- const service_keys_1 = require("./service-keys");
19
18
  const types_1 = require("./types");
20
19
  /**
21
20
  * The east-west env a pod needs to call ANOTHER service's `internal.<host>` route (memberships, settings,
22
21
  * plan): (1) its OWN per-service key — presented as `x-api-key`; the gateway maps it back to the caller and
23
- * stamps `x-api-key-id` (a service can't forge another's identity). (2) `NODE_EXTRA_CA_CERTS` = the dev
24
- * gateway's self-signed CA, so the pod's OUTBOUND `fetch('https://internal.<host>')` clears TLS. Without
22
+ * stamps `x-api-key-id` (a service can't forge another's identity). (2) `NODE_EXTRA_CA_CERTS` = the
23
+ * MACHINE-LEVEL dev CA (`~/.lensmcp/ca`), so the pod's OUTBOUND `fetch('https://internal.<host>')` clears
24
+ * TLS — and keeps clearing it across daemon handovers, since every daemon on the machine signs with that
25
+ * same anchor (a per-workspace path here once broke pods that outlived a guest→own-daemon switch). Without
25
26
  * these the call fails (connect/TLS or a gateway 401) and tenant-token minting can't resolve the role.
26
27
  * NOT `INTERNAL_API_KEY`/`LENSMCP_INTERNAL_TOKEN` (those would flip ON the inbound gateway-trust guard) —
27
28
  * a DEDICATED name a service opts into for OUTBOUND only, leaving inbound dev-standalone behavior unchanged.
@@ -34,7 +35,7 @@ function eastWestEnv(rt, project) {
34
35
  if (rt.https) {
35
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy like server.ts (cert deps)
36
37
  const basicSsl = require('../../../basic-ssl');
37
- const caPath = basicSsl.caCertPath((0, service_keys_1.gatewayCacheDir)(rt.root));
38
+ const caPath = basicSsl.caCertPath();
38
39
  if (fs.existsSync(caPath))
39
40
  env.NODE_EXTRA_CA_CERTS = caPath;
40
41
  }
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CAgTtG"}
1
+ {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/proxy.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC,OAAO,KAAK,KAAK,MAAM,MAAM,aAAa,CAAC;AAG3C,OAAO,KAAK,EAAE,cAAc,EAAW,KAAK,EAAE,MAAM,SAAS,CAAC;AAE9D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AA8BhD,MAAM,WAAW,WAAW;IAC1B,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACxH,EAAE,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAAC;IACnI,EAAE,CAAC,KAAK,EAAE,UAAU,EAAE,EAAE,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,KAAK,IAAI,GAAG,IAAI,CAAC;IACrG,KAAK,CAAC,IAAI,IAAI,CAAC;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,WAAW,CAAC;IACnB,OAAO,CAAC,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,GAAG,EAAE,IAAI,CAAC,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1F,cAAc,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC,KAAK,GAAG,OAAO,YAAY,EAAE,KAAK,CAAC;IACzE,aAAa,IAAI,IAAI,CAAC;IACtB,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,YAAY,GAAG,UAAU,CAsTtG"}
@@ -110,6 +110,11 @@ function createProxy(rt, obs, svcLayer) {
110
110
  agent: agentForTarget(target),
111
111
  };
112
112
  }
113
+ // An h2 ServerResponse whose stream faults (a RST from the client, a
114
+ // `res.destroy(err)` below) emits 'error'; UNHANDLED, that throws and can
115
+ // escalate to tear the whole h2 session down — the very "burst" symptom.
116
+ // A no-op listener keeps the fault local to THIS stream. (Harmless on h1.)
117
+ res.on('error', () => { });
113
118
  const upstreamReq = mod.request(opts, (upstreamRes) => {
114
119
  cbs.onResponse();
115
120
  if (res.headersSent || res.writableEnded || res.destroyed) {
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA0BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAkPxB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,EAAkB,KAAK,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AAK/D,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,KAAK,EAAE,MAAM,SAAS,CAAC;AA0BxH;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,SAAS,EAAE,MAAM,KAAK,EAAE,GAAG,gBAAgB,CA4ChF;AAED,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAmRxB"}
@@ -189,13 +189,13 @@ async function startGateway(options, context) {
189
189
  controlServer = (0, control_1.startControlServer)({
190
190
  rt,
191
191
  killService: (svc, reason) => svcLayer.killSpawned(svc, reason),
192
- onRegister: (fragment) => {
192
+ onRegister: async (fragment) => {
193
193
  // Host the registered workspace's dashboard AND its lens FRONTEND apps (both spawned in ITS root/bus;
194
194
  // the dashboard appends its lensmcp.local/<key> route + the apps rewrite their route targets to the
195
- // actual bound port — all BEFORE the control handler's rebuild). The cert re-mint happens in
196
- // rebuildRoutes (called by the control handler right after this), once the full table is live.
197
- const reapDash = lens.hostWorkspaceDashboard(fragment);
198
- const reapApps = lens.hostWorkspaceApps(fragment);
195
+ // actual bound port — all BEFORE the control handler's rebuild, which AWAITS this). The cert re-mint
196
+ // happens in rebuildRoutes (called by the control handler right after this), once the full table is live.
197
+ const reapDash = await lens.hostWorkspaceDashboard(fragment);
198
+ const reapApps = await lens.hostWorkspaceApps(fragment);
199
199
  workspaceReapers.set(fragment.wsKey, () => { reapDash?.(); reapApps(); });
200
200
  },
201
201
  onUnregister: (fragment) => {
@@ -249,7 +249,7 @@ async function startGateway(options, context) {
249
249
  const sweeper = svcLayer.startSweeper();
250
250
  // --- lens-mode children: dashboard + MCP singletons + `lens:true` apps ------
251
251
  // MUST run before TLS — it pushes the dashboard route, which joins the cert SANs.
252
- lens.bootSingletonsAndApps();
252
+ await lens.bootSingletonsAndApps();
253
253
  // Eager services boot with the gateway.
254
254
  for (const svc of services) {
255
255
  if (svc.decl.eager)
@@ -267,7 +267,7 @@ async function startGateway(options, context) {
267
267
  // below) automatically includes a newly-registered workspace's hostnames.
268
268
  mintCert = () => basicSsl.getCertificateSync(cacheDir, 'gateway', rt.routes.map((r) => r.host).filter((h) => !!h));
269
269
  pem = mintCert();
270
- caPath = basicSsl.caCertPath(cacheDir);
270
+ caPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca) — one anchor for every daemon
271
271
  }
272
272
  const servers = [];
273
273
  const boundPorts = [];
@@ -286,10 +286,44 @@ async function startGateway(options, context) {
286
286
  // because node-http-proxy emits connection-specific headers that are illegal in h2.
287
287
  const server = pem
288
288
  ? // eslint-disable-next-line @typescript-eslint/no-require-imports
289
- require('node:http2').createSecureServer({ key: pem, cert: pem, allowHTTP1: true }, (req, res) => handler(req, res))
289
+ require('node:http2').createSecureServer({
290
+ key: pem,
291
+ cert: pem,
292
+ allowHTTP1: true,
293
+ // A full Vite dev-app reload streams TENS of MB of transformed ESM
294
+ // over ONE h2 session. Node's default `maxSessionMemory` is 10 MB —
295
+ // cross it and the session RSTs its streams / sends GOAWAY, which the
296
+ // browser reports as a BURST of ERR_HTTP2_PROTOCOL_ERROR (every
297
+ // in-flight module fails at the same instant). Raise the ceiling and
298
+ // the per-stream/peer limits to dev-front-door proportions.
299
+ maxSessionMemory: 512,
300
+ settings: { maxConcurrentStreams: 512 },
301
+ peerMaxConcurrentStreams: 512,
302
+ }, (req, res) => handler(req, res))
290
303
  : http.createServer(handler);
291
304
  server.on('upgrade', upgrade);
292
305
  server.on('error', (err) => console.error(`[gateway] port ${port}: ${err.code}`));
306
+ // h2 SESSION resilience: without a `sessionError` handler ANY session-level
307
+ // fault (a client GOAWAY mid-burst, a flood-protection trip, a decode error)
308
+ // is unhandled → Node tears the session down, failing EVERY sibling stream at
309
+ // once. Handle it so ONE bad session logs + dies alone, never throwing and
310
+ // never taking the process (or other clients' sessions) with it. `session`
311
+ // errors are surfaced BOTH per-session and (unhandled) as `sessionError`.
312
+ const isH2 = pem != null;
313
+ if (isH2) {
314
+ const h2 = server;
315
+ h2.on('session', (session) => {
316
+ session.on('error', (err) => {
317
+ const code = err.code ?? err.message;
318
+ console.warn(`[gateway] h2 session error (${code}) — dropping this session only`);
319
+ if (!session.destroyed)
320
+ session.destroy();
321
+ });
322
+ });
323
+ h2.on('sessionError', (err) => {
324
+ console.warn(`[gateway] h2 sessionError: ${err.code ?? err.message}`);
325
+ });
326
+ }
293
327
  await new Promise((resolve) => {
294
328
  server.listen(port, () => {
295
329
  const actual = server.address()?.port ?? port;
@@ -11,7 +11,8 @@ export interface GatewayExecutorSchema {
11
11
  dashboard?: boolean;
12
12
  /** Spawn the single MCP server as a gateway-managed child when a `lens:true` app exists. Default true. */
13
13
  mcp?: boolean;
14
- /** Port the managed dashboard listens on (proxied behind lens.<baseDomain>). Default 4321. */
14
+ /** Port the managed dashboard listens on (proxied behind lens.<baseDomain>).
15
+ * Default: the workspace scope's dashboard port (`.lensmcp/config.json` `ports.dashboard`), else 4321. */
15
16
  dashboardPort?: number;
16
17
  /** Override the dashboard hostname. Default lens.<baseDomain>. */
17
18
  lensHost?: string;
@@ -37,8 +37,7 @@
37
37
  },
38
38
  "dashboardPort": {
39
39
  "type": "number",
40
- "default": 4321,
41
- "description": "Port the gateway-managed dashboard listens on (proxied behind lens.<baseDomain>). Default 4321."
40
+ "description": "Port the gateway-managed dashboard listens on (proxied behind lens.<baseDomain>). When omitted, the workspace's `.lensmcp/config.json` `ports.dashboard` wins (falling back to 4321) — a schema default here would OVERRIDE that per-workspace port for every workspace, colliding multi-workspace dashboards onto one port."
42
41
  },
43
42
  "lensHost": {
44
43
  "type": "string",
@@ -50,4 +50,8 @@ export interface ServeExecutorSchema {
50
50
  gateway?: GatewayConfig;
51
51
  /** Plain-http listener straight to the children - service-to-service calls, no gateway middleware. */
52
52
  internalPort?: number;
53
+ /** Inspector base port: pod slot N -> debugPort+N, workers -> debugPort+20+i. Env fallback: LENSMCP_DEBUG_PORT. */
54
+ debugPort?: number;
55
+ /** Tee pod/worker logs (ANSI-stripped) to this file for IDE tailing (WebStorm Logs tab). Env fallback: LENSMCP_LOG_FILE. */
56
+ logFile?: string;
53
57
  }
@@ -186,6 +186,14 @@
186
186
  "internalPort": {
187
187
  "type": "number",
188
188
  "description": "Plain-http listener that proxies straight to this service children — no gateway routes, no gateway middleware (JWT). For service-to-service calls, mirroring cluster-internal networking."
189
+ },
190
+ "debugPort": {
191
+ "type": "number",
192
+ "description": "Open a Node inspector in every pod (127.0.0.1 only): pod at slot N listens on debugPort+N, workers on debugPort+20+i. The printed 'Debugger listening on ws://…' line is click-to-attach in WebStorm/VS Code run consoles; ports are stable across crash-respawns, and hot reloads swap in-process so an attached debug session survives rebuilds. Also settable via the LENSMCP_DEBUG_PORT env var (this option wins). Give each service a distinct base. Without it, POST /webpack/debug on the running devserver opens inspectors on demand — no restart."
193
+ },
194
+ "logFile": {
195
+ "type": "string",
196
+ "description": "Tee every pod/worker log line (ANSI-stripped) to this file, relative to workspace root (e.g. '.lensmcp/logs/my-service.log'). Point an IDE at it — WebStorm: Run/Debug configuration → Logs tab — to see live service logs next to the debugger. Truncated once per serve run. Also settable via the LENSMCP_LOG_FILE env var (this option wins)."
189
197
  }
190
198
  },
191
199
  "required": [
@@ -1 +1 @@
1
- {"version":3,"file":"serve.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/serve/serve.impl.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAKpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAC5C,CAAC;CACH;AAED;;;;;;;;GAQG;AACH,iBAAgB,aAAa,CAC3B,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,cAAc,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CAgQxD;AAED,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"serve.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/serve/serve.impl.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAKpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,CAAC;KAC5C,CAAC;CACH;AAED;;;;;;;;GAQG;AACH,iBAAgB,aAAa,CAC3B,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,cAAc,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC,CA6RxD;AAED,eAAe,aAAa,CAAC"}
@@ -51,6 +51,21 @@ async function* serveExecutor(options, context) {
51
51
  serviceName = options.serviceName;
52
52
  }
53
53
  const servePrefix = options.servePrefix ?? '';
54
+ // Debug base port: pod #slot opens its inspector on debugPort+slot (workers on
55
+ // debugPort+20+i). Option wins over the LENSMCP_DEBUG_PORT env var so per-project
56
+ // config can give each service a distinct base in cluster mode. Even without it,
57
+ // POST /webpack/debug on the running devserver opens inspectors on demand.
58
+ const debugPort = options.debugPort ?? (Number(process.env.LENSMCP_DEBUG_PORT) || undefined);
59
+ // Log tee: every pod/worker line is also appended (ANSI-stripped) to this file so
60
+ // an IDE can tail it — WebStorm: Run/Debug configuration → Logs tab. Truncated
61
+ // here once per `nx serve` run; devserver crash-respawns keep appending.
62
+ const logFile = options.logFile
63
+ ? path.resolve(workspaceRoot, options.logFile)
64
+ : process.env.LENSMCP_LOG_FILE || undefined;
65
+ if (logFile) {
66
+ fs.mkdirSync(path.dirname(logFile), { recursive: true });
67
+ fs.writeFileSync(logFile, '');
68
+ }
54
69
  // Resolve gateway middleware
55
70
  let gatewayMiddlewarePath;
56
71
  let gatewayConfigJson;
@@ -144,6 +159,8 @@ async function* serveExecutor(options, context) {
144
159
  ...(options.gateway?.https && { GATEWAY_HTTPS: "1" }),
145
160
  ...(options.gateway?.extraPorts?.length && { GATEWAY_EXTRA_PORTS: JSON.stringify(options.gateway.extraPorts) }),
146
161
  ...(options.internalPort && { INTERNAL_PORT: String(options.internalPort) }),
162
+ ...(debugPort && { LENSMCP_DEBUG_PORT: String(debugPort) }),
163
+ ...(logFile && { LENSMCP_LOG_FILE: logFile }),
147
164
  ...((options.workers?.length) && { WORKERS: JSON.stringify(options.workers.map(w => w.name)) }),
148
165
  },
149
166
  stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
@@ -162,6 +179,15 @@ async function* serveExecutor(options, context) {
162
179
  }, 1500).unref?.();
163
180
  });
164
181
  console.log(`[serve] Devserver started on port ${port} (service: ${serviceName}${servePrefix ? `, prefix: /${servePrefix}` : ''})`);
182
+ if (debugPort) {
183
+ console.log(`[serve] Debug: pod inspectors on 127.0.0.1:${debugPort}+ — click the "Debugger listening" link in the console, or attach WebStorm (Attach to Node.js/Chrome) to port ${debugPort}`);
184
+ }
185
+ else {
186
+ console.log(`[serve] Debug on demand: POST http://localhost:${port}/webpack/debug (optionally ?port=<base>) opens pod inspectors without a restart`);
187
+ }
188
+ if (logFile) {
189
+ console.log(`[serve] Logs teed to ${logFile} — tail it in WebStorm via Run/Debug configuration → Logs tab`);
190
+ }
165
191
  }
166
192
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
167
193
  function _notifyDevServerReload() {
@@ -21,10 +21,12 @@ interface ExecutorContext {
21
21
  * `trust` executor — one idempotent command that makes the HTTPS dev gateway
22
22
  * green in real browsers:
23
23
  *
24
- * 1. mints the local dev CA if missing (same CA the gateway serves),
25
- * 2. adds it ONCE to the macOS System keychain (`sudo security
26
- * add-trusted-cert`) every leaf rotation / new hostname then chains
27
- * to it with no interstitial,
24
+ * 1. resolves the MACHINE-LEVEL dev CA (`~/.lensmcp/ca`) minting it if
25
+ * missing, or promoting this workspace's legacy per-workspace CA so
26
+ * existing keychain trust carries over,
27
+ * 2. adds it ONCE PER MACHINE to the macOS System keychain (`sudo security
28
+ * add-trusted-cert`) — every leaf rotation / new hostname / new workspace
29
+ * then chains to it with no interstitial,
28
30
  * 3. writes BOTH `/etc/hosts` families (`127.0.0.1` + `::1`) for every
29
31
  * gateway route hostname — the missing `::1` line is what sends `.local`
30
32
  * AAAA lookups to Bonjour/mDNS and costs ~5s per resolution,
@@ -1 +1 @@
1
- {"version":3,"file":"trust.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/trust/trust.impl.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAEpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CACd,MAAM,EACN;YACE,IAAI,EAAE,MAAM,CAAC;YACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;gBAAE,OAAO,CAAC,EAAE;oBAAE,OAAO,CAAC,EAAE;wBAAE,MAAM,CAAC,EAAE,KAAK,CAAC;4BAAE,IAAI,CAAC,EAAE,MAAM,CAAA;yBAAE,CAAC,CAAA;qBAAE,CAAA;iBAAE,CAAA;aAAE,CAAC,CAAC;SAC7F,CACF,CAAC;KACH,CAAC;CACH;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAA8B,aAAa,CACzC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAqF/B;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,EAAE,OAAO,EAAE,eAAe,GAAG,MAAM,EAAE,CAmC7F"}
1
+ {"version":3,"file":"trust.impl.d.ts","sourceRoot":"","sources":["../../../../../libs/cluster/src/executors/trust/trust.impl.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAEpD,UAAU,eAAe;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,sBAAsB,CAAC,EAAE;QACvB,QAAQ,EAAE,MAAM,CACd,MAAM,EACN;YACE,IAAI,EAAE,MAAM,CAAC;YACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE;gBAAE,OAAO,CAAC,EAAE;oBAAE,OAAO,CAAC,EAAE;wBAAE,MAAM,CAAC,EAAE,KAAK,CAAC;4BAAE,IAAI,CAAC,EAAE,MAAM,CAAA;yBAAE,CAAC,CAAA;qBAAE,CAAA;iBAAE,CAAA;aAAE,CAAC,CAAC;SAC7F,CACF,CAAC;KACH,CAAC;CACH;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAA8B,aAAa,CACzC,OAAO,EAAE,mBAAmB,EAC5B,OAAO,EAAE,eAAe,GACvB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CA+F/B;AAeD,wBAAgB,YAAY,CAAC,OAAO,EAAE,mBAAmB,EAAE,OAAO,EAAE,eAAe,GAAG,MAAM,EAAE,CAmC7F"}
@@ -11,10 +11,12 @@ const basic_ssl_1 = require("../../basic-ssl");
11
11
  * `trust` executor — one idempotent command that makes the HTTPS dev gateway
12
12
  * green in real browsers:
13
13
  *
14
- * 1. mints the local dev CA if missing (same CA the gateway serves),
15
- * 2. adds it ONCE to the macOS System keychain (`sudo security
16
- * add-trusted-cert`) every leaf rotation / new hostname then chains
17
- * to it with no interstitial,
14
+ * 1. resolves the MACHINE-LEVEL dev CA (`~/.lensmcp/ca`) minting it if
15
+ * missing, or promoting this workspace's legacy per-workspace CA so
16
+ * existing keychain trust carries over,
17
+ * 2. adds it ONCE PER MACHINE to the macOS System keychain (`sudo security
18
+ * add-trusted-cert`) — every leaf rotation / new hostname / new workspace
19
+ * then chains to it with no interstitial,
18
20
  * 3. writes BOTH `/etc/hosts` families (`127.0.0.1` + `::1`) for every
19
21
  * gateway route hostname — the missing `::1` line is what sends `.local`
20
22
  * AAAA lookups to Bonjour/mDNS and costs ~5s per resolution,
@@ -25,6 +27,8 @@ const basic_ssl_1 = require("../../basic-ssl");
25
27
  * Run it from a terminal: sudo prompts for your password inline.
26
28
  */
27
29
  async function trustExecutor(options, context) {
30
+ // The workspace cert-cache dir is only the PROMOTION SOURCE now: ensureCaSync resolves the machine-level
31
+ // CA (~/.lensmcp/ca), adopting a pre-existing per-workspace CA from here so its keychain trust survives.
28
32
  const cacheDir = path.join(context.root, 'node_modules', '.cache', 'davnx-webpack');
29
33
  const caPath = (0, basic_ssl_1.ensureCaSync)(cacheDir);
30
34
  // HOSTS-ONLY mode (set by the CLI when this workspace is a GUEST of a shared daemon): the daemon serves
@@ -44,10 +48,14 @@ async function trustExecutor(options, context) {
44
48
  return { success: true };
45
49
  }
46
50
  // --- 1) CA into the System keychain (skip when already there, or in hosts-only/guest mode) -----------
51
+ // "Already there" must match by FINGERPRINT, not CN: every workspace mints its own CA under the same
52
+ // "lensmcp local dev CA" name, so a same-named cert from another workspace (or a pre-rotation stale one)
53
+ // would otherwise satisfy a name lookup while the CA this gateway actually serves stays untrusted —
54
+ // the browser interstitial with a green-looking trust log.
55
+ const fingerprint = caSha256Fingerprint(caPath);
47
56
  const present = hostsOnly ||
48
- (0, node_child_process_1.spawnSync)('security', ['find-certificate', '-c', 'lensmcp local dev CA', '/Library/Keychains/System.keychain'], {
49
- stdio: 'ignore',
50
- }).status === 0;
57
+ (fingerprint !== undefined &&
58
+ (0, node_child_process_1.spawnSync)('security', ['find-certificate', '-a', '-c', 'lensmcp local dev CA', '-Z', '/Library/Keychains/System.keychain'], { encoding: 'utf8' }).stdout?.includes(fingerprint) === true);
51
59
  if (hostsOnly) {
52
60
  console.log('[trust] hosts-only: a shared daemon serves TLS with its own CA — skipping the keychain step.');
53
61
  }
@@ -105,6 +113,19 @@ async function trustExecutor(options, context) {
105
113
  }
106
114
  return { success: true };
107
115
  }
116
+ /**
117
+ * SHA-256 of the CA file as uppercase hex without separators — the exact format
118
+ * `security find-certificate -Z` prints ("SHA-256 hash: <hex>"), so the caller
119
+ * can substring-match the keychain listing. `undefined` when openssl can't read
120
+ * the file (caller then falls through to add-trusted-cert, which is idempotent).
121
+ */
122
+ function caSha256Fingerprint(caPath) {
123
+ const r = (0, node_child_process_1.spawnSync)('openssl', ['x509', '-in', caPath, '-noout', '-fingerprint', '-sha256'], { encoding: 'utf8' });
124
+ if (r.status !== 0 || !r.stdout)
125
+ return undefined;
126
+ const hex = r.stdout.split('=')[1]?.replaceAll(':', '').trim().toUpperCase();
127
+ return hex && /^[0-9A-F]{64}$/.test(hex) ? hex : undefined;
128
+ }
108
129
  function collectHosts(options, context) {
109
130
  const found = [];
110
131
  // 1) This project's embedded gateway routes (single-service setups).
@@ -1 +1 @@
1
- {"version":3,"file":"main.devserver.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/main.devserver.ts"],"names":[],"mappings":"AA8EA,MAAM,WAAW,YAAY;IAC3B,6FAA6F;IAC7F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB"}
1
+ {"version":3,"file":"main.devserver.d.ts","sourceRoot":"","sources":["../../../libs/cluster/src/main.devserver.ts"],"names":[],"mappings":"AA+EA,MAAM,WAAW,YAAY;IAC3B,6FAA6F;IAC7F,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wFAAwF;IACxF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB"}
package/main.devserver.js CHANGED
@@ -25,6 +25,7 @@ const node_child_process_1 = require("node:child_process");
25
25
  const http = tslib_1.__importStar(require("node:http"));
26
26
  const readline = tslib_1.__importStar(require("node:readline"));
27
27
  const inspector = tslib_1.__importStar(require("node:inspector"));
28
+ const util = tslib_1.__importStar(require("node:util"));
28
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
30
  const httpProxy = require('http-proxy');
30
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -150,6 +151,31 @@ function debounce(fn, ms) {
150
151
  // CHILD MODE (APP_RUNNER=1) — loads bundle, bootstraps NestJS app, listens on socket
151
152
  // ===================================================================================
152
153
  if (process.env.APP_RUNNER === '1') {
154
+ // Debug: open the inspector on the port the parent allocated (serve `debugPort`
155
+ // option / LENSMCP_DEBUG_PORT base + pod slot) and report the ws URL up via IPC.
156
+ // inspector.open() itself prints the native "Debugger listening on ws://…" line,
157
+ // which WebStorm/VS Code run consoles turn into a one-click attach link. The
158
+ // matching inspector.close() on shutdown already lives below.
159
+ const openInspector = (port) => {
160
+ if (!inspector.url()) {
161
+ try {
162
+ inspector.open(port, '127.0.0.1', false);
163
+ }
164
+ catch (err) {
165
+ console.warn(`[child] inspector.open(${port}) failed (port taken by another devserver?) — falling back to a random port:`, err.message);
166
+ try {
167
+ inspector.open(0, '127.0.0.1', false);
168
+ }
169
+ catch { /* no inspector at all — pod still serves, just undebuggable */ }
170
+ }
171
+ }
172
+ const url = inspector.url();
173
+ if (url && process.send)
174
+ process.send({ type: 'inspector-url', url });
175
+ };
176
+ const CHILD_DEBUG_PORT = Number(process.env.CHILD_DEBUG_PORT || '');
177
+ if (Number.isInteger(CHILD_DEBUG_PORT) && CHILD_DEBUG_PORT > 1024)
178
+ openInspector(CHILD_DEBUG_PORT);
153
179
  // Zero-touch instrumentation: load BEFORE the app bundle so the require
154
180
  // hook sees pg/ioredis/bullmq/@nestjs/core on their first load, builtins
155
181
  // (fs/net/exec) are tapped, NestFactory.create grafts the lens module, and
@@ -273,6 +299,13 @@ if (process.env.APP_RUNNER === '1') {
273
299
  process.on('message', async (msg) => {
274
300
  if (!msg || typeof msg !== 'object')
275
301
  return;
302
+ // On-demand debugging (POST /webpack/debug): open the inspector NOW, in the
303
+ // running pod — no restart, app state intact. Already open → just re-report.
304
+ if (msg.type === 'debug-open') {
305
+ const port = Number(msg.port);
306
+ openInspector(Number.isInteger(port) && port > 1024 ? port : 0);
307
+ return;
308
+ }
276
309
  if (msg.type === 'reload') {
277
310
  try {
278
311
  await swapNow();
@@ -323,6 +356,67 @@ else {
323
356
  ];
324
357
  const RESET = '\x1b[0m';
325
358
  const CHILD_COUNT = Math.max(1, Number(process.env.CHILD_COUNT || 1));
359
+ // Debug base port (serve `debugPort` option / LENSMCP_DEBUG_PORT env): pod at
360
+ // slot S opens its inspector on base+S, workers on base+WORKER_DEBUG_PORT_OFFSET+i.
361
+ // Unset/0 → no inspectors at boot; POST /webpack/debug can still turn debugging on
362
+ // at runtime (mutating this base — sticky, so respawned pods stay debuggable).
363
+ // Each service needs its OWN base — two services sharing one base would race for
364
+ // the same ports (loser warns and runs undebuggable).
365
+ let debugBasePort = (() => {
366
+ const n = Number(process.env.LENSMCP_DEBUG_PORT || '');
367
+ return Number.isInteger(n) && n > 1024 ? n : 0;
368
+ })();
369
+ const WORKER_DEBUG_PORT_OFFSET = 20;
370
+ // Log tee (serve `logFile` option / LENSMCP_LOG_FILE env): every pod/worker line
371
+ // is ALSO appended, ANSI-stripped, to this file — so an IDE can tail it (WebStorm:
372
+ // run config → Logs tab) while the service itself runs anywhere (terminal, gateway).
373
+ const LOG_FILE = process.env.LENSMCP_LOG_FILE || '';
374
+ const logFileStream = LOG_FILE
375
+ ? (() => {
376
+ try {
377
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
378
+ return fs.createWriteStream(LOG_FILE, { flags: 'a' });
379
+ }
380
+ catch (err) {
381
+ console.warn(`[parent] cannot open log file ${LOG_FILE}:`, err.message);
382
+ return null;
383
+ }
384
+ })()
385
+ : null;
386
+ // eslint-disable-next-line no-control-regex
387
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
388
+ // ---------- Log hub ----------
389
+ // Every pod/worker line (and the parent's own lifecycle lines) fans out to:
390
+ // • the terminal (unchanged),
391
+ // • a ring buffer + live HTTP subscribers — GET /webpack/logs, which is what
392
+ // `lensmcp logs <service>` attaches to from any terminal (human or agent),
393
+ // • the optional LENSMCP_LOG_FILE tee (ANSI-stripped) for IDE Logs tabs.
394
+ const LOG_BUFFER_MAX = 2000;
395
+ const logBuffer = [];
396
+ const logSubscribers = new Set();
397
+ function emitLogLine(text) {
398
+ logBuffer.push(text);
399
+ if (logBuffer.length > LOG_BUFFER_MAX)
400
+ logBuffer.splice(0, logBuffer.length - LOG_BUFFER_MAX);
401
+ logFileStream?.write(text.replace(ANSI_RE, ''));
402
+ for (const sub of logSubscribers) {
403
+ try {
404
+ sub.res.write(sub.plain ? text.replace(ANSI_RE, '') : text);
405
+ }
406
+ catch {
407
+ logSubscribers.delete(sub);
408
+ }
409
+ }
410
+ }
411
+ // The parent's own console lines (ready/respawn/Debugger listening/proxy errors)
412
+ // belong in the stream too — mirror console.* into the hub.
413
+ for (const level of ['log', 'info', 'warn', 'error']) {
414
+ const orig = console[level].bind(console);
415
+ console[level] = (...args) => {
416
+ orig(...args);
417
+ emitLogLine(args.map((a) => (typeof a === 'string' ? a : util.inspect(a))).join(' ') + '\n');
418
+ };
419
+ }
326
420
  const colorFor = (id) => {
327
421
  return COLORS[(id - 1) % COLORS.length];
328
422
  };
@@ -361,6 +455,7 @@ else {
361
455
  const dest = kind === 'stdout' ? process.stdout : process.stderr;
362
456
  const log = isJsonLog(line) ? prettyLog(line) : `${line}\n`;
363
457
  dest.write(`${tag}${log}`);
458
+ emitLogLine(`${tag}${log}`);
364
459
  };
365
460
  const attach = (stream, kind) => {
366
461
  if (!stream)
@@ -381,6 +476,14 @@ else {
381
476
  if (!publicPort)
382
477
  publicPort = 9090;
383
478
  const children = [];
479
+ const usedDebugSlots = new Set();
480
+ function allocDebugSlot() {
481
+ let slot = 0;
482
+ while (usedDebugSlots.has(slot))
483
+ slot++;
484
+ usedDebugSlots.add(slot);
485
+ return slot;
486
+ }
384
487
  let nextId = 1;
385
488
  let rrIndex = 0;
386
489
  let shuttingDown = false; // set by the parent's signal handler
@@ -406,17 +509,21 @@ else {
406
509
  if (__filename.endsWith('.ts')) {
407
510
  childExecArgv.unshift('--require', '@swc-node/register');
408
511
  }
512
+ // Stable inspector port: lowest free slot → debugBasePort+slot. Slots are
513
+ // released when a pod exits, so a crash-respawned pod reclaims the SAME port —
514
+ // an IDE "attach to 127.0.0.1:<port>" config survives respawns unedited.
515
+ const debugSlot = debugBasePort ? allocDebugSlot() : -1;
409
516
  const proc = (0, node_child_process_1.fork)(__filename, {
410
517
  env: {
411
518
  ...process.env,
412
519
  APP_RUNNER: '1',
413
520
  CHILD_SOCK_PATH: sockPath,
414
- CHILD_DEBUG_PORT: CHILD_COUNT == 1 ? '1' : '0',
521
+ ...(debugSlot >= 0 && { CHILD_DEBUG_PORT: String(debugBasePort + debugSlot) }),
415
522
  },
416
523
  stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
417
524
  execArgv: childExecArgv,
418
525
  });
419
- const info = { id, proc, healthy: false, lastError: null };
526
+ const info = { id, proc, healthy: false, lastError: null, debugSlot };
420
527
  proc.on('message', (msg) => {
421
528
  if (!msg || typeof msg !== 'object')
422
529
  return;
@@ -427,7 +534,10 @@ else {
427
534
  console.log(`[parent] Child#${info.id} ready on socket ${info.socketPath}`);
428
535
  }
429
536
  else if (m.type === 'inspector-url') {
430
- console.log(`[parent] Child#${info.id} inspector: ${m.url}`);
537
+ info.inspectorUrl = String(m.url);
538
+ // "Debugger listening on ws://…" is the exact phrase IDE run consoles
539
+ // (WebStorm, VS Code) detect and render as a click-to-attach link.
540
+ console.log(`[parent] Child#${info.id} Debugger listening on ${m.url}`);
431
541
  }
432
542
  else if (m.type === 'reloaded') {
433
543
  info.socketPath = String(m.socketPath);
@@ -446,6 +556,10 @@ else {
446
556
  }
447
557
  });
448
558
  proc.on('exit', (code, signal) => {
559
+ if (info.debugSlot >= 0) {
560
+ usedDebugSlots.delete(info.debugSlot);
561
+ info.debugSlot = -1;
562
+ }
449
563
  const clean = signal === 'SIGINT' || signal === 'SIGTERM' || code === 0;
450
564
  if (clean) {
451
565
  console.log(`[parent] Child#${info.id} exited cleanly (code=${code}, signal=${signal ?? 'none'})`);
@@ -532,12 +646,21 @@ else {
532
646
  workerExecArgv.push('--require', require.resolve('@lensmcp/node-instrumentation/register'));
533
647
  }
534
648
  catch { /* instrumentation package absent — workers run untapped */ }
649
+ // Workers get inspectors too, offset above the pod range: pods sit on
650
+ // base+slot, workers on base+20+i. The index comes from WORKER_NAMES order,
651
+ // so a worker keeps its port across restartWorkers(). With debugging off,
652
+ // --inspect-port only PRESETS the port (no listener) — SIGUSR1 from
653
+ // POST /webpack/debug activates it later without restarting the worker.
654
+ const workerDebugPort = (debugBasePort || 9229) + WORKER_DEBUG_PORT_OFFSET + Math.max(0, WORKER_NAMES.indexOf(name));
655
+ workerExecArgv.push(debugBasePort
656
+ ? `--inspect=127.0.0.1:${workerDebugPort}`
657
+ : `--inspect-port=127.0.0.1:${workerDebugPort}`);
535
658
  const proc = (0, node_child_process_1.fork)(workerBundle, {
536
659
  env: process.env,
537
660
  stdio: ['inherit', 'pipe', 'pipe', 'ipc'],
538
661
  execArgv: workerExecArgv,
539
662
  });
540
- const info = { name, proc };
663
+ const info = { name, proc, debugPort: workerDebugPort };
541
664
  workerProcesses.push(info);
542
665
  wireProcLogging(proc, `\x1b[35m[worker:${name}] \x1b[0m`);
543
666
  proc.on('exit', (code, signal) => {
@@ -602,6 +725,66 @@ else {
602
725
  });
603
726
  return;
604
727
  }
728
+ // Admin endpoint: the live log stream — history, then follow (chunked text).
729
+ // `lensmcp logs <service>` attaches a terminal here (human), or takes a
730
+ // one-shot snapshot with ?follow=0&tail=N (agent). ?plain=1 strips ANSI.
731
+ if (req.method === 'GET' && req.url?.startsWith('/webpack/logs')) {
732
+ const q = new URL(req.url, 'http://localhost').searchParams;
733
+ const tail = Math.min(Math.max(Number(q.get('tail') ?? '100') || 0, 0), LOG_BUFFER_MAX);
734
+ const follow = q.get('follow') !== '0';
735
+ const plain = q.get('plain') === '1';
736
+ res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-cache' });
737
+ const history = tail === 0 ? '' : logBuffer.slice(-tail).join('');
738
+ res.write(plain ? history.replace(ANSI_RE, '') : history);
739
+ if (!follow) {
740
+ res.end();
741
+ return;
742
+ }
743
+ const sub = { res, plain };
744
+ logSubscribers.add(sub);
745
+ req.on('close', () => logSubscribers.delete(sub));
746
+ return;
747
+ }
748
+ // Admin endpoint: attach a debugger to the RUNNING service — no restart, app
749
+ // state intact. POST /webpack/debug[?port=9339] opens (or re-reports) every
750
+ // pod's inspector and returns the ws URLs; the base becomes sticky so pods
751
+ // spawned later (respawn, scale) come up debuggable too. Workers are nudged
752
+ // via SIGUSR1 (their port was preset with --inspect-port at spawn).
753
+ if (req.method === 'POST' && req.url?.startsWith('/webpack/debug')) {
754
+ const requested = Number(new URL(req.url, 'http://localhost').searchParams.get('port') || '');
755
+ if (Number.isInteger(requested) && requested > 1024)
756
+ debugBasePort = requested;
757
+ if (!debugBasePort)
758
+ debugBasePort = 9229;
759
+ const targets = children.filter((c) => c.proc.exitCode === null && c.proc.signalCode === null);
760
+ for (const c of targets) {
761
+ if (c.debugSlot < 0)
762
+ c.debugSlot = allocDebugSlot();
763
+ try {
764
+ c.proc.send?.({ type: 'debug-open', port: debugBasePort + c.debugSlot });
765
+ }
766
+ catch { /* pod died mid-request */ }
767
+ }
768
+ for (const w of workerProcesses) {
769
+ try {
770
+ w.proc.kill('SIGUSR1');
771
+ }
772
+ catch { /* worker gone */ }
773
+ }
774
+ // inspector-url replies arrive async over IPC — wait briefly, then report
775
+ const deadline = Date.now() + 2000;
776
+ while (Date.now() < deadline && targets.some((c) => !c.inspectorUrl)) {
777
+ await new Promise((r) => setTimeout(r, 50));
778
+ }
779
+ res.writeHead(200, { 'content-type': 'application/json' });
780
+ res.end(JSON.stringify({
781
+ ok: true,
782
+ basePort: debugBasePort,
783
+ pods: targets.map((c) => ({ id: c.id, inspectorUrl: c.inspectorUrl ?? null })),
784
+ workers: workerProcesses.map((w) => ({ name: w.name, port: w.debugPort })),
785
+ }, null, 2) + '\n');
786
+ return;
787
+ }
605
788
  // Front-gateway routes: host/prefix match → external target, or fall
606
789
  // through to this service's own children when the route has no target.
607
790
  const route = matchGatewayRoute(req);
@@ -675,7 +858,7 @@ else {
675
858
  const certDomains = GATEWAY_ROUTES.map((r) => r.host).filter((h) => !!h);
676
859
  const certCacheDir = path.join(process.cwd(), 'node_modules', '.cache', 'davnx-webpack');
677
860
  gatewayPem = basicSsl.getCertificateSync(certCacheDir, SERVICE_NAME || 'lensmcp.dev', certDomains);
678
- gatewayCaPath = basicSsl.caCertPath(certCacheDir);
861
+ gatewayCaPath = basicSsl.caCertPath(); // machine-level (~/.lensmcp/ca)
679
862
  // eslint-disable-next-line @typescript-eslint/no-require-imports
680
863
  const httpsMod = require('node:https');
681
864
  server = httpsMod.createServer({ key: gatewayPem, cert: gatewayPem }, requestHandler);
@@ -819,10 +1002,24 @@ else {
819
1002
  });
820
1003
  extraServers.push(extra);
821
1004
  }
1005
+ // Control socket: the SAME admin surface (logs/debug/reload/scale), reachable
1006
+ // WITHOUT knowing the public port or scheme. `lensmcp logs <service>` finds it
1007
+ // by deriving $TMPDIR[/<wsKey>]/<service>-devserver/parent.sock — the exact
1008
+ // derivation the gateway uses for the pod pool dir. Plain HTTP: it's a
1009
+ // per-user unix socket, TLS adds nothing.
1010
+ const controlSockPath = path.join(SOCK_DIR, 'parent.sock');
1011
+ cleanupSock(controlSockPath); // stale socket from a crashed previous parent
1012
+ const controlServer = http.createServer(requestHandler);
1013
+ controlServer.on('error', (err) => {
1014
+ console.warn(`[parent] control socket unavailable (${err.code}) — lensmcp logs/debug attach disabled.`);
1015
+ });
1016
+ controlServer.listen(controlSockPath);
1017
+ extraServers.push(controlServer);
822
1018
  // Graceful shutdown of parent (children get SIGTERM)
823
1019
  const shutdown = async (sig) => {
824
1020
  shuttingDown = true; // stop crash-respawn from fighting the teardown
825
1021
  console.log(`[${sig}] [parent] shutting down…`);
1022
+ cleanupSock(controlSockPath);
826
1023
  for (const extra of extraServers) {
827
1024
  try {
828
1025
  extra.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.16.23",
3
+ "version": "1.16.25",
4
4
  "description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@lensmcp/node-instrumentation": "1.16.23",
69
- "@lensmcp/nx-plugin": "1.16.23",
68
+ "@lensmcp/node-instrumentation": "1.16.25",
69
+ "@lensmcp/nx-plugin": "1.16.25",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",