starmux 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/LICENSE +1 -1
  3. package/README.md +20 -28
  4. package/dist/src/access-control.js +1 -2
  5. package/dist/src/access-dashboard.js +2 -3
  6. package/dist/src/account-access-policy.js +54 -0
  7. package/dist/src/authorized-workspace-roots.js +0 -1
  8. package/dist/src/batch-read-policy.js +30 -0
  9. package/dist/src/cli/instances.js +27 -187
  10. package/dist/src/cli/oauth.js +0 -1
  11. package/dist/src/cli/starmux-status.js +25 -18
  12. package/dist/src/cli/starmux-supervisor.js +0 -1
  13. package/dist/src/cli/starmux.js +47 -37
  14. package/dist/src/config-schemas.js +1 -10
  15. package/dist/src/configured-mcp-upstreams.js +0 -1
  16. package/dist/src/contracts.js +0 -1
  17. package/dist/src/current-permission-probe.js +0 -1
  18. package/dist/src/dc-workspace-http.js +57 -33
  19. package/dist/src/deny-dashboard.js +2 -3
  20. package/dist/src/desktop-commander-facade.js +4 -4
  21. package/dist/src/explicit-deny-rules.js +2 -3
  22. package/dist/src/global-core-control.js +108 -56
  23. package/dist/src/home-write-sandbox.js +0 -1
  24. package/dist/src/http-host-policy.js +20 -0
  25. package/dist/src/instance-control-protocol.js +27 -0
  26. package/dist/src/instance-runtime.js +215 -76
  27. package/dist/src/instance-store.js +28 -22
  28. package/dist/src/instance-worker.js +2 -3
  29. package/dist/src/invocation.js +1 -2
  30. package/dist/src/local-desktop-read-probe.js +5 -88
  31. package/dist/src/managed-workspace-policy.js +38 -4
  32. package/dist/src/mcp-results.js +0 -1
  33. package/dist/src/mcp-upstream-router.js +0 -1
  34. package/dist/src/oauth-keycloak.js +0 -1
  35. package/dist/src/oauth-resource.js +0 -1
  36. package/dist/src/ops-dashboard.js +2 -179
  37. package/dist/src/permissions-dashboard.js +3 -4
  38. package/dist/src/platform.js +6 -4
  39. package/dist/src/privacy-diagnostics.js +0 -1
  40. package/dist/src/private-mcp-activity.js +0 -1
  41. package/dist/src/private-store-lock.js +0 -1
  42. package/dist/src/project-dev-presets.js +0 -1
  43. package/dist/src/project-git-workflow.js +0 -1
  44. package/dist/src/project-workspace-policy.js +10 -6
  45. package/dist/src/runtime-identity.js +0 -14
  46. package/dist/src/runtime-paths.js +0 -3
  47. package/dist/src/tool-dispatch.js +45 -46
  48. package/dist/src/validation.js +0 -1
  49. package/dist/src/workspace-grants.js +1 -2
  50. package/package.json +7 -15
  51. package/scripts/prepare.mjs +1 -1
  52. package/CONTRIBUTING.md +0 -18
  53. package/SECURITY.md +0 -7
  54. package/app/README.md +0 -29
  55. package/dist/src/access-control.js.map +0 -1
  56. package/dist/src/access-dashboard.js.map +0 -1
  57. package/dist/src/authorized-workspace-roots.js.map +0 -1
  58. package/dist/src/cli/access.js +0 -53
  59. package/dist/src/cli/access.js.map +0 -1
  60. package/dist/src/cli/community-remote-revoke.js +0 -32
  61. package/dist/src/cli/community-remote-revoke.js.map +0 -1
  62. package/dist/src/cli/instances.js.map +0 -1
  63. package/dist/src/cli/live-home-settings.js +0 -35
  64. package/dist/src/cli/live-home-settings.js.map +0 -1
  65. package/dist/src/cli/mac-mcp-gateway.js +0 -257
  66. package/dist/src/cli/mac-mcp-gateway.js.map +0 -1
  67. package/dist/src/cli/oauth.js.map +0 -1
  68. package/dist/src/cli/permissions-owner.js +0 -27
  69. package/dist/src/cli/permissions-owner.js.map +0 -1
  70. package/dist/src/cli/service-setup.js +0 -26
  71. package/dist/src/cli/service-setup.js.map +0 -1
  72. package/dist/src/cli/setup-wizard.js +0 -149
  73. package/dist/src/cli/setup-wizard.js.map +0 -1
  74. package/dist/src/cli/starmux-status.js.map +0 -1
  75. package/dist/src/cli/starmux-supervisor.js.map +0 -1
  76. package/dist/src/cli/starmux.js.map +0 -1
  77. package/dist/src/cli/verify-setup.js +0 -53
  78. package/dist/src/cli/verify-setup.js.map +0 -1
  79. package/dist/src/community-remote-revocations.js +0 -135
  80. package/dist/src/community-remote-revocations.js.map +0 -1
  81. package/dist/src/config-schemas.js.map +0 -1
  82. package/dist/src/configured-mcp-upstreams.js.map +0 -1
  83. package/dist/src/contracts.js.map +0 -1
  84. package/dist/src/current-permission-probe.js.map +0 -1
  85. package/dist/src/dc-workspace-http.js.map +0 -1
  86. package/dist/src/deny-dashboard.js.map +0 -1
  87. package/dist/src/desktop-commander-facade.js.map +0 -1
  88. package/dist/src/explicit-deny-rules.js.map +0 -1
  89. package/dist/src/gateway-status-format.js +0 -66
  90. package/dist/src/gateway-status-format.js.map +0 -1
  91. package/dist/src/global-core-control.js.map +0 -1
  92. package/dist/src/home-write-sandbox.js.map +0 -1
  93. package/dist/src/instance-migration.js +0 -138
  94. package/dist/src/instance-migration.js.map +0 -1
  95. package/dist/src/instance-runtime.js.map +0 -1
  96. package/dist/src/instance-store.js.map +0 -1
  97. package/dist/src/instance-worker.js.map +0 -1
  98. package/dist/src/invocation.js.map +0 -1
  99. package/dist/src/live-profile-home-settings.js +0 -80
  100. package/dist/src/live-profile-home-settings.js.map +0 -1
  101. package/dist/src/local-desktop-read-probe.js.map +0 -1
  102. package/dist/src/managed-workspace-policy.js.map +0 -1
  103. package/dist/src/mcp-results.js.map +0 -1
  104. package/dist/src/mcp-upstream-router.js.map +0 -1
  105. package/dist/src/oauth-keycloak.js.map +0 -1
  106. package/dist/src/oauth-resource.js.map +0 -1
  107. package/dist/src/operator-cli.js +0 -559
  108. package/dist/src/operator-cli.js.map +0 -1
  109. package/dist/src/ops-dashboard.js.map +0 -1
  110. package/dist/src/permissions-dashboard.js.map +0 -1
  111. package/dist/src/platform.js.map +0 -1
  112. package/dist/src/privacy-diagnostics.js.map +0 -1
  113. package/dist/src/private-mcp-activity.js.map +0 -1
  114. package/dist/src/private-store-lock.js.map +0 -1
  115. package/dist/src/project-dev-presets.js.map +0 -1
  116. package/dist/src/project-git-workflow.js.map +0 -1
  117. package/dist/src/project-workspace-policy.js.map +0 -1
  118. package/dist/src/read-scope.js +0 -44
  119. package/dist/src/read-scope.js.map +0 -1
  120. package/dist/src/release1-onboarding.js +0 -120
  121. package/dist/src/release1-onboarding.js.map +0 -1
  122. package/dist/src/release1-service.js +0 -131
  123. package/dist/src/release1-service.js.map +0 -1
  124. package/dist/src/remote-auth.js +0 -47
  125. package/dist/src/remote-auth.js.map +0 -1
  126. package/dist/src/runtime-identity.js.map +0 -1
  127. package/dist/src/runtime-paths.js.map +0 -1
  128. package/dist/src/starmux-migration.js +0 -172
  129. package/dist/src/starmux-migration.js.map +0 -1
  130. package/dist/src/tool-dispatch.js.map +0 -1
  131. package/dist/src/validation.js.map +0 -1
  132. package/dist/src/workspace-grants.js.map +0 -1
  133. package/docs/ACCESS_CONTROL.md +0 -51
  134. package/docs/CLIENTS.md +0 -22
  135. package/docs/CONNECT_CHATGPT.md +0 -148
  136. package/docs/INSTALL.md +0 -62
  137. package/docs/INSTANCES.md +0 -83
  138. package/docs/OAUTH.md +0 -116
  139. package/docs/PLATFORMS.md +0 -29
  140. package/docs/README.md +0 -16
  141. package/docs/REMOTE_SETUP.md +0 -280
  142. package/docs/SECURITY.md +0 -15
  143. package/src/dc-workspace-http.mjs +0 -3
  144. package/test/fixtures/p1-fixture.txt +0 -2
