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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/AGENTS.md +67 -116
  2. package/README.md +93 -7
  3. package/docs/architecture.md +408 -9
  4. package/package.json +6 -4
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +201 -0
  7. package/packages/extension/src/__tests__/git-info.test.ts +67 -55
  8. package/packages/extension/src/__tests__/openspec-poller.test.ts +101 -96
  9. package/packages/extension/src/__tests__/process-scanner-kill.test.ts +61 -0
  10. package/packages/extension/src/__tests__/provider-register-reload.test.ts +394 -0
  11. package/packages/extension/src/__tests__/server-auto-start.test.ts +95 -4
  12. package/packages/extension/src/__tests__/server-launcher.test.ts +16 -0
  13. package/packages/extension/src/bridge.ts +69 -2
  14. package/packages/extension/src/dev-build.ts +1 -1
  15. package/packages/extension/src/git-info.ts +9 -19
  16. package/packages/extension/src/pi-env.d.ts +1 -0
  17. package/packages/extension/src/process-scanner.ts +72 -38
  18. package/packages/extension/src/provider-register.ts +304 -16
  19. package/packages/extension/src/server-auto-start.ts +27 -1
  20. package/packages/extension/src/server-launcher.ts +71 -27
  21. package/packages/server/package.json +16 -2
  22. package/packages/server/src/__tests__/bootstrap-queue.test.ts +120 -0
  23. package/packages/server/src/__tests__/bootstrap-routes.test.ts +125 -0
  24. package/packages/server/src/__tests__/bootstrap-state.test.ts +119 -0
  25. package/packages/server/src/__tests__/browse-endpoint.test.ts +17 -0
  26. package/packages/server/src/__tests__/cli-parse.test.ts +11 -0
  27. package/packages/server/src/__tests__/concurrent-launch.test.ts +110 -0
  28. package/packages/server/src/__tests__/config-api.test.ts +68 -0
  29. package/packages/server/src/__tests__/crash-recovery.test.ts +88 -0
  30. package/packages/server/src/__tests__/directory-service.test.ts +234 -8
  31. package/packages/server/src/__tests__/editor-registry.test.ts +28 -15
  32. package/packages/server/src/__tests__/extension-register-appimage.test.ts +5 -1
  33. package/packages/server/src/__tests__/extension-register.test.ts +3 -1
  34. package/packages/server/src/__tests__/find-port-holders.test.ts +94 -0
  35. package/packages/server/src/__tests__/force-kill-handler.test.ts +57 -8
  36. package/packages/server/src/__tests__/home-lock-escape-hatch.test.ts +60 -0
  37. package/packages/server/src/__tests__/home-lock-release.test.ts +85 -0
  38. package/packages/server/src/__tests__/home-lock.test.ts +308 -0
  39. package/packages/server/src/__tests__/is-pi-process.test.ts +36 -0
  40. package/packages/server/src/__tests__/node-guard.test.ts +85 -0
  41. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +5 -1
  42. package/packages/server/src/__tests__/package-manager-wrapper.test.ts +45 -10
  43. package/packages/server/src/__tests__/pi-version-skew.test.ts +165 -0
  44. package/packages/server/src/__tests__/preferences-store.test.ts +73 -4
  45. package/packages/server/src/__tests__/process-manager.test.ts +45 -18
  46. package/packages/server/src/__tests__/provider-probe.test.ts +287 -0
  47. package/packages/server/src/__tests__/provider-test-route.test.ts +149 -0
  48. package/packages/server/src/__tests__/restart-helper.test.ts +83 -0
  49. package/packages/server/src/__tests__/session-action-handler-headless-reload.test.ts +467 -0
  50. package/packages/server/src/__tests__/session-action-handler-reload-predicate.test.ts +73 -0
  51. package/packages/server/src/__tests__/session-action-handler-spawn-error.test.ts +74 -0
  52. package/packages/server/src/__tests__/terminal-manager.test.ts +41 -1
  53. package/packages/server/src/__tests__/tool-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/trusted-networks-config.test.ts +19 -0
  55. package/packages/server/src/__tests__/trusted-networks-no-oauth-roundtrip.test.ts +126 -0
  56. package/packages/server/src/__tests__/tunnel-cleanup.test.ts +90 -0
  57. package/packages/server/src/__tests__/tunnel.test.ts +13 -7
  58. package/packages/server/src/__tests__/wsl-tmux-probe-cache.test.ts +44 -0
  59. package/packages/server/src/bootstrap-queue.ts +130 -0
  60. package/packages/server/src/bootstrap-state.ts +131 -0
  61. package/packages/server/src/browse.ts +8 -3
  62. package/packages/server/src/browser-handlers/directory-handler.ts +23 -8
  63. package/packages/server/src/browser-handlers/session-action-handler.ts +213 -79
  64. package/packages/server/src/browser-handlers/session-action-helpers.ts +36 -0
  65. package/packages/server/src/cli.ts +256 -32
  66. package/packages/server/src/config-api.ts +16 -0
  67. package/packages/server/src/directory-service.ts +270 -39
  68. package/packages/server/src/editor-detection.ts +12 -9
  69. package/packages/server/src/editor-manager.ts +19 -4
  70. package/packages/server/src/editor-pid-registry.ts +9 -8
  71. package/packages/server/src/editor-registry.ts +22 -25
  72. package/packages/server/src/git-operations.ts +1 -1
  73. package/packages/server/src/headless-pid-registry.ts +7 -20
  74. package/packages/server/src/home-lock-release.ts +72 -0
  75. package/packages/server/src/home-lock.ts +389 -0
  76. package/packages/server/src/node-guard.ts +52 -0
  77. package/packages/server/src/package-manager-wrapper.ts +207 -47
  78. package/packages/server/src/pi-core-checker.ts +1 -1
  79. package/packages/server/src/pi-core-updater.ts +7 -1
  80. package/packages/server/src/pi-resource-scanner.ts +5 -8
  81. package/packages/server/src/pi-version-skew.ts +196 -0
  82. package/packages/server/src/preferences-store.ts +17 -3
  83. package/packages/server/src/process-manager.ts +403 -222
  84. package/packages/server/src/provider-probe.ts +234 -0
  85. package/packages/server/src/restart-helper.ts +130 -0
  86. package/packages/server/src/routes/bootstrap-routes.ts +88 -0
  87. package/packages/server/src/routes/openspec-routes.ts +25 -1
  88. package/packages/server/src/routes/pi-core-routes.ts +24 -1
  89. package/packages/server/src/routes/provider-auth-routes.ts +8 -8
  90. package/packages/server/src/routes/provider-routes.ts +43 -0
  91. package/packages/server/src/routes/recommended-routes.ts +10 -12
  92. package/packages/server/src/routes/system-routes.ts +20 -33
  93. package/packages/server/src/routes/tool-routes.ts +153 -0
  94. package/packages/server/src/server-pid.ts +5 -9
  95. package/packages/server/src/server.ts +211 -10
  96. package/packages/server/src/session-api.ts +77 -8
  97. package/packages/server/src/session-bootstrap.ts +17 -3
  98. package/packages/server/src/session-diff.ts +21 -21
  99. package/packages/server/src/terminal-manager.ts +61 -20
  100. package/packages/server/src/tunnel.ts +42 -28
  101. package/packages/shared/package.json +10 -3
  102. package/packages/shared/src/__tests__/{tool-resolver.test.ts → binary-lookup.test.ts} +32 -12
  103. package/packages/shared/src/__tests__/bootstrap/README.md +133 -0
  104. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +370 -0
  105. package/packages/shared/src/__tests__/bootstrap/assertions.ts +136 -0
  106. package/packages/shared/src/__tests__/bootstrap/cube.test.ts +47 -0
  107. package/packages/shared/src/__tests__/bootstrap/cube.ts +66 -0
  108. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +83 -0
  109. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +89 -0
  110. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +33 -0
  111. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/d-overrides.test.ts.snap +20 -0
  112. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +61 -0
  113. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +33 -0
  114. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +46 -0
  115. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/j-path-gui-minimal.test.ts.snap +12 -0
  116. package/packages/shared/src/__tests__/bootstrap/families/a-electron.test.ts +156 -0
  117. package/packages/shared/src/__tests__/bootstrap/families/b-npm-global.test.ts +157 -0
  118. package/packages/shared/src/__tests__/bootstrap/families/c-dev-monorepo.test.ts +102 -0
  119. package/packages/shared/src/__tests__/bootstrap/families/d-overrides.test.ts +76 -0
  120. package/packages/shared/src/__tests__/bootstrap/families/e-stale-partial.test.ts +94 -0
  121. package/packages/shared/src/__tests__/bootstrap/families/f-cwd-variants.test.ts +87 -0
  122. package/packages/shared/src/__tests__/bootstrap/families/g-windows-specifics.test.ts +143 -0
  123. package/packages/shared/src/__tests__/bootstrap/families/h-home-drift.test.ts +64 -0
  124. package/packages/shared/src/__tests__/bootstrap/families/i-malformed-settings.test.ts +77 -0
  125. package/packages/shared/src/__tests__/bootstrap/families/index.ts +19 -0
  126. package/packages/shared/src/__tests__/bootstrap/families/j-path-gui-minimal.test.ts +61 -0
  127. package/packages/shared/src/__tests__/bootstrap/families/k-dashboard-absent.test.ts +50 -0
  128. package/packages/shared/src/__tests__/bootstrap/families/l-instance-coordination.test.ts +272 -0
  129. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +58 -0
  130. package/packages/shared/src/__tests__/bootstrap/fixtures/electron-layout.ts +84 -0
  131. package/packages/shared/src/__tests__/bootstrap/fixtures/index.ts +9 -0
  132. package/packages/shared/src/__tests__/bootstrap/fixtures/managed-install.ts +85 -0
  133. package/packages/shared/src/__tests__/bootstrap/fixtures/npm-global-layout.ts +122 -0
  134. package/packages/shared/src/__tests__/bootstrap/fixtures/pi-versions.ts +36 -0
  135. package/packages/shared/src/__tests__/bootstrap/fixtures/settings-json.ts +39 -0
  136. package/packages/shared/src/__tests__/bootstrap/harness.smoke.test.ts +220 -0
  137. package/packages/shared/src/__tests__/bootstrap/harness.ts +413 -0
  138. package/packages/shared/src/__tests__/bootstrap/scenarios-skipped.ts +125 -0
  139. package/packages/shared/src/__tests__/bootstrap/scenarios.ts +132 -0
  140. package/packages/shared/src/__tests__/bridge-register.test.ts +29 -6
  141. package/packages/shared/src/__tests__/config-openspec.test.ts +106 -0
  142. package/packages/shared/src/__tests__/config.test.ts +56 -0
  143. package/packages/shared/src/__tests__/detached-spawn.test.ts +243 -0
  144. package/packages/shared/src/__tests__/managed-paths.test.ts +60 -0
  145. package/packages/shared/src/__tests__/no-direct-child-process.test.ts +112 -0
  146. package/packages/shared/src/__tests__/no-direct-platform-branch.test.ts +174 -0
  147. package/packages/shared/src/__tests__/no-direct-process-kill.test.ts +105 -0
  148. package/packages/shared/src/__tests__/platform-commands.test.ts +108 -0
  149. package/packages/shared/src/__tests__/platform-exec.test.ts +103 -0
  150. package/packages/shared/src/__tests__/platform-git.test.ts +194 -0
  151. package/packages/shared/src/__tests__/platform-npm.test.ts +137 -0
  152. package/packages/shared/src/__tests__/platform-openspec.test.ts +92 -0
  153. package/packages/shared/src/__tests__/platform-paths.test.ts +284 -0
  154. package/packages/shared/src/__tests__/platform-process-scan.test.ts +55 -0
  155. package/packages/shared/src/__tests__/platform-process.test.ts +160 -0
  156. package/packages/shared/src/__tests__/platform-runner.test.ts +173 -0
  157. package/packages/shared/src/__tests__/platform-shell.test.ts +74 -0
  158. package/packages/shared/src/__tests__/process-identify.test.ts +113 -0
  159. package/packages/shared/src/__tests__/recommended-extensions.test.ts +40 -7
  160. package/packages/shared/src/__tests__/resolve-jiti.test.ts +43 -7
  161. package/packages/shared/src/__tests__/semaphore.test.ts +119 -0
  162. package/packages/shared/src/__tests__/spawn-mechanism.test.ts +131 -0
  163. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +239 -0
  164. package/packages/shared/src/__tests__/tool-registry-overrides.test.ts +137 -0
  165. package/packages/shared/src/__tests__/tool-registry-registry.test.ts +343 -0
  166. package/packages/shared/src/bootstrap-install.ts +212 -0
  167. package/packages/shared/src/bridge-register.ts +87 -20
  168. package/packages/shared/src/browser-protocol.ts +71 -1
  169. package/packages/shared/src/config.ts +87 -15
  170. package/packages/shared/src/managed-paths.ts +31 -4
  171. package/packages/shared/src/openspec-poller.ts +63 -46
  172. package/packages/shared/src/{tool-resolver.ts → platform/binary-lookup.ts} +125 -25
  173. package/packages/shared/src/platform/commands.ts +100 -0
  174. package/packages/shared/src/platform/detached-spawn.ts +305 -0
  175. package/packages/shared/src/platform/exec.ts +220 -0
  176. package/packages/shared/src/platform/git.ts +155 -0
  177. package/packages/shared/src/platform/index.ts +15 -0
  178. package/packages/shared/src/platform/npm.ts +162 -0
  179. package/packages/shared/src/platform/openspec.ts +91 -0
  180. package/packages/shared/src/platform/paths.ts +276 -0
  181. package/packages/shared/src/platform/process-identify.ts +126 -0
  182. package/packages/shared/src/platform/process-scan.ts +94 -0
  183. package/packages/shared/src/platform/process.ts +168 -0
  184. package/packages/shared/src/platform/runner.ts +369 -0
  185. package/packages/shared/src/platform/shell.ts +44 -0
  186. package/packages/shared/src/platform/spawn-mechanism.ts +124 -0
  187. package/packages/shared/src/platform/subprocess-adapter.ts +124 -0
  188. package/packages/shared/src/recommended-extensions.ts +18 -2
  189. package/packages/shared/src/resolve-jiti.ts +62 -3
  190. package/packages/shared/src/rest-api.ts +26 -0
  191. package/packages/shared/src/semaphore.ts +83 -0
  192. package/packages/shared/src/tool-registry/definitions.ts +342 -0
  193. package/packages/shared/src/tool-registry/index.ts +56 -0
  194. package/packages/shared/src/tool-registry/overrides.ts +118 -0
  195. package/packages/shared/src/tool-registry/registry.ts +262 -0
  196. package/packages/shared/src/tool-registry/strategies.ts +198 -0
  197. package/packages/shared/src/tool-registry/types.ts +180 -0
