@blackbelt-technology/pi-agent-dashboard 0.2.9 → 0.4.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 (238) hide show
  1. package/AGENTS.md +64 -8
  2. package/README.md +308 -101
  3. package/docs/architecture.md +515 -16
  4. package/package.json +14 -7
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  8. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  9. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  10. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  11. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  12. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  13. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  14. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  15. package/packages/extension/src/ask-user-tool.ts +289 -20
  16. package/packages/extension/src/bridge.ts +107 -6
  17. package/packages/extension/src/command-handler.ts +34 -39
  18. package/packages/extension/src/dev-build.ts +1 -1
  19. package/packages/extension/src/git-info.ts +9 -19
  20. package/packages/extension/src/pi-env.d.ts +1 -0
  21. package/packages/extension/src/process-scanner.ts +72 -38
  22. package/packages/extension/src/prompt-expander.ts +25 -4
  23. package/packages/extension/src/provider-register.ts +304 -16
  24. package/packages/extension/src/server-auto-start.ts +27 -1
  25. package/packages/extension/src/server-launcher.ts +71 -27
  26. package/packages/server/package.json +17 -2
  27. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  28. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  29. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  30. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  31. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  32. package/packages/server/src/__tests__/browse-endpoint.test.ts +246 -10
  33. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  34. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  35. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  36. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  37. package/packages/server/src/__tests__/cors.test.ts +34 -2
  38. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  39. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  40. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  41. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  42. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  43. package/packages/server/src/__tests__/editor-registry.test.ts +29 -15
  44. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  45. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  46. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  47. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  48. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  49. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  50. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  51. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  52. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  53. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  54. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  55. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  56. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  57. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  58. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +126 -0
  59. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  60. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  61. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  62. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  63. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  64. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  65. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  66. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  67. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  68. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  69. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  70. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  71. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  72. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  73. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  74. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  75. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  76. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  77. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  78. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  79. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  80. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  81. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  82. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  83. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  84. package/packages/server/src/__tests__/tunnel.test.ts +103 -6
  85. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  86. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  87. package/packages/server/src/bootstrap-queue.ts +130 -0
  88. package/packages/server/src/bootstrap-state.ts +131 -0
  89. package/packages/server/src/browse.ts +108 -9
  90. package/packages/server/src/browser-gateway.ts +16 -3
  91. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  92. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  93. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  94. package/packages/server/src/cli.ts +256 -32
  95. package/packages/server/src/config-api.ts +16 -0
  96. package/packages/server/src/directory-service.ts +270 -39
  97. package/packages/server/src/editor-detection.ts +12 -9
  98. package/packages/server/src/editor-manager.ts +39 -5
  99. package/packages/server/src/editor-pid-registry.ts +199 -0
  100. package/packages/server/src/editor-registry.ts +22 -25
  101. package/packages/server/src/fix-pty-permissions.ts +44 -0
  102. package/packages/server/src/git-operations.ts +1 -1
  103. package/packages/server/src/headless-pid-registry.ts +16 -20
  104. package/packages/server/src/home-lock-release.ts +72 -0
  105. package/packages/server/src/home-lock.ts +389 -0
  106. package/packages/server/src/node-guard.ts +52 -0
  107. package/packages/server/src/npm-search-proxy.ts +71 -0
  108. package/packages/server/src/openspec-tasks.ts +158 -0
  109. package/packages/server/src/package-manager-wrapper.ts +225 -34
  110. package/packages/server/src/pi-core-checker.ts +290 -0
  111. package/packages/server/src/pi-core-updater.ts +172 -0
  112. package/packages/server/src/pi-gateway.ts +7 -0
  113. package/packages/server/src/pi-resource-scanner.ts +5 -8
  114. package/packages/server/src/pi-version-skew.ts +196 -0
  115. package/packages/server/src/preferences-store.ts +17 -3
  116. package/packages/server/src/process-manager.ts +403 -222
  117. package/packages/server/src/provider-probe.ts +234 -0
  118. package/packages/server/src/restart-helper.ts +130 -0
  119. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  120. package/packages/server/src/routes/file-routes.ts +30 -3
  121. package/packages/server/src/routes/openspec-routes.ts +107 -1
  122. package/packages/server/src/routes/pi-core-routes.ts +140 -0
  123. package/packages/server/src/routes/provider-auth-routes.ts +12 -10
  124. package/packages/server/src/routes/provider-routes.ts +55 -2
  125. package/packages/server/src/routes/recommended-routes.ts +225 -0
  126. package/packages/server/src/routes/system-routes.ts +30 -34
  127. package/packages/server/src/routes/tool-routes.ts +153 -0
  128. package/packages/server/src/server-pid.ts +5 -9
  129. package/packages/server/src/server.ts +363 -26
  130. package/packages/server/src/session-api.ts +77 -8
  131. package/packages/server/src/session-bootstrap.ts +17 -3
  132. package/packages/server/src/session-diff.ts +21 -21
  133. package/packages/server/src/terminal-manager.ts +65 -20
  134. package/packages/server/src/test-env-guard.ts +26 -0
  135. package/packages/server/src/test-support/test-server.ts +63 -0
  136. package/packages/server/src/tunnel.ts +172 -34
  137. package/packages/shared/package.json +10 -3
  138. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  139. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  140. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  141. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  142. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  143. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  144. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  145. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  146. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  147. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  148. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  149. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  150. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  151. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  152. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  153. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  154. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  155. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  156. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  157. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  158. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  159. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  160. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  161. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  162. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  163. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  164. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  165. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  166. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  167. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  168. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  169. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  170. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  172. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  173. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  174. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  175. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  176. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  177. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  178. package/packages/shared/src/__tests__/config.test.ts +59 -3
  179. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  180. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  181. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  182. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  183. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  184. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  185. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  186. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  187. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  188. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  189. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  190. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  191. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  192. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  193. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  194. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  195. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  196. package/packages/shared/src/__tests__/recommended-extensions.test.ts +156 -0
  197. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  198. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  199. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  200. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  201. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  202. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  203. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  204. package/packages/shared/src/bootstrap-install.ts +212 -0
  205. package/packages/shared/src/bridge-register.ts +87 -20
  206. package/packages/shared/src/browser-protocol.ts +93 -1
  207. package/packages/shared/src/config.ts +87 -15
  208. package/packages/shared/src/managed-paths.ts +31 -4
  209. package/packages/shared/src/openspec-poller.ts +71 -49
  210. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  211. package/packages/shared/src/platform/commands.ts +100 -0
  212. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  213. package/packages/shared/src/platform/exec.ts +220 -0
  214. package/packages/shared/src/platform/git.ts +155 -0
  215. package/packages/shared/src/platform/index.ts +15 -0
  216. package/packages/shared/src/platform/npm.ts +162 -0
  217. package/packages/shared/src/platform/openspec.ts +91 -0
  218. package/packages/shared/src/platform/paths.ts +276 -0
  219. package/packages/shared/src/platform/process-identify.ts +126 -0
  220. package/packages/shared/src/platform/process-scan.ts +94 -0
  221. package/packages/shared/src/platform/process.ts +168 -0
  222. package/packages/shared/src/platform/runner.ts +369 -0
  223. package/packages/shared/src/platform/shell.ts +44 -0
  224. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  225. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  226. package/packages/shared/src/recommended-extensions.ts +196 -0
  227. package/packages/shared/src/resolve-jiti.ts +62 -3
  228. package/packages/shared/src/rest-api.ts +97 -0
  229. package/packages/shared/src/semaphore.ts +83 -0
  230. package/packages/shared/src/source-matching.ts +126 -0
  231. package/packages/shared/src/test-support/setup-home.ts +74 -0
  232. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  233. package/packages/shared/src/tool-registry/index.ts +56 -0
  234. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  235. package/packages/shared/src/tool-registry/registry.ts +262 -0
  236. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  237. package/packages/shared/src/tool-registry/types.ts +180 -0
  238. package/packages/shared/src/types.ts +7 -0
