@blackbelt-technology/pi-agent-dashboard 0.3.0 → 0.4.1

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 (216) hide show
  1. package/AGENTS.md +87 -114
  2. package/README.md +408 -430
  3. package/docs/architecture.md +465 -12
  4. package/package.json +10 -5
  5. package/packages/extension/package.json +14 -4
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +40 -8
  7. package/packages/extension/src/__tests__/bridge-entry-id-pi-070.test.ts +174 -0
  8. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  9. package/packages/extension/src/__tests__/event-forwarder.test.ts +30 -0
  10. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +64 -76
  11. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  12. package/packages/extension/src/__tests__/multiselect-list.test.ts +137 -0
  13. package/packages/extension/src/__tests__/no-session-replacement-calls.test.ts +99 -0
  14. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  15. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  16. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  17. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  18. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  19. package/packages/extension/src/ask-user-tool.ts +5 -4
  20. package/packages/extension/src/bridge.ts +171 -17
  21. package/packages/extension/src/dev-build.ts +1 -1
  22. package/packages/extension/src/git-info.ts +9 -19
  23. package/packages/extension/src/multiselect-list.ts +146 -0
  24. package/packages/extension/src/multiselect-polyfill.ts +43 -0
  25. package/packages/extension/src/pi-env.d.ts +1 -0
  26. package/packages/extension/src/process-scanner.ts +72 -38
  27. package/packages/extension/src/provider-register.ts +304 -16
  28. package/packages/extension/src/server-auto-start.ts +27 -1
  29. package/packages/extension/src/server-launcher.ts +83 -27
  30. package/packages/server/package.json +16 -2
  31. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  32. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  33. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  34. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  35. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  36. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  37. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  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-registry.test.ts +28 -15
  41. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  42. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  43. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  44. package/packages/server/src/__tests__/fixtures/fork-jsonl-roundtrip.jsonl +8 -0
  45. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  46. package/packages/server/src/__tests__/fork-jsonl-roundtrip.test.ts +49 -0
  47. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  48. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  49. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  51. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  52. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  53. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  54. package/packages/server/src/__tests__/pi-version-skew.test.ts +237 -0
  55. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  56. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  57. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  58. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  59. package/packages/server/src/__tests__/restart-helper.test.ts +111 -0
  60. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  61. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  62. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  63. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  64. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  65. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  66. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  67. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  68. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  69. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  70. package/packages/server/src/bootstrap-queue.ts +130 -0
  71. package/packages/server/src/bootstrap-state.ts +131 -0
  72. package/packages/server/src/browse.ts +8 -3
  73. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  74. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  75. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  76. package/packages/server/src/cli.ts +310 -39
  77. package/packages/server/src/config-api.ts +16 -0
  78. package/packages/server/src/directory-service.ts +270 -39
  79. package/packages/server/src/editor-detection.ts +12 -9
  80. package/packages/server/src/editor-manager.ts +19 -4
  81. package/packages/server/src/editor-pid-registry.ts +9 -8
  82. package/packages/server/src/editor-registry.ts +22 -25
  83. package/packages/server/src/git-operations.ts +1 -1
  84. package/packages/server/src/headless-pid-registry.ts +7 -20
  85. package/packages/server/src/home-lock-release.ts +72 -0
  86. package/packages/server/src/home-lock.ts +389 -0
  87. package/packages/server/src/node-guard.ts +52 -0
  88. package/packages/server/src/package-manager-wrapper.ts +207 -47
  89. package/packages/server/src/pi-core-checker.ts +1 -1
  90. package/packages/server/src/pi-core-updater.ts +7 -1
  91. package/packages/server/src/pi-resource-scanner.ts +5 -8
  92. package/packages/server/src/pi-version-skew.ts +207 -0
  93. package/packages/server/src/preferences-store.ts +17 -3
  94. package/packages/server/src/process-manager.ts +403 -222
  95. package/packages/server/src/provider-probe.ts +234 -0
  96. package/packages/server/src/restart-helper.ts +141 -0
  97. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  98. package/packages/server/src/routes/openspec-routes.ts +25 -1
  99. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  100. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  101. package/packages/server/src/routes/provider-routes.ts +43 -0
  102. package/packages/server/src/routes/recommended-routes.ts +10 -12
  103. package/packages/server/src/routes/system-routes.ts +20 -33
  104. package/packages/server/src/routes/tool-routes.ts +153 -0
  105. package/packages/server/src/server-pid.ts +5 -9
  106. package/packages/server/src/server.ts +211 -10
  107. package/packages/server/src/session-api.ts +77 -8
  108. package/packages/server/src/session-bootstrap.ts +17 -3
  109. package/packages/server/src/session-diff.ts +21 -21
  110. package/packages/server/src/terminal-manager.ts +61 -20
  111. package/packages/server/src/tunnel.ts +42 -28
  112. package/packages/shared/package.json +10 -3
  113. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  114. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  115. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  116. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  117. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  118. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  129. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  130. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  131. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  132. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  133. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  134. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  135. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  136. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  137. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  138. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  139. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  140. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  141. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  142. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  143. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  144. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  145. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  146. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  147. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  148. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  149. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  150. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  151. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  152. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  153. package/packages/shared/src/__tests__/config.test.ts +56 -0
  154. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  155. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  156. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  157. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  158. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  159. package/packages/shared/src/__tests__/no-hardcoded-node-modules-paths.test.ts +176 -0
  160. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +146 -0
  161. package/packages/shared/src/__tests__/node-spawn.test.ts +210 -0
  162. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  163. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  164. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  165. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  166. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  167. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  168. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  169. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  170. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  171. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  172. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  173. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  174. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  175. package/packages/shared/src/__tests__/resolve-tool-cli.test.ts +105 -0
  176. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  177. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  178. package/packages/shared/src/__tests__/state-replay-entry-id.test.ts +69 -0
  179. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  180. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  181. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  182. package/packages/shared/src/bootstrap-install.ts +212 -0
  183. package/packages/shared/src/bridge-register.ts +87 -20
  184. package/packages/shared/src/browser-protocol.ts +71 -1
  185. package/packages/shared/src/config.ts +87 -15
  186. package/packages/shared/src/managed-paths.ts +31 -4
  187. package/packages/shared/src/openspec-poller.ts +63 -46
  188. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  189. package/packages/shared/src/platform/commands.ts +100 -0
  190. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  191. package/packages/shared/src/platform/exec.ts +220 -0
  192. package/packages/shared/src/platform/git.ts +155 -0
  193. package/packages/shared/src/platform/index.ts +16 -0
  194. package/packages/shared/src/platform/node-spawn.ts +154 -0
  195. package/packages/shared/src/platform/npm.ts +162 -0
  196. package/packages/shared/src/platform/openspec.ts +91 -0
  197. package/packages/shared/src/platform/paths.ts +276 -0
  198. package/packages/shared/src/platform/process-identify.ts +126 -0
  199. package/packages/shared/src/platform/process-scan.ts +94 -0
  200. package/packages/shared/src/platform/process.ts +168 -0
  201. package/packages/shared/src/platform/runner.ts +369 -0
  202. package/packages/shared/src/platform/shell.ts +44 -0
  203. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  204. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  205. package/packages/shared/src/protocol.ts +23 -0
  206. package/packages/shared/src/recommended-extensions.ts +18 -2
  207. package/packages/shared/src/resolve-jiti.ts +62 -3
  208. package/packages/shared/src/rest-api.ts +26 -0
  209. package/packages/shared/src/semaphore.ts +83 -0
  210. package/packages/shared/src/state-replay.ts +9 -0
  211. package/packages/shared/src/tool-registry/definitions.ts +434 -0
  212. package/packages/shared/src/tool-registry/index.ts +56 -0
  213. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  214. package/packages/shared/src/tool-registry/registry.ts +262 -0
  215. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  216. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,287 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ buildProbeRequest,
