@blackbelt-technology/pi-agent-dashboard 0.2.8 → 0.3.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 (76) hide show
  1. package/AGENTS.md +114 -9
  2. package/README.md +218 -97
  3. package/docs/architecture.md +107 -7
  4. package/package.json +9 -4
  5. package/packages/extension/package.json +1 -1
  6. package/packages/extension/src/__tests__/ask-user-tool.test.ts +300 -3
  7. package/packages/extension/src/__tests__/fork-entryid-timing.test.ts +100 -0
  8. package/packages/extension/src/ask-user-tool.ts +289 -20
  9. package/packages/extension/src/bridge.ts +38 -4
  10. package/packages/extension/src/command-handler.ts +34 -39
  11. package/packages/extension/src/prompt-expander.ts +25 -4
  12. package/packages/server/package.json +2 -1
  13. package/packages/server/src/__tests__/auto-attach.test.ts +10 -1
  14. package/packages/server/src/__tests__/auto-shutdown.test.ts +8 -2
  15. package/packages/server/src/__tests__/browse-endpoint.test.ts +229 -10
  16. package/packages/server/src/__tests__/browser-gateway-handler-errors.test.ts +129 -0
  17. package/packages/server/src/__tests__/cors.test.ts +34 -2
  18. package/packages/server/src/__tests__/editor-manager-pid-registry.test.ts +168 -0
  19. package/packages/server/src/__tests__/editor-manager.test.ts +33 -0
  20. package/packages/server/src/__tests__/editor-pid-registry.test.ts +191 -0
  21. package/packages/server/src/__tests__/editor-registry.test.ts +3 -2
  22. package/packages/server/src/__tests__/fix-pty-permissions.test.ts +59 -0
  23. package/packages/server/src/__tests__/git-operations.test.ts +9 -7
  24. package/packages/server/src/__tests__/health-endpoint.test.ts +11 -13
  25. package/packages/server/src/__tests__/openspec-tasks-parser.test.ts +178 -0
  26. package/packages/server/src/__tests__/openspec-tasks-routes.test.ts +180 -0
  27. package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +122 -0
  28. package/packages/server/src/__tests__/pi-core-checker.test.ts +195 -0
  29. package/packages/server/src/__tests__/pi-core-routes.test.ts +184 -0
  30. package/packages/server/src/__tests__/pi-core-updater.test.ts +214 -0
  31. package/packages/server/src/__tests__/provider-auth-routes.test.ts +13 -3
  32. package/packages/server/src/__tests__/recommended-routes.test.ts +389 -0
  33. package/packages/server/src/__tests__/session-file-dedup.test.ts +10 -10
  34. package/packages/server/src/__tests__/session-lifecycle-logging.test.ts +8 -2
  35. package/packages/server/src/__tests__/sleep-aware-heartbeat.test.ts +3 -1
  36. package/packages/server/src/__tests__/smoke-integration.test.ts +10 -10
  37. package/packages/server/src/__tests__/test-server-canary.test.ts +31 -0
  38. package/packages/server/src/__tests__/tunnel.test.ts +91 -0
  39. package/packages/server/src/__tests__/ws-ping-pong.test.ts +10 -2
  40. package/packages/server/src/browse.ts +100 -6
  41. package/packages/server/src/browser-gateway.ts +16 -3
  42. package/packages/server/src/editor-manager.ts +20 -1
  43. package/packages/server/src/editor-pid-registry.ts +198 -0
  44. package/packages/server/src/fix-pty-permissions.ts +44 -0
  45. package/packages/server/src/headless-pid-registry.ts +9 -0
  46. package/packages/server/src/npm-search-proxy.ts +71 -0
  47. package/packages/server/src/openspec-tasks.ts +158 -0
  48. package/packages/server/src/package-manager-wrapper.ts +31 -0
  49. package/packages/server/src/pi-core-checker.ts +290 -0
  50. package/packages/server/src/pi-core-updater.ts +166 -0
  51. package/packages/server/src/pi-gateway.ts +7 -0
  52. package/packages/server/src/process-manager.ts +1 -1
  53. package/packages/server/src/routes/file-routes.ts +30 -3
  54. package/packages/server/src/routes/openspec-routes.ts +83 -1
  55. package/packages/server/src/routes/pi-core-routes.ts +117 -0
  56. package/packages/server/src/routes/provider-auth-routes.ts +4 -2
  57. package/packages/server/src/routes/provider-routes.ts +12 -2
  58. package/packages/server/src/routes/recommended-routes.ts +227 -0
  59. package/packages/server/src/routes/system-routes.ts +10 -1
  60. package/packages/server/src/server.ts +151 -15
  61. package/packages/server/src/terminal-manager.ts +4 -0
  62. package/packages/server/src/test-env-guard.ts +26 -0
  63. package/packages/server/src/test-support/test-server.ts +63 -0
  64. package/packages/server/src/tunnel.ts +132 -8
  65. package/packages/shared/package.json +1 -1
  66. package/packages/shared/src/__tests__/config.test.ts +3 -3
  67. package/packages/shared/src/__tests__/openspec-poller.test.ts +44 -0
  68. package/packages/shared/src/__tests__/recommended-extensions.test.ts +123 -0
  69. package/packages/shared/src/__tests__/source-matching.test.ts +143 -0
  70. package/packages/shared/src/browser-protocol.ts +23 -1
  71. package/packages/shared/src/openspec-poller.ts +8 -3
  72. package/packages/shared/src/recommended-extensions.ts +180 -0
  73. package/packages/shared/src/rest-api.ts +71 -0
  74. package/packages/shared/src/source-matching.ts +126 -0
  75. package/packages/shared/src/test-support/setup-home.ts +74 -0
  76. package/packages/shared/src/types.ts +7 -0
