@openchamber/web 1.9.2 → 1.9.3
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/dist/assets/ToolOutputDialog-iiUOHO3c.js +16 -0
- package/dist/assets/index-BZ8pfXBh.css +1 -0
- package/dist/assets/index-DEj7Q-1y.js +2 -0
- package/dist/assets/{main-BFP0Fw2a.js → main-Ba2uuSTQ.js} +119 -119
- package/dist/assets/{vendor-.bun-CjZZibdK.js → vendor-.bun-B34wtB0D.js} +39 -39
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/server/TERMINAL_WS_PROTOCOL.md +48 -0
- package/server/lib/fs/routes.js +48 -0
- package/server/lib/opencode/proxy.js +106 -2
- package/server/lib/quota/DOCUMENTATION.md +1 -0
- package/server/lib/quota/index.js +2 -1
- package/server/lib/quota/providers/copilot.js +1 -1
- package/server/lib/quota/providers/index.js +8 -0
- package/server/lib/quota/providers/minimax-cn-coding-plan.js +141 -15
- package/server/lib/quota/providers/minimax-coding-plan.js +139 -15
- package/server/lib/quota/providers/zhipuai.js +114 -0
- package/server/lib/terminal/DOCUMENTATION.md +41 -80
- package/server/lib/terminal/index.js +27 -8
- package/server/lib/terminal/output-replay-buffer.js +66 -0
- package/server/lib/terminal/output-replay-buffer.test.js +66 -0
- package/server/lib/terminal/runtime.js +107 -20
- package/server/lib/terminal/{input-ws-protocol.js → terminal-ws-protocol.js} +13 -11
- package/server/lib/terminal/{input-ws-protocol.test.js → terminal-ws-protocol.test.js} +39 -32
- package/server/opencode-proxy.test.js +83 -0
- package/server/proxy-headers.js +61 -0
- package/server/proxy-headers.test.js +58 -0
- package/dist/assets/ToolOutputDialog-DwlX_M_n.js +0 -16
- package/dist/assets/index-BQqVuvn2.js +0 -2
- package/dist/assets/index-CH1IFYgs.css +0 -1
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +0 -44
- package/server/lib/quota/providers/minimax-shared.js +0 -136
|
@@ -2,8 +2,12 @@ import { WebSocketServer } from 'ws';
|
|
|
2
2
|
import {
|
|
3
3
|
TERMINAL_INPUT_WS_MAX_PAYLOAD_BYTES,
|
|
4
4
|
TERMINAL_INPUT_WS_PATH,
|
|
5
|
+
TERMINAL_OUTPUT_REPLAY_MAX_BYTES,
|
|
6
|
+
appendTerminalOutputReplayChunk,
|
|
7
|
+
createTerminalOutputReplayBuffer,
|
|
5
8
|
createTerminalInputWsControlFrame,
|
|
6
9
|
isRebindRateLimited,
|
|
10
|
+
listTerminalOutputReplayChunksSince,
|
|
7
11
|
normalizeTerminalInputWsMessageToText,
|
|
8
12
|
parseRequestPathname,
|
|
9
13
|
pruneRebindTimestamps,
|
|
@@ -150,8 +154,10 @@ export function createTerminalRuntime({
|
|
|
150
154
|
};
|
|
151
155
|
|
|
152
156
|
const terminalSessions = new Map();
|
|
157
|
+
const terminalWsConnections = new Set();
|
|
153
158
|
const MAX_TERMINAL_SESSIONS = 20;
|
|
154
159
|
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
|
|
160
|
+
const terminalRuntimeName = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
|
155
161
|
const sanitizeTerminalEnv = (env) => {
|
|
156
162
|
const next = { ...env };
|
|
157
163
|
delete next.BASH_XTRACEFD;
|
|
@@ -159,13 +165,22 @@ export function createTerminalRuntime({
|
|
|
159
165
|
delete next.ENV;
|
|
160
166
|
return next;
|
|
161
167
|
};
|
|
162
|
-
const
|
|
168
|
+
const terminalTransportCapabilities = {
|
|
163
169
|
input: {
|
|
164
170
|
preferred: 'ws',
|
|
165
171
|
transports: ['http', 'ws'],
|
|
166
172
|
ws: {
|
|
167
173
|
path: TERMINAL_INPUT_WS_PATH,
|
|
168
|
-
v:
|
|
174
|
+
v: 2,
|
|
175
|
+
enc: 'text+json-bin-control',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
stream: {
|
|
179
|
+
preferred: 'ws',
|
|
180
|
+
transports: ['sse', 'ws'],
|
|
181
|
+
ws: {
|
|
182
|
+
path: TERMINAL_INPUT_WS_PATH,
|
|
183
|
+
v: 2,
|
|
169
184
|
enc: 'text+json-bin-control',
|
|
170
185
|
},
|
|
171
186
|
},
|
|
@@ -189,13 +204,17 @@ export function createTerminalRuntime({
|
|
|
189
204
|
|
|
190
205
|
terminalInputWsServer.on('connection', (socket) => {
|
|
191
206
|
const connectionState = {
|
|
207
|
+
socket,
|
|
192
208
|
boundSessionId: null,
|
|
193
209
|
invalidFrames: 0,
|
|
194
210
|
rebindTimestamps: [],
|
|
211
|
+
replayCursorBySession: new Map(),
|
|
195
212
|
lastActivityAt: Date.now(),
|
|
196
213
|
};
|
|
197
214
|
|
|
198
|
-
|
|
215
|
+
terminalWsConnections.add(connectionState);
|
|
216
|
+
|
|
217
|
+
sendTerminalInputWsControl(socket, { t: 'ok', v: 2 });
|
|
199
218
|
|
|
200
219
|
const heartbeatInterval = setInterval(() => {
|
|
201
220
|
if (socket.readyState !== 1) {
|
|
@@ -231,7 +250,7 @@ export function createTerminalRuntime({
|
|
|
231
250
|
}
|
|
232
251
|
|
|
233
252
|
if (controlMessage.t === 'p') {
|
|
234
|
-
sendTerminalInputWsControl(socket, { t: 'po', v:
|
|
253
|
+
sendTerminalInputWsControl(socket, { t: 'po', v: 2 });
|
|
235
254
|
return;
|
|
236
255
|
}
|
|
237
256
|
|
|
@@ -268,9 +287,32 @@ export function createTerminalRuntime({
|
|
|
268
287
|
return;
|
|
269
288
|
}
|
|
270
289
|
|
|
290
|
+
const replaySinceRaw =
|
|
291
|
+
typeof controlMessage.r === 'number' && Number.isFinite(controlMessage.r)
|
|
292
|
+
? Math.max(0, Math.trunc(controlMessage.r))
|
|
293
|
+
: 0;
|
|
294
|
+
const rememberedReplayCursor = connectionState.replayCursorBySession.get(nextSessionId) ?? 0;
|
|
295
|
+
const replaySince = Math.max(replaySinceRaw, rememberedReplayCursor);
|
|
296
|
+
|
|
271
297
|
connectionState.rebindTimestamps.push(now);
|
|
272
298
|
connectionState.boundSessionId = nextSessionId;
|
|
273
|
-
sendTerminalInputWsControl(socket, {
|
|
299
|
+
sendTerminalInputWsControl(socket, {
|
|
300
|
+
t: 'bok',
|
|
301
|
+
v: 2,
|
|
302
|
+
s: nextSessionId,
|
|
303
|
+
runtime: terminalRuntimeName,
|
|
304
|
+
ptyBackend: targetSession.ptyBackend || 'unknown',
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const replayChunks = listTerminalOutputReplayChunksSince(targetSession.outputReplayBuffer, replaySince);
|
|
308
|
+
for (const replayChunk of replayChunks) {
|
|
309
|
+
try {
|
|
310
|
+
socket.send(replayChunk.data);
|
|
311
|
+
connectionState.replayCursorBySession.set(nextSessionId, replayChunk.id);
|
|
312
|
+
} catch {
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
274
316
|
return;
|
|
275
317
|
}
|
|
276
318
|
|
|
@@ -301,6 +343,8 @@ export function createTerminalRuntime({
|
|
|
301
343
|
|
|
302
344
|
socket.on('close', () => {
|
|
303
345
|
clearInterval(heartbeatInterval);
|
|
346
|
+
connectionState.boundSessionId = null;
|
|
347
|
+
terminalWsConnections.delete(connectionState);
|
|
304
348
|
});
|
|
305
349
|
|
|
306
350
|
socket.on('error', (error) => {
|
|
@@ -347,6 +391,56 @@ export function createTerminalRuntime({
|
|
|
347
391
|
void handleUpgrade();
|
|
348
392
|
});
|
|
349
393
|
|
|
394
|
+
const wireTerminalSession = (sessionId, session) => {
|
|
395
|
+
session.ptyProcess.onData((data) => {
|
|
396
|
+
session.lastActivity = Date.now();
|
|
397
|
+
const replayChunk = appendTerminalOutputReplayChunk(
|
|
398
|
+
session.outputReplayBuffer,
|
|
399
|
+
data,
|
|
400
|
+
TERMINAL_OUTPUT_REPLAY_MAX_BYTES
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
for (const wsConnection of terminalWsConnections) {
|
|
404
|
+
if (wsConnection.boundSessionId !== sessionId) {
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!wsConnection.socket || wsConnection.socket.readyState !== 1) {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
wsConnection.socket.send(data);
|
|
414
|
+
if (replayChunk) {
|
|
415
|
+
wsConnection.replayCursorBySession.set(sessionId, replayChunk.id);
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
session.ptyProcess.onExit(({ exitCode, signal }) => {
|
|
423
|
+
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
|
424
|
+
for (const wsConnection of terminalWsConnections) {
|
|
425
|
+
if (wsConnection.boundSessionId !== sessionId) {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
wsConnection.boundSessionId = null;
|
|
430
|
+
wsConnection.replayCursorBySession.delete(sessionId);
|
|
431
|
+
sendTerminalInputWsControl(wsConnection.socket, {
|
|
432
|
+
t: 'x',
|
|
433
|
+
v: 2,
|
|
434
|
+
s: sessionId,
|
|
435
|
+
exitCode,
|
|
436
|
+
signal,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
terminalSessions.delete(sessionId);
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
|
|
350
444
|
const idleSweepInterval = setInterval(() => {
|
|
351
445
|
const now = Date.now();
|
|
352
446
|
for (const [sessionId, session] of terminalSessions.entries()) {
|
|
@@ -399,17 +493,14 @@ export function createTerminalRuntime({
|
|
|
399
493
|
cwd,
|
|
400
494
|
lastActivity: Date.now(),
|
|
401
495
|
clients: new Set(),
|
|
496
|
+
outputReplayBuffer: createTerminalOutputReplayBuffer(),
|
|
402
497
|
};
|
|
403
498
|
|
|
404
499
|
terminalSessions.set(sessionId, session);
|
|
405
|
-
|
|
406
|
-
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
407
|
-
console.log(`Terminal session ${sessionId} exited with code ${exitCode}, signal ${signal}`);
|
|
408
|
-
terminalSessions.delete(sessionId);
|
|
409
|
-
});
|
|
500
|
+
wireTerminalSession(sessionId, session);
|
|
410
501
|
|
|
411
502
|
console.log(`Created terminal session: ${sessionId} in ${cwd} using shell ${shell}`);
|
|
412
|
-
res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities:
|
|
503
|
+
res.json({ sessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalTransportCapabilities });
|
|
413
504
|
} catch (error) {
|
|
414
505
|
console.error('Failed to create terminal session:', error);
|
|
415
506
|
res.status(500).json({ error: error.message || 'Failed to create terminal session' });
|
|
@@ -433,9 +524,8 @@ export function createTerminalRuntime({
|
|
|
433
524
|
session.clients.add(clientId);
|
|
434
525
|
session.lastActivity = Date.now();
|
|
435
526
|
|
|
436
|
-
const runtime = typeof globalThis.Bun === 'undefined' ? 'node' : 'bun';
|
|
437
527
|
const ptyBackend = session.ptyBackend || 'unknown';
|
|
438
|
-
res.write(`data: ${JSON.stringify({ type: 'connected', runtime, ptyBackend })}\n\n`);
|
|
528
|
+
res.write(`data: ${JSON.stringify({ type: 'connected', runtime: terminalRuntimeName, ptyBackend })}\n\n`);
|
|
439
529
|
|
|
440
530
|
const heartbeatInterval = setInterval(() => {
|
|
441
531
|
try {
|
|
@@ -501,7 +591,7 @@ export function createTerminalRuntime({
|
|
|
501
591
|
req.on('close', cleanup);
|
|
502
592
|
req.on('error', cleanup);
|
|
503
593
|
|
|
504
|
-
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${
|
|
594
|
+
console.log(`Terminal connected: session=${sessionId} client=${clientId} runtime=${terminalRuntimeName} pty=${ptyBackend}`);
|
|
505
595
|
});
|
|
506
596
|
|
|
507
597
|
app.post('/api/terminal/:sessionId/input', express.text({ type: '*/*' }), (req, res) => {
|
|
@@ -613,17 +703,14 @@ export function createTerminalRuntime({
|
|
|
613
703
|
cwd,
|
|
614
704
|
lastActivity: Date.now(),
|
|
615
705
|
clients: new Set(),
|
|
706
|
+
outputReplayBuffer: createTerminalOutputReplayBuffer(),
|
|
616
707
|
};
|
|
617
708
|
|
|
618
709
|
terminalSessions.set(newSessionId, session);
|
|
619
|
-
|
|
620
|
-
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
621
|
-
console.log(`Terminal session ${newSessionId} exited with code ${exitCode}, signal ${signal}`);
|
|
622
|
-
terminalSessions.delete(newSessionId);
|
|
623
|
-
});
|
|
710
|
+
wireTerminalSession(newSessionId, session);
|
|
624
711
|
|
|
625
712
|
console.log(`Restarted terminal session: ${sessionId} -> ${newSessionId} in ${cwd} using shell ${shell}`);
|
|
626
|
-
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities:
|
|
713
|
+
res.json({ sessionId: newSessionId, cols: cols || 80, rows: rows || 24, capabilities: terminalTransportCapabilities });
|
|
627
714
|
} catch (error) {
|
|
628
715
|
console.error('Failed to restart terminal session:', error);
|
|
629
716
|
res.status(500).json({ error: error.message || 'Failed to restart terminal session' });
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export const
|
|
2
|
-
export const
|
|
3
|
-
export const
|
|
1
|
+
export const TERMINAL_WS_PATH = '/api/terminal/ws';
|
|
2
|
+
export const TERMINAL_WS_CONTROL_TAG_JSON = 0x01;
|
|
3
|
+
export const TERMINAL_WS_MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
4
4
|
|
|
5
5
|
export const parseRequestPathname = (requestUrl) => {
|
|
6
6
|
if (typeof requestUrl !== 'string' || requestUrl.length === 0) {
|
|
@@ -14,7 +14,9 @@ export const parseRequestPathname = (requestUrl) => {
|
|
|
14
14
|
}
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
-
export const
|
|
17
|
+
export const isTerminalWsPathname = (pathname) => pathname === TERMINAL_WS_PATH;
|
|
18
|
+
|
|
19
|
+
export const normalizeTerminalWsMessageToBuffer = (rawData) => {
|
|
18
20
|
if (Buffer.isBuffer(rawData)) {
|
|
19
21
|
return rawData;
|
|
20
22
|
}
|
|
@@ -26,21 +28,21 @@ export const normalizeTerminalInputWsMessageToBuffer = (rawData) => {
|
|
|
26
28
|
return Buffer.from(rawData);
|
|
27
29
|
};
|
|
28
30
|
|
|
29
|
-
export const
|
|
31
|
+
export const normalizeTerminalWsMessageToText = (rawData) => {
|
|
30
32
|
if (typeof rawData === 'string') {
|
|
31
33
|
return rawData;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
return
|
|
36
|
+
return normalizeTerminalWsMessageToBuffer(rawData).toString('utf8');
|
|
35
37
|
};
|
|
36
38
|
|
|
37
|
-
export const
|
|
39
|
+
export const readTerminalWsControlFrame = (rawData) => {
|
|
38
40
|
if (!rawData) {
|
|
39
41
|
return null;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
const buffer =
|
|
43
|
-
if (buffer.length < 2 || buffer[0] !==
|
|
44
|
+
const buffer = normalizeTerminalWsMessageToBuffer(rawData);
|
|
45
|
+
if (buffer.length < 2 || buffer[0] !== TERMINAL_WS_CONTROL_TAG_JSON) {
|
|
44
46
|
return null;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -55,9 +57,9 @@ export const readTerminalInputWsControlFrame = (rawData) => {
|
|
|
55
57
|
}
|
|
56
58
|
};
|
|
57
59
|
|
|
58
|
-
export const
|
|
60
|
+
export const createTerminalWsControlFrame = (payload) => {
|
|
59
61
|
const jsonBytes = Buffer.from(JSON.stringify(payload), 'utf8');
|
|
60
|
-
return Buffer.concat([Buffer.from([
|
|
62
|
+
return Buffer.concat([Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]), jsonBytes]);
|
|
61
63
|
};
|
|
62
64
|
|
|
63
65
|
export const pruneRebindTimestamps = (timestamps, now, windowMs) =>
|
|
@@ -1,86 +1,93 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
TERMINAL_WS_PATH,
|
|
5
|
+
TERMINAL_WS_CONTROL_TAG_JSON,
|
|
6
|
+
createTerminalWsControlFrame,
|
|
7
|
+
isTerminalWsPathname,
|
|
7
8
|
isRebindRateLimited,
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
normalizeTerminalWsMessageToBuffer,
|
|
10
|
+
normalizeTerminalWsMessageToText,
|
|
10
11
|
parseRequestPathname,
|
|
11
12
|
pruneRebindTimestamps,
|
|
12
|
-
|
|
13
|
-
} from './
|
|
13
|
+
readTerminalWsControlFrame,
|
|
14
|
+
} from './terminal-ws-protocol.js';
|
|
14
15
|
|
|
15
|
-
describe('terminal
|
|
16
|
-
it('uses fixed websocket
|
|
17
|
-
expect(
|
|
16
|
+
describe('terminal websocket protocol', () => {
|
|
17
|
+
it('uses fixed websocket paths', () => {
|
|
18
|
+
expect(TERMINAL_WS_PATH).toBe('/api/terminal/ws');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('matches supported websocket pathnames', () => {
|
|
22
|
+
expect(isTerminalWsPathname('/api/terminal/ws')).toBe(true);
|
|
23
|
+
expect(isTerminalWsPathname('/api/terminal/input-ws')).toBe(false);
|
|
24
|
+
expect(isTerminalWsPathname('/api/terminal/other')).toBe(false);
|
|
18
25
|
});
|
|
19
26
|
|
|
20
27
|
it('encodes control frames with control tag prefix', () => {
|
|
21
|
-
const frame =
|
|
22
|
-
expect(frame[0]).toBe(
|
|
28
|
+
const frame = createTerminalWsControlFrame({ t: 'ok', v: 1 });
|
|
29
|
+
expect(frame[0]).toBe(TERMINAL_WS_CONTROL_TAG_JSON);
|
|
23
30
|
});
|
|
24
31
|
|
|
25
32
|
it('roundtrips control frame payload', () => {
|
|
26
33
|
const payload = { t: 'b', s: 'abc123', v: 1 };
|
|
27
|
-
const frame =
|
|
28
|
-
expect(
|
|
34
|
+
const frame = createTerminalWsControlFrame(payload);
|
|
35
|
+
expect(readTerminalWsControlFrame(frame)).toEqual(payload);
|
|
29
36
|
});
|
|
30
37
|
|
|
31
38
|
it('rejects control frame without protocol tag', () => {
|
|
32
39
|
const frame = Buffer.from(JSON.stringify({ t: 'b', s: 'abc123' }), 'utf8');
|
|
33
|
-
expect(
|
|
40
|
+
expect(readTerminalWsControlFrame(frame)).toBeNull();
|
|
34
41
|
});
|
|
35
42
|
|
|
36
43
|
it('rejects malformed control json', () => {
|
|
37
44
|
const frame = Buffer.concat([
|
|
38
|
-
Buffer.from([
|
|
45
|
+
Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
|
|
39
46
|
Buffer.from('{not json', 'utf8'),
|
|
40
47
|
]);
|
|
41
|
-
expect(
|
|
48
|
+
expect(readTerminalWsControlFrame(frame)).toBeNull();
|
|
42
49
|
});
|
|
43
50
|
|
|
44
51
|
it('rejects empty control payloads', () => {
|
|
45
|
-
expect(
|
|
46
|
-
expect(
|
|
47
|
-
expect(
|
|
52
|
+
expect(readTerminalWsControlFrame(null)).toBeNull();
|
|
53
|
+
expect(readTerminalWsControlFrame(undefined)).toBeNull();
|
|
54
|
+
expect(readTerminalWsControlFrame(Buffer.alloc(0))).toBeNull();
|
|
48
55
|
});
|
|
49
56
|
|
|
50
57
|
it('rejects control json that is not object', () => {
|
|
51
58
|
const frame = Buffer.concat([
|
|
52
|
-
Buffer.from([
|
|
59
|
+
Buffer.from([TERMINAL_WS_CONTROL_TAG_JSON]),
|
|
53
60
|
Buffer.from('"str"', 'utf8'),
|
|
54
61
|
]);
|
|
55
|
-
expect(
|
|
62
|
+
expect(readTerminalWsControlFrame(frame)).toBeNull();
|
|
56
63
|
});
|
|
57
64
|
|
|
58
65
|
it('parses control frame from chunk arrays', () => {
|
|
59
|
-
const frame =
|
|
66
|
+
const frame = createTerminalWsControlFrame({ t: 'bok', v: 1 });
|
|
60
67
|
const chunks = [frame.subarray(0, 2), frame.subarray(2)];
|
|
61
|
-
expect(
|
|
68
|
+
expect(readTerminalWsControlFrame(chunks)).toEqual({ t: 'bok', v: 1 });
|
|
62
69
|
});
|
|
63
70
|
|
|
64
71
|
it('normalizes buffer passthrough', () => {
|
|
65
72
|
const raw = Buffer.from('abc', 'utf8');
|
|
66
|
-
const normalized =
|
|
73
|
+
const normalized = normalizeTerminalWsMessageToBuffer(raw);
|
|
67
74
|
expect(normalized).toBe(raw);
|
|
68
75
|
expect(normalized.toString('utf8')).toBe('abc');
|
|
69
76
|
});
|
|
70
77
|
|
|
71
78
|
it('normalizes uint8 arrays', () => {
|
|
72
|
-
const normalized =
|
|
79
|
+
const normalized = normalizeTerminalWsMessageToBuffer(new Uint8Array([97, 98, 99]));
|
|
73
80
|
expect(normalized.toString('utf8')).toBe('abc');
|
|
74
81
|
});
|
|
75
82
|
|
|
76
83
|
it('normalizes array buffer payloads', () => {
|
|
77
84
|
const source = new Uint8Array([97, 98, 99]).buffer;
|
|
78
|
-
const normalized =
|
|
85
|
+
const normalized = normalizeTerminalWsMessageToBuffer(source);
|
|
79
86
|
expect(normalized.toString('utf8')).toBe('abc');
|
|
80
87
|
});
|
|
81
88
|
|
|
82
89
|
it('normalizes chunk array payloads', () => {
|
|
83
|
-
const normalized =
|
|
90
|
+
const normalized = normalizeTerminalWsMessageToBuffer([
|
|
84
91
|
Buffer.from('ab', 'utf8'),
|
|
85
92
|
Buffer.from('c', 'utf8'),
|
|
86
93
|
]);
|
|
@@ -88,19 +95,19 @@ describe('terminal input websocket protocol', () => {
|
|
|
88
95
|
});
|
|
89
96
|
|
|
90
97
|
it('normalizes text payload from string', () => {
|
|
91
|
-
expect(
|
|
98
|
+
expect(normalizeTerminalWsMessageToText('\u001b[A')).toBe('\u001b[A');
|
|
92
99
|
});
|
|
93
100
|
|
|
94
101
|
it('normalizes text payload from binary data', () => {
|
|
95
|
-
expect(
|
|
102
|
+
expect(normalizeTerminalWsMessageToText(Buffer.from('\r', 'utf8'))).toBe('\r');
|
|
96
103
|
});
|
|
97
104
|
|
|
98
105
|
it('parses relative request pathname', () => {
|
|
99
|
-
expect(parseRequestPathname('/api/terminal/
|
|
106
|
+
expect(parseRequestPathname('/api/terminal/ws?x=1')).toBe('/api/terminal/ws');
|
|
100
107
|
});
|
|
101
108
|
|
|
102
109
|
it('parses absolute request pathname', () => {
|
|
103
|
-
expect(parseRequestPathname('http://localhost:3000/api/terminal/
|
|
110
|
+
expect(parseRequestPathname('http://localhost:3000/api/terminal/ws')).toBe('/api/terminal/ws');
|
|
104
111
|
});
|
|
105
112
|
|
|
106
113
|
it('returns empty pathname for non-string request url', () => {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import express from 'express';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
import { registerOpenCodeProxy } from './lib/opencode/proxy.js';
|
|
6
|
+
|
|
7
|
+
const listen = (app, host = '127.0.0.1') => new Promise((resolve, reject) => {
|
|
8
|
+
const server = app.listen(0, host, () => resolve(server));
|
|
9
|
+
server.once('error', reject);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const closeServer = (server) => new Promise((resolve, reject) => {
|
|
13
|
+
if (!server) {
|
|
14
|
+
resolve();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
server.close((error) => {
|
|
18
|
+
if (error) {
|
|
19
|
+
reject(error);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('OpenCode proxy SSE forwarding', () => {
|
|
27
|
+
let upstreamServer;
|
|
28
|
+
let proxyServer;
|
|
29
|
+
|
|
30
|
+
afterEach(async () => {
|
|
31
|
+
await closeServer(proxyServer);
|
|
32
|
+
await closeServer(upstreamServer);
|
|
33
|
+
proxyServer = undefined;
|
|
34
|
+
upstreamServer = undefined;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('forwards event streams with nginx-safe headers', async () => {
|
|
38
|
+
let seenAuthorization = null;
|
|
39
|
+
|
|
40
|
+
const upstream = express();
|
|
41
|
+
upstream.get('/global/event', (req, res) => {
|
|
42
|
+
seenAuthorization = req.headers.authorization ?? null;
|
|
43
|
+
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
44
|
+
res.setHeader('Cache-Control', 'private, max-age=0');
|
|
45
|
+
res.setHeader('X-Upstream-Test', 'ok');
|
|
46
|
+
res.write('data: {"ok":true}\n\n');
|
|
47
|
+
res.end();
|
|
48
|
+
});
|
|
49
|
+
upstreamServer = await listen(upstream);
|
|
50
|
+
const upstreamPort = upstreamServer.address().port;
|
|
51
|
+
|
|
52
|
+
const app = express();
|
|
53
|
+
registerOpenCodeProxy(app, {
|
|
54
|
+
fs: {},
|
|
55
|
+
os: {},
|
|
56
|
+
path,
|
|
57
|
+
OPEN_CODE_READY_GRACE_MS: 0,
|
|
58
|
+
getRuntime: () => ({
|
|
59
|
+
openCodePort: upstreamPort,
|
|
60
|
+
isOpenCodeReady: true,
|
|
61
|
+
openCodeNotReadySince: 0,
|
|
62
|
+
isRestartingOpenCode: false,
|
|
63
|
+
}),
|
|
64
|
+
getOpenCodeAuthHeaders: () => ({ Authorization: 'Bearer test-token' }),
|
|
65
|
+
buildOpenCodeUrl: (requestPath) => `http://127.0.0.1:${upstreamPort}${requestPath}`,
|
|
66
|
+
ensureOpenCodeApiPrefix: () => {},
|
|
67
|
+
});
|
|
68
|
+
proxyServer = await listen(app);
|
|
69
|
+
const proxyPort = proxyServer.address().port;
|
|
70
|
+
|
|
71
|
+
const response = await fetch(`http://127.0.0.1:${proxyPort}/api/global/event`, {
|
|
72
|
+
headers: { Accept: 'text/event-stream' },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(response.status).toBe(200);
|
|
76
|
+
expect(response.headers.get('content-type')).toContain('text/event-stream');
|
|
77
|
+
expect(response.headers.get('cache-control')).toBe('no-cache');
|
|
78
|
+
expect(response.headers.get('x-accel-buffering')).toBe('no');
|
|
79
|
+
expect(response.headers.get('x-upstream-test')).toBe('ok');
|
|
80
|
+
expect(await response.text()).toBe('data: {"ok":true}\n\n');
|
|
81
|
+
expect(seenAuthorization).toBe('Bearer test-token');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const filteredRequestHeaders = new Set([
|
|
2
|
+
'host',
|
|
3
|
+
'connection',
|
|
4
|
+
'content-length',
|
|
5
|
+
'transfer-encoding',
|
|
6
|
+
'keep-alive',
|
|
7
|
+
'te',
|
|
8
|
+
'trailer',
|
|
9
|
+
'upgrade',
|
|
10
|
+
'accept-encoding',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const filteredResponseHeaders = new Set([
|
|
14
|
+
'connection',
|
|
15
|
+
'content-length',
|
|
16
|
+
'transfer-encoding',
|
|
17
|
+
'keep-alive',
|
|
18
|
+
'te',
|
|
19
|
+
'trailer',
|
|
20
|
+
'upgrade',
|
|
21
|
+
'www-authenticate',
|
|
22
|
+
'content-encoding',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const collectForwardProxyHeaders = (requestHeaders, authHeaders = {}) => {
|
|
26
|
+
const headers = {};
|
|
27
|
+
|
|
28
|
+
for (const [key, value] of Object.entries(requestHeaders || {})) {
|
|
29
|
+
if (!value) continue;
|
|
30
|
+
const normalizedKey = key.toLowerCase();
|
|
31
|
+
if (filteredRequestHeaders.has(normalizedKey)) continue;
|
|
32
|
+
headers[normalizedKey] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (authHeaders.Authorization) {
|
|
36
|
+
headers.Authorization = authHeaders.Authorization;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return headers;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const shouldForwardProxyResponseHeader = (key) => {
|
|
43
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return !filteredResponseHeaders.has(key.toLowerCase());
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const applyForwardProxyResponseHeaders = (responseHeaders, response) => {
|
|
51
|
+
if (!responseHeaders || typeof response?.setHeader !== 'function') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const [key, value] of responseHeaders.entries()) {
|
|
56
|
+
if (!shouldForwardProxyResponseHeader(key)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
response.setHeader(key, value);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyForwardProxyResponseHeaders,
|
|
5
|
+
collectForwardProxyHeaders,
|
|
6
|
+
shouldForwardProxyResponseHeader,
|
|
7
|
+
} from './proxy-headers.js';
|
|
8
|
+
|
|
9
|
+
describe('OpenCode proxy header handling', () => {
|
|
10
|
+
it('drops accept-encoding from forwarded request headers', () => {
|
|
11
|
+
const headers = collectForwardProxyHeaders({
|
|
12
|
+
accept: 'application/json',
|
|
13
|
+
'accept-encoding': 'gzip, deflate, br',
|
|
14
|
+
connection: 'keep-alive',
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
expect(headers.accept).toBe('application/json');
|
|
18
|
+
expect(headers['accept-encoding']).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('drops content-encoding from forwarded response headers', () => {
|
|
22
|
+
expect(shouldForwardProxyResponseHeader('content-encoding')).toBe(false);
|
|
23
|
+
expect(shouldForwardProxyResponseHeader('Content-Encoding')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('drops transfer-encoding from forwarded response headers', () => {
|
|
27
|
+
expect(shouldForwardProxyResponseHeader('transfer-encoding')).toBe(false);
|
|
28
|
+
expect(shouldForwardProxyResponseHeader('Transfer-Encoding')).toBe(false);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('still keeps ordinary response headers', () => {
|
|
32
|
+
expect(shouldForwardProxyResponseHeader('content-type')).toBe(true);
|
|
33
|
+
expect(shouldForwardProxyResponseHeader('etag')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('applies upstream response headers to express response without content-encoding', () => {
|
|
37
|
+
const applied = [];
|
|
38
|
+
const response = {
|
|
39
|
+
setHeader(key, value) {
|
|
40
|
+
applied.push([key, value]);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
applyForwardProxyResponseHeaders(
|
|
45
|
+
new Headers({
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
etag: 'W/"abc"',
|
|
48
|
+
'content-encoding': 'gzip',
|
|
49
|
+
}),
|
|
50
|
+
response,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(applied).toEqual([
|
|
54
|
+
['content-type', 'application/json'],
|
|
55
|
+
['etag', 'W/"abc"'],
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
});
|