@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.2

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 (201) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +49 -7
  3. package/docs/architecture.md +129 -1
  4. package/package.json +15 -15
  5. package/packages/extension/package.json +11 -3
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
  7. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  8. package/packages/extension/src/__tests__/command-handler.test.ts +78 -8
  9. package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
  10. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  11. package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
  12. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  13. package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
  14. package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
  15. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  16. package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
  17. package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
  18. package/packages/extension/src/ask-user-tool.ts +1 -1
  19. package/packages/extension/src/bridge-context.ts +68 -4
  20. package/packages/extension/src/bridge.ts +79 -11
  21. package/packages/extension/src/command-handler.ts +95 -15
  22. package/packages/extension/src/flow-event-wiring.ts +1 -1
  23. package/packages/extension/src/multiselect-list.ts +1 -1
  24. package/packages/extension/src/pi-env.d.ts +16 -9
  25. package/packages/extension/src/prompt-expander.ts +74 -63
  26. package/packages/extension/src/provider-register.ts +16 -9
  27. package/packages/extension/src/retry-tracker.ts +123 -0
  28. package/packages/extension/src/server-launcher.ts +31 -70
  29. package/packages/extension/src/session-sync.ts +10 -1
  30. package/packages/extension/src/slash-dispatch.ts +123 -0
  31. package/packages/extension/src/usage-limit-orderer.ts +76 -0
  32. package/packages/server/bin/pi-dashboard.mjs +84 -0
  33. package/packages/server/package.json +8 -7
  34. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  35. package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
  36. package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
  37. package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
  38. package/packages/server/src/__tests__/cli-parse.test.ts +16 -4
  39. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  40. package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
  41. package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
  42. package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
  43. package/packages/server/src/__tests__/directory-service.test.ts +2 -2
  44. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  45. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  46. package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
  47. package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
  48. package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
  49. package/packages/server/src/__tests__/headless-pid-registry.test.ts +316 -0
  50. package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
  51. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  52. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  53. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  54. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  55. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  56. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  57. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  58. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  59. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  60. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  61. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  62. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  63. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
  64. package/packages/server/src/__tests__/package-routes.test.ts +1 -1
  65. package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
  66. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  67. package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
  68. package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
  69. package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
  70. package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
  71. package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
  72. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  73. package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
  74. package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
  75. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  76. package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
  77. package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
  78. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  79. package/packages/server/src/__tests__/recommended-routes.test.ts +3 -3
  80. package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
  81. package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
  82. package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
  83. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  84. package/packages/server/src/auth-plugin.ts +3 -0
  85. package/packages/server/src/bootstrap-state.ts +10 -0
  86. package/packages/server/src/browser-gateway.ts +27 -10
  87. package/packages/server/src/browser-handlers/handler-context.ts +9 -0
  88. package/packages/server/src/browser-handlers/session-action-handler.ts +128 -19
  89. package/packages/server/src/changelog-fs.ts +167 -0
  90. package/packages/server/src/changelog-parser.ts +321 -0
  91. package/packages/server/src/changelog-remote.ts +134 -0
  92. package/packages/server/src/cli.ts +62 -82
  93. package/packages/server/src/config-api.ts +14 -2
  94. package/packages/server/src/directory-service.ts +106 -4
  95. package/packages/server/src/event-wiring.ts +90 -6
  96. package/packages/server/src/headless-pid-registry.ts +344 -37
  97. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  98. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  99. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  100. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  101. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  102. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  103. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  104. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  105. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  106. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  107. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  108. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  109. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  110. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  111. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  112. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  113. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  114. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  115. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  116. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  117. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  118. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  119. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  120. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  121. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  122. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  123. package/packages/server/src/model-proxy/request-log.ts +53 -0
  124. package/packages/server/src/model-proxy/streamer.ts +59 -0
  125. package/packages/server/src/openspec-group-store.ts +490 -0
  126. package/packages/server/src/pending-client-correlations.ts +73 -0
  127. package/packages/server/src/pending-fork-registry.ts +24 -12
  128. package/packages/server/src/pi-core-checker.ts +77 -17
  129. package/packages/server/src/pi-core-updater.ts +16 -6
  130. package/packages/server/src/pi-dev-version-check.ts +145 -0
  131. package/packages/server/src/pi-gateway.ts +4 -0
  132. package/packages/server/src/pi-version-skew.ts +12 -4
  133. package/packages/server/src/process-manager.ts +182 -11
  134. package/packages/server/src/provider-auth-storage.ts +29 -47
  135. package/packages/server/src/provider-catalogue-cache.ts +24 -18
  136. package/packages/server/src/restart-helper.ts +17 -16
  137. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  138. package/packages/server/src/routes/jj-routes.ts +3 -0
  139. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  140. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  141. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  142. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  143. package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
  144. package/packages/server/src/routes/pi-core-routes.ts +1 -1
  145. package/packages/server/src/routes/provider-auth-routes.ts +8 -1
  146. package/packages/server/src/routes/provider-routes.ts +28 -5
  147. package/packages/server/src/routes/system-routes.ts +44 -2
  148. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  149. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  150. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  151. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  152. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  153. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  154. package/packages/server/src/server.ts +254 -60
  155. package/packages/server/src/session-api.ts +63 -4
  156. package/packages/server/src/session-discovery.ts +1 -1
  157. package/packages/server/src/session-file-reader.ts +1 -1
  158. package/packages/server/src/spawn-register-watchdog.ts +62 -7
  159. package/packages/server/src/spawn-token.ts +20 -0
  160. package/packages/server/src/tunnel-watchdog.ts +230 -0
  161. package/packages/server/src/tunnel.ts +5 -1
  162. package/packages/shared/package.json +1 -1
  163. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  164. package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
  165. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
  166. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
  167. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
  168. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
  169. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
  170. package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
  171. package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
  172. package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
  173. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  174. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  175. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  176. package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
  177. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  178. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  179. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  180. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  181. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  182. package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
  183. package/packages/shared/src/bootstrap-install.ts +1 -1
  184. package/packages/shared/src/browser-protocol.ts +70 -0
  185. package/packages/shared/src/changelog-types.ts +111 -0
  186. package/packages/shared/src/config.ts +172 -2
  187. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  188. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  189. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  190. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  191. package/packages/shared/src/platform/node-spawn.ts +71 -26
  192. package/packages/shared/src/protocol.ts +27 -1
  193. package/packages/shared/src/recommended-extensions.ts +18 -0
  194. package/packages/shared/src/rest-api.ts +219 -1
  195. package/packages/shared/src/server-launcher.ts +277 -0
  196. package/packages/shared/src/skill-block-parser.ts +1 -1
  197. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  198. package/packages/shared/src/tool-registry/definitions.ts +15 -3
  199. package/packages/shared/src/types.ts +62 -0
  200. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -53
  201. package/packages/shared/src/resolve-jiti.ts +0 -102
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Tests for the provider-catalogue cache.
3
- * See change: replace-hardcoded-provider-lists.
3
+ *
4
+ * The cache is now a single global snapshot of the most-recent
5
+ * `providers_list` push. No per-session split, no `changed` signal —
6
+ * see change: simplify-model-selection-channels for why.
4
7
  */
