From 17a23c4aceaddc19bd68d18dff2e6d3980d6de62 Mon Sep 17 00:00:00 2001 From: mkorwel Date: Mon, 16 Mar 2026 08:45:28 -0700 Subject: [PATCH] feat(cli): add /teleport command for portable session management --- docs/cli/teleportation.md | 136 +++++++ docs/reference/commands.md | 12 + docs/sidebar.json | 5 + integration-tests/teleport.test.ts | 169 +++++++++ .../cli/src/services/BuiltinCommandLoader.ts | 3 + .../src/ui/commands/teleportCommand.test.ts | 189 ++++++++++ .../cli/src/ui/commands/teleportCommand.ts | 264 ++++++++++++++ packages/core/src/index.ts | 1 + .../core/src/services/teleportService.test.ts | 272 ++++++++++++++ packages/core/src/services/teleportService.ts | 334 ++++++++++++++++++ 10 files changed, 1385 insertions(+) create mode 100644 docs/cli/teleportation.md create mode 100644 integration-tests/teleport.test.ts create mode 100644 packages/cli/src/ui/commands/teleportCommand.test.ts create mode 100644 packages/cli/src/ui/commands/teleportCommand.ts create mode 100644 packages/core/src/services/teleportService.test.ts create mode 100644 packages/core/src/services/teleportService.ts diff --git a/docs/cli/teleportation.md b/docs/cli/teleportation.md new file mode 100644 index 0000000000..036cbf2e9a --- /dev/null +++ b/docs/cli/teleportation.md @@ -0,0 +1,136 @@ +# Teleportation + +Teleportation lets you move your active AI engineering sessions between +different machines. Unlike sharing a chat transcript, teleporting captures your +entire workspace state, including your plans, tasks, tracker data, and full +activity logs. + +By using teleportation, you can start a complex engineering task on your local +laptop and "needlecast" it to a powerful remote server or a different +development environment without losing your progress or context. + +## How it works + +Teleportation bundles all session-related data from your local Gemini temporary +directory (`~/.gemini/tmp`) into a portable, compressed archive (`.tar.gz`). You +can then transfer this archive to another machine and import it to resume +working exactly where you left off. + +The bundle includes: + +- Chat history and conversation state. +- AI-generated plans and task statuses. +- Detailed activity logs and tool outputs. +- Project-specific tracker data. + +## Export a session + +To package your current session for transfer, use the `/teleport export` +command. + +1. Run the export command in your active session: + + ```bash + /teleport export + ``` + + This creates a file named `gemini-session-.tar.gz` in your current + directory. + +2. Optional: Specify a custom output path: + + ```bash + /teleport export current my-backup.tar.gz + ``` + +3. Optional: Export a specific session by its ID: + ```bash + /teleport export session-abc-123 + ``` + +## Import a session + +To restore a session on a new machine, use the `/teleport import` command. + +1. Move the exported tarball to the new machine. +2. Run the import command: + ```bash + /teleport import ./my-backup.tar.gz + ``` +3. Resume the imported session: + ```bash + /resume + ``` + The import command will display the session ID you need to resume. + +## Security and privacy + +Teleportation includes several features to ensure your session data remains +secure during transit. + +### Encryption + +You can encrypt your session bundle using AES-256-GCM. This ensures that even if +the archive is intercepted, the contents cannot be read without your secret. + +To use encryption: + +1. Add the `--secret` flag to your export command: + ```bash + /teleport export --secret + ``` +2. Enter a password when prompted. Gemini CLI uses the Scrypt key derivation + function to protect your password against brute-force attacks. +3. When importing, add the `--secret` flag again: + ```bash + /teleport import ./encrypted-session.tar.gz --secret + ``` + +You can also use the `GEMINI_TELEPORT_SECRET` environment variable or a key file +with `--key-file ` to provide the secret without an interactive prompt. + +### Path traversal protection + +During the import process, Gemini CLI automatically scans the archive for +malicious paths. It prevents any files from being extracted outside of the +designated Gemini temporary directory, protecting your system from path +traversal attacks. + +## Cloud blob storage + +Teleportation supports direct transfers to and from Google Cloud Storage (GCS) +and Amazon S3. This lets you store your sessions in a centralized location that +you control, without committing large log files to your Git repository. + +### Prerequisites + +To use cloud storage, you must have the corresponding cloud CLI installed and +authenticated on your machine: + +- **GCS**: Requires `gcloud` or `gsutil`. +- **S3**: Requires `aws`. + +### Cloud usage examples + +**Export directly to a bucket:** + +```bash +/teleport export --blob gs://my-sessions-bucket/task-alpha.tar.gz +``` + +**Import directly from a bucket:** + +```bash +/teleport import gs://my-sessions-bucket/task-alpha.tar.gz +``` + +**Secure cloud transfer:** + +```bash +/teleport export --secret --blob s3://my-bucket/secure-session.tar.gz +``` + +## Next steps + +- Learn more about [Session management](./session-management.md). +- Explore [Checkpointing](./checkpointing.md) for local file safety. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index e9383152d2..005a23544e 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -442,6 +442,18 @@ Slash commands provide meta-level control over the CLI itself. - **`tools`**: - **Description:** Show tool-specific usage statistics. +### `/teleport` + +- **Description:** Export or import sessions to make them portable across + machines. +- **Sub-commands:** + - **`export [session-id] [output-path] [--secret] [--key-file ] [--blob ]`**: + - **Description:** Packages the session state into a compressed archive. + - **Note:** Use `--secret` for AES-256 encryption or `--blob` to upload + directly to GCS/S3. + - **`import [--secret] [--key-file ]`**: + - **Description:** Restores a session from a local file or cloud URI. + ### `/terminal-setup` - **Description:** Configure terminal keybindings for multiline input (VS Code, diff --git a/docs/sidebar.json b/docs/sidebar.json index 6cac5ec9fd..034e699b00 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -43,6 +43,10 @@ "label": "Manage sessions and history", "slug": "docs/cli/tutorials/session-management" }, + { + "label": "Teleport sessions between machines", + "slug": "docs/cli/teleportation" + }, { "label": "Plan tasks with todos", "slug": "docs/cli/tutorials/task-planning" @@ -136,6 +140,7 @@ { "label": "Sandboxing", "slug": "docs/cli/sandbox" }, { "label": "Settings", "slug": "docs/cli/settings" }, { "label": "Telemetry", "slug": "docs/cli/telemetry" }, + { "label": "Teleportation", "slug": "docs/cli/teleportation" }, { "label": "Token caching", "slug": "docs/cli/token-caching" } ] }, diff --git a/integration-tests/teleport.test.ts b/integration-tests/teleport.test.ts new file mode 100644 index 0000000000..e3597eb73d --- /dev/null +++ b/integration-tests/teleport.test.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + TeleportService, + Config, + ChatRecordingService, +} from '@google/gemini-cli-core'; + +describe('Teleport E2E Integration', () => { + let tmpDir: string; + let machineA_Home: string; + let machineB_Home: string; + let projectDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-teleport-e2e-')); + machineA_Home = path.join(tmpDir, 'machineA'); + machineB_Home = path.join(tmpDir, 'machineB'); + projectDir = path.join(tmpDir, 'my-project'); + + await fs.mkdir(machineA_Home, { recursive: true }); + await fs.mkdir(machineB_Home, { recursive: true }); + await fs.mkdir(projectDir, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('should round-trip a session between two simulated machines', async () => { + // --- STEP 1: Setup Machine A and create a session --- + const configA = new Config({ + sessionId: 'session-123', + targetDir: projectDir, + cwd: projectDir, + model: 'test-model', + }); + const machineA_TempDir = path.join(machineA_Home, 'tmp'); + await fs.mkdir(machineA_TempDir, { recursive: true }); + vi.spyOn(configA.storage, 'getProjectTempDir').mockReturnValue( + machineA_TempDir, + ); + + const recordingServiceA = new ChatRecordingService(configA); + recordingServiceA.initialize(); + + recordingServiceA.recordMessage({ + model: 'test-model', + type: 'user', + content: [{ text: 'Hello from Machine A' }], + }); + + const realSessionId = configA.getSessionId(); + const chatFilePathA = recordingServiceA.getConversationFilePath(); + expect(chatFilePathA).not.toBeNull(); + + // --- STEP 2: Export from Machine A --- + const teleportServiceA = new TeleportService(configA); + const tarballPath = path.join(tmpDir, 'teleport.tar.gz'); + await teleportServiceA.exportSession(realSessionId, tarballPath); + + // --- STEP 3: Setup Machine B and Import --- + const configB = new Config({ + sessionId: 'new-session', + targetDir: projectDir, + cwd: projectDir, + model: 'test-model', + }); + const machineB_TempDir = path.join(machineB_Home, 'tmp'); + await fs.mkdir(machineB_TempDir, { recursive: true }); + vi.spyOn(configB.storage, 'getProjectTempDir').mockReturnValue( + machineB_TempDir, + ); + + const teleportServiceB = new TeleportService(configB); + const importResult = await teleportServiceB.importSession(tarballPath); + + expect(importResult.sessionId).toBe(realSessionId); + + // --- STEP 4: Verify Machine B can "see" the session --- + const chatFiles = await configB.storage.listProjectChatFiles(); + expect(chatFiles.length).toBe(1); + + // storage.listProjectChatFiles returns relative paths + const importedFile = path.join(machineB_TempDir, chatFiles[0].filePath); + const conversationData = JSON.parse( + await fs.readFile(importedFile, 'utf8'), + ); + + const recordingServiceB = new ChatRecordingService(configB); + recordingServiceB.initialize({ + filePath: importedFile, + conversation: conversationData, + }); + + const conversation = recordingServiceB.getConversation(); + expect(conversation).not.toBeNull(); + expect(conversation?.messages[0].content[0].text).toBe( + 'Hello from Machine A', + ); + }); + + it('should handle encrypted sessions in E2E', async () => { + const secret = 'password123'; + const configA = new Config({ + sessionId: 'enc-session', + targetDir: projectDir, + cwd: projectDir, + model: 'm', + }); + const machineA_TempDir = path.join(machineA_Home, 'tmp'); + await fs.mkdir(machineA_TempDir, { recursive: true }); + vi.spyOn(configA.storage, 'getProjectTempDir').mockReturnValue( + machineA_TempDir, + ); + + const recordingServiceA = new ChatRecordingService(configA); + recordingServiceA.initialize(); + recordingServiceA.recordMessage({ + model: 'm', + type: 'user', + content: [{ text: 'Encrypted message' }], + }); + + const realSessionId = configA.getSessionId(); + const teleportServiceA = new TeleportService(configA); + const tarballPath = path.join(tmpDir, 'encrypted.tar.gz'); + await teleportServiceA.exportSession(realSessionId, tarballPath, secret); + + // Machine B + const configB = new Config({ + sessionId: 'b', + targetDir: projectDir, + cwd: projectDir, + model: 'm', + }); + const machineB_TempDir = path.join(machineB_Home, 'tmp'); + await fs.mkdir(machineB_TempDir, { recursive: true }); + vi.spyOn(configB.storage, 'getProjectTempDir').mockReturnValue( + machineB_TempDir, + ); + + const teleportServiceB = new TeleportService(configB); + await teleportServiceB.importSession(tarballPath, secret); + + const chatFiles = await configB.storage.listProjectChatFiles(); + const importedFile = path.join(machineB_TempDir, chatFiles[0].filePath); + const conversationData = JSON.parse( + await fs.readFile(importedFile, 'utf8'), + ); + + const recordingServiceB = new ChatRecordingService(configB); + recordingServiceB.initialize({ + filePath: importedFile, + conversation: conversationData, + }); + + const conversation = recordingServiceB.getConversation(); + expect(conversation?.messages[0].content[0].text).toBe('Encrypted message'); + }); +}); diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 66806f5ef1..b25058ae9d 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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() diff --git a/packages/cli/src/ui/commands/teleportCommand.test.ts b/packages/cli/src/ui/commands/teleportCommand.test.ts new file mode 100644 index 0000000000..7c854cba25 --- /dev/null +++ b/packages/cli/src/ui/commands/teleportCommand.test.ts @@ -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(); + 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(); + 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', + ); + }); + }); +}); diff --git a/packages/cli/src/ui/commands/teleportCommand.ts b/packages/cli/src/ui/commands/teleportCommand.ts new file mode 100644 index 0000000000..9c95279ff5 --- /dev/null +++ b/packages/cli/src/ui/commands/teleportCommand.ts @@ -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 { + // 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 => { + 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 => { + 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 => { + return { + type: 'message', + messageType: 'info', + content: + 'Use `/teleport export [session-id] [output-path] [--secret] [--key-file ] [--blob ]` to export a session.\nUse `/teleport import [--secret] [--key-file ]` to import a session.', + }; + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b395daf2f9..ab1474f5af 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/services/teleportService.test.ts b/packages/core/src/services/teleportService.test.ts new file mode 100644 index 0000000000..0d8b8de27a --- /dev/null +++ b/packages/core/src/services/teleportService.test.ts @@ -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( + 'node:child_process', + ) +).spawnSync; + +vi.mock('node:child_process', async (importOriginal) => { + const original = await importOriginal(); + 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)]), + ); + }); +}); diff --git a/packages/core/src/services/teleportService.ts b/packages/core/src/services/teleportService.ts new file mode 100644 index 0000000000..a085ac8f59 --- /dev/null +++ b/packages/core/src/services/teleportService.ts @@ -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 { + 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 { + 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()}`, + ); + } + } +}