@@ -1,6 +1,7 @@
1
1
  import { ownedByUser, createPrivateDirectory, controlRoot, windows } from "./platform.js";
2
2
  import { ownerSchema } from "./config-schemas.js";
3
3
  import { GatewayError, errorCode, isSafeInteger, parseJSON, record } from "./validation.js";
4
+ const CONTROL_VERSION = 1;
4
5
  // One Core / two equal frontends. Owner-only local supervisor control.
5
6
  // The socket is never an MCP/public endpoint, and never signals a discovered PID.
6
7
  import crypto from "node:crypto";
@@ -17,7 +18,7 @@ export function controlPaths(port, root = controlRoot()) {
17
18
  deny("CORE_INVALID_PORT");
18
19
  const lock = path.join(root, `core-${port}.lease`);
19
20
  return { root, lock, recovery: lock + ".recovery",
20
- meta: path.join(lock, "owner.json"), socket: windows ? `\\\\.\\pipe\\mac-mcp-gateway-${crypto.createHash("sha256").update(path.resolve(lock).toLowerCase()).digest("hex").slice(0, 32)}` : path.join(lock, "control.sock") };
21
+ meta: path.join(lock, "owner.json"), socket: windows ? `\\\\.\\pipe\\starmux-${crypto.createHash("sha256").update(path.resolve(lock).toLowerCase()).digest("hex").slice(0, 32)}` : path.join(lock, "control.sock") };
21
22
  }
