@pi-harness/pi-harness 0.1.23 → 0.1.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.
Files changed (295) hide show
  1. package/README.md +14 -9
  2. package/apps/web/dist/assets/index-Deo3hxmM.js +25 -0
  3. package/apps/web/dist/assets/index-xF647pQJ.css +2 -0
  4. package/apps/web/dist/index.html +2 -2
  5. package/apps/web/package.json +6 -6
  6. package/apps/web/profile/cordis.yml +1 -0
  7. package/apps/web/server-dist/bin.js +27 -2
  8. package/apps/web/server-dist/bin.js.map +1 -1
  9. package/apps/web/src/server/bin.ts +27 -2
  10. package/apps/web/test/bin.test.ts +228 -5
  11. package/node_modules/@pi-harness/api-gateway/dist/index.d.ts.map +1 -1
  12. package/node_modules/@pi-harness/api-gateway/dist/index.js +160 -69
  13. package/node_modules/@pi-harness/api-gateway/dist/index.js.map +1 -1
  14. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/agent-teams.json +1 -1
  15. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/anchored-standard.json +1 -1
  16. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/annotation.json +1 -1
  17. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/archify.json +1 -1
  18. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/at-file.json +1 -1
  19. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/auto-mode.json +1 -1
  20. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/better-sidebar.json +1 -1
  21. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/browser-fetch.json +1 -1
  22. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/browser-session.json +1 -1
  23. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/canvas-draw.json +1 -1
  24. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/change-verifier.json +1 -1
  25. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/cleaner.json +1 -1
  26. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/cli-notifier.json +1 -1
  27. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/code2skill.json +1 -1
  28. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/colleague-skill.json +1 -1
  29. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/context-doctor.json +1 -1
  30. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/context.json +1 -1
  31. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/cost-meter.json +1 -1
  32. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/dependency-checker.json +1 -1
  33. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/docker-sandbox.json +1 -1
  34. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/fail-logger.json +1 -1
  35. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/genui.json +1 -1
  36. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/git-time-capsule.json +1 -1
  37. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/graph-memory.json +1 -1
  38. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/history-compressor.json +1 -1
  39. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/hol-guard.json +1 -1
  40. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/i18n-pair.json +1 -1
  41. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/image-compressor.json +1 -1
  42. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/llm-verifier.json +1 -1
  43. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/mcp-client.json +1 -1
  44. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/mcp-panel.json +1 -1
  45. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/memory.json +1 -1
  46. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/mirage-bridge.json +1 -1
  47. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/mock-server.json +1 -1
  48. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/modlens.json +1 -1
  49. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/module-search.json +1 -1
  50. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/obsidian-sync.json +1 -1
  51. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/openpets.json +1 -1
  52. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/plan-execute.json +1 -1
  53. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/plugin-check.json +1 -1
  54. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/plugin-dev.json +1 -1
  55. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/plugin-finder.json +1 -1
  56. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/plugin-radar.json +1 -1
  57. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/plugin-stars.json +1 -1
  58. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/prompt-guard.json +1 -1
  59. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/prompt-library.json +1 -1
  60. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/readme-gen.json +1 -1
  61. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/recall-unread.json +1 -1
  62. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/reverse-skill.json +1 -1
  63. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/reviewer-bot.json +1 -1
  64. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/runtime-doctor.json +1 -1
  65. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/secure-audit.json +1 -1
  66. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/session-bookmarks.json +1 -1
  67. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/session-bridge.json +1 -1
  68. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/session-compare.json +1 -1
  69. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/session-export.json +1 -1
  70. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/session-insights.json +1 -1
  71. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/session-search.json +1 -1
  72. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/skill-catalog.json +1 -1
  73. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/skill-guard.json +1 -1
  74. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/sql-lens.json +1 -1
  75. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/synapse.json +1 -1
  76. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/tab-manager.json +1 -1
  77. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/taskboard.json +1 -1
  78. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/telemetry-blocker.json +1 -1
  79. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/test-harness.json +1 -1
  80. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/theme-studio.json +1 -1
  81. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/token-guard.json +1 -1
  82. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/turn-rewind.json +1 -1
  83. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/undo-savepoint.json +1 -1
  84. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/vision-toolkit.json +1 -1
  85. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/web-research.json +1 -1
  86. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/workspace-navigator.json +1 -1
  87. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/workspace-search.json +1 -1
  88. package/node_modules/@pi-harness/api-gateway/dist/marketplace-entries/official/yaml-validator.json +1 -1
  89. package/node_modules/@pi-harness/api-gateway/package.json +1 -1
  90. package/node_modules/@pi-harness/cli/dist/main.d.ts +1 -0
  91. package/node_modules/@pi-harness/cli/dist/main.d.ts.map +1 -1
  92. package/node_modules/@pi-harness/cli/dist/main.js +11 -0
  93. package/node_modules/@pi-harness/cli/dist/main.js.map +1 -1
  94. package/node_modules/@pi-harness/cli/dist/relaunch.d.ts.map +1 -1
  95. package/node_modules/@pi-harness/cli/dist/relaunch.js +2 -4
  96. package/node_modules/@pi-harness/cli/dist/relaunch.js.map +1 -1
  97. package/node_modules/@pi-harness/cli/package.json +1 -1
  98. package/node_modules/@pi-harness/host-webserver/dist/index.d.ts +1 -0
  99. package/node_modules/@pi-harness/host-webserver/dist/index.d.ts.map +1 -1
  100. package/node_modules/@pi-harness/host-webserver/dist/index.js +43 -7
  101. package/node_modules/@pi-harness/host-webserver/dist/index.js.map +1 -1
  102. package/node_modules/@pi-harness/host-webserver/package.json +1 -1
  103. package/node_modules/@pi-harness/web-app/dist/index.d.ts.map +1 -1
  104. package/node_modules/@pi-harness/web-app/dist/index.js +12 -3
  105. package/node_modules/@pi-harness/web-app/dist/index.js.map +1 -1
  106. package/node_modules/@pi-harness/web-app/package.json +1 -1
  107. package/package.json +7 -7
  108. package/packages/api-gateway/dist/index.d.ts.map +1 -1
  109. package/packages/api-gateway/dist/index.js +160 -69
  110. package/packages/api-gateway/dist/index.js.map +1 -1
  111. package/packages/api-gateway/dist/marketplace-entries/official/agent-teams.json +1 -1
  112. package/packages/api-gateway/dist/marketplace-entries/official/anchored-standard.json +1 -1
  113. package/packages/api-gateway/dist/marketplace-entries/official/annotation.json +1 -1
  114. package/packages/api-gateway/dist/marketplace-entries/official/archify.json +1 -1
  115. package/packages/api-gateway/dist/marketplace-entries/official/at-file.json +1 -1
  116. package/packages/api-gateway/dist/marketplace-entries/official/auto-mode.json +1 -1
  117. package/packages/api-gateway/dist/marketplace-entries/official/better-sidebar.json +1 -1
  118. package/packages/api-gateway/dist/marketplace-entries/official/browser-fetch.json +1 -1
  119. package/packages/api-gateway/dist/marketplace-entries/official/browser-session.json +1 -1
  120. package/packages/api-gateway/dist/marketplace-entries/official/canvas-draw.json +1 -1
  121. package/packages/api-gateway/dist/marketplace-entries/official/change-verifier.json +1 -1
  122. package/packages/api-gateway/dist/marketplace-entries/official/cleaner.json +1 -1
  123. package/packages/api-gateway/dist/marketplace-entries/official/cli-notifier.json +1 -1
  124. package/packages/api-gateway/dist/marketplace-entries/official/code2skill.json +1 -1
  125. package/packages/api-gateway/dist/marketplace-entries/official/colleague-skill.json +1 -1
  126. package/packages/api-gateway/dist/marketplace-entries/official/context-doctor.json +1 -1
  127. package/packages/api-gateway/dist/marketplace-entries/official/context.json +1 -1
  128. package/packages/api-gateway/dist/marketplace-entries/official/cost-meter.json +1 -1
  129. package/packages/api-gateway/dist/marketplace-entries/official/dependency-checker.json +1 -1
  130. package/packages/api-gateway/dist/marketplace-entries/official/docker-sandbox.json +1 -1
  131. package/packages/api-gateway/dist/marketplace-entries/official/fail-logger.json +1 -1
  132. package/packages/api-gateway/dist/marketplace-entries/official/genui.json +1 -1
  133. package/packages/api-gateway/dist/marketplace-entries/official/git-time-capsule.json +1 -1
  134. package/packages/api-gateway/dist/marketplace-entries/official/graph-memory.json +1 -1
  135. package/packages/api-gateway/dist/marketplace-entries/official/history-compressor.json +1 -1
  136. package/packages/api-gateway/dist/marketplace-entries/official/hol-guard.json +1 -1
  137. package/packages/api-gateway/dist/marketplace-entries/official/i18n-pair.json +1 -1
  138. package/packages/api-gateway/dist/marketplace-entries/official/image-compressor.json +1 -1
  139. package/packages/api-gateway/dist/marketplace-entries/official/llm-verifier.json +1 -1
  140. package/packages/api-gateway/dist/marketplace-entries/official/mcp-client.json +1 -1
  141. package/packages/api-gateway/dist/marketplace-entries/official/mcp-panel.json +1 -1
  142. package/packages/api-gateway/dist/marketplace-entries/official/memory.json +1 -1
  143. package/packages/api-gateway/dist/marketplace-entries/official/mirage-bridge.json +1 -1
  144. package/packages/api-gateway/dist/marketplace-entries/official/mock-server.json +1 -1
  145. package/packages/api-gateway/dist/marketplace-entries/official/modlens.json +1 -1
  146. package/packages/api-gateway/dist/marketplace-entries/official/module-search.json +1 -1
  147. package/packages/api-gateway/dist/marketplace-entries/official/obsidian-sync.json +1 -1
  148. package/packages/api-gateway/dist/marketplace-entries/official/openpets.json +1 -1
  149. package/packages/api-gateway/dist/marketplace-entries/official/plan-execute.json +1 -1
  150. package/packages/api-gateway/dist/marketplace-entries/official/plugin-check.json +1 -1
  151. package/packages/api-gateway/dist/marketplace-entries/official/plugin-dev.json +1 -1
  152. package/packages/api-gateway/dist/marketplace-entries/official/plugin-finder.json +1 -1
  153. package/packages/api-gateway/dist/marketplace-entries/official/plugin-radar.json +1 -1
  154. package/packages/api-gateway/dist/marketplace-entries/official/plugin-stars.json +1 -1
  155. package/packages/api-gateway/dist/marketplace-entries/official/prompt-guard.json +1 -1
  156. package/packages/api-gateway/dist/marketplace-entries/official/prompt-library.json +1 -1
  157. package/packages/api-gateway/dist/marketplace-entries/official/readme-gen.json +1 -1
  158. package/packages/api-gateway/dist/marketplace-entries/official/recall-unread.json +1 -1
  159. package/packages/api-gateway/dist/marketplace-entries/official/reverse-skill.json +1 -1
  160. package/packages/api-gateway/dist/marketplace-entries/official/reviewer-bot.json +1 -1
  161. package/packages/api-gateway/dist/marketplace-entries/official/runtime-doctor.json +1 -1
  162. package/packages/api-gateway/dist/marketplace-entries/official/secure-audit.json +1 -1
  163. package/packages/api-gateway/dist/marketplace-entries/official/session-bookmarks.json +1 -1
  164. package/packages/api-gateway/dist/marketplace-entries/official/session-bridge.json +1 -1
  165. package/packages/api-gateway/dist/marketplace-entries/official/session-compare.json +1 -1
  166. package/packages/api-gateway/dist/marketplace-entries/official/session-export.json +1 -1
  167. package/packages/api-gateway/dist/marketplace-entries/official/session-insights.json +1 -1
  168. package/packages/api-gateway/dist/marketplace-entries/official/session-search.json +1 -1
  169. package/packages/api-gateway/dist/marketplace-entries/official/skill-catalog.json +1 -1
  170. package/packages/api-gateway/dist/marketplace-entries/official/skill-guard.json +1 -1
  171. package/packages/api-gateway/dist/marketplace-entries/official/sql-lens.json +1 -1
  172. package/packages/api-gateway/dist/marketplace-entries/official/synapse.json +1 -1
  173. package/packages/api-gateway/dist/marketplace-entries/official/tab-manager.json +1 -1
  174. package/packages/api-gateway/dist/marketplace-entries/official/taskboard.json +1 -1
  175. package/packages/api-gateway/dist/marketplace-entries/official/telemetry-blocker.json +1 -1
  176. package/packages/api-gateway/dist/marketplace-entries/official/test-harness.json +1 -1
  177. package/packages/api-gateway/dist/marketplace-entries/official/theme-studio.json +1 -1
  178. package/packages/api-gateway/dist/marketplace-entries/official/token-guard.json +1 -1
  179. package/packages/api-gateway/dist/marketplace-entries/official/turn-rewind.json +1 -1
  180. package/packages/api-gateway/dist/marketplace-entries/official/undo-savepoint.json +1 -1
  181. package/packages/api-gateway/dist/marketplace-entries/official/vision-toolkit.json +1 -1
  182. package/packages/api-gateway/dist/marketplace-entries/official/web-research.json +1 -1
  183. package/packages/api-gateway/dist/marketplace-entries/official/workspace-navigator.json +1 -1
  184. package/packages/api-gateway/dist/marketplace-entries/official/workspace-search.json +1 -1
  185. package/packages/api-gateway/dist/marketplace-entries/official/yaml-validator.json +1 -1
  186. package/packages/api-gateway/package.json +1 -1
  187. package/packages/api-gateway/src/index.ts +186 -88
  188. package/packages/api-gateway/src/marketplace-entries/official/agent-teams.json +1 -1
  189. package/packages/api-gateway/src/marketplace-entries/official/anchored-standard.json +1 -1
  190. package/packages/api-gateway/src/marketplace-entries/official/annotation.json +1 -1
  191. package/packages/api-gateway/src/marketplace-entries/official/archify.json +1 -1
  192. package/packages/api-gateway/src/marketplace-entries/official/at-file.json +1 -1
  193. package/packages/api-gateway/src/marketplace-entries/official/auto-mode.json +1 -1
  194. package/packages/api-gateway/src/marketplace-entries/official/better-sidebar.json +1 -1
  195. package/packages/api-gateway/src/marketplace-entries/official/browser-fetch.json +1 -1
  196. package/packages/api-gateway/src/marketplace-entries/official/browser-session.json +1 -1
  197. package/packages/api-gateway/src/marketplace-entries/official/canvas-draw.json +1 -1
  198. package/packages/api-gateway/src/marketplace-entries/official/change-verifier.json +1 -1
  199. package/packages/api-gateway/src/marketplace-entries/official/cleaner.json +1 -1
  200. package/packages/api-gateway/src/marketplace-entries/official/cli-notifier.json +1 -1
  201. package/packages/api-gateway/src/marketplace-entries/official/code2skill.json +1 -1
  202. package/packages/api-gateway/src/marketplace-entries/official/colleague-skill.json +1 -1
  203. package/packages/api-gateway/src/marketplace-entries/official/context-doctor.json +1 -1
  204. package/packages/api-gateway/src/marketplace-entries/official/context.json +1 -1
  205. package/packages/api-gateway/src/marketplace-entries/official/cost-meter.json +1 -1
  206. package/packages/api-gateway/src/marketplace-entries/official/dependency-checker.json +1 -1
  207. package/packages/api-gateway/src/marketplace-entries/official/docker-sandbox.json +1 -1
  208. package/packages/api-gateway/src/marketplace-entries/official/fail-logger.json +1 -1
  209. package/packages/api-gateway/src/marketplace-entries/official/genui.json +1 -1
  210. package/packages/api-gateway/src/marketplace-entries/official/git-time-capsule.json +1 -1
  211. package/packages/api-gateway/src/marketplace-entries/official/graph-memory.json +1 -1
  212. package/packages/api-gateway/src/marketplace-entries/official/history-compressor.json +1 -1
  213. package/packages/api-gateway/src/marketplace-entries/official/hol-guard.json +1 -1
  214. package/packages/api-gateway/src/marketplace-entries/official/i18n-pair.json +1 -1
  215. package/packages/api-gateway/src/marketplace-entries/official/image-compressor.json +1 -1
  216. package/packages/api-gateway/src/marketplace-entries/official/llm-verifier.json +1 -1
  217. package/packages/api-gateway/src/marketplace-entries/official/mcp-client.json +1 -1
  218. package/packages/api-gateway/src/marketplace-entries/official/mcp-panel.json +1 -1
  219. package/packages/api-gateway/src/marketplace-entries/official/memory.json +1 -1
  220. package/packages/api-gateway/src/marketplace-entries/official/mirage-bridge.json +1 -1
  221. package/packages/api-gateway/src/marketplace-entries/official/mock-server.json +1 -1
  222. package/packages/api-gateway/src/marketplace-entries/official/modlens.json +1 -1
  223. package/packages/api-gateway/src/marketplace-entries/official/module-search.json +1 -1
  224. package/packages/api-gateway/src/marketplace-entries/official/obsidian-sync.json +1 -1
  225. package/packages/api-gateway/src/marketplace-entries/official/openpets.json +1 -1
  226. package/packages/api-gateway/src/marketplace-entries/official/plan-execute.json +1 -1
  227. package/packages/api-gateway/src/marketplace-entries/official/plugin-check.json +1 -1
  228. package/packages/api-gateway/src/marketplace-entries/official/plugin-dev.json +1 -1
  229. package/packages/api-gateway/src/marketplace-entries/official/plugin-finder.json +1 -1
  230. package/packages/api-gateway/src/marketplace-entries/official/plugin-radar.json +1 -1
  231. package/packages/api-gateway/src/marketplace-entries/official/plugin-stars.json +1 -1
  232. package/packages/api-gateway/src/marketplace-entries/official/prompt-guard.json +1 -1
  233. package/packages/api-gateway/src/marketplace-entries/official/prompt-library.json +1 -1
  234. package/packages/api-gateway/src/marketplace-entries/official/readme-gen.json +1 -1
  235. package/packages/api-gateway/src/marketplace-entries/official/recall-unread.json +1 -1
  236. package/packages/api-gateway/src/marketplace-entries/official/reverse-skill.json +1 -1
  237. package/packages/api-gateway/src/marketplace-entries/official/reviewer-bot.json +1 -1
  238. package/packages/api-gateway/src/marketplace-entries/official/runtime-doctor.json +1 -1
  239. package/packages/api-gateway/src/marketplace-entries/official/secure-audit.json +1 -1
  240. package/packages/api-gateway/src/marketplace-entries/official/session-bookmarks.json +1 -1
  241. package/packages/api-gateway/src/marketplace-entries/official/session-bridge.json +1 -1
  242. package/packages/api-gateway/src/marketplace-entries/official/session-compare.json +1 -1
  243. package/packages/api-gateway/src/marketplace-entries/official/session-export.json +1 -1
  244. package/packages/api-gateway/src/marketplace-entries/official/session-insights.json +1 -1
  245. package/packages/api-gateway/src/marketplace-entries/official/session-search.json +1 -1
  246. package/packages/api-gateway/src/marketplace-entries/official/skill-catalog.json +1 -1
  247. package/packages/api-gateway/src/marketplace-entries/official/skill-guard.json +1 -1
  248. package/packages/api-gateway/src/marketplace-entries/official/sql-lens.json +1 -1
  249. package/packages/api-gateway/src/marketplace-entries/official/synapse.json +1 -1
  250. package/packages/api-gateway/src/marketplace-entries/official/tab-manager.json +1 -1
  251. package/packages/api-gateway/src/marketplace-entries/official/taskboard.json +1 -1
  252. package/packages/api-gateway/src/marketplace-entries/official/telemetry-blocker.json +1 -1
  253. package/packages/api-gateway/src/marketplace-entries/official/test-harness.json +1 -1
  254. package/packages/api-gateway/src/marketplace-entries/official/theme-studio.json +1 -1
  255. package/packages/api-gateway/src/marketplace-entries/official/token-guard.json +1 -1
  256. package/packages/api-gateway/src/marketplace-entries/official/turn-rewind.json +1 -1
  257. package/packages/api-gateway/src/marketplace-entries/official/undo-savepoint.json +1 -1
  258. package/packages/api-gateway/src/marketplace-entries/official/vision-toolkit.json +1 -1
  259. package/packages/api-gateway/src/marketplace-entries/official/web-research.json +1 -1
  260. package/packages/api-gateway/src/marketplace-entries/official/workspace-navigator.json +1 -1
  261. package/packages/api-gateway/src/marketplace-entries/official/workspace-search.json +1 -1
  262. package/packages/api-gateway/src/marketplace-entries/official/yaml-validator.json +1 -1
  263. package/packages/api-gateway/test/index.test.ts +656 -4
  264. package/packages/api-gateway/test/packed-package.test.ts +70 -0
  265. package/packages/api-gateway/test/reference-docs.test.ts +50 -0
  266. package/packages/api-gateway/test/release-package.test.ts +32 -1
  267. package/packages/api-gateway/test/release-version.test.ts +40 -0
  268. package/packages/bundle-web-app/dist/index.d.ts.map +1 -1
  269. package/packages/bundle-web-app/dist/index.js +12 -3
  270. package/packages/bundle-web-app/dist/index.js.map +1 -1
  271. package/packages/bundle-web-app/package.json +1 -1
  272. package/packages/bundle-web-app/src/index.ts +13 -3
  273. package/packages/bundle-web-app/test/index.test.ts +83 -2
  274. package/packages/cli/dist/main.d.ts +1 -0
  275. package/packages/cli/dist/main.d.ts.map +1 -1
  276. package/packages/cli/dist/main.js +11 -0
  277. package/packages/cli/dist/main.js.map +1 -1
  278. package/packages/cli/dist/relaunch.d.ts.map +1 -1
  279. package/packages/cli/dist/relaunch.js +2 -4
  280. package/packages/cli/dist/relaunch.js.map +1 -1
  281. package/packages/cli/package.json +1 -1
  282. package/packages/cli/src/main.ts +11 -0
  283. package/packages/cli/src/relaunch.ts +2 -3
  284. package/packages/cli/test/cli.test.ts +3 -1
  285. package/packages/cli/test/main.test.ts +134 -0
  286. package/packages/cli/test/relaunch.test.ts +86 -11
  287. package/packages/host-webserver/dist/index.d.ts +1 -0
  288. package/packages/host-webserver/dist/index.d.ts.map +1 -1
  289. package/packages/host-webserver/dist/index.js +43 -7
  290. package/packages/host-webserver/dist/index.js.map +1 -1
  291. package/packages/host-webserver/package.json +1 -1
  292. package/packages/host-webserver/src/index.ts +48 -8
  293. package/packages/host-webserver/test/index.test.ts +58 -5
  294. package/apps/web/dist/assets/index-BVI_IS-R.css +0 -2
  295. package/apps/web/dist/assets/index-Bo9CNI_U.js +0 -22
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import { Readable, Writable } from "node:stream";
6
6
  import { describe, expect, test } from "vitest";
