spck 0.3.1
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/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal service - manages PTY sessions with xterm-headless
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as pty from 'node-pty';
|
|
6
|
+
import XtermHeadlessModule from '@xterm/headless';
|
|
7
|
+
import SerializeAddonModule from '@xterm/addon-serialize';
|
|
8
|
+
import defaultShell from 'default-shell';
|
|
9
|
+
import { AuthenticatedSocket, ErrorCode, createRPCError } from '../types.js';
|
|
10
|
+
import { logTerminalRead, logTerminalWrite } from '../utils/logger.js';
|
|
11
|
+
|
|
12
|
+
const { Terminal: XtermHeadless } = XtermHeadlessModule as any;
|
|
13
|
+
const { SerializeAddon } = SerializeAddonModule as any;
|
|
14
|
+
|
|
15
|
+
// Hard limit on terminal buffer size to prevent memory exhaustion (DoS)
|
|
16
|
+
const MAX_BUFFER_LINES_HARD_LIMIT = 50000;
|
|
17
|
+
|
|
18
|
+
interface TerminalSession {
|
|
19
|
+
id: string;
|
|
20
|
+
pty: pty.IPty;
|
|
21
|
+
xterm: any;
|
|
22
|
+
serializeAddon: any;
|
|
23
|
+
cols: number;
|
|
24
|
+
rows: number;
|
|
25
|
+
exited: boolean;
|
|
26
|
+
exitCode?: number;
|
|
27
|
+
uid: string;
|
|
28
|
+
pendingWrites: number; // Track number of pending write operations
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class TerminalService {
|
|
32
|
+
private sessions: Map<string, TerminalSession> = new Map();
|
|
33
|
+
private activeTerminalId: string | null = null;
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
private getSocket: () => AuthenticatedSocket,
|
|
37
|
+
private maxTerminals: number = 10,
|
|
38
|
+
private maxBufferedLines: number = 10000,
|
|
39
|
+
private rootPath: string = process.cwd()
|
|
40
|
+
) {
|
|
41
|
+
// Enforce hard limit on buffer size to prevent memory exhaustion
|
|
42
|
+
if (this.maxBufferedLines > MAX_BUFFER_LINES_HARD_LIMIT) {
|
|
43
|
+
console.warn(
|
|
44
|
+
`Terminal buffer size ${this.maxBufferedLines} exceeds hard limit ${MAX_BUFFER_LINES_HARD_LIMIT}. ` +
|
|
45
|
+
`Capping at ${MAX_BUFFER_LINES_HARD_LIMIT} lines.`
|
|
46
|
+
);
|
|
47
|
+
this.maxBufferedLines = MAX_BUFFER_LINES_HARD_LIMIT;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get UID from socket with fallback
|
|
53
|
+
*/
|
|
54
|
+
private getUid(): string {
|
|
55
|
+
return this.getSocket().data?.uid || 'unknown';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Handle terminal RPC methods
|
|
60
|
+
*/
|
|
61
|
+
async handle(method: string, params: any): Promise<any> {
|
|
62
|
+
const uid = this.getUid();
|
|
63
|
+
let result: any;
|
|
64
|
+
let error: any;
|
|
65
|
+
|
|
66
|
+
// Define read and write operations
|
|
67
|
+
const readOps = ['list', 'refresh'];
|
|
68
|
+
const writeOps = ['create', 'destroy', 'activate', 'send', 'resize'];
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
switch (method) {
|
|
72
|
+
case 'create':
|
|
73
|
+
result = await this.create(params);
|
|
74
|
+
logTerminalWrite(method, params, uid, true, undefined, { terminalId: result });
|
|
75
|
+
return result;
|
|
76
|
+
case 'destroy':
|
|
77
|
+
result = await this.destroy(params);
|
|
78
|
+
logTerminalWrite(method, params, uid, true);
|
|
79
|
+
return result;
|
|
80
|
+
case 'activate':
|
|
81
|
+
result = await this.activate(params);
|
|
82
|
+
logTerminalWrite(method, params, uid, true);
|
|
83
|
+
return result;
|
|
84
|
+
case 'send':
|
|
85
|
+
result = await this.send(params);
|
|
86
|
+
logTerminalWrite(method, params, uid, true);
|
|
87
|
+
return result;
|
|
88
|
+
case 'resize':
|
|
89
|
+
result = await this.resize(params);
|
|
90
|
+
logTerminalWrite(method, params, uid, true);
|
|
91
|
+
return result;
|
|
92
|
+
case 'refresh':
|
|
93
|
+
result = await this.refresh(params);
|
|
94
|
+
logTerminalRead(method, params, uid, true);
|
|
95
|
+
return result;
|
|
96
|
+
case 'list':
|
|
97
|
+
result = await this.list();
|
|
98
|
+
logTerminalRead(method, params, uid, true, undefined, { count: result.length });
|
|
99
|
+
return result;
|
|
100
|
+
default:
|
|
101
|
+
throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: terminal.${method}`);
|
|
102
|
+
}
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
error = err;
|
|
105
|
+
// Log error based on operation type
|
|
106
|
+
if (readOps.includes(method)) {
|
|
107
|
+
logTerminalRead(method, params, uid, false, error);
|
|
108
|
+
} else if (writeOps.includes(method)) {
|
|
109
|
+
logTerminalWrite(method, params, uid, false, error);
|
|
110
|
+
}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create new terminal session
|
|
117
|
+
*/
|
|
118
|
+
private async create(params: { cols: number; rows: number }): Promise<string> {
|
|
119
|
+
const uid = this.getUid();
|
|
120
|
+
|
|
121
|
+
// Check terminal limit
|
|
122
|
+
const userTerminals = Array.from(this.sessions.values()).filter((s) => s.uid === uid);
|
|
123
|
+
|
|
124
|
+
if (userTerminals.length >= this.maxTerminals) {
|
|
125
|
+
throw createRPCError(
|
|
126
|
+
ErrorCode.TERMINAL_LIMIT_EXCEEDED,
|
|
127
|
+
`Terminal limit exceeded (max ${this.maxTerminals})`
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const terminalId = this.generateId();
|
|
132
|
+
|
|
133
|
+
// Get default shell for the platform
|
|
134
|
+
const shell = defaultShell;
|
|
135
|
+
|
|
136
|
+
// Spawn PTY process
|
|
137
|
+
let ptyProcess: pty.IPty;
|
|
138
|
+
try {
|
|
139
|
+
ptyProcess = pty.spawn(shell, [], {
|
|
140
|
+
name: 'xterm-256color',
|
|
141
|
+
cols: params.cols || 80,
|
|
142
|
+
rows: params.rows || 24,
|
|
143
|
+
cwd: this.rootPath,
|
|
144
|
+
env: process.env,
|
|
145
|
+
});
|
|
146
|
+
} catch (error: any) {
|
|
147
|
+
// Log full error details server-side for debugging
|
|
148
|
+
console.error('Failed to spawn terminal:', {
|
|
149
|
+
error: error.message,
|
|
150
|
+
shell,
|
|
151
|
+
cwd: this.rootPath,
|
|
152
|
+
stack: error.stack,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Send sanitized error to client (no system paths or stack traces)
|
|
156
|
+
throw createRPCError(
|
|
157
|
+
ErrorCode.INTERNAL_ERROR,
|
|
158
|
+
'Failed to spawn terminal. Please check that the shell is available and the working directory is accessible.'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Create headless xterm for buffer management
|
|
163
|
+
const xterm = new XtermHeadless({
|
|
164
|
+
cols: params.cols || 80,
|
|
165
|
+
rows: params.rows || 24,
|
|
166
|
+
// Enforce hard limit as defense in depth (should already be capped in constructor)
|
|
167
|
+
scrollback: Math.min(this.maxBufferedLines, MAX_BUFFER_LINES_HARD_LIMIT),
|
|
168
|
+
allowProposedApi: true
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const serializeAddon = new SerializeAddon();
|
|
172
|
+
xterm.loadAddon(serializeAddon);
|
|
173
|
+
|
|
174
|
+
// Connect PTY to xterm buffer
|
|
175
|
+
ptyProcess.onData((data) => {
|
|
176
|
+
const session = this.sessions.get(terminalId);
|
|
177
|
+
if (session) {
|
|
178
|
+
// Track pending write
|
|
179
|
+
session.pendingWrites++;
|
|
180
|
+
|
|
181
|
+
// Write to xterm with callback
|
|
182
|
+
xterm.write(data, () => {
|
|
183
|
+
// Decrement pending writes when complete
|
|
184
|
+
if (session.pendingWrites > 0) {
|
|
185
|
+
session.pendingWrites--;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// If this is the active terminal, stream to client
|
|
191
|
+
if (this.activeTerminalId === terminalId) {
|
|
192
|
+
this.getSocket().emit('rpc', {
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
method: 'terminal.output',
|
|
195
|
+
params: { terminalId, data },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Handle process exit
|
|
201
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
202
|
+
const session = this.sessions.get(terminalId);
|
|
203
|
+
if (session) {
|
|
204
|
+
session.exited = true;
|
|
205
|
+
session.exitCode = exitCode;
|
|
206
|
+
|
|
207
|
+
// Notify client
|
|
208
|
+
this.getSocket().emit('rpc', {
|
|
209
|
+
jsonrpc: '2.0',
|
|
210
|
+
method: 'terminal.exited',
|
|
211
|
+
params: { terminalId, exitCode },
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Store session
|
|
217
|
+
this.sessions.set(terminalId, {
|
|
218
|
+
id: terminalId,
|
|
219
|
+
pty: ptyProcess,
|
|
220
|
+
xterm,
|
|
221
|
+
serializeAddon,
|
|
222
|
+
cols: params.cols || 80,
|
|
223
|
+
rows: params.rows || 24,
|
|
224
|
+
exited: false,
|
|
225
|
+
uid,
|
|
226
|
+
pendingWrites: 0,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Send initial buffer
|
|
230
|
+
await this.sendBufferRefresh(terminalId);
|
|
231
|
+
|
|
232
|
+
return terminalId;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Destroy terminal session
|
|
237
|
+
*/
|
|
238
|
+
private async destroy(params: { terminalId: string }): Promise<void> {
|
|
239
|
+
const session = this.sessions.get(params.terminalId);
|
|
240
|
+
if (!session) {
|
|
241
|
+
throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Verify ownership
|
|
245
|
+
if (session.uid !== this.getUid()) {
|
|
246
|
+
throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Kill PTY process if still running
|
|
250
|
+
if (!session.exited) {
|
|
251
|
+
session.pty.kill();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Clean up
|
|
255
|
+
this.sessions.delete(params.terminalId);
|
|
256
|
+
|
|
257
|
+
if (this.activeTerminalId === params.terminalId) {
|
|
258
|
+
this.activeTerminalId = null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Activate terminal (start streaming)
|
|
264
|
+
*/
|
|
265
|
+
private async activate(params: { terminalId: string }): Promise<void> {
|
|
266
|
+
const session = this.sessions.get(params.terminalId);
|
|
267
|
+
if (!session) {
|
|
268
|
+
throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Verify ownership
|
|
272
|
+
if (session.uid !== this.getUid()) {
|
|
273
|
+
throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Update active terminal
|
|
277
|
+
this.activeTerminalId = params.terminalId;
|
|
278
|
+
|
|
279
|
+
// Send full buffer refresh
|
|
280
|
+
await this.sendBufferRefresh(params.terminalId);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Send input to terminal
|
|
285
|
+
*/
|
|
286
|
+
private async send(params: { terminalId: string; data: string }): Promise<void> {
|
|
287
|
+
const session = this.sessions.get(params.terminalId);
|
|
288
|
+
if (!session) {
|
|
289
|
+
throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Verify ownership
|
|
293
|
+
if (session.uid !== this.getUid()) {
|
|
294
|
+
throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (session.exited) {
|
|
298
|
+
throw createRPCError(
|
|
299
|
+
ErrorCode.TERMINAL_PROCESS_EXITED,
|
|
300
|
+
'Terminal process has exited'
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Write to PTY
|
|
305
|
+
session.pty.write(params.data);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Resize terminal
|
|
310
|
+
*/
|
|
311
|
+
private async resize(params: { terminalId: string; cols: number; rows: number }): Promise<void> {
|
|
312
|
+
const session = this.sessions.get(params.terminalId);
|
|
313
|
+
if (!session) {
|
|
314
|
+
throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Verify ownership
|
|
318
|
+
if (session.uid !== this.getUid()) {
|
|
319
|
+
throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Resize PTY
|
|
323
|
+
session.pty.resize(params.cols, params.rows);
|
|
324
|
+
|
|
325
|
+
// Resize xterm buffer
|
|
326
|
+
session.xterm.resize(params.cols, params.rows);
|
|
327
|
+
|
|
328
|
+
// Update session
|
|
329
|
+
session.cols = params.cols;
|
|
330
|
+
session.rows = params.rows;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Refresh terminal buffer
|
|
335
|
+
*/
|
|
336
|
+
private async refresh(params: { terminalId: string }): Promise<void> {
|
|
337
|
+
const session = this.sessions.get(params.terminalId);
|
|
338
|
+
if (!session) {
|
|
339
|
+
throw createRPCError(ErrorCode.TERMINAL_NOT_FOUND, 'Terminal not found');
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Verify ownership
|
|
343
|
+
if (session.uid !== this.getUid()) {
|
|
344
|
+
throw createRPCError(ErrorCode.PERMISSION_DENIED, 'Permission denied');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
await this.sendBufferRefresh(params.terminalId);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* List terminals for current user
|
|
352
|
+
*/
|
|
353
|
+
private async list(): Promise<string[]> {
|
|
354
|
+
const uid = this.getUid();
|
|
355
|
+
return Array.from(this.sessions.values())
|
|
356
|
+
.filter((s) => s.uid === uid)
|
|
357
|
+
.map((s) => s.id);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Send buffer refresh notification
|
|
362
|
+
*/
|
|
363
|
+
private async sendBufferRefresh(terminalId: string): Promise<void> {
|
|
364
|
+
const session = this.sessions.get(terminalId);
|
|
365
|
+
if (!session) return;
|
|
366
|
+
|
|
367
|
+
// Wait for pending writes to complete
|
|
368
|
+
const waitForWrites = async () => {
|
|
369
|
+
const maxWaitTime = 5000; // 5 seconds max
|
|
370
|
+
const checkInterval = 10; // Check every 10ms
|
|
371
|
+
let elapsed = 0;
|
|
372
|
+
|
|
373
|
+
while (session.pendingWrites > 0 && elapsed < maxWaitTime) {
|
|
374
|
+
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
|
375
|
+
elapsed += checkInterval;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (elapsed >= maxWaitTime) {
|
|
379
|
+
console.warn(`Timeout waiting for pending writes (${session.pendingWrites} remaining)`);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
await waitForWrites();
|
|
384
|
+
|
|
385
|
+
// Serialize xterm buffer
|
|
386
|
+
const buffer = session.serializeAddon.serialize();
|
|
387
|
+
// Send to client
|
|
388
|
+
this.getSocket().emit('rpc', {
|
|
389
|
+
jsonrpc: '2.0',
|
|
390
|
+
method: 'terminal.bufferRefresh',
|
|
391
|
+
params: { terminalId, buffer },
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Generate unique terminal ID
|
|
397
|
+
*/
|
|
398
|
+
private generateId(): string {
|
|
399
|
+
return `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Cleanup all terminals
|
|
404
|
+
*/
|
|
405
|
+
cleanup(): void {
|
|
406
|
+
for (const session of this.sessions.values()) {
|
|
407
|
+
if (!session.exited) {
|
|
408
|
+
session.pty.kill();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
this.sessions.clear();
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for BrowserProxyService edge cases
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import http from 'http';
|
|
7
|
+
import { BrowserProxyService } from '../BrowserProxyService.js';
|
|
8
|
+
import { ErrorCode } from '../../types.js';
|
|
9
|
+
|
|
10
|
+
function makeService() {
|
|
11
|
+
return new BrowserProxyService();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function startServer(handler: http.RequestListener): Promise<{ port: number; close: () => Promise<void> }> {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const server = http.createServer(handler);
|
|
17
|
+
server.listen(0, '127.0.0.1', () => {
|
|
18
|
+
const { port } = server.address() as { port: number };
|
|
19
|
+
resolve({ port, close: () => new Promise<void>((res) => server.close(() => res())) });
|
|
20
|
+
});
|
|
21
|
+
server.on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeParams(overrides: Record<string, any> = {}) {
|
|
26
|
+
return { requestId: 'req-1', url: 'http://localhost:3000/', method: 'GET', headers: {}, body: null, ...overrides };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeSocket(deviceId = 'test-device-1') {
|
|
30
|
+
return { data: { deviceId, uid: 'test-user' } } as any;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('BrowserProxyService', () => {
|
|
34
|
+
let service: BrowserProxyService;
|
|
35
|
+
beforeEach(() => { service = makeService(); });
|
|
36
|
+
|
|
37
|
+
it('rejects unknown RPC methods', async () => {
|
|
38
|
+
await expect(service.handle('unknown', makeParams(), makeSocket())).rejects.toMatchObject({ code: ErrorCode.METHOD_NOT_FOUND });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it.each([
|
|
42
|
+
['non-localhost hostname', 'http://example.com/'],
|
|
43
|
+
['internal IP', 'http://192.168.1.1:3000/'],
|
|
44
|
+
['0.0.0.0', 'http://0.0.0.0:3000/'],
|
|
45
|
+
])('rejects %s', async (_label, url) => {
|
|
46
|
+
await expect(service.handle('request', makeParams({ url }), makeSocket())).rejects.toMatchObject({ code: ErrorCode.PERMISSION_DENIED });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
['malformed URL', 'not-a-url', ErrorCode.INVALID_PARAMS],
|
|
51
|
+
['port 0', 'http://localhost:0/', ErrorCode.INVALID_PARAMS],
|
|
52
|
+
['port > 65535', 'http://localhost:99999/', ErrorCode.INVALID_PARAMS],
|
|
53
|
+
['TRACE method', 'http://localhost:3000/', ErrorCode.INVALID_PARAMS],
|
|
54
|
+
])('rejects %s', async (_label, urlOrMethod, code) => {
|
|
55
|
+
const isMethod = _label.includes('method');
|
|
56
|
+
await expect(
|
|
57
|
+
service.handle('request', makeParams(isMethod ? { method: 'TRACE' } : { url: urlOrMethod }), makeSocket())
|
|
58
|
+
).rejects.toMatchObject({ code });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('forwards POST body bytes and returns base64-encoded response', async () => {
|
|
62
|
+
let receivedBody = '';
|
|
63
|
+
const srv = await startServer((req, res) => {
|
|
64
|
+
req.on('data', (c) => { receivedBody += c.toString(); });
|
|
65
|
+
req.on('end', () => { res.writeHead(200); res.end('pong'); });
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
const result = await service.handle('request', makeParams({
|
|
69
|
+
url: `http://localhost:${srv.port}/`,
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: Array.from(Buffer.from('ping')),
|
|
72
|
+
}), makeSocket());
|
|
73
|
+
expect(receivedBody).toBe('ping');
|
|
74
|
+
expect(result.bodyEncoding).toBe('base64');
|
|
75
|
+
expect(Buffer.from(result.body, 'base64').toString()).toBe('pong');
|
|
76
|
+
} finally {
|
|
77
|
+
await srv.close();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('correctly encodes binary response body', async () => {
|
|
82
|
+
const binary = Buffer.from([0x00, 0x01, 0xff, 0xfe]);
|
|
83
|
+
const srv = await startServer((_req, res) => { res.writeHead(200); res.end(binary); });
|
|
84
|
+
try {
|
|
85
|
+
const result = await service.handle('request', makeParams({ url: `http://localhost:${srv.port}/` }), makeSocket());
|
|
86
|
+
expect(Buffer.from(result.body, 'base64')).toEqual(binary);
|
|
87
|
+
} finally {
|
|
88
|
+
await srv.close();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('strips hop-by-hop headers and forwards custom headers', async () => {
|
|
93
|
+
let received: http.IncomingHttpHeaders = {};
|
|
94
|
+
const srv = await startServer((req, res) => { received = req.headers; res.writeHead(200); res.end(); });
|
|
95
|
+
try {
|
|
96
|
+
await service.handle('request', makeParams({
|
|
97
|
+
url: `http://localhost:${srv.port}/`,
|
|
98
|
+
headers: { 'transfer-encoding': 'chunked', 'upgrade': 'websocket', 'x-custom': 'yes' },
|
|
99
|
+
}), makeSocket());
|
|
100
|
+
expect(received['transfer-encoding']).toBeUndefined();
|
|
101
|
+
expect(received['upgrade']).toBeUndefined();
|
|
102
|
+
expect(received['x-custom']).toBe('yes');
|
|
103
|
+
} finally {
|
|
104
|
+
await srv.close();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects with INTERNAL_ERROR on ECONNREFUSED', async () => {
|
|
109
|
+
await expect(
|
|
110
|
+
service.handle('request', makeParams({ url: 'http://localhost:19999/' }), makeSocket())
|
|
111
|
+
).rejects.toMatchObject({ code: ErrorCode.INTERNAL_ERROR });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects with OPERATION_TIMEOUT when server hangs', async () => {
|
|
115
|
+
const srv = await startServer(() => { /* hang */ });
|
|
116
|
+
try {
|
|
117
|
+
vi.spyOn(service as any, 'fetch').mockImplementation((...args: unknown[]) => {
|
|
118
|
+
const url = args[0] as URL;
|
|
119
|
+
return new Promise((_res, reject) => {
|
|
120
|
+
const req = http.request({ hostname: url.hostname, port: url.port, path: '/', timeout: 50 });
|
|
121
|
+
req.on('timeout', () => { req.destroy(); reject({ code: ErrorCode.OPERATION_TIMEOUT, message: 'timed out' }); });
|
|
122
|
+
req.on('error', reject);
|
|
123
|
+
req.end();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
await expect(service.handle('request', makeParams({ url: `http://localhost:${srv.port}/` }), makeSocket()))
|
|
127
|
+
.rejects.toMatchObject({ code: ErrorCode.OPERATION_TIMEOUT });
|
|
128
|
+
} finally {
|
|
129
|
+
vi.restoreAllMocks();
|
|
130
|
+
await srv.close();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('rejects responses exceeding 10 MB', async () => {
|
|
135
|
+
const srv = await startServer((_req, res) => {
|
|
136
|
+
res.writeHead(200);
|
|
137
|
+
const chunk = Buffer.alloc(1024 * 1024);
|
|
138
|
+
let sent = 0;
|
|
139
|
+
const write = () => {
|
|
140
|
+
while (sent < 11) {
|
|
141
|
+
if (!res.write(chunk)) { res.once('drain', write); return; }
|
|
142
|
+
sent++;
|
|
143
|
+
}
|
|
144
|
+
res.end();
|
|
145
|
+
};
|
|
146
|
+
write();
|
|
147
|
+
});
|
|
148
|
+
try {
|
|
149
|
+
await expect(service.handle('request', makeParams({ url: `http://localhost:${srv.port}/` }), makeSocket()))
|
|
150
|
+
.rejects.toMatchObject({ code: ErrorCode.INTERNAL_ERROR });
|
|
151
|
+
} finally {
|
|
152
|
+
await srv.close();
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|