Skip to content

Commit 0888ef4

Browse files
authored
fix(agent): start mobile previews through trusted launcher (#219)
## Why Production mobile app-builder runs repeatedly failed before the model could edit the scaffold. Cloudflare Workflow history showed the exact error: `pnpm must be passed as direct command argv.` The app-builder had already produced a trusted native-mirror Expo launcher, but then sent it through the generic model-command normalizer. That normalizer replaced it with a shell-wrapped Expo self-heal command, which the sandbox package policy correctly rejected. ## What changed - Launch the app-builder's already-resolved Next and Expo preview commands directly through the provider-neutral sandbox process contract. - Preserve strict normalization and package-manager rejection for model-supplied dev-server commands. - Remove the redundant second project-port allocation and the now-unused combined executor. - Activate immutable snapshot dependencies for exact scaffold manifests; detach them before any package mutation or non-template restore. - Strengthen the snapshot smoke to start Expo through the immutable runtime dependency tree and assert the mirror's dependency link. - Document the trusted harness/model-command boundary and dependency lifecycle. ## Architecture and migration effects - No database or migration changes. - The sandbox snapshot and agent Worker must be promoted together. - Existing strict package-manager policy remains unchanged. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - Reproduced the production failure on `trycheatcode.com` and inspected the Cloudflare Workflow instance; every retry failed with the same direct-argv policy error before model execution. - Reproduced the exact production Expo launch in the active Daytona sandbox; Metro remained healthy for the full bounded test, proving the child runtime was not the terminal failure. Production browser acceptance will be repeated on a new mobile project after the immutable snapshot and Worker are promoted.
1 parent 0589957 commit 0888ef4

10 files changed

Lines changed: 135 additions & 89 deletions

File tree

.github/workflows/build-snapshot.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ jobs:
341341
CI=1 EXPO_NO_TELEMETRY=1 NODE_PATH=/home/node/.cheatcode/app-runtimes/expo/node_modules \
342342
/opt/cheatcode/project-source-sync.py preview-run \
343343
"$expo_source" "$expo_mirror" "$expo_lock" "$expo_mirror" \
344-
/home/node/cheatcode-expo-template -- \
344+
/home/node/.cheatcode/app-runtimes/expo -- \
345345
/home/node/.cheatcode/app-runtimes/expo/node_modules/.bin/expo \
346346
start -c --web --host lan --port 5201 > "$expo_log" 2>&1 &
347347
expo_pid="$!"
@@ -354,6 +354,9 @@ jobs:
354354
sleep 1
355355
expo_attempt=$((expo_attempt + 1))
356356
done
357+
test -L "$expo_mirror/node_modules"
358+
test "$(readlink -f "$expo_mirror/node_modules")" = \
359+
/home/node/.cheatcode/app-runtimes/expo/node_modules
357360
printf "%s\n" "mobile source synchronization works" > "$expo_source/.cheatcode-mobile-sync-smoke"
358361
expo_sync_attempt=0
359362
until test -f "$expo_mirror/.cheatcode-mobile-sync-smoke" && \

apps/agent-worker/README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,13 @@ a stale listening port. The local source,
177177
dependency tree, and build cache are disposable;
178178
wake and restart reconstruct them from the durable project without changing the Files surface.
179179
Persisted pnpm-backed preview commands restore a missing sandbox-local dependency tree before the
180-
server starts. A package/lock/config digest skips the install entirely when the local dependency tree
181-
is current; automatic preview restoration does not create a durable lockfile. An unchanged baked
182-
app-builder template continues to use the immutable runtime without an unnecessary install.
180+
server starts. Exact scaffold manifests link the disposable mirror to the matching immutable runtime
181+
dependency tree; the package boundary detaches that link before any dependency mutation. A
182+
package/lock/config digest skips the install entirely when a project-local dependency tree is current;
183+
automatic preview restoration does not create a durable lockfile.
184+
The app-builder harness launches its already-resolved native-mirror command directly through the
185+
sandbox contract. Model-supplied dev-server commands continue through framework and package-manager
186+
normalization; trusted harness commands are not reinterpreted as user shell input.
183187
The immutable sandbox exposes that synchronizer as the root-owned
184188
`/opt/cheatcode/project-source-sync.py` helper, keeping Worker-to-sandbox command arguments small
185189
and making the snapshot the source of truth for executable sandbox runtime code.

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

Lines changed: 65 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
import {
2-
executeShellExec,
3-
executeShellTerminal,
4-
executeStartDevServer,
5-
} from "@cheatcode/agent-core/tools/code";
1+
import { executeShellExec, executeShellTerminal } from "@cheatcode/agent-core/tools/code";
62
import {
73
type AnalyticsBindings,
84
APIError,
@@ -56,6 +52,9 @@ const DEFAULT_MOBILE_PORT = 8081;
5652
// Keep the header-free Expo Go capability short-lived. It is re-minted whenever
5753
// the preview wakes, so a fresh QR is available without leaving a day-long URL live.
5854
const SIGNED_PREVIEW_TTL_SECONDS = 60 * 60;
55+
const PREVIEW_KEEP_ALIVE_MS = 60 * 60 * 1_000;
56+
const PREVIEW_MAX_RESTARTS = 3;
57+
const PREVIEW_START_TIMEOUT_MS = 180_000;
5958

6059
type AppendChunk = (chunk: UIMessageChunk) => Promise<void>;
6160

@@ -510,34 +509,23 @@ async function startExpoDevServer(
510509
// header-free URL, and Metro must know its public host (EXPO_PACKAGER_PROXY_URL) so the manifest's
511510
// launchAsset/bundle URLs point at the signed host instead of 127.0.0.1 (which Expo Go can't hit).
512511
const signedUrl = await getSignedMetroUrl(sandbox, logger, workspace.port);
513-
await executeStartDevServer(
514-
{
515-
// `--web` makes the single Metro dev server also serve the react-native-web
516-
// build as a real web page at `/` (iframe-renderable in the Computer panel),
517-
// while the SAME server keeps answering exp:// manifests for Expo Go — so we
518-
// get both the in-panel preview and the QR from one process on the project port.
519-
// Metro runs against the same supervised native-disk source mirror as Next. Durable
520-
// workspace edits reach that mirror atomically, so Metro's native watcher hot-reloads them
521-
// without the old post-run restart.
522-
command: localExpoPreviewCommand({
523-
port: workspace.port,
524-
sourceDir: workspace.dir,
525-
workspaceSlug: workspace.slug,
526-
}),
527-
cwd: workspace.dir,
528-
env: {
529-
CHEATCODE_APP_RUNTIME: "expo",
530-
CI: "1",
531-
EXPO_NO_TELEMETRY: "1",
532-
...(signedUrl ? { EXPO_PACKAGER_PROXY_URL: signedUrl } : {}),
533-
},
534-
isMobile: true,
535-
name: workspace.slot,
512+
// `--web` makes the single Metro dev server also serve the react-native-web build as a real page
513+
// in Computer while the same process answers Expo Go manifests. This command is already the
514+
// trusted native-mirror launcher, so it must not pass through model-command normalization.
515+
await startManagedPreview(sandbox, workspace, {
516+
command: localExpoPreviewCommand({
536517
port: workspace.port,
537-
timeoutMs: 180_000,
518+
sourceDir: workspace.dir,
519+
workspaceSlug: workspace.slug,
520+
}),
521+
env: {
522+
CHEATCODE_APP_RUNTIME: "expo",
523+
CI: "1",
524+
EXPO_NO_TELEMETRY: "1",
525+
...(signedUrl ? { EXPO_PACKAGER_PROXY_URL: signedUrl } : {}),
538526
},
539-
{ sandbox, workspaceDir: workspace.dir, workspaceSlug: workspace.slug },
540-
);
527+
shouldReuseMatchingProcess: false,
528+
});
541529
}
542530

543531
// Best-effort Metro bootstrap capability. It is passed only to the live process so Metro emits
@@ -566,28 +554,54 @@ async function startAppBuilderDevServer(
566554
sandbox: ProjectSandboxStub,
567555
workspace: AppBuilderWorkspace,
568556
): Promise<void> {
569-
await executeStartDevServer(
570-
{
571-
command: localNextPreviewCommand({
572-
port: workspace.port,
573-
sourceDir: workspace.dir,
574-
workspaceSlug: workspace.slug,
575-
}),
576-
cwd: workspace.dir,
577-
env: {
578-
CHEATCODE_APP_RUNTIME: "next",
579-
CHEATCODE_NEXT_DIST_DIR: "../cache/next",
580-
CHOKIDAR_USEPOLLING: "true",
581-
WATCHPACK_POLLING: "1000",
582-
},
583-
isMobile: false,
584-
name: workspace.slot,
557+
await startManagedPreview(sandbox, workspace, {
558+
command: localNextPreviewCommand({
585559
port: workspace.port,
586-
shouldReuseMatchingProcess: true,
587-
timeoutMs: 180_000,
560+
sourceDir: workspace.dir,
561+
workspaceSlug: workspace.slug,
562+
}),
563+
env: {
564+
CHEATCODE_APP_RUNTIME: "next",
565+
CHEATCODE_NEXT_DIST_DIR: "../cache/next",
566+
CHOKIDAR_USEPOLLING: "true",
567+
WATCHPACK_POLLING: "1000",
588568
},
589-
{ sandbox, workspaceDir: workspace.dir, workspaceSlug: workspace.slug },
590-
);
569+
shouldReuseMatchingProcess: true,
570+
});
571+
}
572+
573+
interface ManagedPreviewOptions {
574+
command: string[];
575+
env: Record<string, string>;
576+
shouldReuseMatchingProcess: boolean;
577+
}
578+
579+
async function startManagedPreview(
580+
sandbox: ProjectSandboxStub,
581+
workspace: AppBuilderWorkspace,
582+
options: ManagedPreviewOptions,
583+
): Promise<void> {
584+
await sandbox.startProcess({
585+
command: options.command,
586+
cwd: workspace.dir,
587+
env: {
588+
...options.env,
589+
HOST: "0.0.0.0",
590+
HOSTNAME: "0.0.0.0",
591+
PORT: String(workspace.port),
592+
},
593+
isMobile: workspace.mobile,
594+
keepAliveTimeoutMs: PREVIEW_KEEP_ALIVE_MS,
595+
maxRestarts: PREVIEW_MAX_RESTARTS,
596+
processId: workspace.slot,
597+
restartOnFailure: true,
598+
shouldReuseMatchingProcess: options.shouldReuseMatchingProcess,
599+
timeoutMs: PREVIEW_START_TIMEOUT_MS,
600+
waitForPort: {
601+
port: workspace.port,
602+
timeoutMs: PREVIEW_START_TIMEOUT_MS,
603+
},
604+
});
591605
}
592606

593607
async function hasExistingAppBuilderWorkspace(

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { localProjectProcessCommand } from "./project-sandbox-local-source";
22
import {
33
EXPO_RUNTIME_BIN,
4-
EXPO_TEMPLATE_DIR,
4+
EXPO_RUNTIME_DIR,
55
NEXT_RUNTIME_BIN,
6-
NEXT_TEMPLATE_DIR,
6+
NEXT_RUNTIME_DIR,
77
resolveProjectLocalRuntime,
88
} from "./project-sandbox-package-runtime";
99

@@ -25,7 +25,7 @@ export function localNextPreviewCommand(input: LocalPreviewCommandInput): string
2525
"--port",
2626
String(input.port),
2727
];
28-
return localProjectProcessCommand(runtime, nextCommand, NEXT_TEMPLATE_DIR);
28+
return localProjectProcessCommand(runtime, nextCommand, NEXT_RUNTIME_DIR);
2929
}
3030

3131
/** Runs Expo/Metro from native sandbox disk while `/workspace` remains durable source. */
@@ -41,7 +41,7 @@ export function localExpoPreviewCommand(input: LocalPreviewCommandInput): string
4141
"--port",
4242
String(input.port),
4343
];
44-
return localProjectProcessCommand(runtime, expoCommand, EXPO_TEMPLATE_DIR);
44+
return localProjectProcessCommand(runtime, expoCommand, EXPO_RUNTIME_DIR);
4545
}
4646

4747
function requireLocalPreviewRuntime(input: LocalPreviewCommandInput) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function localPackageCommand(
3535
export function localProjectProcessCommand(
3636
runtime: ProjectLocalRuntime,
3737
command: readonly string[],
38-
dependencyTemplateDir?: string,
38+
dependencyRuntimeDir?: string,
3939
): string[] {
4040
const { lockPath } = runtimeStatePaths(runtime);
4141
return [
@@ -45,7 +45,7 @@ export function localProjectProcessCommand(
4545
runtime.localSourceDir,
4646
lockPath,
4747
runtime.localCwd,
48-
dependencyTemplateDir ?? "-",
48+
dependencyRuntimeDir ?? "-",
4949
"--",
5050
...command,
5151
];

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ const SHELL_EXECUTABLES = new Set(["bash", "sh", "zsh"]);
1111
const SHELL_PACKAGE_MANAGER_COMMAND =
1212
/(?:^|&&|\|\||;|\n|\()\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+)\s+)*(?:command\s+)?(?:sudo\s+)?(?:[^\s;&|()]+\/)?(bun|bunx|npm|npx|pnpm|pnpx|yarn|yarnpkg)(?=\s|$)/u;
1313

14-
export const NEXT_RUNTIME_BIN = `${APP_RUNTIME_ROOT}/next/node_modules/.bin/next`;
15-
export const EXPO_RUNTIME_BIN = `${APP_RUNTIME_ROOT}/expo/node_modules/.bin/expo`;
14+
export const NEXT_RUNTIME_DIR = `${APP_RUNTIME_ROOT}/next`;
15+
export const EXPO_RUNTIME_DIR = `${APP_RUNTIME_ROOT}/expo`;
16+
export const NEXT_RUNTIME_BIN = `${NEXT_RUNTIME_DIR}/node_modules/.bin/next`;
17+
export const EXPO_RUNTIME_BIN = `${EXPO_RUNTIME_DIR}/node_modules/.bin/expo`;
1618
export const NEXT_TEMPLATE_DIR = "/home/node/cheatcode-next-template";
1719
export const EXPO_TEMPLATE_DIR = "/home/node/cheatcode-expo-template";
1820

infra/containers/sandbox/README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,11 @@ pnpm locks under `app-templates/`. Their locked packages are installed into immu
8989
sandbox-local runtimes in the image. Project creation copies only source and config to
9090
the persistent Daytona volume; dependency trees and generated compiler caches stay on
9191
the sandbox's local filesystem, avoiding slow, partial writes to object-store FUSE.
92-
Additional dependencies use a project-scoped local modules directory and can be
93-
restored from the reviewed package store after a sandbox replacement. Expo uses an exact
92+
Exact scaffold projects link their disposable native-disk mirror to the matching immutable
93+
runtime dependency tree, avoiding both package copies and installs during startup. The helper
94+
detaches that link before a package mutation or non-template restore; additional dependencies then
95+
use a project-scoped local modules directory and can be restored from the reviewed package store
96+
after a sandbox replacement. Expo uses an exact
9497
`expo-template-default` tarball with a reviewed SHA-256 rather than the mutable
9598
`default` alias. These locks prevent a snapshot rebuild from resolving a different
9699
dependency tree while the application source stays unchanged.
@@ -110,9 +113,10 @@ native-disk app and sync-loop children, and forwards termination signals. Transi
110113
from the durable FUSE source is retried during a bounded grace period without interrupting the app.
111114
If source access does not recover or the synchronizer otherwise exits, the managed preview exits as
112115
one failed process unit so its existing bounded restart policy cannot leave a healthy port backed by
113-
stale source. A dependency-state digest
114-
skips unchanged reinstalls, while projects without a durable lockfile install without creating one
115-
as a preview side effect. Dependency restoration temporarily merges the image's reviewed build
116+
stale source. Exact scaffold manifests activate the immutable runtime dependency tree, while a
117+
dependency-state digest skips unchanged project-local reinstalls. Projects without a durable
118+
lockfile install without creating one as a preview side effect. Dependency restoration temporarily
119+
merges the image's reviewed build
116120
policy into the sandbox-local workspace, currently permitting `esbuild` so Vite can install its
117121
platform binary while pnpm's default-deny lifecycle policy remains intact for every other package.
118122
The original workspace manifest is restored byte-for-byte before the app starts, and the managed

infra/containers/sandbox/scripts/project-source-sync.py

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,7 @@ def package_run(source, target, lock_path, local_cwd, command):
279279
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
280280
sync_directory(source, target)
281281
baseline = tree_state(target)
282+
detach_runtime_dependencies(local_cwd)
282283
completed = subprocess.run(command, cwd=local_cwd, check=False)
283284
if completed.returncode != 0:
284285
return completed.returncode
@@ -329,6 +330,9 @@ def dependency_state_path(local_cwd):
329330

330331

331332
def dependencies_are_current(local_cwd, local_root):
333+
modules = os.path.join(local_cwd, "node_modules")
334+
if os.path.islink(modules):
335+
return False
332336
marker = dependency_state_path(local_cwd)
333337
try:
334338
with open(marker, "r", encoding="ascii") as source:
@@ -353,6 +357,30 @@ def record_dependency_state(local_cwd, local_root):
353357
pass
354358

355359

360+
def activate_runtime_dependencies(local_cwd, dependency_runtime):
361+
runtime_modules = os.path.join(dependency_runtime, "node_modules")
362+
if not os.path.isdir(runtime_modules):
363+
return False
364+
modules = os.path.join(local_cwd, "node_modules")
365+
if os.path.islink(modules) and os.path.realpath(modules) == os.path.realpath(runtime_modules):
366+
return True
367+
temporary = os.path.join(local_cwd, f".cheatcode-modules-{os.getpid()}")
368+
remove_path(temporary)
369+
try:
370+
os.symlink(os.path.realpath(runtime_modules), temporary)
371+
remove_path(modules)
372+
os.replace(temporary, modules)
373+
finally:
374+
remove_path(temporary)
375+
return True
376+
377+
378+
def detach_runtime_dependencies(local_cwd):
379+
modules = os.path.join(local_cwd, "node_modules")
380+
if os.path.islink(modules):
381+
remove_path(modules)
382+
383+
356384
def pnpm_workspace_path(local_cwd, local_root):
357385
current = os.path.realpath(local_cwd)
358386
root = os.path.realpath(local_root)
@@ -412,17 +440,19 @@ def reviewed_pnpm_build_policy(local_cwd, local_root):
412440
restore_path(workspace_path, captured)
413441

414442

415-
def restore_pnpm_dependencies(local_cwd, local_root, dependency_template):
443+
def restore_pnpm_dependencies(local_cwd, local_root, dependency_runtime):
416444
package_path = os.path.join(local_cwd, "package.json")
417445
lock_path = os.path.join(local_cwd, "pnpm-lock.yaml")
418446
if not os.path.isfile(package_path):
419447
return 0
420-
if dependency_template and files_equal(
421-
package_path, os.path.join(dependency_template, "package.json")
422-
) and files_equal(lock_path, os.path.join(dependency_template, "pnpm-lock.yaml")):
423-
return 0
448+
if dependency_runtime and files_equal(
449+
package_path, os.path.join(dependency_runtime, "package.json")
450+
) and files_equal(lock_path, os.path.join(dependency_runtime, "pnpm-lock.yaml")):
451+
if activate_runtime_dependencies(local_cwd, dependency_runtime):
452+
return 0
424453
if dependencies_are_current(local_cwd, local_root):
425454
return 0
455+
detach_runtime_dependencies(local_cwd)
426456
common = ["install", "--prefer-offline", "--network-concurrency", "4"]
427457
with reviewed_pnpm_build_policy(local_cwd, local_root):
428458
if not os.path.isfile(lock_path):
@@ -472,14 +502,14 @@ def reap_process(process):
472502
process.wait()
473503

474504

475-
def preview_run(source, target, lock_path, local_cwd, dependency_template, command):
505+
def preview_run(source, target, lock_path, local_cwd, dependency_runtime, command):
476506
validate_local_cwd(target, local_cwd)
477507
if not command:
478508
raise ValueError("preview-run requires an app command")
479509
with open_lock(lock_path) as lock:
480510
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
481511
sync_source_once(source, target)
482-
dependency_status = restore_pnpm_dependencies(local_cwd, target, dependency_template)
512+
dependency_status = restore_pnpm_dependencies(local_cwd, target, dependency_runtime)
483513
if dependency_status != 0:
484514
return dependency_status
485515

@@ -541,15 +571,15 @@ def main():
541571
raise SystemExit(package_run(source, target, lock_path, sys.argv[5], sys.argv[7:]))
542572
if mode == "preview-run":
543573
if len(sys.argv) < 9 or sys.argv[7] != "--":
544-
raise ValueError("preview-run requires a cwd, dependency template, and argv after --")
545-
dependency_template = None if sys.argv[6] == "-" else sys.argv[6]
574+
raise ValueError("preview-run requires a cwd, dependency runtime, and argv after --")
575+
dependency_runtime = None if sys.argv[6] == "-" else sys.argv[6]
546576
raise SystemExit(
547577
preview_run(
548578
source,
549579
target,
550580
lock_path,
551581
sys.argv[5],
552-
dependency_template,
582+
dependency_runtime,
553583
sys.argv[8:],
554584
)
555585
)

packages/agent-core/src/tools/code/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export {
4545
} from "./git";
4646
export {
4747
executePreparedStartDevServer,
48-
executeStartDevServer,
4948
prepareStartDevServer,
5049
} from "./preview";
5150
export { executeRunCode, RunCodeInputSchema, RunCodeOutputSchema } from "./run-code";

0 commit comments

Comments
 (0)