@blackbelt-technology/pi-agent-dashboard 0.5.1 → 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 (129) hide show
  1. package/AGENTS.md +26 -5
  2. package/README.md +30 -0
  3. package/docs/architecture.md +129 -1
  4. package/package.json +6 -6
  5. package/packages/extension/package.json +2 -2
  6. package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
  7. package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
  8. package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
  9. package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
  10. package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
  11. package/packages/extension/src/bridge-context.ts +67 -3
  12. package/packages/extension/src/bridge.ts +20 -8
  13. package/packages/extension/src/command-handler.ts +36 -13
  14. package/packages/extension/src/prompt-expander.ts +74 -63
  15. package/packages/extension/src/server-launcher.ts +31 -70
  16. package/packages/extension/src/slash-dispatch.ts +123 -0
  17. package/packages/server/bin/pi-dashboard.mjs +84 -0
  18. package/packages/server/package.json +6 -5
  19. package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
  20. package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
  21. package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
  22. package/packages/server/src/__tests__/directory-service.test.ts +1 -1
  23. package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
  24. package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
  25. package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
  26. package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
  27. package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
  28. package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
  29. package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
  30. package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
  31. package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
  32. package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
  33. package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
  34. package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
  35. package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
  36. package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
  37. package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
  38. package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
  39. package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
  40. package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
  41. package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
  42. package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
  43. package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
  44. package/packages/server/src/auth-plugin.ts +3 -0
  45. package/packages/server/src/bootstrap-state.ts +10 -0
  46. package/packages/server/src/browser-gateway.ts +15 -7
  47. package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
  48. package/packages/server/src/cli.ts +61 -81
  49. package/packages/server/src/config-api.ts +14 -2
  50. package/packages/server/src/directory-service.ts +106 -4
  51. package/packages/server/src/event-wiring.ts +31 -1
  52. package/packages/server/src/headless-pid-registry.ts +299 -41
  53. package/packages/server/src/legacy-pi-cleanup.ts +151 -0
  54. package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
  55. package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
  56. package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
  57. package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
  58. package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
  59. package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
  60. package/packages/server/src/model-proxy/api-key-store.ts +87 -0
  61. package/packages/server/src/model-proxy/auth-gate.ts +116 -0
  62. package/packages/server/src/model-proxy/concurrency.ts +76 -0
  63. package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
  64. package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
  65. package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
  66. package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
  67. package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
  68. package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
  69. package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
  70. package/packages/server/src/model-proxy/convert/index.ts +8 -0
  71. package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
  72. package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
  73. package/packages/server/src/model-proxy/convert/types.ts +70 -0
  74. package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
  75. package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
  76. package/packages/server/src/model-proxy/internal-registry.ts +157 -0
  77. package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
  78. package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
  79. package/packages/server/src/model-proxy/request-log.ts +53 -0
  80. package/packages/server/src/model-proxy/streamer.ts +59 -0
  81. package/packages/server/src/openspec-group-store.ts +490 -0
  82. package/packages/server/src/process-manager.ts +128 -0
  83. package/packages/server/src/provider-auth-storage.ts +29 -47
  84. package/packages/server/src/restart-helper.ts +17 -16
  85. package/packages/server/src/routes/bootstrap-routes.ts +37 -0
  86. package/packages/server/src/routes/jj-routes.ts +3 -0
  87. package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
  88. package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
  89. package/packages/server/src/routes/model-proxy-routes.ts +330 -0
  90. package/packages/server/src/routes/openspec-group-routes.ts +231 -0
  91. package/packages/server/src/routes/provider-auth-routes.ts +3 -0
  92. package/packages/server/src/routes/provider-routes.ts +24 -1
  93. package/packages/server/src/routes/system-routes.ts +44 -2
  94. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
  95. package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
  96. package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
  97. package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
  98. package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
  99. package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
  100. package/packages/server/src/server.ts +178 -2
  101. package/packages/server/src/session-api.ts +9 -1
  102. package/packages/server/src/tunnel-watchdog.ts +230 -0
  103. package/packages/server/src/tunnel.ts +5 -1
  104. package/packages/shared/package.json +1 -1
  105. package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
  106. package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
  107. package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
  108. package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
  109. package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
  110. package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
  111. package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
  112. package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
  113. package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
  114. package/packages/shared/src/bootstrap-install.ts +1 -1
  115. package/packages/shared/src/browser-protocol.ts +27 -0
  116. package/packages/shared/src/config.ts +172 -2
  117. package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
  118. package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
  119. package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
  120. package/packages/shared/src/platform/binary-lookup.ts +204 -0
  121. package/packages/shared/src/platform/node-spawn.ts +42 -5
  122. package/packages/shared/src/protocol.ts +19 -1
  123. package/packages/shared/src/recommended-extensions.ts +18 -0
  124. package/packages/shared/src/rest-api.ts +219 -1
  125. package/packages/shared/src/server-launcher.ts +277 -0
  126. package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
  127. package/packages/shared/src/types.ts +55 -0
  128. package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
  129. package/packages/shared/src/resolve-jiti.ts +0 -155
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Tests for `packages/server/bin/pi-dashboard.mjs` — the published CLI
3
+ * bin entry. Spawns the wrapper as a child process to exercise the
4
+ * real jiti-resolution + re-exec behaviour.
5
+ *
6
+ * See change: replace-tsx-with-jiti.
7
+ */
8
+ import { describe, it, expect, beforeAll } from "vitest";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import path from "node:path";
13
+ import url from "node:url";
14
+
15
+ const here = path.dirname(url.fileURLToPath(import.meta.url));
16
+ const wrapperPath = path.resolve(here, "..", "..", "bin", "pi-dashboard.mjs");
17
+ const repoNodeModules = path.resolve(here, "..", "..", "..", "..", "node_modules");
18
+ const repoJitiRegister = path.join(repoNodeModules, "jiti", "lib", "jiti-register.mjs");
19
+
20
+ describe("bin/pi-dashboard.mjs wrapper", () => {
21
+ beforeAll(() => {
22
+ if (!existsSync(wrapperPath)) {
23
+ throw new Error(`Wrapper missing at ${wrapperPath}`);
24
+ }
25
+ });
26
+
27
+ it("exits 1 with install-hint when jiti cannot be resolved", () => {
28
+ // Build an isolated anchor with NO node_modules tree — createRequire on
29
+ // it will fail to resolve `jiti/package.json`, triggering the miss path.
30
+ const tmp = mkdtempSync(path.join(tmpdir(), "pi-dashboard-bin-test-"));
31
+ try {
32
+ const fakeAnchor = path.join(tmp, "fake-anchor.js");
33
+ writeFileSync(fakeAnchor, "// no-op anchor with no node_modules\n");
34
+
35
+ // Spawn the wrapper. We override process.argv[1] indirectly by
36
+ // invoking node with `<wrapper>` then forcing argv[1] to the fake
37
+ // anchor via a tiny preamble — but the wrapper reads its OWN
38
+ // process.argv[1] which is the wrapper path itself when invoked
39
+ // directly. Strategy: copy the wrapper into the isolated tmp dir so
40
+ // its argv[1] resolves there with no jiti adjacency.
41
+ const isolatedWrapper = path.join(tmp, "pi-dashboard.mjs");
42
+ const wrapperSrc = require("node:fs").readFileSync(wrapperPath, "utf-8");
43
+ writeFileSync(isolatedWrapper, wrapperSrc);
44
+
45
+ const result = spawnSync(process.execPath, [isolatedWrapper, "--version"], {
46
+ encoding: "utf-8",
47
+ env: { ...process.env, NODE_PATH: "" },
48
+ timeout: 10_000,
49
+ });
50
+
51
+ expect(result.status).toBe(1);
52
+ expect(result.stderr).toContain("pi-dashboard: cannot find jiti");
53
+ expect(result.stderr).toContain("npm install -g @earendil-works/pi-coding-agent");
54
+ // No tsx mention — proposal mandates no-fallback wrapper.
55
+ expect(result.stderr).not.toMatch(/tsx/i);
56
+ } finally {
57
+ rmSync(tmp, { recursive: true, force: true });
58
+ }
59
+ });
60
+
61
+ it("resolves jiti from process.argv[1] anchor and re-execs cli.ts", () => {
62
+ // Repo root has jiti at node_modules/jiti — wrapper invoked with its
63
+ // real path SHOULD walk createRequire(realpath(argv[1])) up into the
64
+ // repo's node_modules and find jiti.
65
+ if (!existsSync(repoJitiRegister)) {
66
+ // CI / fresh clone without `npm install` — skip rather than fail.
67
+ return;
68
+ }
69
+
70
+ // Use `status` — it doesn't bind ports and exits quickly regardless
71
+ // of whether a server is running. We don't care about exit code (0 if
72
+ // a dashboard is up, 1 if not — both are valid outcomes that prove
73
+ // the wrapper successfully resolved jiti and re-execed cli.ts). What
74
+ // we DO care about: (a) no jiti-miss error on stderr, (b) cli.ts
75
+ // produced its own "Dashboard server" output (running OR not running).
76
+ const result = spawnSync(process.execPath, [wrapperPath, "status"], {
77
+ encoding: "utf-8",
78
+ timeout: 30_000,
79
+ });
80
+
81
+ expect(result.stderr).not.toContain("pi-dashboard: cannot find jiti");
82
+ expect(result.stdout).toMatch(/Dashboard server/i);
83
+ }, 60_000);
84
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Tests for the `useRpcKeeper: true` branch in `spawnHeadless` (Phase 5).
3
+ *
4
+ * Drives `spawnPiSession({strategy: "headless"})` with the keeper-flag
5
+ * override on, an injected fake KeeperManager, and verifies:
6
+ * - keeper branch fires (KeeperManager.spawnKeeperFor called, NOT pi resolved)
7
+ * - returned SpawnResult.pid is the keeper PID
8
+ * - env passed to the keeper includes `PI_DASHBOARD_SPAWN_TOKEN`
9
+ * - keeper failure surfaces as `PI_CRASHED` or `SPAWN_ERRNO`
10
+ * - flag OFF (default) → keeper is NOT used
11
+ */
12
+ import { EventEmitter } from "node:events";
13
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
14
+ import path from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
+ import type {
17
+ KeeperManager,
18
+ KeeperSpawnResult,
19
+ } from "../rpc-keeper/keeper-manager.js";
20
+ import {
21
+ setKeeperManager,
22
+ _setUseRpcKeeperOverrideForTests,
23
+ spawnPiSession,
24
+ } from "../process-manager.js";
25
+
26
+ class FakeKeeperChild extends EventEmitter {
27
+ pid: number;
28
+ unref = vi.fn();
29
+ kill = vi.fn();
30
+ // Never emits "exit" → waitForNoCrash window completes cleanly.
31
+ constructor(pid: number) { super(); this.pid = pid; }
32
+ }
33
+
34
+ interface FakeKeeperManagerState {
35
+ spawnCalls: Array<{ sessionId: string; cwd: string; env: NodeJS.ProcessEnv; piArgs?: string[] }>;
36
+ writeCalls: Array<{ sessionId: string; line: string }>;
37
+ killCalls: string[];
38
+ spawnResult: KeeperSpawnResult;
39
+ }
40
+
41
+ function makeFakeKeeperManager(
42
+ state: Partial<FakeKeeperManagerState> & { spawnResult: KeeperSpawnResult },
43
+ ): { km: KeeperManager; state: FakeKeeperManagerState } {
44
+ const full: FakeKeeperManagerState = {
45
+ spawnCalls: state.spawnCalls ?? [],
46
+ writeCalls: state.writeCalls ?? [],
47
+ killCalls: state.killCalls ?? [],
48
+ spawnResult: state.spawnResult,
49
+ };
50
+ const km: KeeperManager = {
51
+ sessionsDir: "/fake/sessions",
52
+ spawnKeeperFor: async (sessionId, cwd, env, piArgs) => {
53
+ full.spawnCalls.push({ sessionId, cwd, env, piArgs });
54
+ return full.spawnResult;
55
+ },
56
+ writeRpc: async (sessionId, line) => {
57
+ full.writeCalls.push({ sessionId, line });
58
+ return true;
59
+ },
60
+ writeRpcToSockPath: async (_sockPath, _line) => true,
61
+ killKeeper: (sessionId) => {
62
+ full.killCalls.push(sessionId);
63
+ return true;
64
+ },
65
+ discoverExistingKeepers: async () => [],
66
+ };
67
+ return { km, state: full };
68
+ }
69
+
70
+ let tmpCwd: string;
71
+
72
+ beforeEach(() => {
73
+ tmpCwd = mkdtempSync(path.join("/tmp", "km-cwd-"));
74
+ });
75
+ afterEach(() => {
76
+ setKeeperManager(null);
77
+ _setUseRpcKeeperOverrideForTests(null);
78
+ rmSync(tmpCwd, { recursive: true, force: true });
79
+ });
80
+
81
+ describe("spawnHeadless (useRpcKeeper: true)", () => {
82
+ it("routes through KeeperManager when flag is on", async () => {
83
+ const fakeChild = new FakeKeeperChild(11111);
84
+ const { km, state } = makeFakeKeeperManager({
85
+ spawnResult: {
86
+ success: true,
87
+ pid: 11111,
88
+ sockPath: "/fake/sessions/sid.rpc.sock",
89
+ process: fakeChild as unknown as import("node:child_process").ChildProcess,
90
+ },
91
+ });
92
+ setKeeperManager(km);
93
+ _setUseRpcKeeperOverrideForTests(true);
94
+
95
+ const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
96
+
97
+ expect(result.success).toBe(true);
98
+ expect(result.pid).toBe(11111);
99
+ expect(state.spawnCalls).toHaveLength(1);
100
+ expect(state.spawnCalls[0].cwd).toBe(tmpCwd);
101
+
102
+ // spawnToken contract (task 5.3): the env passed to the keeper carries
103
+ // PI_DASHBOARD_SPAWN_TOKEN, which the keeper forwards to pi via
104
+ // process.env inheritance.
105
+ expect(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN).toBeDefined();
106
+ expect(typeof state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN).toBe("string");
107
+ expect(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN!.length).toBeGreaterThan(0);
108
+
109
+ // The returned spawnToken matches what was injected into env.
110
+ expect(result.spawnToken).toBe(state.spawnCalls[0].env.PI_DASHBOARD_SPAWN_TOKEN);
111
+
112
+ // Bare-spawn piArgs are at least `--mode rpc`.
113
+ expect(state.spawnCalls[0].piArgs).toBeDefined();
114
+ expect(state.spawnCalls[0].piArgs).toContain("--mode");
115
+ expect(state.spawnCalls[0].piArgs).toContain("rpc");
116
+
117
+ // SpawnResult.keeperSockPath populated so callers can pass it to
118
+ // `headlessPidRegistry.register(..., {keeperPid, keeperSockPath})`
119
+ // (Phase 6 contract). See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
120
+ expect(result.keeperSockPath).toBe("/fake/sessions/sid.rpc.sock");
121
+ });
122
+
123
+ it("forwards resume flags (sessionFile / mode) to the keeper as piArgs", async () => {
124
+ const fakeChild = new FakeKeeperChild(33333);
125
+ const { km, state } = makeFakeKeeperManager({
126
+ spawnResult: {
127
+ success: true,
128
+ pid: 33333,
129
+ sockPath: "/fake/x.sock",
130
+ process: fakeChild as unknown as import("node:child_process").ChildProcess,
131
+ },
132
+ });
133
+ setKeeperManager(km);
134
+ _setUseRpcKeeperOverrideForTests(true);
135
+
136
+ const sessionFile = "/tmp/sess-resume.jsonl";
137
+ const result = await spawnPiSession(tmpCwd, {
138
+ strategy: "headless",
139
+ sessionFile,
140
+ mode: "continue",
141
+ });
142
+
143
+ expect(result.success).toBe(true);
144
+ expect(state.spawnCalls).toHaveLength(1);
145
+ const piArgs = state.spawnCalls[0].piArgs ?? [];
146
+ // piArgs MUST carry the session-file flag so resume actually resumes
147
+ // (regression guard: in the first Phase-5 cut the keeper hardcoded
148
+ // ["--mode","rpc"] and resume created a fresh session instead).
149
+ expect(piArgs).toContain("--mode");
150
+ expect(piArgs).toContain("rpc");
151
+ // sessionFlagsToArgv emits the session-file path; the exact flag name
152
+ // (`--session-file`) is verified in spawn-mechanism unit tests; here
153
+ // we only assert the path token is present so we don't double-bind to
154
+ // upstream argv shape.
155
+ expect(piArgs).toContain(sessionFile);
156
+ });
157
+
158
+ it("returns SPAWN_ERRNO when KeeperManager.spawnKeeperFor reports !success", async () => {
159
+ const { km } = makeFakeKeeperManager({
160
+ spawnResult: { success: false, error: "EACCES on socket bind" },
161
+ });
162
+ setKeeperManager(km);
163
+ _setUseRpcKeeperOverrideForTests(true);
164
+
165
+ const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
166
+ expect(result.success).toBe(false);
167
+ expect(result.code).toBe("SPAWN_ERRNO");
168
+ expect(result.message).toMatch(/RPC keeper/);
169
+ expect(result.message).toMatch(/EACCES/);
170
+ });
171
+
172
+ it("returns PI_CRASHED when keeper exits within the crash window", async () => {
173
+ // A child that emits "exit" inside 300 ms triggers the waitForNoCrash gate.
174
+ const fakeChild = new FakeKeeperChild(22222);
175
+ setTimeout(() => fakeChild.emit("exit", 1, null), 20);
176
+
177
+ const { km } = makeFakeKeeperManager({
178
+ spawnResult: {
179
+ success: true,
180
+ pid: 22222,
181
+ sockPath: "/fake/sessions/sid.rpc.sock",
182
+ process: fakeChild as unknown as import("node:child_process").ChildProcess,
183
+ },
184
+ });
185
+ setKeeperManager(km);
186
+ _setUseRpcKeeperOverrideForTests(true);
187
+
188
+ const result = await spawnPiSession(tmpCwd, { strategy: "headless" });
189
+ expect(result.success).toBe(false);
190
+ expect(result.code).toBe("PI_CRASHED");
191
+ expect(result.message).toMatch(/crash window/);
192
+ });
193
+
194
+ it("does NOT route through KeeperManager when flag is off (default)", async () => {
195
+ const { km, state } = makeFakeKeeperManager({
196
+ spawnResult: { success: true, pid: 99999, sockPath: "/fake/x.sock" },
197
+ });
198
+ setKeeperManager(km);
199
+ _setUseRpcKeeperOverrideForTests(false);
200
+
201
+ // We don't care about the actual headless spawn result here — only that
202
+ // it does NOT call the fake KeeperManager.
203
+ await spawnPiSession(tmpCwd, { strategy: "headless" });
204
+ expect(state.spawnCalls).toEqual([]);
205
+ });
206
+ });
@@ -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
+ });
@@ -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,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;
@@ -5,6 +5,7 @@
5
5
  import { WebSocketServer, WebSocket } from "ws";