@@ -1,25 +1,26 @@
1
1
  /**
2
2
  * Tests for the browse directory endpoint logic.
3
3
  */
4
- import { describe, it, expect } from "vitest";
5
- import { listDirectories } from "../browse.js";
4
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
5
+ import { listDirectories, createDirectory, validateMkdirName } from "../browse.js";
6
6
  import path from "node:path";
7
7
  import os from "node:os";
8
8
  import fs from "node:fs";
9
+ import fsp from "node:fs/promises";
9
10
 
10
11
  describe("listDirectories", () => {
11
12
  it("should return directory entries for a valid path", async () => {
12
13
  // Use the project root — known to have subdirectories
13
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
14
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
14
15
  const result = await listDirectories(projectRoot);
15
16
 
16
17
  expect(result.current).toBe(projectRoot);
17
18
  expect(result.parent).toBe(path.dirname(projectRoot));
18
19
  expect(result.entries.length).toBeGreaterThan(0);
19
20
 
20
- // Should contain known subdirectories
21
+ // Should contain known subdirectories at the monorepo root
21
22
  const names = result.entries.map((e) => e.name);
22
- expect(names).toContain("src");
23
+ expect(names).toContain("packages");
23
24
  expect(names).toContain("node_modules");
24
25
  });
25
26
 
@@ -29,7 +30,7 @@ describe("listDirectories", () => {
29
30
  });
30
31
 
31
32
  it("should return entries sorted alphabetically", async () => {
32
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
33
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
33
34
  const result = await listDirectories(projectRoot);
34
35
  const names = result.entries.map((e) => e.name);
35
36
  const sorted = [...names].sort((a, b) => a.localeCompare(b));
@@ -45,7 +46,7 @@ describe("listDirectories", () => {
45
46
  });
46
47
 
47
48
  it("should detect isGit flag for git repos", async () => {
48
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
49
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
49
50
  const parentDir = path.dirname(projectRoot);
50
51
  const result = await listDirectories(parentDir);
51
52
 
@@ -57,7 +58,7 @@ describe("listDirectories", () => {
57
58
  });
58
59
 
59
60
  it("should detect isPi flag for pi projects", async () => {
60
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
61
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
61
62
  const parentDir = path.dirname(projectRoot);
62
63
  const result = await listDirectories(parentDir);
63
64
 
@@ -86,7 +87,7 @@ describe("listDirectories", () => {
86
87
  });
87
88
 
88
89
  it("should only return directories, not files", async () => {
89
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
90
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
90
91
  const result = await listDirectories(projectRoot);
91
92
  const names = result.entries.map((e) => e.name);
92
93
  // package.json is a file, should not appear
@@ -95,10 +96,228 @@ describe("listDirectories", () => {
95
96
  });
96
97
 
97
98
  it("should include full path in each entry", async () => {
98
- const projectRoot = path.resolve(import.meta.dirname, "../../..");
99
+ const projectRoot = path.resolve(import.meta.dirname, "../../../..");
99
100
  const result = await listDirectories(projectRoot);
100
101
  for (const entry of result.entries) {
101
102
  expect(entry.path).toBe(path.join(projectRoot, entry.name));
102
103
  }
103
104
  });
104
105
  });
106
+
107
+ describe("listDirectories with q filter", () => {
108
+ let tmp: string;
109
+
110
+ beforeEach(async () => {
111
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-q-"));
112
+ });
113
+
114
+ afterEach(async () => {
115
+ await fsp.rm(tmp, { recursive: true, force: true });
116
+ });
117
+
118
+ async function makeDirs(names: string[]) {
119
+ for (const n of names) await fsp.mkdir(path.join(tmp, n));
120
+ }
121
+
122
+ it("treats empty q as no filter", async () => {
123
+ await makeDirs(["alpha", "beta"]);
124
+ const r1 = await listDirectories(tmp, "");
125
+ const r2 = await listDirectories(tmp, " ");
126
+ const r3 = await listDirectories(tmp);
127
+ const names1 = r1.entries.map((e) => e.name);
128
+ const names2 = r2.entries.map((e) => e.name);
129
+ const names3 = r3.entries.map((e) => e.name);
130
+ expect(names1).toEqual(["alpha", "beta"]);
131
+ expect(names2).toEqual(["alpha", "beta"]);
132
+ expect(names3).toEqual(["alpha", "beta"]);
133
+ });
134
+
135
+ it("returns non-prefix substring matches", async () => {
136
+ await makeDirs(["pi-dashboard", "my-dashboard-old", "readme-dir"]);
137
+ const r = await listDirectories(tmp, "dash");
138
+ const names = r.entries.map((e) => e.name);
139
+ expect(names).toContain("pi-dashboard");
140
+ expect(names).toContain("my-dashboard-old");
141
+ expect(names).not.toContain("readme-dir");
142
+ });
143
+
144
+ it("ranks by tier: exact, prefix, word-boundary, substring", async () => {
145
+ await makeDirs(["pi", "pi-core", "my-pi-tools", "epiphany"]);
146
+ const r = await listDirectories(tmp, "pi");
147
+ const names = r.entries.map((e) => e.name);
148
+ expect(names).toEqual(["pi", "pi-core", "my-pi-tools", "epiphany"]);
149
+ });
150
+
151
+ it("sorts alphabetically within the same tier", async () => {
152
+ await makeDirs(["pi-zeta", "pi-alpha", "pi-mu"]);
153
+ const r = await listDirectories(tmp, "pi");
154
+ const names = r.entries.map((e) => e.name);
155
+ // all prefix-tier → alphabetical
156
+ expect(names).toEqual(["pi-alpha", "pi-mu", "pi-zeta"]);
157
+ });
158
+
159
+ it("is case-insensitive", async () => {
160
+ await makeDirs(["Pi-Dashboard", "OtherThing"]);
161
+ const r = await listDirectories(tmp, "dash");
162
+ const names = r.entries.map((e) => e.name);
163
+ expect(names).toContain("Pi-Dashboard");
164
+ expect(names).not.toContain("OtherThing");
165
+ });
166
+
167
+ it("applies the 200-cap AFTER filtering so late-alphabet matches survive", async () => {
168
+ // Create 210 dummy dirs that don't match 'pi', plus one that does.
169
+ // The matching one alphabetically sorts near the end.
170
+ const dummy: string[] = [];
171
+ for (let i = 0; i < 210; i++) {
172
+ dummy.push(`z-${String(i).padStart(3, "0")}-other`);
173
+ }
174
+ // 'pi-dashboard' is the only match; sorts after all 'z-*'? No — 'p' < 'z',
175
+ // so use 'pi-dashboard' which alphabetically precedes them anyway. Use
176
+ // a different setup: create one matching dir named so alphabetically it
177
+ // falls past position 200 in the unfiltered list.
178
+ await makeDirs(dummy);
179
+ // 'zz-pi-match' will alphabetically be past the 200 'z-*' entries if we
180
+ // keep them, but since we only have 210 total, let's just make the matcher
181
+ // something that would be cut without filtering. Easier: 'aa-other' ×210
182
+ // plus a single 'pi-found'.
183
+ await fsp.rm(tmp, { recursive: true, force: true });
184
+ await fsp.mkdir(tmp);
185
+ const many: string[] = [];
186
+ for (let i = 0; i < 210; i++) many.push(`aa-${String(i).padStart(3, "0")}`);
187
+ many.push("pi-found");
188
+ await makeDirs(many);
189
+
190
+ // Without filter: 'pi-found' sorts alphabetically past 210 'aa-*' entries,
191
+ // so it lands at position 210 — cut by the 200 cap.
192
+ const unfiltered = await listDirectories(tmp);
193
+ expect(unfiltered.entries.length).toBe(200);
194
+ expect(unfiltered.entries.map((e) => e.name)).not.toContain("pi-found");
195
+
196
+ // With filter: it should survive because filtering happens first.
197
+ const filtered = await listDirectories(tmp, "pi");
198
+ expect(filtered.entries.map((e) => e.name)).toContain("pi-found");
199
+ });
200
+ });
201
+
202
+ describe("validateMkdirName", () => {
203
+ it("accepts normal names", () => {
204
+ expect(validateMkdirName("foo")).toBeNull();
205
+ expect(validateMkdirName("foo-bar")).toBeNull();
206
+ expect(validateMkdirName("foo_bar")).toBeNull();
207
+ expect(validateMkdirName("foo.bar")).toBeNull();
208
+ expect(validateMkdirName("foo bar")).toBeNull();
209
+ expect(validateMkdirName("\u00e9l\u00e9phant")).toBeNull();
210
+ });
211
+
212
+ it("rejects empty / whitespace", () => {
213
+ expect(validateMkdirName("")).toBe("invalid name");
214
+ expect(validateMkdirName(" ")).toBe("invalid name");
215
+ expect(validateMkdirName(" foo")).toBe("invalid name");
216
+ expect(validateMkdirName("foo ")).toBe("invalid name");
217
+ });
218
+
219
+ it("rejects . and ..", () => {
220
+ expect(validateMkdirName(".")).toBe("invalid name");
221
+ expect(validateMkdirName("..")).toBe("invalid name");
222
+ });
223
+
224
+ it("rejects path separators", () => {
225
+ expect(validateMkdirName("foo/bar")).toBe("invalid name");
226
+ expect(validateMkdirName("foo\\bar")).toBe("invalid name");
227
+ });
228
+
229
+ it("rejects null byte", () => {
230
+ expect(validateMkdirName("foo\0bar")).toBe("invalid name");
231
+ });
232
+ });
233
+
234
+ describe("createDirectory", () => {
235
+ let tmp: string;
236
+
237
+ beforeEach(async () => {
238
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "mkdir-"));
239
+ });
240
+
241
+ afterEach(async () => {
242
+ await fsp.rm(tmp, { recursive: true, force: true });
243
+ });
244
+
245
+ it("creates a new directory and returns its absolute path", async () => {
246
+ const result = await createDirectory(tmp, "new-thing");
247
+ expect(result).toBe(path.join(tmp, "new-thing"));
248
+ const stat = await fsp.stat(result);
249
+ expect(stat.isDirectory()).toBe(true);
250
+ });
251
+
252
+ it("throws 'already exists' when target already exists", async () => {
253
+ await fsp.mkdir(path.join(tmp, "dup"));
254
+ await expect(createDirectory(tmp, "dup")).rejects.toThrow("already exists");
255
+ });
256
+
257
+ it("throws 'parent not found' when parent does not exist", async () => {
258
+ await expect(createDirectory("/nonexistent/path/really", "x")).rejects.toThrow("parent not found");
259
+ });
260
+
261
+ it("throws 'parent is not a directory' when parent is a file", async () => {
262
+ const filePath = path.join(tmp, "somefile");
263
+ await fsp.writeFile(filePath, "hi");
264
+ await expect(createDirectory(filePath, "x")).rejects.toThrow("parent is not a directory");
265
+ });
266
+
267
+ it("rejects invalid names without touching disk", async () => {
268
+ await expect(createDirectory(tmp, "foo/bar")).rejects.toThrow("invalid name");
269
+ await expect(createDirectory(tmp, "..")).rejects.toThrow("invalid name");
270
+ await expect(createDirectory(tmp, ".")).rejects.toThrow("invalid name");
271
+ await expect(createDirectory(tmp, "")).rejects.toThrow("invalid name");
272
+ await expect(createDirectory(tmp, "foo\0bar")).rejects.toThrow("invalid name");
273
+ const entries = await fsp.readdir(tmp);
274
+ expect(entries).toEqual([]);
275
+ });
276
+ });
277
+
278
+ // ── S1: rankTier word-boundary edge cases ────────────────────
279
+ // rankTier isn't exported; exercise it indirectly via listDirectories.
280
+ describe("listDirectories word-boundary ranking", () => {
281
+ let tmp: string;
282
+
283
+ beforeEach(async () => {
284
+ tmp = await fsp.mkdtemp(path.join(os.tmpdir(), "browse-wb-"));
285
+ });
286
+
287
+ afterEach(async () => {
288
+ await fsp.rm(tmp, { recursive: true, force: true });
289
+ });
290
+
291
+ async function makeDirs(names: string[]) {
292
+ for (const n of names) await fsp.mkdir(path.join(tmp, n));
293
+ }
294
+
295
+ it("treats hyphen, underscore, dot, space as word boundaries", async () => {
296
+ // All four should rank at tier 2 for query 'foo' (word boundary before 'foo');
297
+ // 'embeddedfoo' ranks tier 3 (plain substring).
298
+ await makeDirs([
299
+ "pi-foo", // hyphen boundary
300
+ "pi_foo", // underscore boundary
301
+ "pi.foo", // dot boundary
302
+ "pi foo", // space boundary
303
+ "embeddedfoo", // no boundary
304
+ ]);
305
+ const r = await listDirectories(tmp, "foo");
306
+ const names = r.entries.map((e) => e.name);
307
+ // The first four are tier 2 (alphabetical within tier); 'embeddedfoo' is tier 3 last.
308
+ expect(names[names.length - 1]).toBe("embeddedfoo");
309
+ // All four boundary-matched names appear before embeddedfoo.
310
+ const boundaryNames = ["pi foo", "pi-foo", "pi.foo", "pi_foo"];
311
+ const boundaryPositions = boundaryNames.map((n) => names.indexOf(n));
312
+ for (const p of boundaryPositions) expect(p).toBeGreaterThanOrEqual(0);
313
+ for (const p of boundaryPositions) expect(p).toBeLessThan(names.indexOf("embeddedfoo"));
314
+ });
315
+
316
+ it("treats start-of-string as a word boundary (prefix trumps via tier 1)", async () => {
317
+ await makeDirs(["foo-bar", "xx-foo"]);
318
+ const r = await listDirectories(tmp, "foo");
319
+ // 'foo-bar' is prefix (tier 1), 'xx-foo' is word-boundary (tier 2).
320
+ const names = r.entries.map((e) => e.name);
321
+ expect(names).toEqual(["foo-bar", "xx-foo"]);
322
+ });
323
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Regression tests for browser-gateway exception handling.
3
+ *
4
+ * - Handler exceptions MUST be logged with a `[browser-gw] handler error`
5
+ * prefix and the message type, so real bugs (e.g. node-pty spawn
6
+ * failures) are no longer silently swallowed.
7
+ * - Malformed JSON frames MUST still be silently dropped (no log noise
8
+ * for garbage input).
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+ import { EventEmitter } from "node:events";
12
+ import { createBrowserGateway } from "../browser-gateway.js";
13
+ import { createMemorySessionManager } from "../memory-session-manager.js";
14
+ import { createMemoryEventStore } from "../memory-event-store.js";
15
+ import type { TerminalManager } from "../terminal-manager.js";
16
+ import type { PiGateway } from "../pi-gateway.js";
17
+ import type { SessionOrderManager } from "../session-order-manager.js";
18
+
19
+ function makeFakeWs() {
20
+ const ws = new EventEmitter() as EventEmitter & {
21
+ send: ReturnType<typeof vi.fn>;
22
+ close: ReturnType<typeof vi.fn>;
23
+ readyState: number;
24
+ OPEN: number;
25
+ };
26
+ ws.send = vi.fn();
27
+ ws.close = vi.fn();
28
+ ws.readyState = 1;
29
+ ws.OPEN = 1;
30
+ return ws;
31
+ }
32
+
33
+ function makeStubPiGateway(): PiGateway {
34
+ return {
35
+ start: vi.fn(),
36
+ stop: vi.fn(),
37
+ sendToSession: vi.fn(),
38
+ getConnectedSessionIds: vi.fn(() => []),
39
+ hasSession: vi.fn(() => false),
40
+ onEvent: vi.fn(),
41
+ } as unknown as PiGateway;
42
+ }
43
+
44
+ function makeStubOrderManager(): SessionOrderManager {
45
+ return {
46
+ insert: vi.fn(),
47
+ remove: vi.fn(),
48
+ getOrder: vi.fn(() => []),
49
+ reorder: vi.fn(),
50
+ getAllOrders: vi.fn(() => ({})),
51
+ } as unknown as SessionOrderManager;
52
+ }
53
+
54
+ describe("browser-gateway handler error reporting", () => {
55
+ let errorSpy: ReturnType<typeof vi.spyOn>;
56
+
57
+ beforeEach(() => {
58
+ errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
59
+ });
60
+
61
+ afterEach(() => {
62
+ errorSpy.mockRestore();
63
+ });
64
+
65
+ it("logs handler exceptions with type and error (does not silently swallow)", async () => {
66
+ const throwingTerminalManager = {
67
+ spawn: vi.fn(() => {
68
+ throw new Error("posix_spawnp failed.");
69
+ }),
70
+ attach: vi.fn(),
71
+ detach: vi.fn(),
72
+ kill: vi.fn(),
73
+ get: vi.fn(),
74
+ list: vi.fn(() => []),
75
+ updateTitle: vi.fn(),
76
+ } as unknown as TerminalManager;
77
+
78
+ const gateway = createBrowserGateway(
79
+ createMemorySessionManager(),
80
+ createMemoryEventStore(() => false),
81
+ makeStubPiGateway(),
82
+ undefined,
83
+ undefined,
84
+ makeStubOrderManager(),
85
+ undefined,
86
+ undefined,
87
+ throwingTerminalManager,
88
+ );
89
+
90
+ const ws = makeFakeWs();
91
+ gateway.wss.emit("connection", ws, {});
92
+
93
+ ws.emit(
94
+ "message",
95
+ Buffer.from(JSON.stringify({ type: "create_terminal", cwd: "/tmp" })),
96
+ );
97
+ // Allow any microtasks to settle.
98
+ await new Promise((r) => setImmediate(r));
99
+
100
+ const handlerErrorCall = errorSpy.mock.calls.find(
101
+ (args: unknown[]) =>
102
+ typeof args[0] === "string" &&
103
+ args[0].includes("[browser-gw] handler error") &&
104
+ args[0].includes("type=create_terminal"),
105
+ );
106
+ expect(handlerErrorCall, "expected a [browser-gw] handler error log line").toBeTruthy();
107
+ expect(throwingTerminalManager.spawn).toHaveBeenCalledOnce();
108
+ });
109
+
110
+ it("silently drops malformed JSON frames (no handler-error log)", async () => {
111
+ const gateway = createBrowserGateway(
112
+ createMemorySessionManager(),
113
+ createMemoryEventStore(() => false),
114
+ makeStubPiGateway(),
115
+ );
116
+
117
+ const ws = makeFakeWs();
118
+ gateway.wss.emit("connection", ws, {});
119
+
120
+ ws.emit("message", Buffer.from("{not json"));
121
+ await new Promise((r) => setImmediate(r));
122
+
123
+ const handlerErrorCall = errorSpy.mock.calls.find(
124
+ (args: unknown[]) =>
125
+ typeof args[0] === "string" && args[0].includes("[browser-gw] handler error"),
126
+ );
127
+ expect(handlerErrorCall).toBeUndefined();
128
+ });
129
+ });
@@ -2,10 +2,16 @@ import { describe, it, expect } from "vitest";
2
2
 
3
3
  /**
4
4
  * Unit tests for CORS origin validation logic.
5
- * Tests the same logic used in server.ts CORS callback.
5
+ * Mirrors the callback used in server.ts kept in sync by hand. The tunnel
6
+ * URL is injected via a thunk so tests can simulate an active tunnel without
7
+ * importing the full server.
6
8
  */
7
9
 
8
- function isAllowedOrigin(origin: string | undefined, configuredOrigins: string[]): boolean {
10
+ function isAllowedOrigin(
11
+ origin: string | undefined,
12
+ configuredOrigins: string[],
13
+ getTunnelUrl: () => string | null = () => null,
14
+ ): boolean {
9
15
  if (!origin) return true;
10
16
  try {
11
17
  const u = new URL(origin);
@@ -13,6 +19,9 @@ function isAllowedOrigin(origin: string | undefined, configuredOrigins: string[]
13
19
  if (host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1") {
14
20
  return true;
15
21
  }
22
+ const tunnelUrl = getTunnelUrl();
23
+ if (tunnelUrl && origin === tunnelUrl) return true;
24
+ if (host.endsWith(".share.zrok.io")) return true;
16
25
  } catch { /* ignore */ }
17
26
  return configuredOrigins.includes(origin);
18
27
  }
@@ -45,4 +54,27 @@ describe("CORS origin validation", () => {
45
54
  it("rejects non-localhost remote origins without config", () => {
46
55
  expect(isAllowedOrigin("http://192.168.1.100:3000", [])).toBe(false);
47
56
  });
57
+
58
+ // Regression: Vite emits `<script type="module" crossorigin>` which makes
59
+ // browsers send CORS-mode requests even same-origin. When the dashboard is
60
+ // served through a zrok tunnel the Origin header is the tunnel URL, which
61
+ // previously wasn't in the allow list — the server then threw inside the
62
+ // CORS callback, surfacing as HTTP 500 on every asset. These tests pin the
63
+ // fix so that behavior cannot regress.
64
+ describe("zrok tunnel origins (browser module-script regression)", () => {
65
+ it("allows the currently-active tunnel URL", () => {
66
+ const tunnelUrl = "https://cwanni9wce66.share.zrok.io";
67
+ expect(isAllowedOrigin(tunnelUrl, [], () => tunnelUrl)).toBe(true);
68
+ });
69
+
70
+ it("allows any *.share.zrok.io origin (URL rotation, stale tabs)", () => {
71
+ expect(isAllowedOrigin("https://tgbdzzvlar6b.share.zrok.io", [])).toBe(true);
72
+ expect(isAllowedOrigin("https://anyothershare123.share.zrok.io", [])).toBe(true);
73
+ });
74
+
75
+ it("does not allow non-zrok sibling hosts", () => {
76
+ expect(isAllowedOrigin("https://share.zrok.io.attacker.com", [])).toBe(false);
77
+ expect(isAllowedOrigin("https://evil.io", [])).toBe(false);
78
+ });
79
+ });
48
80
  });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Integration test: verify pidRegistry.register/remove fires during an
3
+ * actual EditorManager start()/stop() cycle.
4
+ *
5
+ * Stubs `node:child_process#spawn` to return a fake ChildProcess that binds
6
+ * a real TCP listener on the port parsed from --bind-addr. This lets the
7
+ * real `waitForPort` resolve true, so the production start() code path
8
+ * (including the pidRegistry.register call with child.pid) executes as in prod.
9
+ */
10
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
11
+ import { EventEmitter } from "node:events";
12
+ import { createServer as createNetServer, type Server as NetServer } from "node:net";
13
+ import type { EditorConfig } from "@blackbelt-technology/pi-dashboard-shared/config.js";
14
+ import type { EditorDetectionResult } from "@blackbelt-technology/pi-dashboard-shared/editor-types.js";
15
+
16
+ // Track every fake child spawned so we can tear them down.
17
+ const spawnedChildren: FakeChild[] = [];
18
+
19
+ class FakeChild extends EventEmitter {
20
+ pid: number;
21
+ killed = false;
22
+ private server: NetServer | null = null;
23
+
24
+ constructor(port: number) {
25
+ super();
26
+ // Use a fake but plausible PID; real PID would be this process's tree.
27
+ // Using Math.random keeps tests from colliding.
28
+ this.pid = 100000 + Math.floor(Math.random() * 900000);
29
+
30
+ // Bind a TCP listener on the requested port so waitForPort's probe
31
+ // succeeds. This is the key trick that lets the real start() path run.
32
+ this.server = createNetServer();
33
+ this.server.listen(port, "127.0.0.1");
34
+ }
35
+
36
+ kill(_signal?: NodeJS.Signals): boolean {
37
+ if (this.killed) return false;
38
+ this.killed = true;
39
+ if (this.server) {
40
+ this.server.close();
41
+ this.server = null;
42
+ }
43
+ // Emit exit asynchronously, like the real ChildProcess.
44
+ setImmediate(() => this.emit("exit", 0, null));
45
+ return true;
46
+ }
47
+ }
48
+
49
+ vi.mock("node:child_process", async (importOriginal) => {
50
+ const real = await importOriginal<typeof import("node:child_process")>();
51
+ return {
52
+ ...real,
53
+ spawn: (_cmd: string, args: readonly string[] = []) => {
54
+ // Parse --bind-addr 127.0.0.1:<port>
55
+ const idx = args.indexOf("--bind-addr");
56
+ const bind = idx >= 0 ? args[idx + 1] : "";
57
+ const port = Number(bind.split(":")[1] ?? 0);
58
+ const child = new FakeChild(port);
59
+ spawnedChildren.push(child);
60
+ return child as unknown as import("node:child_process").ChildProcess;
61
+ },
62
+ };
63
+ });
64
+
65
+ // IMPORTANT: import AFTER vi.mock so the mocked child_process is picked up.
66
+ const { createEditorManager } = await import("../editor-manager.js");
67
+
68
+ const DEFAULT_CONFIG: EditorConfig = { idleTimeoutMinutes: 10, maxInstances: 3 };
69
+ const DETECTED: EditorDetectionResult = { available: true, binary: "/fake/code-server" };
70
+
71
+ function makeRegistryStub() {
72
+ const calls: Array<{ op: "register" | "remove"; payload: unknown }> = [];
73
+ return {
74
+ calls,
75
+ register: vi.fn((entry: unknown) => { calls.push({ op: "register", payload: entry }); }),
76
+ remove: vi.fn((id: unknown) => { calls.push({ op: "remove", payload: id }); }),
77
+ size: () => 0,
78
+ cleanupOrphans: async () => {},
79
+ };
80
+ }
81
+
82
+ describe("EditorManager + pidRegistry integration", () => {
83
+ beforeEach(() => {
84
+ spawnedChildren.length = 0;
85
+ });
86
+
87
+ afterEach(() => {
88
+ // Ensure any dangling fake children release their TCP servers.
89
+ for (const c of spawnedChildren) {
90
+ if (!c.killed) c.kill("SIGTERM");
91
+ }
92
+ });
93
+
94
+ it("calls pidRegistry.register after start() resolves to ready", async () => {
95
+ const registry = makeRegistryStub();
96
+ const mgr = createEditorManager({
97
+ config: DEFAULT_CONFIG,
98
+ detection: DETECTED,
99
+ pidRegistry: registry,
100
+ });
101
+ const info = await mgr.start("/tmp/fake-project-a");
102
+ expect(info.status).toBe("ready");
103
+ expect(registry.register).toHaveBeenCalledTimes(1);
104
+ const payload = (registry.register.mock.calls[0][0] ?? {}) as Record<string, unknown>;
105
+ expect(payload.id).toBe(info.id);
106
+ expect(payload.cwd).toBe("/tmp/fake-project-a");
107
+ expect(payload.port).toBe(info.port);
108
+ expect(typeof payload.pid).toBe("number");
109
+ expect(typeof payload.dataDir).toBe("string");
110
+ mgr.stop(info.id);
111
+ });
112
+
113
+ it("calls pidRegistry.remove when stop(id) is invoked", async () => {
114
+ const registry = makeRegistryStub();
115
+ const mgr = createEditorManager({
116
+ config: DEFAULT_CONFIG,
117
+ detection: DETECTED,
118
+ pidRegistry: registry,
119
+ });
120
+ const info = await mgr.start("/tmp/fake-project-b");
121
+ registry.remove.mockClear();
122
+ mgr.stop(info.id);
123
+ // stop() calls remove() synchronously BEFORE SIGTERM.
124
+ expect(registry.remove).toHaveBeenCalledWith(info.id);
125
+ });
126
+
127
+ it("calls pidRegistry.remove when the child emits exit", async () => {
128
+ const registry = makeRegistryStub();
129
+ const mgr = createEditorManager({
130
+ config: DEFAULT_CONFIG,
131
+ detection: DETECTED,
132
+ pidRegistry: registry,
133
+ });
134
+ const info = await mgr.start("/tmp/fake-project-c");
135
+ registry.remove.mockClear();
136
+ // Simulate the child exiting on its own (e.g., crashed, not via stop()).
137
+ const child = spawnedChildren[spawnedChildren.length - 1];
138
+ child.kill("SIGTERM");
139
+ // exit is emitted on next tick
140
+ await new Promise((r) => setImmediate(r));
141
+ await new Promise((r) => setImmediate(r));
142
+ // Either stop() or the exit handler may call remove — both are fine.
143
+ expect(registry.remove).toHaveBeenCalledWith(info.id);
144
+ });
145
+
146
+ it("does not call pidRegistry.register if spawn fails (no detection)", async () => {
147
+ const registry = makeRegistryStub();
148
+ const mgr = createEditorManager({
149
+ config: DEFAULT_CONFIG,
150
+ detection: { available: false },
151
+ allowRedetection: false,
152
+ pidRegistry: registry,
153
+ });
154
+ await expect(mgr.start("/tmp/fake-project-d")).rejects.toThrow("binary_not_found");
155
+ expect(registry.register).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it("operates normally when pidRegistry is undefined (back-compat)", async () => {
159
+ const mgr = createEditorManager({
160
+ config: DEFAULT_CONFIG,
161
+ detection: DETECTED,
162
+ // no pidRegistry
163
+ });
164
+ const info = await mgr.start("/tmp/fake-project-e");
165
+ expect(info.status).toBe("ready");
166
+ expect(() => mgr.stop(info.id)).not.toThrow();
167
+ });
168
+ });
@@ -58,6 +58,39 @@ describe("createEditorManager", () => {
58
58
  await expect(mgr.start("/tmp/test")).rejects.toThrow("max_instances_reached");
59
59
  });
60
60
 
61
+ it("accepts an injected pidRegistry without affecting back-compat behavior", () => {
62
+ const calls: string[] = [];
63
+ const stubRegistry = {
64
+ register: () => calls.push("register"),
65
+ remove: () => calls.push("remove"),
66
+ size: () => 0,
67
+ cleanupOrphans: async () => {},
68
+ };
69
+ const mgr = createEditorManager({ config: DEFAULT_CONFIG, detection: DETECTED, pidRegistry: stubRegistry });
70
+ expect(mgr.list()).toEqual([]);
71
+ // stop on unknown id is a no-op and must not call registry.remove
72
+ expect(() => mgr.stop("nonexistent")).not.toThrow();
73
+ expect(calls).toEqual([]);
74
+ });
75
+
76
+ it("start failure path does not call pidRegistry.register", async () => {
77
+ const calls: string[] = [];
78
+ const stubRegistry = {
79
+ register: () => calls.push("register"),
80
+ remove: () => calls.push("remove"),
81
+ size: () => 0,
82
+ cleanupOrphans: async () => {},
83
+ };
84
+ const mgr = createEditorManager({
85
+ config: DEFAULT_CONFIG,
86
+ detection: NOT_DETECTED,
87
+ allowRedetection: false,
88
+ pidRegistry: stubRegistry,
89
+ });
90
+ await expect(mgr.start("/tmp/test")).rejects.toThrow("binary_not_found");
91
+ expect(calls).toEqual([]);
92
+ });
93
+
61
94
  it("calls onStatusChange callback", async () => {
62
95
  const statusChanges: Array<{ cwd: string; id: string; status: string }> = [];
63
96
  const mgr = createEditorManager({