Skip to content

Commit 106d7ea

Browse files
iamjr15claude
andcommitted
feat: improve mobile preview, simplify Composio integration, add retry logic
- Mobile preview: increase max-workers to 4 for better parallelism with 2 CPUs - Composio: skip tool selection dialog - MCP servers auto-expose all tools - API: add retry logic with exponential backoff for sandbox lock errors - Sandbox: optimize Dockerfiles for web and mobile templates - QR extractor: improve error handling and logging 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 460cfef commit 106d7ea

File tree

16 files changed

+816
-305
lines changed

16 files changed

+816
-305
lines changed

.github/workflows/docker-build.yml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,15 @@ jobs:
6969
--image=${{ env.REGISTRY }}/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/api:latest \
7070
--platform=managed \
7171
--allow-unauthenticated \
72-
--cpu=2 \
73-
--memory=4Gi \
72+
--cpu=1 \
73+
--memory=2Gi \
7474
--timeout=3500 \
7575
--min-instances=0 \
76-
--max-instances=5 \
76+
--max-instances=10 \
7777
--cpu-throttling \
78-
--vpc-connector=cheatcode-connector \
78+
--cpu-boost \
79+
--network=default \
80+
--subnet=default \
7981
--vpc-egress=private-ranges-only \
8082
--service-account=cheatcode-api@${{ env.PROJECT_ID }}.iam.gserviceaccount.com \
8183
--set-secrets="REDIS_URL=REDIS_URL:latest,ANTHROPIC_API_KEY=ANTHROPIC_API_KEY:latest,OPENAI_API_KEY=OPENAI_API_KEY:latest,OPENROUTER_API_KEY=OPENROUTER_API_KEY:latest,SUPABASE_URL=SUPABASE_URL:latest,SUPABASE_ANON_KEY=SUPABASE_ANON_KEY:latest,SUPABASE_SERVICE_ROLE_KEY=SUPABASE_SERVICE_ROLE_KEY:latest,CLERK_SECRET_KEY=CLERK_SECRET_KEY:latest,CLERK_DOMAIN=CLERK_DOMAIN:latest,POLAR_ACCESS_TOKEN=POLAR_ACCESS_TOKEN:latest,POLAR_ORGANIZATION_ID=POLAR_ORGANIZATION_ID:latest,POLAR_PRODUCT_ID_PRO=POLAR_PRODUCT_ID_PRO:latest,POLAR_PRODUCT_ID_PREMIUM=POLAR_PRODUCT_ID_PREMIUM:latest,POLAR_PRODUCT_ID_BYOK=POLAR_PRODUCT_ID_BYOK:latest,DAYTONA_API_KEY=DAYTONA_API_KEY:latest,DAYTONA_SERVER_URL=DAYTONA_SERVER_URL:latest,TAVILY_API_KEY=TAVILY_API_KEY:latest,FIRECRAWL_API_KEY=FIRECRAWL_API_KEY:latest,LANGFUSE_PUBLIC_KEY=LANGFUSE_PUBLIC_KEY:latest,LANGFUSE_SECRET_KEY=LANGFUSE_SECRET_KEY:latest,MCP_CREDENTIAL_ENCRYPTION_KEY=MCP_CREDENTIAL_ENCRYPTION_KEY:latest,COMPOSIO_API_KEY=COMPOSIO_API_KEY:latest,FREESTYLE_API_KEY=FREESTYLE_API_KEY:latest,GOOGLE_API_KEY=GOOGLE_API_KEY:latest,MORPH_API_KEY=MORPH_API_KEY:latest" \
@@ -95,7 +97,9 @@ jobs:
9597
--min-instances=1 \
9698
--max-instances=3 \
9799
--no-cpu-throttling \
98-
--vpc-connector=cheatcode-connector \
100+
--cpu-boost \
101+
--network=default \
102+
--subnet=default \
99103
--vpc-egress=private-ranges-only \
100104
--service-account=cheatcode-api@${{ env.PROJECT_ID }}.iam.gserviceaccount.com \
101105
--set-secrets="REDIS_URL=REDIS_URL:latest,ANTHROPIC_API_KEY=ANTHROPIC_API_KEY:latest,OPENAI_API_KEY=OPENAI_API_KEY:latest,OPENROUTER_API_KEY=OPENROUTER_API_KEY:latest,SUPABASE_URL=SUPABASE_URL:latest,SUPABASE_ANON_KEY=SUPABASE_ANON_KEY:latest,SUPABASE_SERVICE_ROLE_KEY=SUPABASE_SERVICE_ROLE_KEY:latest,CLERK_SECRET_KEY=CLERK_SECRET_KEY:latest,CLERK_DOMAIN=CLERK_DOMAIN:latest,POLAR_ACCESS_TOKEN=POLAR_ACCESS_TOKEN:latest,POLAR_ORGANIZATION_ID=POLAR_ORGANIZATION_ID:latest,POLAR_PRODUCT_ID_PRO=POLAR_PRODUCT_ID_PRO:latest,POLAR_PRODUCT_ID_PREMIUM=POLAR_PRODUCT_ID_PREMIUM:latest,POLAR_PRODUCT_ID_BYOK=POLAR_PRODUCT_ID_BYOK:latest,DAYTONA_API_KEY=DAYTONA_API_KEY:latest,DAYTONA_SERVER_URL=DAYTONA_SERVER_URL:latest,TAVILY_API_KEY=TAVILY_API_KEY:latest,FIRECRAWL_API_KEY=FIRECRAWL_API_KEY:latest,LANGFUSE_PUBLIC_KEY=LANGFUSE_PUBLIC_KEY:latest,LANGFUSE_SECRET_KEY=LANGFUSE_SECRET_KEY:latest,MCP_CREDENTIAL_ENCRYPTION_KEY=MCP_CREDENTIAL_ENCRYPTION_KEY:latest,COMPOSIO_API_KEY=COMPOSIO_API_KEY:latest,FREESTYLE_API_KEY=FREESTYLE_API_KEY:latest,GOOGLE_API_KEY=GOOGLE_API_KEY:latest,MORPH_API_KEY=MORPH_API_KEY:latest" \