5
8
  import { describe, it, expect, beforeEach } from "vitest";
6
9
  import {
7
10
  setCatalogueForSession,
8
- getCatalogueForSession,
9
11
  getLatestCatalogue,
10
- clearForSession,
11
12
  _resetForTests,
12
13
  } from "../provider-catalogue-cache.js";
13
14
  import type { ProviderInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
@@ -18,37 +19,26 @@ const B: ProviderInfo = { id: "b", displayName: "B", hasOAuth: false, configured
18
19
  describe("provider-catalogue-cache", () => {
19
20
  beforeEach(() => _resetForTests());
20
21
 
21
- it("starts empty", () => {
22
+ it("getLatestCatalogue returns [] before any push", () => {
22
23
  expect(getLatestCatalogue()).toEqual([]);
23
- expect(getCatalogueForSession("s1")).toBeUndefined();
24
24
  });
25
25
 
26
- it("set/get per session", () => {
27
- setCatalogueForSession("s1", [A]);
28
- expect(getCatalogueForSession("s1")).toEqual([A]);
29
- });
30
-
31
- it("latestSnapshot reflects most recent push across sessions", () => {
26
+ it("setCatalogueForSession overwrites the global snapshot", () => {
32
27
  setCatalogueForSession("s1", [A]);
33
28
  expect(getLatestCatalogue()).toEqual([A]);
34
- setCatalogueForSession("s2", [A, B]);
35
- expect(getLatestCatalogue()).toEqual([A, B]);
36
- });
37
-
38
- it("clearForSession removes that session and clears latest only when empty", () => {
39
- setCatalogueForSession("s1", [A]);
40
29
  setCatalogueForSession("s2", [B]);
41
- clearForSession("s1");
42
- expect(getCatalogueForSession("s1")).toBeUndefined();
43
30
  expect(getLatestCatalogue()).toEqual([B]);
44
- clearForSession("s2");
45
- expect(getLatestCatalogue()).toEqual([]);
46
31
  });
47
32
 
48
- it("_resetForTests wipes everything", () => {
33
+ it("last writer wins regardless of sessionId", () => {
34
+ setCatalogueForSession("s1", [A, B]);
35
+ setCatalogueForSession("s2", [A]);
36
+ expect(getLatestCatalogue()).toEqual([A]);
37
+ });
38
+
39
+ it("_resetForTests clears the snapshot", () => {
49
40
  setCatalogueForSession("s1", [A]);
50
41
  _resetForTests();
51
42
  expect(getLatestCatalogue()).toEqual([]);
52
- expect(getCatalogueForSession("s1")).toBeUndefined();
53
43
  });
54
44
  });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Integration test for recursion guard wired into PUT /api/providers (task 10.4).
3
+ *
4
+ * Verifies:
5
+ * - Self-pointing baseUrl → 400 with code RECURSIVE_PROXY
6
+ * - Valid external baseUrl → accepted (2xx, written to disk)
7
+ * - Existing providers untouched on validation failure
8
+ */
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import Fastify from "fastify";
11
+ import { writeFileSync, mkdirSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { homedir } from "node:os";
14
+ import { registerProviderRoutes } from "../routes/provider-routes.js";
15
+
16
+ const PROVIDERS_PATH = join(homedir(), ".pi", "agent", "providers.json");
17
+ const PROVIDERS_DIR = join(homedir(), ".pi", "agent");
18
+
19
+ // Back up / restore providers.json around each test
20
+ let backup: string | null = null;
21
+ beforeEach(() => {
22
+ try { backup = require("fs").readFileSync(PROVIDERS_PATH, "utf-8"); } catch { backup = null; }
23
+ });
24
+ afterEach(() => {
25
+ try {
26
+ if (backup !== null) {
27
+ writeFileSync(PROVIDERS_PATH, backup);
28
+ } else {
29
+ rmSync(PROVIDERS_PATH, { force: true });
30
+ }
31
+ } catch {}
32
+ });
33
+
34
+ async function buildApp(port = 8000) {
35
+ const app = Fastify({ logger: false });
36
+ const networkGuard = async () => {};
37
+ mkdirSync(PROVIDERS_DIR, { recursive: true });
38
+ registerProviderRoutes(app, { networkGuard, port });
39
+ await app.ready();
40
+ return app;
41
+ }
42
+
43
+ describe("recursion guard on PUT /api/providers (task 10.4)", () => {
44
+ it("localhost self-pointing baseUrl → 400 RECURSIVE_PROXY", async () => {
45
+ const app = await buildApp(8000);
46
+
47
+ const res = await app.inject({
48
+ method: "PUT",
49
+ url: "/api/providers",
50
+ headers: { "content-type": "application/json" },
51
+ body: JSON.stringify({
52
+ providers: {
53
+ self: { baseUrl: "http://localhost:8000/v1", apiKey: "" },
54
+ },
55
+ }),
56
+ });
57
+
58
+ expect(res.statusCode).toBe(400);
59
+ const body = JSON.parse(res.body);
60
+ expect(body.code).toBe("RECURSIVE_PROXY");
61
+ expect(body.offendingBaseUrl).toBe("http://localhost:8000/v1");
62
+ });
63
+
64
+ it("127.0.0.1 variant also caught", async () => {
65
+ const app = await buildApp(8000);
66
+
67
+ const res = await app.inject({
68
+ method: "PUT",
69
+ url: "/api/providers",
70
+ headers: { "content-type": "application/json" },
71
+ body: JSON.stringify({
72
+ providers: {
73
+ self: { baseUrl: "http://127.0.0.1:8000/v1", apiKey: "" },
74
+ },
75
+ }),
76
+ });
77
+
78
+ expect(res.statusCode).toBe(400);
79
+ expect(JSON.parse(res.body).code).toBe("RECURSIVE_PROXY");
80
+ });
81
+
82
+ it("external baseUrl passes validation", async () => {
83
+ const app = await buildApp(8000);
84
+
85
+ const res = await app.inject({
86
+ method: "PUT",
87
+ url: "/api/providers",
88
+ headers: { "content-type": "application/json" },
89
+ body: JSON.stringify({
90
+ providers: {
91
+ openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-test" },
92
+ },
93
+ }),
94
+ });
95
+
96
+ // Should succeed (200/204) or return a non-400 error
97
+ expect(res.statusCode).not.toBe(400);
98
+ const body = JSON.parse(res.body);
99
+ expect(body.code).not.toBe("RECURSIVE_PROXY");
100
+ });
101
+
102
+ it("validation failure leaves existing providers untouched", async () => {
103
+ // Pre-populate providers.json with a valid provider
104
+ mkdirSync(PROVIDERS_DIR, { recursive: true });
105
+ writeFileSync(PROVIDERS_PATH, JSON.stringify({
106
+ providers: { openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-existing" } },
107
+ }));
108
+
109
+ const app = await buildApp(8000);
110
+
111
+ // Attempt to add a recursive provider — should fail
112
+ const res = await app.inject({
113
+ method: "PUT",
114
+ url: "/api/providers",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({
117
+ providers: {
118
+ openai: { baseUrl: "https://api.openai.com/v1", apiKey: "sk-existing" },
119
+ self: { baseUrl: "http://localhost:8000/v1", apiKey: "" },
120
+ },
121
+ }),
122
+ });
123
+
124
+ expect(res.statusCode).toBe(400);
125
+
126
+ // Read providers.json — existing provider should still be there
127
+ const stored = JSON.parse(require("fs").readFileSync(PROVIDERS_PATH, "utf-8"));
128
+ expect(stored.providers?.openai?.baseUrl).toBe("https://api.openai.com/v1");
129
+ expect(stored.providers?.self).toBeUndefined();
130
+ });
131
+ });
@@ -8,7 +8,7 @@ import os from "node:os";
8
8
  import path from "node:path";
9
9
 
10
10
  // Mock pi dependency (pulled transitively by package-manager-wrapper)
11
- vi.mock("@mariozechner/pi-coding-agent", () => ({
11
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
12
12
  DefaultPackageManager: function () {
13
13
  return {};
14
14
  },
@@ -195,7 +195,7 @@ describe("GET /api/packages/recommended", () => {
195
195
  return fastify;
196
196
  }
197
197
 
198
- it("returns the 5 manifest entries with default (offline) descriptions", async () => {
198
+ it("returns the 6 manifest entries with default (offline) descriptions", async () => {
199
199
  vi.mocked(fetchPackageMeta).mockResolvedValue(null);
200
200
  vi.mocked(fetchGithubPackageJson).mockResolvedValue(null);
201
201
  await setupRoute();
@@ -208,7 +208,7 @@ describe("GET /api/packages/recommended", () => {
208
208
  const body = JSON.parse(res.payload);
209
209
  expect(body.success).toBe(true);
210
210
  const entries = body.data.recommended;
211
- expect(entries).toHaveLength(5);
211
+ expect(entries).toHaveLength(6);
212
212
  // Every entry falls back to fallbackDescription and has no version.
213
213
  for (const e of entries) {
214
214
  expect(typeof e.description).toBe("string");
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Integration: verify the kill-fork-kills-parent bug stays fixed.
3
+ *
4
+ * Simulates the race window where parent + fork are both registered in the
5
+ * same cwd and bridges connect in arbitrary order. Asserts the registry
6
+ * resolves each sessionId to its OWN PID via the three-tier link.
7
+ *
8
+ * See change: spawn-correlation-token.
9
+ */
10
+ import { describe, it, expect } from "vitest";
11
+ import { EventEmitter } from "node:events";
12
+ import { mkdtempSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { tmpdir } from "node:os";
15
+ import { createHeadlessPidRegistry } from "../headless-pid-registry.js";
16
+ import { createPendingForkRegistry } from "../pending-fork-registry.js";
17
+ import { mintSpawnToken } from "../spawn-token.js";
18
+
19
+ function mockProc() {
20
+ return new EventEmitter() as any;
21
+ }
22
+
23
+ function tmpPidFile() {
24
+ return join(mkdtempSync(join(tmpdir(), "spawn-corr-")), "pids.json");
25
+ }
26
+
27
+ describe("spawn-correlation-token: kill-fork-doesn't-kill-parent regression", () => {
28
+ it("two same-cwd spawns: each session resolves to its OWN pid via token", () => {
29
+ // Setup: simulate dashboard spawning parent, then fork, in the same cwd.
30
+ // Each spawn mints a token; registry stores entries by pid + token.
31
+ const registry = createHeadlessPidRegistry({ pidFilePath: tmpPidFile() });
32
+ const tokenParent = mintSpawnToken();
33
+ const tokenFork = mintSpawnToken();
34
+ expect(tokenParent).not.toBe(tokenFork);
35
+
36
+ registry.register(1000, "/proj", mockProc(), tokenParent);
37
+ registry.register(1234, "/proj", mockProc(), tokenFork);
38
+
39
+ // Bridge connect order is reversed (fork first, parent second) — the
40
+ // worst-case race that produced the original bug.
41
+ expect(registry.linkByToken(tokenFork, "S_fork")).toBe(true);
42
+ expect(registry.linkByToken(tokenParent, "S_parent")).toBe(true);
43
+
44
+ // Critical: each session resolves to its OWN pid. Pre-fix this would
45
+ // have given S_fork → 1000 (parent) and S_parent → 1234 (fork) due to
46
+ // cwd-FIFO ordering.
47
+ expect(registry.getPid("S_fork")).toBe(1234);
48
+ expect(registry.getPid("S_parent")).toBe(1000);
49
+ });
50
+
51
+ it("legacy bridge fallback: linkByPid is exact even without tokens", () => {
52
+ const registry = createHeadlessPidRegistry({ pidFilePath: tmpPidFile() });
53
+ // Legacy bridges don't send spawnToken; only pid.
54
+ registry.register(1000, "/proj", mockProc()); // no token
55
+ registry.register(1234, "/proj", mockProc()); // no token
56
+
57
+ // Race-order register messages, but pid-link is direct lookup.
58
+ expect(registry.linkByPid("S_fork", 1234)).toBe(true);
59
+ expect(registry.linkByPid("S_parent", 1000)).toBe(true);
60
+
61
+ expect(registry.getPid("S_fork")).toBe(1234);
62
+ expect(registry.getPid("S_parent")).toBe(1000);
63
+ });
64
+
65
+ it("fork registry: per-token keying separates two forks in same cwd", () => {
66
+ const forkRegistry = createPendingForkRegistry();
67
+ const tokenA = mintSpawnToken();
68
+ const tokenB = mintSpawnToken();
69
+
70
+ // Two forks issued in the same cwd, each with its own token. Pre-fix
71
+ // (cwd-keyed registry) the second recordFork would overwrite the first.
72
+ forkRegistry.recordFork(tokenA, "parent-A");
73
+ forkRegistry.recordFork(tokenB, "parent-B");
74
+
75
+ // Bridge connect order arbitrary; each token resolves to its OWN parent.
76
+ expect(forkRegistry.consumeFork(tokenB)).toBe("parent-B");
77
+ expect(forkRegistry.consumeFork(tokenA)).toBe("parent-A");
78
+ });
79
+
80
+ it("stale token (server-restart-mid-spawn) degrades to lower-tier match", () => {
81
+ const registry = createHeadlessPidRegistry({ pidFilePath: tmpPidFile() });
82
+ // Bridge holds an env-var token from before server restart; the new
83
+ // server has no entry for that token. linkByToken returns false; the
84
+ // event-wiring caller falls through to linkByPid.
85
+ registry.register(1000, "/proj", mockProc()); // post-restart entry, no token
86
+ expect(registry.linkByToken("stale_tok_from_old_server", "S")).toBe(false);
87
+ // Caller falls back:
88
+ expect(registry.linkByPid("S", 1000)).toBe(true);
89
+ expect(registry.getPid("S")).toBe(1000);
90
+ });
91
+ });
@@ -164,3 +164,87 @@ describe("SpawnRegisterWatchdog", () => {
164
164
  expect(recoveries).toHaveLength(0);
165
165
  });
166
166
  });
167
+
168
+ // See change: spawn-correlation-token — third index by token.
169
+ describe("SpawnRegisterWatchdog: byToken index", () => {
170
+ beforeEach(() => {
171
+ vi.useFakeTimers();
172
+ });
173
+ afterEach(() => {
174
+ vi.useRealTimers();
175
+ });
176
+
177
+ it("clearByToken cancels the watchdog", () => {
178
+ const w = new SpawnRegisterWatchdog(30_000);
179
+ const { ws, messages } = makeMockWs();
180
+ w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_a" });
181
+ w.clearByToken("tok_a");
182
+ vi.advanceTimersByTime(60_000);
183
+ expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
184
+ });
185
+
186
+ it("clearByToken removes entry from cwd and pid indices too", () => {
187
+ const w = new SpawnRegisterWatchdog(30_000);
188
+ const { ws } = makeMockWs();
189
+ w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_a" });
190
+ w.clearByToken("tok_a");
191
+ // Subsequent clearByPid / clearByCwd are no-ops (entry already removed).
192
+ w.clearByPid(100);
193
+ w.clearByCwd("/p");
194
+ // No exception, no double-clear.
195
+ expect(true).toBe(true);
196
+ });
197
+
198
+ it("clearByPid also clears the token index", () => {
199
+ const w = new SpawnRegisterWatchdog(30_000);
200
+ const { ws, messages } = makeMockWs();
201
+ w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_a" });
202
+ w.clearByPid(100);
203
+ // Token-keyed clear is now a no-op (already cleaned up).
204
+ w.clearByToken("tok_a");
205
+ vi.advanceTimersByTime(60_000);
206
+ expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
207
+ });
208
+
209
+ it("tmux arm without pid: token clears watchdog", () => {
210
+ const w = new SpawnRegisterWatchdog(30_000);
211
+ const { ws, messages } = makeMockWs();
212
+ w.arm({ cwd: "/p", mechanism: "tmux", ws, spawnToken: "tok_b" });
213
+ w.clearByToken("tok_b");
214
+ vi.advanceTimersByTime(60_000);
215
+ expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
216
+ });
217
+
218
+ it("late clearByToken after timeout emits recovered", () => {
219
+ const w = new SpawnRegisterWatchdog(30_000);
220
+ const { ws, messages } = makeMockWs();
221
+ w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws, spawnToken: "tok_c" });
222
+ vi.advanceTimersByTime(31_000); // timeout fires
223
+ expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(1);
224
+ w.clearByToken("tok_c");
225
+ expect(messages.filter((m) => m.includes("spawn_register_recovered"))).toHaveLength(1);
226
+ });
227
+
228
+ it("two simultaneous arms with distinct tokens, distinct cwds: token-clears each independently", () => {
229
+ const w = new SpawnRegisterWatchdog(30_000);
230
+ const { ws: ws1, messages: m1 } = makeMockWs();
231
+ const { ws: ws2, messages: m2 } = makeMockWs();
232
+ w.arm({ pid: 100, cwd: "/p1", mechanism: "headless", ws: ws1, spawnToken: "tok_x" });
233
+ w.arm({ pid: 200, cwd: "/p2", mechanism: "headless", ws: ws2, spawnToken: "tok_y" });
234
+ w.clearByToken("tok_y");
235
+ vi.advanceTimersByTime(31_000);
236
+ // Only the first arm's timeout fired (second was cleared).
237
+ expect(m1.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(1);
238
+ expect(m2.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(0);
239
+ });
240
+
241
+ it("arm without spawnToken behaves as before", () => {
242
+ const w = new SpawnRegisterWatchdog(30_000);
243
+ const { ws, messages } = makeMockWs();
244
+ w.arm({ pid: 100, cwd: "/p", mechanism: "headless", ws });
245
+ // Token-clear with empty / unknown token is a no-op.
246
+ w.clearByToken("tok_unknown");
247
+ vi.advanceTimersByTime(31_000);
248
+ expect(messages.filter((m) => m.includes("spawn_register_timeout"))).toHaveLength(1);
249
+ });
250
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Tests for spawn correlation token primitives.
3
+ *
4
+ * Covers:
5
+ * - `mintSpawnToken()` returns distinct UUIDv4 strings.
6
+ * - `buildSpawnEnv(env, { spawnToken })` injects `PI_DASHBOARD_SPAWN_TOKEN`.
7
+ * - Without `spawnToken`, env is unchanged (no leakage).
8
+ *
9
+ * See change: spawn-correlation-token.
10
+ */
11
+ import { describe, expect, it } from "vitest";
12
+ import { mintSpawnToken, SPAWN_TOKEN_ENV_VAR } from "../spawn-token.js";
13
+ import { buildSpawnEnv } from "../process-manager.js";
14
+
15
+ describe("mintSpawnToken", () => {
16
+ it("returns a UUIDv4 string", () => {
17
+ const t = mintSpawnToken();
18
+ expect(typeof t).toBe("string");
19
+ // UUIDv4: 8-4-4-4-12 hex with version=4 nibble
20
+ expect(t).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
21
+ });
22
+
23
+ it("returns distinct tokens on each call", () => {
24
+ const tokens = new Set<string>();
25
+ for (let i = 0; i < 50; i++) tokens.add(mintSpawnToken());
26
+ expect(tokens.size).toBe(50);
27
+ });
28
+ });
29
+
30
+ describe("buildSpawnEnv: spawnToken injection", () => {
31
+ it("sets PI_DASHBOARD_SPAWN_TOKEN when spawnToken is provided", () => {
32
+ const env = buildSpawnEnv({ HOME: "/tmp" }, { spawnToken: "tok_test_123" });
33
+ expect(env[SPAWN_TOKEN_ENV_VAR]).toBe("tok_test_123");
34
+ });
35
+
36
+ it("does not set PI_DASHBOARD_SPAWN_TOKEN when spawnToken is omitted", () => {
37
+ const env = buildSpawnEnv({ HOME: "/tmp" });
38
+ expect(env[SPAWN_TOKEN_ENV_VAR]).toBeUndefined();
39
+ });
40
+
41
+ it("does not set PI_DASHBOARD_SPAWN_TOKEN when opts is empty", () => {
42
+ const env = buildSpawnEnv({ HOME: "/tmp" }, {});
43
+ expect(env[SPAWN_TOKEN_ENV_VAR]).toBeUndefined();
44
+ });
45
+
46
+ it("preserves baseEnv variables unchanged when injecting", () => {
47
+ const env = buildSpawnEnv(
48
+ { HOME: "/tmp", FOO: "bar", PATH: "/usr/bin" },
49
+ { spawnToken: "tok_xyz" },
50
+ );
51
+ expect(env.HOME).toBe("/tmp");
52
+ expect(env.FOO).toBe("bar");
53
+ expect(env[SPAWN_TOKEN_ENV_VAR]).toBe("tok_xyz");
54
+ // PATH may be mutated by managed-node prepend, but the raw value should still appear in it.
55
+ expect(env.PATH).toContain("/usr/bin");
56
+ });
57
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ probeTunnel,
4
+ startTunnelWatchdog,
5
+ stopTunnelWatchdog,
6
+ getTunnelWatchdogStatus,
7
+ _runTickForTest,
8
+ _resetForTest,
9
+ } from "../tunnel-watchdog.js";
10
+
11
+ const URL = "https://abc.share.zrok.io";
12
+
13
+ function makeFetch(responses: Array<Response | Error>): typeof fetch {
14
+ let i = 0;
15
+ return (async () => {
16
+ const r = responses[Math.min(i, responses.length - 1)];
17
+ i += 1;
18
+ if (r instanceof Error) throw r;
19
+ return r;
20
+ }) as unknown as typeof fetch;
21
+ }
22
+
23
+ describe("probeTunnel", () => {
24
+ it("returns ok on 2xx", async () => {
25
+ const f = makeFetch([new Response("{}", { status: 200 })]);
26
+ expect(await probeTunnel(URL, 1000, f)).toEqual({ ok: true, status: 200 });
27
+ });
28
+
29
+ it("returns ok on 4xx (auth gate proves edge↔local works)", async () => {
30
+ const f = makeFetch([new Response("", { status: 401 })]);
31
+ expect(await probeTunnel(URL, 1000, f)).toEqual({ ok: true, status: 401 });
32
+ });
33
+
34
+ it("returns NOT ok on 5xx", async () => {
35
+ const f = makeFetch([new Response("bad gateway", { status: 502 })]);
36
+ const r = await probeTunnel(URL, 1000, f);
37
+ expect(r.ok).toBe(false);
38
+ expect(r.status).toBe(502);
39
+ expect(r.reason).toMatch(/502/);
40
+ });
41
+
42
+ it("returns NOT ok on network error", async () => {
43
+ const f = makeFetch([new Error("ENOTFOUND")]);
44
+ const r = await probeTunnel(URL, 1000, f);
45
+ expect(r.ok).toBe(false);
46
+ expect(r.reason).toMatch(/ENOTFOUND/);
47
+ });
48
+ });
49
+
50
+ describe("watchdog lifecycle", () => {
51
+ beforeEach(() => { _resetForTest(); });
52
+ afterEach(() => { _resetForTest(); });
53
+
54
+ it("does not start when disabled", () => {
55
+ startTunnelWatchdog(
56
+ { getUrl: () => URL, recycle: vi.fn(async () => URL) },
57
+ { enabled: false },
58
+ );
59
+ expect(getTunnelWatchdogStatus()).toBeNull();
60
+ });
61
+
62
+ it("recycles after threshold consecutive 5xx", async () => {
63
+ const recycle = vi.fn(async () => URL);
64
+ const fetchFn = makeFetch([
65
+ new Response("", { status: 502 }),
66
+ new Response("", { status: 502 }),
67
+ ]);
68
+ startTunnelWatchdog(
69
+ { getUrl: () => URL, recycle, fetchFn, log: () => {} },
70
+ { intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
71
+ );
72
+ await _runTickForTest();
73
+ expect(recycle).not.toHaveBeenCalled();
74
+ expect(getTunnelWatchdogStatus()?.consecutiveFailures).toBe(1);
75
+
76
+ await _runTickForTest();
77
+ expect(recycle).toHaveBeenCalledTimes(1);
78
+ const s = getTunnelWatchdogStatus()!;
79
+ expect(s.consecutiveFailures).toBe(0);
80
+ expect(s.recycleCount).toBe(1);
81
+ expect(s.lastRecycleAt).toBeGreaterThan(0);
82
+ });
83
+
84
+ it("does not recycle on a single failure surrounded by success", async () => {
85
+ const recycle = vi.fn(async () => URL);
86
+ const fetchFn = makeFetch([
87
+ new Response("", { status: 200 }),
88
+ new Response("", { status: 502 }),
89
+ new Response("", { status: 200 }),
90
+ ]);
91
+ startTunnelWatchdog(
92
+ { getUrl: () => URL, recycle, fetchFn, log: () => {} },
93
+ { intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
94
+ );
95
+ await _runTickForTest();
96
+ await _runTickForTest();
97
+ await _runTickForTest();
98
+ expect(recycle).not.toHaveBeenCalled();
99
+ expect(getTunnelWatchdogStatus()?.consecutiveFailures).toBe(0);
100
+ });
101
+
102
+ it("treats recycle failure as a no-op for stats but flags it for backoff", async () => {
103
+ const recycle = vi.fn(async () => null); // recycle returned no URL
104
+ const fetchFn = makeFetch([
105
+ new Response("", { status: 502 }),
106
+ new Response("", { status: 502 }),
107
+ ]);
108
+ startTunnelWatchdog(
109
+ { getUrl: () => URL, recycle, fetchFn, log: () => {} },
110
+ { intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
111
+ );
112
+ await _runTickForTest();
113
+ await _runTickForTest();
114
+ expect(recycle).toHaveBeenCalledTimes(1);
115
+ expect(getTunnelWatchdogStatus()?.recycleCount).toBe(1);
116
+ });
117
+
118
+ it("skips probing when no tunnel URL", async () => {
119
+ const recycle = vi.fn(async () => URL);
120
+ const fetchFn = vi.fn();
121
+ startTunnelWatchdog(
122
+ { getUrl: () => null, recycle, fetchFn: fetchFn as any, log: () => {} },
123
+ { intervalMs: 1000, failureThreshold: 2, probeTimeoutMs: 500 },
124
+ );
125
+ await _runTickForTest();
126
+ expect(fetchFn).not.toHaveBeenCalled();
127
+ expect(recycle).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("stop clears state", () => {
131
+ startTunnelWatchdog(
132
+ { getUrl: () => URL, recycle: vi.fn(async () => URL), log: () => {} },
133
+ { intervalMs: 1000 },
134
+ );
135
+ expect(getTunnelWatchdogStatus()).not.toBeNull();
136
+ stopTunnelWatchdog();
137
+ expect(getTunnelWatchdogStatus()).toBeNull();
138
+ });
139
+ });
@@ -253,6 +253,9 @@ export async function registerAuthPlugin(
253
253
  // Skip health endpoint
254
254
  if (request.url === "/api/health") return;
255
255
 
256
+ // Skip /v1/* — proxy auth gate handles those
257
+ if (request.url.startsWith("/v1/")) return;
258
+
256
259
  // Skip configured bypass URL prefixes
257
260
  if (isBypassed(request.url, authState.bypassUrls)) return;
258
261
 
@@ -69,6 +69,16 @@ export interface BootstrapState {
69
69
  /** Package names that failed to install. */
70
70
  failed: string[];
71
71
  };
72
+ /**
73
+ * Legacy `@mariozechner/pi-coding-agent` installs detected on disk.
74
+ * Populated at server start and after every cleanup POST. See
75
+ * `legacy-pi-cleanup.ts`.
76
+ */
77
+ legacyPiInstalls?: Array<{
78
+ scope: "npm-global" | "npx-cache" | "managed";
79
+ path: string;
80
+ version: string | null;
81
+ }>;
72
82
  }
73
83
 
74
84
  export type BootstrapListener = (state: BootstrapState) => void;