voicecc 1.2.8 → 1.2.10

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Tests that dashboard password protection (HTTP Basic Auth) works correctly.
3
+ *
4
+ * Uses Hono's in-memory app.request() -- no real HTTP server needed.
5
+ *
6
+ * Run: npx tsx --test dashboard/dashboard-auth.test.ts
7
+ */
8
+
9
+ import { test, describe, beforeEach } from "node:test";
10
+ import { strict as assert } from "node:assert";
11
+
12
+ import { createApp } from "./server.js";
13
+
14
+ // ============================================================================
15
+ // CONSTANTS
16
+ // ============================================================================
17
+
18
+ const TEST_PASSWORD = "test-secret-123";
19
+ const TEST_ENDPOINT = "/api/status";
20
+
21
+ /**
22
+ * Encode credentials as a Basic Auth header value.
23
+ */
24
+ function basicAuthHeader(username: string, password: string): string {
25
+ return "Basic " + Buffer.from(`${username}:${password}`).toString("base64");
26
+ }
27
+
28
+ // ============================================================================
29
+ // TESTS
30
+ // ============================================================================
31
+
32
+ describe("dashboard password protection", () => {
33
+ beforeEach(() => {
34
+ delete process.env.DASHBOARD_PASSWORD;
35
+ });
36
+
37
+ test("no password set -- requests succeed without credentials", async () => {
38
+ const app = createApp();
39
+ const res = await app.request(TEST_ENDPOINT);
40
+ assert.equal(res.status, 200);
41
+ });
42
+
43
+ test("password set, no credentials -- returns 401", async () => {
44
+ process.env.DASHBOARD_PASSWORD = TEST_PASSWORD;
45
+ const app = createApp();
46
+ const res = await app.request(TEST_ENDPOINT);
47
+ assert.equal(res.status, 401);
48
+ });
49
+
50
+ test("password set, wrong credentials -- returns 401", async () => {
51
+ process.env.DASHBOARD_PASSWORD = TEST_PASSWORD;
52
+ const app = createApp();
53
+ const res = await app.request(TEST_ENDPOINT, {
54
+ headers: { Authorization: basicAuthHeader("admin", "wrong-password") },
55
+ });
56
+ assert.equal(res.status, 401);
57
+ });
58
+
59
+ test("password set, correct credentials -- returns 200", async () => {
60
+ process.env.DASHBOARD_PASSWORD = TEST_PASSWORD;
61
+ const app = createApp();
62
+ const res = await app.request(TEST_ENDPOINT, {
63
+ headers: { Authorization: basicAuthHeader("admin", TEST_PASSWORD) },
64
+ });
65
+ assert.equal(res.status, 200);
66
+ });
67
+
68
+ test("password set, /chat bypasses basic auth", async () => {
69
+ process.env.DASHBOARD_PASSWORD = TEST_PASSWORD;
70
+ const app = createApp();
71
+ const res = await app.request("/chat");
72
+ assert.notEqual(res.status, 401);
73
+ });
74
+
75
+ test("password set, /api/chat/* bypasses basic auth", async () => {
76
+ process.env.DASHBOARD_PASSWORD = TEST_PASSWORD;
77
+ const app = createApp();
78
+ const res = await app.request("/api/chat/send", { method: "POST" });
79
+ assert.notEqual(res.status, 401);
80
+ });
81
+
82
+ test("password set, /api/webrtc/* bypasses basic auth", async () => {
83
+ process.env.DASHBOARD_PASSWORD = TEST_PASSWORD;
84
+ const app = createApp();
85
+ const res = await app.request("/api/webrtc/validate");
86
+ assert.notEqual(res.status, 401);
87
+ });
88
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Vite configuration for the Claude Voice dashboard frontend.
3
+ *
4
+ * - Uses @vitejs/plugin-react for JSX transform
5
+ * - Proxies /api requests to the Hono backend server during development
6
+ * - Outputs production build to dashboard/dist/
7
+ */
8
+
9
+ import { defineConfig } from "vite";
10
+ import react from "@vitejs/plugin-react";
11
+
12
+ // ============================================================================
13
+ // CONFIG
14
+ // ============================================================================
15
+
16
+ const API_PROXY_TARGET = "http://localhost:3456";
17
+
18
+ export default defineConfig({
19
+ plugins: [react()],
20
+ server: {
21
+ proxy: {
22
+ "/api": API_PROXY_TARGET,
23
+ },
24
+ },
25
+ build: {
26
+ outDir: "dist",
27
+ },
28
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests that the WebSocket proxy preserves frame types (text vs binary).
3
+ *
4
+ * Twilio sends and expects JSON as text frames. If the proxy silently converts
5
+ * text→binary, Twilio ignores outbound audio and the caller hears nothing.
6
+ *
7
+ * Run: npx tsx --test dashboard/ws-proxy-frames.test.ts
8
+ */
9
+
10
+ import { test, describe, after } from "node:test";
11
+ import { strict as assert } from "node:assert";
12
+ import { WebSocketServer, WebSocket } from "ws";
13
+ import http from "node:http";
14
+
15
+ import { attachMediaProxy } from "./ws-proxy.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Helpers
19
+ // ---------------------------------------------------------------------------
20
+
21
+ function listen(server: http.Server): Promise<number> {
22
+ return new Promise((resolve) => {
23
+ server.listen(0, "127.0.0.1", () => {
24
+ const addr = server.address();
25
+ resolve(typeof addr === "object" ? addr!.port : 0);
26
+ });
27
+ });
28
+ }
29
+
30
+ function connect(url: string): Promise<WebSocket> {
31
+ return new Promise((resolve, reject) => {
32
+ const ws = new WebSocket(url);
33
+ ws.on("open", () => resolve(ws));
34
+ ws.on("error", reject);
35
+ });
36
+ }
37
+
38
+ function nextMessage(ws: WebSocket): Promise<{ data: Buffer; isBinary: boolean }> {
39
+ return new Promise((resolve) => {
40
+ ws.once("message", (data, isBinary) => {
41
+ resolve({ data: data as Buffer, isBinary });
42
+ });
43
+ });
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Tests
48
+ // ---------------------------------------------------------------------------
49
+
50
+ describe("WebSocket proxy frame types", () => {
51
+ const servers: http.Server[] = [];
52
+ const sockets: WebSocket[] = [];
53
+
54
+ after(() => {
55
+ for (const ws of sockets) ws.close();
56
+ for (const srv of servers) srv.close();
57
+ });
58
+
59
+ async function setup() {
60
+ // Mock upstream (stands in for Python voice server)
61
+ const upstreamServer = http.createServer();
62
+ const upstreamWss = new WebSocketServer({ server: upstreamServer });
63
+ servers.push(upstreamServer);
64
+ const upstreamPort = await listen(upstreamServer);
65
+
66
+ // Proxy server using real attachMediaProxy from application code
67
+ const proxyServer = http.createServer();
68
+ attachMediaProxy(proxyServer, `http://127.0.0.1:${upstreamPort}`);
69
+ servers.push(proxyServer);
70
+ const proxyPort = await listen(proxyServer);
71
+
72
+ // Wait for both sides to connect
73
+ const upstreamClientP = new Promise<WebSocket>((resolve) => {
74
+ upstreamWss.once("connection", resolve);
75
+ });
76
+ const client = await connect(`ws://127.0.0.1:${proxyPort}/media/aabbccdd-1234-5678-9900-aabbccddeeff`);
77
+ const upstreamClient = await upstreamClientP;
78
+ sockets.push(client, upstreamClient);
79
+
80
+ return { client, upstreamClient };
81
+ }
82
+
83
+ test("client→upstream: text frame arrives as text, not binary", async () => {
84
+ const { client, upstreamClient } = await setup();
85
+ const msg = JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload: "AQID" } });
86
+
87
+ const received = nextMessage(upstreamClient);
88
+ client.send(msg); // string → text frame
89
+ const { data, isBinary } = await received;
90
+
91
+ assert.equal(isBinary, false, "upstream should receive a text frame");
92
+ assert.equal(data.toString("utf-8"), msg);
93
+ });
94
+
95
+ test("upstream→client: text frame arrives as text, not binary", async () => {
96
+ const { client, upstreamClient } = await setup();
97
+ const msg = JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload: "AQID" } });
98
+
99
+ const received = nextMessage(client);
100
+ upstreamClient.send(msg); // string → text frame
101
+ const { data, isBinary } = await received;
102
+
103
+ assert.equal(isBinary, false, "client should receive a text frame");
104
+ assert.equal(data.toString("utf-8"), msg);
105
+ });
106
+
107
+ test("binary frames stay binary through the proxy", async () => {
108
+ const { client, upstreamClient } = await setup();
109
+ const buf = Buffer.from([0x01, 0x02, 0x03, 0x04]);
110
+
111
+ const received = nextMessage(upstreamClient);
112
+ client.send(buf); // Buffer → binary frame
113
+ const { isBinary } = await received;
114
+
115
+ assert.equal(isBinary, true, "upstream should receive a binary frame");
116
+ });
117
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Integration test for the Twilio ↔ Python WebSocket media proxy.
3
+ *
4
+ * Spins up a mock "Python voice server", attaches the real attachMediaProxy
5
+ * from ws-proxy.ts to an HTTP server, then connects a mock "Twilio" client.
6
+ * Verifies that Twilio media JSON round-trips correctly in both directions
7
+ * and that connections tear down cleanly.
8
+ *
9
+ * Run: npx tsx --test dashboard/ws-proxy-integration.test.ts
10
+ */
11
+
12
+ import { test, describe, after } from "node:test";
13
+ import { strict as assert } from "node:assert";
14
+ import { WebSocketServer, WebSocket } from "ws";
15
+ import http from "node:http";
16
+
17
+ import { attachMediaProxy } from "./ws-proxy.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function listen(server: http.Server): Promise<number> {
24
+ return new Promise((resolve) => {
25
+ server.listen(0, "127.0.0.1", () => {
26
+ const addr = server.address();
27
+ resolve(typeof addr === "object" ? addr!.port : 0);
28
+ });
29
+ });
30
+ }
31
+
32
+ function connect(url: string): Promise<WebSocket> {
33
+ return new Promise((resolve, reject) => {
34
+ const ws = new WebSocket(url);
35
+ ws.on("open", () => resolve(ws));
36
+ ws.on("error", reject);
37
+ });
38
+ }
39
+
40
+ function nextMessage(ws: WebSocket): Promise<{ data: Buffer; isBinary: boolean }> {
41
+ return new Promise((resolve) => {
42
+ ws.once("message", (data, isBinary) => {
43
+ resolve({ data: data as Buffer, isBinary });
44
+ });
45
+ });
46
+ }
47
+
48
+ function waitForClose(ws: WebSocket): Promise<void> {
49
+ return new Promise((resolve) => {
50
+ if (ws.readyState === WebSocket.CLOSED) return resolve();
51
+ ws.once("close", () => resolve());
52
+ });
53
+ }
54
+
55
+ // A realistic Twilio media message
56
+ const TWILIO_MEDIA_MSG = JSON.stringify({
57
+ event: "media",
58
+ streamSid: "MZ0b4ca5d9cfd2658e5b0934ed835c66d8",
59
+ media: { payload: "PCUgJCIdJlGsrL9ELiooJSY12a+s" },
60
+ });
61
+
62
+ const TWILIO_START_MSG = JSON.stringify({
63
+ event: "start",
64
+ streamSid: "MZ0b4ca5d9cfd2658e5b0934ed835c66d8",
65
+ start: { callSid: "CA123", streamSid: "MZ0b4ca5d9cfd2658e5b0934ed835c66d8" },
66
+ });
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Tests
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe("Twilio media proxy (integration)", () => {
73
+ const servers: http.Server[] = [];
74
+ const sockets: WebSocket[] = [];
75
+
76
+ after(() => {
77
+ for (const ws of sockets) ws.close();
78
+ for (const srv of servers) srv.close();
79
+ });
80
+
81
+ /** Set up mock Python server + proxy + mock Twilio client */
82
+ async function setup() {
83
+ // 1. Mock Python voice server (accepts WebSocket connections)
84
+ const pythonServer = http.createServer();
85
+ const pythonWss = new WebSocketServer({ server: pythonServer });
86
+ servers.push(pythonServer);
87
+ const pythonPort = await listen(pythonServer);
88
+
89
+ // 2. Proxy server using the real attachMediaProxy
90
+ const proxyServer = http.createServer();
91
+ attachMediaProxy(proxyServer, `http://127.0.0.1:${pythonPort}`);
92
+ servers.push(proxyServer);
93
+ const proxyPort = await listen(proxyServer);
94
+
95
+ // 3. Connect mock Twilio client to the proxy (must match /media/<token>)
96
+ const pythonClientP = new Promise<WebSocket>((resolve) => {
97
+ pythonWss.once("connection", resolve);
98
+ });
99
+ const twilioClient = await connect(`ws://127.0.0.1:${proxyPort}/media/aabbccdd-1234-5678-9900-aabbccddeeff`);
100
+ const pythonClient = await pythonClientP;
101
+ sockets.push(twilioClient, pythonClient);
102
+
103
+ return { twilioClient, pythonClient };
104
+ }
105
+
106
+ test("Twilio start event reaches Python as parseable JSON text", async () => {
107
+ const { twilioClient, pythonClient } = await setup();
108
+
109
+ const received = nextMessage(pythonClient);
110
+ twilioClient.send(TWILIO_START_MSG);
111
+ const { data, isBinary } = await received;
112
+
113
+ assert.equal(isBinary, false, "start event must arrive as text frame");
114
+ const parsed = JSON.parse(data.toString("utf-8"));
115
+ assert.equal(parsed.event, "start");
116
+ assert.equal(parsed.start.callSid, "CA123");
117
+ });
118
+
119
+ test("Twilio media reaches Python as JSON text with intact payload", async () => {
120
+ const { twilioClient, pythonClient } = await setup();
121
+
122
+ const received = nextMessage(pythonClient);
123
+ twilioClient.send(TWILIO_MEDIA_MSG);
124
+ const { data, isBinary } = await received;
125
+
126
+ assert.equal(isBinary, false, "media must arrive as text frame");
127
+ const parsed = JSON.parse(data.toString("utf-8"));
128
+ assert.equal(parsed.event, "media");
129
+ assert.equal(parsed.media.payload, "PCUgJCIdJlGsrL9ELiooJSY12a+s");
130
+ });
131
+
132
+ test("Python response media reaches Twilio as JSON text", async () => {
133
+ const { twilioClient, pythonClient } = await setup();
134
+ const responseMsg = JSON.stringify({
135
+ event: "media",
136
+ streamSid: "MZ0b4ca5d9cfd2658e5b0934ed835c66d8",
137
+ media: { payload: "Yu/d7OlyenN0+mrt+ubecGdbYO7f" },
138
+ });
139
+
140
+ const received = nextMessage(twilioClient);
141
+ pythonClient.send(responseMsg);
142
+ const { data, isBinary } = await received;
143
+
144
+ assert.equal(isBinary, false, "response media must arrive as text frame");
145
+ const parsed = JSON.parse(data.toString("utf-8"));
146
+ assert.equal(parsed.event, "media");
147
+ assert.equal(parsed.media.payload, "Yu/d7OlyenN0+mrt+ubecGdbYO7f");
148
+ });
149
+
150
+ test("closing Twilio side tears down Python connection", async () => {
151
+ const { twilioClient, pythonClient } = await setup();
152
+
153
+ const pythonClosed = waitForClose(pythonClient);
154
+ twilioClient.close();
155
+ await pythonClosed;
156
+
157
+ assert.equal(pythonClient.readyState, WebSocket.CLOSED);
158
+ });
159
+
160
+ test("multiple messages round-trip without corruption", async () => {
161
+ const { twilioClient, pythonClient } = await setup();
162
+ const messages = Array.from({ length: 10 }, (_, i) =>
163
+ JSON.stringify({
164
+ event: "media",
165
+ streamSid: "MZ123",
166
+ media: { payload: `chunk${i}_` + "A".repeat(200) },
167
+ })
168
+ );
169
+
170
+ // Send all from Twilio, collect on Python side
171
+ const allReceived: string[] = [];
172
+ const done = new Promise<void>((resolve) => {
173
+ pythonClient.on("message", (data) => {
174
+ allReceived.push((data as Buffer).toString("utf-8"));
175
+ if (allReceived.length === messages.length) resolve();
176
+ });
177
+ });
178
+
179
+ for (const msg of messages) {
180
+ twilioClient.send(msg);
181
+ }
182
+ await done;
183
+
184
+ assert.equal(allReceived.length, messages.length);
185
+ for (let i = 0; i < messages.length; i++) {
186
+ assert.equal(allReceived[i], messages[i], `message ${i} should match`);
187
+ }
188
+ });
189
+ });
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Twilio ↔ Python WebSocket proxy utilities.
3
+ *
4
+ * The `ws` library always delivers message data as a Buffer, even for text
5
+ * frames. If you forward that Buffer via ws.send(Buffer), it emits a binary
6
+ * frame — silently changing the frame type. Twilio ignores binary frames for
7
+ * JSON media messages, so the caller hears nothing.
8
+ *
9
+ * wsForward checks the isBinary flag and converts to string when needed
10
+ * so that text frames stay text and binary frames stay binary.
11
+ */
12
+
13
+ import { WebSocket, WebSocketServer } from "ws";
14
+ import type http from "node:http";
15
+ import type { Duplex } from "node:stream";
16
+
17
+ export function wsForward(
18
+ dest: WebSocket,
19
+ data: Buffer | ArrayBuffer | Buffer[],
20
+ isBinary: boolean,
21
+ ): void {
22
+ if (dest.readyState !== WebSocket.OPEN) return;
23
+ dest.send(isBinary ? data : (data as Buffer).toString("utf-8"));
24
+ }
25
+
26
+ /**
27
+ * Attach a Twilio media WebSocket proxy to an HTTP server.
28
+ *
29
+ * Intercepts upgrade requests matching /media/<token> and proxies them
30
+ * bidirectionally to the given upstream URL, preserving frame types.
31
+ */
32
+ export function attachMediaProxy(
33
+ server: http.Server,
34
+ upstreamBaseUrl: string,
35
+ ): void {
36
+ const wss = new WebSocketServer({ noServer: true });
37
+
38
+ server.on("upgrade", (req: http.IncomingMessage, socket: Duplex, head: Buffer) => {
39
+ const url = req.url ?? "";
40
+ const match = url.match(/^\/media\/([a-f0-9-]+)(?:\?.*)?$/);
41
+ if (!match) return;
42
+
43
+ const targetWsUrl = upstreamBaseUrl.replace(/^http/, "ws") + url;
44
+ const upstream = new WebSocket(targetWsUrl);
45
+
46
+ let proxyMsgCount = { fromClient: 0, fromUpstream: 0 };
47
+ upstream.on("open", () => {
48
+ wss.handleUpgrade(req, socket, head, (clientWs) => {
49
+ clientWs.on("message", (data, isBinary) => {
50
+ proxyMsgCount.fromClient++;
51
+ wsForward(upstream, data as Buffer, isBinary);
52
+ });
53
+ upstream.on("message", (data, isBinary) => {
54
+ proxyMsgCount.fromUpstream++;
55
+ wsForward(clientWs, data as Buffer, isBinary);
56
+ });
57
+
58
+ clientWs.on("close", () => upstream.close());
59
+ upstream.on("close", () => clientWs.close());
60
+ clientWs.on("error", (e) => { console.error(`[ws-proxy] Twilio WS error: ${e.message}`); upstream.close(); });
61
+ upstream.on("error", (e) => { console.error(`[ws-proxy] Python WS error: ${e.message}`); clientWs.close(); });
62
+ });
63
+ });
64
+
65
+ upstream.on("error", (err) => {
66
+ console.error(`[dashboard] Twilio WS proxy error: ${err.message}`);
67
+ socket.destroy();
68
+ });
69
+ });
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicecc",
3
- "version": "1.2.8",
3
+ "version": "1.2.10",
4
4
  "description": "Voice Agent Platform running on Claude Code -- create and deploy conversational voice agents with ElevenLabs STT/TTS and VAD",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,8 +25,8 @@
25
25
  "bin/",
26
26
  "server/",
27
27
  "voice-server/",
28
+ "dashboard/*.ts",
28
29
  "dashboard/dist/",
29
- "dashboard/server.ts",
30
30
  "dashboard/routes/",
31
31
  "init/",
32
32
  "tsconfig.json"
@@ -234,6 +234,7 @@ class ClaudeLLMService(LLMService):
234
234
  permission_mode="bypassPermissions",
235
235
  include_partial_messages=True,
236
236
  max_thinking_tokens=0,
237
+ setting_sources=["user", "project", "local"],
237
238
  )
238
239
  self._client = ClaudeSDKClient(options=options)
239
240
 
@@ -138,6 +138,7 @@ async def get_or_create_session(session_key: str, agent_id: str | None = None) -
138
138
  permission_mode="bypassPermissions",
139
139
  include_partial_messages=True,
140
140
  max_thinking_tokens=10000,
141
+ setting_sources=["user", "project", "local"],
141
142
  )
142
143
 
143
144
  client = ClaudeSDKClient(options=options)
@@ -315,6 +315,7 @@ async def _run_heartbeat_session(
315
315
  permission_mode="bypassPermissions",
316
316
  include_partial_messages=True,
317
317
  max_thinking_tokens=0,
318
+ setting_sources=["user", "project", "local"],
318
319
  )
319
320
  client = ClaudeSDKClient(options=options)
320
321
  await client.connect()