22
23
  function secureDir(p, create = false) {
23
24
  if (create)
@@ -39,7 +40,10 @@ function readOwner(paths) {
39
40
  const d = fs.readFileSync(paths.meta);
40
41
  if (d.length > 1024)
41
42
  deny("CORE_CONTROL_METADATA_INVALID");
42
- const checked = ownerSchema.safeParse(parseJSON(d.toString("utf8")));
43
+ const metadata = record(parseJSON(d.toString("utf8")), "CORE_CONTROL_METADATA_INVALID");
44
+ if (metadata.version !== CONTROL_VERSION)
45
+ deny("CORE_CONTROL_PROTOCOL_UNSUPPORTED");
46
+ const checked = ownerSchema.safeParse(metadata);
43
47
  if (!checked.success)
44
48
  deny("CORE_CONTROL_METADATA_INVALID");
45
49
  const owner = checked.data;
@@ -71,7 +75,7 @@ async function exchange(paths, owner, method, timeoutMs = 11500) {
71
75
  resolve(result);
72
76
  };
73
77
  client.once("connect", () => client.write(JSON.stringify({
74
- version: 1, ...(windows ? { nonce, proof: proof(owner.token, nonce + ":" + owner.runId + ":" + method) } : { token: owner.token }), runId: owner.runId, method
78
+ version: CONTROL_VERSION, ...(windows ? { nonce, proof: proof(owner.token, nonce + ":" + owner.runId + ":" + method) } : { token: owner.token }), runId: owner.runId, method
75
79
  }) + "\n"));
76
80
  client.on("data", d => {
77
81
  body += d.toString("utf8");
@@ -83,8 +87,10 @@ async function exchange(paths, owner, method, timeoutMs = 11500) {
83
87
  const reply = record(parseJSON(body.split("\n")[0]));
84
88
  if (windows && reply.proof !== proof(owner.token, nonce + ":" + String(reply.result)))
85
89
  throw Error("CORE_CONTROL_REPLY_UNAUTHENTICATED");
86
- if (reply.runId !== owner.runId || !["RUNNING", "STARTING", "STOPPED",
87
- "STOP_FAILED"].includes(String(reply.result)))
90
+ if (reply.version !== CONTROL_VERSION || reply.result === "PROTOCOL_UNSUPPORTED")
91
+ throw Error("CORE_CONTROL_PROTOCOL_UNSUPPORTED");
92
+ if (reply.runId !== owner.runId || !["RUNNING", "STARTING", "STOPPING", "STOPPED",
93
+ "STOP_ACCEPTED", "STOP_FAILED"].includes(String(reply.result)))
88
94
  throw Error("CORE_CONTROL_REPLY_INVALID");
89
95
  finish(null, { result: String(reply.result), runId: owner.runId, launcher: owner.launcher });
90
96
  }
@@ -153,35 +159,70 @@ function cleanStale(paths) {
153
159
  catch { }
154
160
  }
155
161
  }
156
- export async function connectOwner(port, { root } = {}) {
162
+ export function inspectControlOwner(port, { root } = {}) {
157
163
  const paths = controlPaths(port, root);
158
- let owner;
159
164
  try {
160
- owner = readOwner(paths);
165
+ return readOwner(paths);
161
166
  }
162
167
  catch (e) {
163
- if (["ENOENT"].includes(errorCode(e) || ""))
168
+ if (errorCode(e) === "ENOENT" && !fs.existsSync(paths.lock))
164
169
  return null;
165
170
  throw e;
166
171
  }
172
+ }
173
+ export async function connectOwner(port, { root } = {}) {
174
+ const paths = controlPaths(port, root);
175
+ const owner = inspectControlOwner(port, { root });
176
+ if (!owner || !alive(owner.pid))
177
+ return null;
167
178
  const result = await exchange(paths, owner, "status", 1500);
168
179
  return { owner, result };
169
180
  }
170
- export async function globalStop(port, { root } = {}) {
181
+ export async function globalStop(port, { root, timeoutMs = 65000 } = {}) {
182
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
183
+ deny("CORE_STOP_TIMEOUT_INVALID");
171
184
  const paths = controlPaths(port, root);
172
185
  let owner;
173
186
  try {
174
187
  owner = readOwner(paths);
175
188
  }
176
189
  catch (e) {
177
- if (errorCode(e) === "ENOENT")
190
+ if (errorCode(e) === "ENOENT" && !fs.existsSync(paths.lock))
178
191
  return { result: "NO_CONTROL_OWNER" };
179
192
  throw e;
180
193
  }
181
- const result = await exchange(paths, owner, "stop");
182
- if (result.result !== "STOPPED")
194
+ const deadline = Date.now() + timeoutMs;
195
+ const result = await exchange(paths, owner, "stop", Math.min(11500, timeoutMs));
196
+ if (result.result !== "STOP_ACCEPTED")
183
197
  deny("CORE_STOP_UNVERIFIED");
184
- return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid };
198
+ // The acknowledgment only accepts the request. Completion belongs to this
199
+ // lease, not to whatever may bind the same port after it has been released.
200
+ while (Date.now() < deadline) {
201
+ try {
202
+ const current = readOwner(paths);
203
+ if (current.runId !== owner.runId)
204
+ return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid, restarted: true };
205
+ try {
206
+ const state = await exchange(paths, owner, "status", Math.min(250, Math.max(1, deadline - Date.now())));
207
+ if (state.result === "STOP_FAILED")
208
+ deny("CORE_STOP_FAILED");
209
+ }
210
+ catch (e) {
211
+ // Closing the control listener is part of releasing the lease.
212
+ if (!["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(errorCode(e) ?? "") &&
213
+ !["CORE_CONTROL_EOF", "CORE_CONTROL_TIMEOUT"].includes(e instanceof Error ? e.message : ""))
214
+ throw e;
215
+ }
216
+ }
217
+ catch (e) {
218
+ if (errorCode(e) !== "ENOENT")
219
+ throw e;
220
+ if (!fs.existsSync(paths.lock))
221
+ return { result: "STOPPED", launcher: owner.launcher, ownerPid: owner.pid, restarted: false };
222
+ }
223
+ await delay(Math.min(50, Math.max(1, deadline - Date.now())));
224
+ }
225
+ deny("CORE_STOP_TIMEOUT");
185
226
  }
186
227
  export async function acquireControl(port, { root, launcher = "cli" } = {}) {
187
228
  if (!["cli", "app"].includes(launcher))
@@ -226,18 +267,21 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
226
267
  deny("CORE_CONTROL_CLAIM_TIMEOUT");
227
268
  }
228
269
  secureDir(paths.lock);
229
- const owner = { version: 1, port, pid: process.pid, launcher,
270
+ const owner = { version: CONTROL_VERSION, port, pid: process.pid, launcher,
230
271
  token: crypto.randomBytes(24).toString("hex"),
231
272
  runId: crypto.randomBytes(16).toString("hex") };
232
273
  fs.writeFileSync(paths.meta, JSON.stringify(owner), { mode: 0o600, flag: "wx" });
233
- let onStop = null, phase = "STARTING";
274
+ let onStop = null, phase = "STARTING", stopAccepted = false;
234
275
  const clients = new Set();
235
276
  const server = net.createServer(socket => {
236
277
  clients.add(socket);
237
278
  socket.setTimeout(12000, () => socket.destroy());
279
+ socket.on("error", () => { });
238
280
  socket.once("close", () => clients.delete(socket));
239
- let raw = "";
240
- socket.on("data", async (chunk) => {
281
+ let raw = "", handled = false;
282
+ socket.on("data", chunk => {
283
+ if (handled)
284
+ return;
241
285
  raw += chunk.toString("utf8");
242
286
  if (raw.length > 2048) {
243
287
  socket.destroy();
@@ -246,7 +290,7 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
246
290
  if (!raw.includes("\n"))
247
291
  return;
248
292
  const line = raw.split("\n")[0];
249
- raw = "";
293
+ handled = true;
250
294
  let request;
251
295
  try {
252
296
  request = record(parseJSON(line));
@@ -255,24 +299,30 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
255
299
  socket.destroy();
256
300
  return;
257
301
  }
258
- if (request.version !== 1 ||
259
- (windows ? typeof request.nonce !== "string" || request.nonce.length !== 48 || request.proof !== proof(owner.token, request.nonce + ":" + owner.runId + ":" + String(request.method)) : request.token !== owner.token) ||
302
+ if ((windows ? typeof request.nonce !== "string" || request.nonce.length !== 48 || request.proof !== proof(owner.token, request.nonce + ":" + owner.runId + ":" + String(request.method)) : request.token !== owner.token) ||
260
303
  request.runId !== owner.runId) {
261
304
  socket.destroy();
262
305
  return;
263
306
  }
264
- let result = "STOP_FAILED";
265
- if (request.method === "status")
307
+ let result = "STOP_FAILED", startStop = false;
308
+ if (request.version !== CONTROL_VERSION)
309
+ result = "PROTOCOL_UNSUPPORTED";
310
+ else if (request.method === "status")
266
311
  result = phase;
267
312
  else if (request.method === "stop" && onStop) {
268
- try {
269
- result = await onStop();
270
- }
271
- catch {
272
- result = "STOP_FAILED";
313
+ if (phase !== "STOP_FAILED") {
314
+ result = "STOP_ACCEPTED";
315
+ startStop = !stopAccepted;
316
+ stopAccepted = true;
317
+ phase = "STOPPING";
273
318
  }
274
319
  }
275
- socket.end(JSON.stringify({ result, runId: owner.runId, launcher: owner.launcher, ...(windows ? { proof: proof(owner.token, String(request.nonce) + ":" + result) } : {}) }) + "\n");
320
+ socket.end(JSON.stringify({ version: CONTROL_VERSION, result, runId: owner.runId, launcher: owner.launcher, ...(windows ? { proof: proof(owner.token, String(request.nonce) + ":" + result) } : {}) }) + "\n", () => {
321
+ // Never await shutdown on its own control connection: release() waits
322
+ // for these sockets to close. Acceptance and completion are distinct.
323
+ if (startStop)
324
+ void Promise.resolve().then(() => onStop()).catch(() => { phase = "STOP_FAILED"; });
325
+ });
276
326
  });
277
327
  });
278
328
  try {
@@ -301,41 +351,44 @@ export async function acquireControl(port, { root, launcher = "cli" } = {}) {
301
351
  catch { }
302
352
  throw e;
303
353
  }
304
- let closing = false;
354
+ let releasePromise = null;
305
355
  return {
306
356
  owner,
307
357
  markRunning() { phase = "RUNNING"; },
308
358
  markStopped() { phase = "STOPPED"; },
309
359
  onStop(fn) { onStop = fn; },
310
- async release() {
311
- if (closing)
312
- return;
313
- closing = true;
314
- // Response sockets close before the lease disappears.
315
- await new Promise(resolve => {
316
- server.close(resolve);
317
- setTimeout(() => { for (const s of clients)
318
- s.destroy(); }, 1200).unref();
319
- });
320
- if (readOwner(paths).runId !== owner.runId)
321
- deny("CORE_CONTROL_LEASE_CHANGED");
322
- // node:net automatically unlinks a Unix socket on server.close().
323
- // A second unlink is not required and must not abort lease cleanup.
324
- try {
325
- if (!windows)
326
- fs.unlinkSync(paths.socket);
327
- }
328
- catch (e) {
329
- if (errorCode(e) !== "ENOENT")
330
- throw e;
331
- }
332
- fs.unlinkSync(paths.meta);
333
- fs.rmdirSync(paths.lock);
360
+ release() {
361
+ return releasePromise ??= (async () => {
362
+ // Response sockets close before the lease disappears.
363
+ await new Promise((resolve, reject) => {
364
+ const timer = setTimeout(() => { for (const s of clients)
365
+ s.destroy(); }, 1200);
366
+ timer.unref();
367
+ server.close(error => { clearTimeout(timer); if (error)
368
+ reject(error);
369
+ else
370
+ resolve(); });
371
+ });
372
+ if (readOwner(paths).runId !== owner.runId)
373
+ deny("CORE_CONTROL_LEASE_CHANGED");
374
+ // node:net automatically unlinks a Unix socket on server.close().
375
+ // A second unlink is not required and must not abort lease cleanup.
376
+ try {
377
+ if (!windows)
378
+ fs.unlinkSync(paths.socket);
379
+ }
380
+ catch (e) {
381
+ if (errorCode(e) !== "ENOENT")
382
+ throw e;
383
+ }
384
+ fs.unlinkSync(paths.meta);
385
+ fs.rmdirSync(paths.lock);
386
+ })();
334
387
  }
335
388
  };
336
389
  }
337
390
  // Private worker handoff for the setup profile. This is a same-UID
338
- // supervisor identity check, not an OS boundary against the Mac owner.
391
+ // supervisor identity check, not an OS boundary against the local owner.
339
392
  export function verifyInternalSetupWorker({ port, ownerPid, runId, token }) {
340
393
  const paths = controlPaths(port);
341
394
  const owner = readOwner(paths);
@@ -345,4 +398,3 @@ export function verifyInternalSetupWorker({ port, ownerPid, runId, token }) {
345
398
  deny("CORE_SETUP_WORKER_NOT_SUPERVISED");
346
399
  return true;
347
400
  }
348
- //# sourceMappingURL=global-core-control.js.map
@@ -47,4 +47,3 @@ export async function sandboxedDesktopSpawn({ home, nodeBinary, entryArgs, binar
47
47
  args: ["-p", homeWriteProfile(h), nodeBinary, ...entryArgs]
48
48
  };
49
49
  }
50
- //# sourceMappingURL=home-write-sandbox.js.map
@@ -0,0 +1,20 @@
1
+ /** Host is an HTTP authority, not a URL; do not normalize alternate IP spellings or userinfo. */
2
+ export function isAllowedGatewayHost(req, listenerPort, route) {
3
+ // Node keeps only one Host in headers; inspect rawHeaders to reject duplicates too.
4
+ let hostCount = 0;
5
+ for (let i = 0; i < req.rawHeaders.length; i += 2)
6
+ if (req.rawHeaders[i]?.toLowerCase() === 'host')
7
+ hostCount++;
8
+ const host = req.headers.host;
9
+ if (hostCount !== 1 || !host || listenerPort === null)
10
+ return false;
11
+ // Owner pages and discovery retain their existing listener-specific boundary.
12
+ if (route !== '/mcp')
13
+ return host === `127.0.0.1:${listenerPort}` || host === `localhost:${listenerPort}`;
14
+ // SSH forwards TCP bytes unchanged, including the client's local Host port.
15
+ // Keep the hostname allowlist and validate the entire authority, including its port.
16
+ const match = /^(?:127\.0\.0\.1|localhost)(?::([0-9]{1,5}))?$/i.exec(host);
17
+ if (!match || match[0] !== host)
18
+ return false;
19
+ return match[1] === undefined || (Number(match[1]) >= 1 && Number(match[1]) <= 65535);
20
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { instanceConfigSchema } from './instance-store.js';
3
+ // Version the local control contract independently of npm releases. Unknown
4
+ // versions fail explicitly; additional descriptive fields are safe to ignore.
5
+ export const INSTANCE_CONTROL_VERSION = 1;
6
+ const runId = z.string().regex(/^[a-f0-9]{32}$/);
7
+ export const instanceOwnerSchema = z.object({
8
+ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), instanceId: z.string().uuid(), runId,
9
+ pid: z.number().int().min(2), port: z.number().int().min(1024).max(65535),
10
+ token: z.string().regex(/^[a-f0-9]{64}$/), launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable()
11
+ });
12
+ export const activeConfigSchema = z.object({ config: instanceConfigSchema, revision: z.string(), effectiveRevision: z.string(), sourcePath: z.string() });
13
+ export const runtimeStateSchema = z.object({
14
+ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), instanceId: z.string().uuid(), runId, pid: z.number().int().min(2),
15
+ launcher: z.enum(['cli', 'app']), appParentPid: z.number().int().nullable(), executionMode: z.enum(['foreground', 'background']).optional(),
16
+ permissionMode: z.enum(['global', 'workspace']).optional(), phase: z.enum(['starting', 'running', 'stopping', 'stopped', 'error']),
17
+ port: z.number().int().min(1024).max(65535).nullable(), active: activeConfigSchema.nullable(), problem: z.string().nullable(),
18
+ coreProgram: z.string(), buildVersion: z.string()
19
+ });
20
+ export const controlRequestSchema = z.object({ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), method: z.enum(['status', 'stop', 'apply']),
21
+ revision: z.string().nullable(), runId, nonce: z.string().regex(/^[a-f0-9]{48}$/) });
22
+ export const controlReplySchema = z.object({ protocolVersion: z.literal(INSTANCE_CONTROL_VERSION), state: runtimeStateSchema,
23
+ result: z.enum(['STATE', 'STOP_ACCEPTED']), error: z.string().optional() });
24
+ export function checkInstanceProtocol(value) {
25
+ if (!value || typeof value !== 'object' || !('protocolVersion' in value) || value.protocolVersion !== INSTANCE_CONTROL_VERSION)
26
+ throw Error('INSTANCE_CONTROL_PROTOCOL_UNSUPPORTED');
27
+ }