4
+ resolveProbeApiKey,
5
+ probeProvider,
6
+ type ProbeInput,
7
+ } from "../provider-probe.js";
8
+
9
+ describe("buildProbeRequest", () => {
10
+ it("openai-completions: GET {baseUrl}/models with Authorization: Bearer", () => {
11
+ const req = buildProbeRequest({
12
+ baseUrl: "https://api.example.com/v1",
13
+ apiKey: "sk-abc",
14
+ api: "openai-completions",
15
+ });
16
+ expect(req.url).toBe("https://api.example.com/v1/models");
17
+ expect(req.headers.Authorization).toBe("Bearer sk-abc");
18
+ });
19
+
20
+ it("openai-completions: handles trailing slash on baseUrl", () => {
21
+ const req = buildProbeRequest({
22
+ baseUrl: "https://api.example.com/v1/",
23
+ apiKey: "sk-abc",
24
+ api: "openai-completions",
25
+ });
26
+ expect(req.url).toBe("https://api.example.com/v1/models");
27
+ });
28
+
29
+ it("openai-responses: same shape as openai-completions", () => {
30
+ const req = buildProbeRequest({
31
+ baseUrl: "https://api.example.com/v1",
32
+ apiKey: "sk-abc",
33
+ api: "openai-responses",
34
+ });
35
+ expect(req.url).toBe("https://api.example.com/v1/models");
36
+ expect(req.headers.Authorization).toBe("Bearer sk-abc");
37
+ });
38
+
39
+ it("anthropic-messages: x-api-key + anthropic-version, no Authorization", () => {
40
+ const req = buildProbeRequest({
41
+ baseUrl: "https://api.anthropic.com",
42
+ apiKey: "sk-ant-123",
43
+ api: "anthropic-messages",
44
+ });
45
+ expect(req.url).toBe("https://api.anthropic.com/v1/models");
46
+ expect(req.headers["x-api-key"]).toBe("sk-ant-123");
47
+ expect(req.headers["anthropic-version"]).toBe("2023-06-01");
48
+ expect(req.headers.Authorization).toBeUndefined();
49
+ });
50
+
51
+ it("google-generative-ai: key query param, no Authorization", () => {
52
+ const req = buildProbeRequest({
53
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
54
+ apiKey: "AIza abc+def",
55
+ api: "google-generative-ai",
56
+ });
57
+ // apiKey must be URL-encoded
58
+ expect(req.url).toBe(
59
+ "https://generativelanguage.googleapis.com/v1beta/models?key=AIza%20abc%2Bdef",
60
+ );
61
+ expect(req.headers.Authorization).toBeUndefined();
62
+ });
63
+
64
+ it("throws on unknown api type", () => {
65
+ expect(() =>
66
+ buildProbeRequest({
67
+ baseUrl: "https://x",
68
+ apiKey: "k",
69
+ api: "unknown-api" as any,
70
+ }),
71
+ ).toThrow(/unsupported api/i);
72
+ });
73
+ });
74
+
75
+ describe("resolveProbeApiKey", () => {
76
+ const ORIGINAL_ENV = { ...process.env };
77
+ beforeEach(() => {
78
+ process.env = { ...ORIGINAL_ENV };
79
+ });
80
+ afterEach(() => {
81
+ process.env = { ...ORIGINAL_ENV };
82
+ });
83
+
84
+ it("literal key passes through", () => {
85
+ const result = resolveProbeApiKey({ apiKey: "sk-abc", readProviders: () => ({}) });
86
+ expect(result).toEqual({ ok: true, key: "sk-abc" });
87
+ });
88
+
89
+ it("$ENV_VAR: reads from process.env when set", () => {
90
+ process.env.MY_LLM_KEY = "resolved-value";
91
+ const result = resolveProbeApiKey({ apiKey: "$MY_LLM_KEY", readProviders: () => ({}) });
92
+ expect(result).toEqual({ ok: true, key: "resolved-value" });
93
+ });
94
+
95
+ it("$ENV_VAR: returns error when env var is missing", () => {
96
+ delete process.env.NONEXISTENT_VAR;
97
+ const result = resolveProbeApiKey({ apiKey: "$NONEXISTENT_VAR", readProviders: () => ({}) });
98
+ expect(result.ok).toBe(false);
99
+ if (!result.ok) expect(result.error).toMatch(/NONEXISTENT_VAR/);
100
+ });
101
+
102
+ it("REDACTED (***): resolves via provider name from readProviders", () => {
103
+ const result = resolveProbeApiKey({
104
+ apiKey: "***",
105
+ name: "my-provider",
106
+ readProviders: () => ({
107
+ "my-provider": { baseUrl: "u", apiKey: "stored-key", api: "openai-completions" },
108
+ }),
109
+ });
110
+ expect(result).toEqual({ ok: true, key: "stored-key" });
111
+ });
112
+
113
+ it("REDACTED + stored key is $ENV_VAR: follows env-var resolution", () => {
114
+ process.env.STORED_ENV = "env-value";
115
+ const result = resolveProbeApiKey({
116
+ apiKey: "***",
117
+ name: "my-provider",
118
+ readProviders: () => ({
119
+ "my-provider": { baseUrl: "u", apiKey: "$STORED_ENV", api: "openai-completions" },
120
+ }),
121
+ });
122
+ expect(result).toEqual({ ok: true, key: "env-value" });
123
+ });
124
+
125
+ it("REDACTED without name: returns error", () => {
126
+ const result = resolveProbeApiKey({ apiKey: "***", readProviders: () => ({}) });
127
+ expect(result.ok).toBe(false);
128
+ if (!result.ok) expect(result.error).toMatch(/no.*provider/i);
129
+ });
130
+
131
+ it("REDACTED with unknown name: returns error", () => {
132
+ const result = resolveProbeApiKey({
133
+ apiKey: "***",
134
+ name: "missing",
135
+ readProviders: () => ({}),
136
+ });
137
+ expect(result.ok).toBe(false);
138
+ if (!result.ok) expect(result.error).toMatch(/missing/);
139
+ });
140
+
141
+ it("empty key: returns error", () => {
142
+ const result = resolveProbeApiKey({ apiKey: "", readProviders: () => ({}) });
143
+ expect(result.ok).toBe(false);
144
+ });
145
+ });
146
+
147
+ describe("probeProvider", () => {
148
+ const baseInput: ProbeInput = {
149
+ baseUrl: "https://api.example.com/v1",
150
+ apiKey: "sk-abc",
151
+ api: "openai-completions",
152
+ };
153
+
154
+ let originalFetch: typeof globalThis.fetch;
155
+ beforeEach(() => {
156
+ originalFetch = globalThis.fetch;
157
+ });
158
+ afterEach(() => {
159
+ globalThis.fetch = originalFetch;
160
+ });
161
+
162
+ function mockFetch(impl: (url: string, init: RequestInit) => Promise<Response>) {
163
+ globalThis.fetch = vi.fn(impl) as any;
164
+ }
165
+
166
+ it("2xx with data array returns ok + modelCount + sample (capped at 5)", async () => {
167
+ const ids = ["m1", "m2", "m3", "m4", "m5", "m6", "m7"];
168
+ mockFetch(async () =>
169
+ new Response(JSON.stringify({ data: ids.map((id) => ({ id })) }), { status: 200 }),
170
+ );
171
+ const result = await probeProvider(baseInput);
172
+ expect(result.ok).toBe(true);
173
+ if (result.ok) {
174
+ expect(result.status).toBe(200);
175
+ expect(result.modelCount).toBe(7);
176
+ expect(result.sample).toEqual(["m1", "m2", "m3", "m4", "m5"]);
177
+ }
178
+ });
179
+
180
+ it("2xx with unexpected body shape: ok with modelCount=0", async () => {
181
+ mockFetch(async () => new Response(JSON.stringify({ weird: true }), { status: 200 }));
182
+ const result = await probeProvider(baseInput);
183
+ expect(result.ok).toBe(true);
184
+ if (result.ok) {
185
+ expect(result.modelCount).toBe(0);
186
+ expect(result.sample).toEqual([]);
187
+ }
188
+ });
189
+
190
+ it("401 returns ok=false with status + error", async () => {
191
+ mockFetch(async () =>
192
+ new Response("Invalid API key", { status: 401, statusText: "Unauthorized" }),
193
+ );
194
+ const result = await probeProvider(baseInput);
195
+ expect(result.ok).toBe(false);
196
+ if (!result.ok) {
197
+ expect(result.status).toBe(401);
198
+ expect(result.error).toMatch(/Invalid API key|Unauthorized|401/);
199
+ }
200
+ });
201
+
202
+ it("500 returns ok=false with status", async () => {
203
+ mockFetch(async () => new Response("boom", { status: 500 }));
204
+ const result = await probeProvider(baseInput);
205
+ expect(result.ok).toBe(false);
206
+ if (!result.ok) expect(result.status).toBe(500);
207
+ });
208
+
209
+ it("body excerpt is truncated to 500 chars", async () => {
210
+ const long = "x".repeat(2000);
211
+ mockFetch(async () => new Response(long, { status: 400 }));
212
+ const result = await probeProvider(baseInput);
213
+ expect(result.ok).toBe(false);
214
+ if (!result.ok && result.error) expect(result.error.length).toBeLessThanOrEqual(500);
215
+ });
216
+
217
+ it("network error: ok=false with error, no status", async () => {
218
+ mockFetch(async () => {
219
+ throw new Error("ECONNREFUSED");
220
+ });
221
+ const result = await probeProvider(baseInput);
222
+ expect(result.ok).toBe(false);
223
+ if (!result.ok) {
224
+ expect(result.error).toMatch(/ECONNREFUSED/);
225
+ expect(result.status).toBeUndefined();
226
+ }
227
+ });
228
+
229
+ it("timeout aborts the request and returns error", async () => {
230
+ mockFetch(
231
+ (_url, init) =>
232
+ new Promise((_resolve, reject) => {
233
+ const signal = init.signal as AbortSignal;
234
+ signal.addEventListener("abort", () => reject(new Error("aborted")));
235
+ }),
236
+ );
237
+ const result = await probeProvider({ ...baseInput, timeoutMs: 20 });
238
+ expect(result.ok).toBe(false);
239
+ if (!result.ok) expect(result.error).toBeDefined();
240
+ });
241
+
242
+ it("response never echoes the apiKey", async () => {
243
+ mockFetch(async () =>
244
+ new Response(`echoed sk-abc in body`, { status: 401 }),
245
+ );
246
+ const result = await probeProvider({ ...baseInput, apiKey: "sk-abc" });
247
+ // even though upstream echoes the key, our result error should not leak it
248
+ if (!result.ok && result.error) {
249
+ expect(result.error).not.toContain("sk-abc");
250
+ }
251
+ });
252
+
253
+ it("anthropic-messages uses x-api-key header (not Authorization)", async () => {
254
+ let capturedInit: RequestInit | undefined;
255
+ mockFetch(async (_url, init) => {
256
+ capturedInit = init;
257
+ return new Response(JSON.stringify({ data: [] }), { status: 200 });
258
+ });
259
+ await probeProvider({
260
+ baseUrl: "https://api.anthropic.com",
261
+ apiKey: "sk-ant-x",
262
+ api: "anthropic-messages",
263
+ });
264
+ const headers = capturedInit!.headers as Record<string, string>;
265
+ expect(headers["x-api-key"]).toBe("sk-ant-x");
266
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
267
+ expect(headers.Authorization).toBeUndefined();
268
+ });
269
+
270
+ it("google-generative-ai uses ?key= query param (no Authorization)", async () => {
271
+ let capturedUrl: string | undefined;
272
+ let capturedInit: RequestInit | undefined;
273
+ mockFetch(async (url, init) => {
274
+ capturedUrl = url;
275
+ capturedInit = init;
276
+ return new Response(JSON.stringify({ data: [] }), { status: 200 });
277
+ });
278
+ await probeProvider({
279
+ baseUrl: "https://generativelanguage.googleapis.com/v1beta",
280
+ apiKey: "AIzaTest",
281
+ api: "google-generative-ai",
282
+ });
283
+ expect(capturedUrl).toContain("?key=AIzaTest");
284
+ const headers = (capturedInit!.headers ?? {}) as Record<string, string>;
285
+ expect(headers.Authorization).toBeUndefined();
286
+ });
287
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import Fastify from "fastify";
3
+ import { registerProviderRoutes } from "../routes/provider-routes.js";
4
+
5
+ // Pass-through network guard — we're testing the endpoint contract, not auth
6
+ const allowGuard = async () => {};
7
+
8
+ function makeApp() {
9
+ const app = Fastify();
10
+ registerProviderRoutes(app, { networkGuard: allowGuard as any });
11
+ return app;
12
+ }
13
+
14
+ describe("POST /api/providers/test", () => {
15
+ let originalFetch: typeof globalThis.fetch;
16
+ beforeEach(() => {
17
+ originalFetch = globalThis.fetch;
18
+ });
19
+ afterEach(() => {
20
+ globalThis.fetch = originalFetch;
21
+ });
22
+
23
+ function mockFetch(impl: (url: string, init: RequestInit) => Promise<Response>) {
24
+ globalThis.fetch = vi.fn(impl) as any;
25
+ }
26
+
27
+ it("400 when body is invalid", async () => {
28
+ const app = makeApp();
29
+ await app.ready();
30
+ const res = await app.inject({
31
+ method: "POST",
32
+ url: "/api/providers/test",
33
+ payload: "not-json",
34
+ headers: { "content-type": "application/json" },
35
+ });
36
+ expect(res.statusCode).toBe(400);
37
+ await app.close();
38
+ });
39
+
40
+ it("400 when baseUrl missing", async () => {
41
+ const app = makeApp();
42
+ await app.ready();
43
+ const res = await app.inject({
44
+ method: "POST",
45
+ url: "/api/providers/test",
46
+ payload: { apiKey: "sk-abc", api: "openai-completions" },
47
+ });
48
+ expect(res.statusCode).toBe(400);
49
+ const body = JSON.parse(res.payload);
50
+ expect(body.error).toMatch(/baseUrl/);
51
+ await app.close();
52
+ });
53
+
54
+ it("400 when api missing", async () => {
55
+ const app = makeApp();
56
+ await app.ready();
57
+ const res = await app.inject({
58
+ method: "POST",
59
+ url: "/api/providers/test",
60
+ payload: { baseUrl: "https://x", apiKey: "sk" },
61
+ });
62
+ expect(res.statusCode).toBe(400);
63
+ await app.close();
64
+ });
65
+
66
+ it("happy path: probe returns ok with modelCount", async () => {
67
+ mockFetch(async () =>
68
+ new Response(JSON.stringify({ data: [{ id: "m1" }, { id: "m2" }] }), { status: 200 }),
69
+ );
70
+ const app = makeApp();
71
+ await app.ready();
72
+ const res = await app.inject({
73
+ method: "POST",
74
+ url: "/api/providers/test",
75
+ payload: {
76
+ baseUrl: "https://api.example.com/v1",
77
+ apiKey: "sk-abc",
78
+ api: "openai-completions",
79
+ },
80
+ });
81
+ expect(res.statusCode).toBe(200);
82
+ const body = JSON.parse(res.payload);
83
+ expect(body.ok).toBe(true);
84
+ expect(body.modelCount).toBe(2);
85
+ expect(body.sample).toEqual(["m1", "m2"]);
86
+ await app.close();
87
+ });
88
+
89
+ it("401 upstream: returns ok=false with status", async () => {
90
+ mockFetch(async () => new Response("bad key", { status: 401 }));
91
+ const app = makeApp();
92
+ await app.ready();
93
+ const res = await app.inject({
94
+ method: "POST",
95
+ url: "/api/providers/test",
96
+ payload: {
97
+ baseUrl: "https://api.example.com/v1",
98
+ apiKey: "sk-bad",
99
+ api: "openai-completions",
100
+ },
101
+ });
102
+ // Our endpoint always returns 200 with the structured result
103
+ expect(res.statusCode).toBe(200);
104
+ const body = JSON.parse(res.payload);
105
+ expect(body.ok).toBe(false);
106
+ expect(body.status).toBe(401);
107
+ await app.close();
108
+ });
109
+
110
+ it("missing $ENV_VAR: returns ok=false without hitting upstream", async () => {
111
+ delete process.env.PROBE_ROUTE_MISSING;
112
+ const fetchSpy = vi.fn(async () => new Response("{}", { status: 200 }));
113
+ globalThis.fetch = fetchSpy as any;
114
+ const app = makeApp();
115
+ await app.ready();
116
+ const res = await app.inject({
117
+ method: "POST",
118
+ url: "/api/providers/test",
119
+ payload: {
120
+ baseUrl: "https://x",
121
+ apiKey: "$PROBE_ROUTE_MISSING",
122
+ api: "openai-completions",
123
+ },
124
+ });
125
+ expect(res.statusCode).toBe(200);
126
+ const body = JSON.parse(res.payload);
127
+ expect(body.ok).toBe(false);
128
+ expect(body.error).toMatch(/PROBE_ROUTE_MISSING/);
129
+ expect(fetchSpy).not.toHaveBeenCalled();
130
+ await app.close();
131
+ });
132
+
133
+ it("response never echoes the apiKey", async () => {
134
+ mockFetch(async () => new Response("bad key xyz-secret-abc", { status: 401 }));
135
+ const app = makeApp();
136
+ await app.ready();
137
+ const res = await app.inject({
138
+ method: "POST",
139
+ url: "/api/providers/test",
140
+ payload: {
141
+ baseUrl: "https://api.example.com/v1",
142
+ apiKey: "xyz-secret-abc",
143
+ api: "openai-completions",
144
+ },
145
+ });
146
+ expect(res.payload).not.toContain("xyz-secret-abc");
147
+ await app.close();
148
+ });
149
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tests for the cross-platform restart orchestrator.
3
+ * See change: fix-windows-server-parity.
4
+ */
5
+ import { describe, it, expect } from "vitest";
6
+ import { buildOrchestratorScript } from "../restart-helper.js";
7
+
8
+ describe("buildOrchestratorScript", () => {
9
+ const baseParams = {
10
+ cliPath: "/tmp/cli.ts",
11
+ loader: "file:///tmp/jiti-register.mjs",
12
+ port: 8000,
13
+ extraArgs: [] as string[],
14
+ execPath: "/usr/bin/node",
15
+ };
16
+
17
+ it("produces a self-contained Node script (no shell/lsof/curl)", () => {
18
+ const script = buildOrchestratorScript(baseParams);
19
+ expect(script).not.toMatch(/\blsof\b/);
20
+ expect(script).not.toMatch(/\bcurl\b/);
21
+ expect(script).not.toMatch(/\bsh\s+-c\b/);
22
+ // Uses Node built-ins
23
+ expect(script).toMatch(/require\("node:net"\)/);
24
+ expect(script).toMatch(/require\("node:http"\)/);
25
+ expect(script).toMatch(/require\("node:child_process"\)/);
26
+ });
27
+
28
+ it("embeds the port as a number literal", () => {
29
+ const script = buildOrchestratorScript({ ...baseParams, port: 12345 });
30
+ expect(script).toMatch(/const PORT = 12345/);
31
+ });
32
+
33
+ it("embeds the loader as a --import arg when provided", () => {
34
+ const script = buildOrchestratorScript(baseParams);
35
+ // ARGS should be a JSON array containing --import and the loader
36
+ expect(script).toMatch(/const ARGS = \[.*"--import".*"file:\/\/\/tmp\/jiti-register\.mjs"/);
37
+ // On POSIX, cliPath stays RAW — jiti's resolver misbehaves on file:// URL entries.
38
+ expect(script).toMatch(/"\/tmp\/cli\.ts"/);
39
+ expect(script).not.toContain(JSON.stringify("file:///tmp/cli.ts"));
40
+ expect(script).toMatch(/"start"/);
41
+ });
42
+
43
+ it("omits --import when loader is empty", () => {
44
+ const script = buildOrchestratorScript({ ...baseParams, loader: "" });
45
+ expect(script).not.toMatch(/"--import"/);
46
+ // No loader + POSIX host → raw entry.
47
+ expect(script).toMatch(/"\/tmp\/cli\.ts"/);
48
+ expect(script).not.toContain(JSON.stringify("file:///tmp/cli.ts"));
49
+ expect(script).toMatch(/"start"/);
50
+ });
51
+
52
+ it("appends extra args (e.g. --dev) after 'start'", () => {
53
+ const script = buildOrchestratorScript({ ...baseParams, extraArgs: ["--dev"] });
54
+ // ARGS array should have "start" immediately followed by "--dev"
55
+ expect(script).toMatch(/"start","--dev"/);
56
+ });
57
+
58
+ it("wraps Windows cliPath as file:// URL when loader is jiti AND host is Windows (Node parses drive letters as URL schemes)", () => {
59
+ // NOTE: shouldUrlWrapEntry consults process.platform. This test runs on
60
+ // Linux CI, so the wrap branch isn't directly exercised here — but the
61
+ // UNIT test for shouldUrlWrapEntry itself covers the win32 contract.
62
+ // Here we verify the tree of what buildOrchestratorScript emits on the
63
+ // host platform (Linux): raw entry even with a Windows-styled path.
64
+ const winParams = {
65
+ ...baseParams,
66
+ cliPath: "B:\\Dev\\BB\\pi-agent-dashboard\\packages\\server\\src\\cli.ts",
67
+ loader: "file:///B:/Dev/Nodejs/global/node_modules/@mariozechner/jiti/lib/jiti-register.mjs",
68
+ execPath: "C:\\Program Files\\nodejs\\node.exe",
69
+ };
70
+ const script = buildOrchestratorScript(winParams);
71
+ expect(script).toContain(JSON.stringify(winParams.execPath));
72
+ expect(script).toContain(JSON.stringify(winParams.loader));
73
+ // Host is Linux → entry stays raw (tested branch here).
74
+ expect(script).toContain(JSON.stringify(winParams.cliPath));
75
+ });
76
+
77
+ it("keeps cliPath as RAW path when loader is tsx (tsx rejects file:// URL entries)", () => {
78
+ // Regression: tsx's ESM hook treats the entry as a user-typed specifier
79
+ // and attempts bare/relative resolution. A file:// URL becomes "<cwd>/file:/..."
80
+ // and crashes with ERR_MODULE_NOT_FOUND. This is the Linux dev-loop case
81
+ // (jiti not in repo node_modules, tsx fallback picked up).
82
+ const tsxParams = {
83
+ cliPath: "/home/u/repo/packages/server/src/cli.ts",
84
+ loader: "file:///home/u/repo/node_modules/tsx/dist/esm/index.mjs",
85
+ port: 8000,
86
+ extraArgs: [] as string[],
87
+ execPath: "/usr/bin/node",
88
+ };
89
+ const script = buildOrchestratorScript(tsxParams);
90
+ // Loader is still URL-wrapped (Node's --import requires file://)
91
+ expect(script).toContain(JSON.stringify(tsxParams.loader));
92
+ // Entry is the RAW path, NOT a file:// URL
93
+ expect(script).toContain(JSON.stringify(tsxParams.cliPath));
94
+ // Negative: must NOT contain the file:// URL form of the entry
95
+ const urlForm = "file://" + tsxParams.cliPath;
96
+ expect(script).not.toContain(JSON.stringify(urlForm));
97
+ });
98
+
99
+ it("references ~/.pi/dashboard/restart.log for failure logging", () => {
100
+ const script = buildOrchestratorScript(baseParams);
101
+ expect(script).toMatch(/restart\.log/);
102
+ expect(script).toMatch(/fs\.appendFileSync/);
103
+ });
104
+
105
+ it("health-check target is /api/health on the configured port", () => {
106
+ const script = buildOrchestratorScript({ ...baseParams, port: 8765 });
107
+ expect(script).toMatch(/\/api\/health/);
108
+ expect(script).toMatch(/const PORT = 8765/);
109
+ expect(script).toMatch(/port: PORT/);
110
+ });
111
+ });