@dmsdc-ai/aigentry-telepty 0.1.68 → 0.1.70
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/cli.js +129 -52
- package/cross-machine.js +174 -128
- package/daemon.js +230 -48
- package/interactive-terminal.js +46 -1
- package/package.json +1 -1
- package/terminal-backend.js +137 -0
package/cross-machine.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { spawn } = require('child_process');
|
|
3
|
+
const { execSync, spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
|
|
8
8
|
const PEERS_PATH = path.join(os.homedir(), '.telepty', 'peers.json');
|
|
9
|
-
const
|
|
9
|
+
const CONTROL_DIR = path.join(os.homedir(), '.telepty', 'ssh');
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
|
|
11
|
+
// SSH ControlMaster socket path pattern
|
|
12
|
+
function controlPath(target) {
|
|
13
|
+
return path.join(CONTROL_DIR, `ctrl-${target.replace(/[^a-zA-Z0-9@.-]/g, '_')}`);
|
|
14
|
+
}
|
|
13
15
|
|
|
14
16
|
function loadPeers() {
|
|
15
17
|
try {
|
|
@@ -25,155 +27,188 @@ function savePeers(data) {
|
|
|
25
27
|
} catch {}
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
let port = BASE_LOCAL_PORT;
|
|
31
|
-
while (usedPorts.has(port)) port++;
|
|
32
|
-
return port;
|
|
33
|
-
}
|
|
30
|
+
// In-memory active peers
|
|
31
|
+
const activePeers = new Map(); // name -> { target, controlSocket, connectedAt, machineId }
|
|
34
32
|
|
|
35
33
|
/**
|
|
36
|
-
* Connect to a remote machine via SSH
|
|
37
|
-
* @param {string} target - "user@host" or "host" (uses current user)
|
|
38
|
-
* @param {object} options - { name, port }
|
|
39
|
-
* @returns {Promise<object>} - { success, name, localPort, machineId, version } or { success: false, error }
|
|
34
|
+
* Connect to a remote machine via SSH ControlMaster.
|
|
40
35
|
*/
|
|
41
36
|
async function connect(target, options = {}) {
|
|
42
|
-
const remotePort = options.port || 3848;
|
|
43
|
-
const localPort = getNextLocalPort();
|
|
44
|
-
|
|
45
|
-
// Parse target
|
|
46
37
|
let sshTarget = target;
|
|
47
38
|
if (!target.includes('@')) {
|
|
48
39
|
sshTarget = `${os.userInfo().username}@${target}`;
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
const name = options.name || target.split('@').pop().split('.')[0];
|
|
42
|
+
const name = options.name || target.split('@').pop().split('.')[0];
|
|
52
43
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const existing = activeTunnels.get(name);
|
|
56
|
-
return { success: false, error: `Already connected to ${name} on port ${existing.localPort}` };
|
|
44
|
+
if (activePeers.has(name)) {
|
|
45
|
+
return { success: false, error: `Already connected to ${name}` };
|
|
57
46
|
}
|
|
58
47
|
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
'-o', 'ServerAliveInterval=30', // Keep alive
|
|
64
|
-
'-o', 'ServerAliveCountMax=3', // Disconnect after 3 missed keepalives
|
|
65
|
-
'-o', 'ExitOnForwardFailure=yes', // Fail if port forwarding fails
|
|
66
|
-
'-o', 'ConnectTimeout=10', // Connection timeout
|
|
67
|
-
'-o', 'StrictHostKeyChecking=accept-new', // Auto-accept new host keys
|
|
68
|
-
sshTarget
|
|
69
|
-
], {
|
|
70
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
|
-
detached: false
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
// Wait for tunnel to establish or fail
|
|
75
|
-
const result = await new Promise((resolve) => {
|
|
76
|
-
let stderr = '';
|
|
77
|
-
const timeout = setTimeout(() => {
|
|
78
|
-
// If process is still running after 5s, tunnel is up
|
|
79
|
-
if (!tunnel.killed && tunnel.exitCode === null) {
|
|
80
|
-
resolve({ success: true });
|
|
81
|
-
} else {
|
|
82
|
-
resolve({ success: false, error: stderr || 'Connection timeout' });
|
|
83
|
-
}
|
|
84
|
-
}, 5000);
|
|
85
|
-
|
|
86
|
-
tunnel.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
87
|
-
tunnel.on('exit', (code) => {
|
|
88
|
-
clearTimeout(timeout);
|
|
89
|
-
if (code !== 0) {
|
|
90
|
-
resolve({ success: false, error: stderr || `SSH exited with code ${code}` });
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
tunnel.on('error', (err) => {
|
|
94
|
-
clearTimeout(timeout);
|
|
95
|
-
resolve({ success: false, error: err.message });
|
|
96
|
-
});
|
|
97
|
-
});
|
|
48
|
+
// Ensure control directory exists
|
|
49
|
+
fs.mkdirSync(CONTROL_DIR, { recursive: true });
|
|
50
|
+
|
|
51
|
+
const ctrlPath = controlPath(sshTarget);
|
|
98
52
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
53
|
+
// Start SSH ControlMaster
|
|
54
|
+
try {
|
|
55
|
+
execSync([
|
|
56
|
+
'ssh', '-o', 'ControlMaster=auto',
|
|
57
|
+
'-o', `ControlPath=${ctrlPath}`,
|
|
58
|
+
'-o', 'ControlPersist=600',
|
|
59
|
+
'-o', 'ConnectTimeout=10',
|
|
60
|
+
'-o', 'ServerAliveInterval=30',
|
|
61
|
+
'-o', 'ServerAliveCountMax=3',
|
|
62
|
+
'-o', 'StrictHostKeyChecking=accept-new',
|
|
63
|
+
'-N', '-f', // Go to background
|
|
64
|
+
sshTarget
|
|
65
|
+
].join(' '), { timeout: 15000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return { success: false, error: `SSH connection failed: ${err.message}` };
|
|
102
68
|
}
|
|
103
69
|
|
|
104
|
-
// Verify remote
|
|
70
|
+
// Verify remote telepty is available
|
|
71
|
+
let machineId = name;
|
|
105
72
|
try {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const peerInfo = {
|
|
113
|
-
process: tunnel,
|
|
114
|
-
localPort,
|
|
115
|
-
target: sshTarget,
|
|
116
|
-
name,
|
|
117
|
-
machineId: meta.machine_id || name,
|
|
118
|
-
version: meta.version || 'unknown',
|
|
119
|
-
connectedAt: new Date().toISOString()
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
activeTunnels.set(name, peerInfo);
|
|
123
|
-
|
|
124
|
-
// Persist peer for reconnection
|
|
125
|
-
const peers = loadPeers();
|
|
126
|
-
peers.peers[name] = {
|
|
127
|
-
target: sshTarget,
|
|
128
|
-
remotePort,
|
|
129
|
-
lastConnected: peerInfo.connectedAt,
|
|
130
|
-
machineId: peerInfo.machineId
|
|
131
|
-
};
|
|
132
|
-
savePeers(peers);
|
|
133
|
-
|
|
134
|
-
// Monitor tunnel health
|
|
135
|
-
tunnel.on('exit', () => {
|
|
136
|
-
console.log(`[PEER] SSH tunnel to ${name} disconnected`);
|
|
137
|
-
activeTunnels.delete(name);
|
|
138
|
-
});
|
|
73
|
+
const output = execSync(
|
|
74
|
+
`ssh -o ControlPath=${ctrlPath} ${sshTarget} "hostname"`,
|
|
75
|
+
{ timeout: 5000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
76
|
+
).trim();
|
|
77
|
+
if (output) machineId = output;
|
|
78
|
+
} catch {}
|
|
139
79
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
80
|
+
// Verify telepty CLI is available on remote
|
|
81
|
+
try {
|
|
82
|
+
execSync(
|
|
83
|
+
`ssh -o ControlPath=${ctrlPath} ${sshTarget} "telepty list --json"`,
|
|
84
|
+
{ timeout: 10000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
85
|
+
);
|
|
147
86
|
} catch (err) {
|
|
148
|
-
|
|
149
|
-
|
|
87
|
+
// Clean up ControlMaster
|
|
88
|
+
try { execSync(`ssh -O exit -o ControlPath=${ctrlPath} ${sshTarget}`, { stdio: 'pipe' }); } catch {}
|
|
89
|
+
return { success: false, error: `Remote telepty not available: ${err.message}` };
|
|
150
90
|
}
|
|
91
|
+
|
|
92
|
+
const peerInfo = {
|
|
93
|
+
target: sshTarget,
|
|
94
|
+
controlSocket: ctrlPath,
|
|
95
|
+
name,
|
|
96
|
+
machineId,
|
|
97
|
+
connectedAt: new Date().toISOString()
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
activePeers.set(name, peerInfo);
|
|
101
|
+
|
|
102
|
+
// Persist peer
|
|
103
|
+
const peers = loadPeers();
|
|
104
|
+
peers.peers[name] = {
|
|
105
|
+
target: sshTarget,
|
|
106
|
+
lastConnected: peerInfo.connectedAt,
|
|
107
|
+
machineId
|
|
108
|
+
};
|
|
109
|
+
savePeers(peers);
|
|
110
|
+
|
|
111
|
+
return { success: true, name, machineId };
|
|
151
112
|
}
|
|
152
113
|
|
|
153
114
|
function disconnect(name) {
|
|
154
|
-
const
|
|
155
|
-
if (!
|
|
115
|
+
const peer = activePeers.get(name);
|
|
116
|
+
if (!peer) {
|
|
156
117
|
return { success: false, error: `Not connected to ${name}` };
|
|
157
118
|
}
|
|
158
|
-
|
|
159
|
-
|
|
119
|
+
|
|
120
|
+
// Close ControlMaster
|
|
121
|
+
try {
|
|
122
|
+
execSync(`ssh -O exit -o ControlPath=${peer.controlSocket} ${peer.target}`, {
|
|
123
|
+
timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
|
|
124
|
+
});
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
activePeers.delete(name);
|
|
160
128
|
return { success: true, name };
|
|
161
129
|
}
|
|
162
130
|
|
|
163
131
|
function disconnectAll() {
|
|
164
|
-
const names = [...
|
|
132
|
+
const names = [...activePeers.keys()];
|
|
165
133
|
names.forEach(name => disconnect(name));
|
|
166
134
|
return { disconnected: names };
|
|
167
135
|
}
|
|
168
136
|
|
|
137
|
+
/**
|
|
138
|
+
* List sessions on a remote peer via SSH.
|
|
139
|
+
* @returns {Array} sessions with host info
|
|
140
|
+
*/
|
|
141
|
+
function listRemoteSessions(name) {
|
|
142
|
+
const peer = activePeers.get(name);
|
|
143
|
+
if (!peer) return [];
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const output = execSync(
|
|
147
|
+
`ssh -o ControlPath=${peer.controlSocket} ${peer.target} "telepty list --json"`,
|
|
148
|
+
{ timeout: 10000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
149
|
+
);
|
|
150
|
+
const sessions = JSON.parse(output);
|
|
151
|
+
return sessions.map(s => ({ ...s, host: peer.target, peerName: name, remote: true }));
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Discover sessions across all connected peers.
|
|
159
|
+
* @returns {Array} all remote sessions
|
|
160
|
+
*/
|
|
161
|
+
function discoverAllRemoteSessions() {
|
|
162
|
+
const allSessions = [];
|
|
163
|
+
for (const [name] of activePeers) {
|
|
164
|
+
allSessions.push(...listRemoteSessions(name));
|
|
165
|
+
}
|
|
166
|
+
return allSessions;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Inject text into a remote session via SSH.
|
|
171
|
+
*/
|
|
172
|
+
function remoteInject(name, sessionId, prompt, options = {}) {
|
|
173
|
+
const peer = activePeers.get(name);
|
|
174
|
+
if (!peer) return { success: false, error: `Not connected to ${name}` };
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const escaped = prompt.replace(/'/g, "'\\''");
|
|
178
|
+
const fromFlag = options.from ? `--from '${options.from}'` : '';
|
|
179
|
+
const noEnterFlag = options.no_enter ? '--no-enter' : '';
|
|
180
|
+
execSync(
|
|
181
|
+
`ssh -o ControlPath=${peer.controlSocket} ${peer.target} "telepty inject ${noEnterFlag} ${fromFlag} '${sessionId}' '${escaped}'"`,
|
|
182
|
+
{ timeout: 15000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
183
|
+
);
|
|
184
|
+
return { success: true };
|
|
185
|
+
} catch (err) {
|
|
186
|
+
return { success: false, error: err.message };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Spawn an interactive SSH attach to a remote session.
|
|
192
|
+
* Returns the child process for stdin/stdout piping.
|
|
193
|
+
*/
|
|
194
|
+
function remoteAttach(name, sessionId) {
|
|
195
|
+
const peer = activePeers.get(name);
|
|
196
|
+
if (!peer) return null;
|
|
197
|
+
|
|
198
|
+
return spawn('ssh', [
|
|
199
|
+
'-o', `ControlPath=${peer.controlSocket}`,
|
|
200
|
+
'-t', // Force TTY allocation
|
|
201
|
+
peer.target,
|
|
202
|
+
'telepty', 'attach', sessionId
|
|
203
|
+
], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
204
|
+
}
|
|
205
|
+
|
|
169
206
|
function listActivePeers() {
|
|
170
|
-
return [...
|
|
207
|
+
return [...activePeers.entries()].map(([name, info]) => ({
|
|
171
208
|
name,
|
|
172
209
|
target: info.target,
|
|
173
|
-
localPort: info.localPort,
|
|
174
210
|
machineId: info.machineId,
|
|
175
|
-
connectedAt: info.connectedAt
|
|
176
|
-
host: `127.0.0.1:${info.localPort}`
|
|
211
|
+
connectedAt: info.connectedAt
|
|
177
212
|
}));
|
|
178
213
|
}
|
|
179
214
|
|
|
@@ -182,25 +217,31 @@ function listKnownPeers() {
|
|
|
182
217
|
}
|
|
183
218
|
|
|
184
219
|
/**
|
|
185
|
-
*
|
|
186
|
-
* @returns {
|
|
220
|
+
* Find which peer has a given session.
|
|
221
|
+
* @returns {{ peerName, peer } | null}
|
|
187
222
|
*/
|
|
223
|
+
function findSessionPeer(sessionId) {
|
|
224
|
+
for (const [name] of activePeers) {
|
|
225
|
+
const sessions = listRemoteSessions(name);
|
|
226
|
+
if (sessions.some(s => s.id === sessionId)) {
|
|
227
|
+
return { peerName: name, peer: activePeers.get(name) };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Backward compat - getConnectedHosts no longer returns HTTP hosts
|
|
234
|
+
// Instead returns peer names for SSH-based discovery
|
|
188
235
|
function getConnectedHosts() {
|
|
189
|
-
return [
|
|
236
|
+
return []; // No HTTP hosts - use discoverAllRemoteSessions() instead
|
|
190
237
|
}
|
|
191
238
|
|
|
192
|
-
/**
|
|
193
|
-
* Get connected host for a specific peer.
|
|
194
|
-
* @param {string} name - Peer name
|
|
195
|
-
* @returns {string|null} "127.0.0.1:PORT" or null
|
|
196
|
-
*/
|
|
197
239
|
function getPeerHost(name) {
|
|
198
|
-
|
|
199
|
-
return tunnel ? `127.0.0.1:${tunnel.localPort}` : null;
|
|
240
|
+
return null; // No HTTP host - use SSH direct
|
|
200
241
|
}
|
|
201
242
|
|
|
202
243
|
function removePeer(name) {
|
|
203
|
-
disconnect(name);
|
|
244
|
+
disconnect(name);
|
|
204
245
|
const peers = loadPeers();
|
|
205
246
|
delete peers.peers[name];
|
|
206
247
|
savePeers(peers);
|
|
@@ -217,5 +258,10 @@ module.exports = {
|
|
|
217
258
|
getPeerHost,
|
|
218
259
|
removePeer,
|
|
219
260
|
loadPeers,
|
|
261
|
+
listRemoteSessions,
|
|
262
|
+
discoverAllRemoteSessions,
|
|
263
|
+
remoteInject,
|
|
264
|
+
remoteAttach,
|
|
265
|
+
findSessionPeer,
|
|
220
266
|
PEERS_PATH
|
|
221
267
|
};
|