backend/agent/api.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,6 @@ async def cleanup():
181181

182182

183183

184-
185184
async def get_agent_run_with_access_check(client, agent_run_id: str, user_id: str):
186185
agent_run = await client.table('agent_runs').select('*').eq('run_id', agent_run_id).execute()
187186
if not agent_run.data:

backend/sandbox/api.py

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from utils.logger import logger
1616
from utils.auth_utils import get_optional_user_id
1717
from services.supabase import DBConnection
18+
from utils.qr_extractor import extract_expo_url
1819

1920
# Preview proxy URL - removes Daytona warning page
2021
PREVIEW_PROXY_URL = os.environ.get('PREVIEW_PROXY_URL', 'https://preview.trycheatcode.com')
@@ -1095,6 +1096,146 @@ async def get_sandbox_preview_url(
10951096
# Complex proxy endpoint removed - replaced with simple preview URL endpoint above
10961097

10971098

1099+
@router.get("/sandboxes/{sandbox_id}/expo-url")
1100+
async def get_expo_url(
1101+
sandbox_id: str,
1102+
user_id: str = Depends(get_optional_user_id)
1103+
):
1104+
"""
1105+
Get the Expo URL for a mobile sandbox using Daytona's deterministic preview links.
1106+
1107+
This endpoint uses Daytona's get_preview_link() API to generate a deterministic
1108+
exp:// URL that Expo Go can use to connect to the Metro bundler. This approach
1109+
is more reliable than parsing terminal logs for tunnel URLs.
1110+
1111+
The URL format is: exp://{port}-{sandbox_id}.{daytona_domain}
1112+
"""
1113+
if not db:
1114+
raise HTTPException(status_code=500, detail="Database not initialized")
1115+
1116+
if not user_id:
1117+
raise HTTPException(status_code=401, detail="Authentication required")
1118+
1119+
client = await db.client
1120+
1121+
# Verify the user has access to this sandbox
1122+
await verify_sandbox_access(client, sandbox_id, user_id)
1123+
1124+
try:
1125+
# Verify this is a mobile project
1126+
project_result = await client.table('projects').select('app_type').filter('sandbox->>id', 'eq', sandbox_id).execute()
1127+
if not project_result.data:
1128+
raise HTTPException(status_code=404, detail="Project not found for sandbox")
1129+
1130+
app_type = project_result.data[0].get('app_type', 'web')
1131+
1132+
if app_type != 'mobile':
1133+
return {
1134+
"expo_url": None,
1135+
"status": "not_mobile_project",
1136+
"message": "This endpoint is only available for mobile projects"
1137+
}
1138+
1139+
# Get sandbox (don't start if stopped - just check current state)
1140+
sandbox = await get_sandbox_by_id_safely(client, sandbox_id, start_if_stopped=False)
1141+
1142+
if not sandbox:
1143+
return {
1144+
"expo_url": None,
1145+
"status": "sandbox_not_running"
1146+
}
1147+
1148+
# DETERMINISTIC APPROACH: Use Daytona's get_preview_link API
1149+
# This returns a URL based on sandbox ID - no log parsing needed!
1150+
# Metro bundler runs on port 8081 for Expo apps
1151+
METRO_PORT = 8081
1152+
1153+
try:
1154+
# Get the deterministic preview link from Daytona
1155+
preview_info = await sandbox.get_preview_link(METRO_PORT)
1156+
daytona_url = preview_info.url if hasattr(preview_info, 'url') else str(preview_info)
1157+
1158+
logger.info(f"[EXPO URL] Got Daytona preview URL for port {METRO_PORT}: {daytona_url}")
1159+
1160+
if daytona_url:
1161+
# Convert HTTPS URL to exp:// URL for Expo Go
1162+
# Daytona URL format: https://8081-{sandbox_id}.{domain}
1163+
# Expo Go URL format: exp://8081-{sandbox_id}.{domain}
1164+
if daytona_url.startswith('https://'):
1165+
expo_url = daytona_url.replace('https://', 'exp://', 1)
1166+
elif daytona_url.startswith('http://'):
1167+
expo_url = daytona_url.replace('http://', 'exp://', 1)
1168+
else:
1169+
expo_url = f"exp://{daytona_url}"
1170+
1171+
# Remove any trailing slashes
1172+
expo_url = expo_url.rstrip('/')
1173+
1174+
logger.info(f"[EXPO URL] Deterministic Expo URL for sandbox {sandbox_id}: {expo_url}")
1175+
1176+
return {
1177+
"expo_url": expo_url,
1178+
"daytona_url": daytona_url, # Include original URL for debugging
1179+
"status": "available",
1180+
"method": "daytona_preview_link" # Indicate which method was used
1181+
}
1182+
1183+
except Exception as preview_error:
1184+
logger.warning(f"[EXPO URL] Failed to get Daytona preview link: {preview_error}")
1185+
# Fall through to legacy log parsing method
1186+
1187+
# FALLBACK: Try legacy log parsing if Daytona preview link fails
1188+
# This handles cases where the Expo tunnel might have a different URL
1189+
logger.info(f"[EXPO URL] Falling back to log parsing method for sandbox {sandbox_id}")
1190+
1191+
try:
1192+
# Try to extract exp.direct URL from tmux session (Expo's tunnel format)
1193+
extract_cmd = """
1194+
# Check for exp.direct URL in Metro output (Expo's tunnel domain)
1195+
if command -v tmux &> /dev/null && tmux has-session -t dev_server_mobile 2>/dev/null; then
1196+
TMUX_OUTPUT=$(tmux capture-pane -t dev_server_mobile -p -S -200 2>/dev/null)
1197+
# Look for exp.direct URL pattern
1198+
EXP_URL=$(echo "$TMUX_OUTPUT" | grep -oE 'exp://[a-zA-Z0-9-]+\.exp\.direct' | tail -1)
1199+
if [ -n "$EXP_URL" ]; then
1200+
echo "$EXP_URL"
1201+
exit 0
1202+
fi
1203+
# Also check for exp.direct with port suffix
1204+
EXP_URL=$(echo "$TMUX_OUTPUT" | grep -oE 'exp://[a-zA-Z0-9-]+-[0-9]+\.exp\.direct' | tail -1)
1205+
if [ -n "$EXP_URL" ]; then
1206+
echo "$EXP_URL"
1207+
exit 0
1208+
fi
1209+
fi
1210+
echo ""
1211+
"""
1212+
1213+
result = await sandbox.process.exec(extract_cmd, timeout=10)
1214+
1215+
if result and hasattr(result, 'result') and result.result:
1216+
output = result.result.strip()
1217+
if output and output.startswith('exp://'):
1218+
logger.info(f"[EXPO URL] Found Expo tunnel URL from logs: {output}")
1219+
return {
1220+
"expo_url": output,
1221+
"status": "available",
1222+
"method": "log_parsing"
1223+
}
1224+
1225+
except Exception as log_error:
1226+
logger.warning(f"[EXPO URL] Log parsing fallback failed: {log_error}")
1227+
1228+
return {
1229+
"expo_url": None,
1230+
"status": "not_ready",
1231+
"message": "Expo URL not yet available. The dev server may still be starting."
1232+
}
1233+
1234+
except Exception as e:
1235+
logger.error(f"Error getting Expo URL for sandbox {sandbox_id}: {str(e)}")
1236+
raise HTTPException(status_code=500, detail=f"Error getting Expo URL: {str(e)}")
1237+
1238+
10981239
@router.get("/sandboxes/{sandbox_id}/dev-server/stream")
10991240
async def stream_dev_server_status(
11001241
sandbox_id: str,
@@ -1158,10 +1299,11 @@ async def event_generator():
11581299
# Stream logs using Daytona's async log streaming
11591300
server_ready = False
11601301
log_buffer = []
1302+
expo_url_found = None # Track if we've found an Expo URL
11611303

11621304
async def on_log_chunk(chunk: str):
11631305
"""Callback for real-time log chunks."""
1164-
nonlocal server_ready, log_buffer
1306+
nonlocal server_ready, log_buffer, expo_url_found
11651307
log_buffer.append(chunk)
11661308

11671309
# Check if any ready pattern is in the logs
@@ -1171,6 +1313,13 @@ async def on_log_chunk(chunk: str):
11711313
server_ready = True
11721314
break
11731315

1316+
# For mobile apps, also try to extract the Expo URL from logs
1317+
if app_type == 'mobile' and expo_url_found is None:
1318+
extracted_url = extract_expo_url(combined_logs)
1319+
if extracted_url:
1320+
expo_url_found = extracted_url
1321+
logger.info(f"Extracted Expo URL from logs: {extracted_url}")
1322+
11741323
# Start streaming logs asynchronously
11751324
try:
11761325
# Get the command ID - Command object has 'id' field, not 'cmd_id'
@@ -1205,6 +1354,11 @@ async def on_log_chunk(chunk: str):
12051354
except Exception:
12061355
pass
12071356

1357+
# For mobile apps, emit the Expo URL for QR code generation
1358+
if app_type == 'mobile' and expo_url_found:
1359+
yield f"data: {json.dumps({'type': 'expo_url', 'url': expo_url_found})}\n\n"
1360+
logger.info(f"Emitted Expo URL via SSE: {expo_url_found}")
1361+
12081362
break
12091363

12101364
# Check if client disconnected
Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,97 @@
1+
# ================================================================== #
2+
# Cheatcode Mobile App Sandbox - Expo/React Native #
3+
# Optimized for AI Agent Coding #
4+
# ================================================================== #
5+
16
FROM node:20-alpine AS build
27

3-
# ---------- Expo React Native Mobile App Development Environment ----------
4-
# This Dockerfile creates a sandbox for building mobile apps with Expo Router
5-
# Template: cheatcode-mobile (React Native + NativeWind + Expo Router)
8+
# ---------- Build arguments ----------
9+
ARG PNPM_VERSION=9.15.0
10+
ARG TEMPLATE_REPO="https://github.com/iamjr15/cheatcode-mobile.git"
11+
ARG TEMPLATE_REF="master"
612

7-
# ---------- Build arguments & environment ----------
8-
ARG PNPM_VERSION=9.2.0
13+
# ---------- Environment ----------
914
ENV PNPM_HOME="/pnpm"
1015
ENV PATH="$PNPM_HOME:$PATH"
1116
ENV PNPM_STORE_PATH="/pnpm/store/v3"
1217

13-
# ---------- Build dependencies ----------
14-
RUN apk add --no-cache git curl
15-
16-
# ---------- Corepack / pnpm ----------
17-
RUN corepack enable \
18+
# ---------- Install build dependencies & setup pnpm ----------
19+
RUN apk add --no-cache git curl bash \
20+
&& corepack enable \
1821
&& corepack prepare "pnpm@$PNPM_VERSION" --activate
1922

2023
# ---------- Workdir ----------
2124
WORKDIR /workspace/cheatcode-mobile
2225

23-
# ---------- Fetch cheatcode-mobile template & install deps ----------
24-
ARG TEMPLATE_REPO="https://github.com/iamjr15/cheatcode-mobile.git"
25-
ARG TEMPLATE_REF="master"
26-
27-
# Clone only the template (shallow) and install dependencies
26+
# ---------- Clone template and install dependencies ----------
2827
RUN git clone --depth 1 --branch "$TEMPLATE_REF" "$TEMPLATE_REPO" . \
29-
&& rm -rf .git \
3028
&& pnpm install \
31-
&& npm install -g @expo/cli @expo/ngrok@^4.1.0 \
32-
&& npx --yes expo install --fix \
3329
&& pnpm store prune \
3430
&& rm -rf /root/.npm /root/.cache
3531

36-
# ---------- Supabase CLI (static binary) ----------
37-
RUN curl -fsSL https://github.com/supabase/cli/releases/latest/download/supabase_linux_amd64.tar.gz \
38-
| tar -xz -C /usr/local/bin/ supabase
32+
# ---------- Install Expo CLI and global tools ----------
33+
RUN npm install -g @expo/cli expo-doctor eas-cli typescript@5
3934

4035
# ================================================================== #
41-
# Runtime (slim) Stage #
36+
# Runtime Stage #
4237
# ================================================================== #
4338
FROM node:20-alpine AS runtime
4439

45-
# ---------- Minimal runtime utilities ----------
46-
RUN apk add --no-cache git bash \
47-
&& echo 'install_if !documentation' >> /etc/apk/world
40+
# ---------- Labels for image tracking ----------
41+
LABEL org.opencontainers.image.title="Cheatcode Mobile Sandbox"
42+
LABEL org.opencontainers.image.description="Expo/React Native development environment for AI agent coding"
43+
LABEL org.opencontainers.image.version="2.0.0"
44+
45+
# ---------- Build arguments ----------
46+
ARG PNPM_VERSION=9.15.0
4847

49-
# ---------- Environment variables for pnpm ----------
48+
# ---------- Environment variables ----------
5049
ENV PNPM_HOME="/pnpm"
5150
ENV PATH="$PNPM_HOME:$PATH"
5251
ENV PNPM_STORE_PATH="/pnpm/store/v3"
52+
ENV NODE_ENV="development"
53+
ENV EXPO_NO_TELEMETRY=1
54+
ENV CI=1
5355

54-
# ---------- Enable corepack in runtime stage ----------
55-
RUN corepack enable
56+
# ---------- Install runtime dependencies & setup pnpm ----------
57+
RUN apk add --no-cache git bash curl \
58+
&& corepack enable \
59+
&& corepack prepare "pnpm@$PNPM_VERSION" --activate
5660

5761
# ---------- Workdir & PATH ----------
5862
WORKDIR /workspace/cheatcode-mobile
5963
ENV PATH="/workspace/cheatcode-mobile/node_modules/.bin:$PNPM_HOME:$PATH"
6064

61-
# ---------- Copy ready-to-run app ----------
65+
# ---------- Copy application and pnpm store from build stage ----------
6266
COPY --from=build /workspace/cheatcode-mobile /workspace/cheatcode-mobile
63-
COPY --from=build /usr/local/bin/supabase /usr/local/bin/supabase
6467
COPY --from=build /pnpm /pnpm
68+
COPY --from=build /usr/local/lib/node_modules /usr/local/lib/node_modules
69+
COPY --from=build /usr/local/bin /usr/local/bin
6570

66-
# ---------- Copy corepack and pnpm setup from build stage ----------
67-
# Copy the corepack binary and its configuration
68-
COPY --from=build /usr/local/bin/corepack /usr/local/bin/corepack
69-
70-
# Copy pnpm setup from build stage (optional files)
71-
RUN mkdir -p /usr/local/lib/node_modules
72-
73-
# ---------- Prepare pnpm in runtime (fallback) ----------
74-
ARG PNPM_VERSION=9.2.0
75-
RUN corepack prepare "pnpm@$PNPM_VERSION" --activate || echo "pnpm already prepared"
76-
77-
# ---------- Fix pnpm store location ----------
78-
# Ensure pnpm store directory exists and is properly configured
71+
# ---------- Configure pnpm store ----------
7972
RUN mkdir -p /pnpm/store/v3 \
8073
&& pnpm config set store-dir /pnpm/store/v3 \
8174
&& pnpm config set package-import-method copy
8275

83-
# ---------- Exposed dev ports ----------
84-
# 8081: Expo Metro bundler, 19000: Expo DevTools, 19001: Expo tunnel
85-
EXPOSE 8081 19000 19001
76+
# ---------- Initialize git for agent version control ----------
77+
RUN git config --global user.email "[email protected]" \
78+
&& git config --global user.name "Cheatcode Agent" \
79+
&& git config --global init.defaultBranch main \
80+
&& rm -rf .git \
81+
&& git init \
82+
&& git add -A \
83+
&& git commit -m "Initial template state"
8684

87-
# ---------- Default command ----------
88-
ENTRYPOINT ["sleep", "infinity"]
85+
# ---------- Exposed dev ports ----------
86+
# 8081: Metro bundler
87+
# 19000: Expo DevTools
88+
# 19001: Expo tunnel
89+
# 19002: Expo web
90+
EXPOSE 8081 19000 19001 19002
91+
92+
# ---------- Health check ----------
93+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
94+
CMD node -e "console.log('healthy')" || exit 1
95+
96+
# ---------- Default command (keeps container running) ----------
97+
ENTRYPOINT ["sleep", "infinity"]

0 commit comments

Comments
 (0)