voicemode-channel 0.1.4 → 0.2.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.
package/README.md CHANGED
@@ -17,6 +17,7 @@ User hears TTS response <- Channel reply tool <----------------------+
17
17
  ### Claude Code plugin
18
18
 
19
19
  ```bash
20
+ claude plugin marketplace add mbailey/claude-plugins
20
21
  claude plugin install voicemode-channel@mbailey
21
22
  ```
22
23
 
@@ -63,13 +64,13 @@ Open **[app.voicemode.dev](https://app.voicemode.dev)** on your phone or browser
63
64
 
64
65
  ## Configuration
65
66
 
66
- | Environment Variable | Default | Description |
67
- |---------------------|---------|-------------|
68
- | `VOICEMODE_CHANNEL_ENABLED` | `false` | **Required.** Must be `true` to enable. Server exits immediately otherwise. |
69
- | `VOICEMODE_CHANNEL_DEBUG` | `false` | Enable debug logging |
70
- | `VOICEMODE_CONNECT_WS_URL` | `wss://voicemode.dev/ws` | WebSocket gateway URL |
71
- | `VOICEMODE_AGENT_NAME` | `voicemode` | Agent identity for gateway registration |
72
- | `VOICEMODE_AGENT_DISPLAY_NAME` | `Claude Code` | Display name shown to callers |
67
+ | Environment Variable | Default | Description |
68
+ | ------------------------------ | ------------------------ | --------------------------------------------------------------------------- |
69
+ | `VOICEMODE_CHANNEL_ENABLED` | `false` | **Required.** Must be `true` to enable. Server exits immediately otherwise. |
70
+ | `VOICEMODE_CHANNEL_DEBUG` | `false` | Enable debug logging |
71
+ | `VOICEMODE_CONNECT_WS_URL` | `wss://voicemode.dev/ws` | WebSocket gateway URL |
72
+ | `VOICEMODE_AGENT_NAME` | `voicemode` | Agent identity for gateway registration |
73
+ | `VOICEMODE_AGENT_DISPLAY_NAME` | `Claude Code` | Display name shown to callers |
73
74
 
74
75
  ## How it works
75
76
 
@@ -81,6 +82,7 @@ This plugin provides an MCP server that declares the experimental `claude/channe
81
82
  4. Provides a `reply` tool for Claude to send responses back
82
83
 
83
84
  Channel events appear in Claude's session as:
85
+
84
86
  ```
85
87
  <channel source="voicemode-channel" caller="NAME">TRANSCRIPT</channel>
86
88
  ```
@@ -88,20 +90,24 @@ Channel events appear in Claude's session as:
88
90
  ## Troubleshooting
89
91
 
90
92
  **Channel not connecting**
93
+
91
94
  - Ensure `VOICEMODE_CHANNEL_ENABLED=true` is set
92
95
  - Check credentials exist: `voicemode-channel auth status`
93
96
  - Re-authenticate: `voicemode-channel auth login`
94
97
  - Enable debug logging: `VOICEMODE_CHANNEL_DEBUG=true`
95
98
 
96
99
  **No audio on caller's device**
100
+
97
101
  - Confirm you're signed into [app.voicemode.dev](https://app.voicemode.dev) with the same account
98
102
  - Check that Claude is using the `reply` tool (not a plain text response)
99
103
 
100
104
  **Plugin not found after install**
105
+
101
106
  - Verify Claude Code v2.1.80+ is installed: `claude --version`
102
107
  - Reinstall: `claude plugin install voicemode-channel@mbailey`
103
108
 
104
109
  **Hook timeout on startup**
110
+
105
111
  - The SessionStart hook installs npm dependencies -- this may take a moment on first run
106
112
  - Subsequent starts use the cached install and are fast
107
113
 
@@ -114,12 +120,37 @@ cd voicemode-channel
114
120
  npm install
115
121
 
116
122
  # Build
117
- npm run build
123
+ make build
118
124
 
119
125
  # Test with --plugin-dir
120
126
  VOICEMODE_CHANNEL_ENABLED=true claude --plugin-dir . --dangerously-load-development-channels server:voicemode-channel
121
127
  ```
122
128
 
129
+ ### Testing with mcptools
130
+
131
+ [mcptools](https://github.com/f/mcptools) provides an interactive shell for testing MCP servers (`brew install mcptools`):
132
+
133
+ ```bash
134
+ make shell
135
+ ```
136
+
137
+ Example session:
138
+
139
+ ```
140
+ mcp > tools # List available tools
141
+ mcp > status # Check connection state
142
+ mcp > reply {"text":"hello from cli"} # Send a voice reply
143
+ mcp > profile # View agent profile
144
+ mcp > profile {"voice":"af_sky"} # Update profile fields
145
+ mcp > /q # Quit
146
+ ```
147
+
148
+ The MCP Inspector web UI is also available:
149
+
150
+ ```bash
151
+ make inspect
152
+ ```
153
+
123
154
  ## Status
124
155
 
125
156
  Research preview. Requires Claude Code v2.1.80+ with channel support.
package/dist/auth.js CHANGED
@@ -12,7 +12,6 @@ import { createHash, randomBytes } from 'node:crypto';
12
12
  import { createServer } from 'node:http';
13
13
  import { execFile } from 'node:child_process';
14
14
  import { platform } from 'node:os';
15
- import { createConnection } from 'node:net';
16
15
  import { AUTH0_DOMAIN, AUTH0_CLIENT_ID, save_credentials, } from './credentials.js';
17
16
  // ---------------------------------------------------------------------------
18
17
  // Auth0 OAuth parameters (matching Python CLI)
@@ -38,23 +37,27 @@ function generate_pkce_params() {
38
37
  // ---------------------------------------------------------------------------
39
38
  // Port selection
40
39
  // ---------------------------------------------------------------------------
41
- function is_port_available(port) {
40
+ /**
41
+ * Try to listen on a port, resolving with the server if successful.
42
+ * Eliminates TOCTOU race by using listen() directly instead of checking first.
43
+ */
44
+ function try_listen(server, port) {
42
45
  return new Promise((resolve) => {
43
- const sock = createConnection({ host: '127.0.0.1', port });
44
- sock.once('connect', () => { sock.destroy(); resolve(false); });
45
- sock.once('error', () => { sock.destroy(); resolve(true); });
46
+ server.once('error', (err) => {
47
+ if (err.code === 'EADDRINUSE')
48
+ resolve(false);
49
+ else
50
+ resolve(false);
51
+ });
52
+ server.listen(port, '127.0.0.1', () => resolve(true));
46
53
  });
47
54
  }
48
- async function find_available_port() {
49
- for (let port = CALLBACK_PORT_START; port <= CALLBACK_PORT_END; port++) {
50
- if (await is_port_available(port))
51
- return port;
52
- }
53
- return null;
54
- }
55
55
  // ---------------------------------------------------------------------------
56
56
  // Callback HTML page
57
57
  // ---------------------------------------------------------------------------
58
+ function escape_html(s) {
59
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
60
+ }
58
61
  function callback_page(success, error_message = '') {
59
62
  const icon_bg = success ? '#3fb950' : '#f85149';
60
63
  const icon_svg = success
@@ -63,7 +66,7 @@ function callback_page(success, error_message = '') {
63
66
  const heading = success ? 'Authentication Successful' : 'Authentication Failed';
64
67
  const message = success
65
68
  ? 'You can close this window and return to the terminal.'
66
- : (error_message ? `Error: ${error_message}` : 'Something went wrong.');
69
+ : (error_message ? `Error: ${escape_html(error_message)}` : 'Something went wrong.');
67
70
  return `<!DOCTYPE html>
68
71
  <html lang="en">
69
72
  <head>
@@ -195,22 +198,15 @@ function build_authorize_url(redirect_uri, pkce, state) {
195
198
  // Main login flow
196
199
  // ---------------------------------------------------------------------------
197
200
  export async function login(log) {
198
- // Find available port
199
- const port = await find_available_port();
200
- if (port === null) {
201
- throw new Error(`No available ports in range ${CALLBACK_PORT_START}-${CALLBACK_PORT_END}. ` +
202
- 'Please close applications using these ports and try again.');
203
- }
204
- // Generate PKCE parameters and state
201
+ // Generate PKCE parameters and state upfront (needed for the request handler)
205
202
  const pkce = generate_pkce_params();
206
203
  const state = randomBytes(16).toString('base64url');
207
- const redirect_uri = `http://localhost:${port}/callback`;
208
- // Build authorization URL
209
- const auth_url = build_authorize_url(redirect_uri, pkce, state);
210
- // Start callback server and wait for the authorization code
204
+ // Create the server with request handler and find a port by trying to listen directly.
205
+ // The server stays bound to the port the entire time -- no TOCTOU race.
211
206
  const result = await new Promise((resolve, reject) => {
212
- const server = createServer((req, res) => {
213
- const url = new URL(req.url ?? '/', `http://localhost:${port}`);
207
+ let bound_port = 0;
208
+ const callback_server = createServer((req, res) => {
209
+ const url = new URL(req.url ?? '/', `http://localhost:${bound_port}`);
214
210
  if (url.pathname !== '/callback') {
215
211
  res.writeHead(404);
216
212
  res.end();
@@ -236,7 +232,7 @@ export async function login(log) {
236
232
  res.writeHead(200, { 'Content-Type': 'text/html' });
237
233
  res.end(callback_page(true));
238
234
  cleanup();
239
- resolve({ code, state: url.searchParams.get('state') });
235
+ resolve({ code, state: url.searchParams.get('state'), port: bound_port });
240
236
  });
241
237
  const timeout_timer = setTimeout(() => {
242
238
  cleanup();
@@ -244,10 +240,24 @@ export async function login(log) {
244
240
  }, CALLBACK_TIMEOUT_MS);
245
241
  function cleanup() {
246
242
  clearTimeout(timeout_timer);
247
- server.close();
243
+ callback_server.close();
248
244
  }
249
- server.listen(port, '127.0.0.1', () => {
250
- log(`Callback server listening on port ${port}`);
245
+ // Try ports sequentially until one binds
246
+ async function bind_port() {
247
+ for (let p = CALLBACK_PORT_START; p <= CALLBACK_PORT_END; p++) {
248
+ if (await try_listen(callback_server, p)) {
249
+ bound_port = p;
250
+ return;
251
+ }
252
+ }
253
+ cleanup();
254
+ reject(new Error(`No available ports in range ${CALLBACK_PORT_START}-${CALLBACK_PORT_END}. ` +
255
+ 'Please close applications using these ports and try again.'));
256
+ }
257
+ bind_port().then(() => {
258
+ const redirect_uri = `http://localhost:${bound_port}/callback`;
259
+ const auth_url = build_authorize_url(redirect_uri, pkce, state);
260
+ log(`Callback server listening on port ${bound_port}`);
251
261
  // Try to open browser
252
262
  const opened = open_browser(auth_url);
253
263
  if (!opened || !process.env.DISPLAY && !process.env.BROWSER && platform() === 'linux') {
@@ -258,11 +268,7 @@ export async function login(log) {
258
268
  log('Opening browser for authentication...');
259
269
  }
260
270
  log('Waiting for authentication (up to 5 minutes)...');
261
- });
262
- server.on('error', (err) => {
263
- cleanup();
264
- reject(new Error(`Callback server error: ${err.message}`));
265
- });
271
+ }).catch(reject);
266
272
  });
267
273
  if (result === null) {
268
274
  throw new Error('Authentication timed out. Please try again.');
@@ -273,6 +279,7 @@ export async function login(log) {
273
279
  }
274
280
  log('Authorization code received, exchanging for tokens...');
275
281
  // Exchange code for tokens
282
+ const redirect_uri = `http://localhost:${result.port}/callback`;
276
283
  const token_response = await exchange_code_for_tokens(result.code, pkce.code_verifier, redirect_uri);
277
284
  const expires_in = token_response.expires_in ?? 3600;
278
285
  // Fetch user info (optional, non-fatal)
@@ -35,7 +35,7 @@ export function load_credentials() {
35
35
  export function save_credentials(creds) {
36
36
  try {
37
37
  const dir = join(homedir(), '.voicemode');
38
- mkdirSync(dir, { recursive: true });
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
39
39
  writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
40
40
  }
41
41
  catch {
package/dist/gateway.js CHANGED
@@ -23,6 +23,9 @@ import { get_valid_token } from './credentials.js';
23
23
  // Configuration
24
24
  // ---------------------------------------------------------------------------
25
25
  const WS_URL = process.env.VOICEMODE_CONNECT_WS_URL ?? 'wss://voicemode.dev/ws';
26
+ if (WS_URL.startsWith('ws://')) {
27
+ process.stderr.write('[voicemode-channel] WARNING: Gateway URL uses unencrypted ws:// -- tokens will be sent in cleartext\n');
28
+ }
26
29
  const HEARTBEAT_INTERVAL_MS = 25_000;
27
30
  const HEARTBEAT_LIVENESS_TIMEOUT_MS = 60_000; // Force-close if no pong received within this window
28
31
  const INITIAL_RETRY_DELAY_MS = 1_000;
@@ -287,8 +290,8 @@ export class GatewayClient extends EventEmitter {
287
290
  const display_name = profile?.display_name ?? process.env.VOICEMODE_AGENT_DISPLAY_NAME ?? 'Claude Code';
288
291
  const presence = profile?.presence ?? 'available';
289
292
  const host = hostname();
290
- const project_path = process.cwd();
291
- const context = profile?.context ?? get_project_context(project_path);
293
+ const project_path = basename(process.cwd()); // Send basename only -- don't leak full filesystem path
294
+ const context = profile?.context ?? get_project_context(process.cwd());
292
295
  const user_entry = {
293
296
  name: agent_name,
294
297
  host,
package/dist/index.js CHANGED
@@ -38,6 +38,9 @@ try {
38
38
  continue;
39
39
  const key = trimmed.slice(0, eq).trim();
40
40
  const value = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
41
+ // Only allow VOICEMODE_ prefixed keys to prevent env var injection
42
+ if (!key.startsWith('VOICEMODE_'))
43
+ continue;
41
44
  // Don't override existing env vars
42
45
  if (!(key in process.env)) {
43
46
  process.env[key] = value;
@@ -116,6 +119,7 @@ if (process.env.VOICEMODE_CHANNEL_ENABLED !== 'true') {
116
119
  process.stderr.write('VoiceMode channel server disabled. Set VOICEMODE_CHANNEL_ENABLED=true to enable.\n');
117
120
  process.exit(0);
118
121
  }
122
+ const WS_URL = process.env.VOICEMODE_CONNECT_WS_URL ?? 'wss://voicemode.dev/ws';
119
123
  const CHANNEL_NAME = 'voicemode-channel';
120
124
  const CHANNEL_VERSION = '0.1.4';
121
125
  const INSTRUCTIONS = [
@@ -183,6 +187,15 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
183
187
  required: ['text'],
184
188
  },
185
189
  },
190
+ {
191
+ name: 'status',
192
+ description: 'Check the current status of the VoiceMode channel. ' +
193
+ 'Returns connection state, gateway URL, session IDs, auth info, and current profile.',
194
+ inputSchema: {
195
+ type: 'object',
196
+ properties: {},
197
+ },
198
+ },
186
199
  {
187
200
  name: 'profile',
188
201
  description: 'Get or update the agent profile. ' +
@@ -220,6 +233,9 @@ mcp.setRequestHandler(ListToolsRequestSchema, async () => {
220
233
  });
221
234
  mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
222
235
  const { name, arguments: args } = request.params;
236
+ if (name === 'status') {
237
+ return handle_status_tool();
238
+ }
223
239
  if (name === 'profile') {
224
240
  return handle_profile_tool(args);
225
241
  }
@@ -232,6 +248,50 @@ mcp.setRequestHandler(CallToolRequestSchema, async (request) => {
232
248
  };
233
249
  });
234
250
  // ---------------------------------------------------------------------------
251
+ // Tool handler: status
252
+ // ---------------------------------------------------------------------------
253
+ function handle_status_tool() {
254
+ const lines = [];
255
+ // Connection state
256
+ const state = gateway?.state ?? 'disconnected';
257
+ lines.push(`Connection: ${state}`);
258
+ lines.push(`Gateway: ${WS_URL}`);
259
+ // Session IDs
260
+ const server_session = gateway?.session_id ?? 'none';
261
+ const agent_session = gateway?.agent_session_id ?? 'none';
262
+ lines.push(`Session: ${server_session} (server) / ${agent_session} (agent)`);
263
+ lines.push('');
264
+ // Auth info
265
+ const creds = load_credentials();
266
+ if (creds) {
267
+ const expired = is_expired(creds);
268
+ const expires_date = new Date(creds.expires_at * 1000);
269
+ const expires_str = expires_date.toISOString().slice(0, 16);
270
+ lines.push(`Auth: authenticated`);
271
+ const name = creds.user_info?.name;
272
+ const email = creds.user_info?.email;
273
+ if (name || email) {
274
+ const parts = [name, email ? `(${email})` : null].filter(Boolean).join(' ');
275
+ lines.push(`User: ${parts}`);
276
+ }
277
+ lines.push(`Token: ${expired ? 'expired' : 'valid'} (expires ${expires_str})`);
278
+ }
279
+ else {
280
+ lines.push('Auth: not authenticated');
281
+ }
282
+ lines.push('');
283
+ // Profile
284
+ lines.push(`Profile: set`);
285
+ lines.push(` Name: ${currentProfile.name}`);
286
+ lines.push(` Display: ${currentProfile.display_name}`);
287
+ lines.push(` Context: ${currentProfile.context ?? 'none'}`);
288
+ lines.push(` Voice: ${currentProfile.voice ?? 'default'}`);
289
+ lines.push(` Presence: ${currentProfile.presence}`);
290
+ return {
291
+ content: [{ type: 'text', text: lines.join('\n') }],
292
+ };
293
+ }
294
+ // ---------------------------------------------------------------------------
235
295
  // Tool handler: reply
236
296
  // ---------------------------------------------------------------------------
237
297
  function handle_reply_tool(args) {
@@ -293,15 +353,16 @@ function handle_profile_tool(args) {
293
353
  }],
294
354
  };
295
355
  }
296
- // Merge provided fields into current profile
356
+ // Merge provided fields into current profile (cap at 200 chars to prevent abuse)
357
+ const cap = (s) => s.slice(0, 200);
297
358
  if (typeof args.name === 'string')
298
- currentProfile.name = args.name;
359
+ currentProfile.name = cap(args.name);
299
360
  if (typeof args.display_name === 'string')
300
- currentProfile.display_name = args.display_name;
361
+ currentProfile.display_name = cap(args.display_name);
301
362
  if (typeof args.context === 'string')
302
- currentProfile.context = args.context;
363
+ currentProfile.context = cap(args.context);
303
364
  if (typeof args.voice === 'string')
304
- currentProfile.voice = args.voice;
365
+ currentProfile.voice = cap(args.voice);
305
366
  if (args.presence === 'available' || args.presence === 'busy' || args.presence === 'away')
306
367
  currentProfile.presence = args.presence;
307
368
  log(`Profile updated: ${JSON.stringify(currentProfile)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voicemode-channel",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "VoiceMode Channel - inbound voice calls to Claude Code via VoiceMode Connect",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,9 +20,9 @@
20
20
  "ws": "^8.18.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@types/node": "^22.0.0",
23
+ "@types/node": "^25.5.2",
24
24
  "@types/ws": "^8.5.0",
25
25
  "tsx": "^4.19.0",
26
- "typescript": "^5.7.0"
26
+ "typescript": "^6.0.2"
27
27
  }
28
28
  }