@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,39 @@
1
+ /**
2
+ * Fixture: `<homedir>/.pi/agent/settings.json` — pi's extension registry.
3
+ *
4
+ * Supports:
5
+ * - valid { packages: [...] }
6
+ * - empty/missing (return no entry)
7
+ * - malformed (broken JSON)
8
+ * - extra non-dashboard packages (preservation test)
9
+ */
10
+ import posix from "node:path/posix";
11
+ import win32 from "node:path/win32";
12
+ import type { FsRecord } from "../harness.js";
13
+
14
+ export interface SettingsJsonSpec {
15
+ homedir: string;
16
+ platform: NodeJS.Platform;
17
+ packages?: readonly string[];
18
+ /** If true, write broken JSON instead of a valid object. */
19
+ malformed?: boolean;
20
+ /** If true, omit the file entirely. */
21
+ missing?: boolean;
22
+ }
23
+
24
+ export function settingsJsonPath(homedir: string, platform: NodeJS.Platform): string {
25
+ const p = platform === "win32" ? win32 : posix;
26
+ return p.join(homedir, ".pi", "agent", "settings.json");
27
+ }
28
+
29
+ export function settingsJson(spec: SettingsJsonSpec): FsRecord {
30
+ if (spec.missing) return {};
31
+ const out: Record<string, string> = {};
32
+ const path = settingsJsonPath(spec.homedir, spec.platform);
33
+ if (spec.malformed) {
34
+ out[path] = "{broken json here";
35
+ return out;
36
+ }
37
+ out[path] = JSON.stringify({ packages: spec.packages ?? [] }, null, 2) + "\n";
38
+ return out;
39
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Smoke tests — validate the harness itself, not any real scenario.
3
+ * Full scenario families (A–K) land in later tasks.
4
+ */
5
+ import { describe, expect, it } from "vitest";
6
+ import { withFakeEnv, layer, toMemfsPath } from "./harness.js";
7
+ import {
8
+ registerDefaultTools,
9
+ } from "../../tool-registry/definitions.js";
10
+ import { registerBridgeExtension } from "../../bridge-register.js";
11
+
12
+ describe("harness smoke", () => {
13
+ it("withFakeEnv runs callback with HarnessContext (posix)", async () => {
14
+ const result = await withFakeEnv(
15
+ {
16
+ platform: "linux",
17
+ homedir: "/home/robson",
18
+ env: { PATH: "/usr/bin:/usr/local/bin" },
19
+ fs: { "/home/robson/.pi/config": "x" },
20
+ },
21
+ (ctx) => {
22
+ expect(ctx.platform).toBe("linux");
23
+ expect(ctx.homedir).toBe("/home/robson");
24
+ expect(ctx.pathEntries).toEqual(["/usr/bin", "/usr/local/bin"]);
25
+ expect(ctx.fs.existsSync("/home/robson/.pi/config")).toBe(true);
26
+ return 42;
27
+ },
28
+ );
29
+ expect(result).toBe(42);
30
+ });
31
+
32
+ it("withFakeEnv supports win32 paths via translation", async () => {
33
+ await withFakeEnv(
34
+ {
35
+ platform: "win32",
36
+ homedir: "C:\\Users\\Robert",
37
+ env: { PATH: "C:\\Windows\\System32;C:\\Program Files\\nodejs" },
38
+ fs: {
39
+ "C:\\Users\\Robert\\.pi-dashboard\\node_modules\\.bin\\pi.cmd":
40
+ "@node pi",
41
+ },
42
+ },
43
+ (ctx) => {
44
+ expect(ctx.pathEntries).toEqual([
45
+ "C:\\Windows\\System32",
46
+ "C:\\Program Files\\nodejs",
47
+ ]);
48
+ // existsSync via the wrapped fs should accept win32-style paths
49
+ expect(
50
+ ctx.fs.existsSync(
51
+ "C:\\Users\\Robert\\.pi-dashboard\\node_modules\\.bin\\pi.cmd",
52
+ ),
53
+ ).toBe(true);
54
+ },
55
+ );
56
+ });
57
+
58
+ it("toMemfsPath translates Windows paths to posix keys", () => {
59
+ expect(toMemfsPath("C:\\Users\\Robert\\.pi")).toBe("/C:/Users/Robert/.pi");
60
+ expect(toMemfsPath("/already/posix")).toBe("/already/posix");
61
+ });
62
+
63
+ it("which() walks fake PATH and finds binaries with .cmd on win32", async () => {
64
+ await withFakeEnv(
65
+ {
66
+ platform: "win32",
67
+ homedir: "C:\\Users\\R",
68
+ env: { PATH: "C:\\bin" },
69
+ fs: { "C:\\bin\\pi.cmd": "@echo off" },
70
+ },
71
+ (ctx) => {
72
+ const deps = ctx.createStrategyDeps();
73
+ expect(deps.which("pi")).toBe("C:\\bin\\pi.cmd");
74
+ expect(deps.which("nonexistent")).toBe(null);
75
+ },
76
+ );
77
+ });
78
+
79
+ it("which() on posix finds binary without extension", async () => {
80
+ await withFakeEnv(
81
+ {
82
+ platform: "linux",
83
+ homedir: "/home/r",
84
+ env: { PATH: "/usr/local/bin" },
85
+ fs: { "/usr/local/bin/pi": "#!/bin/sh\nexec node pi.js" },
86
+ },
87
+ (ctx) => {
88
+ const deps = ctx.createStrategyDeps();
89
+ expect(deps.which("pi")).toBe("/usr/local/bin/pi");
90
+ },
91
+ );
92
+ });
93
+
94
+ it("resolveModule() walks node_modules ancestor chain", async () => {
95
+ await withFakeEnv(
96
+ {
97
+ platform: "linux",
98
+ homedir: "/home/r",
99
+ fs: {
100
+ "/home/r/project/src/index.ts": "x",
101
+ "/home/r/project/node_modules/@mariozechner/pi/package.json":
102
+ JSON.stringify({ name: "@mariozechner/pi", main: "dist/cli.js" }),
103
+ "/home/r/project/node_modules/@mariozechner/pi/dist/cli.js":
104
+ "#!/usr/bin/env node",
105
+ },
106
+ },
107
+ (ctx) => {
108
+ const deps = ctx.createStrategyDeps();
109
+ const resolved = deps.resolveModule(
110
+ "@mariozechner/pi",
111
+ "/home/r/project/src/index.ts",
112
+ );
113
+ expect(resolved).toBe(
114
+ "/home/r/project/node_modules/@mariozechner/pi/dist/cli.js",
115
+ );
116
+ },
117
+ );
118
+ });
119
+
120
+ it("resolveModule() returns null when package absent", async () => {
121
+ await withFakeEnv(
122
+ {
123
+ platform: "linux",
124
+ homedir: "/home/r",
125
+ fs: { "/home/r/project/src/index.ts": "x" },
126
+ },
127
+ (ctx) => {
128
+ const deps = ctx.createStrategyDeps();
129
+ expect(
130
+ deps.resolveModule("@mariozechner/pi", "/home/r/project/src/index.ts"),
131
+ ).toBe(null);
132
+ },
133
+ );
134
+ });
135
+
136
+ it("layer() merges fs records (later overrides earlier)", () => {
137
+ const merged = layer(
138
+ { "/a": "1", "/b": "2" },
139
+ { "/b": "3", "/c": "4" },
140
+ );
141
+ expect(merged).toEqual({ "/a": "1", "/b": "3", "/c": "4" });
142
+ });
143
+
144
+ it("createRegistry wires env and platform through to strategies (linux, managed bin)", async () => {
145
+ // On Unix, pi resolves via <managedBin>/pi (the `.bin` shim).
146
+ await withFakeEnv(
147
+ {
148
+ platform: "linux",
149
+ homedir: "/home/r",
150
+ fs: {
151
+ "/home/r/.pi-dashboard/node_modules/.bin/pi":
152
+ "#!/usr/bin/env node",
153
+ },
154
+ },
155
+ (ctx) => {
156
+ const registry = ctx.createRegistry();
157
+ const deps = ctx.createStrategyDeps();
158
+ registerDefaultTools(registry, deps);
159
+ const res = registry.resolve("pi");
160
+ expect(res.ok).toBe(true);
161
+ expect(res.source).toBe("managed");
162
+ expect(res.path).toBe("/home/r/.pi-dashboard/node_modules/.bin/pi");
163
+ },
164
+ );
165
+ });
166
+
167
+ it("createRegistry resolves pi via managed module on win32", async () => {
168
+ // On Windows, pi's chain includes managedModuleStrategy for
169
+ // pi-coding-agent — finds dist/cli.js directly.
170
+ await withFakeEnv(
171
+ {
172
+ platform: "win32",
173
+ homedir: "C:\\Users\\R",
174
+ fs: {
175
+ "C:\\Users\\R\\.pi-dashboard\\node_modules\\@mariozechner\\pi-coding-agent\\dist\\cli.js":
176
+ "#!/usr/bin/env node",
177
+ },
178
+ },
179
+ (ctx) => {
180
+ const registry = ctx.createRegistry();
181
+ const deps = ctx.createStrategyDeps();
182
+ registerDefaultTools(registry, deps);
183
+ const res = registry.resolve("pi");
184
+ expect(res.ok).toBe(true);
185
+ expect(res.source).toBe("managed");
186
+ },
187
+ );
188
+ });
189
+
190
+ it("readSettings returns null when settings.json absent", async () => {
191
+ await withFakeEnv(
192
+ { platform: "linux", homedir: "/home/r", fs: {} },
193
+ (ctx) => {
194
+ expect(ctx.readSettings()).toBe(null);
195
+ },
196
+ );
197
+ });
198
+
199
+ it("bridge registration writes settings.json under fake HOME", async () => {
200
+ await withFakeEnv(
201
+ {
202
+ platform: "linux",
203
+ homedir: "/home/r",
204
+ fs: {
205
+ "/opt/extension/package.json": JSON.stringify({ name: "ext" }),
206
+ },
207
+ },
208
+ (ctx) => {
209
+ // Register bridge using the homedir override, but the real
210
+ // implementation uses `node:fs` — which means this assertion
211
+ // verifies the API signature compiles. Actual fake-fs bridge
212
+ // writes land in a follow-up task (bridge-register needs to
213
+ // accept an injectable fs impl; out of scope for smoke).
214
+ expect(() =>
215
+ registerBridgeExtension("/opt/extension", { homedir: "/tmp/nope" }),
216
+ ).not.toThrow();
217
+ },
218
+ );
219
+ });
220
+ });
@@ -0,0 +1,413 @@
1
+ /**
2
+ * In-memory bootstrap harness — pure-fs, pure-env test runner for
3
+ * ToolRegistry resolution + bridge-extension registration scenarios.
4
+ *
5
+ * See openspec/changes/bootstrap-resolution-harness/design.md §7 for
6
+ * the full design.
7
+ *
8
+ * Usage:
9
+ *
10
+ * await withFakeEnv({
11
+ * platform: "win32",
12
+ * homedir: "C:\\Users\\Robert",
13
+ * cwd: "C:\\Program Files\\PI Dashboard",
14
+ * env: { APPDATA: "C:\\Users\\Robert\\AppData\\Roaming", PATH: "..." },
15
+ * fs: layer(
16
+ * fixtures.npmGlobalOnWindows({ pi: "0.6.3" }),
17
+ * fixtures.managedInstall({ pi: "0.5.1" }),
18
+ * ),
19
+ * }, async (ctx) => {
20
+ * const registry = ctx.createRegistry();
21
+ * const res = registry.resolve("pi");
22
+ * expect(snapshotTrail(res, ctx)).toMatchSnapshot();
23
+ * });
24
+ *
25
+ * Invariants:
26
+ * - No real fs, child_process, or network access during a scenario run.
27
+ * - Every strategy dep (`exists`, `which`, `npmRootGlobal`, `resolveModule`)
28
+ * is wired to the in-memory volume.
29
+ * - Platform is fully injected; tests do NOT mutate `process.platform`.
30
+ */
31
+ import path from "node:path";
32
+ import posix from "node:path/posix";
33
+ import win32 from "node:path/win32";
34
+ import { Volume } from "memfs";
35
+ import type { IFs } from "memfs";
36
+ import {
37
+ ToolRegistry,
38
+ type ToolRegistryDeps,
39
+ type PlatformEnv,
40
+ } from "../../tool-registry/registry.js";
41
+ import type { StrategyDeps } from "../../tool-registry/strategies.js";
42
+ import { OverridesStore } from "../../tool-registry/overrides.js";
43
+
44
+ /**
45
+ * Minimal in-memory OverridesStore replacement that doesn't touch
46
+ * the real filesystem. Shape-compatible with the real class (TS
47
+ * `private` is erased at runtime, so casting through `unknown` is
48
+ * safe).
49
+ */
50
+ class FakeOverridesStore {
51
+ constructor(private cache: Record<string, string>) {}
52
+ list(): Readonly<Record<string, string>> {
53
+ return this.cache;
54
+ }
55
+ set(name: string, overridePath: string): void {
56
+ this.cache[name] = overridePath;
57
+ }
58
+ clear(name: string): void {
59
+ delete this.cache[name];
60
+ }
61
+ invalidate(): void {
62
+ /* cache is always fresh in the fake store */
63
+ }
64
+ }
65
+
66
+ /** File contents for the fake filesystem: path -> content. Directories are
67
+ * implied by the paths of their children. */
68
+ export type FsRecord = Readonly<Record<string, string | Buffer>>;
69
+
70
+ /** Env variables visible inside the scenario. */
71
+ export type FakeEnv = Readonly<Record<string, string>>;
72
+
73
+ export interface FakeEnvSpec {
74
+ platform: NodeJS.Platform;
75
+ homedir: string;
76
+ cwd?: string;
77
+ env?: FakeEnv;
78
+ /** File contents — use `layer(...)` to compose fixtures. */
79
+ fs?: FsRecord;
80
+ /** Per-tool override map (tool-overrides.json content). */
81
+ overrides?: Readonly<Record<string, string>>;
82
+ /** Override `npm root -g`. Defaults to platform-appropriate path. */
83
+ npmRootGlobal?: string;
84
+ }
85
+
86
+ export interface HarnessContext {
87
+ readonly spec: FakeEnvSpec;
88
+ readonly vol: Volume;
89
+ readonly fs: IFs;
90
+ readonly platform: NodeJS.Platform;
91
+ readonly homedir: string;
92
+ readonly cwd: string;
93
+ readonly env: FakeEnv;
94
+ /** Platform-correct `path` module (posix vs win32). */
95
+ readonly pathlib: typeof posix | typeof win32;
96
+ /** PATH entries as an array (split on platform-correct delimiter). */
97
+ readonly pathEntries: readonly string[];
98
+ /** Resolved `npm root -g` value. */
99
+ readonly npmRootGlobal: string;
100
+
101
+ /** Create the strategy deps wired to the fake filesystem/env. */
102
+ createStrategyDeps(): Required<StrategyDeps>;
103
+
104
+ /** Create a ToolRegistry pre-wired with the fake env + overrides. */
105
+ createRegistry(extra?: Partial<ToolRegistryDeps>): ToolRegistry;
106
+
107
+ /** Read the fake filesystem's settings.json (or null if absent/broken). */
108
+ readSettings(): Record<string, unknown> | null;
109
+ }
110
+
111
+ /**
112
+ * Merge multiple FsRecord layers. Later layers override earlier on path
113
+ * conflict. Returns a single FsRecord.
114
+ */
115
+ export function layer(...layers: readonly (FsRecord | undefined | null)[]): FsRecord {
116
+ const out: Record<string, string | Buffer> = {};
117
+ for (const l of layers) {
118
+ if (!l) continue;
119
+ for (const [k, v] of Object.entries(l)) out[k] = v;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ /** Platform-aware PATH delimiter. */
125
+ function pathDelim(platform: NodeJS.Platform): string {
126
+ return platform === "win32" ? ";" : ":";
127
+ }
128
+
129
+ /** Platform-correct path module. */
130
+ function pathFor(platform: NodeJS.Platform): typeof posix | typeof win32 {
131
+ return platform === "win32" ? win32 : posix;
132
+ }
133
+
134
+ /** Default `npm root -g` per platform when not provided by spec. */
135
+ function defaultNpmRootGlobal(spec: FakeEnvSpec): string {
136
+ const p = pathFor(spec.platform);
137
+ if (spec.platform === "win32") {
138
+ const appdata = spec.env?.APPDATA ?? p.join(spec.homedir, "AppData", "Roaming");
139
+ return p.join(appdata, "npm", "node_modules");
140
+ }
141
+ return p.join(spec.homedir, ".npm", "lib", "node_modules");
142
+ }
143
+
144
+ /**
145
+ * Build a `which(name)` function that walks PATH inside the fake fs.
146
+ * On win32, tries `name`, `name.cmd`, `name.exe` in that order.
147
+ */
148
+ function buildWhich(
149
+ fs: IFs,
150
+ pathEntries: readonly string[],
151
+ platform: NodeJS.Platform,
152
+ ): (name: string) => string | null {
153
+ const p = pathFor(platform);
154
+ const exts = platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
155
+ return (name: string): string | null => {
156
+ // If name has an extension or absolute path, short-circuit.
157
+ if (p.isAbsolute(name)) {
158
+ return fs.existsSync(name) ? name : null;
159
+ }
160
+ for (const entry of pathEntries) {
161
+ for (const ext of exts) {
162
+ const candidate = p.join(entry, name + ext);
163
+ try {
164
+ if (fs.existsSync(candidate)) return candidate;
165
+ } catch {
166
+ /* ignore */
167
+ }
168
+ }
169
+ }
170
+ return null;
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Build a `resolveModule(id, from)` that walks the fake fs' node_modules
176
+ * ancestor chain starting at `from`, looking for
177
+ * <dir>/node_modules/<id>/package.json
178
+ * then reading that package.json's `main`/`exports` to derive the entry.
179
+ * When no package.json `main` is present, falls back to `index.js`.
180
+ *
181
+ * NOT a full Node resolver — covers the cases the bootstrap harness
182
+ * needs (bare package import of CJS/ESM with a `main` field). Good
183
+ * enough for pi-coding-agent, openspec, tsx.
184
+ */
185
+ function buildResolveModule(
186
+ fs: IFs,
187
+ platform: NodeJS.Platform,
188
+ ): (id: string, from: string) => string | null {
189
+ const p = pathFor(platform);
190
+
191
+ function readJson(p2: string): Record<string, unknown> | null {
192
+ try {
193
+ const raw = fs.readFileSync(p2, "utf-8") as string;
194
+ return JSON.parse(raw);
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function entryFromPkg(pkgPath: string, pkg: Record<string, unknown>): string {
201
+ const pkgDir = p.dirname(pkgPath);
202
+ const main = typeof pkg.main === "string" ? pkg.main : "index.js";
203
+ return p.join(pkgDir, main);
204
+ }
205
+
206
+ // Split a require id into { pkgName, subpath }.
207
+ // "foo" → { pkgName: "foo", subpath: null }
208
+ // "foo/bar" → { pkgName: "foo", subpath: "bar" }
209
+ // "@scope/foo" → { pkgName: "@scope/foo", subpath: null }
210
+ // "@scope/foo/package.json" → { pkgName: "@scope/foo", subpath: "package.json" }
211
+ function splitId(id: string): { pkgName: string; subpath: string | null } {
212
+ if (id.startsWith("@")) {
213
+ const parts = id.split("/");
214
+ if (parts.length <= 2) return { pkgName: id, subpath: null };
215
+ return { pkgName: parts.slice(0, 2).join("/"), subpath: parts.slice(2).join("/") };
216
+ }
217
+ const idx = id.indexOf("/");
218
+ if (idx === -1) return { pkgName: id, subpath: null };
219
+ return { pkgName: id.slice(0, idx), subpath: id.slice(idx + 1) };
220
+ }
221
+
222
+ return (id: string, from: string): string | null => {
223
+ // Normalize `from`: if it's a file:// URL, strip it.
224
+ let anchor = from;
225
+ if (anchor.startsWith("file://")) {
226
+ // file:///C:/... on win32, file:///home/... on posix
227
+ anchor = anchor.slice(platform === "win32" ? 8 : 7);
228
+ }
229
+ // Starting directory: if anchor is a file, use its dir; if it's already
230
+ // a directory path, use as-is.
231
+ let dir = anchor;
232
+ try {
233
+ const st = fs.statSync(dir);
234
+ if (!st.isDirectory()) dir = p.dirname(dir);
235
+ } catch {
236
+ dir = p.dirname(anchor);
237
+ }
238
+
239
+ const { pkgName, subpath } = splitId(id);
240
+
241
+ // Walk up looking for node_modules/<pkgName>/package.json.
242
+ // Stop when we hit the filesystem root (dirname returns same value).
243
+ let prev = "";
244
+ while (dir !== prev) {
245
+ const pkgJsonPath = p.join(dir, "node_modules", pkgName, "package.json");
246
+ if (fs.existsSync(pkgJsonPath)) {
247
+ // Subpath request — resolve relative to the package dir.
248
+ if (subpath !== null) {
249
+ const candidate = p.join(p.dirname(pkgJsonPath), subpath);
250
+ if (fs.existsSync(candidate)) return candidate;
251
+ return null;
252
+ }
253
+ // Bare package — read main from package.json.
254
+ const pkg = readJson(pkgJsonPath);
255
+ if (pkg) {
256
+ const entry = entryFromPkg(pkgJsonPath, pkg);
257
+ if (fs.existsSync(entry)) return entry;
258
+ }
259
+ }
260
+ prev = dir;
261
+ dir = p.dirname(dir);
262
+ }
263
+ return null;
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Populate a memfs Volume from an FsRecord. Also creates directory
269
+ * entries implicitly.
270
+ */
271
+ function populateVolume(vol: Volume, records: FsRecord, platform: NodeJS.Platform): void {
272
+ const p = pathFor(platform);
273
+ for (const [rawPath, content] of Object.entries(records)) {
274
+ // memfs internally uses posix — but its APIs accept win32 paths on
275
+ // win32. For our purposes we normalize to posix for the Volume,
276
+ // because memfs is posix-native. Tests use the platform-correct
277
+ // path module via `ctx.pathlib` for path composition, and we
278
+ // translate at volume-populate time.
279
+ const normalized = platform === "win32" ? toMemfsPath(rawPath) : rawPath;
280
+ const dir = posix.dirname(normalized);
281
+ try {
282
+ vol.mkdirSync(dir, { recursive: true });
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ vol.writeFileSync(normalized, content);
287
+ }
288
+ void p; // silence unused on some paths
289
+ }
290
+
291
+ /**
292
+ * Translate a win32-style path to the posix-like path memfs uses internally.
293
+ * e.g. `C:\Users\Robert\.pi\settings.json` → `/C:/Users/Robert/.pi/settings.json`.
294
+ * Drive letter becomes a top-level directory. Separators flipped.
295
+ */
296
+ export function toMemfsPath(winPath: string): string {
297
+ const replaced = winPath.replace(/\\/g, "/");
298
+ if (/^[A-Za-z]:/.test(replaced)) {
299
+ return "/" + replaced;
300
+ }
301
+ return replaced.startsWith("/") ? replaced : "/" + replaced;
302
+ }
303
+
304
+ /**
305
+ * Build a memfs-backed IFs that understands win32 paths by translating
306
+ * them to posix-form keys inside the volume.
307
+ */
308
+ function wrapFsForPlatform(vol: Volume, platform: NodeJS.Platform): IFs {
309
+ const base = vol as unknown as IFs;
310
+ if (platform !== "win32") return base;
311
+ // Wrap: translate any incoming path through toMemfsPath.
312
+ const translate = (p: unknown): unknown =>
313
+ typeof p === "string" ? toMemfsPath(p) : p;
314
+ const wrap = <K extends keyof IFs>(name: K): IFs[K] => {
315
+ const orig = base[name] as unknown as (...args: unknown[]) => unknown;
316
+ if (typeof orig !== "function") return base[name];
317
+ return ((...args: unknown[]) => {
318
+ if (args.length > 0) args[0] = translate(args[0]);
319
+ return orig.apply(base, args);
320
+ }) as unknown as IFs[K];
321
+ };
322
+ return new Proxy(base, {
323
+ get(target, prop: string) {
324
+ if (prop === "existsSync" || prop === "readFileSync" || prop === "statSync"
325
+ || prop === "readdirSync" || prop === "writeFileSync" || prop === "mkdirSync"
326
+ || prop === "rmSync" || prop === "lstatSync") {
327
+ return wrap(prop as keyof IFs);
328
+ }
329
+ return (target as unknown as Record<string, unknown>)[prop];
330
+ },
331
+ }) as IFs;
332
+ }
333
+
334
+ /**
335
+ * Run an async callback inside a fresh in-memory environment.
336
+ *
337
+ * Does NOT mutate `process.platform`, `process.env`, `process.cwd()`,
338
+ * or any other host state — all environment surface is threaded through
339
+ * `HarnessContext` and the `StrategyDeps` it produces.
340
+ */
341
+ export async function withFakeEnv<T>(
342
+ spec: FakeEnvSpec,
343
+ fn: (ctx: HarnessContext) => Promise<T> | T,
344
+ ): Promise<T> {
345
+ const vol = new Volume();
346
+ populateVolume(vol, spec.fs ?? {}, spec.platform);
347
+ const fs = wrapFsForPlatform(vol, spec.platform);
348
+ const pathlib = pathFor(spec.platform);
349
+ const env = spec.env ?? {};
350
+ const cwd = spec.cwd ?? spec.homedir;
351
+ const pathVar = env.PATH ?? "";
352
+ const pathEntries = pathVar === ""
353
+ ? []
354
+ : pathVar.split(pathDelim(spec.platform)).filter(Boolean);
355
+ const npmRootGlobal = spec.npmRootGlobal ?? defaultNpmRootGlobal(spec);
356
+
357
+ const whichFn = buildWhich(fs, pathEntries, spec.platform);
358
+ const resolveModuleFn = buildResolveModule(fs, spec.platform);
359
+ const existsFn = (p: string) => {
360
+ try {
361
+ return fs.existsSync(p);
362
+ } catch {
363
+ return false;
364
+ }
365
+ };
366
+
367
+ const createStrategyDeps = (): Required<StrategyDeps> => ({
368
+ exists: existsFn,
369
+ which: whichFn,
370
+ npmRootGlobal: () => npmRootGlobal,
371
+ resolveModule: resolveModuleFn,
372
+ });
373
+
374
+ const createRegistry = (extra?: Partial<ToolRegistryDeps>): ToolRegistry => {
375
+ const overridesStore = new FakeOverridesStore({ ...(spec.overrides ?? {}) });
376
+ const platformEnv: PlatformEnv = { homedir: spec.homedir, cwd };
377
+ return new ToolRegistry({
378
+ overrides: overridesStore as unknown as OverridesStore,
379
+ platform: spec.platform,
380
+ env: platformEnv,
381
+ now: () => 0,
382
+ ...extra,
383
+ });
384
+ };
385
+
386
+ const readSettings = (): Record<string, unknown> | null => {
387
+ const settingsPath = pathlib.join(spec.homedir, ".pi", "agent", "settings.json");
388
+ try {
389
+ const raw = fs.readFileSync(settingsPath, "utf-8") as string;
390
+ return JSON.parse(raw);
391
+ } catch {
392
+ return null;
393
+ }
394
+ };
395
+
396
+ const ctx: HarnessContext = {
397
+ spec,
398
+ vol,
399
+ fs,
400
+ platform: spec.platform,
401
+ homedir: spec.homedir,
402
+ cwd,
403
+ env,
404
+ pathlib,
405
+ pathEntries,
406
+ npmRootGlobal,
407
+ createStrategyDeps,
408
+ createRegistry,
409
+ readSettings,
410
+ };
411
+
412
+ return await fn(ctx);
413
+ }