@@ -0,0 +1,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,83 @@
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
+ expect(script).toMatch(/"\/tmp\/cli\.ts"/);
38
+ expect(script).toMatch(/"start"/);
39
+ });
40
+
41
+ it("omits --import when loader is empty", () => {
42
+ const script = buildOrchestratorScript({ ...baseParams, loader: "" });
43
+ expect(script).not.toMatch(/"--import"/);
44
+ expect(script).toMatch(/"\/tmp\/cli\.ts"/);
45
+ expect(script).toMatch(/"start"/);
46
+ });
47
+
48
+ it("appends extra args (e.g. --dev) after 'start'", () => {
49
+ const script = buildOrchestratorScript({ ...baseParams, extraArgs: ["--dev"] });
50
+ // ARGS array should have "start" immediately followed by "--dev"
51
+ expect(script).toMatch(/"start","--dev"/);
52
+ });
53
+
54
+ it("safely embeds Windows paths with backslashes and drive letters", () => {
55
+ const winParams = {
56
+ ...baseParams,
57
+ cliPath: "B:\\Dev\\BB\\pi-agent-dashboard\\packages\\server\\src\\cli.ts",
58
+ loader: "file:///B:/Dev/Nodejs/global/node_modules/@mariozechner/jiti/lib/jiti-register.mjs",
59
+ execPath: "C:\\Program Files\\nodejs\\node.exe",
60
+ };
61
+ const script = buildOrchestratorScript(winParams);
62
+ // Must be embedded via JSON.stringify (backslashes escaped, quotes preserved)
63
+ expect(script).toContain(JSON.stringify(winParams.execPath));
64
+ expect(script).toContain(JSON.stringify(winParams.cliPath));
65
+ expect(script).toContain(JSON.stringify(winParams.loader));
66
+ // Should not contain raw unescaped backslashes that would break the JS
67
+ // (we embed via JSON.stringify which escapes them to \\)
68
+ expect(script).toMatch(/B:\\\\Dev\\\\BB/);
69
+ });
70
+
71
+ it("references ~/.pi/dashboard/restart.log for failure logging", () => {
72
+ const script = buildOrchestratorScript(baseParams);
73
+ expect(script).toMatch(/restart\.log/);
74
+ expect(script).toMatch(/fs\.appendFileSync/);
75
+ });
76
+
77
+ it("health-check target is /api/health on the configured port", () => {
78
+ const script = buildOrchestratorScript({ ...baseParams, port: 8765 });
79
+ expect(script).toMatch(/\/api\/health/);
80
+ expect(script).toMatch(/const PORT = 8765/);
81
+ expect(script).toMatch(/port: PORT/);
82
+ });
83
+ });