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
+136
View File
@@ -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-<short-id>.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 <session-id>
```
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 <path>` 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.
+12
View File
@@ -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 <path>] [--blob <uri>]`**:
- **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 <path-or-uri> [--secret] [--key-file <path>]`**:
- **Description:** Restores a session from a local file or cloud URI.
### `/terminal-setup`
- **Description:** Configure terminal keybindings for multiline input (VS Code,
+5
View File
@@ -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" }
]
},
+169
View File
@@ -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');
});
});
@@ -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()}`,
);
}
}
}