@@ -2,7 +2,8 @@
2
2
  * Smoke integration tests — validates end-to-end flows without SQLite.
3
3
  */
4
4
  import { describe, it, expect, afterAll } from "vitest";
5
- import { createServer, type DashboardServer, type ServerConfig } from "../server.js";
5
+ import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
6
+ import type { DashboardServer } from "../server.js";
6
7
  import { WebSocket } from "ws";
7
8
 
8
9
  function waitForOpen(ws: WebSocket): Promise<void> {
@@ -24,22 +25,21 @@ function collectMsgs(ws: WebSocket, ms: number): Promise<any[]> {
24
25
  }
25
26
 
26
27
  const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
27
- const httpPort = 19070;
28
- const piPort = 19071;
28
+ let handle: TestServerHandle;
29
29
  let server: DashboardServer;
30
+ let httpPort: number;
31
+ let piPort: number;
30
32
 
31
33
  describe("Smoke integration", () => {
32
34
  afterAll(async () => {
33
- if (server) await server.stop();
35
+ if (handle) await handle.stop();
34
36
  });
35
37
 
36
38
  it("9.2 — events flow and replay from memory on reconnect", async () => {
37
- server = await createServer({
38
- port: httpPort, piPort, dev: true,
39
- autoShutdown: false, shutdownIdleSeconds: 999, tunnel: false,
40
- editor: { idleTimeoutMinutes: 10, maxInstances: 3 },
41
- });
42
- await server.start();
39
+ handle = await createTestServer();
40
+ server = handle.server;
41
+ httpPort = handle.httpPort;
42
+ piPort = handle.piPort;
43
43
 
44
44
  // Bridge connects and registers
45
45
  const bridge = new WebSocket(`ws://localhost:${piPort}`);
@@ -25,6 +25,12 @@ vi.mock("node-pty", () => ({
25
25
  })),
26
26
  }));
27
27
 
28
+ // Mock platform/process.ts killProcess so the Windows path is observable in tests.
29
+ const mockKillProcess = vi.fn((..._args: unknown[]) => Promise.resolve({ ok: true, forced: false }));
30
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/process.js", () => ({
31
+ killProcess: (...args: unknown[]) => mockKillProcess(...args),
32
+ }));
33
+
28
34
  describe("TerminalManager", () => {
29
35
  let manager: TerminalManager;
30
36
  let exitCallbacks: Array<(termId: string) => void>;
@@ -112,10 +118,44 @@ describe("TerminalManager", () => {
112
118
  });
113
119
 
114
120
  describe("kill", () => {
115
- it("sends SIGHUP to PTY (bash on Linux ignores SIGTERM)", () => {
121
+ beforeEach(() => {
122
+ mockKillProcess.mockClear();
123
+ });
124
+
125
+ it("POSIX: sends SIGHUP to PTY (bash on Linux ignores SIGTERM)", () => {
126
+ if (process.platform === "win32") return; // skipped on Windows; covered below
116
127
  const session = manager.spawn("/tmp");
117
128
  manager.kill(session.id);
118
129
  expect(mockPtyKill).toHaveBeenCalledWith("SIGHUP");
130
+ expect(mockKillProcess).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("Windows: routes kill through platform killProcess (tree kill via taskkill /F /T)", () => {
134
+ if (process.platform !== "win32") return; // skipped off-Windows
135
+ const session = manager.spawn("C:\\tmp");
136
+ manager.kill(session.id);
137
+ // pty.kill MUST NOT be called on Windows — killProcess(pid) does the tree-kill.
138
+ expect(mockPtyKill).not.toHaveBeenCalled();
139
+ expect(mockKillProcess).toHaveBeenCalledWith(12345, expect.objectContaining({ timeoutMs: 2000 }));
140
+ });
141
+
142
+ it("fallback cleanup fires if onExit does not within 3 s (simulates Windows ConPTY)", async () => {
143
+ vi.useFakeTimers();
144
+ try {
145
+ const session = manager.spawn("/tmp");
146
+ let exitCalled = false;
147
+ manager = createTerminalManager({
148
+ onExit: () => { exitCalled = true; },
149
+ });
150
+ const session2 = manager.spawn("/tmp");
151
+ manager.kill(session2.id);
152
+ // Simulate node-pty NOT firing onExit (the actual Windows failure mode).
153
+ await vi.advanceTimersByTimeAsync(3001);
154
+ expect(exitCalled).toBe(true);
155
+ expect(manager.get(session2.id)).toBeUndefined(); // removed from map
156
+ } finally {
157
+ vi.useRealTimers();
158
+ }
119
159
  });
120
160
 
121
161
  it("throws for unknown ID", () => {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Canary for createTestServer(): verifies that port:0 end-to-end resolution
3
+ * works and the helper returns non-zero, distinct ports.
4
+ *
5
+ * This test exists to de-risk the integration-test migration (tasks 4.x).
6
+ * If createServer / piGateway ever stop propagating resolved ports, this
7
+ * fails loudly before the other tests are touched.
8
+ */
9
+ import { describe, it, expect, afterAll } from "vitest";
10
+ import { createTestServer, type TestServerHandle } from "../test-support/test-server.js";
11
+
12
+ let handle: TestServerHandle | undefined;
13
+
14
+ describe("createTestServer (port:0 canary)", () => {
15
+ afterAll(async () => {
16
+ if (handle) await handle.stop();
17
+ });
18
+
19
+ it("resolves non-zero distinct ports and answers /api/health", async () => {
20
+ handle = await createTestServer();
21
+
22
+ expect(handle.httpPort).toBeGreaterThan(0);
23
+ expect(handle.piPort).toBeGreaterThan(0);
24
+ expect(handle.httpPort).not.toBe(handle.piPort);
25
+
26
+ const res = await fetch(`http://localhost:${handle.httpPort}/api/health`);
27
+ expect(res.status).toBe(200);
28
+ const body = await res.json();
29
+ expect(body.ok).toBe(true);
30
+ }, 15000);
31
+ });
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Tests for /api/tools REST routes.
3
+ *
4
+ * Covers: list, get single, rescan (all / one), set override, clear
5
+ * override, unknown-tool 404, bad-body 400, diagnostics text format.
6
+ */
7
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
8
+ import Fastify, { type FastifyInstance } from "fastify";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import {
13
+ ToolRegistry,
14
+ OverridesStore,
15
+ registerDefaultTools,
16
+ type Strategy,
17
+ } from "@blackbelt-technology/pi-dashboard-shared/tool-registry/index.js";
18
+ import { registerToolRoutes, formatDiagnostics } from "../routes/tool-routes.js";
19
+
20
+ // ── Helpers ────────────────────────────────────────────────────────────────
21
+
22
+ function noGuard() {
23
+ return async () => { /* allow all */ };
24
+ }
25
+
26
+ function tmpOverridesPath(): string {
27
+ return path.join(
28
+ os.tmpdir(),
29
+ `tool-routes-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Build a registry with two fake tools: `pi` (resolves) and `ghost`
35
+ * (never resolves). The `override` strategy honors ctx.overrides so
36
+ * set/clear flows are observable.
37
+ */
38
+ function buildRegistry(opts?: { existsPath?: string }): ToolRegistry {
39
+ const overrides = new OverridesStore({ filePath: tmpOverridesPath(), warn: () => {} });
40
+ const r = new ToolRegistry({ overrides, platform: "linux" });
41
+
42
+ const piStrategies: Strategy[] = [
43
+ {
44
+ name: "override",
45
+ run: (ctx) => ctx.overrides["pi"]
46
+ ? { ok: true, path: ctx.overrides["pi"] }
47
+ : { ok: false, reason: "no override set" },
48
+ },
49
+ { name: "where", run: () => ({ ok: true, path: opts?.existsPath ?? "/usr/bin/pi" }) },
50
+ ];
51
+ const ghostStrategies: Strategy[] = [
52
+ { name: "override", run: (ctx) => ctx.overrides["ghost"]
53
+ ? { ok: true, path: ctx.overrides["ghost"] }
54
+ : { ok: false, reason: "no override set" } },
55
+ { name: "where", run: () => ({ ok: false, reason: "not found on PATH" }) },
56
+ ];
57
+ r.register({ name: "pi", kind: "binary", strategies: piStrategies });
58
+ r.register({ name: "ghost", kind: "binary", strategies: ghostStrategies });
59
+ return r;
60
+ }
61
+
62
+ function buildServer(registry: ToolRegistry): FastifyInstance {
63
+ const fastify = Fastify();
64
+ registerToolRoutes(fastify, { registry, networkGuard: noGuard() });
65
+ return fastify;
66
+ }
67
+
68
+ // ── Tests ──────────────────────────────────────────────────────────────────
69
+
70
+ describe("GET /api/tools", () => {
71
+ let fastify: FastifyInstance;
72
+ beforeEach(() => { fastify = buildServer(buildRegistry()); });
73
+ afterEach(async () => { await fastify.close(); });
74
+
75
+ it("returns all registered tools", async () => {
76
+ const res = await fastify.inject({ method: "GET", url: "/api/tools" });
77
+ expect(res.statusCode).toBe(200);
78
+ const body = res.json();
79
+ expect(body.success).toBe(true);
80
+ const names = body.data.tools.map((t: any) => t.name).sort();
81
+ expect(names).toEqual(["ghost", "pi"]);
82
+ });
83
+ });
84
+
85
+ describe("GET /api/tools/:name", () => {
86
+ let fastify: FastifyInstance;
87
+ beforeEach(() => { fastify = buildServer(buildRegistry()); });
88
+ afterEach(async () => { await fastify.close(); });
89
+
90
+ it("returns the resolution for a known tool", async () => {
91
+ const res = await fastify.inject({ method: "GET", url: "/api/tools/pi" });
92
+ expect(res.statusCode).toBe(200);
93
+ const body = res.json();
94
+ expect(body.data.name).toBe("pi");
95
+ expect(body.data.ok).toBe(true);
96
+ expect(body.data.path).toBe("/usr/bin/pi");
97
+ });
98
+
99
+ it("404s for an unregistered tool", async () => {
100
+ const res = await fastify.inject({ method: "GET", url: "/api/tools/bogus" });
101
+ expect(res.statusCode).toBe(404);
102
+ expect(res.json().error).toMatch(/Unknown tool/);
103
+ });
104
+ });
105
+
106
+ describe("POST /api/tools/rescan", () => {
107
+ it("rescan() without body clears all caches", async () => {
108
+ let calls = 0;
109
+ const overrides = new OverridesStore({ filePath: tmpOverridesPath(), warn: () => {} });
110
+ const r = new ToolRegistry({ overrides, platform: "linux" });
111
+ r.register({
112
+ name: "pi",
113
+ kind: "binary",
114
+ strategies: [{ name: "where", run: () => ({ ok: true, path: `/pi${++calls}` }) }],
115
+ });
116
+ const fastify = buildServer(r);
117
+
118
+ await fastify.inject({ method: "GET", url: "/api/tools/pi" });
119
+ expect(r.resolve("pi").path).toBe("/pi1");
120
+
121
+ const res = await fastify.inject({ method: "POST", url: "/api/tools/rescan", payload: {} });
122
+ expect(res.statusCode).toBe(200);
123
+ expect(res.json().data.tools[0].path).toBe("/pi2");
124
+ await fastify.close();
125
+ });
126
+
127
+ it("rescan({ name }) only clears that tool's cache", async () => {
128
+ let piCalls = 0, ghostCalls = 0;
129
+ const overrides = new OverridesStore({ filePath: tmpOverridesPath(), warn: () => {} });
130
+ const r = new ToolRegistry({ overrides, platform: "linux" });
131
+ r.register({
132
+ name: "pi",
133
+ kind: "binary",
134
+ strategies: [{ name: "where", run: () => ({ ok: true, path: `/pi${++piCalls}` }) }],
135
+ });
136
+ r.register({
137
+ name: "ghost",
138
+ kind: "binary",
139
+ strategies: [{ name: "where", run: () => ({ ok: true, path: `/ghost${++ghostCalls}` }) }],
140
+ });
141
+ const fastify = buildServer(r);
142
+
143
+ r.resolve("pi"); r.resolve("ghost");
144
+ await fastify.inject({
145
+ method: "POST", url: "/api/tools/rescan", payload: { name: "pi" },
146
+ });
147
+ expect(r.resolve("pi").path).toBe("/pi2");
148
+ expect(r.resolve("ghost").path).toBe("/ghost1"); // unchanged
149
+ await fastify.close();
150
+ });
151
+
152
+ it("404s when rescanning an unknown name", async () => {
153
+ const fastify = buildServer(buildRegistry());
154
+ const res = await fastify.inject({
155
+ method: "POST", url: "/api/tools/rescan", payload: { name: "bogus" },
156
+ });
157
+ expect(res.statusCode).toBe(404);
158
+ await fastify.close();
159
+ });
160
+ });
161
+
162
+ describe("PUT /api/tools/:name (set override)", () => {
163
+ it("sets the override and returns refreshed Resolution", async () => {
164
+ const r = buildRegistry();
165
+ const fastify = buildServer(r);
166
+
167
+ const res = await fastify.inject({
168
+ method: "PUT", url: "/api/tools/pi",
169
+ payload: { path: "/custom/pi" },
170
+ });
171
+ expect(res.statusCode).toBe(200);
172
+ const body = res.json();
173
+ expect(body.data.source).toBe("override");
174
+ expect(body.data.path).toBe("/custom/pi");
175
+ await fastify.close();
176
+ });
177
+
178
+ it("400s on missing path body", async () => {
179
+ const fastify = buildServer(buildRegistry());
180
+ const res = await fastify.inject({
181
+ method: "PUT", url: "/api/tools/pi", payload: {},
182
+ });
183
+ expect(res.statusCode).toBe(400);
184
+ await fastify.close();
185
+ });
186
+
187
+ it("404s for unknown tool name", async () => {
188
+ const fastify = buildServer(buildRegistry());
189
+ const res = await fastify.inject({
190
+ method: "PUT", url: "/api/tools/bogus", payload: { path: "/x" },
191
+ });
192
+ expect(res.statusCode).toBe(404);
193
+ await fastify.close();
194
+ });
195
+ });
196
+
197
+ describe("DELETE /api/tools/:name (clear override)", () => {
198
+ it("clears the override and returns refreshed Resolution", async () => {
199
+ const r = buildRegistry();
200
+ r.setOverride("pi", "/custom/pi");
201
+ const fastify = buildServer(r);
202
+
203
+ const res = await fastify.inject({ method: "DELETE", url: "/api/tools/pi" });
204
+ expect(res.statusCode).toBe(200);
205
+ expect(res.json().data.path).toBe("/usr/bin/pi");
206
+ await fastify.close();
207
+ });
208
+
209
+ it("404s for unknown tool", async () => {
210
+ const fastify = buildServer(buildRegistry());
211
+ const res = await fastify.inject({ method: "DELETE", url: "/api/tools/bogus" });
212
+ expect(res.statusCode).toBe(404);
213
+ await fastify.close();
214
+ });
215
+ });
216
+
217
+ describe("POST /api/tools/diagnostics", () => {
218
+ it("returns text/plain with per-tool headers and trail lines", async () => {
219
+ const fastify = buildServer(buildRegistry());
220
+ const res = await fastify.inject({ method: "POST", url: "/api/tools/diagnostics" });
221
+ expect(res.statusCode).toBe(200);
222
+ expect(res.headers["content-type"]).toMatch(/text\/plain/);
223
+ const body = res.body;
224
+ // Header line per tool
225
+ expect(body).toMatch(/^\[ok\] +pi /m);
226
+ expect(body).toMatch(/^\[miss\] +ghost /m);
227
+ // Each attempted strategy appears with a leading dash
228
+ expect(body).toMatch(/- where: ok/);
229
+ expect(body).toMatch(/- where: not found on PATH/);
230
+ await fastify.close();
231
+ });
232
+
233
+ it("formatDiagnostics is stable for unit testing", () => {
234
+ const text = formatDiagnostics([
235
+ {
236
+ name: "pi", ok: true, path: "/usr/bin/pi", source: "system",
237
+ tried: [{ strategy: "where", result: "ok" }],
238
+ resolvedAt: 0,
239
+ },
240
+ ]);
241
+ expect(text).toMatch(/\[ok\] +pi \(system\) → \/usr\/bin\/pi/);
242
+ expect(text).toMatch(/- where: ok/);
243
+ });
244
+ });
245
+
246
+ describe("integration with default tool definitions", () => {
247
+ it("the standard registry exposes pi, openspec, git, npm etc. via GET /api/tools", async () => {
248
+ const overrides = new OverridesStore({ filePath: tmpOverridesPath(), warn: () => {} });
249
+ const r = new ToolRegistry({ overrides, platform: "linux" });
250
+ registerDefaultTools(r, {
251
+ exists: () => false,
252
+ which: () => null,
253
+ npmRootGlobal: () => "",
254
+ });
255
+ const fastify = buildServer(r);
256
+
257
+ const res = await fastify.inject({ method: "GET", url: "/api/tools" });
258
+ expect(res.statusCode).toBe(200);
259
+ const names = res.json().data.tools.map((t: any) => t.name).sort();
260
+ expect(names).toEqual(expect.arrayContaining(["git", "node", "npm", "openspec", "pi", "pi-coding-agent", "zrok"]));
261
+ expect(names).not.toContain("tsx");
262
+ expect(names).not.toContain("pi-dashboard");
263
+ await fastify.close();
264
+ });
265
+ });
266
+
267
+ // Clean up tmp overrides files
268
+ afterAll();
269
+ function afterAll() {
270
+ try {
271
+ for (const f of fs.readdirSync(os.tmpdir())) {
272
+ if (f.startsWith("tool-routes-test-")) {
273
+ try { fs.unlinkSync(path.join(os.tmpdir(), f)); } catch {}
274
+ }
275
+ }
276
+ } catch {}
277
+ }
@@ -52,6 +52,25 @@ describe("trustedNetworks config", () => {
52
52
  expect(config.resolvedTrustedNetworks).toContain("10.0.0.0/8");
53
53
  });
54
54
 
55
+ // Companion to the test above. The archived trusted-networks spec scenario
56
+ // "trustedNetworks merged with auth.bypassHosts" as written did NOT include
57
+ // a `providers` field; the test above silently adds one to make it pass.
58
+ // This second test exercises the literal spec scenario — it would have
59
+ // failed pre-fix (parseAuthConfig nuked the whole auth block when
60
+ // providers was absent) and demonstrates the scenario as written now holds.
61
+ // See openspec/changes/fix-trusted-networks-no-oauth.
62
+ it("should merge trustedNetworks with auth.bypassHosts (no providers configured)", () => {
63
+ fs.writeFileSync(configFile, JSON.stringify({
64
+ trustedNetworks: ["192.168.1.0/24"],
65
+ auth: {
66
+ bypassHosts: ["10.0.0.0/8"],
67
+ },
68
+ }));
69
+ const config = loadConfig();
70
+ expect(config.resolvedTrustedNetworks).toContain("192.168.1.0/24");
71
+ expect(config.resolvedTrustedNetworks).toContain("10.0.0.0/8");
72
+ });
73
+
55
74
  it("should deduplicate entries", () => {
56
75
  fs.writeFileSync(configFile, JSON.stringify({
57
76
  trustedNetworks: ["192.168.1.0/24"],
@@ -0,0 +1,126 @@
1
+ /**
2
+ * End-to-end regression test for fix-trusted-networks-no-oauth.
3
+ *
4
+ * Round-trips a bypassHosts-only auth config through the full
5
+ * write → disk → loadConfig → resolvedTrustedNetworks path.
6
+ *
7
+ * This is the test that would have caught the bug activated by
8
+ * eb24780 (consolidate-trusted-networks). The archived tasks.md
9
+ * section 5.4 claimed this case was "covered by unit test" — but
10
+ * the cited unit test only checked the React onChange handler's
11
+ * return value in memory, never writing to disk or reloading.
12
+ *
13
+ * This test asserts the end state: after the UI's PUT /api/config
14
+ * equivalent fires, a subsequent loadConfig() sees the entry in
15
+ * resolvedTrustedNetworks.
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
18
+ import { writeConfigPartial } from "../config-api.js";
19
+ import { loadConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import os from "node:os";
23
+
24
+ describe("fix-trusted-networks-no-oauth: round-trip", () => {
25
+ let testDir: string;
26
+ let configFile: string;
27
+ let origHome: string;
28
+
29
+ beforeEach(() => {
30
+ testDir = path.join(os.tmpdir(), `test-no-oauth-rt-${Date.now()}`);
31
+ fs.mkdirSync(path.join(testDir, ".pi", "dashboard"), { recursive: true });
32
+ configFile = path.join(testDir, ".pi", "dashboard", "config.json");
33
+ origHome = process.env.HOME!;
34
+ process.env.HOME = testDir;
35
+ });
36
+
37
+ afterEach(() => {
38
+ process.env.HOME = origHome;
39
+ if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
40
+ });
41
+
42
+ it("UI add → save → reload → resolvedTrustedNetworks contains entry", () => {
43
+ // Simulate fresh config (no auth section).
44
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
45
+
46
+ // Simulate the UI PUT /api/config from Settings → Security → Add
47
+ // with no OAuth configured (the case that broke users).
48
+ const writeResult = writeConfigPartial({
49
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
50
+ });
51
+ expect(writeResult.success).toBe(true);
52
+
53
+ // Disk assertion — the bypassHosts must actually land on disk.
54
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
55
+ expect(written.auth).toBeDefined();
56
+ expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
57
+
58
+ // Reload assertion — loadConfig must surface the entry in
59
+ // resolvedTrustedNetworks, which is what the network guard
60
+ // and the WS upgrade handler consult.
61
+ const loaded = loadConfig();
62
+ expect(loaded.auth).toBeDefined();
63
+ expect(loaded.auth!.bypassHosts).toEqual(["192.168.1.0/24"]);
64
+ expect(loaded.resolvedTrustedNetworks).toContain("192.168.1.0/24");
65
+ });
66
+
67
+ it("UI add with existing OAuth → round-trip preserves both", () => {
68
+ fs.writeFileSync(
69
+ configFile,
70
+ JSON.stringify({
71
+ port: 8000,
72
+ auth: {
73
+ secret: "s",
74
+ providers: { github: { clientId: "abc", clientSecret: "xyz" } },
75
+ },
76
+ }),
77
+ );
78
+
79
+ const writeResult = writeConfigPartial({
80
+ auth: { bypassHosts: ["10.0.0.0/8"] },
81
+ });
82
+ expect(writeResult.success).toBe(true);
83
+
84
+ const loaded = loadConfig();
85
+ expect(loaded.auth).toBeDefined();
86
+ expect(loaded.auth!.providers.github).toBeDefined();
87
+ expect(loaded.auth!.bypassHosts).toEqual(["10.0.0.0/8"]);
88
+ expect(loaded.resolvedTrustedNetworks).toContain("10.0.0.0/8");
89
+ });
90
+
91
+ it("UI clear → save → reload → entry gone from resolvedTrustedNetworks", () => {
92
+ fs.writeFileSync(
93
+ configFile,
94
+ JSON.stringify({
95
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
96
+ }),
97
+ );
98
+
99
+ const writeResult = writeConfigPartial({
100
+ auth: { bypassHosts: [] },
101
+ });
102
+ expect(writeResult.success).toBe(true);
103
+
104
+ const loaded = loadConfig();
105
+ // Loader returns auth === undefined when nothing auth-relevant remains.
106
+ // Either way, the trusted entry must not survive.
107
+ expect(loaded.resolvedTrustedNetworks).not.toContain("192.168.1.0/24");
108
+ expect(loaded.resolvedTrustedNetworks).toEqual([]);
109
+ });
110
+
111
+ it("hand-edited config (no UI write) with bypassHosts only → loads correctly", () => {
112
+ // This path simulates a user who edited config.json by hand
113
+ // rather than through the UI. The fix must make loadConfig
114
+ // honor this shape.
115
+ fs.writeFileSync(
116
+ configFile,
117
+ JSON.stringify({
118
+ auth: { providers: {}, bypassHosts: ["192.168.0.0/24"] },
119
+ }),
120
+ );
121
+
122
+ const loaded = loadConfig();
123
+ expect(loaded.auth).toBeDefined();
124
+ expect(loaded.resolvedTrustedNetworks).toContain("192.168.0.0/24");
125
+ });
126
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Tests that `cleanupStaleZrok` routes liveness + termination through the
3
+ * shared platform module rather than raw `process.kill`.
4
+ *
5
+ * See change: route-kill-paths-through-platform.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ const killProcessSpy = vi.fn(async (_pid: number, _opts?: any) => ({ ok: true, forced: false }));
13
+ const isProcessAliveSpy = vi.fn((_pid: number) => true);
14
+
15
+ vi.mock("@blackbelt-technology/pi-dashboard-shared/platform/process.js", async () => {
16
+ const actual = await vi.importActual<typeof import("@blackbelt-technology/pi-dashboard-shared/platform/process.js")>(
17
+ "@blackbelt-technology/pi-dashboard-shared/platform/process.js",
18
+ );
19
+ return {
20
+ ...actual,
21
+ killProcess: (pid: number, opts?: any) => killProcessSpy(pid, opts),
22
+ isProcessAlive: (pid: number) => isProcessAliveSpy(pid),
23
+ };
24
+ });
25
+
26
+ const { cleanupStaleZrok, writeZrokPid, readZrokPid } = await import("../tunnel.js");
27
+
28
+ function pidFile(): string {
29
+ return path.join(os.homedir(), ".pi", "dashboard", "zrok.pid");
30
+ }
31
+
32
+ describe("cleanupStaleZrok uses platform helpers", () => {
33
+ const original = readZrokPid();
34
+
35
+ beforeEach(() => {
36
+ killProcessSpy.mockClear();
37
+ killProcessSpy.mockImplementation(async () => ({ ok: true, forced: false }));
38
+ isProcessAliveSpy.mockClear();
39
+ isProcessAliveSpy.mockReturnValue(true);
40
+ });
41
+
42
+ afterEach(() => {
43
+ // Restore prior PID file content if any.
44
+ try {
45
+ if (original !== null) writeZrokPid(original);
46
+ else fs.unlinkSync(pidFile());
47
+ } catch { /* ignore */ }
48
+ });
49
+
50
+ it("calls platform killProcess when a live stale PID exists", async () => {
51
+ writeZrokPid(654321);
52
+ isProcessAliveSpy.mockReturnValue(true);
53
+
54
+ await cleanupStaleZrok();
55
+
56
+ expect(isProcessAliveSpy).toHaveBeenCalledWith(654321);
57
+ expect(killProcessSpy).toHaveBeenCalledOnce();
58
+ expect(killProcessSpy).toHaveBeenCalledWith(654321, expect.any(Object));
59
+ // PID file removed after cleanup
60
+ expect(readZrokPid()).toBeNull();
61
+ });
62
+
63
+ it("skips killProcess when PID is already dead but still removes PID file", async () => {
64
+ writeZrokPid(654322);
65
+ isProcessAliveSpy.mockReturnValue(false);
66
+
67
+ await cleanupStaleZrok();
68
+
69
+ expect(killProcessSpy).not.toHaveBeenCalled();
70
+ expect(readZrokPid()).toBeNull();
71
+ });
72
+
73
+ it("no-ops when no PID file exists", async () => {
74
+ try { fs.unlinkSync(pidFile()); } catch { /* ignore */ }
75
+ await cleanupStaleZrok();
76
+ expect(killProcessSpy).not.toHaveBeenCalled();
77
+ expect(isProcessAliveSpy).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("does not invoke process.kill directly", async () => {
81
+ writeZrokPid(654323);
82
+ isProcessAliveSpy.mockReturnValue(true);
83
+ const processKillSpy = vi.spyOn(process, "kill");
84
+
85
+ await cleanupStaleZrok();
86
+
87
+ expect(processKillSpy).not.toHaveBeenCalled();
88
+ processKillSpy.mockRestore();
89
+ });
90
+ });