@blackbelt-technology/pi-agent-dashboard 0.3.0 → 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 (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -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
+ });
@@ -160,30 +160,32 @@ describe("PID file helpers", () => {
160
160
  });
161
161
 
162
162
  describe("cleanupStaleZrok", () => {
163
- it("should do nothing when no PID file exists", () => {
163
+ it("should do nothing when no PID file exists", async () => {
164
164
  vi.mocked(fs.readFileSync).mockImplementation(() => {
165
165
  throw new Error("ENOENT");
166
166
  });
167
167
  const killSpy = vi.spyOn(process, "kill");
168
168
 
169
- cleanupStaleZrok();
169
+ await cleanupStaleZrok();
170
170
 
171
171
  expect(killSpy).not.toHaveBeenCalled();
172
172
  });
173
173
 
174
- it("should kill running stale process and remove PID file", () => {
174
+ it("should kill running stale process and remove PID file", async () => {
175
175
  vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
176
176
  const killSpy = vi.spyOn(process, "kill").mockReturnValue(true);
177
177
  vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
178
178
 
179
- cleanupStaleZrok();
179
+ // cleanupStaleZrok became async when it moved to platform/process's
180
+ // killProcess (SIGTERM+grace+SIGKILL orchestration).
181
+ await cleanupStaleZrok();
180
182
 
181
183
  expect(killSpy).toHaveBeenCalledWith(99999, 0);
182
184
  expect(killSpy).toHaveBeenCalledWith(99999, "SIGTERM");
183
185
  expect(fs.unlinkSync).toHaveBeenCalled();
184
186
  });
185
187
 
186
- it("should just remove PID file if process is not running", () => {
188
+ it("should just remove PID file if process is not running", async () => {
187
189
  vi.mocked(fs.readFileSync).mockReturnValue("99999\n");
188
190
  const killSpy = vi.spyOn(process, "kill").mockImplementation((_pid: number, signal?: string | number) => {
189
191
  if (signal === 0) throw new Error("ESRCH");
@@ -191,7 +193,7 @@ describe("cleanupStaleZrok", () => {
191
193
  });
192
194
  vi.mocked(fs.unlinkSync).mockReturnValue(undefined);
193
195
 
194
- cleanupStaleZrok();
196
+ await cleanupStaleZrok();
195
197
 
196
198
  expect(killSpy).toHaveBeenCalledTimes(1);
197
199
  expect(fs.unlinkSync).toHaveBeenCalled();
@@ -254,8 +256,12 @@ describe("scavengeOrphanZrokProcesses", () => {
254
256
  const killed = scavengeOrphanZrokProcesses(8000);
255
257
 
256
258
  expect(killed).toEqual([12345]);
257
- expect(killSpy).toHaveBeenCalledWith(12345, "SIGTERM");
259
+ // Negative PID targets the whole process group on Unix (killPidWithGroup's
260
+ // contract); positive PID on Windows. Match whichever platform we're on.
261
+ const expectedPid = process.platform === "win32" ? 12345 : -12345;
262
+ expect(killSpy).toHaveBeenCalledWith(expectedPid, "SIGTERM");
258
263
  expect(killSpy).not.toHaveBeenCalledWith(12346, expect.anything());
264
+ expect(killSpy).not.toHaveBeenCalledWith(-12346, expect.anything());
259
265
  });
260
266
 
261
267
  it("should return empty array on ps failure", () => {
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { _resetWslTmuxCacheForTests } from "../process-manager.js";
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = path.dirname(__filename);
9
+
10
+ /**
11
+ * Structural test: the WSL-tmux probe MUST have a module-scoped cache so it's
12
+ * invoked at most once per server lifetime. Without this cache, users on
13
+ * Windows without `wt.exe` pay the full WSL VM cold-start cost (1.5–30 s) on
14
+ * every + Session click.
15
+ *
16
+ * We assert the cache exists by source inspection (tight, deterministic) and
17
+ * that a reset helper is exported for tests.
18
+ */
19
+ describe("WSL-tmux probe cache invariant", () => {
20
+ const src = readFileSync(
21
+ path.resolve(__dirname, "../process-manager.ts"),
22
+ "utf-8",
23
+ );
24
+
25
+ it("process-manager.ts declares _wslTmuxAvailabilityCache", () => {
26
+ expect(src).toMatch(/let\s+_wslTmuxAvailabilityCache\s*:\s*boolean\s*\|\s*null\s*=\s*null/);
27
+ });
28
+
29
+ it("isWslTmuxAvailable() returns the cached value when non-null", () => {
30
+ expect(src).toMatch(/if\s*\(\s*_wslTmuxAvailabilityCache\s*!==\s*null\s*\)\s*return\s+_wslTmuxAvailabilityCache/);
31
+ });
32
+
33
+ it("fallback-log fires at most once per server run", () => {
34
+ expect(src).toMatch(/_wslFallbackLogged\s*=\s*true/);
35
+ expect(src).toMatch(/if\s*\(\s*!_wslTmuxAvailabilityCache\s*&&\s*!_wslFallbackLogged\s*\)/);
36
+ });
37
+
38
+ it("exports a cache-reset helper for tests", () => {
39
+ expect(typeof _resetWslTmuxCacheForTests).toBe("function");
40
+ // reset is idempotent — safe to call repeatedly
41
+ _resetWslTmuxCacheForTests();
42
+ _resetWslTmuxCacheForTests();
43
+ });
44
+ });