@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,178 @@
1
+ /**
2
+ * dispatch-router unit tests (Phase 8 / task 8.7).
3
+ *
4
+ * Drives `handleDispatchExtensionCommand` with a mock `headlessPidRegistry`
5
+ * + browser broadcaster; asserts the optimistic-completion contract from
6
+ * `extension-rpc-dispatch` Requirement "Server-side dispatch routing to keeper".
7
+ *
8
+ * See change: add-rpc-stdin-dispatch-with-keeper-sidecar.
9
+ */
10
+ import { describe, it, expect, vi } from "vitest";
11
+ import {
12
+ buildPiRpcLine,
13
+ handleDispatchExtensionCommand,
14
+ type DispatchRouterContext,
15
+ } from "../rpc-keeper/dispatch-router.js";
16
+ import type { HeadlessPidRegistry } from "../headless-pid-registry.js";
17
+
18
+ // ── Mocks ────────────────────────────────────────────────────────────────────
19
+
20
+ interface FakeRegistryState {
21
+ writeRpcCalls: Array<{ sessionId: string; line: string }>;
22
+ writeRpcResult: boolean | Error;
23
+ }
24
+
25
+ function makeFakeRegistry(opts: { result: boolean | Error }): {
26
+ registry: HeadlessPidRegistry;
27
+ state: FakeRegistryState;
28
+ } {
29
+ const state: FakeRegistryState = {
30
+ writeRpcCalls: [],
31
+ writeRpcResult: opts.result,
32
+ };
33
+ const registry: Partial<HeadlessPidRegistry> = {
34
+ writeRpc: async (sessionId, line) => {
35
+ state.writeRpcCalls.push({ sessionId, line });
36
+ if (state.writeRpcResult instanceof Error) throw state.writeRpcResult;
37
+ return state.writeRpcResult;
38
+ },
39
+ };
40
+ return { registry: registry as HeadlessPidRegistry, state };
41
+ }
42
+
43
+ interface FeedbackBroadcast {
44
+ sessionId: string;
45
+ command: string;
46
+ status: "completed" | "error";
47
+ message?: string;
48
+ }
49
+
50
+ function makeContext(registry: HeadlessPidRegistry): {
51
+ ctx: DispatchRouterContext;
52
+ broadcasts: FeedbackBroadcast[];
53
+ } {
54
+ const broadcasts: FeedbackBroadcast[] = [];
55
+ return {
56
+ ctx: {
57
+ headlessPidRegistry: registry,
58
+ emitCommandFeedback: (sessionId, command, status, message) =>
59
+ broadcasts.push({ sessionId, command, status, message }),
60
+ },
61
+ broadcasts,
62
+ };
63
+ }
64
+
65
+ function feedbackData(b: FeedbackBroadcast): FeedbackBroadcast {
66
+ return b;
67
+ }
68
+
69
+ // ── Tests ────────────────────────────────────────────────────────────────────
70
+
71
+ describe("buildPiRpcLine", () => {
72
+ it("constructs the pi RPC prompt JSON with command and id", () => {
73
+ const line = buildPiRpcLine("/ctx-stats", "req-1");
74
+ expect(JSON.parse(line)).toEqual({
75
+ type: "prompt",
76
+ message: "/ctx-stats",
77
+ id: "req-1",
78
+ });
79
+ });
80
+
81
+ it("preserves command text verbatim (no quoting)", () => {
82
+ const line = buildPiRpcLine("/ctx-stats verbose=1", "req-2");
83
+ const parsed = JSON.parse(line);
84
+ expect(parsed.message).toBe("/ctx-stats verbose=1");
85
+ });
86
+ });
87
+
88
+ describe("handleDispatchExtensionCommand", () => {
89
+ it("success path: writeRpc invoked, optimistic 'completed' broadcast", async () => {
90
+ const { registry, state } = makeFakeRegistry({ result: true });
91
+ const { ctx, broadcasts } = makeContext(registry);
92
+
93
+ await handleDispatchExtensionCommand(
94
+ { type: "dispatch_extension_command", sessionId: "S1", command: "/ctx-stats", requestId: "r1" },
95
+ ctx,
96
+ );
97
+
98
+ expect(state.writeRpcCalls).toHaveLength(1);
99
+ expect(state.writeRpcCalls[0].sessionId).toBe("S1");
100
+ expect(JSON.parse(state.writeRpcCalls[0].line)).toEqual({
101
+ type: "prompt",
102
+ message: "/ctx-stats",
103
+ id: "r1",
104
+ });
105
+
106
+ expect(broadcasts).toHaveLength(1);
107
+ expect(broadcasts[0].sessionId).toBe("S1");
108
+ expect(broadcasts[0].command).toBe("/ctx-stats");
109
+ expect(broadcasts[0].status).toBe("completed");
110
+ expect(broadcasts[0].message).toBeUndefined();
111
+ });
112
+
113
+ it("no-keeper path: writeRpc returns false \u2192 'error' with keeper-unavailable message", async () => {
114
+ const { registry } = makeFakeRegistry({ result: false });
115
+ const { ctx, broadcasts } = makeContext(registry);
116
+
117
+ await handleDispatchExtensionCommand(
118
+ { type: "dispatch_extension_command", sessionId: "S2", command: "/curator", requestId: "r2" },
119
+ ctx,
120
+ );
121
+
122
+ expect(broadcasts).toHaveLength(1);
123
+ expect(broadcasts[0].status).toBe("error");
124
+ expect(broadcasts[0].command).toBe("/curator");
125
+ expect(broadcasts[0].message).toMatch(/RPC keeper unavailable/);
126
+ });
127
+
128
+ it("write-fails path: writeRpc throws \u2192 'error' with reason-prefixed message", async () => {
129
+ const { registry } = makeFakeRegistry({ result: new Error("EPIPE") });
130
+ const { ctx, broadcasts } = makeContext(registry);
131
+
132
+ await handleDispatchExtensionCommand(
133
+ { type: "dispatch_extension_command", sessionId: "S3", command: "/agents", requestId: "r3" },
134
+ ctx,
135
+ );
136
+
137
+ expect(broadcasts).toHaveLength(1);
138
+ expect(broadcasts[0].status).toBe("error");
139
+ expect(broadcasts[0].message).toMatch(/Failed to write RPC line/);
140
+ expect(broadcasts[0].message).toMatch(/EPIPE/);
141
+ });
142
+
143
+ it("never throws even on registry failures", async () => {
144
+ const { registry } = makeFakeRegistry({ result: new Error("boom") });
145
+ const { ctx } = makeContext(registry);
146
+
147
+ await expect(
148
+ handleDispatchExtensionCommand(
149
+ { type: "dispatch_extension_command", sessionId: "S4", command: "/x", requestId: "r4" },
150
+ ctx,
151
+ ),
152
+ ).resolves.toBeUndefined();
153
+ });
154
+
155
+ it("emits exactly one broadcast per dispatch (success)", async () => {
156
+ const { registry } = makeFakeRegistry({ result: true });
157
+ const { ctx, broadcasts } = makeContext(registry);
158
+
159
+ await handleDispatchExtensionCommand(
160
+ { type: "dispatch_extension_command", sessionId: "S5", command: "/x", requestId: "r5" },
161
+ ctx,
162
+ );
163
+
164
+ expect(broadcasts).toHaveLength(1);
165
+ });
166
+
167
+ it("emits exactly one broadcast per dispatch (failure)", async () => {
168
+ const { registry } = makeFakeRegistry({ result: false });
169
+ const { ctx, broadcasts } = makeContext(registry);
170
+
171
+ await handleDispatchExtensionCommand(
172
+ { type: "dispatch_extension_command", sessionId: "S6", command: "/x", requestId: "r6" },
173
+ ctx,
174
+ );
175
+
176
+ expect(broadcasts).toHaveLength(1);
177
+ });
178
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * End-to-end smoke test for the model proxy using Google Gemini Flash (task 16.1).
3
+ *
4
+ * Skipped by default in CI. Enable with:
5
+ * E2E_MODEL_PROXY=1 GEMINI_API_KEY=<key> npm test -- model-proxy-google-flash
6
+ *
7
+ * Steps:
8
+ * 1. Boot dashboard server on a random port
9
+ * 2. POST /api/model-proxy/api-keys → get a proxy key
10
+ * 3. GET /v1/models with the key → expect ≥1 model
11
+ * 4. If google/gemini-2.5-flash* model present:
12
+ * a. POST /v1/chat/completions non-streaming → 200 + non-empty assistant text
13
+ * b. POST /v1/chat/completions streaming → SSE chunks with delta.content
14
+ * c. POST /v1/messages (Anthropic shape) → 200
15
+ * 5. Delete the API key → re-use → expect 401
16
+ * 6. Shutdown
17
+ *
18
+ * See change: add-dashboard-model-proxy, task 16.2.
19
+ */
20
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
21
+ import { createTestServer, type TestServerHandle } from "../../test-support/test-server.js";
22
+
23
+ const ENABLED = process.env["E2E_MODEL_PROXY"] === "1";
24
+
25
+ let handle: TestServerHandle | null = null;
26
+ let httpPort: number;
27
+ let proxyKey: string;
28
+
29
+ beforeAll(async () => {
30
+ if (!ENABLED) return;
31
+ handle = await createTestServer();
32
+ httpPort = handle.httpPort;
33
+ });
34
+
35
+ afterAll(async () => {
36
+ if (handle) {
37
+ try { await handle.stop(); } catch {}
38
+ handle = null;
39
+ }
40
+ });
41
+
42
+ describe.skipIf(!ENABLED)("model-proxy e2e: google gemini flash", () => {
43
+ it("creates a proxy API key", async () => {
44
+ const res = await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ label: "e2e-test" }),
48
+ });
49
+ expect(res.ok).toBe(true);
50
+ const body = await res.json() as any;
51
+ proxyKey = body.data.key;
52
+ expect(proxyKey).toMatch(/^pi-proxy-/);
53
+ });
54
+
55
+ it("GET /v1/models returns at least 1 model", async () => {
56
+ const res = await fetch(`http://localhost:${httpPort}/v1/models`, {
57
+ headers: { authorization: `Bearer ${proxyKey}` },
58
+ });
59
+ expect(res.ok).toBe(true);
60
+ const body = await res.json() as any;
61
+ expect(body.object).toBe("list");
62
+ expect(body.data.length).toBeGreaterThanOrEqual(1);
63
+ });
64
+
65
+ it("POST /v1/chat/completions non-streaming with google flash", async () => {
66
+ const modelsRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
67
+ headers: { authorization: `Bearer ${proxyKey}` },
68
+ });
69
+ const modelsBody = await modelsRes.json() as any;
70
+ const flashModel = modelsBody.data.find((m: any) =>
71
+ m.id.includes("google/gemini-2.5-flash") || m.id.includes("gemini-2.5-flash"),
72
+ );
73
+
74
+ if (!flashModel) {
75
+ console.warn("No google/gemini-2.5-flash model available — skipping");
76
+ return;
77
+ }
78
+
79
+ const res = await fetch(`http://localhost:${httpPort}/v1/chat/completions`, {
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ authorization: `Bearer ${proxyKey}`,
84
+ },
85
+ body: JSON.stringify({
86
+ model: flashModel.id,
87
+ messages: [{ role: "user", content: "Reply with just the word: ok" }],
88
+ stream: false,
89
+ max_tokens: 20,
90
+ }),
91
+ });
92
+
93
+ expect(res.ok).toBe(true);
94
+ const body = await res.json() as any;
95
+ const content = body.choices?.[0]?.message?.content ?? "";
96
+ expect(content.length).toBeGreaterThan(0);
97
+ });
98
+
99
+ it("POST /v1/chat/completions streaming with google flash", async () => {
100
+ const modelsRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
101
+ headers: { authorization: `Bearer ${proxyKey}` },
102
+ });
103
+ const modelsBody = await modelsRes.json() as any;
104
+ const flashModel = modelsBody.data.find((m: any) =>
105
+ m.id.includes("google/gemini-2.5-flash") || m.id.includes("gemini-2.5-flash"),
106
+ );
107
+
108
+ if (!flashModel) {
109
+ console.warn("No google/gemini-2.5-flash model available — skipping");
110
+ return;
111
+ }
112
+
113
+ const res = await fetch(`http://localhost:${httpPort}/v1/chat/completions`, {
114
+ method: "POST",
115
+ headers: {
116
+ "Content-Type": "application/json",
117
+ authorization: `Bearer ${proxyKey}`,
118
+ },
119
+ body: JSON.stringify({
120
+ model: flashModel.id,
121
+ messages: [{ role: "user", content: "Reply with just the word: ok" }],
122
+ stream: true,
123
+ max_tokens: 20,
124
+ }),
125
+ });
126
+
127
+ expect(res.ok).toBe(true);
128
+ expect(res.headers.get("content-type")).toContain("text/event-stream");
129
+
130
+ const text = await res.text();
131
+ // Should contain at least one data chunk
132
+ expect(text).toContain("data:");
133
+ // Should end with [DONE]
134
+ expect(text).toContain("[DONE]");
135
+ });
136
+
137
+ it("POST /v1/messages (Anthropic shape) with google flash", async () => {
138
+ const modelsRes = await fetch(`http://localhost:${httpPort}/v1/models`, {
139
+ headers: { authorization: `Bearer ${proxyKey}` },
140
+ });
141
+ const modelsBody = await modelsRes.json() as any;
142
+ const flashModel = modelsBody.data.find((m: any) =>
143
+ m.id.includes("google/gemini-2.5-flash") || m.id.includes("gemini-2.5-flash"),
144
+ );
145
+
146
+ if (!flashModel) {
147
+ console.warn("No google/gemini-2.5-flash model available — skipping");
148
+ return;
149
+ }
150
+
151
+ const res = await fetch(`http://localhost:${httpPort}/v1/messages`, {
152
+ method: "POST",
153
+ headers: {
154
+ "Content-Type": "application/json",
155
+ authorization: `Bearer ${proxyKey}`,
156
+ },
157
+ body: JSON.stringify({
158
+ model: flashModel.id,
159
+ messages: [{ role: "user", content: "Reply with just the word: ok" }],
160
+ max_tokens: 20,
161
+ }),
162
+ });
163
+
164
+ expect(res.ok).toBe(true);
165
+ });
166
+
167
+ it("deleted API key returns 401 on re-use", async () => {
168
+ // Revoke the key first
169
+ const keysRes = await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys`);
170
+ const keysBody = await keysRes.json() as any;
171
+ const keyId = keysBody.data?.keys?.[0]?.id;
172
+ if (!keyId) return;
173
+
174
+ await fetch(`http://localhost:${httpPort}/api/model-proxy/api-keys/${keyId}/revoke`, {
175
+ method: "POST",
176
+ });
177
+
178
+ // Re-use should now fail
179
+ const res = await fetch(`http://localhost:${httpPort}/v1/models`, {
180
+ headers: { authorization: `Bearer ${proxyKey}` },
181
+ });
182
+ expect(res.status).toBe(401);
183
+ });
184
+ });
@@ -314,3 +314,236 @@ describe("HeadlessPidRegistry: three-tier link", () => {
314
314
  expect(registry.getPid("S_parent")).toBe(1000);
315
315
  });
