mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-16 14:53:19 -07:00
feat(cli): add /teleport command for portable session management
This commit is contained in:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user