@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.
- package/AGENTS.md +26 -5
- package/README.md +30 -0
- package/docs/architecture.md +129 -1
- package/package.json +6 -6
- package/packages/extension/package.json +2 -2
- package/packages/extension/src/__tests__/bridge-slash-command-routing.test.ts +362 -0
- package/packages/extension/src/__tests__/command-handler.test.ts +10 -8
- package/packages/extension/src/__tests__/extension-slash-command-detection.test.ts +107 -0
- package/packages/extension/src/__tests__/prompt-expander.test.ts +110 -1
- package/packages/extension/src/__tests__/server-launcher-launch.test.ts +78 -0
- package/packages/extension/src/bridge-context.ts +67 -3
- package/packages/extension/src/bridge.ts +20 -8
- package/packages/extension/src/command-handler.ts +36 -13
- package/packages/extension/src/prompt-expander.ts +74 -63
- package/packages/extension/src/server-launcher.ts +31 -70
- package/packages/extension/src/slash-dispatch.ts +123 -0
- package/packages/server/bin/pi-dashboard.mjs +84 -0
- package/packages/server/package.json +6 -5
- package/packages/server/scripts/fix-pty-permissions.cjs +52 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +12 -18
- package/packages/server/src/__tests__/directory-service-openspec-enabled.test.ts +187 -0
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/dispatch-extension-command-router.test.ts +178 -0
- package/packages/server/src/__tests__/e2e/model-proxy-google-flash.test.ts +184 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +233 -0
- package/packages/server/src/__tests__/keeper-manager.test.ts +298 -0
- package/packages/server/src/__tests__/legacy-pi-cleanup.test.ts +149 -0
- package/packages/server/src/__tests__/model-proxy-api-key-routes.test.ts +277 -0
- package/packages/server/src/__tests__/model-proxy-auth-gate.test.ts +263 -0
- package/packages/server/src/__tests__/model-proxy-multi-user.test.ts +169 -0
- package/packages/server/src/__tests__/model-proxy-routes.test.ts +286 -0
- package/packages/server/src/__tests__/model-proxy-second-port.test.ts +116 -0
- package/packages/server/src/__tests__/openspec-connect-snapshot.test.ts +64 -8
- package/packages/server/src/__tests__/openspec-group-broadcast.test.ts +97 -0
- package/packages/server/src/__tests__/openspec-group-join.test.ts +80 -0
- package/packages/server/src/__tests__/openspec-group-routes.test.ts +370 -0
- package/packages/server/src/__tests__/openspec-group-store.test.ts +496 -0
- package/packages/server/src/__tests__/pi-ai-shape.test.ts +147 -0
- package/packages/server/src/__tests__/pi-dashboard-bin-wrapper.test.ts +84 -0
- package/packages/server/src/__tests__/process-manager-keeper-spawn.test.ts +206 -0
- package/packages/server/src/__tests__/provider-routes-recursion-guard.test.ts +131 -0
- package/packages/server/src/__tests__/recommended-routes.test.ts +2 -2
- package/packages/server/src/__tests__/tunnel-watchdog.test.ts +139 -0
- package/packages/server/src/auth-plugin.ts +3 -0
- package/packages/server/src/bootstrap-state.ts +10 -0
- package/packages/server/src/browser-gateway.ts +15 -7
- package/packages/server/src/browser-handlers/session-action-handler.ts +30 -4
- package/packages/server/src/cli.ts +61 -81
- package/packages/server/src/config-api.ts +14 -2
- package/packages/server/src/directory-service.ts +106 -4
- package/packages/server/src/event-wiring.ts +31 -1
- package/packages/server/src/headless-pid-registry.ts +299 -41
- package/packages/server/src/legacy-pi-cleanup.ts +151 -0
- package/packages/server/src/model-proxy/__tests__/api-key-store.test.ts +142 -0
- package/packages/server/src/model-proxy/__tests__/auth-json-contention.test.ts +98 -0
- package/packages/server/src/model-proxy/__tests__/concurrency.test.ts +107 -0
- package/packages/server/src/model-proxy/__tests__/failed-auth-backoff.test.ts +46 -0
- package/packages/server/src/model-proxy/__tests__/recursion-guard.test.ts +61 -0
- package/packages/server/src/model-proxy/__tests__/streamer.test.ts +139 -0
- package/packages/server/src/model-proxy/api-key-store.ts +87 -0
- package/packages/server/src/model-proxy/auth-gate.ts +116 -0
- package/packages/server/src/model-proxy/concurrency.ts +76 -0
- package/packages/server/src/model-proxy/convert/UPSTREAM.md +13 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-in.test.ts +137 -0
- package/packages/server/src/model-proxy/convert/__tests__/anthropic-out.test.ts +183 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-in.test.ts +134 -0
- package/packages/server/src/model-proxy/convert/__tests__/openai-out.test.ts +166 -0
- package/packages/server/src/model-proxy/convert/anthropic-in.ts +129 -0
- package/packages/server/src/model-proxy/convert/anthropic-out.ts +173 -0
- package/packages/server/src/model-proxy/convert/index.ts +8 -0
- package/packages/server/src/model-proxy/convert/openai-in.ts +119 -0
- package/packages/server/src/model-proxy/convert/openai-out.ts +151 -0
- package/packages/server/src/model-proxy/convert/types.ts +70 -0
- package/packages/server/src/model-proxy/failed-auth-backoff.ts +45 -0
- package/packages/server/src/model-proxy/internal-auth-storage.ts +146 -0
- package/packages/server/src/model-proxy/internal-registry.ts +157 -0
- package/packages/server/src/model-proxy/recursion-guard.ts +72 -0
- package/packages/server/src/model-proxy/registry-singleton.ts +109 -0
- package/packages/server/src/model-proxy/request-log.ts +53 -0
- package/packages/server/src/model-proxy/streamer.ts +59 -0
- package/packages/server/src/openspec-group-store.ts +490 -0
- package/packages/server/src/process-manager.ts +128 -0
- package/packages/server/src/provider-auth-storage.ts +29 -47
- package/packages/server/src/restart-helper.ts +17 -16
- package/packages/server/src/routes/bootstrap-routes.ts +37 -0
- package/packages/server/src/routes/jj-routes.ts +3 -0
- package/packages/server/src/routes/model-proxy-api-key-routes.ts +168 -0
- package/packages/server/src/routes/model-proxy-refresh-routes.ts +24 -0
- package/packages/server/src/routes/model-proxy-routes.ts +330 -0
- package/packages/server/src/routes/openspec-group-routes.ts +231 -0
- package/packages/server/src/routes/provider-auth-routes.ts +3 -0
- package/packages/server/src/routes/provider-routes.ts +24 -1
- package/packages/server/src/routes/system-routes.ts +44 -2
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi-shim.sh +9 -0
- package/packages/server/src/rpc-keeper/__tests__/fixtures/mock-pi.cjs +50 -0
- package/packages/server/src/rpc-keeper/__tests__/keeper.test.ts +371 -0
- package/packages/server/src/rpc-keeper/dispatch-router.ts +85 -0
- package/packages/server/src/rpc-keeper/keeper-manager.ts +364 -0
- package/packages/server/src/rpc-keeper/keeper.cjs +313 -0
- package/packages/server/src/server.ts +178 -2
- package/packages/server/src/session-api.ts +9 -1
- package/packages/server/src/tunnel-watchdog.ts +230 -0
- package/packages/server/src/tunnel.ts +5 -1
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/binary-lookup-resolveJiti.test.ts +228 -0
- package/packages/shared/src/__tests__/config-openspec.test.ts +74 -0
- package/packages/shared/src/__tests__/model-proxy-config.test.ts +146 -0
- package/packages/shared/src/__tests__/no-raw-node-import.test.ts +7 -5
- package/packages/shared/src/__tests__/node-spawn.test.ts +51 -0
- package/packages/shared/src/__tests__/openspec-groups-types.test.ts +135 -0
- package/packages/shared/src/__tests__/publish-workflow-contract.test.ts +96 -0
- package/packages/shared/src/__tests__/recommended-extensions.test.ts +11 -3
- package/packages/shared/src/__tests__/server-launcher.test.ts +227 -0
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +27 -0
- package/packages/shared/src/config.ts +172 -2
- package/packages/shared/src/dashboard-plugin/manifest-types.ts +16 -1
- package/packages/shared/src/dashboard-plugin/slot-props.ts +8 -0
- package/packages/shared/src/dashboard-plugin/slot-types.ts +57 -0
- package/packages/shared/src/platform/binary-lookup.ts +204 -0
- package/packages/shared/src/platform/node-spawn.ts +42 -5
- package/packages/shared/src/protocol.ts +19 -1
- package/packages/shared/src/recommended-extensions.ts +18 -0
- package/packages/shared/src/rest-api.ts +219 -1
- package/packages/shared/src/server-launcher.ts +277 -0
- package/packages/shared/src/tool-registry/__tests__/pi-ai-registration.test.ts +124 -0
- package/packages/shared/src/types.ts +55 -0
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +0 -184
- 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
|
+
});
|