teleportation-cli 1.0.0 → 1.0.2
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/.claude/hooks/heartbeat.mjs +67 -2
- package/.claude/hooks/permission_request.mjs +55 -26
- package/.claude/hooks/pre_tool_use.mjs +29 -2
- package/.claude/hooks/session-register.mjs +64 -5
- package/.claude/hooks/stop.mjs +205 -1
- package/.claude/hooks/user_prompt_submit.mjs +111 -0
- package/README.md +36 -12
- package/lib/auth/claude-key-extractor.js +196 -0
- package/lib/auth/credentials.js +7 -2
- package/lib/cli/remote-commands.js +649 -0
- package/lib/daemon/teleportation-daemon.js +131 -41
- package/lib/install/installer.js +22 -7
- package/lib/machine-coders/claude-code-adapter.js +191 -37
- package/lib/remote/code-sync.js +213 -0
- package/lib/remote/init-script-robust.js +187 -0
- package/lib/remote/liveport-client.js +417 -0
- package/lib/remote/orchestrator.js +480 -0
- package/lib/remote/pr-creator.js +382 -0
- package/lib/remote/providers/base-provider.js +407 -0
- package/lib/remote/providers/daytona-provider.js +506 -0
- package/lib/remote/providers/fly-provider.js +611 -0
- package/lib/remote/providers/provider-factory.js +228 -0
- package/lib/remote/results-delivery.js +333 -0
- package/lib/remote/session-manager.js +273 -0
- package/lib/remote/state-capture.js +324 -0
- package/lib/remote/vault-client.js +478 -0
- package/lib/session/metadata.js +80 -49
- package/lib/session/mute-checker.js +2 -1
- package/lib/utils/vault-errors.js +353 -0
- package/package.json +5 -5
- package/teleportation-cli.cjs +417 -7
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base Provider Interface for Remote Development Environments
|
|
3
|
+
*
|
|
4
|
+
* All remote providers (Fly.io, Daytona, etc.) must implement this interface.
|
|
5
|
+
* This class provides common functionality for:
|
|
6
|
+
* - SSH key management
|
|
7
|
+
* - LivePort tunnel setup
|
|
8
|
+
* - Secret storage and retrieval
|
|
9
|
+
* - Resource cleanup
|
|
10
|
+
*
|
|
11
|
+
* @abstract
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class BaseProvider {
|
|
15
|
+
/**
|
|
16
|
+
* Initialize the base provider
|
|
17
|
+
* @param {Object} config - Provider configuration
|
|
18
|
+
* @param {VaultClient} config.vaultClient - Mech Vault client instance
|
|
19
|
+
* @param {LivePortClient} config.livePortClient - LivePort client instance
|
|
20
|
+
* @param {Object} [config.validationRules] - Custom validation rules for provider
|
|
21
|
+
*/
|
|
22
|
+
constructor(config) {
|
|
23
|
+
if (!config) {
|
|
24
|
+
throw new Error('Provider config is required');
|
|
25
|
+
}
|
|
26
|
+
if (!config.vaultClient) {
|
|
27
|
+
throw new Error('VaultClient is required in provider config');
|
|
28
|
+
}
|
|
29
|
+
if (!config.livePortClient) {
|
|
30
|
+
throw new Error('LivePortClient is required in provider config');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.config = config;
|
|
34
|
+
this.vaultClient = config.vaultClient;
|
|
35
|
+
this.livePortClient = config.livePortClient;
|
|
36
|
+
this.validationRules = config.validationRules || {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Create a remote machine/workspace
|
|
41
|
+
*
|
|
42
|
+
* @abstract
|
|
43
|
+
* @param {Object} sessionConfig - Session configuration
|
|
44
|
+
* @param {string} sessionConfig.sessionId - Unique session identifier
|
|
45
|
+
* @param {string} sessionConfig.repoUrl - Git repository URL
|
|
46
|
+
* @param {string} [sessionConfig.branch] - Git branch to checkout
|
|
47
|
+
* @param {string} sessionConfig.relayApiUrl - Relay API endpoint
|
|
48
|
+
* @param {string} sessionConfig.relayApiKey - Relay API key
|
|
49
|
+
* @param {string} sessionConfig.githubToken - GitHub personal access token
|
|
50
|
+
* @param {string} [sessionConfig.mechApiKey] - Mech API key for router
|
|
51
|
+
* @param {Object} [sessionConfig.additionalSecrets] - Additional secrets to store
|
|
52
|
+
* @returns {Promise<Object>} Machine details
|
|
53
|
+
* @returns {string} return.machineId - Unique machine identifier
|
|
54
|
+
* @returns {string} return.sshHost - SSH hostname
|
|
55
|
+
* @returns {number} return.sshPort - SSH port
|
|
56
|
+
* @returns {string} return.tunnelUrl - LivePort tunnel URL
|
|
57
|
+
* @returns {string} return.namespace - Vault namespace for secrets
|
|
58
|
+
* @returns {string[]} return.secretIds - Array of stored secret IDs
|
|
59
|
+
* @returns {string} return.sshKeyId - SSH key ID in Vault
|
|
60
|
+
* @returns {string} return.bridgeKeyId - LivePort bridge key ID
|
|
61
|
+
* @throws {Error} If machine creation fails
|
|
62
|
+
*/
|
|
63
|
+
async createMachine(sessionConfig) {
|
|
64
|
+
throw new Error('createMachine must be implemented');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Destroy the remote machine/workspace
|
|
69
|
+
*
|
|
70
|
+
* @abstract
|
|
71
|
+
* @param {string} machineId - Machine identifier
|
|
72
|
+
* @returns {Promise<boolean>} True if destroyed successfully
|
|
73
|
+
* @throws {Error} If machine destruction fails
|
|
74
|
+
*/
|
|
75
|
+
async destroyMachine(machineId) {
|
|
76
|
+
throw new Error('destroyMachine must be implemented');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get machine status
|
|
81
|
+
*
|
|
82
|
+
* @abstract
|
|
83
|
+
* @param {string} machineId - Machine identifier
|
|
84
|
+
* @returns {Promise<Object>} Machine status
|
|
85
|
+
* @returns {string} return.status - Machine status (running, stopped, error)
|
|
86
|
+
* @returns {string} [return.ip] - Machine IP address
|
|
87
|
+
* @returns {string} [return.uptime] - Machine uptime
|
|
88
|
+
* @throws {Error} If status retrieval fails
|
|
89
|
+
*/
|
|
90
|
+
async getMachineStatus(machineId) {
|
|
91
|
+
throw new Error('getMachineStatus must be implemented');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Execute command on remote machine
|
|
96
|
+
*
|
|
97
|
+
* @abstract
|
|
98
|
+
* @param {string} machineId - Machine identifier
|
|
99
|
+
* @param {string} command - Command to execute
|
|
100
|
+
* @returns {Promise<Object>} Command execution result
|
|
101
|
+
* @returns {string} return.stdout - Standard output
|
|
102
|
+
* @returns {string} return.stderr - Standard error
|
|
103
|
+
* @returns {number} return.exitCode - Exit code
|
|
104
|
+
* @throws {Error} If command execution fails
|
|
105
|
+
*/
|
|
106
|
+
async executeCommand(machineId, command) {
|
|
107
|
+
throw new Error('executeCommand must be implemented');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get machine logs
|
|
112
|
+
*
|
|
113
|
+
* @abstract
|
|
114
|
+
* @param {string} machineId - Machine identifier
|
|
115
|
+
* @param {Object} [options] - Log retrieval options
|
|
116
|
+
* @param {number} [options.tail] - Number of lines to retrieve
|
|
117
|
+
* @param {boolean} [options.follow] - Stream logs continuously
|
|
118
|
+
* @returns {Promise<Object>} Log data
|
|
119
|
+
* @returns {Array<string>} return.logs - Array of log lines
|
|
120
|
+
* @throws {Error} If log retrieval fails
|
|
121
|
+
*/
|
|
122
|
+
async getLogs(machineId, options = {}) {
|
|
123
|
+
throw new Error('getLogs must be implemented');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate session configuration before machine creation
|
|
128
|
+
*
|
|
129
|
+
* @protected
|
|
130
|
+
* @param {Object} sessionConfig - Session configuration to validate
|
|
131
|
+
* @throws {Error} If validation fails
|
|
132
|
+
*/
|
|
133
|
+
_validateSessionConfig(sessionConfig) {
|
|
134
|
+
if (!sessionConfig) {
|
|
135
|
+
throw new Error('Session config is required');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const requiredFields = ['sessionId', 'repoUrl', 'relayApiUrl', 'relayApiKey'];
|
|
139
|
+
for (const field of requiredFields) {
|
|
140
|
+
if (!sessionConfig[field]) {
|
|
141
|
+
throw new Error(`Session config missing required field: ${field}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validate sessionId format
|
|
146
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(sessionConfig.sessionId)) {
|
|
147
|
+
throw new Error('Invalid sessionId: must contain only alphanumeric characters, dashes, and underscores');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Validate repoUrl format and hostname
|
|
151
|
+
this._validateRepoUrl(sessionConfig.repoUrl);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Validate repository URL format and hostname
|
|
156
|
+
*
|
|
157
|
+
* @protected
|
|
158
|
+
* @param {string} repoUrl - Repository URL to validate
|
|
159
|
+
* @throws {Error} If URL is invalid or hostname not allowed
|
|
160
|
+
*/
|
|
161
|
+
_validateRepoUrl(repoUrl) {
|
|
162
|
+
let hostname;
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
if (repoUrl.startsWith('http://') || repoUrl.startsWith('https://')) {
|
|
166
|
+
// Parse HTTP(S) URLs
|
|
167
|
+
const url = new URL(repoUrl);
|
|
168
|
+
hostname = url.hostname;
|
|
169
|
+
} else if (repoUrl.startsWith('git@')) {
|
|
170
|
+
// Parse git@github.com:user/repo.git format
|
|
171
|
+
const match = repoUrl.match(/^git@([^:]+):(.+)$/);
|
|
172
|
+
if (!match) {
|
|
173
|
+
throw new Error('Invalid git URL format. Expected: git@hostname:user/repo.git');
|
|
174
|
+
}
|
|
175
|
+
hostname = match[1];
|
|
176
|
+
} else {
|
|
177
|
+
throw new Error('Invalid repoUrl: must start with http://, https://, or git@');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Validate against allowed Git hosts
|
|
181
|
+
const allowedHosts = this.validationRules.allowedGitHosts || [
|
|
182
|
+
'github.com',
|
|
183
|
+
'gitlab.com',
|
|
184
|
+
'bitbucket.org',
|
|
185
|
+
'git.sr.ht', // SourceHut
|
|
186
|
+
'codeberg.org',
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
if (!allowedHosts.includes(hostname)) {
|
|
190
|
+
throw new Error(`Git host not allowed: ${hostname}. Allowed hosts: ${allowedHosts.join(', ')}`);
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
if (error.message.includes('Invalid URL')) {
|
|
194
|
+
throw new Error(`Invalid repoUrl format: ${error.message}`);
|
|
195
|
+
}
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Validate machine ID format
|
|
202
|
+
*
|
|
203
|
+
* @protected
|
|
204
|
+
* @param {string} machineId - Machine identifier to validate
|
|
205
|
+
* @throws {Error} If validation fails
|
|
206
|
+
*/
|
|
207
|
+
_validateMachineId(machineId) {
|
|
208
|
+
if (!machineId || typeof machineId !== 'string' || machineId.trim() === '') {
|
|
209
|
+
throw new Error('Invalid machineId: must be a non-empty string');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Setup SSH access to the machine
|
|
215
|
+
*
|
|
216
|
+
* Generates an ED25519 SSH key pair via Vault and returns the credentials.
|
|
217
|
+
*
|
|
218
|
+
* @protected
|
|
219
|
+
* @param {string} machineId - Machine identifier
|
|
220
|
+
* @param {string} namespace - Vault namespace for the key
|
|
221
|
+
* @returns {Promise<Object>} SSH key details
|
|
222
|
+
* @returns {string} return.keyId - Vault secret ID for the SSH key
|
|
223
|
+
* @returns {string} return.publicKey - SSH public key
|
|
224
|
+
* @returns {string} return.privateKey - SSH private key
|
|
225
|
+
* @returns {string} return.fingerprint - SSH key fingerprint
|
|
226
|
+
* @throws {Error} If SSH key generation fails
|
|
227
|
+
*/
|
|
228
|
+
async _setupSSHAccess(machineId, namespace) {
|
|
229
|
+
try {
|
|
230
|
+
// Generate SSH key via Vault
|
|
231
|
+
const sshKey = await this.vaultClient.generateSSHKey('ed25519', {
|
|
232
|
+
namespace,
|
|
233
|
+
name: `machine-${machineId}`,
|
|
234
|
+
metadata: {
|
|
235
|
+
machineId,
|
|
236
|
+
provider: this.constructor.name,
|
|
237
|
+
createdAt: new Date().toISOString(),
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
keyId: sshKey.secretId,
|
|
243
|
+
publicKey: sshKey.publicKey,
|
|
244
|
+
privateKey: sshKey.privateKey,
|
|
245
|
+
fingerprint: sshKey.fingerprint,
|
|
246
|
+
};
|
|
247
|
+
} catch (error) {
|
|
248
|
+
throw new Error(`Failed to setup SSH access: ${error.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Setup LivePort tunnel for the machine
|
|
254
|
+
*
|
|
255
|
+
* Creates a bridge key and generates tunnel configuration for the remote machine.
|
|
256
|
+
*
|
|
257
|
+
* @protected
|
|
258
|
+
* @param {string} sessionId - Session identifier
|
|
259
|
+
* @param {string} machineId - Machine identifier
|
|
260
|
+
* @returns {Promise<Object>} Tunnel details
|
|
261
|
+
* @returns {string} return.bridgeKey - LivePort bridge key
|
|
262
|
+
* @returns {string} return.keyId - Bridge key ID
|
|
263
|
+
* @returns {string} return.tunnelUrl - Public tunnel URL
|
|
264
|
+
* @returns {string} return.tunnelCommand - Command to run on remote machine
|
|
265
|
+
* @throws {Error} If tunnel setup fails
|
|
266
|
+
*/
|
|
267
|
+
async _setupTunnel(sessionId, machineId) {
|
|
268
|
+
try {
|
|
269
|
+
// Create bridge key for this session
|
|
270
|
+
const bridgeKey = await this.livePortClient.createBridgeKey({
|
|
271
|
+
name: `session-${sessionId}`,
|
|
272
|
+
metadata: {
|
|
273
|
+
sessionId,
|
|
274
|
+
machineId,
|
|
275
|
+
provider: this.constructor.name,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Get the tunnel command to run on remote machine
|
|
280
|
+
const tunnelCmd = this.livePortClient.getTunnelCommand(bridgeKey.key, {
|
|
281
|
+
port: 3000, // Default port for remote services
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
bridgeKey: bridgeKey.key,
|
|
286
|
+
keyId: bridgeKey.keyId,
|
|
287
|
+
tunnelUrl: this.livePortClient.getTunnelUrl(sessionId),
|
|
288
|
+
tunnelCommand: tunnelCmd,
|
|
289
|
+
};
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new Error(`Failed to setup LivePort tunnel: ${error.message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Store secrets for the remote machine
|
|
297
|
+
*
|
|
298
|
+
* Stores multiple secrets in Vault under the session namespace.
|
|
299
|
+
*
|
|
300
|
+
* @protected
|
|
301
|
+
* @param {string} namespace - Vault namespace
|
|
302
|
+
* @param {Object} secrets - Key-value pairs of secrets to store
|
|
303
|
+
* @returns {Promise<string[]>} Array of secret IDs
|
|
304
|
+
* @throws {Error} If secret storage fails
|
|
305
|
+
*/
|
|
306
|
+
async _storeSecrets(namespace, secrets) {
|
|
307
|
+
const secretIds = [];
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
311
|
+
const result = await this.vaultClient.storeSecret(
|
|
312
|
+
namespace,
|
|
313
|
+
name,
|
|
314
|
+
value,
|
|
315
|
+
{
|
|
316
|
+
description: `Secret for remote session: ${name}`,
|
|
317
|
+
tags: ['teleportation', 'remote'],
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
secretIds.push(result.secretId);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return secretIds;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
throw new Error(`Failed to store secrets: ${error.message}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Export environment file for the remote machine
|
|
332
|
+
*
|
|
333
|
+
* Exports all secrets in the namespace as a .env file format.
|
|
334
|
+
*
|
|
335
|
+
* @protected
|
|
336
|
+
* @param {string} namespace - Vault namespace
|
|
337
|
+
* @returns {Promise<string>} Environment file content
|
|
338
|
+
* @throws {Error} If export fails
|
|
339
|
+
*/
|
|
340
|
+
async _exportEnvFile(namespace) {
|
|
341
|
+
try {
|
|
342
|
+
return await this.vaultClient.exportEnvFile(namespace, 'env');
|
|
343
|
+
} catch (error) {
|
|
344
|
+
throw new Error(`Failed to export env file: ${error.message}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Cleanup resources after session ends
|
|
350
|
+
*
|
|
351
|
+
* Removes all session resources: bridge keys, SSH keys, and stored secrets.
|
|
352
|
+
* Failures are collected and returned but don't throw to ensure cleanup continues.
|
|
353
|
+
*
|
|
354
|
+
* @protected
|
|
355
|
+
* @param {string} namespace - Vault namespace to clean up
|
|
356
|
+
* @param {string} [bridgeKeyId] - LivePort bridge key ID to revoke
|
|
357
|
+
* @param {string} [sshKeyId] - SSH key ID to delete
|
|
358
|
+
* @returns {Promise<Object>} Cleanup result with success status and errors
|
|
359
|
+
* @returns {boolean} return.success - True if all operations succeeded
|
|
360
|
+
* @returns {string[]} return.errors - Array of error messages (empty if successful)
|
|
361
|
+
*/
|
|
362
|
+
async _cleanup(namespace, bridgeKeyId, sshKeyId) {
|
|
363
|
+
const errors = [];
|
|
364
|
+
|
|
365
|
+
// Revoke LivePort bridge key
|
|
366
|
+
if (bridgeKeyId) {
|
|
367
|
+
try {
|
|
368
|
+
await this.livePortClient.revokeBridgeKey(bridgeKeyId);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
errors.push(`Failed to revoke bridge key: ${error.message}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Delete SSH key from vault
|
|
375
|
+
if (sshKeyId) {
|
|
376
|
+
try {
|
|
377
|
+
await this.vaultClient.deleteSecret(sshKeyId);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
errors.push(`Failed to delete SSH key: ${error.message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Delete all secrets in the namespace
|
|
384
|
+
try {
|
|
385
|
+
const secrets = await this.vaultClient.listSecrets(namespace);
|
|
386
|
+
for (const secret of secrets.data || []) {
|
|
387
|
+
try {
|
|
388
|
+
await this.vaultClient.deleteSecret(secret.secretId);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
errors.push(`Failed to delete secret ${secret.secretId}: ${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
errors.push(`Failed to list secrets: ${error.message}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Log warnings if there were errors
|
|
398
|
+
if (errors.length > 0) {
|
|
399
|
+
console.warn('Cleanup encountered errors:', errors.join('; '));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
success: errors.length === 0,
|
|
404
|
+
errors,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|