@moxxy/plugin-stt-whisper-codex 0.27.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/LICENSE +21 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/transcriber.d.ts +39 -0
- package/dist/transcriber.d.ts.map +1 -0
- package/dist/transcriber.js +400 -0
- package/dist/transcriber.js.map +1 -0
- package/package.json +84 -0
- package/src/index.test.ts +609 -0
- package/src/index.ts +112 -0
- package/src/transcriber.ts +468 -0
package/package.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-stt-whisper-codex",
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "OpenAI Codex OAuth speech-to-text transcriber (Whisper family) for moxxy.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"stt",
|
|
9
|
+
"whisper",
|
|
10
|
+
"codex"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://moxxy.ai",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
19
|
+
"directory": "packages/plugin-stt-whisper-codex"
|
|
20
|
+
},
|
|
21
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"src"
|
|
38
|
+
],
|
|
39
|
+
"moxxy": {
|
|
40
|
+
"plugin": {
|
|
41
|
+
"entry": "./dist/index.js",
|
|
42
|
+
"kind": "transcriber"
|
|
43
|
+
},
|
|
44
|
+
"requirements": [
|
|
45
|
+
{
|
|
46
|
+
"kind": "plugin",
|
|
47
|
+
"name": "@moxxy/plugin-provider-openai-codex",
|
|
48
|
+
"state": "registered",
|
|
49
|
+
"hint": "Enable @moxxy/plugin-provider-openai-codex."
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"kind": "plugin",
|
|
53
|
+
"name": "@moxxy/plugin-stt-whisper",
|
|
54
|
+
"state": "registered",
|
|
55
|
+
"hint": "Enable @moxxy/plugin-stt-whisper for shared audio helpers."
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"kind": "plugin",
|
|
59
|
+
"name": "@moxxy/plugin-vault",
|
|
60
|
+
"state": "registered",
|
|
61
|
+
"hint": "whisper-codex resolves the vault from the service registry for the Codex OAuth token; @moxxy/plugin-vault must load first."
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@moxxy/plugin-stt-whisper": "0.27.0",
|
|
67
|
+
"@moxxy/plugin-provider-openai-codex": "0.27.0",
|
|
68
|
+
"@moxxy/sdk": "0.27.0"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"@types/node": "^22.10.0",
|
|
72
|
+
"typescript": "^5.7.3",
|
|
73
|
+
"vitest": "^2.1.8",
|
|
74
|
+
"@moxxy/plugin-vault": "0.27.0",
|
|
75
|
+
"@moxxy/tsconfig": "0.0.0",
|
|
76
|
+
"@moxxy/vitest-preset": "0.0.0"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"build": "tsc -p tsconfig.json",
|
|
80
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
81
|
+
"test": "vitest run",
|
|
82
|
+
"clean": "rm -rf dist .turbo"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { AddressInfo } from 'node:net';
|
|
7
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
8
|
+
import { persistCodexTokens } from '@moxxy/plugin-provider-openai-codex';
|
|
9
|
+
import {
|
|
10
|
+
createStaticKeySource,
|
|
11
|
+
deriveKey,
|
|
12
|
+
generateSalt,
|
|
13
|
+
VaultStore,
|
|
14
|
+
} from '@moxxy/plugin-vault';
|
|
15
|
+
import {
|
|
16
|
+
buildCodexTranscribeUrl,
|
|
17
|
+
buildWhisperCodexPlugin,
|
|
18
|
+
CodexOAuthTranscriber,
|
|
19
|
+
MOXXY_PCM16_24KHZ_MIME,
|
|
20
|
+
pcm16MonoToWav,
|
|
21
|
+
} from './index.js';
|
|
22
|
+
|
|
23
|
+
interface CapturedRequest {
|
|
24
|
+
readonly req: IncomingMessage;
|
|
25
|
+
readonly body: Buffer;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const tempDirs: string[] = [];
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
async function makeVault(): Promise<VaultStore> {
|
|
35
|
+
const dir = await mkdtemp(path.join(tmpdir(), 'moxxy-codex-stt-'));
|
|
36
|
+
tempDirs.push(dir);
|
|
37
|
+
return new VaultStore({
|
|
38
|
+
filePath: path.join(dir, 'vault.json'),
|
|
39
|
+
keySource: createStaticKeySource(deriveKey('test-passphrase', generateSalt())),
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function codeOf(fn: () => unknown): string | undefined {
|
|
44
|
+
try {
|
|
45
|
+
fn();
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return (err as { code?: string }).code;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
53
|
+
const chunks: Buffer[] = [];
|
|
54
|
+
for await (const chunk of req) chunks.push(Buffer.from(chunk as Buffer));
|
|
55
|
+
return Buffer.concat(chunks);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function startServer(
|
|
59
|
+
handler: (captured: CapturedRequest, res: ServerResponse) => void | Promise<void>,
|
|
60
|
+
): Promise<{ readonly baseUrl: string; readonly close: () => Promise<void> }> {
|
|
61
|
+
const server = createServer(async (req, res) => {
|
|
62
|
+
await handler({ req, body: await readBody(req) }, res);
|
|
63
|
+
});
|
|
64
|
+
server.listen(0, '127.0.0.1');
|
|
65
|
+
await once(server, 'listening');
|
|
66
|
+
const addr = server.address() as AddressInfo;
|
|
67
|
+
return {
|
|
68
|
+
baseUrl: `http://127.0.0.1:${addr.port}`,
|
|
69
|
+
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('CodexOAuthTranscriber', () => {
|
|
74
|
+
it('posts wav multipart audio to the Codex transcribe endpoint with OAuth headers', async () => {
|
|
75
|
+
const vault = await makeVault();
|
|
76
|
+
await persistCodexTokens(vault, {
|
|
77
|
+
access: 'access-token',
|
|
78
|
+
refresh: 'refresh-token',
|
|
79
|
+
expires: Date.now() + 3_600_000,
|
|
80
|
+
accountId: 'acct_123',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
let captured: CapturedRequest | null = null;
|
|
84
|
+
const server = await startServer((request, res) => {
|
|
85
|
+
captured = request;
|
|
86
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
87
|
+
res.end(JSON.stringify({ text: 'hello from codex' }));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const plugin = buildWhisperCodexPlugin({
|
|
92
|
+
vault,
|
|
93
|
+
baseUrl: server.baseUrl,
|
|
94
|
+
sessionIdProvider: () => 'stt-session-id',
|
|
95
|
+
});
|
|
96
|
+
const def = plugin.transcribers?.[0];
|
|
97
|
+
expect(def?.name).toBe('openai-codex-transcribe');
|
|
98
|
+
|
|
99
|
+
const transcriber = def!.createClient({});
|
|
100
|
+
const result = await transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), {
|
|
101
|
+
mimeType: 'audio/wav',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(result.text).toBe('hello from codex');
|
|
105
|
+
expect(captured?.req.method).toBe('POST');
|
|
106
|
+
expect(captured?.req.url).toBe('/transcribe');
|
|
107
|
+
expect(captured?.req.headers.authorization).toBe('Bearer access-token');
|
|
108
|
+
expect(captured?.req.headers['chatgpt-account-id']).toBe('acct_123');
|
|
109
|
+
expect(captured?.req.headers.session_id).toBe('stt-session-id');
|
|
110
|
+
expect(captured?.req.headers.originator).toBe('Codex Desktop');
|
|
111
|
+
expect(captured?.req.headers['user-agent']).toMatch(/^Mozilla\/5\.0/);
|
|
112
|
+
expect(captured?.req.headers['content-type']).toContain('multipart/form-data; boundary=');
|
|
113
|
+
expect(captured?.body.toString('latin1')).toContain('filename="moxxy.wav"');
|
|
114
|
+
expect(captured?.body.toString('latin1')).toContain('Content-Type: audio/wav');
|
|
115
|
+
} finally {
|
|
116
|
+
await server.close();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('converts local pcm16/24k microphone bytes to wav before upload', async () => {
|
|
121
|
+
const vault = await makeVault();
|
|
122
|
+
await persistCodexTokens(vault, {
|
|
123
|
+
access: 'access-token',
|
|
124
|
+
refresh: 'refresh-token',
|
|
125
|
+
expires: Date.now() + 3_600_000,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let body = Buffer.alloc(0);
|
|
129
|
+
const server = await startServer((request, res) => {
|
|
130
|
+
body = request.body;
|
|
131
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ text: 'pcm converted' }));
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: server.baseUrl });
|
|
137
|
+
await transcriber.transcribe(new Uint8Array([1, 0, 2, 0]), {
|
|
138
|
+
mimeType: MOXXY_PCM16_24KHZ_MIME,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(body.includes(Buffer.from('RIFF', 'ascii'))).toBe(true);
|
|
142
|
+
expect(body.includes(Buffer.from('WAVE', 'ascii'))).toBe(true);
|
|
143
|
+
} finally {
|
|
144
|
+
await server.close();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('fails with the login hint when OAuth credentials are missing', async () => {
|
|
149
|
+
const vault = await makeVault();
|
|
150
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: 'http://127.0.0.1:9' });
|
|
151
|
+
|
|
152
|
+
await expect(transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' }))
|
|
153
|
+
.rejects
|
|
154
|
+
.toThrow(/moxxy login openai-codex/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('classifies 403 transcription responses as authorization denials', async () => {
|
|
158
|
+
const vault = await makeVault();
|
|
159
|
+
await persistCodexTokens(vault, {
|
|
160
|
+
access: 'access-token',
|
|
161
|
+
refresh: 'refresh-token',
|
|
162
|
+
expires: Date.now() + 3_600_000,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const server = await startServer((_request, res) => {
|
|
166
|
+
res.writeHead(403, { 'content-type': 'text/html' });
|
|
167
|
+
res.end('<html><head><style>body{display:flex}</style></head></html>');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: server.baseUrl });
|
|
172
|
+
await expect(transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' }))
|
|
173
|
+
.rejects
|
|
174
|
+
.toMatchObject({
|
|
175
|
+
code: 'AUTH_DENIED',
|
|
176
|
+
message: expect.not.stringContaining('<html>'),
|
|
177
|
+
});
|
|
178
|
+
} finally {
|
|
179
|
+
await server.close();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rejects non-2xx and malformed responses, and treats an empty transcript as a valid empty result', async () => {
|
|
184
|
+
const vault = await makeVault();
|
|
185
|
+
await persistCodexTokens(vault, {
|
|
186
|
+
access: 'access-token',
|
|
187
|
+
refresh: 'refresh-token',
|
|
188
|
+
expires: Date.now() + 3_600_000,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const badServer = await startServer((_request, res) => {
|
|
192
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
193
|
+
res.end(JSON.stringify({ error: 'nope' }));
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: badServer.baseUrl });
|
|
197
|
+
await expect(transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' }))
|
|
198
|
+
.rejects
|
|
199
|
+
.toThrow(/500/);
|
|
200
|
+
} finally {
|
|
201
|
+
await badServer.close();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A well-formed 200 with an all-whitespace transcript means "no intelligible
|
|
205
|
+
// speech" — a valid empty RESULT, not an error. Callers (TUI/desktop/Telegram/
|
|
206
|
+
// HTTP) all have their own empty-text path; the transcriber must not throw past
|
|
207
|
+
// them.
|
|
208
|
+
const emptyServer = await startServer((_request, res) => {
|
|
209
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
210
|
+
res.end(JSON.stringify({ text: ' ' }));
|
|
211
|
+
});
|
|
212
|
+
try {
|
|
213
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: emptyServer.baseUrl });
|
|
214
|
+
const result = await transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' });
|
|
215
|
+
expect(result.text).toBe('');
|
|
216
|
+
} finally {
|
|
217
|
+
await emptyServer.close();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// A 200 that's missing the `text` field IS a contract violation → throw.
|
|
221
|
+
const malformedServer = await startServer((_request, res) => {
|
|
222
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
223
|
+
res.end(JSON.stringify({ notText: 1 }));
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: malformedServer.baseUrl });
|
|
227
|
+
await expect(transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' }))
|
|
228
|
+
.rejects
|
|
229
|
+
.toThrow(/missing a text field/);
|
|
230
|
+
} finally {
|
|
231
|
+
await malformedServer.close();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('CodexOAuthTranscriber rich response fields', () => {
|
|
237
|
+
it('surfaces language/duration/segments when the backend reports them', async () => {
|
|
238
|
+
const vault = await makeVault();
|
|
239
|
+
await persistCodexTokens(vault, {
|
|
240
|
+
access: 'access-token',
|
|
241
|
+
refresh: 'refresh-token',
|
|
242
|
+
expires: Date.now() + 3_600_000,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const server = await startServer((_request, res) => {
|
|
246
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
247
|
+
res.end(
|
|
248
|
+
JSON.stringify({
|
|
249
|
+
text: ' hi there ',
|
|
250
|
+
language: 'en',
|
|
251
|
+
duration: 2.5,
|
|
252
|
+
segments: [
|
|
253
|
+
{ start: 0, end: 1.2, text: 'hi' },
|
|
254
|
+
{ start: 1.2, end: 2.5, text: 'there' },
|
|
255
|
+
{ start: 'bad', end: 3, text: 'dropped' },
|
|
256
|
+
],
|
|
257
|
+
}),
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: server.baseUrl });
|
|
263
|
+
const result = await transcriber.transcribe(new Uint8Array([1, 2, 3]), {
|
|
264
|
+
mimeType: 'audio/wav',
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
expect(result.text).toBe('hi there');
|
|
268
|
+
expect(result.language).toBe('en');
|
|
269
|
+
expect(result.durationSec).toBe(2.5);
|
|
270
|
+
// Malformed segment (non-numeric start) is filtered out.
|
|
271
|
+
expect(result.segments).toEqual([
|
|
272
|
+
{ start: 0, end: 1.2, text: 'hi' },
|
|
273
|
+
{ start: 1.2, end: 2.5, text: 'there' },
|
|
274
|
+
]);
|
|
275
|
+
} finally {
|
|
276
|
+
await server.close();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('returns only text when the backend omits the rich fields', async () => {
|
|
281
|
+
const vault = await makeVault();
|
|
282
|
+
await persistCodexTokens(vault, {
|
|
283
|
+
access: 'access-token',
|
|
284
|
+
refresh: 'refresh-token',
|
|
285
|
+
expires: Date.now() + 3_600_000,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const server = await startServer((_request, res) => {
|
|
289
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
290
|
+
res.end(JSON.stringify({ text: 'plain' }));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: server.baseUrl });
|
|
295
|
+
const result = await transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' });
|
|
296
|
+
expect(result).toEqual({ text: 'plain' });
|
|
297
|
+
} finally {
|
|
298
|
+
await server.close();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe('buildWhisperCodexPlugin createClient config merge', () => {
|
|
304
|
+
it('keeps the host-wired vault/fetch authoritative when config tries to override them', async () => {
|
|
305
|
+
const hostVault = await makeVault();
|
|
306
|
+
await persistCodexTokens(hostVault, {
|
|
307
|
+
access: 'host-token',
|
|
308
|
+
refresh: 'refresh-token',
|
|
309
|
+
expires: Date.now() + 3_600_000,
|
|
310
|
+
accountId: 'host-acct',
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
let captured: CapturedRequest | null = null;
|
|
314
|
+
const server = await startServer((request, res) => {
|
|
315
|
+
captured = request;
|
|
316
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
317
|
+
res.end(JSON.stringify({ text: 'host wins' }));
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// A vault that, if used, would yield different (attacker) credentials.
|
|
321
|
+
const evilVault = await makeVault();
|
|
322
|
+
await persistCodexTokens(evilVault, {
|
|
323
|
+
access: 'evil-token',
|
|
324
|
+
refresh: 'evil-refresh',
|
|
325
|
+
expires: Date.now() + 3_600_000,
|
|
326
|
+
accountId: 'evil-acct',
|
|
327
|
+
});
|
|
328
|
+
const evilFetch = (() => {
|
|
329
|
+
throw new Error('config-supplied fetch must not be used');
|
|
330
|
+
}) as unknown as typeof fetch;
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
const plugin = buildWhisperCodexPlugin({
|
|
334
|
+
vault: hostVault,
|
|
335
|
+
baseUrl: server.baseUrl,
|
|
336
|
+
sessionIdProvider: () => 'host-session',
|
|
337
|
+
});
|
|
338
|
+
const def = plugin.transcribers?.[0];
|
|
339
|
+
|
|
340
|
+
// Untrusted registry config attempts to shadow host-wired dependencies.
|
|
341
|
+
const transcriber = def!.createClient({
|
|
342
|
+
vault: evilVault,
|
|
343
|
+
fetch: evilFetch,
|
|
344
|
+
});
|
|
345
|
+
const result = await transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), {
|
|
346
|
+
mimeType: 'audio/wav',
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(result.text).toBe('host wins');
|
|
350
|
+
// Host vault credentials were used, not the config-supplied ones.
|
|
351
|
+
expect(captured?.req.headers.authorization).toBe('Bearer host-token');
|
|
352
|
+
expect(captured?.req.headers['chatgpt-account-id']).toBe('host-acct');
|
|
353
|
+
} finally {
|
|
354
|
+
await server.close();
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('still honours the caller-overridable baseUrl/sessionIdProvider from config', async () => {
|
|
359
|
+
const hostVault = await makeVault();
|
|
360
|
+
await persistCodexTokens(hostVault, {
|
|
361
|
+
access: 'host-token',
|
|
362
|
+
refresh: 'refresh-token',
|
|
363
|
+
expires: Date.now() + 3_600_000,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
let captured: CapturedRequest | null = null;
|
|
367
|
+
const configServer = await startServer((request, res) => {
|
|
368
|
+
captured = request;
|
|
369
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
370
|
+
res.end(JSON.stringify({ text: 'from config server' }));
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// Host baseUrl points elsewhere; config must win for these whitelisted keys.
|
|
375
|
+
const plugin = buildWhisperCodexPlugin({
|
|
376
|
+
vault: hostVault,
|
|
377
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
378
|
+
sessionIdProvider: () => 'host-session',
|
|
379
|
+
});
|
|
380
|
+
const def = plugin.transcribers?.[0];
|
|
381
|
+
const transcriber = def!.createClient({
|
|
382
|
+
baseUrl: configServer.baseUrl,
|
|
383
|
+
sessionIdProvider: () => 'config-session',
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
const result = await transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), {
|
|
387
|
+
mimeType: 'audio/wav',
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(result.text).toBe('from config server');
|
|
391
|
+
expect(captured?.req.headers.session_id).toBe('config-session');
|
|
392
|
+
} finally {
|
|
393
|
+
await configServer.close();
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('CodexOAuthTranscriber hardening', () => {
|
|
399
|
+
it('fast-fails on empty audio without loading tokens or hitting the network', async () => {
|
|
400
|
+
// Empty vault would normally throw the login hint; an empty buffer must
|
|
401
|
+
// short-circuit before that and never touch a (refused) endpoint.
|
|
402
|
+
const vault = await makeVault();
|
|
403
|
+
const fetchSpy = (() => {
|
|
404
|
+
throw new Error('network must not be touched for empty audio');
|
|
405
|
+
}) as unknown as typeof fetch;
|
|
406
|
+
const transcriber = new CodexOAuthTranscriber({
|
|
407
|
+
vault,
|
|
408
|
+
baseUrl: 'http://127.0.0.1:9',
|
|
409
|
+
fetch: fetchSpy,
|
|
410
|
+
});
|
|
411
|
+
const result = await transcriber.transcribe(new Uint8Array(0), { mimeType: 'audio/wav' });
|
|
412
|
+
expect(result).toEqual({ text: '' });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('rejects with NETWORK_TIMEOUT when the upstream accepts the connection but never responds', async () => {
|
|
416
|
+
const vault = await makeVault();
|
|
417
|
+
await persistCodexTokens(vault, {
|
|
418
|
+
access: 'access-token',
|
|
419
|
+
refresh: 'refresh-token',
|
|
420
|
+
expires: Date.now() + 3_600_000,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const liveResponses: ServerResponse[] = [];
|
|
424
|
+
const server = await startServer((_request, res) => {
|
|
425
|
+
// Hold the request open forever — exercises the slow-loris / half-open path.
|
|
426
|
+
liveResponses.push(res);
|
|
427
|
+
});
|
|
428
|
+
try {
|
|
429
|
+
const transcriber = new CodexOAuthTranscriber({
|
|
430
|
+
vault,
|
|
431
|
+
baseUrl: server.baseUrl,
|
|
432
|
+
requestTimeoutMs: 50,
|
|
433
|
+
});
|
|
434
|
+
await expect(
|
|
435
|
+
transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), { mimeType: 'audio/wav' }),
|
|
436
|
+
)
|
|
437
|
+
.rejects
|
|
438
|
+
.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
|
439
|
+
} finally {
|
|
440
|
+
for (const res of liveResponses) res.destroy();
|
|
441
|
+
await server.close();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('rejects with NETWORK_TIMEOUT when headers arrive but the body never finishes (slow-loris body)', async () => {
|
|
446
|
+
const vault = await makeVault();
|
|
447
|
+
await persistCodexTokens(vault, {
|
|
448
|
+
access: 'access-token',
|
|
449
|
+
refresh: 'refresh-token',
|
|
450
|
+
expires: Date.now() + 3_600_000,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Send a 200 + headers + one byte of body, then hold the body open forever.
|
|
454
|
+
// The fetch promise resolves (headers are in), so a size-only cap wouldn't
|
|
455
|
+
// help — the deadline must also span the body read.
|
|
456
|
+
const liveResponses: ServerResponse[] = [];
|
|
457
|
+
const server = await startServer((_request, res) => {
|
|
458
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
459
|
+
res.write('{');
|
|
460
|
+
liveResponses.push(res);
|
|
461
|
+
// Never call res.end(): the body stream dribbles and stalls.
|
|
462
|
+
});
|
|
463
|
+
try {
|
|
464
|
+
const transcriber = new CodexOAuthTranscriber({
|
|
465
|
+
vault,
|
|
466
|
+
baseUrl: server.baseUrl,
|
|
467
|
+
requestTimeoutMs: 80,
|
|
468
|
+
});
|
|
469
|
+
await expect(
|
|
470
|
+
transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), { mimeType: 'audio/wav' }),
|
|
471
|
+
)
|
|
472
|
+
.rejects
|
|
473
|
+
.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
|
474
|
+
} finally {
|
|
475
|
+
for (const res of liveResponses) res.destroy();
|
|
476
|
+
await server.close();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('caps an oversized body read instead of buffering the whole thing', async () => {
|
|
481
|
+
const vault = await makeVault();
|
|
482
|
+
await persistCodexTokens(vault, {
|
|
483
|
+
access: 'access-token',
|
|
484
|
+
refresh: 'refresh-token',
|
|
485
|
+
expires: Date.now() + 3_600_000,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// A 200 whose body is megabytes of junk before any closing brace: the read
|
|
489
|
+
// must stop at the cap (yielding unparseable truncated JSON) rather than
|
|
490
|
+
// buffering it all. We assert it surfaces a structured PROVIDER error and
|
|
491
|
+
// returns promptly, not that it OOMs.
|
|
492
|
+
const server = await startServer((_request, res) => {
|
|
493
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
494
|
+
res.end(`{"text":"${'A'.repeat(8 * 1024 * 1024)}`); // 8MB, no closing quote/brace
|
|
495
|
+
});
|
|
496
|
+
try {
|
|
497
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: server.baseUrl });
|
|
498
|
+
await expect(
|
|
499
|
+
transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), { mimeType: 'audio/wav' }),
|
|
500
|
+
)
|
|
501
|
+
.rejects
|
|
502
|
+
.toMatchObject({ code: 'PROVIDER_UNKNOWN_RESPONSE' });
|
|
503
|
+
} finally {
|
|
504
|
+
await server.close();
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('honours a caller AbortSignal mid-flight without hanging', async () => {
|
|
509
|
+
const vault = await makeVault();
|
|
510
|
+
await persistCodexTokens(vault, {
|
|
511
|
+
access: 'access-token',
|
|
512
|
+
refresh: 'refresh-token',
|
|
513
|
+
expires: Date.now() + 3_600_000,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const liveResponses: ServerResponse[] = [];
|
|
517
|
+
const server = await startServer((_request, res) => {
|
|
518
|
+
// Hold open so the caller's own abort is what settles the promise.
|
|
519
|
+
liveResponses.push(res);
|
|
520
|
+
});
|
|
521
|
+
try {
|
|
522
|
+
const controller = new AbortController();
|
|
523
|
+
const transcriber = new CodexOAuthTranscriber({
|
|
524
|
+
vault,
|
|
525
|
+
baseUrl: server.baseUrl,
|
|
526
|
+
// Disable the internal deadline so only the caller signal can settle it.
|
|
527
|
+
requestTimeoutMs: 0,
|
|
528
|
+
});
|
|
529
|
+
const pending = transcriber.transcribe(new Uint8Array([1, 2, 3, 4]), {
|
|
530
|
+
mimeType: 'audio/wav',
|
|
531
|
+
signal: controller.signal,
|
|
532
|
+
});
|
|
533
|
+
setTimeout(() => controller.abort(), 30);
|
|
534
|
+
await expect(pending).rejects.toMatchObject({ code: 'NETWORK_ABORTED' });
|
|
535
|
+
} finally {
|
|
536
|
+
for (const res of liveResponses) res.destroy();
|
|
537
|
+
await server.close();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('truncates and sanitizes the body of an unmapped non-2xx status in the error cause', async () => {
|
|
542
|
+
const vault = await makeVault();
|
|
543
|
+
await persistCodexTokens(vault, {
|
|
544
|
+
access: 'access-token',
|
|
545
|
+
refresh: 'refresh-token',
|
|
546
|
+
expires: Date.now() + 3_600_000,
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
const secret = 'X'.repeat(50_000);
|
|
550
|
+
// 402 is not mapped by classifyHttpStatus → falls through to the raw path.
|
|
551
|
+
const server = await startServer((_request, res) => {
|
|
552
|
+
res.writeHead(402, { 'content-type': 'text/plain' });
|
|
553
|
+
res.end(`set-cookie-leak ${secret}`);
|
|
554
|
+
});
|
|
555
|
+
try {
|
|
556
|
+
const transcriber = new CodexOAuthTranscriber({ vault, baseUrl: server.baseUrl });
|
|
557
|
+
let thrown: unknown;
|
|
558
|
+
try {
|
|
559
|
+
await transcriber.transcribe(new Uint8Array([1]), { mimeType: 'audio/wav' });
|
|
560
|
+
} catch (err) {
|
|
561
|
+
thrown = err;
|
|
562
|
+
}
|
|
563
|
+
expect((thrown as { code?: string }).code).toBe('PROVIDER_BAD_REQUEST');
|
|
564
|
+
const cause = (thrown as { cause?: { message?: string } }).cause;
|
|
565
|
+
// The full 50KB body must NOT be embedded verbatim in the cause.
|
|
566
|
+
expect(cause?.message?.length ?? 0).toBeLessThanOrEqual(500);
|
|
567
|
+
expect(cause?.message ?? '').not.toContain(secret);
|
|
568
|
+
} finally {
|
|
569
|
+
await server.close();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
describe('Codex transcribe helpers', () => {
|
|
575
|
+
it('builds the ChatGPT and local transcribe URLs', () => {
|
|
576
|
+
expect(buildCodexTranscribeUrl()).toBe('https://chatgpt.com/backend-api/transcribe');
|
|
577
|
+
expect(buildCodexTranscribeUrl('https://chatgpt.com')).toBe('https://chatgpt.com/backend-api/transcribe');
|
|
578
|
+
expect(buildCodexTranscribeUrl('http://127.0.0.1:4567')).toBe('http://127.0.0.1:4567/transcribe');
|
|
579
|
+
expect(buildCodexTranscribeUrl('http://127.0.0.1:4567/backend-api')).toBe(
|
|
580
|
+
'http://127.0.0.1:4567/backend-api/transcribe',
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it('rejects a malformed base URL with CONFIG_INVALID instead of a raw TypeError', () => {
|
|
585
|
+
expect(() => buildCodexTranscribeUrl('not a url')).toThrow(/Invalid Codex transcribe base URL/);
|
|
586
|
+
expect(codeOf(() => buildCodexTranscribeUrl('not a url'))).toBe('CONFIG_INVALID');
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('refuses to target a non-chatgpt, non-loopback origin (bearer-token exfil guard)', () => {
|
|
590
|
+
// A config-controlled host must not receive the live OAuth bearer token.
|
|
591
|
+
expect(codeOf(() => buildCodexTranscribeUrl('https://evil.example.com'))).toBe('CONFIG_INVALID');
|
|
592
|
+
// Plain-http chatgpt.com is also refused (must be https).
|
|
593
|
+
expect(codeOf(() => buildCodexTranscribeUrl('http://chatgpt.com'))).toBe('CONFIG_INVALID');
|
|
594
|
+
// Loopback over https/http stays allowed for the local/test seam.
|
|
595
|
+
expect(() => buildCodexTranscribeUrl('http://127.0.0.1:4567')).not.toThrow();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('wraps pcm16 mono 24khz bytes in a valid wav header', () => {
|
|
599
|
+
const wav = pcm16MonoToWav(new Uint8Array([1, 0, 2, 0]), 24_000);
|
|
600
|
+
const buf = Buffer.from(wav);
|
|
601
|
+
|
|
602
|
+
expect(buf.toString('ascii', 0, 4)).toBe('RIFF');
|
|
603
|
+
expect(buf.toString('ascii', 8, 12)).toBe('WAVE');
|
|
604
|
+
expect(buf.readUInt32LE(24)).toBe(24_000);
|
|
605
|
+
expect(buf.readUInt16LE(34)).toBe(16);
|
|
606
|
+
expect(buf.readUInt32LE(40)).toBe(4);
|
|
607
|
+
expect(buf.subarray(44)).toEqual(Buffer.from([1, 0, 2, 0]));
|
|
608
|
+
});
|
|
609
|
+
});
|