feat(cli): add /teleport command for portable session management

This commit is contained in:
mkorwel
2026-03-16 08:45:28 -07:00
parent fd62938945
commit 17a23c4ace
10 changed files with 1385 additions and 0 deletions
@@ -52,6 +52,7 @@ import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { teleportCommand } from '../ui/commands/teleportCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { skillsCommand } from '../ui/commands/skillsCommand.js';
@@ -112,6 +113,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
];
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const chatResumeSubCommands = addDebugToChatResumeSubCommands(
chatCommand.subCommands,
);
@@ -195,6 +197,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
subCommands: addDebugToChatResumeSubCommands(resumeCommand.subCommands),
},
statsCommand,
teleportCommand,
themeCommand,
toolsCommand,
...(this.config?.isSkillsSupportEnabled()
@@ -0,0 +1,189 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { teleportCommand } from './teleportCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { type Config } from '@google/gemini-cli-core';
import { existsSync } from 'node:fs';
const mockExportSession = vi.fn();
const mockImportSession = vi.fn();
vi.mock('prompts', () => ({
default: vi.fn().mockResolvedValue({ secret: 'prompted-secret' }),
}));
vi.mock('node:fs', async (importOriginal) => {
const original = await importOriginal<typeof import('node:fs')>();
return {
...original,
default: {
...original,
existsSync: vi.fn(),
readFileSync: vi.fn(),
},
existsSync: vi.fn(),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
rmSync: vi.fn(),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
TeleportService: class {
exportSession = mockExportSession;
importSession = mockImportSession;
},
getAdminErrorMessage: vi.fn().mockReturnValue('Admin Error'),
debugLogger: {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
};
});
describe('teleportCommand', () => {
let mockContext: CommandContext;
let mockConfig: Config;
beforeEach(() => {
mockExportSession.mockResolvedValue({
packagePath: 'any.tar.gz',
sessionId: 'current-session-id',
filesIncluded: ['file1'],
});
mockImportSession.mockResolvedValue({
sessionId: 'imported-id',
projectIdentifier: 'imported-project',
});
mockConfig = {
getSessionId: vi.fn().mockReturnValue('current-session-id'),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/project'),
},
} as unknown as Config;
mockContext = createMockCommandContext({
services: {
config: mockConfig,
},
});
vi.stubEnv('GEMINI_TELEPORT_SECRET', '');
mockExportSession.mockClear();
mockImportSession.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('should have the correct metadata', () => {
expect(teleportCommand.name).toBe('teleport');
expect(teleportCommand.subCommands).toHaveLength(2);
});
describe('export', () => {
it('should export the current session when no args provided', async () => {
const exportSubCommand = teleportCommand.subCommands?.find(
(c) => c.name === 'export',
);
await exportSubCommand?.action?.(mockContext, '');
expect(mockExportSession).toHaveBeenCalledWith(
'current-session-id',
expect.stringContaining('gemini-session-current-'),
undefined,
undefined,
);
});
it('should use environment variable for secret', async () => {
vi.stubEnv('GEMINI_TELEPORT_SECRET', 'env-secret');
const exportSubCommand = teleportCommand.subCommands?.find(
(c) => c.name === 'export',
);
await exportSubCommand?.action?.(mockContext, '--secret');
expect(mockExportSession).toHaveBeenCalledWith(
'current-session-id',
expect.any(String),
'env-secret',
undefined,
);
});
it('should use key file for secret', async () => {
const exportSubCommand = teleportCommand.subCommands?.find(
(c) => c.name === 'export',
);
vi.mocked(existsSync).mockReturnValue(true);
await exportSubCommand?.action?.(mockContext, '--key-file /path/to/key');
expect(mockExportSession).toHaveBeenCalledWith(
'current-session-id',
expect.any(String),
expect.any(String),
undefined,
);
});
});
describe('import', () => {
it('should import successfully if file exists', async () => {
const importSubCommand = teleportCommand.subCommands?.find(
(c) => c.name === 'import',
);
vi.mocked(existsSync).mockReturnValue(true);
const result = await importSubCommand?.action?.(
mockContext,
'my-session.tar.gz',
);
expect(mockImportSession).toHaveBeenCalledWith(
'my-session.tar.gz',
undefined,
);
expect(result).toEqual(
expect.objectContaining({
type: 'message',
messageType: 'info',
content: expect.stringContaining('imported successfully'),
}),
);
});
it('should handle environment secret during import', async () => {
vi.stubEnv('GEMINI_TELEPORT_SECRET', 'env-secret');
const importSubCommand = teleportCommand.subCommands?.find(
(c) => c.name === 'import',
);
vi.mocked(existsSync).mockReturnValue(true);
await importSubCommand?.action?.(
mockContext,
'my-session.tar.gz --secret',
);
expect(mockImportSession).toHaveBeenCalledWith(
'my-session.tar.gz',
'env-secret',
);
});
});
});
@@ -0,0 +1,264 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { TeleportService, getAdminErrorMessage } from '@google/gemini-cli-core';
import {
CommandKind,
type SlashCommand,
type CommandContext,
type SlashCommandActionReturn,
} from './types.js';
import * as path from 'node:path';
import * as fs from 'node:fs';
import prompts from 'prompts';
async function getSecret(keyFilePath?: string): Promise<string | undefined> {
// 1. Check environment variable
if (process.env['GEMINI_TELEPORT_SECRET']) {
return process.env['GEMINI_TELEPORT_SECRET'];
}
// 2. Check key file
if (keyFilePath) {
if (fs.existsSync(keyFilePath)) {
return fs.readFileSync(keyFilePath, 'utf8').trim();
}
throw new Error(`Key file not found: ${keyFilePath}`);
}
// 3. Interactive prompt
const response = await prompts({
type: 'password',
name: 'secret',
message: 'Enter teleport secret:',
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return response.secret as string | undefined;
}
export const teleportCommand: SlashCommand = {
name: 'teleport',
description:
'Export or import sessions to make them portable across machines',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'export',
description: 'Export a session to a portable tarball or blob storage',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.services.config) {
return {
type: 'message',
messageType: 'error',
content: getAdminErrorMessage('Teleport', undefined),
};
}
const parts = args
.trim()
.split(/\s+/)
.filter((p) => p !== '');
let sessionId = '';
let outputPath = '';
let useSecret = false;
let keyFilePath: string | undefined;
let blobUri: string | undefined;
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '--secret') {
useSecret = true;
} else if (parts[i] === '--key-file') {
useSecret = true;
keyFilePath = parts[i + 1];
i++;
} else if (parts[i] === '--blob') {
blobUri = parts[i + 1];
if (!blobUri || blobUri.startsWith('--')) {
return {
type: 'message',
messageType: 'error',
content:
'Please provide a blob URI after --blob flag (e.g. gs://bucket/path).',
};
}
i++;
} else if (!sessionId && !parts[i].startsWith('--')) {
sessionId = parts[i];
} else if (!outputPath && !parts[i].startsWith('--')) {
outputPath = parts[i];
}
}
if (!sessionId || sessionId === 'current' || sessionId === '') {
sessionId = context.services.config.getSessionId();
}
if (!outputPath) {
outputPath = `gemini-session-${sessionId.slice(0, 8)}.tar.gz`;
}
let secret: string | undefined;
if (useSecret) {
try {
secret = await getSecret(keyFilePath);
if (!secret) {
return {
type: 'message',
messageType: 'error',
content: 'Export cancelled: secret is required.',
};
}
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: String(e),
};
}
}
const teleportService = new TeleportService(context.services.config);
try {
const result = await teleportService.exportSession(
sessionId,
outputPath,
secret,
blobUri,
);
let message = `Session ${sessionId} exported successfully to ${path.resolve(outputPath)}.\nIncluded ${result.filesIncluded.length} files/directories.${secret ? ' (Encrypted)' : ''}`;
if (blobUri) {
message += `\nAlso uploaded to: ${blobUri}`;
}
return {
type: 'message',
messageType: 'info',
content: message,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to export session: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
},
{
name: 'import',
description: 'Import a session from a portable tarball or blob storage',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
if (!context.services.config) {
return {
type: 'message',
messageType: 'error',
content: getAdminErrorMessage('Teleport', undefined),
};
}
const parts = args
.trim()
.split(/\s+/)
.filter((p) => p !== '');
let packagePathOrUri = '';
let useSecret = false;
let keyFilePath: string | undefined;
for (let i = 0; i < parts.length; i++) {
if (parts[i] === '--secret') {
useSecret = true;
} else if (parts[i] === '--key-file') {
useSecret = true;
keyFilePath = parts[i + 1];
i++;
} else if (!packagePathOrUri && !parts[i].startsWith('--')) {
packagePathOrUri = parts[i];
}
}
if (!packagePathOrUri) {
return {
type: 'message',
messageType: 'error',
content:
'Please provide the path or URI to the session tarball to import.',
};
}
// Only check local file if it doesn't look like a URI
if (
!packagePathOrUri.startsWith('gs://') &&
!packagePathOrUri.startsWith('s3://') &&
!fs.existsSync(packagePathOrUri)
) {
return {
type: 'message',
messageType: 'error',
content: `File not found: ${packagePathOrUri}`,
};
}
let secret: string | undefined;
if (useSecret) {
try {
secret = await getSecret(keyFilePath);
if (!secret) {
return {
type: 'message',
messageType: 'error',
content: 'Import cancelled: secret is required.',
};
}
} catch (e) {
return {
type: 'message',
messageType: 'error',
content: String(e),
};
}
}
const teleportService = new TeleportService(context.services.config);
try {
const result = await teleportService.importSession(
packagePathOrUri,
secret,
);
return {
type: 'message',
messageType: 'info',
content: `Session imported successfully.\nSession ID: ${result.sessionId}\nProject: ${result.projectIdentifier}\n\nYou can now resume this session using: /resume ${result.sessionId}`,
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: `Failed to import session: ${error instanceof Error ? error.message : String(error)}`,
};
}
},
},
],
action: async (
_context: CommandContext,
_args: string,
): Promise<SlashCommandActionReturn> => {
return {
type: 'message',
messageType: 'info',
content:
'Use `/teleport export [session-id] [output-path] [--secret] [--key-file <path>] [--blob <uri>]` to export a session.\nUse `/teleport import <package-path-or-uri> [--secret] [--key-file <path>]` to import a session.',
};
},
};
+1
View File
@@ -124,6 +124,7 @@ export * from './services/FolderTrustDiscoveryService.js';
export * from './services/chatRecordingService.js';
export * from './services/fileSystemService.js';
export * from './services/sessionSummaryUtils.js';
export * from './services/teleportService.js';
export * from './services/contextManager.js';
export * from './services/trackerService.js';
export * from './services/trackerTypes.js';
@@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import { TeleportService } from './teleportService.js';
import { type Config } from '../config/config.js';
import { spawnSync, type SpawnSyncOptions } from 'node:child_process';
const actualSpawnSync = (
await vi.importActual<typeof import('node:child_process')>(
'node:child_process',
)
).spawnSync;
vi.mock('node:child_process', async (importOriginal) => {
const original = await importOriginal<typeof import('node:child_process')>();
return {
...original,
spawnSync: vi
.fn()
.mockImplementation(
(cmd: string, args: string[], options: SpawnSyncOptions) => {
return original.spawnSync(cmd, args, options);
},
),
};
});
describe('TeleportService', () => {
let tempDir: string;
let config: Config;
let teleportService: TeleportService;
const sessionId = 'test-session-id-12345678';
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-teleport-test-'));
fs.mkdirSync(path.join(tempDir, 'chats'), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'logs'), { recursive: true });
fs.mkdirSync(path.join(tempDir, sessionId), { recursive: true });
fs.mkdirSync(path.join(tempDir, 'tool-outputs', `session-${sessionId}`), {
recursive: true,
});
const chatFileName = `session-2026-03-14-test-ses.json`;
fs.writeFileSync(
path.join(tempDir, 'chats', chatFileName),
JSON.stringify({ sessionId, messages: [] }),
);
fs.writeFileSync(
path.join(tempDir, 'logs', `session-${sessionId}.jsonl`),
'log content',
);
config = {
storage: {
getProjectTempDir: () => tempDir,
},
getSessionId: () => sessionId,
} as unknown as Config;
teleportService = new TeleportService(config);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
vi.mocked(spawnSync).mockImplementation(actualSpawnSync as any);
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
vi.clearAllMocks();
});
it('should export a session successfully', async () => {
const outputPath = path.join(tempDir, 'export.tar.gz');
const result = await teleportService.exportSession(sessionId, outputPath);
expect(result.sessionId).toBe(sessionId);
expect(fs.existsSync(outputPath)).toBe(true);
expect(result.filesIncluded.some((f) => f.includes('chats'))).toBe(true);
expect(result.filesIncluded.some((f) => f.includes('logs'))).toBe(true);
});
it('should import a session successfully', async () => {
const exportPath = path.join(tempDir, 'export.tar.gz');
await teleportService.exportSession(sessionId, exportPath);
const importTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-import-test-'),
);
const importConfig = {
storage: {
getProjectTempDir: () => importTempDir,
},
} as unknown as Config;
const importService = new TeleportService(importConfig);
const result = await importService.importSession(exportPath);
expect(result.sessionId).toBe(sessionId);
expect(fs.existsSync(path.join(importTempDir, 'chats'))).toBe(true);
const chatFiles = fs.readdirSync(path.join(importTempDir, 'chats'));
expect(chatFiles.length).toBe(1);
const importedChatContent = JSON.parse(
fs.readFileSync(path.join(importTempDir, 'chats', chatFiles[0]), 'utf8'),
);
expect(importedChatContent.sessionId).toBe(sessionId);
fs.rmSync(importTempDir, { recursive: true, force: true });
});
it('should export and import an encrypted session', async () => {
const secret = 'super-secret';
const exportPath = path.join(tempDir, 'encrypted.tar.gz');
await teleportService.exportSession(sessionId, exportPath, secret);
expect(fs.existsSync(exportPath)).toBe(true);
const content = fs.readFileSync(exportPath);
expect(content.subarray(0, 2).toString('hex')).not.toBe('1f8b'); // Gzip header
const importTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-encrypted-import-test-'),
);
const importConfig = {
storage: {
getProjectTempDir: () => importTempDir,
},
} as unknown as Config;
const importService = new TeleportService(importConfig);
const result = await importService.importSession(exportPath, secret);
expect(result.sessionId).toBe(sessionId);
expect(fs.existsSync(path.join(importTempDir, 'chats'))).toBe(true);
fs.rmSync(importTempDir, { recursive: true, force: true });
});
it('should throw error on incorrect secret', async () => {
const secret = 'super-secret';
const exportPath = path.join(tempDir, 'encrypted-fail.tar.gz');
await teleportService.exportSession(sessionId, exportPath, secret);
const importTempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-encrypted-fail-test-'),
);
const importConfig = {
storage: {
getProjectTempDir: () => importTempDir,
},
} as unknown as Config;
const importService = new TeleportService(importConfig);
await expect(
importService.importSession(exportPath, 'wrong-secret'),
).rejects.toThrow('Failed to decrypt');
fs.rmSync(importTempDir, { recursive: true, force: true });
});
it('should throw security error on path traversal in tarball', async () => {
vi.mocked(spawnSync).mockImplementation((cmd, args) => {
if (
cmd === 'tar' &&
args &&
Array.isArray(args) &&
args.includes('-tf')
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {
status: 0,
stdout: Buffer.from('../../etc/passwd\nchats/file.json'),
stderr: Buffer.from(''),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
return actualSpawnSync(cmd as any, args as any);
});
const exportPath = path.join(tempDir, 'malicious.tar.gz');
fs.writeFileSync(exportPath, 'dummy');
await expect(teleportService.importSession(exportPath)).rejects.toThrow(
'Security violation: Malicious path detected',
);
});
it('should export to blob storage', async () => {
const outputPath = path.join(tempDir, 'export-blob.tar.gz');
const blobUri = 'gs://my-bucket/session.tar.gz';
vi.mocked(spawnSync).mockImplementation((cmd, args, options) => {
if (cmd === 'gcloud' || cmd === 'gsutil' || cmd === 'aws') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {
status: 0,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
} as any;
}
return actualSpawnSync(
cmd,
args as string[],
options as SpawnSyncOptions,
);
});
await teleportService.exportSession(
sessionId,
outputPath,
undefined,
blobUri,
);
expect(spawnSync).toHaveBeenCalledWith(
expect.stringMatching(/gcloud|gsutil|aws/),
expect.arrayContaining(['cp', outputPath, blobUri]),
);
});
it('should import from blob storage', async () => {
const blobUri = 'gs://my-bucket/session.tar.gz';
vi.mocked(spawnSync).mockImplementation((cmd, args, options) => {
if (cmd === 'gcloud' || cmd === 'gsutil' || cmd === 'aws') {
if (args && Array.isArray(args)) {
const dest = args[args.length - 1];
const chatFileName = path.join(
tempDir,
'chats',
`session-2026-03-14-test-ses.json`,
);
if (!fs.existsSync(path.join(tempDir, 'chats'))) {
fs.mkdirSync(path.join(tempDir, 'chats'), { recursive: true });
}
if (!fs.existsSync(chatFileName)) {
fs.writeFileSync(
chatFileName,
JSON.stringify({ sessionId, messages: [] }),
);
}
actualSpawnSync('tar', ['-czf', dest, '-C', tempDir, 'chats']);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return {
status: 0,
stdout: Buffer.from(''),
stderr: Buffer.from(''),
} as any;
}
return actualSpawnSync(
cmd,
args as string[],
options as SpawnSyncOptions,
);
});
const result = await teleportService.importSession(blobUri);
expect(result.sessionId).toBe(sessionId);
expect(spawnSync).toHaveBeenCalledWith(
expect.stringMatching(/gcloud|gsutil|aws/),
expect.arrayContaining(['cp', blobUri, expect.any(String)]),
);
});
});
@@ -0,0 +1,334 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as crypto from 'node:crypto';
import { type Config } from '../config/config.js';
import { SESSION_FILE_PREFIX } from './chatRecordingService.js';
import { spawnSync } from 'node:child_process';
export interface TeleportExportResult {
packagePath: string;
sessionId: string;
filesIncluded: string[];
blobUri?: string;
}
export interface TeleportImportResult {
sessionId: string;
projectIdentifier: string;
}
/**
* Service for exporting and importing sessions to make them portable.
*/
export class TeleportService {
constructor(private config: Config) {}
/**
* Exports a session to a tarball, optionally uploading to blob storage.
*/
async exportSession(
sessionId: string,
outputPath: string,
secret?: string,
blobUri?: string,
): Promise<TeleportExportResult> {
const storage = this.config.storage;
const tempDir = storage.getProjectTempDir();
const chatsDir = path.join(tempDir, 'chats');
const chatFiles = await fs.promises.readdir(chatsDir);
const chatFile = chatFiles.find(
(f) =>
f.startsWith(SESSION_FILE_PREFIX) &&
f.includes(sessionId.slice(0, 8)) &&
f.endsWith('.json'),
);
if (!chatFile) {
throw new Error(`Chat file for session ${sessionId} not found.`);
}
const filesToInclude: string[] = [];
const metadataFile = 'teleport-metadata.json';
const metadataPath = path.join(tempDir, metadataFile);
fs.writeFileSync(
metadataPath,
JSON.stringify({
sessionId,
exportedAt: new Date().toISOString(),
isEncrypted: !!secret,
}),
);
filesToInclude.push(metadataFile);
filesToInclude.push(path.join('chats', chatFile));
const logFile = `session-${sessionId}.jsonl`;
const logPath = path.join(tempDir, 'logs', logFile);
if (fs.existsSync(logPath)) {
filesToInclude.push(path.join('logs', logFile));
}
const sessionDir = path.join(tempDir, sessionId);
if (fs.existsSync(sessionDir)) {
filesToInclude.push(sessionId);
}
const toolOutputDir = path.join(
tempDir,
'tool-outputs',
`session-${sessionId}`,
);
if (fs.existsSync(toolOutputDir)) {
filesToInclude.push(path.join('tool-outputs', `session-${sessionId}`));
}
const tarPath = secret ? `${outputPath}.tmp` : outputPath;
const result = spawnSync('tar', [
'-czf',
tarPath,
'-C',
tempDir,
...filesToInclude,
]);
if (fs.existsSync(metadataPath)) {
fs.unlinkSync(metadataPath);
}
if (result.status !== 0) {
throw new Error(`Failed to create tarball: ${result.stderr.toString()}`);
}
if (secret) {
try {
this.encryptFile(tarPath, outputPath, secret);
fs.unlinkSync(tarPath);
} catch (e) {
if (fs.existsSync(tarPath)) fs.unlinkSync(tarPath);
throw e;
}
}
if (blobUri) {
this.uploadToBlob(outputPath, blobUri);
}
return {
packagePath: outputPath,
sessionId,
filesIncluded: filesToInclude,
blobUri,
};
}
/**
* Imports a session from a tarball or blob storage.
*/
async importSession(
packagePathOrUri: string,
secret?: string,
): Promise<TeleportImportResult> {
const storage = this.config.storage;
const tempDir = storage.getProjectTempDir();
let packagePath = packagePathOrUri;
let isTempBlobFile = false;
if (
packagePathOrUri.startsWith('gs://') ||
packagePathOrUri.startsWith('s3://')
) {
const fileName = path.basename(packagePathOrUri);
packagePath = path.join(tempDir, `downloaded-${fileName}`);
this.downloadFromBlob(packagePathOrUri, packagePath);
isTempBlobFile = true;
}
let extractionPath = packagePath;
let isTempExtractionFile = false;
if (secret) {
extractionPath = `${packagePath}.decrypted.tmp`;
try {
this.decryptFile(packagePath, extractionPath, secret);
isTempExtractionFile = true;
} finally {
if (isTempBlobFile && fs.existsSync(packagePath)) {
fs.unlinkSync(packagePath);
isTempBlobFile = false;
}
}
}
try {
fs.mkdirSync(tempDir, { recursive: true });
const listResult = spawnSync('tar', ['-tf', extractionPath]);
if (listResult.status === 0) {
const files = listResult.stdout.toString().split('\n');
for (const file of files) {
if (!file) continue;
if (path.isAbsolute(file) || file.includes('..')) {
throw new Error(
`Security violation: Malicious path detected in archive: ${file}`,
);
}
}
}
const result = spawnSync('tar', ['-xzf', extractionPath, '-C', tempDir]);
if (result.status !== 0) {
throw new Error(
`Failed to extract tarball: ${result.stderr.toString()}`,
);
}
} finally {
if (isTempExtractionFile && fs.existsSync(extractionPath)) {
fs.unlinkSync(extractionPath);
}
if (isTempBlobFile && fs.existsSync(packagePath)) {
fs.unlinkSync(packagePath);
}
}
let sessionId: string | undefined;
const metadataPath = path.join(tempDir, 'teleport-metadata.json');
if (fs.existsSync(metadataPath)) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion
sessionId = metadata.sessionId as string;
fs.unlinkSync(metadataPath);
} catch {
// Fallback to heuristic
}
}
if (!sessionId) {
const chatsDir = path.join(tempDir, 'chats');
const chatFiles = await fs.promises.readdir(chatsDir);
const chatFilesWithStats = await Promise.all(
chatFiles.map(async (f) => {
const stats = await fs.promises.stat(path.join(chatsDir, f));
return { file: f, mtime: stats.mtimeMs };
}),
);
const latestChat = chatFilesWithStats.sort(
(a, b) => b.mtime - a.mtime,
)[0];
if (!latestChat) {
throw new Error('No chat files found after import.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const chatContent = JSON.parse(
await fs.promises.readFile(
path.join(chatsDir, latestChat.file),
'utf8',
),
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-type-assertion
sessionId = chatContent.sessionId as string;
}
if (!sessionId) {
throw new Error('Could not determine session ID after import.');
}
return {
sessionId,
projectIdentifier: path.basename(tempDir),
};
}
private encryptFile(inputPath: string, outputPath: string, secret: string) {
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(secret, salt, 32, { N: 16384, r: 8, p: 1 });
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const input = fs.readFileSync(inputPath);
const encrypted = Buffer.concat([cipher.update(input), cipher.final()]);
const authTag = cipher.getAuthTag();
const output = Buffer.concat([salt, iv, authTag, encrypted]);
fs.writeFileSync(outputPath, output);
}
private decryptFile(inputPath: string, outputPath: string, secret: string) {
const input = fs.readFileSync(inputPath);
if (input.length < 48) {
throw new Error('Invalid or corrupted encrypted file.');
}
const salt = input.subarray(0, 16);
const iv = input.subarray(16, 32);
const authTag = input.subarray(32, 48);
const encrypted = input.subarray(48);
const key = crypto.scryptSync(secret, salt, 32, { N: 16384, r: 8, p: 1 });
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
try {
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final(),
]);
fs.writeFileSync(outputPath, decrypted);
} catch {
throw new Error('Failed to decrypt. Incorrect secret or corrupted data.');
}
}
private uploadToBlob(localPath: string, blobUri: string) {
let result;
if (blobUri.startsWith('gs://')) {
result = spawnSync('gcloud', ['storage', 'cp', localPath, blobUri]);
if (result.status !== 0) {
result = spawnSync('gsutil', ['cp', localPath, blobUri]);
}
} else if (blobUri.startsWith('s3://')) {
result = spawnSync('aws', ['s3', 'cp', localPath, blobUri]);
} else {
throw new Error(`Unsupported blob storage URI scheme: ${blobUri}`);
}
if (result.status !== 0) {
throw new Error(
`Failed to upload to blob storage: ${result.stderr.toString()}`,
);
}
}
private downloadFromBlob(blobUri: string, localPath: string) {
let result;
if (blobUri.startsWith('gs://')) {
result = spawnSync('gcloud', ['storage', 'cp', blobUri, localPath]);
if (result.status !== 0) {
result = spawnSync('gsutil', ['cp', blobUri, localPath]);
}
} else if (blobUri.startsWith('s3://')) {
result = spawnSync('aws', ['s3', 'cp', blobUri, localPath]);
} else {
throw new Error(`Unsupported blob storage URI scheme: ${blobUri}`);
}
if (result.status !== 0) {
throw new Error(
`Failed to download from blob storage: ${result.stderr.toString()}`,
);
}
}
}