@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
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Tests for platform/detached-spawn.ts primitives.
3
+ *
4
+ * Uses real `node -e` subprocess fixtures (no mocking) so we can exercise
5
+ * the actual Node spawn path with libuv's detached semantics on whatever
6
+ * OS the test runs on.
7
+ *
8
+ * All platform-dependent helpers take an explicit `platform` argument so
9
+ * tests can exercise both branches. We never mutate `process.platform`
10
+ * and never `vi.mock`.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import { openSync, closeSync, readFileSync, mkdtempSync, rmSync } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
16
+ import { spawnDetached, waitForNoCrash, waitForReady } from "../platform/detached-spawn.js";
17
+
18
+ function tmpLog(): string {
19
+ const dir = mkdtempSync(path.join(os.tmpdir(), "dspawn-"));
20
+ return path.join(dir, "out.log");
21
+ }
22
+
23
+ describe("spawnDetached", () => {
24
+ it("spawns a real detached child with correct defaults", async () => {
25
+ const r = await spawnDetached({
26
+ cmd: process.execPath,
27
+ args: ["-e", "setTimeout(() => process.exit(0), 300)"],
28
+ });
29
+ expect(r.ok).toBe(true);
30
+ expect(r.pid).toBeTypeOf("number");
31
+ expect(r.process).toBeDefined();
32
+ // clean up
33
+ await new Promise((res) => r.process!.once("exit", res));
34
+ });
35
+
36
+ it("returns ok:false with error when cmd does not exist", async () => {
37
+ const r = await spawnDetached({
38
+ cmd: "/definitely/not/a/real/binary/nope.exe",
39
+ args: [],
40
+ });
41
+ expect(r.ok).toBe(false);
42
+ expect(r.error).toBeTruthy();
43
+ });
44
+
45
+ it("redirects stderr to logFd when provided", async () => {
46
+ const logPath = tmpLog();
47
+ const fd = openSync(logPath, "a");
48
+ try {
49
+ const r = await spawnDetached({
50
+ cmd: process.execPath,
51
+ args: ["-e", "process.stderr.write('BOOM'); setTimeout(() => process.exit(0), 100)"],
52
+ logFd: fd,
53
+ });
54
+ expect(r.ok).toBe(true);
55
+ await new Promise((res) => r.process!.once("exit", res));
56
+ } finally {
57
+ try { closeSync(fd); } catch { /* ignore */ }
58
+ }
59
+ const content = readFileSync(logPath, "utf-8");
60
+ expect(content).toContain("BOOM");
61
+ rmSync(path.dirname(logPath), { recursive: true, force: true });
62
+ });
63
+
64
+ it("does not keep parent event loop alive (unref)", async () => {
65
+ // Can only check behaviour indirectly: the returned pid/process exist
66
+ // and the child is running detached. Lifecycle survival is covered by
67
+ // Node's own libuv tests; we assert we didn't throw.
68
+ const r = await spawnDetached({
69
+ cmd: process.execPath,
70
+ args: ["-e", "setTimeout(() => process.exit(0), 100)"],
71
+ });
72
+ expect(r.ok).toBe(true);
73
+ await new Promise((res) => r.process!.once("exit", res));
74
+ });
75
+
76
+ // ── detach option ────────────────────────────────────────────────────────
77
+ //
78
+ // Behaviour of `detach` can't be directly observed at the Node level
79
+ // (libuv's UV_PROCESS_DETACHED flag + setpgid/JobObject are internal).
80
+ // These tests verify the OPTION is accepted and does not break spawn;
81
+ // lifecycle semantics are exercised in the integration smoke tests
82
+ // (phase 2.10 manual Windows check).
83
+
84
+ it("accepts detach: true (default behaviour, unchanged)", async () => {
85
+ const r = await spawnDetached({
86
+ cmd: process.execPath,
87
+ args: ["-e", "setTimeout(() => process.exit(0), 100)"],
88
+ detach: true,
89
+ });
90
+ expect(r.ok).toBe(true);
91
+ await new Promise((res) => r.process!.once("exit", res));
92
+ });
93
+
94
+ it("accepts detach: false without breaking spawn", async () => {
95
+ const r = await spawnDetached({
96
+ cmd: process.execPath,
97
+ args: ["-e", "setTimeout(() => process.exit(0), 100)"],
98
+ detach: false,
99
+ });
100
+ expect(r.ok).toBe(true);
101
+ await new Promise((res) => r.process!.once("exit", res));
102
+ });
103
+
104
+ it("accepts detach: undefined (implicit default)", async () => {
105
+ const r = await spawnDetached({
106
+ cmd: process.execPath,
107
+ args: ["-e", "setTimeout(() => process.exit(0), 100)"],
108
+ // detach is deliberately omitted
109
+ });
110
+ expect(r.ok).toBe(true);
111
+ await new Promise((res) => r.process!.once("exit", res));
112
+ });
113
+ });
114
+
115
+ describe("waitForNoCrash", () => {
116
+ it("returns ok:true when child outlives the window", async () => {
117
+ const r = await spawnDetached({
118
+ cmd: process.execPath,
119
+ args: ["-e", "setTimeout(() => process.exit(0), 1000)"],
120
+ });
121
+ expect(r.ok).toBe(true);
122
+ const gate = await waitForNoCrash({ child: r.process!, windowMs: 150 });
123
+ expect(gate.ok).toBe(true);
124
+ await new Promise((res) => r.process!.once("exit", res));
125
+ });
126
+
127
+ it("returns ok:false with exitCode when child exits early", async () => {
128
+ const r = await spawnDetached({
129
+ cmd: process.execPath,
130
+ args: ["-e", "process.exit(7)"],
131
+ });
132
+ expect(r.ok).toBe(true);
133
+ const gate = await waitForNoCrash({ child: r.process!, windowMs: 1000 });
134
+ expect(gate.ok).toBe(false);
135
+ expect(gate.exitCode).toBe(7);
136
+ });
137
+
138
+ it("respects a small windowMs and does not hang on live children", async () => {
139
+ const r = await spawnDetached({
140
+ cmd: process.execPath,
141
+ args: ["-e", "setTimeout(() => process.exit(0), 5000)"],
142
+ });
143
+ expect(r.ok).toBe(true);
144
+ const start = Date.now();
145
+ const gate = await waitForNoCrash({ child: r.process!, windowMs: 100 });
146
+ const elapsed = Date.now() - start;
147
+ expect(gate.ok).toBe(true);
148
+ expect(elapsed).toBeLessThan(500);
149
+ r.process!.kill();
150
+ await new Promise((res) => r.process!.once("exit", res));
151
+ });
152
+ });
153
+
154
+ describe("waitForReady", () => {
155
+ it("returns ok:true when probe succeeds", async () => {
156
+ const r = await waitForReady({
157
+ probe: async () => true,
158
+ deadlineMs: 1000,
159
+ pollIntervalMs: 50,
160
+ });
161
+ expect(r.ok).toBe(true);
162
+ });
163
+
164
+ it("returns ok:false with 'timeout' when probe never succeeds", async () => {
165
+ const r = await waitForReady({
166
+ probe: async () => false,
167
+ deadlineMs: 200,
168
+ pollIntervalMs: 50,
169
+ });
170
+ expect(r.ok).toBe(false);
171
+ expect(r.error).toBe("timeout");
172
+ });
173
+
174
+ it("short-circuits on child error event", async () => {
175
+ // Spawn a nonexistent path via spawnDetached — triggers error event.
176
+ const bad = await spawnDetached({
177
+ cmd: "/does/not/exist/XYZQQ",
178
+ args: [],
179
+ });
180
+ // bad.process may or may not exist depending on how Node surfaced the
181
+ // error. If it does, we can observe the short-circuit; if not, skip
182
+ // this specific assertion. Either way, waitForReady must not hang.
183
+ if (bad.process) {
184
+ const start = Date.now();
185
+ const r = await waitForReady({
186
+ probe: async () => false,
187
+ deadlineMs: 5000,
188
+ pollIntervalMs: 500,
189
+ child: bad.process,
190
+ });
191
+ const elapsed = Date.now() - start;
192
+ expect(r.ok).toBe(false);
193
+ expect(elapsed).toBeLessThan(5000);
194
+ }
195
+ });
196
+
197
+ it("waits indefinitely when deadlineMs is undefined (succeeds eventually)", async () => {
198
+ let calls = 0;
199
+ const start = Date.now();
200
+ const r = await waitForReady({
201
+ probe: async () => ++calls >= 5,
202
+ // deadlineMs intentionally omitted
203
+ pollIntervalMs: 30,
204
+ });
205
+ const elapsed = Date.now() - start;
206
+ expect(r.ok).toBe(true);
207
+ expect(calls).toBeGreaterThanOrEqual(5);
208
+ // ~5 polls at 30ms interval ≈ 120–180ms. Just ensure we're not
209
+ // short-circuiting suspiciously fast or hanging absurdly long.
210
+ expect(elapsed).toBeLessThan(2000);
211
+ });
212
+
213
+ it("waits indefinitely until child crashes (no deadline, child-exit wins)", async () => {
214
+ // Spawn a short-lived child that exits non-zero after ~200ms.
215
+ const bad = await spawnDetached({
216
+ cmd: process.execPath,
217
+ args: ["-e", "setTimeout(() => process.exit(1), 200)"],
218
+ });
219
+ expect(bad.ok).toBe(true);
220
+ const start = Date.now();
221
+ const r = await waitForReady({
222
+ probe: async () => false, // never ready
223
+ // deadlineMs intentionally omitted — relies on child-exit
224
+ pollIntervalMs: 50,
225
+ child: bad.process!,
226
+ });
227
+ const elapsed = Date.now() - start;
228
+ expect(r.ok).toBe(false);
229
+ expect(r.error).toMatch(/child exited/);
230
+ expect(elapsed).toBeLessThan(2000); // short-circuited, not stuck
231
+ });
232
+
233
+ it("polls at pollIntervalMs until probe flips", async () => {
234
+ let calls = 0;
235
+ const r = await waitForReady({
236
+ probe: async () => ++calls >= 3,
237
+ deadlineMs: 2000,
238
+ pollIntervalMs: 50,
239
+ });
240
+ expect(r.ok).toBe(true);
241
+ expect(calls).toBeGreaterThanOrEqual(3);
242
+ });
243
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Unit tests for `managed-paths.ts` getters.
3
+ *
4
+ * The constants (MANAGED_DIR, MANAGED_BIN, PI_SETTINGS_PATH) reflect
5
+ * the live environment at module-load time — those are covered by
6
+ * implicit use throughout the codebase. These tests pin the
7
+ * getter-with-override path used by the bootstrap harness and by
8
+ * future tests/proposals that need HOME injection.
9
+ */
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { describe, expect, it } from "vitest";
13
+ import {
14
+ MANAGED_BIN,
15
+ MANAGED_DIR,
16
+ PI_SETTINGS_PATH,
17
+ getManagedBin,
18
+ getManagedDir,
19
+ getPiSettingsPath,
20
+ } from "../managed-paths.js";
21
+
22
+ describe("managed-paths getters", () => {
23
+ it("getManagedDir() with no arg matches live MANAGED_DIR", () => {
24
+ expect(getManagedDir()).toBe(MANAGED_DIR);
25
+ expect(getManagedDir()).toBe(path.join(os.homedir(), ".pi-dashboard"));
26
+ });
27
+
28
+ it("getManagedBin() with no arg matches live MANAGED_BIN", () => {
29
+ expect(getManagedBin()).toBe(MANAGED_BIN);
30
+ });
31
+
32
+ it("getPiSettingsPath() with no arg matches live PI_SETTINGS_PATH", () => {
33
+ expect(getPiSettingsPath()).toBe(PI_SETTINGS_PATH);
34
+ });
35
+
36
+ it("getManagedDir({ homedir }) uses the override", () => {
37
+ expect(getManagedDir({ homedir: "/fake/home" })).toBe(
38
+ path.join("/fake/home", ".pi-dashboard"),
39
+ );
40
+ });
41
+
42
+ it("getManagedBin({ homedir }) composes from override", () => {
43
+ expect(getManagedBin({ homedir: "/fake/home" })).toBe(
44
+ path.join("/fake/home", ".pi-dashboard", "node_modules", ".bin"),
45
+ );
46
+ });
47
+
48
+ it("getPiSettingsPath({ homedir }) uses the override", () => {
49
+ expect(getPiSettingsPath({ homedir: "/fake/home" })).toBe(
50
+ path.join("/fake/home", ".pi", "agent", "settings.json"),
51
+ );
52
+ });
53
+
54
+ it("override-less getManagedDir and live MANAGED_DIR constant are in sync", () => {
55
+ // Sanity: if someone accidentally drifts the constant from the
56
+ // getter default, this catches it.
57
+ expect(getManagedDir()).toEqual(MANAGED_DIR);
58
+ expect(getManagedBin()).toEqual(MANAGED_BIN);
59
+ });
60
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Repo-level invariant: `node:child_process` MUST NOT be imported directly
3
+ * outside `packages/shared/src/platform/exec.ts` (and, once added,
4
+ * `packages/shared/src/platform/runner.ts`). All subprocess execution goes
5
+ * through the safe wrappers so `windowsHide: true` and other defaults are
6
+ * uniform.
7
+ *
8
+ * If this test fails, migrate the offending file's import to:
9
+ * import { ... } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";
10
+ *
11
+ * See change: platform-command-executor.
12
+ */
13
+ import { describe, it, expect } from "vitest";
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+ import url from "node:url";
17
+
18
+ /** Files allowed to import from node:child_process directly. */
19
+ const ALLOWLIST: readonly string[] = [
20
+ "packages/shared/src/platform/exec.ts",
21
+ "packages/shared/src/platform/runner.ts",
22
+ // Platform primitives that legitimately own the raw child_process
23
+ // APIs (Windows detached-spawn + cross-platform subprocess adapter).
24
+ // See change: consolidate-windows-spawn-and-platform-handlers.
25
+ "packages/shared/src/platform/detached-spawn.ts",
26
+ "packages/shared/src/platform/subprocess-adapter.ts",
27
+ ];
28
+
29
+ /**
30
+ * Regex catches any textual reference to the `node:child_process` module:
31
+ * - import X from "node:child_process"
32
+ * - import { X } from 'node:child_process'
33
+ * - require("node:child_process")
34
+ * - const X = await import("node:child_process")
35
+ *
36
+ * We intentionally match the `node:` prefix strictly — this codebase uses
37
+ * ESM node-protocol imports everywhere, and the bare `child_process`
38
+ * alias is already absent.
39
+ */
40
+ const CHILD_PROCESS_IMPORT_RE = /(?:from\s+|require\s*\(\s*|import\s*\(\s*)["']node:child_process["']/;
41
+
42
+ /**
43
+ * Per-line opt-out marker. Use for embedded scripts (e.g. `node -e` orchestrators
44
+ * or Electron renderer bootstrap strings) where the `require("node:child_process")`
45
+ * is source text that runs in a separate Node process, not an import by the
46
+ * host module. Add this comment on the same line as the allowed usage:
47
+ * const { spawn } = require("node:child_process"); // ban:child_process-ok
48
+ */
49
+ const OPT_OUT_MARKER = "ban:child_process-ok";
50
+
51
+ /** Recursively walk a directory, yielding all .ts / .tsx files. */
52
+ async function* walk(dir: string): AsyncGenerator<string> {
53
+ const entries = await fs.readdir(dir, { withFileTypes: true });
54
+ for (const entry of entries) {
55
+ const full = path.join(dir, entry.name);
56
+ if (entry.isDirectory()) {
57
+ // Skip nested node_modules, dist, and test directories
58
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
59
+ yield* walk(full);
60
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
61
+ yield full;
62
+ }
63
+ }
64
+ }
65
+
66
+ describe("no direct node:child_process imports outside platform/exec.ts", () => {
67
+ it("only allowlisted files import node:child_process", async () => {
68
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
69
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
70
+ const packagesDir = path.resolve(repoRoot, "packages");
71
+
72
+ const violations: Array<{ file: string; line: number; text: string }> = [];
73
+ const allowSet = new Set(
74
+ ALLOWLIST.map((p) => path.resolve(repoRoot, p).replace(/\\/g, "/")),
75
+ );
76
+
77
+ for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
78
+ if (!pkg.isDirectory()) continue;
79
+ const srcDir = path.join(packagesDir, pkg.name, "src");
80
+ try {
81
+ await fs.access(srcDir);
82
+ } catch {
83
+ continue; // package has no src/
84
+ }
85
+ for await (const file of walk(srcDir)) {
86
+ const normalized = file.replace(/\\/g, "/");
87
+ if (allowSet.has(normalized)) continue;
88
+
89
+ const content = await fs.readFile(file, "utf-8");
90
+ const lines = content.split(/\r?\n/);
91
+ lines.forEach((line, idx) => {
92
+ if (!CHILD_PROCESS_IMPORT_RE.test(line)) return;
93
+ if (line.includes(OPT_OUT_MARKER)) return;
94
+ violations.push({ file: path.relative(repoRoot, file), line: idx + 1, text: line.trim() });
95
+ });
96
+ }
97
+ }
98
+
99
+ if (violations.length > 0) {
100
+ const msg =
101
+ `Direct node:child_process imports found outside the allowlist.\n` +
102
+ `Migrate each to:\n` +
103
+ ` import { ... } from "@blackbelt-technology/pi-dashboard-shared/platform/exec.js";\n\n` +
104
+ `Offenders (${violations.length}):\n` +
105
+ violations
106
+ .map((v) => ` ${v.file}:${v.line} ${v.text}`)
107
+ .join("\n");
108
+ // Use a plain expect to surface the full diff in the test output.
109
+ expect(violations, msg).toEqual([]);
110
+ }
111
+ });
112
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Repo-level invariant: `process.platform === "<os>"` branches (and the
3
+ * inverse `!==` form) MUST NOT appear outside the canonical platform
4
+ * primitive locations. All OS-specific behaviour lives under
5
+ * `packages/shared/src/platform/**` (and `packages/electron/src/platform/**`
6
+ * for Electron-specific primitives) plus a small documented allowlist.
7
+ *
8
+ * If this test fails, either:
9
+ * (a) Move the platform-aware logic into a platform/* primitive that
10
+ * takes an optional `platform: NodeJS.Platform` parameter, OR
11
+ * (b) Add an opt-out marker `// platform-branch-ok` on the same line
12
+ * for a genuine, localised OS probe (e.g. a top-level env fingerprint).
13
+ *
14
+ * See change: consolidate-windows-spawn-and-platform-handlers.
15
+ */
16
+ import { describe, it, expect } from "vitest";
17
+ import fs from "node:fs/promises";
18
+ import path from "node:path";
19
+ import url from "node:url";
20
+
21
+ /**
22
+ * Files / directory-prefixes where platform branches are allowed.
23
+ *
24
+ * Each entry is a repo-relative path using forward slashes. Entries
25
+ * ending in `/` match any file under that directory (prefix match);
26
+ * entries without a trailing slash must match exactly.
27
+ *
28
+ * Every entry has a one-line reason and a follow-up owner.
29
+ */
30
+ const ALLOWLIST: readonly string[] = [
31
+ // Canonical platform primitives — the whole POINT is platform branching.
32
+ "packages/shared/src/platform/",
33
+ // Electron-specific platform primitives.
34
+ "packages/electron/src/platform/",
35
+
36
+ // ── Seed allowlist (documented follow-ups, out of scope for this change)
37
+
38
+ // Extension's pgid/ps scanner — platform-aware but uses `_platform` test
39
+ // hooks rather than shared primitives; consolidating into
40
+ // shared/platform/process-scan.ts is a separate change.
41
+ "packages/extension/src/process-scanner.ts",
42
+
43
+ // Electron dependency detection predates the tool-registry; migration
44
+ // to ToolRegistry.resolve is a separate change.
45
+ "packages/electron/src/lib/dependency-detector.ts",
46
+
47
+ // Electron top-level bootstrap: process.platform printed in log output,
48
+ // legitimate observability use.
49
+ "packages/electron/src/main.ts",
50
+
51
+ // Electron doctor: reports process.platform to the user.
52
+ "packages/electron/src/lib/doctor.ts",
53
+
54
+ // Electron forge config: build-time darwin special-case.
55
+ "packages/electron/forge.config.ts",
56
+
57
+ // Server process-manager: one domain branch in spawnHeadless picking
58
+ // Unix "sh -c tail -f" wrapper vs Windows direct node.exe spawn.
59
+ // The wrapper is genuinely Unix-only (sh+tail); splitting the headless
60
+ // mechanism into two is tracked as a follow-up.
61
+ "packages/server/src/process-manager.ts",
62
+
63
+ // Server editor registry: selects per-OS process patterns from a data
64
+ // table. Genuine data-lookup branching, benign.
65
+ "packages/server/src/editor-registry.ts",
66
+
67
+ // Server tunnel: surfaces process.platform in a response body.
68
+ "packages/server/src/tunnel.ts",
69
+
70
+ // Server browse: returns process.platform in BrowseResult for the
71
+ // client path-picker (protocol surface).
72
+ "packages/server/src/browse.ts",
73
+
74
+ // Client session-grouping: reads process.platform in a comment-only
75
+ // doc reference and uses inferPlatform heuristic; no actual branch.
76
+ "packages/client/src/lib/session-grouping.ts",
77
+
78
+ // ── Follow-up: migrate to electron/src/platform/ per deferred
79
+ // consolidate-platform-handlers (18→13 file refactor).
80
+
81
+ // App menu: darwin detection for role:appMenu (Electron convention).
82
+ "packages/electron/src/lib/app-menu.ts",
83
+ // Bundled node: win32 binary name suffix; data-lookup branch.
84
+ "packages/electron/src/lib/bundled-node.ts",
85
+ // Server lifecycle: win32 managed-tsx.cmd + which/where probes.
86
+ "packages/electron/src/lib/server-lifecycle.ts",
87
+ // Tray icon: platform-specific asset selection; will move to
88
+ // electron/src/platform/tray-icon.ts in deferred consolidation.
89
+ "packages/electron/src/lib/tray.ts",
90
+
91
+ // Server editor PID registry: per-OS process pattern matching for
92
+ // orphan detection on boot. Genuine data-table branching.
93
+ "packages/server/src/editor-pid-registry.ts",
94
+ // Electron dependency installer: Windows npm is npm.cmd (batch wrapper);
95
+ // spawn('npm') without .cmd extension fails ENOENT on Windows. The branch
96
+ // routes around this by preferring bundled node+npm-cli.js on Windows.
97
+ // Follow-up: migrate to a platform/exec npm-resolver primitive.
98
+ "packages/electron/src/lib/dependency-installer.ts",
99
+ // fix-pty-permissions: Windows short-circuit (no chmod needed).
100
+ "packages/server/src/fix-pty-permissions.ts",
101
+ // package-manager-wrapper: comment-only reference; no runtime branch.
102
+ "packages/server/src/package-manager-wrapper.ts",
103
+ // terminal-manager: win32 branch for node-pty spawnOptions; will move
104
+ // to platform/terminal in deferred consolidation.
105
+ "packages/server/src/terminal-manager.ts",
106
+ ];
107
+
108
+ const PLATFORM_BRANCH_RE = /process\.platform\s*(===|!==)\s*["'](win32|linux|darwin)["']/;
109
+
110
+ const OPT_OUT_MARKER = "platform-branch-ok";
111
+
112
+ async function* walk(dir: string): AsyncGenerator<string> {
113
+ const entries = await fs.readdir(dir, { withFileTypes: true });
114
+ for (const entry of entries) {
115
+ const full = path.join(dir, entry.name);
116
+ if (entry.isDirectory()) {
117
+ if (entry.name === "node_modules" || entry.name === "dist" || entry.name === "__tests__") continue;
118
+ yield* walk(full);
119
+ } else if (entry.isFile() && /\.(ts|tsx|mts|cts)$/.test(entry.name)) {
120
+ yield full;
121
+ }
122
+ }
123
+ }
124
+
125
+ /** Check if a repo-relative normalised path is covered by the allowlist. */
126
+ function isAllowed(relPath: string, allow: readonly string[]): boolean {
127
+ for (const entry of allow) {
128
+ if (entry.endsWith("/")) {
129
+ if (relPath.startsWith(entry)) return true;
130
+ } else {
131
+ if (relPath === entry) return true;
132
+ }
133
+ }
134
+ return false;
135
+ }
136
+
137
+ describe("no direct process.platform branches outside platform/**", () => {
138
+ it("only allowlisted files contain process.platform === \"<os>\" branches", async () => {
139
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
140
+ const repoRoot = path.resolve(here, "..", "..", "..", "..");
141
+ const packagesDir = path.resolve(repoRoot, "packages");
142
+
143
+ const violations: Array<{ file: string; line: number; text: string }> = [];
144
+
145
+ for (const pkg of await fs.readdir(packagesDir, { withFileTypes: true })) {
146
+ if (!pkg.isDirectory()) continue;
147
+ const srcDir = path.join(packagesDir, pkg.name, "src");
148
+ try { await fs.access(srcDir); } catch { continue; }
149
+
150
+ for await (const file of walk(srcDir)) {
151
+ const relPath = path.relative(repoRoot, file).replace(/\\/g, "/");
152
+ if (isAllowed(relPath, ALLOWLIST)) continue;
153
+
154
+ const content = await fs.readFile(file, "utf-8");
155
+ const lines = content.split(/\r?\n/);
156
+ lines.forEach((line, idx) => {
157
+ if (!PLATFORM_BRANCH_RE.test(line)) return;
158
+ if (line.includes(OPT_OUT_MARKER)) return;
159
+ violations.push({ file: relPath, line: idx + 1, text: line.trim() });
160
+ });
161
+ }
162
+ }
163
+
164
+ if (violations.length > 0) {
165
+ const msg =
166
+ `Direct process.platform branches found outside the allowlist.\n` +
167
+ `Move the logic into a platform/* primitive or add a ` +
168
+ `\`// ${OPT_OUT_MARKER}\` comment on the line with a justification.\n\n` +
169
+ `Offenders (${violations.length}):\n` +
170
+ violations.map((v) => ` ${v.file}:${v.line} ${v.text}`).join("\n");
171
+ expect(violations, msg).toEqual([]);
172
+ }
173
+ });
174
+ });