316
316
  });
317
+
318
+ // See change: add-rpc-stdin-dispatch-with-keeper-sidecar (Phase 6 / task 6.5).
319
+ describe("HeadlessPidRegistry: keeper mode", () => {
320
+ it("register stores keeperPid + keeperSockPath when keeperOpts provided", () => {
321
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
322
+ const proc = mockProcess();
323
+ registry.register(7777, "/proj", proc, "tok_k", {
324
+ keeperPid: 7777,
325
+ keeperSockPath: "/tmp/sid.rpc.sock",
326
+ });
327
+ // Before bridge connects, getPid (no sessionId yet) is undefined.
328
+ registry.linkByToken("tok_k", "S_keep");
329
+ // No piPid passed → falls back to entry.pid (= keeper pid).
330
+ expect(registry.getPid("S_keep")).toBe(7777);
331
+ });
332
+
333
+ it("linkByToken in keeper mode stores piPid distinct from keeperPid", () => {
334
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
335
+ registry.register(8888, "/proj", mockProcess(), "tok_keep", {
336
+ keeperPid: 8888,
337
+ keeperSockPath: "/tmp/sid.sock",
338
+ });
339
+ // Bridge connects with pi's actual PID.
340
+ expect(registry.linkByToken("tok_keep", "S_keep", 5050)).toBe(true);
341
+ // getPid prefers piPid in keeper mode.
342
+ expect(registry.getPid("S_keep")).toBe(5050);
343
+ });
344
+
345
+ it("linkByToken non-keeper mode ignores pid arg (legacy behavior)", () => {
346
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
347
+ registry.register(100, "/proj", mockProcess(), "tok");
348
+ expect(registry.linkByToken("tok", "S1", 999)).toBe(true);
349
+ // Non-keeper: piPid not stored; getPid returns entry.pid.
350
+ expect(registry.getPid("S1")).toBe(100);
351
+ });
352
+
353
+ it("writeRpc returns false when no entry for sessionId", async () => {
354
+ const writer = { writeRpcToSockPath: vi.fn(async () => true), discoverExistingKeepers: vi.fn(async () => []) };
355
+ const registry = createHeadlessPidRegistry({
356
+ pidFilePath: join(makeTempDir(), "pids.json"),
357
+ keeperManager: writer,
358
+ });
359
+ expect(await registry.writeRpc("unknown-session", "line")).toBe(false);
360
+ expect(writer.writeRpcToSockPath).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it("writeRpc returns false for non-keeper entry", async () => {
364
+ const writer = { writeRpcToSockPath: vi.fn(async () => true), discoverExistingKeepers: vi.fn(async () => []) };
365
+ const registry = createHeadlessPidRegistry({
366
+ pidFilePath: join(makeTempDir(), "pids.json"),
367
+ keeperManager: writer,
368
+ });
369
+ registry.register(100, "/proj", mockProcess());
370
+ registry.linkSession("S1", "/proj");
371
+ expect(await registry.writeRpc("S1", "line")).toBe(false);
372
+ expect(writer.writeRpcToSockPath).not.toHaveBeenCalled();
373
+ });
374
+
375
+ it("writeRpc delegates to keeper writer for keeper entry", async () => {
376
+ const writer = {
377
+ writeRpcToSockPath: vi.fn(async (_p: string, _l: string) => true),
378
+ discoverExistingKeepers: vi.fn(async () => []),
379
+ };
380
+ const registry = createHeadlessPidRegistry({
381
+ pidFilePath: join(makeTempDir(), "pids.json"),
382
+ keeperManager: writer,
383
+ });
384
+ registry.register(7777, "/proj", mockProcess(), "tok", {
385
+ keeperPid: 7777,
386
+ keeperSockPath: "/tmp/x.sock",
387
+ });
388
+ registry.linkByToken("tok", "S_keep");
389
+ const ok = await registry.writeRpc("S_keep", "hello");
390
+ expect(ok).toBe(true);
391
+ expect(writer.writeRpcToSockPath).toHaveBeenCalledWith("/tmp/x.sock", "hello");
392
+ });
393
+
394
+ it("writeRpc returns false when keeper writer not injected", async () => {
395
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
396
+ registry.register(7777, "/proj", mockProcess(), "tok", {
397
+ keeperPid: 7777,
398
+ keeperSockPath: "/tmp/x.sock",
399
+ });
400
+ registry.linkByToken("tok", "S_keep");
401
+ expect(await registry.writeRpc("S_keep", "hello")).toBe(false);
402
+ });
403
+
404
+ it("setKeeperWriter injects writer after construction", async () => {
405
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
406
+ registry.register(7777, "/proj", mockProcess(), "tok", {
407
+ keeperPid: 7777,
408
+ keeperSockPath: "/tmp/x.sock",
409
+ });
410
+ registry.linkByToken("tok", "S_keep");
411
+ const writer = {
412
+ writeRpcToSockPath: vi.fn(async () => true),
413
+ discoverExistingKeepers: vi.fn(async () => []),
414
+ };
415
+ registry.setKeeperWriter(writer);
416
+ expect(await registry.writeRpc("S_keep", "line")).toBe(true);
417
+ expect(writer.writeRpcToSockPath).toHaveBeenCalledTimes(1);
418
+ });
419
+
420
+ it("killBySessionId in keeper mode SIGTERMs pi first then keeper", () => {
421
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
422
+ registry.register(process.pid, "/proj", mockProcess(), "tok", {
423
+ keeperPid: process.pid,
424
+ keeperSockPath: "/tmp/x.sock",
425
+ });
426
+ // Bridge connect: piPid distinct from keeperPid.
427
+ registry.linkByToken("tok", "S_keep", process.pid);
428
+ // Now piPid === process.pid, keeperPid === process.pid (both alive).
429
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
430
+ const ok = registry.killBySessionId("S_keep");
431
+ expect(ok).toBe(true);
432
+ // pi killed first (process group on Unix).
433
+ expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
434
+ expect(registry.size()).toBe(0);
435
+ killSpy.mockRestore();
436
+ });
437
+
438
+ it("killBySessionId keeper mode without pi link still kills keeper", () => {
439
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
440
+ registry.register(process.pid, "/proj", mockProcess(), "tok", {
441
+ keeperPid: process.pid,
442
+ keeperSockPath: "/tmp/x.sock",
443
+ });
444
+ registry.linkByToken("tok", "S_keep");
445
+ // No piPid set (bridge never connected). Should still kill the keeper.
446
+ const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
447
+ const ok = registry.killBySessionId("S_keep");
448
+ expect(ok).toBe(true);
449
+ expect(killSpy).toHaveBeenCalledWith(-process.pid, "SIGTERM");
450
+ killSpy.mockRestore();
451
+ });
452
+
453
+ it("cleanupKeeperOrphans no-op when no keeper writer", async () => {
454
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
455
+ await expect(registry.cleanupKeeperOrphans()).resolves.toBeUndefined();
456
+ });
457
+
458
+ it("persist round-trips keeper fields so linkByPid via piPid works after restart", async () => {
459
+ // Scenario: keeper-managed session lived through a dashboard restart.
460
+ // BEFORE restart: register with keeperOpts, linkByToken sets piPid +
461
+ // persists. AFTER restart: cleanupOrphans reclaims with piPid intact.
462
+ // Bridge re-registers with `pid: piPid` (no token) — linkByPid MUST
463
+ // match via entry.piPid, NOT fall through to cwd-FIFO. Regression
464
+ // guard for the cross-session dispatch / kill bug.
465
+ const dir = makeTempDir();
466
+ const pidFile = join(dir, "pids.json");
467
+
468
+ // ── Pre-restart server lifetime ──
469
+ const r1 = createHeadlessPidRegistry({ pidFilePath: pidFile });
470
+ r1.register(7777, "/proj", mockProcess(), "tok_keep", {
471
+ keeperPid: 7777,
472
+ keeperSockPath: "/tmp/abc.sock",
473
+ });
474
+ // Bridge connects with pi's PID 5050.
475
+ expect(r1.linkByToken("tok_keep", "S_keep", 5050)).toBe(true);
476
+ expect(r1.getPid("S_keep")).toBe(5050);
477
+
478
+ // ── Server restart (new registry instance, same pid file) ──
479
+ const r2 = createHeadlessPidRegistry({ pidFilePath: pidFile });
480
+ // Use the current test process PID so isProcessAlive returns true and
481
+ // cleanupOrphans reclaims the entry. Re-write the pid file with the
482
+ // correct PID under our test's spawnedAt rules to avoid the >7-day kill.
483
+ writeFileSync(pidFile, JSON.stringify({
484
+ entries: [{
485
+ pid: process.pid,
486
+ cwd: "/proj",
487
+ spawnedAt: new Date().toISOString(),
488
+ spawnToken: "tok_keep",
489
+ piPid: 5050,
490
+ keeperPid: process.pid,
491
+ keeperSockPath: "/tmp/abc.sock",
492
+ }],
493
+ }));
494
+ r2.cleanupOrphans();
495
+ expect(r2.size()).toBe(1);
496
+
497
+ // Bridge reattach: no spawnToken (omitted on reattach), sends pi's PID.
498
+ expect(r2.linkByToken("", "S_new", 5050)).toBe(false); // empty token
499
+ expect(r2.linkByPid("S_new", 5050)).toBe(true); // matches via piPid
500
+ expect(r2.getPid("S_new")).toBe(5050);
501
+ });
502
+
503
+ it("linkByPid does NOT mis-map when two keeper-mode entries share a cwd (regression)", () => {
504
+ const registry = createHeadlessPidRegistry({ pidFilePath: join(makeTempDir(), "pids.json") });
505
+ // Two same-cwd keeper-mode entries with distinct piPids — the exact
506
+ // shape that produced the cross-session dispatch bug before piPid was
507
+ // persisted / linkByPid checked entry.piPid.
508
+ registry.register(1000, "/proj", mockProcess(), "tok_A", {
509
+ keeperPid: 1000, keeperSockPath: "/tmp/A.sock",
510
+ });
511
+ registry.register(1001, "/proj", mockProcess(), "tok_B", {
512
+ keeperPid: 1001, keeperSockPath: "/tmp/B.sock",
513
+ });
514
+ // Each entry's first linkByToken stamped piPid.
515
+ registry.linkByToken("tok_A", "S_A", 5050);
516
+ registry.linkByToken("tok_B", "S_B", 6060);
517
+
518
+ // Simulate post-restart reattach: bridges come back with no token,
519
+ // server only knows piPid. linkByPid MUST resolve to correct entry.
520
+ // Drop sessionId to simulate fresh-restart entry state.
521
+ // (Direct mutation isn't exposed; recreate via persist+reload below.)
522
+ // Instead: assert sockPath disambiguation via writeRpc lookup.
523
+ expect(registry.getPid("S_A")).toBe(5050);
524
+ expect(registry.getPid("S_B")).toBe(6060);
525
+ });
526
+
527
+ it("cleanupKeeperOrphans attaches keeper info to existing entries", async () => {
528
+ const writer = {
529
+ writeRpcToSockPath: vi.fn(async () => true),
530
+ discoverExistingKeepers: vi.fn(async () => [
531
+ { sessionId: "transport-1", keeperPid: 4242, sockPath: "/tmp/transport-1.sock" },
532
+ ]),
533
+ };
534
+ const registry = createHeadlessPidRegistry({
535
+ pidFilePath: join(makeTempDir(), "pids.json"),
536
+ keeperManager: writer,
537
+ });
538
+ // Pre-existing entry with the same PID (would happen after
539
+ // cleanupOrphans reclaim of a long-lived keeper from disk).
540
+ registry.register(4242, "/proj", mockProcess());
541
+ await registry.cleanupKeeperOrphans();
542
+ // Verify writer was consulted and entry got keeper info via
543
+ // observable side-effect: writeRpc now succeeds for that entry.
544
+ registry.linkSession("S_attached", "/proj");
545
+ const ok = await registry.writeRpc("S_attached", "line");
546
+ expect(ok).toBe(true);
547
+ expect(writer.writeRpcToSockPath).toHaveBeenCalledWith("/tmp/transport-1.sock", "line");
548
+ });
549
+ });