@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
@@ -1,25 +1,26 @@
1
1
  /**
2
2
  * Tests for the browse directory endpoint logic.
3
3
  */
4
- import { describe, it, expect } from "vitest";
5
- import { listDirectories } from "../browse.js";
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import { listDirectories, createDirectory, validateMkdirName } from "../browse.js";
6
6
  import path from "node:path";
7
7
  import os from "node:os";
8
8
  import fs from "node:fs";
9
+ import fsp from "node:fs/promises";
9
10
 
10
11
  describe("listDirectories", () => {
11
12
  it("should return directory entries for a valid path", async () => {
12
13
  // Use the project root — known to have subdirectories
13
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
14
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
14
15
  const result = await listDirectories(projectRoot);
15
16
 
16
17
  expect(result.current).toBe(projectRoot);
17
18
  expect(result.parent).toBe(path.dirname(projectRoot));
18
19
  expect(result.entries.length).toBeGreaterThan(0);
19
20
 
20
- // Should contain known subdirectories
21
+ // Should contain known subdirectories at the monorepo root
21
22
  const names = result.entries.map((e) => e.name);
22
- expect(names).toContain("src");
23
+ expect(names).toContain("packages");
23
24
  expect(names).toContain("node_modules");
24
25
  });
25
26
 
@@ -29,7 +30,7 @@ describe("listDirectories", () => {
29
30
  });
30
31
 
31
32
  it("should return entries sorted alphabetically", async () => {
32
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
33
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
33
34
  const result = await listDirectories(projectRoot);
34
35
  const names = result.entries.map((e) => e.name);
35
36
  const sorted = [...names].sort((a, b) => a.localeCompare(b));
@@ -45,7 +46,7 @@ describe("listDirectories", () => {
45
46
  });
46
47
 
47
48
  it("should detect isGit flag for git repos", async () => {
48
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
49
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
49
50
  const parentDir = path.dirname(projectRoot);
50
51
  const result = await listDirectories(parentDir);
51
52
 
@@ -57,7 +58,7 @@ describe("listDirectories", () => {
57
58
  });
58
59
 
59
60
  it("should detect isPi flag for pi projects", async () => {
60
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
61
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
61
62
  const parentDir = path.dirname(projectRoot);
62
63
  const result = await listDirectories(parentDir);
63
64
 
@@ -86,7 +87,7 @@ describe("listDirectories", () => {
86
87
  });
87
88
 
88
89
  it("should only return directories, not files", async () => {
89
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
90
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
90
91
  const result = await listDirectories(projectRoot);
91
92
  const names = result.entries.map((e) => e.name);
92
93
  // package.json is a file, should not appear
@@ -95,10 +96,245 @@ describe("listDirectories", () => {
95
96
  });
96
97
 
97
98
  it("should include full path in each entry", async () => {
98
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
99
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
99
100
  const result = await listDirectories(projectRoot);
100
101
  for (const entry of result.entries) {
101
102
  expect(entry.path).toBe(path.join(projectRoot, entry.name));
102
103
  }
103
104
  });
105
+
106
+ it("should return the server's platform", async () => {
107
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
108
+ const result = await listDirectories(projectRoot);
109
+ expect(result.platform).toBe(process.platform);
110
+ });
111
+
112
+ it("returns parent=null at the filesystem root", async () => {
113
+ // Use whichever root is appropriate for the host: "/" on Unix, the
114
+ // process's drive root on Windows. Previously this test only
115
+ // exercised Unix; `isFilesystemRoot` covers both branches now.
116
+ const root = process.platform === "win32"
117
+ ? path.parse(process.cwd()).root // e.g., "C:\\" or "B:\\"
118
+ : "/";
119
+ const result = await listDirectories(root);
120
+ expect(result.parent).toBeNull();
121
+ });
122
+ });
123
+
124
+ describe("listDirectories with q filter", () => {
125
+ let tmp: string;
126
+
127
+ beforeEach(async () => {
128
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-q-"));
129
+ });
130
+
131
+ afterEach(async () => {
132
+ await fsp.rm(tmp, { recursive: true, force: true });
133
+ });
134
+
135
+ async function makeDirs(names: string[]) {
136
+ for (const n of names) await fsp.mkdir(path.join(tmp, n));
137
+ }
138
+
139
+ it("treats empty q as no filter", async () => {
140
+ await makeDirs(["alpha", "beta"]);
141
+ const r1 = await listDirectories(tmp, "");
142
+ const r2 = await listDirectories(tmp, " ");
143
+ const r3 = await listDirectories(tmp);
144
+ const names1 = r1.entries.map((e) => e.name);
145
+ const names2 = r2.entries.map((e) => e.name);
146
+ const names3 = r3.entries.map((e) => e.name);
147
+ expect(names1).toEqual(["alpha", "beta"]);
148
+ expect(names2).toEqual(["alpha", "beta"]);
149
+ expect(names3).toEqual(["alpha", "beta"]);
150
+ });
151
+
152
+ it("returns non-prefix substring matches", async () => {
153
+ await makeDirs(["pi-dashboard", "my-dashboard-old", "readme-dir"]);
154
+ const r = await listDirectories(tmp, "dash");
155
+ const names = r.entries.map((e) => e.name);
156
+ expect(names).toContain("pi-dashboard");
157
+ expect(names).toContain("my-dashboard-old");
158
+ expect(names).not.toContain("readme-dir");
159
+ });
160
+
161
+ it("ranks by tier: exact, prefix, word-boundary, substring", async () => {
162
+ await makeDirs(["pi", "pi-core", "my-pi-tools", "epiphany"]);
163
+ const r = await listDirectories(tmp, "pi");
164
+ const names = r.entries.map((e) => e.name);
165
+ expect(names).toEqual(["pi", "pi-core", "my-pi-tools", "epiphany"]);
166
+ });
167
+
168
+ it("sorts alphabetically within the same tier", async () => {
169
+ await makeDirs(["pi-zeta", "pi-alpha", "pi-mu"]);
170
+ const r = await listDirectories(tmp, "pi");
171
+ const names = r.entries.map((e) => e.name);
172
+ // all prefix-tier → alphabetical
173
+ expect(names).toEqual(["pi-alpha", "pi-mu", "pi-zeta"]);
174
+ });
175
+
176
+ it("is case-insensitive", async () => {
177
+ await makeDirs(["Pi-Dashboard", "OtherThing"]);
178
+ const r = await listDirectories(tmp, "dash");
179
+ const names = r.entries.map((e) => e.name);
180
+ expect(names).toContain("Pi-Dashboard");
181
+ expect(names).not.toContain("OtherThing");
182
+ });
183
+
184
+ it("applies the 200-cap AFTER filtering so late-alphabet matches survive", async () => {
185
+ // Create 210 dummy dirs that don't match 'pi', plus one that does.
186
+ // The matching one alphabetically sorts near the end.
187
+ const dummy: string[] = [];
188
+ for (let i = 0; i < 210; i++) {
189
+ dummy.push(`z-${String(i).padStart(3, "0")}-other`);
190
+ }
191
+ // 'pi-dashboard' is the only match; sorts after all 'z-*'? No — 'p' < 'z',
192
+ // so use 'pi-dashboard' which alphabetically precedes them anyway. Use
193
+ // a different setup: create one matching dir named so alphabetically it
194
+ // falls past position 200 in the unfiltered list.
195
+ await makeDirs(dummy);
196
+ // 'zz-pi-match' will alphabetically be past the 200 'z-*' entries if we
197
+ // keep them, but since we only have 210 total, let's just make the matcher
198
+ // something that would be cut without filtering. Easier: 'aa-other' ×210
199
+ // plus a single 'pi-found'.
200
+ await fsp.rm(tmp, { recursive: true, force: true });
201
+ await fsp.mkdir(tmp);
202
+ const many: string[] = [];
203
+ for (let i = 0; i < 210; i++) many.push(`aa-${String(i).padStart(3, "0")}`);
204
+ many.push("pi-found");
205
+ await makeDirs(many);
206
+
207
+ // Without filter: 'pi-found' sorts alphabetically past 210 'aa-*' entries,
208
+ // so it lands at position 210 — cut by the 200 cap.
209
+ const unfiltered = await listDirectories(tmp);
210
+ expect(unfiltered.entries.length).toBe(200);
211
+ expect(unfiltered.entries.map((e) => e.name)).not.toContain("pi-found");
212
+
213
+ // With filter: it should survive because filtering happens first.
214
+ const filtered = await listDirectories(tmp, "pi");
215
+ expect(filtered.entries.map((e) => e.name)).toContain("pi-found");
216
+ });
217
+ });
218
+
219
+ describe("validateMkdirName", () => {
220
+ it("accepts normal names", () => {
221
+ expect(validateMkdirName("foo")).toBeNull();
222
+ expect(validateMkdirName("foo-bar")).toBeNull();
223
+ expect(validateMkdirName("foo_bar")).toBeNull();
224
+ expect(validateMkdirName("foo.bar")).toBeNull();
225
+ expect(validateMkdirName("foo bar")).toBeNull();
226
+ expect(validateMkdirName("\u00e9l\u00e9phant")).toBeNull();
227
+ });
228
+
229
+ it("rejects empty / whitespace", () => {
230
+ expect(validateMkdirName("")).toBe("invalid name");
231
+ expect(validateMkdirName(" ")).toBe("invalid name");
232
+ expect(validateMkdirName(" foo")).toBe("invalid name");
233
+ expect(validateMkdirName("foo ")).toBe("invalid name");
234
+ });
235
+
236
+ it("rejects . and ..", () => {
237
+ expect(validateMkdirName(".")).toBe("invalid name");
238
+ expect(validateMkdirName("..")).toBe("invalid name");
239
+ });
240
+
241
+ it("rejects path separators", () => {
242
+ expect(validateMkdirName("foo/bar")).toBe("invalid name");
243
+ expect(validateMkdirName("foo\\bar")).toBe("invalid name");
244
+ });
245
+
246
+ it("rejects null byte", () => {
247
+ expect(validateMkdirName("foo\0bar")).toBe("invalid name");
248
+ });
249
+ });
250
+
251
+ describe("createDirectory", () => {
252
+ let tmp: string;
253
+
254
+ beforeEach(async () => {
255
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "mkdir-"));
256
+ });
257
+
258
+ afterEach(async () => {
259
+ await fsp.rm(tmp, { recursive: true, force: true });
260
+ });
261
+
262
+ it("creates a new directory and returns its absolute path", async () => {
263
+ const result = await createDirectory(tmp, "new-thing");
264
+ expect(result).toBe(path.join(tmp, "new-thing"));
265
+ const stat = await fsp.stat(result);
266
+ expect(stat.isDirectory()).toBe(true);
267
+ });
268
+
269
+ it("throws 'already exists' when target already exists", async () => {
270
+ await fsp.mkdir(path.join(tmp, "dup"));
271
+ await expect(createDirectory(tmp, "dup")).rejects.toThrow("already exists");
272
+ });
273
+
274
+ it("throws 'parent not found' when parent does not exist", async () => {
275
+ await expect(createDirectory("/nonexistent/path/really", "x")).rejects.toThrow("parent not found");
276
+ });
277
+
278
+ it("throws 'parent is not a directory' when parent is a file", async () => {
279
+ const filePath = path.join(tmp, "somefile");
280
+ await fsp.writeFile(filePath, "hi");
281
+ await expect(createDirectory(filePath, "x")).rejects.toThrow("parent is not a directory");
282
+ });
283
+
284
+ it("rejects invalid names without touching disk", async () => {
285
+ await expect(createDirectory(tmp, "foo/bar")).rejects.toThrow("invalid name");
286
+ await expect(createDirectory(tmp, "..")).rejects.toThrow("invalid name");
287
+ await expect(createDirectory(tmp, ".")).rejects.toThrow("invalid name");
288
+ await expect(createDirectory(tmp, "")).rejects.toThrow("invalid name");
289
+ await expect(createDirectory(tmp, "foo\0bar")).rejects.toThrow("invalid name");
290
+ const entries = await fsp.readdir(tmp);
291
+ expect(entries).toEqual([]);
292
+ });
293
+ });
294
+
295
+ // ── S1: rankTier word-boundary edge cases ────────────────────
296
+ // rankTier isn't exported; exercise it indirectly via listDirectories.
297
+ describe("listDirectories word-boundary ranking", () => {
298
+ let tmp: string;
299
+
300
+ beforeEach(async () => {
301
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-wb-"));
302
+ });
303
+
304
+ afterEach(async () => {
305
+ await fsp.rm(tmp, { recursive: true, force: true });
306
+ });
307
+
308
+ async function makeDirs(names: string[]) {
309
+ for (const n of names) await fsp.mkdir(path.join(tmp, n));
310
+ }
311
+
312
+ it("treats hyphen, underscore, dot, space as word boundaries", async () => {
313
+ // All four should rank at tier 2 for query 'foo' (word boundary before 'foo');
314
+ // 'embeddedfoo' ranks tier 3 (plain substring).
315
+ await makeDirs([
316
+ "pi-foo", // hyphen boundary
317
+ "pi_foo", // underscore boundary
318
+ "pi.foo", // dot boundary
319
+ "pi foo", // space boundary
320
+ "embeddedfoo", // no boundary
321
+ ]);
322
+ const r = await listDirectories(tmp, "foo");
323
+ const names = r.entries.map((e) => e.name);
324
+ // The first four are tier 2 (alphabetical within tier); 'embeddedfoo' is tier 3 last.
325
+ expect(names[names.length - 1]).toBe("embeddedfoo");
326
+ // All four boundary-matched names appear before embeddedfoo.
327
+ const boundaryNames = ["pi foo", "pi-foo", "pi.foo", "pi_foo"];
328
+ const boundaryPositions = boundaryNames.map((n) => names.indexOf(n));
329
+ for (const p of boundaryPositions) expect(p).toBeGreaterThanOrEqual(0);
330
+ for (const p of boundaryPositions) expect(p).toBeLessThan(names.indexOf("embeddedfoo"));
331
+ });
332
+
333
+ it("treats start-of-string as a word boundary (prefix trumps via tier 1)", async () => {
334
+ await makeDirs(["foo-bar", "xx-foo"]);
335
+ const r = await listDirectories(tmp, "foo");
336
+ // 'foo-bar' is prefix (tier 1), 'xx-foo' is word-boundary (tier 2).
337
+ const names = r.entries.map((e) => e.name);
338
+ expect(names).toEqual(["foo-bar", "xx-foo"]);
339
+ });
104
340
  });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Regression tests for browser-gateway exception handling.
3
+ *
4
+ * - Handler exceptions MUST be logged with a `[browser-gw] handler error`
5
+ * prefix and the message type, so real bugs (e.g. node-pty spawn
6
+ * failures) are no longer silently swallowed.
7
+ * - Malformed JSON frames MUST still be silently dropped (no log noise
8
+ * for garbage input).
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+ import { EventEmitter } from "node:events";
12
+ import { createBrowserGateway } from "../browser-gateway.js";
13
+ import { createMemorySessionManager } from "../memory-session-manager.js";
14
+ import { createMemoryEventStore } from "../memory-event-store.js";
15
+ import type { TerminalManager } from "../terminal-manager.js";
16
+ import type { PiGateway } from "../pi-gateway.js";
17
+ import type { SessionOrderManager } from "../session-order-manager.js";
18
+
19
+ function makeFakeWs() {
20
+ const ws = new EventEmitter() as EventEmitter & {
21
+ send: ReturnType<typeof vi.fn>;
22
+ close: ReturnType<typeof vi.fn>;
23
+ readyState: number;
24
+ OPEN: number;
25
+ };
26
+ ws.send = vi.fn();
27
+ ws.close = vi.fn();
28
+ ws.readyState = 1;
29
+ ws.OPEN = 1;
30
+ return ws;
31
+ }
32
+
33
+ function makeStubPiGateway(): PiGateway {
34
+ return {
35
+ start: vi.fn(),
36
+ stop: vi.fn(),
37
+ sendToSession: vi.fn(),
38
+ getConnectedSessionIds: vi.fn(() => []),
39
+ hasSession: vi.fn(() => false),
40
+ onEvent: vi.fn(),
41
+ } as unknown as PiGateway;
42
+ }
43
+
44
+ function makeStubOrderManager(): SessionOrderManager {
45
+ return {
46
+ insert: vi.fn(),
47
+ remove: vi.fn(),
48
+ getOrder: vi.fn(() => []),
49
+ reorder: vi.fn(),
50
+ getAllOrders: vi.fn(() => ({})),
51
+ } as unknown as SessionOrderManager;
52
+ }
53
+
54
+ describe("browser-gateway handler error reporting", () => {
55
+ let errorSpy: ReturnType<typeof vi.spyOn>;
56
+
57
+ beforeEach(() => {
58
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
59
+ });
60
+
61
+ afterEach(() => {
62
+ errorSpy.mockRestore();
63
+ });
64
+
65
+ it("logs handler exceptions with type and error (does not silently swallow)", async () => {
66
+ const throwingTerminalManager = {
67
+ spawn: vi.fn(() => {
68
+ throw new Error("posix_spawnp failed.");
69
+ }),
70
+ attach: vi.fn(),
71
+ detach: vi.fn(),
72
+ kill: vi.fn(),
73
+ get: vi.fn(),
74
+ list: vi.fn(() => []),
75
+ updateTitle: vi.fn(),
76
+ } as unknown as TerminalManager;
77
+
78
+ const gateway = createBrowserGateway(
79
+ createMemorySessionManager(),
80
+ createMemoryEventStore(() => false),
81
+ makeStubPiGateway(),
82
+ undefined,
83
+ undefined,
84
+ makeStubOrderManager(),
85
+ undefined,
86
+ undefined,
87
+ throwingTerminalManager,
88
+ );
89
+
90
+ const ws = makeFakeWs();
91
+ gateway.wss.emit("connection", ws, {});
92
+
93
+ ws.emit(
94
+ "message",
95
+ Buffer.from(JSON.stringify({ type: "create_terminal", cwd: "/tmp" })),
96
+ );
97
+ // Allow any microtasks to settle.
98
+ await new Promise((r) => setImmediate(r));
99
+
100
+ const handlerErrorCall = errorSpy.mock.calls.find(
101
+ (args: unknown[]) =>
102
+ typeof args[0] === "string" &&
103
+ args[0].includes("[browser-gw] handler error") &&
104
+ args[0].includes("type=create_terminal"),
105
+ );
106
+ expect(handlerErrorCall, "expected a [browser-gw] handler error log line").toBeTruthy();
107
+ expect(throwingTerminalManager.spawn).toHaveBeenCalledOnce();
108
+ });
109
+
110
+ it("silently drops malformed JSON frames (no handler-error log)", async () => {
111
+ const gateway = createBrowserGateway(
112
+ createMemorySessionManager(),
113
+ createMemoryEventStore(() => false),
114
+ makeStubPiGateway(),
115
+ );
116
+
117
+ const ws = makeFakeWs();
118
+ gateway.wss.emit("connection", ws, {});
119
+
120
+ ws.emit("message", Buffer.from("{not json"));
121
+ await new Promise((r) => setImmediate(r));
122
+
123
+ const handlerErrorCall = errorSpy.mock.calls.find(
124
+ (args: unknown[]) =>
125
+ typeof args[0] === "string" && args[0].includes("[browser-gw] handler error"),
126
+ );
127
+ expect(handlerErrorCall).toBeUndefined();
128
+ });
129
+ });
@@ -31,6 +31,17 @@ describe("parseArgs", () => {
31
31
  expect(result.subcommand).toBe("status");
32
32
  });
33
33
 
34
+ it("parses upgrade-pi subcommand (unified-bootstrap-install §8)", () => {
35
+ const result = parseArgs(["upgrade-pi"]);
36
+ expect(result.subcommand).toBe("upgrade-pi");
37
+ });
38
+
39
+ it("parses upgrade-pi with --port flag", () => {
40
+ const result = parseArgs(["upgrade-pi", "--port", "9090"]);
41
+ expect(result.subcommand).toBe("upgrade-pi");
42
+ expect(result.flags.port).toBe(9090);
43
+ });
44
+
34
45
  it("parses subcommand with flags", () => {
35
46
  const result = parseArgs(["start", "--port", "3000", "--pi-port", "4000"]);
36
47
  expect(result.subcommand).toBe("start");
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Integration test — concurrent launches.
3
+ *
4
+ * Simulates two dashboard startups racing for the same per-HOME lock.
5
+ * Asserts that exactly one wins (`acquired`) and the other falls back
6
+ * cleanly (`attach` OR `InstanceLockMismatchError`, depending on liveness
7
+ * of the winner's probe).
8
+ *
9
+ * Uses real tmp dirs (not memfs) because proper-lockfile requires real
10
+ * filesystem semantics.
11
+ *
12
+ * See change: single-dashboard-per-home, task 12.1.
13
+ */
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+ import fs from "node:fs";
16
+ import os from "node:os";
17
+ import path from "node:path";
18
+ import { acquireOrAttach, InstanceLockMismatchError } from "../home-lock.js";
19
+
20
+ let tmpHome: string;
21
+ let lockPath: string;
22
+ let metaPath: string;
23
+
24
+ beforeEach(() => {
25
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "pi-concurrent-"));
26
+ lockPath = path.join(tmpHome, ".pi", "dashboard", "server.lock");
27
+ metaPath = `${lockPath}.meta.json`;
28
+ });
29
+
30
+ afterEach(() => {
31
+ try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
32
+ });
33
+
34
+ describe("concurrent launch", () => {
35
+ it("exactly one of two parallel acquireOrAttach calls wins the lock", async () => {
36
+ // Both attempts race. Whichever wins first gets `acquired`. The loser
37
+ // sees ELOCKED; because our probe says the winner is "not alive" (we
38
+ // intentionally return dead to avoid racing the probe), the loser
39
+ // steals the stale lock and also acquires. That's not right for
40
+ // same-HOME same-instant races — we need the loser to SEE the winner.
41
+ //
42
+ // To mimic reality: make isProcessAlive true (process IS alive) and
43
+ // have the probe treat the metadata's identity as authoritative.
44
+ const hookFactory = () => ({
45
+ lockPath, metaPath, staleMs: 5_000,
46
+ isProcessAlive: () => true,
47
+ probeHealth: async () => {
48
+ // Read the live metadata file and echo back its identity — this
49
+ // models a working /api/health from the winner.
50
+ try {
51
+ const raw = fs.readFileSync(metaPath, "utf-8");
52
+ const m = JSON.parse(raw) as { identity?: string; pid?: number };
53
+ if (m && typeof m.identity === "string") {
54
+ return { running: true, identity: m.identity, pid: m.pid };
55
+ }
56
+ } catch { /* metadata not yet written */ }
57
+ return { running: true, pid: process.pid };
58
+ },
59
+ });
60
+
61
+ const cfg = (id: string) => ({
62
+ httpPort: 8000, piPort: 9999, version: "t",
63
+ identity: id,
64
+ hooks: hookFactory(),
65
+ });
66
+
67
+ const [a, b] = await Promise.allSettled([
68
+ acquireOrAttach(cfg("racer-A")),
69
+ acquireOrAttach(cfg("racer-B")),
70
+ ]);
71
+
72
+ // Count outcomes.
73
+ const outcomes = [a, b].map(r => {
74
+ if (r.status === "rejected") return "error";
75
+ return r.value.mode;
76
+ });
77
+
78
+ // Exactly one winner, and the loser is either "attach" or "error"
79
+ // (identity mismatch if the winner's identity appears in metadata
80
+ // before the loser reads it).
81
+ const winners = outcomes.filter(o => o === "acquired");
82
+ expect(winners).toHaveLength(1);
83
+
84
+ const losers = outcomes.filter(o => o !== "acquired");
85
+ expect(losers).toHaveLength(1);
86
+ expect(["attach", "error"]).toContain(losers[0]);
87
+
88
+ // Cleanup: release whichever won.
89
+ for (const r of [a, b]) {
90
+ if (r.status === "fulfilled" && r.value.mode === "acquired") {
91
+ await r.value.release();
92
+ }
93
+ }
94
+ });
95
+
96
+ it("the winning identity is persisted to metadata", async () => {
97
+ const first = await acquireOrAttach({
98
+ httpPort: 8000, piPort: 9999, version: "t",
99
+ identity: "winner",
100
+ hooks: { lockPath, metaPath, staleMs: 5_000 },
101
+ });
102
+ expect(first.mode).toBe("acquired");
103
+
104
+ const raw = fs.readFileSync(metaPath, "utf-8");
105
+ const meta = JSON.parse(raw) as { identity: string };
106
+ expect(meta.identity).toBe("winner");
107
+
108
+ if (first.mode === "acquired") await first.release();
109
+ });
110
+ });
@@ -100,5 +100,73 @@ describe("config-api", () => {
100
100
  // providers preserved
101
101
  expect(written.auth.providers.github.clientId).toBe("x");
102
102
  });
103
+
104
+ // ── fix-trusted-networks-no-oauth regression tests ─────────────────
105
+ // These assert that auth.bypassHosts and auth.bypassUrls are persisted
106
+ // through PUT /api/config. Before the fix, the auth-merge block only
107
+ // copied secret / providers / allowedUsers, silently dropping bypass*
108
+ // on every save.
109
+
110
+ it("should persist auth.bypassHosts with no pre-existing auth (task 1.5)", () => {
111
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
112
+ const result = writeConfigPartial({
113
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
114
+ });
115
+ expect(result.success).toBe(true);
116
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
117
+ expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
118
+ });
119
+
120
+ it("should persist auth.bypassHosts alongside existing providers (task 1.6)", () => {
121
+ fs.writeFileSync(configFile, JSON.stringify({
122
+ auth: {
123
+ secret: "s",
124
+ providers: { github: { clientId: "abc", clientSecret: "xyz" } },
125
+ },
126
+ }));
127
+ const result = writeConfigPartial({
128
+ auth: { bypassHosts: ["10.0.0.0/8"] },
129
+ });
130
+ expect(result.success).toBe(true);
131
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
132
+ expect(written.auth.providers.github.clientId).toBe("abc");
133
+ expect(written.auth.providers.github.clientSecret).toBe("xyz");
134
+ expect(written.auth.bypassHosts).toEqual(["10.0.0.0/8"]);
135
+ });
136
+
137
+ it("should clear auth.bypassHosts via empty array (task 1.7)", () => {
138
+ fs.writeFileSync(configFile, JSON.stringify({
139
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
140
+ }));
141
+ const result = writeConfigPartial({
142
+ auth: { bypassHosts: [] },
143
+ });
144
+ expect(result.success).toBe(true);
145
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
146
+ expect(written.auth.bypassHosts).toEqual([]);
147
+ });
148
+
149
+ it("should preserve existing auth.bypassHosts when partial omits the key (task 1.8)", () => {
150
+ fs.writeFileSync(configFile, JSON.stringify({
151
+ auth: { providers: {}, bypassHosts: ["192.168.1.0/24"] },
152
+ }));
153
+ const result = writeConfigPartial({
154
+ auth: { allowedUsers: ["alice"] },
155
+ });
156
+ expect(result.success).toBe(true);
157
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
158
+ expect(written.auth.bypassHosts).toEqual(["192.168.1.0/24"]);
159
+ expect(written.auth.allowedUsers).toEqual(["alice"]);
160
+ });
161
+
162
+ it("should persist auth.bypassUrls symmetrically (task 1.9)", () => {
163
+ fs.writeFileSync(configFile, JSON.stringify({ port: 8000 }));
164
+ const result = writeConfigPartial({
165
+ auth: { providers: {}, bypassUrls: ["/webhooks/", "/metrics"] },
166
+ });
167
+ expect(result.success).toBe(true);
168
+ const written = JSON.parse(fs.readFileSync(configFile, "utf-8"));
169
+ expect(written.auth.bypassUrls).toEqual(["/webhooks/", "/metrics"]);
170
+ });
103
171
  });
104
172
  });