diff --git a/.github/workflows/build-snapshot.yml b/.github/workflows/build-snapshot.yml index 07fbbe32..df22e41c 100644 --- a/.github/workflows/build-snapshot.yml +++ b/.github/workflows/build-snapshot.yml @@ -329,6 +329,31 @@ jobs: NODE_PATH=/home/node/.cheatcode/app-runtimes/next/node_modules node -e "require.resolve(\"next\");require.resolve(\"react\");require.resolve(\"react-dom\")" NODE_PATH=/home/node/.cheatcode/app-runtimes/expo/node_modules node -e "require.resolve(\"expo\");require.resolve(\"react-native-web\");require.resolve(\"@expo/metro-runtime\")" + next_test_dir="$(mktemp -d)" + next_source="$(mktemp -d /workspace/.cheatcode-next-smoke.XXXXXX)" + next_mirror="$next_test_dir/mirror" + next_lock="$next_test_dir/project.lock" + cleanup_next_test() { + rm -rf "$next_test_dir" "$next_source" + } + trap cleanup_next_test EXIT + mkdir -p "$next_mirror" + cp -R /home/node/cheatcode-next-template/. "$next_source/" + /opt/cheatcode/project-source-sync.py preview-once \ + "$next_source" "$next_mirror" "$next_lock" + ln -s /home/node/.cheatcode/app-runtimes/next/node_modules \ + "$next_mirror/node_modules" + /opt/cheatcode/project-source-sync.py package-run \ + "$next_source" "$next_mirror" "$next_lock" "$next_mirror" -- \ + pnpm run build + test -L "$next_mirror/node_modules" + test "$(readlink -f "$next_mirror/node_modules")" = \ + /home/node/.cheatcode/app-runtimes/next/node_modules + test -f "$next_mirror/.next/BUILD_ID" + echo "Next native-mirror production build smoke passed" + cleanup_next_test + trap - EXIT + expo_test_dir="$(mktemp -d)" expo_source="$(mktemp -d /workspace/.cheatcode-expo-smoke.XXXXXX)" expo_mirror="$expo_test_dir/mirror" diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 13b37e52..b0e84e96 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -192,9 +192,12 @@ dependency tree, and build cache are disposable; wake and restart reconstruct them from the durable project without changing the Files surface. Persisted pnpm-backed preview commands restore a missing sandbox-local dependency tree before the server starts. Exact scaffold manifests link the disposable mirror to the matching immutable runtime -dependency tree. That shared runtime is image-owned and read-only. Read-only pnpm validation and -script commands disable pnpm's pre-run dependency verification and keep the link intact, while the -package boundary detaches it before a dependency mutation. Dependency mutations advance a +dependency tree. The immutable scaffold also owns the TypeScript configuration; package resolution +uses the linked `node_modules` tree rather than runtime-specific compiler path aliases, so editor, +typecheck, and production-build resolution share one contract. That shared runtime is image-owned +and read-only. Read-only pnpm validation and script commands disable pnpm's pre-run dependency +verification and keep the link intact, while the package boundary detaches it before a dependency +mutation. Dependency mutations advance a generation under the same package lock; the native preview supervisor observes that generation and restarts only the app child after the transaction releases the lock, preserving its process session, source synchronizer, and signed launch environment. Metro and other long-lived resolvers therefore diff --git a/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts b/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts index 705718c1..e5326e0b 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-app-builder-scaffold.ts @@ -5,12 +5,7 @@ import { } from "@cheatcode/agent-core/tools/code"; import { APIError, type createLogger } from "@cheatcode/observability"; import type { CodeRuntimeContext } from "@cheatcode/sandbox-contracts"; -import { - appBuilderGlobalStylesSource, - appBuilderLayoutSource, - appBuilderPageSource, - appBuilderTypeScriptConfigSource, -} from "./app-builder-template"; +import { appBuilderPageSource } from "./app-builder-template"; import { metroForwardedHostFixScript } from "./expo-metro-forwarded-host"; import { EXPO_RUNTIME_BIN, @@ -26,7 +21,6 @@ type AgentRunLogger = ReturnType; interface AppBuilderSeedInput { messageText: string; - workspaceSlug: string; } export function writeAppBuilderFiles( @@ -34,36 +28,13 @@ export function writeAppBuilderFiles( sandbox: ProjectSandboxStub, dir: string, ): Promise { - return Promise.all([ - executeWriteFile( - { - path: `${dir}/src/app/layout.tsx`, - content: appBuilderLayoutSource(), - }, - { sandbox }, - ), - executeWriteFile( - { - path: `${dir}/src/app/globals.css`, - content: appBuilderGlobalStylesSource(), - }, - { sandbox }, - ), - executeWriteFile( - { - path: `${dir}/src/app/page.tsx`, - content: appBuilderPageSource(input.messageText), - }, - { sandbox }, - ), - executeWriteFile( - { - path: `${dir}/tsconfig.json`, - content: appBuilderTypeScriptConfigSource(input.workspaceSlug), - }, - { sandbox }, - ), - ]).then(() => undefined); + return executeWriteFile( + { + path: `${dir}/src/app/page.tsx`, + content: appBuilderPageSource(input.messageText), + }, + { sandbox }, + ).then(() => undefined); } export async function scaffoldExpoApp( diff --git a/apps/agent-worker/src/durable-objects/app-builder-template.ts b/apps/agent-worker/src/durable-objects/app-builder-template.ts index 4db12919..3c4ef779 100644 --- a/apps/agent-worker/src/durable-objects/app-builder-template.ts +++ b/apps/agent-worker/src/durable-objects/app-builder-template.ts @@ -1,42 +1,3 @@ -export function appBuilderLayoutSource(): string { - return `import type { ReactNode } from "react"; -import "./globals.css"; - -export const metadata = { - title: "Cheatcode Preview", - description: "Generated by Cheatcode", -}; - -export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { - return ( - - {children} - - ); -} -`; -} - -export function appBuilderGlobalStylesSource(): string { - return `@import "tailwindcss"; - -:root { - color-scheme: dark; -} - -html, -body { - min-height: 100%; -} - -body { - margin: 0; - background: #0b0b0b; - font-family: ui-monospace, "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace; -} -`; -} - export function appBuilderPageSource(messageText: string): string { return `const cards = [ ["Gateway", "Clerk auth and rate limits route the request."], @@ -76,46 +37,6 @@ export default function Home() { `; } -export function appBuilderTypeScriptConfigSource(workspaceSlug: string): string { - const localModules = `/home/node/.cheatcode/projects/${workspaceSlug}/source/node_modules`; - const runtimeModules = "/home/node/.cheatcode/app-runtimes/next/node_modules"; - return `${JSON.stringify( - { - compilerOptions: { - allowJs: true, - esModuleInterop: true, - incremental: true, - isolatedModules: true, - jsx: "react-jsx", - lib: ["dom", "dom.iterable", "esnext"], - module: "esnext", - moduleResolution: "bundler", - noEmit: true, - paths: { - "@/*": ["./src/*"], - "*": [`${localModules}/*`, `${runtimeModules}/*`], - }, - plugins: [{ name: "next" }], - resolveJsonModule: true, - skipLibCheck: true, - strict: true, - target: "ES2017", - }, - exclude: ["node_modules"], - include: [ - "next-env.d.ts", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts", - "**/*.ts", - "**/*.tsx", - ], - }, - null, - 2, - )}\n`; -} - function escapeForTsxText(value: string): string { return value.replace(/[<>{}]/g, ""); } diff --git a/infra/containers/sandbox/README.md b/infra/containers/sandbox/README.md index 155d5466..8d95217a 100644 --- a/infra/containers/sandbox/README.md +++ b/infra/containers/sandbox/README.md @@ -102,6 +102,11 @@ generated images: only manifests and the starter route cross the persistent obje boundary. Its dependency tree remains the exact reviewed runtime installed on native sandbox disk. These locks prevent a snapshot rebuild from resolving a different dependency tree while the application source stays unchanged. +The Next scaffold's production build explicitly uses webpack because its immutable dependency tree +lives outside the project mirror; Next's Turbopack build rejects that deliberate cross-root symlink. +Snapshot smoke runs the checked-in build script through the same native-mirror package boundary and +requires a real production `BUILD_ID`, so preview and verification cannot silently use incompatible +bundlers. The root-owned `/opt/cheatcode/project-source-sync.py` helper is the single runtime boundary between persistent project source and the native-disk project mirror. It is diff --git a/infra/containers/sandbox/app-templates/next/package.json b/infra/containers/sandbox/app-templates/next/package.json index cf760da7..bddbcf30 100644 --- a/infra/containers/sandbox/app-templates/next/package.json +++ b/infra/containers/sandbox/app-templates/next/package.json @@ -9,7 +9,7 @@ }, "scripts": { "dev": "next dev", - "build": "next build", + "build": "next build --webpack", "start": "next start", "lint": "biome check", "format": "biome format --write" diff --git a/infra/containers/sandbox/app-templates/next/postcss.config.mjs b/infra/containers/sandbox/app-templates/next/postcss.config.mjs new file mode 100644 index 00000000..c7bcb4b1 --- /dev/null +++ b/infra/containers/sandbox/app-templates/next/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/infra/containers/sandbox/app-templates/next/src/app/globals.css b/infra/containers/sandbox/app-templates/next/src/app/globals.css new file mode 100644 index 00000000..ff8cfbce --- /dev/null +++ b/infra/containers/sandbox/app-templates/next/src/app/globals.css @@ -0,0 +1,17 @@ +@import "tailwindcss"; + +:root { + color-scheme: dark; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + background: #0b0b0b; + font-family: ui-monospace, "SFMono-Regular", "Menlo", "Monaco", "Consolas", + "Liberation Mono", "Courier New", monospace; +} diff --git a/infra/containers/sandbox/app-templates/next/src/app/layout.tsx b/infra/containers/sandbox/app-templates/next/src/app/layout.tsx new file mode 100644 index 00000000..61e0a59a --- /dev/null +++ b/infra/containers/sandbox/app-templates/next/src/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Cheatcode Preview", + description: "Generated by Cheatcode", +}; + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/infra/containers/sandbox/app-templates/next/src/app/page.tsx b/infra/containers/sandbox/app-templates/next/src/app/page.tsx new file mode 100644 index 00000000..36c05a7b --- /dev/null +++ b/infra/containers/sandbox/app-templates/next/src/app/page.tsx @@ -0,0 +1,32 @@ +const cards = [ + ["Gateway", "Clerk auth and rate limits route the request."], + ["Agent session", "Durable Object stores resumable stream parts."], + ["Sandbox", "Daytona serves this live preview."], +]; + +export default function Home() { + return ( +
+
+
+

+ Cheatcode Preview +

+

+ Sandbox preview is live. +

+
+
+ {cards.map(([title, body]) => ( +
+

+ {title} +

+

{body}

+
+ ))} +
+
+
+ ); +} diff --git a/infra/containers/sandbox/app-templates/next/tsconfig.json b/infra/containers/sandbox/app-templates/next/tsconfig.json new file mode 100644 index 00000000..2153668a --- /dev/null +++ b/infra/containers/sandbox/app-templates/next/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "allowJs": true, + "esModuleInterop": true, + "incremental": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [{ "name": "next" }], + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2017" + }, + "exclude": ["node_modules"], + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts", + "**/*.ts", + "**/*.tsx" + ] +} diff --git a/knip.jsonc b/knip.jsonc index 5351ff81..4be45937 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -4,8 +4,6 @@ // `cloudflare:workers` and `cloudflare:workflows` are platform modules, not // installable package dependencies. Knip cannot infer either edge. "ignoreDependencies": ["@cheatcode/tsconfig", "cloudflare"], - // Copied into the generated Next scaffold by the sandbox Dockerfile. - "ignore": ["infra/containers/sandbox/app-templates/next/next.config.ts"], "includeEntryExports": true, "workspaces": { ".": { @@ -51,6 +49,17 @@ "react-native-worklets" ] }, + "infra/containers/sandbox/app-templates/next": { + // Next loads config and App Router modules by filesystem convention after the + // Docker image copies this complete scaffold into a user workspace. + "entry": ["src/app/*.tsx"], + "includeEntryExports": false, + // Next CLI is invoked by package scripts rather than imported by source. + "ignoreBinaries": ["next"], + // Next's client runtime and generated route types consume these without a + // repository-owned import edge. + "ignoreDependencies": ["@types/react-dom", "react-dom"] + }, "apps/agent-worker": { // Wrangler reaches the platform module through its `alias` deployment // configuration, which is intentionally outside TypeScript's import graph. diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index fbc9c02c..58a720d7 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -49,6 +49,12 @@ inference and returns page-bound element refs. Execution accepts only a single-u latest state tree plus a bounded method/value, resolves its server-held XPath, and atomically binds the returned post-action tree as the next actionable state. A click or fill therefore cannot invoke a hidden model decision, expose a selector, reuse a stale ref, or cross the active origin. +Every first-party browser tool advertises its strict JSON schema to providers that support strict +tool calling, while the same Zod contract remains the provider-independent runtime boundary. +Action methods use explicit no-value, value, and drag shapes, so a model cannot omit a required ref +or value and still produce an executable call. A tool-validation or driver failure is verification +failure, not evidence that generated application state is broken; app-builder agents preserve the +framework event model and correct the browser call instead of injecting page scripts. The managed preview tool owns Computer-visible dev servers, remaps a requested port to the project’s allocated port when necessary, injects the supported framework binding when the model diff --git a/packages/agent-core/src/mastra/system-prompt.ts b/packages/agent-core/src/mastra/system-prompt.ts index 982f0038..b0e3a097 100644 --- a/packages/agent-core/src/mastra/system-prompt.ts +++ b/packages/agent-core/src/mastra/system-prompt.ts @@ -219,7 +219,7 @@ Build the Expo Router screens for a polished, native-feeling app: real screens, // keeps WEB_MODULE's "start the dev server yourself" guidance; this note only applies here. const APP_BUILDER_PREVIEW_NOTE = `## Your preview is already running — do not start your own -This project is scaffolded at the workspace root and its dev server + live preview are ALREADY running and managed for you before your turn begins (for a mobile app that's Metro serving the app on web plus the Expo Go QR). Do NOT initialize, scaffold, or create another app or nested project. Do NOT start, restart, or reconfigure the server yourself — no code_start_dev_server, \`expo start\`, \`npm run dev\`/\`web\`, or \`npx expo …\`: a second server fights the managed one for the project's port and breaks the preview. Use pnpm, never npm/npx, only when dependency changes are necessary. Inspect and edit the existing root files; the preview hot-reloads on save. Verify by opening the running app in the sandbox's headed Chromium at its INTERNAL localhost address; it's shown to the user automatically in the Computer/App panel — never paste the preview URL. Metro may briefly show an empty document while rebuilding the first web bundle after edits: wait for page content once and reload at most once before treating it as a defect. Take one screenshot with the exact visual acceptance criterion and use its returned PASS/FAIL assessment; never judge screenshot byte size. Exercise one representative interaction by calling browser_observe once, choosing one exact hyphenated element ref from its accessibility tree, and calling browser_act with that ref plus the required method/value. Use browser_act's actionable post-action tree as the result check; do not observe or extract again. If the request explicitly requires another interaction, chain it from a fresh ref in the returned tree; observe again only after navigation, an external page change, or when that tree lacks the required element. Never invent a ref or selector, write a separate Playwright/Python test, or install another browser. If a check fails, fix the concrete defect and repeat only that changed check once. Once the requested content renders, that interaction passes, and no blocking browser error remains, finish.`; +This project is scaffolded at the workspace root and its dev server + live preview are ALREADY running and managed for you before your turn begins (for a mobile app that's Metro serving the app on web plus the Expo Go QR). Do NOT initialize, scaffold, or create another app or nested project. Do NOT start, restart, or reconfigure the server yourself — no code_start_dev_server, \`expo start\`, \`npm run dev\`/\`web\`, or \`npx expo …\`: a second server fights the managed one for the project's port and breaks the preview. Use pnpm, never npm/npx, only when dependency changes are necessary. Inspect and edit the existing root files; the preview hot-reloads on save. Verify by opening the running app in the sandbox's headed Chromium at its INTERNAL localhost address; it's shown to the user automatically in the Computer/App panel — never paste the preview URL. Metro may briefly show an empty document while rebuilding the first web bundle after edits: wait for page content once and reload at most once before treating it as a defect. Take one screenshot with the exact visual acceptance criterion and use its returned PASS/FAIL assessment; never judge screenshot byte size. Exercise one representative interaction by calling browser_observe once, choosing one exact hyphenated element ref from its accessibility tree, and calling browser_act with that ref plus the required method/value. Use browser_act's actionable post-action tree as the result check; do not observe or extract again. If the request explicitly requires another interaction, chain it from a fresh ref in the returned tree; observe again only after navigation, an external page change, or when that tree lacks the required element. Never invent a ref or selector, write a separate Playwright/Python test, or install another browser. A browser-tool validation or driver error is a verification failure, not evidence of an app defect: correct the tool call once and preserve the framework's state and event model. Never replace React or React Native behavior with injected page scripts or manual DOM listeners to work around browser automation. If the rendered page or build output identifies a concrete code defect, fix that defect and repeat only the changed check once. Once the requested content renders, that interaction passes, and no blocking browser error remains, finish.`; const DOCS_MODULE = `## Building documents & slides diff --git a/packages/agent-core/src/mastra/tool-defs/browser-tools.ts b/packages/agent-core/src/mastra/tool-defs/browser-tools.ts index 995a7918..a4a74804 100644 --- a/packages/agent-core/src/mastra/tool-defs/browser-tools.ts +++ b/packages/agent-core/src/mastra/tool-defs/browser-tools.ts @@ -21,6 +21,7 @@ export const mastraBrowserOpen = createTool({ "Open a URL in the sandbox's local headed Chromium browser through Stagehand LOCAL mode.", inputSchema: BrowserOpenInputSchema, outputSchema: BrowserActionsOutputSchema, + strict: true, execute: async (input, context) => executeBrowserOpen(input, await browserRuntimeFromContext(context)), }); @@ -30,7 +31,16 @@ export const mastraBrowserAct = createTool({ description: "Execute one deterministic action against an exact element ref from the latest browser_observe or browser_act tree. The consumed ref is page-bound and single-use; the result includes an actionable post-action page tree for the next step.", inputSchema: BrowserActInputSchema, + inputExamples: [ + { + input: { + action: { method: "click", ref: "1-2" }, + timeoutMs: 10_000, + }, + }, + ], outputSchema: BrowserActionsOutputSchema, + strict: true, execute: async (input, context) => { const parsedInput = BrowserActInputSchema.parse(input); const runtimeContext = await browserRuntimeFromContext(context); @@ -52,6 +62,7 @@ export const mastraBrowserObserve = createTool({ "Read the current sandbox page as a deterministic accessibility tree with page-bound element refs. Choose an exact hyphenated ref from the tree for browser_act; no secondary model is invoked.", inputSchema: BrowserObserveInputSchema, outputSchema: BrowserActionsOutputSchema, + strict: true, execute: async (input, context) => executeBrowserObserve(input, await browserRuntimeFromContext(context)), }); @@ -62,6 +73,7 @@ export const mastraBrowserExtract = createTool({ "Extract structured information from the current sandbox browser page with Stagehand LOCAL mode.", inputSchema: BrowserExtractInputSchema, outputSchema: BrowserActionsOutputSchema, + strict: true, execute: async (input, context) => executeBrowserExtract(input, await browserRuntimeFromContext(context)), }); @@ -72,6 +84,7 @@ export const mastraBrowserScreenshot = createTool({ "Capture and visually assess the current sandbox browser page against one explicit acceptance criterion. The image appears inside the expanded browser action as internal evidence, never as a user deliverable.", inputSchema: BrowserScreenshotInputSchema, outputSchema: BrowserActionsOutputSchema, + strict: true, execute: async (input, context) => executeBrowserScreenshot(input, await browserRuntimeFromContext(context)), }); diff --git a/packages/agent-core/src/tools/browser/actions.ts b/packages/agent-core/src/tools/browser/actions.ts index 86f83d2c..7977672c 100644 --- a/packages/agent-core/src/tools/browser/actions.ts +++ b/packages/agent-core/src/tools/browser/actions.ts @@ -68,7 +68,8 @@ const BrowserActionMethodSchema = z.enum([ "type", ]); -const BROWSER_VALUE_METHODS = new Set>([ +const BrowserNoValueActionMethodSchema = BrowserActionMethodSchema.exclude([ + "dragAndDrop", "fill", "press", "scrollTo", @@ -76,53 +77,37 @@ const BROWSER_VALUE_METHODS = new Set> "type", ]); -interface BrowserBoundActionRefinementInput { - method: z.infer; - ref: string; - targetRef?: string | undefined; - value?: string | undefined; -} - -function validateBrowserBoundAction( - action: BrowserBoundActionRefinementInput, - context: z.RefinementCtx, -): void { - const needsValue = BROWSER_VALUE_METHODS.has(action.method); - if (needsValue !== (action.value !== undefined)) { - context.addIssue({ - code: "custom", - message: needsValue - ? `${action.method} requires value` - : `${action.method} does not accept value`, - path: ["value"], - }); - } - const needsTarget = action.method === "dragAndDrop"; - if (needsTarget !== (action.targetRef !== undefined)) { - context.addIssue({ - code: "custom", - message: needsTarget - ? "dragAndDrop requires targetRef" - : `${action.method} does not accept targetRef`, - path: ["targetRef"], - }); - } -} +const BrowserValueActionMethodSchema = z.enum([ + "fill", + "press", + "scrollTo", + "selectOptionFromDropdown", + "type", +]); -const BrowserBoundActionSchema = z - .strictObject({ - method: BrowserActionMethodSchema.describe("Deterministic action to perform on the ref."), +const BrowserBoundActionSchema = z.union([ + z.strictObject({ + method: BrowserNoValueActionMethodSchema.describe( + "Deterministic action to perform on the ref.", + ), ref: BrowserElementRefSchema, - targetRef: BrowserElementRefSchema.optional().describe( - "Destination ref; required only for dragAndDrop.", + }), + z.strictObject({ + method: BrowserValueActionMethodSchema.describe( + "Deterministic value-taking action to perform on the ref.", ), + ref: BrowserElementRefSchema, value: z .string() .max(2_000) - .optional() - .describe("Text, key, option, or percentage required by value-taking methods."), - }) - .superRefine(validateBrowserBoundAction); + .describe("Text, key, option, or percentage required by this method."), + }), + z.strictObject({ + method: z.literal("dragAndDrop"), + ref: BrowserElementRefSchema, + targetRef: BrowserElementRefSchema.describe("Destination ref for the drag operation."), + }), +]); export const BrowserActInputSchema = z.strictObject({ action: BrowserBoundActionSchema.describe(