6
6
  import type {
7
7
  ServerToBrowserMessage,
8
+ BrowserOpenSpecUpdateMessage,
8
9
  BrowserToServerMessage,
9
10
  } from "@blackbelt-technology/pi-dashboard-shared/browser-protocol.js";
10
11
  import type { SessionManager } from "./memory-session-manager.js";
@@ -15,7 +16,7 @@ import { createHeadlessPidRegistry, type HeadlessPidRegistry } from "./headless-
15
16
  import type { PendingForkRegistry } from "./pending-fork-registry.js";
16
17
  import type { SessionOrderManager } from "./session-order-manager.js";
17
18
  import type { PreferencesStore } from "./preferences-store.js";
18
- import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
19
+ import { hasOpenSpecDir, hasOpenSpecRoot, type DirectoryService } from "./directory-service.js";
19
20
 
20
21
  /**
21
22
  * Pure helper: build the per-cwd `openspec_update` messages a freshly
@@ -31,23 +32,30 @@ import { hasOpenSpecDir, type DirectoryService } from "./directory-service.js";
31
32
  export function buildOpenSpecConnectSnapshot(
32
33
  directoryService: Pick<DirectoryService, "knownDirectories" | "getOpenSpecData">,
33
34
  hasDir: (cwd: string) => boolean,
34
- ): Array<ServerToBrowserMessage> {
35
- const out: Array<ServerToBrowserMessage> = [];
35
+ hasRoot: (cwd: string) => boolean = hasDir,
36
+ ): Array<BrowserOpenSpecUpdateMessage> {
37
+ const out: Array<BrowserOpenSpecUpdateMessage> = [];
36
38
  for (const cwd of directoryService.knownDirectories()) {
37
39
  const cached = directoryService.getOpenSpecData(cwd);
40
+ const root = hasRoot(cwd);
38
41
  if (cached && cached.initialized) {
39
- out.push({ type: "openspec_update", cwd, data: cached });
42
+ // Cached payload already carries `hasOpenspecDir` set by `pollOne`; if
43
+ // an old cache entry predates that field, fill it from the live probe.
44
+ const data = cached.hasOpenspecDir === undefined
45
+ ? { ...cached, hasOpenspecDir: root }
46
+ : cached;
47
+ out.push({ type: "openspec_update", cwd, data });
40
48
  } else if (hasDir(cwd)) {
41
49
  out.push({
42
50
  type: "openspec_update",
43
51
  cwd,
44
- data: { initialized: false, pending: true, changes: [] },
52
+ data: { initialized: false, pending: true, changes: [], hasOpenspecDir: root },
45
53
  });
46
54
  } else {
47
55
  out.push({
48
56
  type: "openspec_update",
49
57
  cwd,
50
- data: { initialized: false, pending: false, changes: [] },
58
+ data: { initialized: false, pending: false, changes: [], hasOpenspecDir: root },
51
59
  });
52
60
  }
53
61
  }
@@ -272,7 +280,7 @@ export function createBrowserGateway(
272
280
  // `openspec_update` per cwd, never silently omit.
273
281
  // See change: fix-cold-boot-openspec-protocol.
274
282
  if (directoryService) {
275
- for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir)) {
283
+ for (const msg of buildOpenSpecConnectSnapshot(directoryService, hasOpenSpecDir, hasOpenSpecRoot)) {
276
284
  sendTo(ws, msg);
277
285
  }
278
286
  }