seahorse-bash-client 1.0.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/src/types.ts ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Seahorse Bash Client Types
3
+ */
4
+
5
+ // PTY Session Types
6
+ export type SessionStatus = 'running' | 'exited' | 'killed';
7
+
8
+ export interface PTYSession {
9
+ id: string;
10
+ name?: string;
11
+ pid: number;
12
+ command: string;
13
+ args: string[];
14
+ cwd: string;
15
+ status: SessionStatus;
16
+ createdAt: Date;
17
+ exitCode?: number;
18
+ signal?: string;
19
+ lineCount: number;
20
+ }
21
+
22
+ export interface OutputLine {
23
+ line: number;
24
+ text: string;
25
+ timestamp: Date;
26
+ }
27
+
28
+ // Tool Input Types
29
+ export interface BashSpawnInput {
30
+ command?: string;
31
+ args?: string[];
32
+ cwd?: string;
33
+ env?: Record<string, string>;
34
+ name?: string;
35
+ shell?: 'bash' | 'sh' | 'zsh';
36
+ cols?: number;
37
+ rows?: number;
38
+ notifyOnExit?: boolean;
39
+ }
40
+
41
+ export interface BashExecInput {
42
+ command: string;
43
+ cwd?: string;
44
+ timeout?: number;
45
+ env?: Record<string, string>;
46
+ }
47
+
48
+ export interface BashWriteInput {
49
+ sessionId: string;
50
+ data?: string;
51
+ specialKey?: 'ctrl+c' | 'ctrl+d' | 'ctrl+z' | 'enter' | 'tab' | 'up' | 'down' | 'left' | 'right';
52
+ }
53
+
54
+ export interface BashReadInput {
55
+ sessionId: string;
56
+ offset?: number;
57
+ limit?: number;
58
+ pattern?: string;
59
+ ignoreCase?: boolean;
60
+ since?: string;
61
+ tail?: boolean;
62
+ }
63
+
64
+ export interface BashListInput {
65
+ status?: 'all' | 'running' | 'exited' | 'killed';
66
+ includeOutput?: boolean;
67
+ outputLines?: number;
68
+ }
69
+
70
+ export interface BashKillInput {
71
+ sessionId: string;
72
+ signal?: 'SIGTERM' | 'SIGKILL' | 'SIGINT';
73
+ cleanup?: boolean;
74
+ gracePeriod?: number;
75
+ }
76
+
77
+ // Tool Response Types
78
+ export interface BashSpawnResponse {
79
+ sessionId: string;
80
+ pid: number;
81
+ command: string;
82
+ status: SessionStatus;
83
+ message: string;
84
+ }
85
+
86
+ export interface BashExecResponse {
87
+ exitCode: number;
88
+ stdout: string;
89
+ stderr: string;
90
+ duration: number;
91
+ timedOut: boolean;
92
+ }
93
+
94
+ export interface BashWriteResponse {
95
+ success: boolean;
96
+ bytesWritten: number;
97
+ sessionStatus: SessionStatus;
98
+ }
99
+
100
+ export interface BashReadResponse {
101
+ sessionId: string;
102
+ lines: Array<{ line: number; text: string; timestamp: string }>;
103
+ totalLines: number;
104
+ hasMore: boolean;
105
+ sessionStatus: SessionStatus;
106
+ }
107
+
108
+ export interface BashListResponse {
109
+ sessions: Array<{
110
+ sessionId: string;
111
+ name?: string;
112
+ pid: number;
113
+ command: string;
114
+ status: SessionStatus;
115
+ createdAt: string;
116
+ lineCount: number;
117
+ cwd: string;
118
+ recentOutput?: string[];
119
+ }>;
120
+ summary: {
121
+ total: number;
122
+ running: number;
123
+ exited: number;
124
+ killed: number;
125
+ };
126
+ }
127
+
128
+ export interface BashKillResponse {
129
+ sessionId: string;
130
+ exitCode?: number;
131
+ signal: string;
132
+ cleaned: boolean;
133
+ finalLineCount: number;
134
+ message: string;
135
+ }
136
+
137
+ // WebSocket Protocol Types
138
+ export interface ToolDefinition {
139
+ name: string;
140
+ description: string;
141
+ inputSchema: object;
142
+ }
143
+
144
+ export interface RegisterMessage {
145
+ type: 'registration';
146
+ tools: ToolDefinition[];
147
+ metadata: {
148
+ version: string;
149
+ hostname: string;
150
+ platform: string;
151
+ shell: string;
152
+ };
153
+ }
154
+
155
+ export interface ToolCallMessage {
156
+ type: 'tool_call';
157
+ request_id: string;
158
+ method: string;
159
+ params: {
160
+ name: string;
161
+ arguments: Record<string, unknown>;
162
+ };
163
+ }
164
+
165
+ export interface ToolResponseMessage {
166
+ type: 'tool_response';
167
+ request_id: string;
168
+ content: Array<{ type: 'text'; text: string }>;
169
+ isError: boolean;
170
+ error?: string;
171
+ }
172
+
173
+ export interface NotificationMessage {
174
+ type: 'notification';
175
+ method: string;
176
+ params: Record<string, unknown>;
177
+ }
178
+
179
+ export interface HeartbeatMessage {
180
+ type: 'heartbeat';
181
+ }
182
+
183
+ export interface HeartbeatAckMessage {
184
+ type: 'heartbeat_ack';
185
+ }
186
+
187
+ export interface RegistrationAckMessage {
188
+ type: 'registration_ack';
189
+ server_name: string;
190
+ registered_tools: number;
191
+ tool_names: string[];
192
+ }
193
+
194
+ export interface ErrorMessage {
195
+ type: 'error';
196
+ error: string;
197
+ }
198
+
199
+ export type WebSocketMessage =
200
+ | RegisterMessage
201
+ | ToolCallMessage
202
+ | ToolResponseMessage
203
+ | NotificationMessage
204
+ | HeartbeatMessage
205
+ | HeartbeatAckMessage
206
+ | RegistrationAckMessage
207
+ | ErrorMessage;
208
+
209
+ // Configuration
210
+ export interface ClientConfig {
211
+ serverUrl: string;
212
+ serverName: string;
213
+ reconnectInterval?: number;
214
+ heartbeatInterval?: number;
215
+ maxOutputLines?: number;
216
+ defaultShell?: string;
217
+ allowedCommands?: string[];
218
+ blockedCommands?: string[];
219
+ allowedCwdPaths?: string[];
220
+ insecure?: boolean; // Skip SSL certificate verification
221
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * WebSocket Client - Connects to Seahorse Agent and handles MCP tool calls
3
+ */
4
+
5
+ import WebSocket from 'ws';
6
+ import { EventEmitter } from 'events';
7
+ import os from 'os';
8
+ import https from 'https';
9
+ import http from 'http';
10
+ import {
11
+ ClientConfig,
12
+ RegisterMessage,
13
+ ToolCallMessage,
14
+ ToolResponseMessage,
15
+ NotificationMessage,
16
+ WebSocketMessage,
17
+ } from './types';
18
+ import { PTYManager } from './pty-manager';
19
+ import { TOOL_DEFINITIONS } from './tools';
20
+
21
+ export class WebSocketClient extends EventEmitter {
22
+ private ws: WebSocket | null = null;
23
+ private config: ClientConfig;
24
+ private ptyManager: PTYManager;
25
+ private reconnectTimer: NodeJS.Timeout | null = null;
26
+ private heartbeatTimer: NodeJS.Timeout | null = null;
27
+ private isConnected = false;
28
+ private isShuttingDown = false;
29
+
30
+ constructor(config: ClientConfig) {
31
+ super();
32
+ this.config = {
33
+ reconnectInterval: 5000,
34
+ heartbeatInterval: 30000,
35
+ maxOutputLines: 50000,
36
+ ...config,
37
+ };
38
+ this.ptyManager = new PTYManager({
39
+ maxOutputLines: this.config.maxOutputLines,
40
+ defaultShell: this.config.defaultShell,
41
+ });
42
+
43
+ // Forward session exit events
44
+ this.ptyManager.on('session:exited', (data) => {
45
+ this.sendNotification('session/exited', data);
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Connect to the Seahorse Agent
51
+ */
52
+ async connect(): Promise<void> {
53
+ if (this.isShuttingDown) return;
54
+
55
+ return new Promise((resolve, reject) => {
56
+ try {
57
+ const url = new URL(this.config.serverUrl);
58
+ const isSecure = url.protocol === 'wss:';
59
+
60
+ // Create agent that forces HTTP/1.1 (no ALPN for HTTP/2)
61
+ const agent = isSecure
62
+ ? new https.Agent({
63
+ rejectUnauthorized: !this.config.insecure,
64
+ // Force HTTP/1.1 by not advertising HTTP/2
65
+ ALPNProtocols: ['http/1.1'],
66
+ })
67
+ : new http.Agent();
68
+
69
+ const wsOptions: WebSocket.ClientOptions = {
70
+ agent,
71
+ perMessageDeflate: false,
72
+ };
73
+
74
+ this.ws = new WebSocket(this.config.serverUrl, wsOptions);
75
+
76
+ this.ws.on('open', () => {
77
+ this.isConnected = true;
78
+ this.emit('connected');
79
+ this.register();
80
+ this.startHeartbeat();
81
+ resolve();
82
+ });
83
+
84
+ this.ws.on('message', (data: Buffer) => {
85
+ this.handleMessage(data);
86
+ });
87
+
88
+ this.ws.on('close', (code: number, reason: Buffer) => {
89
+ this.isConnected = false;
90
+ this.stopHeartbeat();
91
+ this.emit('disconnected', { code, reason: reason.toString() });
92
+
93
+ if (!this.isShuttingDown) {
94
+ this.scheduleReconnect();
95
+ }
96
+ });
97
+
98
+ this.ws.on('error', (error: Error) => {
99
+ this.emit('error', error);
100
+ if (!this.isConnected) {
101
+ reject(error);
102
+ }
103
+ });
104
+ } catch (error) {
105
+ reject(error);
106
+ }
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Disconnect from the server
112
+ */
113
+ async disconnect(): Promise<void> {
114
+ this.isShuttingDown = true;
115
+
116
+ if (this.reconnectTimer) {
117
+ clearTimeout(this.reconnectTimer);
118
+ this.reconnectTimer = null;
119
+ }
120
+
121
+ this.stopHeartbeat();
122
+
123
+ // Shutdown all PTY sessions
124
+ await this.ptyManager.shutdown();
125
+
126
+ if (this.ws) {
127
+ this.ws.close(1000, 'Client shutdown');
128
+ this.ws = null;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Register tools with the agent
134
+ */
135
+ private register(): void {
136
+ // Note: Server expects "registration" type, not "register"
137
+ const message = {
138
+ type: 'registration',
139
+ tools: TOOL_DEFINITIONS,
140
+ metadata: {
141
+ version: '1.0.0',
142
+ hostname: os.hostname(),
143
+ platform: process.platform,
144
+ shell: this.config.defaultShell || process.env.SHELL || '/bin/bash',
145
+ },
146
+ };
147
+
148
+ this.send(message as any);
149
+ }
150
+
151
+ /**
152
+ * Handle incoming WebSocket messages
153
+ */
154
+ private handleMessage(data: Buffer): void {
155
+ try {
156
+ const message: WebSocketMessage = JSON.parse(data.toString());
157
+
158
+ switch (message.type) {
159
+ case 'tool_call':
160
+ this.handleToolCall(message as ToolCallMessage);
161
+ break;
162
+ case 'heartbeat':
163
+ case 'heartbeat_ack':
164
+ // Respond to heartbeat, ignore ack
165
+ if (message.type === 'heartbeat') {
166
+ this.send({ type: 'heartbeat' });
167
+ }
168
+ break;
169
+ case 'registration_ack':
170
+ // Server acknowledged registration
171
+ const ackData = message as { type: string; server_name: string; registered_tools: number; tool_names: string[] };
172
+ this.emit('registered', {
173
+ serverName: ackData.server_name,
174
+ tools: TOOL_DEFINITIONS,
175
+ registeredCount: ackData.registered_tools,
176
+ });
177
+ break;
178
+ case 'error':
179
+ const errorData = message as { type: string; error: string };
180
+ this.emit('error', new Error(`Server error: ${errorData.error}`));
181
+ break;
182
+ default:
183
+ this.emit('message', message);
184
+ }
185
+ } catch (error) {
186
+ this.emit('error', new Error(`Failed to parse message: ${error}`));
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Handle a tool call from the agent
192
+ */
193
+ private async handleToolCall(message: ToolCallMessage): Promise<void> {
194
+ const { request_id, params } = message;
195
+ const { name, arguments: args } = params;
196
+
197
+ this.emit('tool:call', { name, args, requestId: request_id });
198
+
199
+ try {
200
+ const result = await this.executeTool(name, args);
201
+ this.sendToolResponse(request_id, result, false);
202
+ this.emit('tool:success', { name, requestId: request_id });
203
+ } catch (error) {
204
+ const errorMessage = error instanceof Error ? error.message : String(error);
205
+ this.sendToolResponse(request_id, { error: errorMessage }, true, errorMessage);
206
+ this.emit('tool:error', { name, requestId: request_id, error: errorMessage });
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Execute a tool
212
+ */
213
+ private async executeTool(
214
+ name: string,
215
+ args: Record<string, unknown>
216
+ ): Promise<object> {
217
+ // Validate allowed commands if configured
218
+ if (this.config.allowedCommands && name.startsWith('bash_')) {
219
+ const command = (args.command as string) || '';
220
+ const isAllowed = this.config.allowedCommands.some(
221
+ (allowed) => command.startsWith(allowed) || command === allowed
222
+ );
223
+ if (!isAllowed) {
224
+ throw new Error(`Command not in allowed list: ${command}`);
225
+ }
226
+ }
227
+
228
+ // Validate blocked commands if configured
229
+ if (this.config.blockedCommands && name.startsWith('bash_')) {
230
+ const command = (args.command as string) || '';
231
+ const isBlocked = this.config.blockedCommands.some(
232
+ (blocked) => command.includes(blocked) || command.startsWith(blocked)
233
+ );
234
+ if (isBlocked) {
235
+ throw new Error(`Command is blocked: ${command}`);
236
+ }
237
+ }
238
+
239
+ // Validate cwd if restrictions are configured
240
+ if (this.config.allowedCwdPaths && args.cwd) {
241
+ const cwd = args.cwd as string;
242
+ const isAllowed = this.config.allowedCwdPaths.some((allowed) => cwd.startsWith(allowed));
243
+ if (!isAllowed) {
244
+ throw new Error(`Working directory not allowed: ${cwd}`);
245
+ }
246
+ }
247
+
248
+ switch (name) {
249
+ case 'bash_spawn':
250
+ return this.ptyManager.spawn(args as any);
251
+
252
+ case 'bash_exec':
253
+ return this.ptyManager.exec(args as any);
254
+
255
+ case 'bash_write':
256
+ return this.ptyManager.write(args as any);
257
+
258
+ case 'bash_read':
259
+ return this.ptyManager.read(args as any);
260
+
261
+ case 'bash_list':
262
+ return this.ptyManager.list(args as any);
263
+
264
+ case 'bash_kill':
265
+ return this.ptyManager.kill(args as any);
266
+
267
+ default:
268
+ throw new Error(`Unknown tool: ${name}`);
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Send a tool response
274
+ */
275
+ private sendToolResponse(
276
+ requestId: string,
277
+ result: object,
278
+ isError: boolean,
279
+ errorMessage?: string
280
+ ): void {
281
+ const response: ToolResponseMessage = {
282
+ type: 'tool_response',
283
+ request_id: requestId,
284
+ content: [
285
+ {
286
+ type: 'text',
287
+ text: JSON.stringify(result, null, 2),
288
+ },
289
+ ],
290
+ isError,
291
+ ...(errorMessage && { error: errorMessage }),
292
+ };
293
+
294
+ this.send(response);
295
+ }
296
+
297
+ /**
298
+ * Send a notification to the agent
299
+ */
300
+ private sendNotification(method: string, params: Record<string, unknown>): void {
301
+ if (!this.isConnected) return;
302
+
303
+ const notification: NotificationMessage = {
304
+ type: 'notification',
305
+ method,
306
+ params,
307
+ };
308
+
309
+ this.send(notification);
310
+ this.emit('notification:sent', { method, params });
311
+ }
312
+
313
+ /**
314
+ * Send a message through the WebSocket
315
+ */
316
+ private send(message: WebSocketMessage): void {
317
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
318
+ this.ws.send(JSON.stringify(message));
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Schedule a reconnection attempt
324
+ */
325
+ private scheduleReconnect(): void {
326
+ if (this.reconnectTimer) return;
327
+
328
+ this.emit('reconnecting', { interval: this.config.reconnectInterval });
329
+
330
+ this.reconnectTimer = setTimeout(async () => {
331
+ this.reconnectTimer = null;
332
+ try {
333
+ await this.connect();
334
+ } catch (error) {
335
+ // Will retry via close handler
336
+ }
337
+ }, this.config.reconnectInterval);
338
+ }
339
+
340
+ /**
341
+ * Start heartbeat timer
342
+ */
343
+ private startHeartbeat(): void {
344
+ this.heartbeatTimer = setInterval(() => {
345
+ if (this.isConnected) {
346
+ this.send({ type: 'heartbeat' });
347
+ }
348
+ }, this.config.heartbeatInterval);
349
+ }
350
+
351
+ /**
352
+ * Stop heartbeat timer
353
+ */
354
+ private stopHeartbeat(): void {
355
+ if (this.heartbeatTimer) {
356
+ clearInterval(this.heartbeatTimer);
357
+ this.heartbeatTimer = null;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Get connection status
363
+ */
364
+ get connected(): boolean {
365
+ return this.isConnected;
366
+ }
367
+
368
+ /**
369
+ * Get PTY manager for direct access
370
+ */
371
+ get pty(): PTYManager {
372
+ return this.ptyManager;
373
+ }
374
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true
16
+ },
17
+ "include": ["src/**/*"],
18
+ "exclude": ["node_modules", "dist"]
19
+ }
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ testTimeout: 30000,
7
+ hookTimeout: 10000,
8
+ include: ['src/**/*.test.ts'],
9
+ },
10
+ });