Skip to content

Commit 50d23c3

Browse files
committed
fix(agent-worker): Expo Go QR now works — signed Daytona preview + EXPO_PACKAGER_PROXY_URL
The mobile QR was broken: expoUrlFromPreview derived the exp:// URL from the custom token-gated preview proxy (preview.trycheatcode.com) and dropped the ?__cc_pt token, so Expo Go hit the proxy with no credential -> 401. (V1 worked by using Daytona's raw preview host directly.) Fix (verified end-to-end against a live sandbox + Expo CLI source): - Derive the deep link from Daytona's SIGNED preview URL for the Metro port (8081), whose token lives in the SUBDOMAIN (https://8081-<token>.daytonaproxy01.net) — no header/cookie needed, so Expo Go reaches it directly (curl-verified: HTTP 200, native manifest served). QR value = exps://8081-<token>.daytonaproxy01.net. - Start Metro with EXPO_PACKAGER_PROXY_URL=<signed https url> so its manifest launchAsset/bundle URLs use that public host instead of 127.0.0.1 (Expo's own documented proxy override; V1's older SDK used the request Host so didn't need it). - Applied on both the run path (startExpoDevServer) and the wake path (wakePreview re-mints a fresh 24h signed URL + re-sets EXPO_PACKAGER_PROXY_URL on relaunch). - Deleted expoUrlFromPreview; added signedUrlToExpo (https->exps, http->exp); ProjectSandbox.getSignedPreviewUrl wraps the existing daytona-client method. No tunnel/ngrok, no Daytona image change. Live mobile build produced exps://8081-ynuywcwxudw6lprr.daytonaproxy01.net and its manifest launchAsset.url host = the signed host (200), not 127.0.0.1. Gate green.
1 parent 3c8aac4 commit 50d23c3

7 files changed

Lines changed: 152 additions & 23 deletions

File tree

apps/agent-worker/src/durable-objects/agent-run-app-builder.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,16 @@ import {
2020
appBuilderLayoutSource,
2121
appBuilderPageSource,
2222
} from "./app-builder-template";
23+
import { signedUrlToExpo } from "./project-sandbox-preview";
2324

2425
export { restoreBestEffortSnapshot, snapshotAppBuilderWorkspace };
2526

2627
const APP_BUILDER_DIR = "/workspace/app";
2728
const APP_BUILDER_PORT = 5173;
2829
const EXPO_METRO_PORT = 8081;
30+
// 24h is Daytona's max signed-preview TTL; the token rides in the subdomain so Expo Go needs no
31+
// header, and we re-mint on every dev-server (re)start so it never serves an expired manifest URL.
32+
const SIGNED_PREVIEW_TTL_SECONDS = 24 * 60 * 60;
2933
const DEFAULT_PREVIEW_HOSTNAME = "trycheatcode.com";
3034
const PreviewHostnameSchema = z.string().trim().min(1).max(255).default(DEFAULT_PREVIEW_HOSTNAME);
3135