7
- import { runCli, type CliEnvironment } from "../src/main.js";
7
+ import { DUPLICATE_SIGNAL_WINDOW_MS, runCli, type CliEnvironment } from "../src/main.js";
8
8
 
9
9
  interface TestEnvironment extends CliEnvironment {
10
10
  readonly stdin: Readable;
@@ -200,6 +200,8 @@ describe("runCli", () => {
200
200
  await waitForFileContent(join(profile.directory, "marker.txt"), "started");
201
201
 
202
202
  environment.emitSignal("SIGINT");
203
+ // A deliberate second Ctrl-C is hundreds of milliseconds after the first; only a repeat inside DUPLICATE_SIGNAL_WINDOW_MS is treated as one keypress delivered twice.
204
+ await new Promise((resolve) => setTimeout(resolve, DUPLICATE_SIGNAL_WINDOW_MS + 10));
203
205
  environment.emitSignal("SIGINT");
204
206
 
205
207
  expect(environment.forcedExitCodes).toEqual([130]);
@@ -0,0 +1,134 @@
1
+ import { mkdtemp, readFile, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { Readable, Writable } from "node:stream";
5
+ import { pathToFileURL } from "node:url";
6
+ import { describe, expect, test } from "vitest";
7
+ import { DUPLICATE_SIGNAL_WINDOW_MS, runCli, type CliEnvironment } from "../src/main.js";
8
+
9
+ interface TestEnvironment extends CliEnvironment {
10
+ readonly output: string[];
11
+ readonly errors: string[];
12
+ readonly forcedExitCodes: number[];
13
+ emitSignal(signal: NodeJS.Signals): void;
14
+ }
15
+
16
+ function createEnvironment(cwd: string, shutdownTimeoutMs = 5_000): TestEnvironment {
17
+ const output: string[] = [];
18
+ const errors: string[] = [];
19
+ const forcedExitCodes: number[] = [];
20
+ const signals = new Set<(signal: NodeJS.Signals) => void>();
21
+ const stdin = new Readable({ read() {} });
22
+ stdin.push(null);
23
+ return {
24
+ cwd,
25
+ agentDir: join(cwd, ".pi-agent-test"),
26
+ version: "0.1.0-test",
27
+ stdin,
28
+ stdout: new Writable({
29
+ write(chunk, _encoding, callback) {
30
+ output.push(String(chunk));
31
+ callback();
32
+ },
33
+ }),
34
+ stderr: new Writable({
35
+ write(chunk, _encoding, callback) {
36
+ errors.push(String(chunk));
37
+ callback();
38
+ },
39
+ }),
40
+ output,
41
+ errors,
42
+ forcedExitCodes,
43
+ shutdownTimeoutMs,
44
+ forceExit(code) {
45
+ forcedExitCodes.push(code);
46
+ },
47
+ onSignal(listener) {
48
+ signals.add(listener);
49
+ return () => signals.delete(listener);
50
+ },
51
+ emitSignal(signal) {
52
+ for (const listener of signals) listener(signal);
53
+ },
54
+ };
55
+ }
56
+
57
+ async function createApplicationProfile(source: string): Promise<{ configPath: string; directory: string; markerPath: string }> {
58
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-signal-"));
59
+ const pluginPath = join(directory, "application.mjs");
60
+ const configPath = join(directory, "cordis.yml");
61
+ const markerPath = join(directory, "marker.txt");
62
+ await writeFile(pluginPath, source, "utf8");
63
+ await writeFile(configPath, JSON.stringify([{ name: pathToFileURL(pluginPath).href, config: { markerPath } }]), "utf8");
64
+ return { configPath, directory, markerPath };
65
+ }
66
+
67
+ async function waitForFileContent(path: string, expected: string, timeoutMs = 5_000): Promise<void> {
68
+ const startedAt = Date.now();
69
+ while (true) {
70
+ try {
71
+ if ((await readFile(path, "utf8")) === expected) return;
72
+ } catch {
73
+ // The producer has not created the marker yet.
74
+ }
75
+ if (Date.now() - startedAt > timeoutMs) throw new Error(`Timed out waiting for ${path} to contain ${expected}`);
76
+ await new Promise((resolve) => setTimeout(resolve, 10));
77
+ }
78
+ }
79
+
80
+ const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
81
+
82
+ // A shutdown that outlives the duplicate window is what makes the second delivery observable at all; a 20ms abort handler stands in for a plugin dispose or a stdio flush.
83
+ const SLOW_SHUTDOWN_APPLICATION = `import { appendFileSync, writeFileSync } from "node:fs"; export default { apply(ctx, config) { writeFileSync(config.markerPath, "started"); ctx.effect(() => () => appendFileSync(config.markerPath, ":disposed")); ctx.provide("piApplication", { async run(signal) { appendFileSync(config.markerPath, ":run"); await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })); await new Promise((resolve) => setTimeout(resolve, 20)); appendFileSync(config.markerPath, ":aborted"); return 0; } }); } };`;
84
+ // A shutdown that never finishes keeps the signal listener registered, so a deliberate second Ctrl-C arriving long after the first still has something to interrupt.
85
+ const STUCK_SHUTDOWN_APPLICATION = `import { writeFileSync } from "node:fs"; export default { apply(ctx, config) { writeFileSync(config.markerPath, "started"); ctx.provide("piRuntime", { async abort() { return new Promise(() => {}); }, async dispose() {} }); ctx.provide("piApplication", { async run() { return new Promise(() => {}); } }); } };`;
86
+
87
+ describe("runCli duplicate signal delivery", () => {
88
+ test("collapses the same signal delivered twice inside the duplicate window into one graceful shutdown", async () => {
89
+ const profile = await createApplicationProfile(SLOW_SHUTDOWN_APPLICATION);
90
+ const environment = createEnvironment(profile.directory);
91
+ const result = runCli(["--config", profile.configPath], environment);
92
+ await waitForFileContent(profile.markerPath, "started:run");
93
+
94
+ // The terminal broadcasts Ctrl-C to the process group and the development supervisor relays the same signal; both land within a millisecond or two of each other.
95
+ environment.emitSignal("SIGINT");
96
+ await sleep(2);
97
+ environment.emitSignal("SIGINT");
98
+
99
+ await expect(result).resolves.toBe(130);
100
+ expect(environment.forcedExitCodes).toEqual([]);
101
+ expect(environment.errors.join("")).not.toContain("Received SIGINT again");
102
+ await expect(readFile(profile.markerPath, "utf8")).resolves.toBe("started:run:aborted:disposed");
103
+ });
104
+
105
+ test("still force-quits when the same signal arrives again after the duplicate window", async () => {
106
+ const profile = await createApplicationProfile(STUCK_SHUTDOWN_APPLICATION);
107
+ const environment = createEnvironment(profile.directory, 60_000);
108
+ const result = runCli(["--config", profile.configPath], environment);
109
+ await waitForFileContent(profile.markerPath, "started");
110
+
111
+ environment.emitSignal("SIGINT");
112
+ await sleep(DUPLICATE_SIGNAL_WINDOW_MS + 10);
113
+ environment.emitSignal("SIGINT");
114
+
115
+ expect(environment.forcedExitCodes).toEqual([130]);
116
+ expect(environment.errors.join("")).toContain("Received SIGINT again");
117
+ await expect(Promise.race([result, sleep(200).then(() => "pending")])).resolves.toBe("pending");
118
+ });
119
+
120
+ test("does not collapse two different signals that arrive inside the duplicate window", async () => {
121
+ const profile = await createApplicationProfile(STUCK_SHUTDOWN_APPLICATION);
122
+ const environment = createEnvironment(profile.directory, 60_000);
123
+ const result = runCli(["--config", profile.configPath], environment);
124
+ await waitForFileContent(profile.markerPath, "started");
125
+
126
+ environment.emitSignal("SIGINT");
127
+ await sleep(2);
128
+ environment.emitSignal("SIGTERM");
129
+
130
+ expect(environment.forcedExitCodes).toEqual([143]);
131
+ expect(environment.errors.join("")).toContain("Received SIGTERM again");
132
+ await expect(Promise.race([result, sleep(200).then(() => "pending")])).resolves.toBe("pending");
133
+ });
134
+ });
@@ -1,8 +1,9 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
2
  import { mkdtemp, readFile, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { describe, expect, test } from "vitest";
6
+ import { DUPLICATE_SIGNAL_WINDOW_MS } from "../src/main.js";
6
7
  import { PI_HARNESS_RESTART_EXIT_CODE, shouldRelaunchForDevelopmentProfile, superviseDevelopmentProcess } from "../src/relaunch.js";
7
8
 
8
9
  describe("development profile relaunch", () => {
@@ -66,7 +67,8 @@ describe("development profile signal forwarding", () => {
66
67
  const directory = await mkdtemp(join(tmpdir(), `pi-harness-supervisor-${label}-`));
67
68
  const markerPath = join(directory, "started.txt");
68
69
  const childPath = join(directory, "child.mjs");
69
- await writeFile(childPath, `import { writeFileSync } from "node:fs"; writeFileSync(process.argv[2], "started"); ${body}`, "utf8");
70
+ // The marker is written last, after the body has installed its handlers. Written first, it announces a child that node would still kill outright with the default disposition, because a signal only becomes catchable once a listener for it exists - which is how this test was seen to observe exit 130 where it expected 31.
71
+ await writeFile(childPath, `import { writeFileSync } from "node:fs"; ${body} writeFileSync(process.argv[2], "started");`, "utf8");
70
72
  return { markerPath, childPath };
71
73
  }
72
74
 
@@ -80,17 +82,90 @@ describe("development profile signal forwarding", () => {
80
82
  await expect(supervised).resolves.toBe(143);
81
83
  });
82
84
 
85
+ // The vitest worker has no terminal on any of the three descriptors, so the tty state is faked here to pin that relaying does not depend on it.
86
+ function withTerminalStdio<T>(isTTY: boolean, spawnChild: () => T): T {
87
+ const streams = [process.stdin, process.stdout, process.stderr];
88
+ const original = streams.map((stream) => stream.isTTY);
89
+ for (const stream of streams) stream.isTTY = isTTY;
90
+ try {
91
+ return spawnChild();
92
+ } finally {
93
+ for (const [index, stream] of streams.entries()) stream.isTTY = original[index] ?? false;
94
+ }
95
+ }
96
+
83
97
  test.skipIf(process.platform === "win32")(
84
- "does not forward SIGINT or SIGHUP, which the shared foreground process group already delivers to the child",
98
+ "relays SIGINT and SIGHUP to the child whether or not the inherited stdio is a terminal",
85
99
  async () => {
86
- const { markerPath, childPath } = await startChild("int", "setTimeout(() => process.exit(21), 800);");
87
- const supervised = superviseDevelopmentProcess(process.execPath, [childPath, markerPath], { stdio: "ignore" });
88
- await expect.poll(() => existsSync(markerPath), { interval: 20, timeout: 5_000 }).toBe(true);
89
-
90
- expect(process.emit("SIGINT", "SIGINT")).toBe(true);
91
- expect(process.emit("SIGHUP", "SIGHUP")).toBe(true);
92
-
93
- await expect(supervised).resolves.toBe(21);
100
+ for (const isTTY of [true, false]) {
101
+ for (const [signal, code] of [
102
+ ["SIGINT", 31],
103
+ ["SIGHUP", 32],
104
+ ] as const) {
105
+ const { markerPath, childPath } = await startChild(
106
+ `${signal.toLowerCase()}-${String(isTTY)}`,
107
+ // Exiting with 21 when no signal arrives distinguishes a swallowed relay from a slow one.
108
+ `process.on("SIGINT", () => process.exit(31)); process.on("SIGHUP", () => process.exit(32)); setTimeout(() => process.exit(21), 2_000);`,
109
+ );
110
+ const supervised = withTerminalStdio(isTTY, () => superviseDevelopmentProcess(process.execPath, [childPath, markerPath], { stdio: "ignore" }));
111
+ await expect.poll(() => existsSync(markerPath), { interval: 20, timeout: 5_000 }).toBe(true);
112
+
113
+ expect(process.emit(signal, signal)).toBe(true);
114
+
115
+ await expect(supervised).resolves.toBe(code);
116
+ }
117
+ }
94
118
  },
119
+ 30_000,
95
120
  );
121
+
122
+ // A real `kill -INT -<pgid>` would also hit the vitest worker, so the group broadcast is simulated: the signal is delivered straight to the child (the terminal's copy) and emitted on this process (the copy the supervisor relays), 2ms apart, which matches the sub-millisecond spacing measured between the two copies and is well inside the duplicate window.
123
+ // How many deliveries the child sees is the kernel's decision, not this code's: two SIGINTs that are pending together are coalesced into one, which was observed on roughly one run in five and is why the count is asserted as at least one rather than exactly two. What this test pins is the outcome under a group broadcast - one graceful shutdown, exit 130, no force-quit - and it holds under either delivery count. The window logic itself is pinned deterministically and in-process against the real runCli in main.test.ts.
124
+ test.skipIf(process.platform === "win32")("a group broadcast that reaches the child directly and through the relay shuts the child down once", async () => {
125
+ const directory = await mkdtemp(join(tmpdir(), "pi-harness-supervisor-broadcast-"));
126
+ const markerPath = join(directory, "signals.txt");
127
+ const pidPath = join(directory, "pid.txt");
128
+ const childPath = join(directory, "child.mjs");
129
+ await writeFile(
130
+ childPath,
131
+ // The child mirrors runCli's handler: every delivery is recorded, but a repeat of the same signal inside DUPLICATE_SIGNAL_WINDOW_MS is ignored instead of counting toward the force-quit path.
132
+ `import { appendFileSync, writeFileSync } from "node:fs";
133
+ const marker = process.argv[2];
134
+ let lastSignal;
135
+ let lastSignalAt = 0;
136
+ let signalCount = 0;
137
+ process.on("SIGINT", () => {
138
+ appendFileSync(marker, "delivery\\n");
139
+ const receivedAt = performance.now();
140
+ if (lastSignal === "SIGINT" && receivedAt - lastSignalAt < ${DUPLICATE_SIGNAL_WINDOW_MS}) return;
141
+ lastSignal = "SIGINT";
142
+ lastSignalAt = receivedAt;
143
+ signalCount += 1;
144
+ if (signalCount > 1) {
145
+ appendFileSync(marker, "force-quit\\n");
146
+ process.exit(1);
147
+ }
148
+ setTimeout(() => {
149
+ appendFileSync(marker, "graceful\\n");
150
+ process.exit(130);
151
+ }, 25);
152
+ });
153
+ writeFileSync(process.argv[3], String(process.pid));
154
+ setInterval(() => {}, 1_000);`,
155
+ "utf8",
156
+ );
157
+ const supervised = superviseDevelopmentProcess(process.execPath, [childPath, markerPath, pidPath], { stdio: "ignore" });
158
+ await expect.poll(() => (existsSync(pidPath) ? readFileSync(pidPath, "utf8") : ""), { interval: 20, timeout: 5_000 }).not.toBe("");
159
+ const childPid = Number(readFileSync(pidPath, "utf8"));
160
+
161
+ process.kill(childPid, "SIGINT");
162
+ await new Promise((resolve) => setTimeout(resolve, 2));
163
+ expect(process.emit("SIGINT", "SIGINT")).toBe(true);
164
+
165
+ await expect(supervised).resolves.toBe(130);
166
+ const events = (await readFile(markerPath, "utf8")).split("\n").filter(Boolean);
167
+ expect(events.filter((event) => event === "delivery").length).toBeGreaterThanOrEqual(1);
168
+ expect(events).toContain("graceful");
169
+ expect(events).not.toContain("force-quit");
170
+ });
96
171
  });
@@ -7,6 +7,7 @@ export interface WebRoute {
7
7
  export interface WebServerConfig {
8
8
  readonly host?: string;
9
9
  readonly port?: number;
10
+ readonly allowedHosts?: readonly string[];
10
11
  }
11
12
  export interface WebServer {
12
13
  readonly host: string;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AACpF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,cAAc,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChG;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,IAAI,CAAC;IACtC,gBAAgB,CAAC,OAAO,EAAE,QAAQ,CAAC,SAAS,CAAC,GAAG,MAAM,IAAI,CAAC;IAC3D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,SAAS,EAAE,SAAS,CAAC;KACtB;CACF;;;mBAmEsB,OAAO,UAAU,eAAe;;AAFvD,wBA2DE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,WAAW,CAAC;AAEpF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAC;AAEnD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,cAAc,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChG;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAEvB,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3C;AAED,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,KAAK,EAAE,QAAQ,GAAG,MAAM,IAAI,CAAC;IACtC,gBAAgB,CAAC,OAAO,EAAE,QAAQ,CAAC,SAAS,CAAC,GAAG,MAAM,IAAI,CAAC;IAC3D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,OAAO;QACf,SAAS,EAAE,SAAS,CAAC;KACtB;CACF;;;mBAqGsB,OAAO,UAAU,eAAe;;AAFvD,wBA8DE"}
@@ -1,4 +1,5 @@
1
1
  import { createServer } from "node:http";
2
+ import { hostname as machineHostname, networkInterfaces } from "node:os";
2
3
  export const DEFAULT_WEB_SERVER_PORT = 3141;
3
4
  function normalizePath(path) {
4
5
  if (!path.startsWith("/") || (path.length > 1 && path.endsWith("/")))
@@ -32,16 +33,48 @@ function parseHttpAuthority(value) {
32
33
  return undefined;
33
34
  return { hostname: url.hostname, port: url.port === "" ? 80 : Number(url.port) };
34
35
  }
35
- // Browsers send the Origin header on every cross-origin request and on all non-GET/HEAD requests, so requiring it to match the (already validated) Host header blocks cross-site requests from pages the user visits. Requests without an Origin header (same-origin navigations, EventSource, curl, Node fetch) pass. The Host check defeats DNS rebinding: a page on attacker.example that resolves to 127.0.0.1 arrives with Host: attacker.example, which is neither loopback nor the configured bind host.
36
- function rejectForeignRequest(request, response, configuredHost, boundPort) {
36
+ // Normalizes a configured host or allowlist entry the same way parseHttpAuthority normalizes a header value, so IPv6 forms and letter case compare equal. Returns undefined for values that are not a valid host.
37
+ function canonicalHostname(host) {
38
+ try {
39
+ return new URL("http://" + hostForUrl(host)).hostname;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ // A wildcard bind answers on every local address, so the names a browser may legitimately put in the Host header are this machine's own addresses and hostname. They are read per request because DHCP and interface changes rewrite them while the server runs. An interface-scoped IPv6 address ("fe80::1%en0") is compared without its zone id, which is what a URL host carries.
46
+ function localHostnames() {
47
+ const names = new Set();
48
+ const own = canonicalHostname(machineHostname());
49
+ if (own !== undefined)
50
+ names.add(own);
51
+ for (const entries of Object.values(networkInterfaces())) {
52
+ for (const entry of entries ?? []) {
53
+ const name = canonicalHostname(entry.address.split("%")[0] ?? entry.address);
54
+ if (name !== undefined)
55
+ names.add(name);
56
+ }
57
+ }
58
+ return names;
59
+ }
60
+ function isHostnameAllowed(hostname, configuredHost, allowedHosts) {
61
+ if (LOOPBACK_HOSTNAMES.has(hostname))
62
+ return true;
63
+ if (allowedHosts.some((entry) => canonicalHostname(entry) === hostname))
64
+ return true;
65
+ if (!WILDCARD_HOSTS.has(configuredHost))
66
+ return hostname === hostForUrl(configuredHost).toLowerCase();
67
+ return localHostnames().has(hostname);
68
+ }
69
+ // Browsers send the Origin header on every cross-origin request and on all non-GET/HEAD requests, so requiring it to match the (already validated) Host header blocks cross-site requests from pages the user visits. Requests without an Origin header (same-origin navigations, EventSource, curl, Node fetch) pass. The Host check defeats DNS rebinding: a page on attacker.example that resolves to one of this machine's addresses arrives with Host: attacker.example, which is neither loopback, nor the bind address, nor an address or hostname this machine owns. The check stays on for a wildcard bind, where only the machine's own addresses (or an operator-supplied allowedHosts entry) are accepted, because the Origin check cannot help: a rebinding page sends a matching Origin and Host pair.
70
+ function rejectForeignRequest(request, response, configuredHost, boundPort, allowedHosts) {
37
71
  const hostHeader = request.headers.host;
38
72
  const authority = hostHeader === undefined ? undefined : parseHttpAuthority(hostHeader);
39
73
  if (authority === undefined) {
40
74
  sendError(response, 400, "Invalid Host header");
41
75
  return true;
42
76
  }
43
- const hostnameAllowed = WILDCARD_HOSTS.has(configuredHost) || LOOPBACK_HOSTNAMES.has(authority.hostname) || authority.hostname === hostForUrl(configuredHost).toLowerCase();
44
- if (!hostnameAllowed || authority.port !== boundPort) {
77
+ if (!isHostnameAllowed(authority.hostname, configuredHost, allowedHosts) || authority.port !== boundPort) {
45
78
  sendError(response, 400, "Host header does not match the web server address");
46
79
  return true;
47
80
  }
@@ -60,14 +93,17 @@ export default {
60
93
  async apply(context, config) {
61
94
  const host = config.host ?? "127.0.0.1";
62
95
  const port = config.port ?? DEFAULT_WEB_SERVER_PORT;
96
+ const allowedHosts = config.allowedHosts ?? [];
97
+ // A wildcard bind (including the empty host, which listen() accepts) has no hostname of its own, and "http://" + "" is not a parsable URL base, so resolving a request path against it threw out of the request listener. Loopback is the address such a bind always answers on, so it stands in both as the base for request URLs and as the advertised URL.
98
+ const urlHost = WILDCARD_HOSTS.has(host) ? "127.0.0.1" : hostForUrl(host);
63
99
  const routes = new Map();
64
100
  let fallback;
65
101
  // Assigned once listen() completes; tests bind port 0 so the configured port is not the one clients address.
66
102
  let boundPort = 0;
67
103
  const server = createServer((request, response) => {
68
- if (rejectForeignRequest(request, response, host, boundPort))
104
+ if (rejectForeignRequest(request, response, host, boundPort, allowedHosts))
69
105
  return;
70
- const path = new URL(request.url ?? "/", "http://" + hostForUrl(host)).pathname;
106
+ const path = new URL(request.url ?? "/", "http://" + urlHost).pathname;
71
107
  const handler = routes.get(path) ?? fallback;
72
108
  if (handler === undefined) {
73
109
  notFound(response);
@@ -97,7 +133,7 @@ export default {
97
133
  const service = {
98
134
  host,
99
135
  port: address.port,
100
- url: "http://" + hostForUrl(host) + ":" + address.port,
136
+ url: "http://" + urlHost + ":" + address.port,
101
137
  register(route) {
102
138
  const path = normalizePath(route.path);
103
139
  if (routes.has(path))
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-harness/host-webserver",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "private": true,
5
5
  "description": "Cordis route-registration service for the Pi Harness web host",
6
6
  "homepage": "https://github.com/pi-harness/pi-harness#readme",
@@ -1,4 +1,5 @@
1
1
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { hostname as machineHostname, networkInterfaces } from "node:os";
2
3
  import type { Context } from "@deepseek-ai/cordis";
3
4
 
4
5
  export interface WebRoute {
@@ -9,6 +10,8 @@ export interface WebRoute {
9
10
  export interface WebServerConfig {
10
11
  readonly host?: string;
11
12
  readonly port?: number;
13
+ // Extra hostnames accepted in the Host header, on top of loopback names, the bound address and (for a wildcard bind) this machine's own addresses and hostname. Needed when the harness is reached through a proxy or a DNS name the machine itself does not know about.
14
+ readonly allowedHosts?: readonly string[];
12
15
  }
13
16
 
14
17
  export interface WebServer {
@@ -67,17 +70,51 @@ function parseHttpAuthority(value: string): Authority | undefined {
67
70
  return { hostname: url.hostname, port: url.port === "" ? 80 : Number(url.port) };
68
71
  }
69
72
 
70
- // Browsers send the Origin header on every cross-origin request and on all non-GET/HEAD requests, so requiring it to match the (already validated) Host header blocks cross-site requests from pages the user visits. Requests without an Origin header (same-origin navigations, EventSource, curl, Node fetch) pass. The Host check defeats DNS rebinding: a page on attacker.example that resolves to 127.0.0.1 arrives with Host: attacker.example, which is neither loopback nor the configured bind host.
71
- function rejectForeignRequest(request: IncomingMessage, response: ServerResponse, configuredHost: string, boundPort: number): boolean {
73
+ // Normalizes a configured host or allowlist entry the same way parseHttpAuthority normalizes a header value, so IPv6 forms and letter case compare equal. Returns undefined for values that are not a valid host.
74
+ function canonicalHostname(host: string): string | undefined {
75
+ try {
76
+ return new URL("http://" + hostForUrl(host)).hostname;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ // A wildcard bind answers on every local address, so the names a browser may legitimately put in the Host header are this machine's own addresses and hostname. They are read per request because DHCP and interface changes rewrite them while the server runs. An interface-scoped IPv6 address ("fe80::1%en0") is compared without its zone id, which is what a URL host carries.
83
+ function localHostnames(): Set<string> {
84
+ const names = new Set<string>();
85
+ const own = canonicalHostname(machineHostname());
86
+ if (own !== undefined) names.add(own);
87
+ for (const entries of Object.values(networkInterfaces())) {
88
+ for (const entry of entries ?? []) {
89
+ const name = canonicalHostname(entry.address.split("%")[0] ?? entry.address);
90
+ if (name !== undefined) names.add(name);
91
+ }
92
+ }
93
+ return names;
94
+ }
95
+
96
+ function isHostnameAllowed(hostname: string, configuredHost: string, allowedHosts: readonly string[]): boolean {
97
+ if (LOOPBACK_HOSTNAMES.has(hostname)) return true;
98
+ if (allowedHosts.some((entry) => canonicalHostname(entry) === hostname)) return true;
99
+ if (!WILDCARD_HOSTS.has(configuredHost)) return hostname === hostForUrl(configuredHost).toLowerCase();
100
+ return localHostnames().has(hostname);
101
+ }
102
+
103
+ // Browsers send the Origin header on every cross-origin request and on all non-GET/HEAD requests, so requiring it to match the (already validated) Host header blocks cross-site requests from pages the user visits. Requests without an Origin header (same-origin navigations, EventSource, curl, Node fetch) pass. The Host check defeats DNS rebinding: a page on attacker.example that resolves to one of this machine's addresses arrives with Host: attacker.example, which is neither loopback, nor the bind address, nor an address or hostname this machine owns. The check stays on for a wildcard bind, where only the machine's own addresses (or an operator-supplied allowedHosts entry) are accepted, because the Origin check cannot help: a rebinding page sends a matching Origin and Host pair.
104
+ function rejectForeignRequest(
105
+ request: IncomingMessage,
106
+ response: ServerResponse,
107
+ configuredHost: string,
108
+ boundPort: number,
109
+ allowedHosts: readonly string[],
110
+ ): boolean {
72
111
  const hostHeader = request.headers.host;
73
112
  const authority = hostHeader === undefined ? undefined : parseHttpAuthority(hostHeader);
74
113
  if (authority === undefined) {
75
114
  sendError(response, 400, "Invalid Host header");
76
115
  return true;
77
116
  }
78
- const hostnameAllowed =
79
- WILDCARD_HOSTS.has(configuredHost) || LOOPBACK_HOSTNAMES.has(authority.hostname) || authority.hostname === hostForUrl(configuredHost).toLowerCase();
80
- if (!hostnameAllowed || authority.port !== boundPort) {
117
+ if (!isHostnameAllowed(authority.hostname, configuredHost, allowedHosts) || authority.port !== boundPort) {
81
118
  sendError(response, 400, "Host header does not match the web server address");
82
119
  return true;
83
120
  }
@@ -96,13 +133,16 @@ export default {
96
133
  async apply(context: Context, config: WebServerConfig) {
97
134
  const host = config.host ?? "127.0.0.1";
98
135
  const port = config.port ?? DEFAULT_WEB_SERVER_PORT;
136
+ const allowedHosts = config.allowedHosts ?? [];
137
+ // A wildcard bind (including the empty host, which listen() accepts) has no hostname of its own, and "http://" + "" is not a parsable URL base, so resolving a request path against it threw out of the request listener. Loopback is the address such a bind always answers on, so it stands in both as the base for request URLs and as the advertised URL.
138
+ const urlHost = WILDCARD_HOSTS.has(host) ? "127.0.0.1" : hostForUrl(host);
99
139
  const routes = new Map<string, WebRoute["handler"]>();
100
140
  let fallback: WebRoute["handler"] | undefined;
101
141
  // Assigned once listen() completes; tests bind port 0 so the configured port is not the one clients address.
102
142
  let boundPort = 0;
103
143
  const server = createServer((request, response) => {
104
- if (rejectForeignRequest(request, response, host, boundPort)) return;
105
- const path = new URL(request.url ?? "/", "http://" + hostForUrl(host)).pathname;
144
+ if (rejectForeignRequest(request, response, host, boundPort, allowedHosts)) return;
145
+ const path = new URL(request.url ?? "/", "http://" + urlHost).pathname;
106
146
  const handler = routes.get(path) ?? fallback;
107
147
  if (handler === undefined) {
108
148
  notFound(response);
@@ -131,7 +171,7 @@ export default {
131
171
  const service: WebServer = {
132
172
  host,
133
173
  port: address.port,
134
- url: "http://" + hostForUrl(host) + ":" + address.port,
174
+ url: "http://" + urlHost + ":" + address.port,
135
175
  register(route) {
136
176
  const path = normalizePath(route.path);
137
177
  if (routes.has(path)) throw new Error("Web route already registered: " + path);
@@ -1,15 +1,22 @@
1
1
  import { Context } from "@deepseek-ai/cordis";
2
2
  import { request as httpRequest } from "node:http";
3
+ import { hostname as machineHostname, networkInterfaces } from "node:os";
3
4
  import { afterEach, describe, expect, test } from "vitest";
4
5
  import webServerPlugin, { DEFAULT_WEB_SERVER_PORT, type WebServer } from "../src/index.js";
5
6
 
6
7
  const contexts: Context[] = [];
7
8
 
9
+ function firstLocalIpv4(): string | undefined {
10
+ return Object.values(networkInterfaces())
11
+ .flatMap((entries) => entries ?? [])
12
+ .find((entry) => entry.family === "IPv4" && !entry.internal)?.address;
13
+ }
14
+
8
15
  afterEach(async () => {
9
16
  await Promise.all(contexts.splice(0).map(async (context) => context.fiber.dispose()));
10
17
  });
11
18
 
12
- async function startServer(config: { host?: string; port: number }): Promise<WebServer> {
19
+ async function startServer(config: { host?: string; port: number; allowedHosts?: readonly string[] }): Promise<WebServer> {
13
20
  const context = new Context();
14
21
  contexts.push(context);
15
22
  await context.plugin(webServerPlugin, config);
@@ -163,12 +170,58 @@ describe("web server plugin", () => {
163
170
  expect(health.calls).toBe(3);
164
171
  });
165
172
 
166
- test("accepts the configured non-loopback host and any hostname on a wildcard bind", async () => {
173
+ test("accepts only this machine's own names on a wildcard bind so DNS rebinding still fails", async () => {
167
174
  const wildcard = await startServer({ host: "0.0.0.0", port: 0 });
168
- registerHealth(wildcard);
169
- await expect(rawRequest(wildcard, { host: "workstation.lan:" + wildcard.port })).resolves.toMatchObject({ status: 200 });
170
- await expect(rawRequest(wildcard, { host: "workstation.lan:" + (wildcard.port + 1) })).resolves.toMatchObject({ status: 400 });
175
+ const health = registerHealth(wildcard);
176
+ expect(wildcard.url).toBe("http://127.0.0.1:" + wildcard.port);
177
+
178
+ await expect(rawRequest(wildcard, { host: "attacker.example:" + wildcard.port })).resolves.toEqual({
179
+ status: 400,
180
+ body: JSON.stringify({ error: "Host header does not match the web server address" }),
181
+ });
182
+ await expect(rawRequest(wildcard, { host: "workstation.lan:" + wildcard.port })).resolves.toMatchObject({ status: 400 });
183
+ expect(health.calls).toBe(0);
184
+
185
+ await expect(rawRequest(wildcard, { host: "127.0.0.1:" + wildcard.port })).resolves.toMatchObject({ status: 200 });
186
+ await expect(rawRequest(wildcard, { host: machineHostname() + ":" + wildcard.port })).resolves.toMatchObject({ status: 200 });
187
+ const localIpv4 = firstLocalIpv4();
188
+ if (localIpv4 !== undefined) await expect(rawRequest(wildcard, { host: localIpv4 + ":" + wildcard.port })).resolves.toMatchObject({ status: 200 });
189
+ await expect(rawRequest(wildcard, { host: machineHostname() + ":" + (wildcard.port + 1) })).resolves.toMatchObject({ status: 400 });
190
+ });
191
+
192
+ test("accepts hostnames named by allowedHosts on a wildcard bind", async () => {
193
+ const proxied = await startServer({ host: "0.0.0.0", port: 0, allowedHosts: ["Console.lan"] });
194
+ registerHealth(proxied);
195
+
196
+ await expect(rawRequest(proxied, { host: "console.lan:" + proxied.port })).resolves.toMatchObject({ status: 200 });
197
+ await expect(rawRequest(proxied, { host: "console.lan:" + proxied.port, origin: "http://console.lan:" + proxied.port }, "POST")).resolves.toMatchObject({
198
+ status: 200,
199
+ });
200
+ await expect(rawRequest(proxied, { host: "other.lan:" + proxied.port })).resolves.toMatchObject({ status: 400 });
201
+ });
202
+
203
+ test("canonicalizes configured hostnames the same way as the Host header", async () => {
204
+ // WHATWG URL compresses the IPv6 literal in the Host header, so a bind address or allowlist entry written out in full has to go through the same parser or it can never match.
205
+ const server = await startServer({ host: "127.0.0.1", port: 0, allowedHosts: ["2001:db8:0:0:0:0:0:1", "WORKSTATION.LAN"] });
206
+ registerHealth(server);
207
+
208
+ await expect(rawRequest(server, { host: "[2001:db8::1]:" + server.port })).resolves.toMatchObject({ status: 200 });
209
+ await expect(rawRequest(server, { host: "workstation.lan:" + server.port })).resolves.toMatchObject({ status: 200 });
210
+ await expect(rawRequest(server, { host: "[2001:db8::2]:" + server.port })).resolves.toMatchObject({ status: 400 });
211
+ });
212
+
213
+ test("serves an empty configured host, which listen() treats as a wildcard bind", async () => {
214
+ // "http://" + "" is not a parsable URL base, so resolving the request path against the raw configured host threw a TypeError out of the request listener and killed the process on the very first request.
215
+ const server = await startServer({ host: "", port: 0 });
216
+ const health = registerHealth(server);
217
+
218
+ expect(server.url).toBe("http://127.0.0.1:" + server.port);
219
+ await expect(rawRequest(server, { host: "127.0.0.1:" + server.port })).resolves.toMatchObject({ status: 200, body: "ok" });
220
+ await expect(rawRequest(server, { host: "evil.example:" + server.port })).resolves.toMatchObject({ status: 400 });
221
+ expect(health.calls).toBe(1);
222
+ });
171
223
 
224
+ test("serves an IPv6 bind at its bracketed URL", async () => {
172
225
  const ipv6 = await startServer({ host: "::1", port: 0 });
173
226
  registerHealth(ipv6);
174
227
  expect(ipv6.url).toBe("http://[::1]:" + ipv6.port);