Files
Codeman/test/voice-stream.test.ts
Codeman maintainer 4b51ba306e feat(voice): dictate through the server's Claude Code login, no API key
The mic button previously needed a Deepgram API key, or fell back to the
browser's Web Speech engine. It can now transcribe through the same
speech-to-text service Claude Code's own /voice mode uses, so anyone signed
in to Claude Code on the server gets dictation with no third-party account.

Claude Code's voice mode cannot be driven directly: it opens the HOST's
microphone (sox/arecord), and the CLI runs in a headless tmux pane while the
human is in a browser somewhere else. So capture stays in the browser and only
the transcription backend is borrowed.

Audio goes browser -> Codeman -> Anthropic. The OAuth token never reaches the
page: the browser sends PCM16 (16 kHz mono, produced by an AudioWorklet since
MediaRecorder cannot emit raw PCM) and receives text.

- GET /api/voice/status reports readiness and never the token
- GET /ws/voice/stream relays one dictation, with the same Host/Origin upgrade
  guard as the terminal socket, plus caps on concurrency, stream length and
  frame size
- credentials are read-only: Codeman never refreshes them, since a refresh
  rotates the refresh token and could sign the user out of their own CLI
- claudeVoiceEnabled (synced, default OFF) gates the whole server side
- voiceSettings.provider picks auto/claude/deepgram/webspeech; auto prefers
  Claude, then a configured Deepgram key, then the browser

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 11:19:51 +02:00

112 lines
4.2 KiB
TypeScript

/**
* Voice stream request building.
*
* The audio format lives in the query string, so a drift between these params
* and the browser worklet does not fail loudly — it transcribes as noise. These
* tests pin the contract, and pin that the bearer token never leaks into a URL
* (which would land it in proxy logs).
*/
import { describe, it, expect } from 'vitest';
import {
buildVoiceStreamHeaders,
buildVoiceStreamUrl,
normalizeVoiceLanguage,
sanitizeKeyterms,
} from '../src/web/voice-stream.js';
import { MAX_KEYTERMS_HEADER_CHARS } from '../src/config/voice.js';
describe('buildVoiceStreamUrl', () => {
it('pins linear16 / 16 kHz / mono, matching the browser worklet', () => {
const url = new URL(buildVoiceStreamUrl({}, {}));
expect(url.protocol).toBe('wss:');
expect(url.host).toBe('api.anthropic.com');
expect(url.pathname).toBe('/api/ws/speech_to_text/voice_stream');
expect(url.searchParams.get('encoding')).toBe('linear16');
expect(url.searchParams.get('sample_rate')).toBe('16000');
expect(url.searchParams.get('channels')).toBe('1');
expect(url.searchParams.get('stt_provider')).toBe('deepgram-nova3');
});
it('carries the language hint', () => {
expect(new URL(buildVoiceStreamUrl({ language: 'de' }, {})).searchParams.get('language')).toBe('de');
});
it('accepts a ws:// or wss:// base override for tests and gateways', () => {
const url = buildVoiceStreamUrl({}, { CODEMAN_VOICE_STREAM_BASE: 'ws://127.0.0.1:3199/' });
expect(url.startsWith('ws://127.0.0.1:3199/api/ws/speech_to_text/voice_stream?')).toBe(true);
});
it('ignores a non-websocket override rather than building a broken URL', () => {
expect(buildVoiceStreamUrl({}, { CODEMAN_VOICE_STREAM_BASE: 'https://evil.example' })).toContain(
'wss://api.anthropic.com'
);
});
it('never puts credentials in the URL', () => {
expect(buildVoiceStreamUrl({ language: 'en' }, {})).not.toMatch(/token|Bearer|sk-ant/i);
});
});
describe('normalizeVoiceLanguage', () => {
it.each([
['en', 'en'],
['en-US', 'en-US'],
['multi', 'multi'],
['', 'en'],
[undefined, 'en'],
['not a language', 'en'],
['../../etc/passwd', 'en'],
])('normalizes %s to %s', (input, expected) => {
expect(normalizeVoiceLanguage(input as string | undefined)).toBe(expected);
});
});
describe('sanitizeKeyterms', () => {
it('joins terms with commas', () => {
expect(sanitizeKeyterms(['tmux', 'respawn'])).toBe('tmux,respawn');
});
it('replaces an inner comma with a space so one term cannot become two', () => {
expect(sanitizeKeyterms(['hello, world'])).toBe('hello world');
});
it('drops non-ASCII, which is not portable in a header value', () => {
expect(sanitizeKeyterms(['café', 'naïve'])).toBe('caf,nave');
});
it('strips CR/LF so a term cannot inject a header', () => {
const result = sanitizeKeyterms(['ok\r\nX-Evil: 1']);
expect(result).not.toContain('\r');
expect(result).not.toContain('\n');
expect(result).toBe('okX-Evil: 1');
});
it('dedupes and skips empties', () => {
expect(sanitizeKeyterms(['a', 'a', '', ' ', 'b'])).toBe('a,b');
});
it('truncates on a term boundary rather than mangling the last term', () => {
const terms = Array.from({ length: 500 }, (_, i) => `term${i}`);
const result = sanitizeKeyterms(terms);
expect(result.length).toBeLessThanOrEqual(MAX_KEYTERMS_HEADER_CHARS);
for (const term of result.split(',')) expect(term).toMatch(/^term\d+$/);
});
});
describe('buildVoiceStreamHeaders', () => {
it('sends the bearer token and identifies Codeman honestly', () => {
const headers = buildVoiceStreamHeaders('sk-ant-oat01-test', '1.2.3');
expect(headers.Authorization).toBe('Bearer sk-ant-oat01-test');
expect(headers['User-Agent']).toBe('codeman/1.2.3 (voice-bridge)');
expect(headers['x-app']).toBe('codeman');
});
it('omits the keyterms header when nothing survives sanitizing', () => {
expect(buildVoiceStreamHeaders('t', '1.0.0', ['', ' '])).not.toHaveProperty('x-config-keyterms');
});
it('includes sanitized keyterms when present', () => {
expect(buildVoiceStreamHeaders('t', '1.0.0', ['tmux', 'respawn'])['x-config-keyterms']).toBe('tmux,respawn');
});
});