@dmsdc-ai/aigentry-telepty 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/aigentry-telepty-0.0.4.tgz +0 -0
- package/auth.js +33 -0
- package/cli.js +226 -0
- package/daemon.js +159 -0
- package/install.sh +43 -0
- package/mcp.js +68 -0
- package/package.json +24 -0
- package/test-pty.js +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dmsdc
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
Binary file
|
package/auth.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { v4: uuidv4 } = require('uuid');
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR = path.join(os.homedir(), '.telepty');
|
|
7
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
8
|
+
|
|
9
|
+
function getConfig() {
|
|
10
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
11
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); // Restrict permissions
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
15
|
+
try {
|
|
16
|
+
const data = fs.readFileSync(CONFIG_FILE, 'utf8');
|
|
17
|
+
return JSON.parse(data);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
console.warn('⚠️ Warning: Failed to read config file, generating a new one.', e.message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Generate new config
|
|
24
|
+
const newConfig = {
|
|
25
|
+
authToken: uuidv4(),
|
|
26
|
+
createdAt: new Date().toISOString()
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(newConfig, null, 2), { mode: 0o600 });
|
|
30
|
+
return newConfig;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = { getConfig };
|
package/cli.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const WebSocket = require('ws');
|
|
5
|
+
const { execSync } = require('child_process');
|
|
6
|
+
const readline = require('readline');
|
|
7
|
+
const { getConfig } = require('./auth');
|
|
8
|
+
const args = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
// Support remote host via environment variable or default to localhost
|
|
11
|
+
let REMOTE_HOST = process.env.TELEPTY_HOST || '127.0.0.1';
|
|
12
|
+
const PORT = 3848;
|
|
13
|
+
let DAEMON_URL = `http://${REMOTE_HOST}:${PORT}`;
|
|
14
|
+
let WS_URL = `ws://${REMOTE_HOST}:${PORT}`;
|
|
15
|
+
|
|
16
|
+
const config = getConfig();
|
|
17
|
+
const TOKEN = config.authToken;
|
|
18
|
+
|
|
19
|
+
const fetchWithAuth = (url, options = {}) => {
|
|
20
|
+
const headers = { ...options.headers, 'x-telepty-token': TOKEN };
|
|
21
|
+
return fetch(url, { ...options, headers });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
async function discoverSessions() {
|
|
25
|
+
const hosts = ['127.0.0.1'];
|
|
26
|
+
try {
|
|
27
|
+
const tsStatus = execSync('tailscale status --json', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
28
|
+
const tsData = JSON.parse(tsStatus);
|
|
29
|
+
if (tsData && tsData.Peer) {
|
|
30
|
+
for (const peer of Object.values(tsData.Peer)) {
|
|
31
|
+
if (peer.Online && peer.TailscaleIPs && peer.TailscaleIPs.length > 0) {
|
|
32
|
+
hosts.push(peer.TailscaleIPs[0]);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
// Tailscale not available or not running, ignore
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const allSessions = [];
|
|
41
|
+
process.stdout.write('\x1b[36m🔍 Discovering active sessions across your Tailnet...\x1b[0m\n');
|
|
42
|
+
|
|
43
|
+
await Promise.all(hosts.map(async (host) => {
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetchWithAuth(`http://${host}:${PORT}/api/sessions`, {
|
|
46
|
+
signal: AbortSignal.timeout(1500)
|
|
47
|
+
});
|
|
48
|
+
if (res.ok) {
|
|
49
|
+
const sessions = await res.json();
|
|
50
|
+
sessions.forEach(s => {
|
|
51
|
+
allSessions.push({ host, ...s });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
// Ignore nodes that don't have telepty running
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
return allSessions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const cmd = args[0];
|
|
64
|
+
if (cmd === 'mcp') {
|
|
65
|
+
require('./mcp.js');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cmd === 'daemon') {
|
|
70
|
+
console.log('Starting telepty daemon...');
|
|
71
|
+
require('./daemon.js');
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (cmd === 'list') {
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetchWithAuth(`${DAEMON_URL}/api/sessions`);
|
|
78
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
79
|
+
const sessions = await res.json();
|
|
80
|
+
if (sessions.length === 0) { console.log('No active sessions found.'); return; }
|
|
81
|
+
console.log('\x1b[1mActive Sessions:\x1b[0m');
|
|
82
|
+
sessions.forEach(s => {
|
|
83
|
+
console.log(` - ID: \x1b[36m${s.id}\x1b[0m`);
|
|
84
|
+
console.log(` Command: ${s.command}`);
|
|
85
|
+
console.log(` CWD: ${s.cwd}`);
|
|
86
|
+
console.log(` Clients: ${s.active_clients}`);
|
|
87
|
+
console.log(` Started: ${new Date(s.createdAt).toLocaleString()}`);
|
|
88
|
+
console.log('');
|
|
89
|
+
});
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.error('❌ Failed to connect to daemon. Is it running? (run `telepty daemon`)');
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (cmd === 'spawn') {
|
|
97
|
+
const idIndex = args.indexOf('--id');
|
|
98
|
+
if (idIndex === -1 || !args[idIndex + 1]) { console.error('❌ Usage: telepty spawn --id <session_id> <command> [args...]'); process.exit(1); }
|
|
99
|
+
const sessionId = args[idIndex + 1];
|
|
100
|
+
const spawnArgs = args.filter((a, i) => a !== 'spawn' && i !== idIndex && i !== idIndex + 1);
|
|
101
|
+
if (spawnArgs.length === 0) { console.error('❌ Missing command. Example: telepty spawn --id "test" bash'); process.exit(1); }
|
|
102
|
+
const command = spawnArgs[0]; const cmdArgs = spawnArgs.slice(1);
|
|
103
|
+
|
|
104
|
+
const cols = process.stdout.columns || 80;
|
|
105
|
+
const rows = process.stdout.rows || 30;
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetchWithAuth(`${DAEMON_URL}/api/sessions/spawn`, {
|
|
109
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
110
|
+
body: JSON.stringify({ session_id: sessionId, command: command, args: cmdArgs, cwd: process.cwd(), cols, rows })
|
|
111
|
+
});
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
if (!res.ok) { console.error(`❌ Error: ${data.error}`); return; }
|
|
114
|
+
console.log(`✅ Session '\x1b[36m${data.session_id}\x1b[0m' spawned successfully.`);
|
|
115
|
+
} catch (e) { console.error('❌ Failed to connect to daemon. Is it running?'); }
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (cmd === 'attach') {
|
|
120
|
+
let sessionId = args[1];
|
|
121
|
+
let targetHost = REMOTE_HOST;
|
|
122
|
+
|
|
123
|
+
if (!sessionId) {
|
|
124
|
+
const sessions = await discoverSessions();
|
|
125
|
+
if (sessions.length === 0) {
|
|
126
|
+
console.log('❌ No active sessions found on any known networks.');
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
console.log('\n\x1b[1mAvailable Sessions:\x1b[0m');
|
|
131
|
+
sessions.forEach((s, i) => {
|
|
132
|
+
const hostLabel = s.host === '127.0.0.1' ? 'Local' : s.host;
|
|
133
|
+
console.log(` [${i + 1}] \x1b[36m${s.id}\x1b[0m (\x1b[33m${hostLabel}\x1b[0m) - ${s.command}`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
137
|
+
const answer = await new Promise(resolve => rl.question('\nSelect a session number to attach: ', resolve));
|
|
138
|
+
rl.close();
|
|
139
|
+
|
|
140
|
+
const idx = parseInt(answer) - 1;
|
|
141
|
+
if (isNaN(idx) || !sessions[idx]) {
|
|
142
|
+
console.error('❌ Invalid selection.');
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
sessionId = sessions[idx].id;
|
|
147
|
+
targetHost = sessions[idx].host;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const wsUrl = `ws://${targetHost}:${PORT}/api/sessions/${encodeURIComponent(sessionId)}?token=${encodeURIComponent(TOKEN)}`;
|
|
151
|
+
const ws = new WebSocket(wsUrl);
|
|
152
|
+
|
|
153
|
+
ws.on('open', () => {
|
|
154
|
+
console.log(`\x1b[32mConnected to session '${sessionId}' at ${targetHost}. Press Ctrl+C to detach.\x1b[0m\n`);
|
|
155
|
+
|
|
156
|
+
if (process.stdin.isTTY) {
|
|
157
|
+
process.stdin.setRawMode(true);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
process.stdin.on('data', (data) => {
|
|
161
|
+
ws.send(JSON.stringify({ type: 'input', data: data.toString() }));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const resizeHandler = () => {
|
|
165
|
+
ws.send(JSON.stringify({
|
|
166
|
+
type: 'resize',
|
|
167
|
+
cols: process.stdout.columns,
|
|
168
|
+
rows: process.stdout.rows
|
|
169
|
+
}));
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
process.stdout.on('resize', resizeHandler);
|
|
173
|
+
resizeHandler(); // Initial resize
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
ws.on('message', (message) => {
|
|
177
|
+
const { type, data } = JSON.parse(message);
|
|
178
|
+
if (type === 'output') {
|
|
179
|
+
process.stdout.write(data);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
ws.on('close', (code, reason) => {
|
|
184
|
+
if (process.stdin.isTTY) {
|
|
185
|
+
process.stdin.setRawMode(false);
|
|
186
|
+
}
|
|
187
|
+
console.log(`\n\x1b[33mDisconnected from session. (Code: ${code}, Reason: ${reason || 'None'})\x1b[0m`);
|
|
188
|
+
process.exit(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
ws.on('error', (err) => {
|
|
192
|
+
console.error('❌ WebSocket Error:', err.message);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (cmd === 'inject') {
|
|
200
|
+
const sessionId = args[1]; const prompt = args.slice(2).join(' ');
|
|
201
|
+
if (!sessionId || !prompt) { console.error('❌ Usage: telepty inject <session_id> "<prompt text>"'); process.exit(1); }
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetchWithAuth(`${DAEMON_URL}/api/sessions/${encodeURIComponent(sessionId)}/inject`, {
|
|
204
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt })
|
|
205
|
+
});
|
|
206
|
+
const data = await res.json();
|
|
207
|
+
if (!res.ok) { console.error(`❌ Error: ${data.error}`); return; }
|
|
208
|
+
console.log(`✅ Context injected successfully into '\x1b[36m${sessionId}\x1b[0m'.`);
|
|
209
|
+
} catch (e) { console.error('❌ Failed to connect to daemon. Is it running?'); }
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
console.log(`
|
|
214
|
+
\x1b[1maigentry-telepty\x1b[0m - Remote PTY Control
|
|
215
|
+
|
|
216
|
+
Usage:
|
|
217
|
+
telepty daemon Start the background daemon
|
|
218
|
+
telepty spawn --id <id> <command> [args...] Spawn a new background CLI
|
|
219
|
+
telepty list List all active sessions
|
|
220
|
+
telepty attach [id] Attach to a session (Interactive picker if no ID)
|
|
221
|
+
telepty inject <id> "<prompt>" Inject text into an active session
|
|
222
|
+
telepty mcp Start the MCP stdio server
|
|
223
|
+
`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main();
|
package/daemon.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const cors = require('cors');
|
|
3
|
+
const pty = require('node-pty');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const { WebSocketServer } = require('ws');
|
|
6
|
+
const { getConfig } = require('./auth');
|
|
7
|
+
|
|
8
|
+
const config = getConfig();
|
|
9
|
+
const EXPECTED_TOKEN = config.authToken;
|
|
10
|
+
|
|
11
|
+
const app = express();
|
|
12
|
+
app.use(cors());
|
|
13
|
+
app.use(express.json());
|
|
14
|
+
|
|
15
|
+
// Authentication Middleware
|
|
16
|
+
app.use((req, res, next) => {
|
|
17
|
+
const isLocalhost = req.ip === '127.0.0.1' || req.ip === '::1' || req.ip === '::ffff:127.0.0.1';
|
|
18
|
+
const isTailscale = req.ip && req.ip.startsWith('100.');
|
|
19
|
+
|
|
20
|
+
if (isLocalhost || isTailscale) {
|
|
21
|
+
return next(); // Trust local and Tailscale networks
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const token = req.headers['x-telepty-token'] || req.query.token;
|
|
25
|
+
if (token === EXPECTED_TOKEN) {
|
|
26
|
+
return next();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.warn(`[AUTH] Rejected unauthorized request from ${req.ip}`);
|
|
30
|
+
res.status(401).json({ error: 'Unauthorized: Invalid or missing token.' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const PORT = process.env.PORT || 3848;
|
|
34
|
+
|
|
35
|
+
const HOST = process.env.HOST || '0.0.0.0';
|
|
36
|
+
|
|
37
|
+
const sessions = {};
|
|
38
|
+
|
|
39
|
+
app.post('/api/sessions/spawn', (req, res) => {
|
|
40
|
+
const { session_id, command, args = [], cwd = process.cwd(), cols = 80, rows = 30 } = req.body;
|
|
41
|
+
if (!session_id) return res.status(400).json({ error: 'session_id is strictly required.' });
|
|
42
|
+
if (sessions[session_id]) return res.status(409).json({ error: `Session ID '${session_id}' is already active.` });
|
|
43
|
+
if (!command) return res.status(400).json({ error: 'command is required' });
|
|
44
|
+
|
|
45
|
+
const isWin = os.platform() === 'win32';
|
|
46
|
+
const shell = isWin ? (command === 'powershell' ? 'powershell.exe' : 'cmd.exe') : command;
|
|
47
|
+
const shellArgs = isWin ? (command === 'powershell' || command === 'cmd' ? args : ['/c', command, ...args]) : args;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
console.log(`[SPAWN] Spawning ${shell} with args:`, shellArgs, "in cwd:", cwd);
|
|
51
|
+
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
52
|
+
name: isWin ? 'Windows Terminal' : 'xterm-256color',
|
|
53
|
+
cols: parseInt(cols),
|
|
54
|
+
rows: parseInt(rows),
|
|
55
|
+
cwd,
|
|
56
|
+
env: { ...process.env, TERM: isWin ? undefined : 'xterm-256color' }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
sessions[session_id] = {
|
|
60
|
+
ptyProcess,
|
|
61
|
+
command,
|
|
62
|
+
cwd,
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
clients: new Set()
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
ptyProcess.onData((data) => {
|
|
68
|
+
sessions[session_id].clients.forEach(ws => {
|
|
69
|
+
if (ws.readyState === 1) ws.send(JSON.stringify({ type: 'output', data }));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
74
|
+
console.log(`[EXIT] Session ${session_id} exited with code ${exitCode}`);
|
|
75
|
+
sessions[session_id].clients.forEach(ws => ws.close(1000, 'Session exited'));
|
|
76
|
+
delete sessions[session_id];
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
console.log(`[SPAWN] Created session ${session_id} (${command})`);
|
|
80
|
+
res.status(201).json({ session_id, command, cwd });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
res.status(500).json({ error: err.message });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
app.get('/api/sessions', (req, res) => {
|
|
87
|
+
const list = Object.entries(sessions).map(([id, session]) => ({
|
|
88
|
+
id,
|
|
89
|
+
command: session.command,
|
|
90
|
+
cwd: session.cwd,
|
|
91
|
+
createdAt: session.createdAt,
|
|
92
|
+
active_clients: session.clients.size
|
|
93
|
+
}));
|
|
94
|
+
res.json(list);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
app.post('/api/sessions/:id/inject', (req, res) => {
|
|
98
|
+
const { id } = req.params;
|
|
99
|
+
const { prompt } = req.body;
|
|
100
|
+
const session = sessions[id];
|
|
101
|
+
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
102
|
+
if (!prompt) return res.status(400).json({ error: 'prompt is required' });
|
|
103
|
+
try {
|
|
104
|
+
session.ptyProcess.write(`${prompt}\r`);
|
|
105
|
+
console.log(`[INJECT] Wrote to session ${id}`);
|
|
106
|
+
res.json({ success: true });
|
|
107
|
+
} catch (err) {
|
|
108
|
+
res.status(500).json({ error: err.message });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const server = app.listen(PORT, HOST, () => {
|
|
113
|
+
console.log(`🚀 aigentry-telepty daemon listening on http://${HOST}:${PORT}`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const wss = new WebSocketServer({ server });
|
|
117
|
+
|
|
118
|
+
wss.on('connection', (ws, req) => {
|
|
119
|
+
const isLocalhost = req.socket.remoteAddress === '127.0.0.1' || req.socket.remoteAddress === '::1' || req.socket.remoteAddress === '::ffff:127.0.0.1';
|
|
120
|
+
const isTailscale = req.socket.remoteAddress && req.socket.remoteAddress.startsWith('100.');
|
|
121
|
+
|
|
122
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
123
|
+
const token = url.searchParams.get('token');
|
|
124
|
+
|
|
125
|
+
if (!isLocalhost && !isTailscale && token !== EXPECTED_TOKEN) {
|
|
126
|
+
console.warn(`[WS-AUTH] Rejected unauthorized WebSocket from ${req.socket.remoteAddress}`);
|
|
127
|
+
ws.close(1008, 'Unauthorized');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sessionId = url.pathname.split('/').pop();
|
|
132
|
+
const session = sessions[sessionId];
|
|
133
|
+
|
|
134
|
+
if (!session) {
|
|
135
|
+
ws.close(1008, 'Session not found');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
session.clients.add(ws);
|
|
140
|
+
console.log(`[WS] Client attached to session ${sessionId} (Total: ${session.clients.size})`);
|
|
141
|
+
|
|
142
|
+
ws.on('message', (message) => {
|
|
143
|
+
try {
|
|
144
|
+
const { type, data, cols, rows } = JSON.parse(message);
|
|
145
|
+
if (type === 'input') {
|
|
146
|
+
session.ptyProcess.write(data);
|
|
147
|
+
} else if (type === 'resize') {
|
|
148
|
+
session.ptyProcess.resize(cols, rows);
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error('[WS] Invalid message format', e);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
ws.on('close', () => {
|
|
156
|
+
session.clients.delete(ws);
|
|
157
|
+
console.log(`[WS] Client detached from session ${sessionId} (Total: ${session.clients.size})`);
|
|
158
|
+
});
|
|
159
|
+
});
|
package/install.sh
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
echo "🚀 Installing aigentry-telepty..."
|
|
5
|
+
if ! command -v npm &> /dev/null; then
|
|
6
|
+
echo "❌ Error: npm is not installed. Please install Node.js first."
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
|
|
10
|
+
npm install -g git+https://github.com/dmsdc-ai/aigentry-telepty.git
|
|
11
|
+
|
|
12
|
+
# Set up systemd service if systemd is available
|
|
13
|
+
if command -v systemctl &> /dev/null && [ -d "/etc/systemd/system" ] && [ "$EUID" -eq 0 ]; then
|
|
14
|
+
echo "⚙️ Setting up systemd service..."
|
|
15
|
+
TELEPTY_PATH=$(which telepty)
|
|
16
|
+
cat <<SYSTEMD_EOF > /etc/systemd/system/telepty.service
|
|
17
|
+
[Unit]
|
|
18
|
+
Description=Telepty Daemon
|
|
19
|
+
After=network.target tailscaled.service
|
|
20
|
+
|
|
21
|
+
[Service]
|
|
22
|
+
ExecStart=$TELEPTY_PATH daemon
|
|
23
|
+
Restart=always
|
|
24
|
+
User=$SUDO_USER
|
|
25
|
+
Environment=PATH=/usr/bin:/usr/local/bin:$PATH
|
|
26
|
+
Environment=NODE_ENV=production
|
|
27
|
+
|
|
28
|
+
[Install]
|
|
29
|
+
WantedBy=multi-user.target
|
|
30
|
+
SYSTEMD_EOF
|
|
31
|
+
|
|
32
|
+
systemctl daemon-reload
|
|
33
|
+
systemctl enable telepty
|
|
34
|
+
systemctl start telepty
|
|
35
|
+
echo "✅ Systemd service installed and started. Daemon will run automatically on boot."
|
|
36
|
+
else
|
|
37
|
+
echo "⚠️ Skipping systemd setup (requires root and systemd). Starting daemon in background..."
|
|
38
|
+
nohup telepty daemon > /dev/null 2>&1 &
|
|
39
|
+
echo "✅ Daemon started in background. (Note: It will not auto-start on reboot)"
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
echo "🎉 Installation complete!"
|
|
43
|
+
echo "You can now use 'telepty spawn --id <name> <command>' to create sessions."
|
package/mcp.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
|
|
2
|
+
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
|
|
3
|
+
const { CallToolRequestSchema, ListToolsRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
|
|
4
|
+
const { z } = require('zod');
|
|
5
|
+
const { zodToJsonSchema } = require('zod-to-json-schema');
|
|
6
|
+
const { getConfig } = require('./auth');
|
|
7
|
+
|
|
8
|
+
const config = getConfig();
|
|
9
|
+
const TOKEN = config.authToken;
|
|
10
|
+
|
|
11
|
+
const server = new Server({ name: 'telepty-mcp-server', version: '0.0.1' }, { capabilities: { tools: {} } });
|
|
12
|
+
|
|
13
|
+
const tools = [
|
|
14
|
+
{
|
|
15
|
+
name: 'telepty_list_remote_sessions',
|
|
16
|
+
description: 'List all active AI CLI sessions (PTYs) running on a remote telepty daemon. Used to discover available target session IDs.',
|
|
17
|
+
schema: z.object({ remote_url: z.string().describe('Tailscale IP/Host and port (e.g., 100.100.100.5:3848) of the remote daemon.') })
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'telepty_inject_context',
|
|
21
|
+
description: 'Inject a prompt or context into an active AI CLI session on a remote machine. WARNING: You MUST use telepty_list_remote_sessions first to find the exact session_id, and ask the user for confirmation if ambiguous.',
|
|
22
|
+
schema: z.object({ remote_url: z.string(), session_id: z.string().describe('The EXACT session ID.'), prompt: z.string().describe('Text to inject into stdin.') })
|
|
23
|
+
}
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
27
|
+
tools: tools.map(t => {
|
|
28
|
+
const schema = zodToJsonSchema(t.schema);
|
|
29
|
+
delete schema.$schema;
|
|
30
|
+
if (!schema.type) schema.type = 'object';
|
|
31
|
+
return { name: t.name, description: t.description, inputSchema: schema };
|
|
32
|
+
})
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
36
|
+
try {
|
|
37
|
+
const { name, arguments: args } = request.params;
|
|
38
|
+
if (name === 'telepty_list_remote_sessions') {
|
|
39
|
+
const baseUrl = args.remote_url.startsWith('http') ? args.remote_url : `http://${args.remote_url}`;
|
|
40
|
+
const res = await fetch(`${baseUrl}/api/sessions`, { headers: { 'x-telepty-token': TOKEN } });
|
|
41
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
42
|
+
const sessions = await res.json();
|
|
43
|
+
if (!sessions || sessions.length === 0) return { content: [{ type: 'text', text: `No active sessions found on ${args.remote_url}` }] };
|
|
44
|
+
let text = `Active sessions on ${args.remote_url}:\n\n`;
|
|
45
|
+
sessions.forEach(s => { text += `- ID: ${s.id}\n Command: ${s.command}\n Workspace: ${s.cwd}\n\n`; });
|
|
46
|
+
return { content: [{ type: 'text', text }] };
|
|
47
|
+
}
|
|
48
|
+
if (name === 'telepty_inject_context') {
|
|
49
|
+
const baseUrl = args.remote_url.startsWith('http') ? args.remote_url : `http://${args.remote_url}`;
|
|
50
|
+
const res = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(args.session_id)}/inject`, {
|
|
51
|
+
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-telepty-token': TOKEN }, body: JSON.stringify({ prompt: args.prompt })
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
55
|
+
return { content: [{ type: 'text', text: `✅ Successfully injected context into session '${args.session_id}'. The remote agent has been awakened.` }] };
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { content: [{ type: 'text', text: `❌ Error: ${err.message}` }], isError: true };
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function main() {
|
|
64
|
+
const transport = new StdioServerTransport();
|
|
65
|
+
await server.connect(transport);
|
|
66
|
+
console.error('Telepty MCP Server running on stdio');
|
|
67
|
+
}
|
|
68
|
+
main().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dmsdc-ai/aigentry-telepty",
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"main": "daemon.js",
|
|
5
|
+
"bin": {
|
|
6
|
+
"telepty": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [],
|
|
12
|
+
"author": "",
|
|
13
|
+
"license": "ISC",
|
|
14
|
+
"description": "",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
17
|
+
"cors": "^2.8.6",
|
|
18
|
+
"express": "^5.2.1",
|
|
19
|
+
"node-pty": "^1.2.0-beta.11",
|
|
20
|
+
"uuid": "^13.0.0",
|
|
21
|
+
"ws": "^8.19.0",
|
|
22
|
+
"zod": "^4.3.6"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/test-pty.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const pty = require('node-pty');
|
|
2
|
+
try {
|
|
3
|
+
const p = pty.spawn('/bin/bash', [], {
|
|
4
|
+
name: 'xterm-color',
|
|
5
|
+
cols: 80,
|
|
6
|
+
rows: 30,
|
|
7
|
+
cwd: process.env.HOME,
|
|
8
|
+
env: process.env
|
|
9
|
+
});
|
|
10
|
+
console.log('Success, PID:', p.pid);
|
|
11
|
+
p.kill();
|
|
12
|
+
} catch (e) {
|
|
13
|
+
console.error('Error:', e);
|
|
14
|
+
}
|