Claude Code's session teleportation system enables moving conversations and code sessions between devices and execution contexts. The system uses OAuth authentication, REST APIs, and Git bundles to transfer complete session state including message history, file references, and uncommitted changes. Teleportation consists of two main flows: teleporting to remote (sending local work to cloud) and resuming from remote (pulling cloud work locally).
Key Architecture:
- API-first: Sessions API (
/v1/sessions) serves as the central control plane - Multi-mode source strategy: GitHub cloning (preferred) with automatic fallback to git bundles (for local-only repos)
- Stateless transfer protocol: Messages and state are serialized as JSON and transmitted via REST
- Authentication: OAuth2 tokens with organization UUID scoping
- Serialization: JSON for messages/metadata, Git native format for repository state
Primary Purpose: Session API client library with types, headers, and retry logic.
-
prepareApiRequest()(lines 181-198)- Validates OAuth token and fetches org UUID
- Returns:
{accessToken: string, orgUUID: string} - Error: Throws if no auth or org UUID
-
fetchCodeSessionsFromSessionsAPI()(lines 204-269)- Lists all sessions accessible to the authenticated user
- Transforms
SessionResource→CodeSessionformat - Extracts git repo from
session_context.sources(GitSource type) - Uses retry-enabled GET with exponential backoff (2s, 4s, 8s, 16s)
- Error: Logs and re-throws on failure
-
fetchSession(sessionId: string)(lines 289-327)- GET
/v1/sessions/{sessionId}with 15s timeout - Returns full
SessionResourceincludingsession_context - Handles 404 (not found), 401 (expired token), other errors
- No retry here (unlike the list endpoint)
- GET
-
getBranchFromSession(session)(lines 334-342)- Extracts first branch name from GitRepositoryOutcome
- Path:
session.session_context.outcomes[]→ findtype: 'git_repository'→ branches[0]
-
sendEventToRemoteSession()(lines 361-417)- POST
{uuid, session_id, type: 'user', message}as event - 30s timeout (accounts for CCR worker cold-start, ~2.6s observed)
- De-duplication: callers can pass UUID of a local message for echo filtering
- Returns boolean (success/failure, no exceptions)
- POST
-
updateSessionTitle()(lines 425-466)- PATCH
/v1/sessions/{sessionId}with new title - Returns boolean success/failure
- PATCH
type SessionStatus = 'requires_action' | 'running' | 'idle' | 'archived'
type SessionContext = {
sources: SessionContextSource[] // [{ type: 'git_repository', url, revision?, allow_unrestricted_git_push? }]
cwd: string
outcomes: Outcome[] | null // git_info with branches
custom_system_prompt: string | null
append_system_prompt: string | null
model: string | null
seed_bundle_file_id?: string // For git-bundle seeding
github_pr?: { owner, repo, number }
reuse_outcome_branches?: boolean // Reuse caller's branch instead of claude/xxx
}
type SessionResource = {
type: 'session'
id: string
title: string | null
session_status: SessionStatus
environment_id: string
created_at: string
updated_at: string
session_context: SessionContext
}- Transient errors retried: Network errors (no response), 5xx status
- Not retried: Client errors (4xx) — permanent failures
- Exponential backoff: 2s, 4s, 8s, 16s (4 retries = 5 total attempts)
- Beta header:
'anthropic-beta': 'ccr-byoc-2025-07-29'signals CCR support - Org scoping:
'x-organization-uuid': orgUUIDin all headers
- OAuth tokens required (no API key fallback)
- 15-30s timeouts prevent hanging
- Error messages reveal minimal internal state
- No sensitive data logged by default (logForDebugging used)
Primary Purpose: List and create compute environments for remote sessions.
-
fetchEnvironments()(lines 32-70)- GET
/v1/environment_providerslisting all available environments - Requires OAuth + org UUID
- Returns
EnvironmentResource[]withkind: 'anthropic_cloud' | 'byoc' | 'bridge'
- GET
-
createDefaultCloudEnvironment(name)(lines 76-120)- POST
/v1/environment_providers/cloud/create - Creates a default anthropic_cloud environment with preset config:
- cwd:
/home/user - languages: Python 3.11, Node 20
- network: default hosts allowed, no IP restrictions
- cwd:
- Returns new environment resource
- POST
type EnvironmentKind = 'anthropic_cloud' | 'byoc' | 'bridge'
// anthropic_cloud: standard Anthropic-managed compute
// byoc: customer's own compute environment
// bridge: lightweight bridging environmentteleportToRemote() fetches environments to select where the session runs. Preference ladder: user's configured default → anthropic_cloud (preferred) → first available (excluding bridge).
Primary Purpose: Merge environment list with user settings to determine the selected environment.
getEnvironmentSelectionInfo()(lines 24-77)- Queries
fetchEnvironments()then checks merged settings - Respects
settings.remote.defaultEnvironmentIdif configured - Iterates SETTING_SOURCES in reverse (highest priority last)
- Returns object:
{ availableEnvironments: EnvironmentResource[], selectedEnvironment: EnvironmentResource | null, // env that would be used selectedEnvironmentSource: SettingSource | null // where the config came from }
- Queries
Used by CLI to let users choose their default compute target without hard-coding env IDs.
Primary Purpose: Create and upload Git bundles for "seed bundle" seeding — allows sessions to clone from a local snapshot instead of GitHub.
- Stash uncommitted changes:
git stash create→ dangling commit (untracked files excluded) - Make stash reachable:
git update-ref refs/seed/stash <sha> - Bundle --all: Packs all refs including
refs/seed/stash - Upload: POST to Files API as
_source_seed.bundle - Cleanup: Delete refs/seed/stash and refs/seed/root (don't pollute user's repo)
_bundleWithFallback() (lines 50-146)
- Tries bundle in order:
--all→ HEAD → squashed-root - Scope tiers:
all: Full history, all refs (default, smallest when possible)head: Current branch only + stash (if present)squashed: Single parentless commit of HEAD's tree (or stash tree if WIP exists) — no history, just the snapshot
- Falls back when bundle exceeds
maxBytes(default 100MB, tunable viatengu_ccr_bundle_max_bytesfeature gate) - Why squashed? Large repos: a single-commit tree is tiny; history is dropped entirely
- Stash handling: When WIP exists, squashed tier bakes uncommitted changes in (can't bundle stash ref separately because its parents drag history back)
createAndUploadGitBundle() (lines 152-292)
- Main entry point; called from
teleportToRemote()when bundle mode is active - Pre-flight:
- Sweeps stale refs from prior crashed runs (
refs/seed/stash,refs/seed/root) - Checks for any refs with
git for-each-ref --count=1 refs/(rejects empty repos)
- Sweeps stale refs from prior crashed runs (
- Stash capture:
git stash createwrites a dangling commit (doesn't touch reflog or working tree)- Untracked files intentionally excluded
- Bundle creation:
- Calls
_bundleWithFallback()with signal support for cancellation
- Calls
- Upload:
- POST to
/v1/fileswith fixedrelativePath: '_source_seed.bundle' - CCR reads this path to locate the bundle
- POST to
- Cleanup:
- Always delete temp bundle file and refs (in finally block, non-fatal if fails)
- Returns:
{ success: true, fileId: string, // File API ID for seed_bundle_file_id bundleSizeBytes: number, scope: 'all' | 'head' | 'squashed', hasWip: boolean } | { success: false, error: string, failReason: 'git_error' | 'too_large' | 'empty_repo' }
- maxBytes: Tunable per feature gate; default 100MB
- Gate:
tengu_ccr_bundle_max_bytes(GrowthBook feature) - Trigger: CCR_FORCE_BUNDLE env var or
tengu_ccr_bundle_seed_enabledgate
- Untracked files not captured (only tracked + stash)
- Very large repos may fail all tiers (then fall back to GitHub if available)
- Bundle is a standard Git format; no custom serialization
- Files API handles access control (via session_id and token)
- No credentials embedded in bundle
Teleport To Remote:
- User runs
claude --remote(or--teleport) with a description - CLI bundles local repo + uncommitted changes
- Creates a session on Anthropic's cloud (CCR)
- Returns session ID (can be resumed later from another machine)
- Use case: Offload heavy compute or switch devices mid-task
Teleport From Remote (Resume):
- User runs
claude --teleport <session-id>from another machine/checkout - CLI fetches session history and metadata from API
- Validates current checkout matches session's repo
- Pulls branch created by remote session
- Resumes conversation locally
- Use case: Continue work started in cloud on a different device
A session is a durable object on Anthropic's infrastructure:
- Created by: REST API call (POST /v1/sessions)
- Identified by: UUID string (
session_id) - Persisted across: Device restarts, network outages, multiple resumptions
- Owned by: Organization (org_uuid scoped)
- Lifecycle:
idle→ user resumes or sends eventsrunning→ CCR processingrequires_action→ waiting for user inputarchived→ no new events accepted (via POST /archive)
What Transfers:
| Data | Format | Transport | Source → Dest |
|---|---|---|---|
| Messages | JSON SDK format (SDKMessage[]) | REST API /v1/sessions/{id}/events | Serialized thread store |
| Git history + uncommitted | Git native (bundle format) | Files API + seed_bundle_file_id | git bundle --all |
| Metadata (branch, model) | JSON | REST API /v1/sessions/{id} | session_context |
| File references | URLs/IDs in message content | Not directly transferred | Resolved by receiver |
| Working directory | File paths + cwd | Not transferred | Reconstructed from git checkout |
What Does NOT Transfer:
- File system state outside git (except stash via bundle)
- MCP connections (rebuilt on destination)
- Tool permissions context (reset/revalidated)
- Local environment variables (not in session)
- SSH keys, OAuth tokens (re-authenticated locally)
Authentication:
- User must have OAuth credentials (Claude.ai account, not API key)
- OAuth token scoped to organization UUID
- Token validated on every API request
Session Access:
- Sessions are org-scoped, not globally shareable
- User can only access sessions in their org
- No per-session access tokens (auth delegates to OAuth token)
- Session IDs are UUIDs but not secret — brute-force risk is org-wide OAuth scope
Risk: If an attacker gets your OAuth token, they can:
- List all your sessions (
/v1/sessions) - Fetch any session's history (
/v1/sessions/{id}) - Send messages to running sessions (
/v1/sessions/{id}/events) - Archive sessions (prevent resumption)
They cannot:
- Create sessions without your auth context
- Access sessions from other orgs
- Execute in your repo (remote only; no code execution on client)
Session Creation (Teleport To Remote):
POST /v1/sessions
Headers:
Authorization: Bearer <oauth_token>
x-organization-uuid: <org-uuid>
anthropic-beta: ccr-byoc-2025-07-29
Content-Type: application/json
Body:
{
title: string,
events: [
{
type: 'event',
data: {
type: 'control_request',
request: { subtype: 'set_permission_mode', mode: 'plan' | 'execute', ... }
}
},
{
type: 'event',
data: {
type: 'user',
message: { role: 'user', content: string | ContentBlock[] }
}
}
],
session_context: {
sources: [{ type: 'git_repository', url: 'https://github.com/...', revision: 'branch' }],
seed_bundle_file_id: 'file-xyz', // optional; takes precedence
outcomes: [{ type: 'git_repository', git_info: { branches: ['claude/xxx'] } }],
model: 'claude-3-5-sonnet-20241022',
cwd: '/home/user'
},
environment_id: 'env-abc123'
}
Response (201 Created):
{
id: 'session-uuid',
title: string,
session_status: 'running' | 'idle',
environment_id: string,
session_context: { ... },
created_at: ISO8601,
updated_at: ISO8601
}
Session Fetch (Teleport Resume):
GET /v1/sessions/{sessionId}
Headers:
Authorization: Bearer <oauth_token>
x-organization-uuid: <org-uuid>
anthropic-beta: ccr-byoc-2025-07-29
Response (200 OK):
{
id: 'session-uuid',
title: string,
session_status: ...,
environment_id: string,
session_context: {
sources: [...],
outcomes: [{ git_info: { branches: ['claude/task'] } }],
...
},
...
}
Events Polling (Remote Work Sync):
GET /v1/sessions/{sessionId}/events?after_id=<cursor>
Headers: (same as above)
Response (200 OK):
{
data: [
{ type: 'user', message: {...} },
{ type: 'assistant', message: {...} },
{ type: 'result', subtype: 'success' | 'error_during_execution', ... }
],
has_more: boolean,
first_id: string | null,
last_id: string | null
}
Problem: GitHub clone requires OAuth setup and fails for local-only repos. Bundle solves this.
Mechanism:
-
Stash uncommitted changes (tracked files only):
git stash create # → SHA of dangling commit with staged + unstaged git update-ref refs/seed/stash <SHA> # Make it reachable
-
Create bundle (fallback ladder):
# Tier 1: Full history git bundle create seed.bundle --all [refs/seed/stash] # Tier 2: Current branch only git bundle create seed.bundle HEAD [refs/seed/stash] # Tier 3: Single snapshot commit git commit-tree refs/seed/stash^{tree} -m 'seed' # Orphan commit git update-ref refs/seed/root <new-sha> git bundle create seed.bundle refs/seed/root
-
Upload via Files API:
POST /v1/files file: seed.bundle (binary) relativePath: '_source_seed.bundle' -
Cleanup (always):
git update-ref -d refs/seed/stash # Delete temp ref rm seed.bundle -
CCR side:
git clone --bundle <file-id>(or similar internal logic)- Restores full tree state
- If stash present, checkout tree from stash (baked into squashed tier)
Why This Works:
- Bundle is Git's native format; no custom serialization needed
- Survives full git history (--all tier) when repo is small enough
- Falls back to just the tree snapshot when history is huge
- Untracked files are not included (intentional; they're local config/build artifacts)
Fallback Triggers:
- If --all bundle > 100MB, try HEAD
- If HEAD bundle > 100MB, try squashed (single orphan commit)
- If squashed > 100MB, fail and fall back to GitHub (if available)
- If no GitHub remote, user gets empty sandbox
Challenge: Messages contain file paths/references that exist on source but not destination.
How It's Handled:
-
Messages are serialized as-is:
- Content blocks include file paths:
"file": "/path/to/file.ts" - These are strings in the message; no resolution happens during transfer
- Model has no knowledge that file was transferred
- Content blocks include file paths:
-
Destination Reconstruction:
Teleport From Remote: 1. Fetch session history (messages with file paths) 2. Validate repo matches (session.sources[0].url == current repo) 3. Checkout branch (fetch + git checkout) 4. Messages become available in local session 5. File paths are now valid (same repo, same files exist) Teleport To Remote: 1. Create session with session_context.sources = [github_url] 2. CCR clones repo before session starts 3. Initial message sent with file paths 4. Paths are relative to repo root, valid in CCR container -
For Bundle Mode:
- Bundle contains full tree snapshot (or history)
- CCR extracts it into a fresh working directory
- File paths in messages now point to extracted files
- No special handling needed; it just works
-
Unresolvable References:
- If destination repo differs from session's source, files may not exist
- Teleport validates this before resuming (
validateSessionRepository()) - Throws error: "must run from checkout of owner/repo"
Data Leakage Risk:
- File paths leaked in messages if repo access changes
- Mitigation: org-scoped auth; can't access other orgs' sessions
- No URLs/IDs exposed; just paths relative to repo root
How Teleportation Affects MCP:
-
Teleport To Remote:
- MCP connections are local (to CLI's MCP server)
- Not transferred to remote
- Remote session has its own MCP setup (in the container)
- Initial message sent with raw text (no tool context)
-
Teleport Resume:
- Message history fetched; includes tool calls from remote
- Local MCP is initialized fresh
- Tool-use blocks from remote are replayed as-is
- Local tooling may differ (tools available in remote ≠ tools available locally)
- Model continuation works because message format is standard
-
Tool Permission Context:
- Permissions are re-evaluated on the receiving end
- No permission tokens transferred
- Each environment (local, remote) checks policy independently
- Risk: Different policy enforcement on each end could cause tool calls to fail
Mitigation:
- Teleport validates preconditions before resuming (git repo, GitHub app, etc.)
- Policy checks in precondition validation
- User sees errors early if remote work can't continue locally
What Transfers in Session Context:
session_context = {
sources: [{ url: 'https://github.com/...' }], // Public URL
outcomes: [...],
model: 'claude-3-5-sonnet-20241022',
custom_system_prompt: string | null, // User-written; may contain instructions
append_system_prompt: string | null,
// These do NOT transfer:
// - OAuth tokens
// - SSH keys
// - API credentials
// - Environment variables (except explicitly sent in environmentVariables)
}Credential Flow:
- User authenticates locally with OAuth (
/login) - OAuth token stored in local state
- Token used to create remote session (passed in Authorization header)
- Remote session does not receive the token
- Remote gets
CLAUDE_CODE_OAUTH_TOKENinjected (fresh token for the session, not user's token) - When resuming, user's local OAuth token required (not the session's token)
Implications:
- Tokens are transient and non-transferable
- Each environment must authenticate independently
- No shared credentials across machines
- Reduces blast radius if one token compromised
Progress Steps (from TeleportProgressStep):
'validating' // Checking repo, auth, preconditions
'fetching_logs' // Pulling session history via /v1/sessions/{id}/events
'fetching_branch'// Extracting git branch from session metadata
'checking_out' // Running git fetch/checkout locally
'done' // CompleteCallback: (step: TeleportProgressStep) => void
Error Handling:
-
Network Errors (api.ts):
- Retried with exponential backoff (2s, 4s, 8s, 16s)
- 5xx errors trigger retry
- 4xx errors thrown immediately (permanent failures)
-
Partial Transfers:
- Bundle upload fails → fall back to GitHub (if available)
- Session creation fails → return null (caller decides action)
- Events fetch fails → throw error (can't resume)
-
Repo Mismatch:
- Validates session.sources[0].url == current repo
- If mismatch: throws
TeleportOperationErrorwith formatted message - User directed to correct checkout
-
Branch Checkout Failures:
- Fetch fails: logs and attempts without mapping
- Checkout fails: throws error, attempts alternative strategies (with/without -b)
- Upstream setup fails: non-fatal; logged but doesn't block
-
Session Not Found:
- 404 on GET /v1/sessions/{id} → specific error message
- 401 on any request → suggests
/loginto re-authenticate
Structured Analytics:
tengu_teleport_error_*events logged for each error typetengu_teleport_bundle_modeevents for bundle success/failtengu_teleport_source_decisionlogged with reason (github_preflight_ok, bundle_fallback, etc.)
State Transitions:
CREATE → running
↓
(user sends event)
↓
running → idle
↓
(user sends event or resumes)
↓
running → requires_action (wait for user input)
↓
(waiting for browser approval, etc.)
↓
running → idle/requires_action (cycle)
↓
(user calls archive or session errors)
↓
archived (no new events accepted)
Archival:
// From teleport.tsx line ~1192+
export async function archiveRemoteSession(sessionId: string): Promise<boolean> {
POST /v1/sessions/{sessionId}/archive
// 409 if already archived (treated as success)
// No running-status check (unlike DELETE)
// Fire-and-forget; failure leaves visible session until GC
}Why Archival Instead of Delete?
- Archive immediately stops accepting new events
- Remote session stops on next write attempt
- Soft delete; visible in history
- GC cleaner removes archived sessions after retention period
Vector 1: OAuth Token Compromise
- Attacker obtains user's OAuth token
- Can fetch any session in the org
- Can send events (commands) to running sessions
- Cannot execute code on client (remote only)
- Mitigation: Token rotation, short expiry, secure storage
Vector 2: Session ID Enumeration
- Session IDs are UUIDs; not random enough to prevent brute-force
- Attacker tries 2^64 session IDs to find valid ones
- Each valid session reveals history (via GET /v1/sessions/{id})
- Mitigation: API rate-limiting, IP-based throttling, audit logging
- Assessment: Low risk if OAuth scoped (can't cross orgs)
Vector 3: Repository Mismatch Exploitation
- Attacker creates session with different repo (e.g., phishing repo)
- Sends resume session ID to victim
- Victim runs
claude --teleport <id>and gets wrong branch - Vulnerability: No signature on session; history could be forged if API is compromised
- Mitigation: Session validation checks current repo matches; user must initiate resume
Vector 4: File Path Injection in Messages
- Attacker includes malicious file paths in initial message
- Model generates code that reads/writes those paths
- Risk: Limited to files in checked-out repo (no directory traversal with git)
- Mitigation: Same as any model interaction; user must review code
Vector 5: Git Bundle Tampering
- Attacker modifies uploaded bundle
- CCR extracts malicious history
- Mitigation: Files API access control; needs valid OAuth token + session_id
- Assessment: Bundle is Git-verified on extract (git clone validates SHA1, though SHA1 is weak)
Vector 6: Untracked File Loss
- Bundle excludes untracked files
- User teleports, untracked files disappear remotely
- Risk: Low (expected behavior; documented)
- Mitigation: Documentation; user can
.gitignorefiles to include them
Risk: No
- Sessions run with user's own credentials
- No elevation of privilege
- CCR container isolated from user's machine
- OAuth token scoped to user's org
1. Session History Exposure:
- GET /v1/sessions/{sessionId}/events requires OAuth token
- Token scope: org-wide (not per-session)
- If token compromised: all org's sessions visible
- Mitigation: Org-wide access control; audit logging
2. File Paths in Messages:
- Message content includes file paths
- Path reveals directory structure
- Mitigation: Paths are relative to repo root; same visibility as GitHub
3. Bundle Contents:
- Bundle is Git format (standard); contents are repo history
- Uploaded to Files API with
relativePath: '_source_seed.bundle' - Files API enforces org-scoped access
- Mitigation: Org-scoped auth; no cross-org leaks
4. Metadata Leakage:
- Session title, branch names, model choice, environment ID visible
- Doesn't reveal secrets but exposes task intent
- Mitigation: None by design (intended for session management)
Scenario 1: Attacker Gets Session ID from Victim
- Victim runs
claude --remoteand gets back session ID - Victim shares ID in chat (or it leaks in logs)
- Attacker runs
claude --teleport <id>from their machine - Result: Attacker must be in correct repo; gets same code but can't execute
- Mitigation: Session IDs are not secret; recommendation to not share in untrusted channels
Scenario 2: OAuth Token Leaked in Session History
- User accidentally includes token in a message to Claude
- Session history retrieved later
- Result: Attacker can hijack user's account
- Mitigation: User responsibility; model should refuse tokens
- Note: This is a general Claude issue, not teleport-specific
Scenario 3: MITM on API Calls
- Attacker intercepts HTTPS between CLI and API
- Captures OAuth token
- Result: Full account compromise
- Mitigation: TLS; no custom auth mechanism (delegates to OAuth)
{
"sources": [
{
"type": "git_repository",
"url": "https://github.com/anthropic-ai/anthropic-sdk-python",
"revision": "main",
"allow_unrestricted_git_push": false
}
],
"outcomes": [
{
"type": "git_repository",
"git_info": {
"type": "github",
"repo": "anthropic-ai/anthropic-sdk-python",
"branches": ["claude/fix-retry-logic"]
}
}
],
"cwd": "/home/user/repo",
"custom_system_prompt": null,
"append_system_prompt": null,
"model": "claude-3-5-sonnet-20241022",
"seed_bundle_file_id": "file-abc123xyz",
"github_pr": null,
"reuse_outcome_branches": false
}type SDKMessage =
| { type: 'user'; message: { role: 'user'; content: string | ContentBlock[] } }
| { type: 'assistant'; message: { role: 'assistant'; content: ContentBlock[] } }
| { type: 'result'; subtype: 'success' | 'error_during_execution' | ... }
| { type: 'tool_result'; tool_use_id: string; ... }
// Sent as event in create:
{
"type": "event",
"data": {
"uuid": "uuid-string",
"session_id": "session-uuid",
"type": "user",
"message": {
"role": "user",
"content": "Fix the login button"
}
}
}
// Fetched from /events:
[
{
"type": "user",
"message": { "role": "user", "content": "..." },
"uuid": "...",
"session_id": "..."
},
{
"type": "assistant",
"message": { "role": "assistant", "content": [...] },
"uuid": "...",
"session_id": "..."
}
]Binary Git format:
- Standard `git bundle` output
- Contains packfile (objects) + refs (heads, tags, custom)
- Verifiable: SHA1 hashes embedded (weak but standard)
- Portable: works with any Git implementation
- Size: varies (100MB typical limit)
Structure:
refs/seed/stash → dangling commit with WIP
refs/heads/main → branch HEAD
refs/heads/feature → other branches
...objects... (packfile)
async function teleportToRemote(options) {
1. Check auth
- getClaudeAIOAuthTokens() → error if missing
- getOrganizationUUID() → error if missing
2. Generate title & branch name (unless provided)
- Call Haiku API (queryHaiku)
- Parse JSON schema response
- Fallback to description truncated + "claude/task"
3. Detect current repository
- parseGitRemote(git config --get remote.origin.url)
- Result: { owner, repo, host }
4. GitHub Preflight Check (if not forced to bundle)
- checkGithubAppInstalled(owner, repo)
- Result: boolean (is CCR authorized on this repo?)
- If false and bundle gate on: skip GitHub, use bundle
- If false and bundle gate off: proceed optimistically (fail in CCR)
5. Create/Upload Git Bundle (if GitHub not viable)
- createAndUploadGitBundle({ oauthToken, sessionId, baseUrl })
- Flow:
a. Find .git/ or throw "not in git"
b. Sweep stale refs (cleanup from prior crashes)
c. Check for commits (reject empty repos)
d. git stash create (capture WIP)
e. git update-ref refs/seed/stash (make reachable)
f. git bundle create --all (Tier 1)
g. If > 100MB, try HEAD (Tier 2)
h. If > 100MB, try squashed (Tier 3)
i. Upload to /v1/files with relativePath '_source_seed.bundle'
j. Cleanup refs + temp file
- Result: { fileId, bundleSizeBytes, scope, hasWip }
6. Fetch environments
- GET /v1/environment_providers
- Filter: anthropic_cloud preferred, skip bridge
- Result: selected environment_id
7. Build session context
- sources: [{ type: 'git_repository', url, revision }]
- seed_bundle_file_id: (if bundle created)
- outcomes: [{ type: 'git_repository', git_info: { branches: ['claude/xxx'] } }]
- model: options.model || getMainLoopModel()
- cwd: /home/user (default)
8. Create session
- POST /v1/sessions
- Body: { title, events: [], session_context, environment_id }
- Response: { id, title, session_context, ... }
9. Return session ID
- { id: 'session-uuid', title: 'Generated title' }
}async function teleportResumeCodeSession(sessionId) {
1. Check auth
- getClaudeAIOAuthTokens() → error if missing
- getOrganizationUUID() → error if missing
2. Fetch session metadata
- GET /v1/sessions/{sessionId}
- Response: SessionResource with session_context
3. Validate repository
- currentRepo = detectCurrentRepositoryWithHost()
- sessionRepo = parseGitRemote(session_context.sources[0].url)
- If repos differ: throw TeleportOperationError
- If no repo required: proceed
4. Fetch session history
- getTeleportEvents(sessionId) → new v2 endpoint
- Fallback: getSessionLogsViaOAuth(sessionId) → old endpoint
- Both return Message[] (transcript messages only, no sidechain)
5. Fetch branch name
- getBranchFromSession(session) → first branch from outcomes
- Result: 'claude/task' or similar
6. Update progress callback
- onProgress?.('fetching_branch')
- onProgress?.('checking_out')
7. Checkout branch locally
- git fetch origin (or specific branch)
- git checkout -b <branch> --track origin/<branch>
- ensureUpstreamIsSet(branch)
8. Reconstruct messages
- Deserialize fetched messages
- Add teleport-resume notice (system + user message)
- Messages are now ready for local replay
9. Return result
- { log: Message[], branch: 'claude/task' }
}async function pollRemoteSessionEvents(sessionId, afterId = null) {
const MAX_EVENT_PAGES = 50 // Safety cap
let cursor = afterId
let sdkMessages = []
for (let page = 0; page < MAX_EVENT_PAGES; page++) {
// GET /v1/sessions/{sessionId}/events?after_id=<cursor>
const response = await axios.get(eventsUrl, {
params: cursor ? { after_id: cursor } : undefined,
timeout: 30000
})
// Parse response
const eventsData = response.data
for (const event of eventsData.data) {
// Skip internal events
if (event.type === 'env_manager_log' || event.type === 'control_response') continue
// Collect SDK messages
if (event.type in SDKMessage types) {
sdkMessages.push(event)
}
}
// Pagination
if (!eventsData.has_more) break
cursor = eventsData.last_id
}
// Fetch metadata if needed
if (!opts?.skipMetadata) {
const sessionData = await fetchSession(sessionId)
const branch = getBranchFromSession(sessionData)
const status = sessionData.session_status
}
return {
newEvents: sdkMessages,
lastEventId: cursor,
branch,
sessionStatus
}
}-
Untracked Files Not Transferred
- Bundle only includes tracked files + stash
- Local config files, build artifacts, node_modules not transferred
- Workaround: gitignore them (so they're tracked), or manually sync
-
No Per-Session Access Tokens
- Session ID is not secret (UUID brute-forceable in theory)
- OAuth token provides org-wide access (can't revoke per-session)
- Improvement: per-session short-lived tokens (future work)
-
No File Diff Indication
- Teleport validates repo matches but doesn't show diffs
- User doesn't know if files changed since session creation
- Improvement: show file diffs on resume
-
Large Repo Limitations
- 100MB bundle limit (fallback to squashed or GitHub)
- Very large monorepos may fail all tiers
- Improvement: streaming bundles, sparse checkout
-
No Selective File Transfer
- Bundle is all-or-nothing
- Can't request subset of repo
- Improvement: sparse bundles, partial fetch
-
MCP Mismatch on Resume
- Tools available locally ≠ tools in remote
- Tool calls from remote may fail locally (if tool not available)
- Improvement: validate tool compatibility before resuming
-
Streaming Events
- Events poll is paginated (50 pages max)
- Streaming API could reduce latency
-
Delta Sync
- Full history always fetched on resume
- Could sync only new events since last resume
-
Compression
- Bundle could be compressed (gzip)
- Saves bandwidth for large repos
-
Retry Mechanisms
- Events poll has no retry; single failure aborts
- Could add exponential backoff (like getCodeSessionsFromSessionsAPI)
-
Session Expiry
- No explicit TTL on sessions
- Old sessions may accumulate (GC cleans up)
- Could add user-initiated cleanup
checkBackgroundRemoteSessionEligibility() validates:
- Policy: allow_remote_sessions (policy_blocked)
- Auth: OAuth token present (not_logged_in)
- Environment: at least one available (no_remote_environment)
- Repo: in .git/ or has remote (not_in_git_repo, no_git_remote)
- GitHub: app installed if no bundle gate (github_app_not_installed)
- Bundle: .git/ exists if bundle mode (not_in_git_repo)validateSessionRepository() checks:
- Current repo matches session's source URL (mismatch → error)
- Host matches (github.com vs GHE instance)
- Port normalization (8443 dropped)
- Case-insensitive comparisonScenario A: Local Repo to Cloud
$ cd ~/my-feature
$ claude --remote "Fix login button"
→ Creates session, bundles repo, returns ID
→ User can switch to another machine and resume
Scenario B: Resume on Different Machine
$ cd ~/my-feature # Same repo, same files
$ claude --teleport session-uuid
→ Validates repo matches
→ Fetches history
→ Checks out claude/fix-login-button
→ Resumes conversation
Scenario C: Large Repo Bundle Fallback
$ cd ~/monorepo (1GB history)
$ claude --remote "Refactor config"
→ git bundle --all fails (> 100MB)
→ git bundle HEAD fails (> 100MB)
→ git bundle squashed succeeds (10MB)
→ Session created with squashed bundle
Scenario D: GitHub Preflight Failure, Bundle Fallback
$ cd ~/private-repo
$ checkGithubAppInstalled returns false
$ CCR_ENABLE_BUNDLE=1
→ Skip GitHub, create bundle
→ Session created with bundle seed
Strong:
- OAuth-based authentication (no hardcoded credentials)
- Org-scoped access control (no cross-org leaks)
- No secrets in session context (tokens, keys not transferred)
- Git bundle format is standard (no custom crypto)
Weak:
- Session ID enumeration risk (UUID; no rate-limiting mentioned)
- No per-session tokens (org-wide scope on OAuth token)
- Untracked file handling (silently dropped; could surprise users)
Recommendations:
- Add rate-limiting on /v1/sessions/{id} to prevent brute-force
- Implement short-lived per-session tokens (future work)
- Document untracked file behavior in CLI help
- Add warning when resuming if files changed since creation
Well-Engineered:
- Graceful fallbacks (GitHub → bundle → empty sandbox)
- Feature gates allow gradual rollout (tengu_ccr_bundle_seed_enabled)
- Comprehensive error handling with analytics
- Progress callbacks for long operations
Areas for Improvement:
- No streaming (all-at-once fetch/upload)
- Limited debugging visibility into bundle failures
- No diff/summary on resume (what changed?)
What's Verified:
- Repository validation (session source == current repo)
- Session existence (404 handling)
- Bundle validity (Git format verified on extraction)
What's Not Verified:
- Session history authenticity (no signature; relies on API auth)
- File integrity beyond Git's SHA1 (weak by modern standards)
Fully Implemented:
- Session creation with git source + bundle support
- Event polling and history fetch
- Branch checkout with upstream setup
- Precondition validation
- Error handling and analytics
Not Covered (Outside Scope):
- Browser UI for session management
- Real-time collaboration features
- Session merging/conflict resolution
- Persistent local session cache
| File | Lines | Functions | Purpose |
|---|---|---|---|
| api.ts | 467 | 6 | Core API client, retry logic, types |
| environments.ts | 121 | 2 | Environment listing & creation |
| environmentSelection.ts | 78 | 1 | Environment preference resolution |
| gitBundle.ts | 293 | 2 main + 1 helper | Bundle creation & upload |
| teleport.tsx | ~1200+ | 10+ | Orchestration & validation |
Total teleport module: ~955 lines
// Retry configuration
TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000] // 4 retries
MAX_TELEPORT_RETRIES = 4
// Bundle limits
DEFAULT_BUNDLE_MAX_BYTES = 100 * 1024 * 1024 // 100MB
Tunable via: 'tengu_ccr_bundle_max_bytes' (GrowthBook)
// Feature gates
'tengu_ccr_bundle_seed_enabled' // Enable bundle fallback
'tengu_ccr_bundle_max_bytes' // Bundle size limit
// Environment variables
CCR_FORCE_BUNDLE = 1 // Force bundle mode
CCR_ENABLE_BUNDLE = 1 // Enable bundle fallback
CLAUDE_CODE_OAUTH_TOKEN // Passed to session (injected)
// Beta header
'anthropic-beta': 'ccr-byoc-2025-07-29' // CCR support signaling
// Timeouts
Session creation: 30s (CCR worker cold-start)
Session fetch: 15s
Events poll: 30s per pagePOST /v1/sessions Create session
GET /v1/sessions List sessions
GET /v1/sessions/{sessionId} Fetch session
PATCH /v1/sessions/{sessionId} Update title
POST /v1/sessions/{sessionId}/events Send event
GET /v1/sessions/{sessionId}/events Poll events
POST /v1/sessions/{sessionId}/archive Archive session
GET /v1/environment_providers List environments
POST /v1/environment_providers/cloud/create Create cloud env
POST /v1/files Upload file (bundle)
End of Analysis