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