@@ -130,13 +134,12 @@ async function prepareTemplateWorkspace(
130134
async function startTemplatePreview(
131135
options: RunAppBuilderOptions & { mobile: boolean },
132136
): Promise<{ previewUrl: string; expoUrl: string | null }> {
133-
const { append, env, mobile, sandbox, setRunStage } = options;
137+
const { append, env, logger, mobile, sandbox, setRunStage } = options;
134138
setRunStage("Starting the dev server.");
135139
const preview = mobile
136-
? await startExpoDevServer(env, sandbox)
137-
: await startAppBuilderDevServer(env, sandbox);
138-
const previewUrl = preview.previewUrl;
139-
const expoUrl = mobile ? expoUrlFromPreview(preview.previewUrl) : null;
140+
? await startExpoDevServer(env, sandbox, logger)
141+
: { ...(await startAppBuilderDevServer(env, sandbox)), expoUrl: null };
142+
const { expoUrl, previewUrl } = preview;
140143
await append({
141144
type: "data-sandbox-status",
142145
data: { v: 1, status: "ready", previewUrl, ...(expoUrl ? { expoUrl } : {}) },
@@ -353,15 +356,6 @@ function repoImportError(message: string): APIError {
353356
});
354357
}
355358

356-
export function expoUrlFromPreview(previewUrl: string): null | string {
357-
const parsed = new URL(previewUrl);
358-
if (parsed.hostname.endsWith(".localhost")) {
359-
return null;
360-
}
361-
const scheme = parsed.protocol === "https:" ? "exps" : "exp";
362-
return `${scheme}://${parsed.host}${parsed.pathname === "/" ? "" : parsed.pathname}`;
363-
}
364-
365359
export async function warmSandbox(
366360
sandbox: ProjectSandboxStub,
367361
logger: AgentRunLogger,
@@ -409,8 +403,13 @@ function writeAppBuilderFiles(
409403
async function startExpoDevServer(
410404
env: AgentRunAppBuilderEnv,
411405
sandbox: ProjectSandboxStub,
412-
): Promise<{ previewUrl: string }> {
413-
return executeStartDevServer(
406+
logger: AgentRunLogger,
407+
): Promise<{ previewUrl: string; expoUrl: string | null }> {
408+
// Mint the signed Metro URL BEFORE starting the server: Expo Go reaches the manifest via this
409+
// header-free URL, and Metro must know its public host (EXPO_PACKAGER_PROXY_URL) so the manifest's
410+
// launchAsset/bundle URLs point at the signed host instead of 127.0.0.1 (which Expo Go can't hit).
411+
const signedUrl = await getSignedMetroUrl(sandbox, logger);
412+
const preview = await executeStartDevServer(
414413
{
415414
// `--web` makes the single Metro dev server also serve the react-native-web
416415
// build as a real web page at `/` (iframe-renderable in the Computer panel),
@@ -431,6 +430,7 @@ async function startExpoDevServer(
431430
env: {
432431
CI: "1",
433432
EXPO_NO_TELEMETRY: "1",
433+
...(signedUrl ? { EXPO_PACKAGER_PROXY_URL: signedUrl } : {}),
434434
},
435435
hostname: resolvePreviewHostname(env),
436436
name: "app-preview",
@@ -439,6 +439,33 @@ async function startExpoDevServer(
439439
},
440440
{ sandbox },
441441
);
442+
return {
443+
previewUrl: preview.previewUrl,
444+
expoUrl: signedUrl ? signedUrlToExpo(signedUrl) : null,
445+
};
446+
}
447+
448+
// Best-effort: a Daytona-signed preview URL for the Metro port (token in the subdomain). Null when
449+
// the sandbox stub can't sign (older stub) or the call fails — the run still proceeds without a QR.
450+
async function getSignedMetroUrl(
451+
sandbox: ProjectSandboxStub,
452+
logger: AgentRunLogger,
453+
): Promise<string | null> {
454+
if (!sandbox.getSignedPreviewUrl) {
455+
return null;
456+
}
457+
try {
458+
const signed = await sandbox.getSignedPreviewUrl({
459+
expiresInSeconds: SIGNED_PREVIEW_TTL_SECONDS,
460+
port: EXPO_METRO_PORT,
461+
});
462+
return signed.url;
463+
} catch (error) {
464+
logger.warn("expo_signed_preview_url_failed", {
465+
error: error instanceof Error ? error.message : "Unknown signed preview URL error",
466+
});
467+
return null;
468+
}
442469
}
443470

444471
async function startAppBuilderDevServer(

apps/agent-worker/src/durable-objects/project-sandbox-preview.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ export async function buildPreviewUrl(input: BuildPreviewUrlInput): Promise<Buil
5757
return { expiresAt: new Date(exp).toISOString(), url, token };
5858
}
5959

60+
/**
61+
* Turn a Daytona-signed preview URL (`https://8081-<token>.daytonaproxy01.net`) into an Expo Go
62+
* deep link by swapping the scheme: `https://` → `exps://` (the daytonaproxy edge is https-only,
63+
* so the secure Expo scheme is required) and `http://` → `exp://`. The token stays in the host, so
64+
* Expo Go reaches the Metro manifest with no header/cookie. Keeps the full host/path/query and only
65+
* trims a trailing slash (unlike the old proxy-derived helper, which dropped the token).
66+
*/
67+
export function signedUrlToExpo(url: string): string {
68+
const withScheme = url.startsWith("https://")
69+
? `exps://${url.slice("https://".length)}`
70+
: url.startsWith("http://")
71+
? `exp://${url.slice("http://".length)}`
72+
: url;
73+
return withScheme.replace(/\/+$/u, "");
74+
}
75+
6076
function normalizeHostname(hostname: string): string {
6177
const trimmed = hostname.trim().toLowerCase();
6278
const withoutScheme = trimmed.includes("://") ? (trimmed.split("://")[1] ?? trimmed) : trimmed;

apps/agent-worker/src/durable-objects/project-sandbox-runtime.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,17 @@ export const ProjectWakePreviewInputSchema = z
190190
})
191191
.strict();
192192

193+
export const ProjectSignedPreviewUrlInputSchema = z
194+
.object({
195+
port: z.number().int().positive().max(65_535),
196+
expiresInSeconds: z
197+
.number()
198+
.int()
199+
.positive()
200+
.max(24 * 60 * 60),
201+
})
202+
.strict();
203+
193204
export const ProjectCreateBackupInputSchema = z
194205
.object({
195206
dir: WorkspacePathSchema.default("/workspace"),
@@ -223,6 +234,7 @@ export type ProjectExposePortInput = z.input<typeof ProjectExposePortInputSchema
223234
export type ProjectCodeServerInput = z.input<typeof ProjectCodeServerInputSchema>;
224235
export type ProjectUnexposePortInput = z.input<typeof ProjectUnexposePortInputSchema>;
225236
export type ProjectWakePreviewInput = z.input<typeof ProjectWakePreviewInputSchema>;
237+
export type ProjectSignedPreviewUrlInput = z.input<typeof ProjectSignedPreviewUrlInputSchema>;
226238

227239
/** Result of waking a preview: the (possibly restarted) dev-server preview URL + liveness. */
228240
export interface ProjectWakePreviewResult {
@@ -231,6 +243,9 @@ export interface ProjectWakePreviewResult {
231243
port?: number;
232244
url?: string;
233245
expiresAt?: string;
246+
// exp(s):// deep link for the Expo Go QR — only present for a mobile (Metro/8081) dev server,
247+
// regenerated from a fresh signed preview URL on every wake.
248+
expoUrl?: string;
234249
}
235250

236251
/** Current Daytona lifecycle state for the project's sandbox (webhook/status surface). */

apps/agent-worker/src/durable-objects/project-sandbox.ts

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
type SandboxMeteringContext,
3131
setSandboxQuotaPeriod,
3232
} from "./project-sandbox-metering";
33-
import { buildPreviewUrl } from "./project-sandbox-preview";
33+
import { buildPreviewUrl, signedUrlToExpo } from "./project-sandbox-preview";
3434
import { emptyConsoleSnapshot, sliceProcessLogs } from "./project-sandbox-process-logs";
3535
import {
3636
commandToShellString,
@@ -61,6 +61,8 @@ import {
6161
type ProjectSandboxRuntimeState,
6262
type ProjectSearchFilesInput,
6363
ProjectSearchFilesInputSchema,
64+
type ProjectSignedPreviewUrlInput,
65+
ProjectSignedPreviewUrlInputSchema,
6466
type ProjectStartProcessInput,
6567
ProjectStartProcessInputSchema,
6668
type ProjectUnexposePortInput,
@@ -103,6 +105,12 @@ const DEFAULT_IDLE_STOP_MIN = 30;
103105
const AUTO_ARCHIVE_MIN = 1_440; // 1 day stopped → cold storage
104106
const NEVER_AUTO_DELETE = -1; // the sandbox disk is the durable store
105107
const APP_PREVIEW_TTL_MS = 24 * 60 * 60 * 1000;
108+
// Daytona's signed preview URL (token in the subdomain) TTL — 24h is Daytona's max. Regenerated on
109+
// every mobile dev-server (re)start so Expo Go always has an unexpired, header-free manifest URL.
110+
const SIGNED_PREVIEW_TTL_SECONDS = 24 * 60 * 60;
111+
// Expo Metro dev-server port. A dev server tracked on this port is a mobile build, so its wake path
112+
// re-mints a signed preview URL + EXPO_PACKAGER_PROXY_URL and returns an exp(s):// deep link.
113+
const EXPO_METRO_PORT = 8081;
106114
// Upper bound for a dev server (Expo Metro / Next) to boot when waking a stopped preview.
107115
const PREVIEW_WAKE_TIMEOUT_MS = 90_000;
108116
const CODE_SERVER_PORT = 13_340;
@@ -486,6 +494,18 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
486494
};
487495
}
488496

497+
// Mint a Daytona-signed preview URL for a port. Unlike the custom token-gated proxy URL, the
498+
// token lives in the subdomain, so the URL is reachable with no header/cookie — used to hand
499+
// Expo Go a working manifest URL and to set the Metro dev server's EXPO_PACKAGER_PROXY_URL.
500+
public async getSignedPreviewUrl(
501+
input: ProjectSignedPreviewUrlInput,
502+
): Promise<{ token: string; url: string }> {
503+
const parsed = ProjectSignedPreviewUrlInputSchema.parse(input);
504+
const id = await this.ensureSandbox();
505+
const link = await this.client().getSignedPreviewUrl(id, parsed.port, parsed.expiresInSeconds);
506+
return { token: link.token, url: link.url };
507+
}
508+
489509
public async exposeCodeServer(input: ProjectCodeServerInput): Promise<{
490510
expiresAt: string;
491511
port: number;
@@ -531,9 +551,12 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
531551
return { running: false, state: "started" };
532552
}
533553
const port = record.port;
554+
// Mobile (Metro/8081): re-mint the header-free signed URL and thread EXPO_PACKAGER_PROXY_URL
555+
// into the relaunch env so Metro emits bundle/asset URLs on that host after a restart.
556+
const mobile = await this.mobileExpoProxy(id, port, record);
534557
let running = await this.isPortAlive(id, port);
535558
if (!running) {
536-
await this.relaunchDevServer(id, "app-preview", record);
559+
await this.relaunchDevServer(id, "app-preview", mobile?.record ?? record);
537560
await this.waitForPort(id, port, "/", PREVIEW_WAKE_TIMEOUT_MS).catch(() => undefined);
538561
running = await this.isPortAlive(id, port);
539562
}
@@ -550,7 +573,38 @@ export class ProjectSandbox extends DurableObject<ProjectSandboxEnv> {
550573
secret,
551574
ttlMs: APP_PREVIEW_TTL_MS,
552575
});
553-
return { expiresAt: built.expiresAt, port, running, state: "started", url: built.url };
576+
return {
577+
expiresAt: built.expiresAt,
578+
port,
579+
running,
580+
state: "started",
581+
url: built.url,
582+
...(mobile?.expoUrl ? { expoUrl: mobile.expoUrl } : {}),
583+
};
584+
}
585+
586+
// For a mobile Expo dev server (Metro on 8081), mint a fresh 24h signed preview URL (token in the
587+
// subdomain, so Expo Go reaches it with no header), thread EXPO_PACKAGER_PROXY_URL into the
588+
// relaunch env so Metro's bundle/asset URLs use that signed host, and derive the exps:// deep
589+
// link. Returns null for non-mobile ports or when signing is unavailable.
590+
private async mobileExpoProxy(
591+
id: string,
592+
port: number,
593+
record: ProcessRecord,
594+
): Promise<{ expoUrl: string; record: ProcessRecord } | null> {
595+
if (port !== EXPO_METRO_PORT) {
596+
return null;
597+
}
598+
const signed = await this.client()
599+
.getSignedPreviewUrl(id, port, SIGNED_PREVIEW_TTL_SECONDS)
600+
.catch(() => null);
601+
if (!signed) {
602+
return null;
603+
}
604+
return {
605+
expoUrl: signedUrlToExpo(signed.url),
606+
record: { ...record, env: { ...record.env, EXPO_PACKAGER_PROXY_URL: signed.url } },
607+
};
554608
}
555609

556610
// Current Daytona lifecycle state without forcing a start — the status surface for the

apps/agent-worker/src/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ import {
5858
withRunLocation,
5959
} from "./agent-routing";
6060
import { AgentRun } from "./durable-objects/agent-run";
61-
import { expoUrlFromPreview } from "./durable-objects/agent-run-app-builder";
6261
import { ProjectSandbox } from "./durable-objects/project-sandbox";
6362
import { formatAgentRouteError } from "./error-handling";
6463
import {
@@ -618,10 +617,10 @@ agentApp.post("/v1/threads/:threadId/sandbox/preview/wake", async (c) => {
618617
const sandbox = project
619618
? await sandboxForProject(c.env, userId, project.id)
620619
: await sandboxForThread(c.env, userId, threadId);
620+
// wakePreview re-mints the signed Metro URL for a mobile dev server and returns the exp(s)://
621+
// deep link directly (expoUrl) — the web/local paths leave it undefined.
621622
const result = await sandbox.wakePreview({ hostname: resolvePreviewHostname(c.env) });
622-
// exp:// deep link only makes sense for the Expo Metro port (8081); null for web/local.
623-
const expoUrl = result.url && result.port === 8081 ? expoUrlFromPreview(result.url) : null;
624-
return c.json(SandboxPreviewWakeSchema.parse({ ...result, ...(expoUrl ? { expoUrl } : {}) }));
623+
return c.json(SandboxPreviewWakeSchema.parse(result));
625624
});
626625

627626
// Current sandbox lifecycle state for the preview panel (polled while the panel is open so the

packages/tools-code/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export type {
125125
SandboxSearchFilesInput,
126126
SandboxSearchFilesResult,
127127
SandboxSearchMatch,
128+
SandboxSignedPreviewUrlInput,
129+
SandboxSignedPreviewUrlResult,
128130
SandboxStartProcessInput,
129131
SandboxStatus,
130132
SandboxTerminalInput,

packages/tools-code/src/runtime.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,21 @@ export interface SandboxUnexposePortInput {
169169
port: number;
170170
}
171171

172+
export interface SandboxSignedPreviewUrlInput {
173+
port: number;
174+
expiresInSeconds: number;
175+
}
176+
177+
/**
178+
* A Daytona-signed preview URL whose access token is encoded in the subdomain
179+
* (`https://<port>-<token>.daytonaproxy01.net`), so it needs no header/cookie — the form
180+
* Expo Go can reach for exp(s):// deep links and `EXPO_PACKAGER_PROXY_URL`.
181+
*/
182+
export interface SandboxSignedPreviewUrlResult {
183+
url: string;
184+
token: string;
185+
}
186+
172187
export interface SandboxBackupHandle {
173188
id: string;
174189
dir: string;
@@ -197,6 +212,7 @@ export interface SandboxLike {
197212
ensureReady?(): Promise<SandboxStatus>;
198213
exec?(input: SandboxExecInput): Promise<SandboxExecResult>;
199214
exposePort?(input: SandboxExposePortInput): Promise<SandboxExposePortResult>;
215+
getSignedPreviewUrl?(input: SandboxSignedPreviewUrlInput): Promise<SandboxSignedPreviewUrlResult>;
200216
killAllProcesses?(): Promise<number>;
201217
killProcess?(input: SandboxKillProcessInput): Promise<SandboxKillProcessResult>;
202218
listFiles?(input: SandboxListFilesInput): Promise<SandboxListFilesResult>;

0 commit comments

Comments
 (0)