diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index b0e84e96..c4edeb2d 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -169,6 +169,10 @@ web path unless the request carries an explicit mobile or non-web runtime signal high-confidence fallback materializes the project, scaffolds its canonical workspace, and registers the managed preview even when the selected model would otherwise attempt generic shell work and finish without a Computer target. +Template preparation records managed-preview ownership in checkpointed Workflow state. Every model +turn excludes the generic dev-server tool in that state, and every tool execution receives the same +request-scoped policy marker so stale tool calls cannot restart the server or replace root framework +manifests. Imported repositories use the separate unmanaged path and retain those capabilities. The starter page is an internal server-readiness target, not user-facing generated content. A fresh template run emits the typed `app-preview-status` transition from `building` to `ready` only after model execution, so the web client can keep diff --git a/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts b/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts index 13c74724..835f0561 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-app-builder.ts @@ -151,6 +151,7 @@ interface RunAppBuilderOptions { interface AppBuilderSetup { agentContextNote?: string; + usesManagedPreview: boolean; waitsForGeneratedPreview: boolean; } @@ -168,6 +169,7 @@ export async function runAppBuilder(options: RunAppBuilderOptions): Promise Promise; workspaceResolver: WorkspaceResolver; + usesManagedPreview: boolean; } /** Resolves request-scoped credentials and user skills inside the active Workflow step. */ @@ -92,6 +93,7 @@ export function createAgentRequestContext( return createCodeRequestContext(codeRuntime, { agentDisplayName: input.agentDisplayName, anthropicApiKey: credential.transportProvider === "anthropic" ? credential.apiKey : undefined, + appBuilderManagedPreview: options.usesManagedPreview, composioApiKey: toolCredentials.composioApiKey, composioConnectedAccounts: toolCredentials.composioConnectedAccounts, composioQuotaMeter: toolCredentials.composioQuotaMeter, diff --git a/apps/agent-worker/src/durable-objects/agent-run-path.ts b/apps/agent-worker/src/durable-objects/agent-run-path.ts index 0e95558f..bea8ac87 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-path.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-path.ts @@ -31,6 +31,7 @@ type ProjectBoundAppBuilderRunOptions = AppBuilderRunOptions & { export interface PreparedAppBuilderRun { agentContextNote?: string; options: ProjectBoundAppBuilderRunOptions; + usesManagedPreview: boolean; waitsForGeneratedPreview: boolean; } @@ -44,7 +45,7 @@ export async function prepareAppBuilderRun( const boundOptions = { ...options, input: requireProjectBinding(options.input) }; await warmSandbox(boundOptions.sandbox, boundOptions.logger); if (boundOptions.isCanceled()) { - return { options: boundOptions, waitsForGeneratedPreview: false }; + return { options: boundOptions, usesManagedPreview: false, waitsForGeneratedPreview: false }; } const prepared = await runAppBuilder(boundOptions); return { ...prepared, options: boundOptions }; diff --git a/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts b/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts index d136c17b..80d99ed4 100644 --- a/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts +++ b/apps/agent-worker/src/durable-objects/agent-run-workflow-runtime.ts @@ -69,6 +69,7 @@ const WorkflowToolCallSchema = z.strictObject({ }); export const WorkflowAgentStateSchema = z.strictObject({ + appBuilderUsesManagedPreview: z.boolean(), appBuilderWaitsForPreview: z.boolean(), hasArtifact: z.boolean(), hasVisibleText: z.boolean(), @@ -138,6 +139,7 @@ export async function prepareWorkflowAgentRun( const preparedApp = await prepareAppBuilderRun(runtime.pathOptions); const threadContext = await loadWorkflowThreadContext(env, input, preparedApp?.agentContextNote); return WorkflowAgentStateSchema.parse({ + appBuilderUsesManagedPreview: preparedApp?.usesManagedPreview ?? false, appBuilderWaitsForPreview: preparedApp?.waitsForGeneratedPreview ?? false, hasArtifact: false, hasVisibleText: false, @@ -175,6 +177,7 @@ export async function generateWorkflowModelStep( primary, sandbox, stub, + usesManagedPreview: state.appBuilderUsesManagedPreview, }); } catch (error) { if (!shouldFallbackToOpenAI(input.isModelExplicit, primary, false, error)) throw error; @@ -190,6 +193,7 @@ export async function generateWorkflowModelStep( primary: fallback, sandbox, stub, + usesManagedPreview: state.appBuilderUsesManagedPreview, }); return { ...generated, @@ -235,6 +239,7 @@ export async function executeWorkflowToolStep( selectedLogicalModelId: state.selectedLogicalModelId, stub, toolCall, + usesManagedPreview: state.appBuilderUsesManagedPreview, }), run: input, sandbox, @@ -281,6 +286,7 @@ async function executePreparedWorkflowTool(input: { selectedLogicalModelId: LogicalModelId | undefined; stub: DurableObjectStub; toolCall: GeneralAgentToolCall; + usesManagedPreview: boolean; }): Promise { const startedAt = Date.now(); const credential = await resolveCredentialForState( @@ -297,6 +303,7 @@ async function executePreparedWorkflowTool(input: { logger: input.logger, sandbox: input.sandbox, stub: input.stub, + usesManagedPreview: input.usesManagedPreview, }); const prepared = await prepareMastraContext( runtime.mastraOptions(credential), @@ -394,6 +401,7 @@ async function generateWithCredential(input: { primary: LlmCredential; sandbox: ProjectSandboxStub; stub: DurableObjectStub; + usesManagedPreview: boolean; }): Promise { const runtime = workflowRuntimeOptions({ callback: input.callback, @@ -403,6 +411,7 @@ async function generateWithCredential(input: { logger: input.logger, sandbox: input.sandbox, stub: input.stub, + usesManagedPreview: input.usesManagedPreview, }); const options = runtime.mastraOptions(input.primary); const prepared = await prepareMastraContext(options); @@ -423,6 +432,7 @@ async function generateWithCredential(input: { ], } : {}), + ...(input.usesManagedPreview ? { excludedTools: ["code_start_dev_server"] } : {}), isDeepSeek: input.primary.transportProvider === "deepseek", messages: input.messages, requestContext, @@ -443,6 +453,7 @@ function workflowRuntimeOptions(input: { logger: ReturnType; sandbox: ProjectSandboxStub; stub: DurableObjectStub; + usesManagedPreview?: boolean; }) { const append = async (chunk: UIMessageChunk) => { const event: AgentRunWorkflowEventInput = { @@ -487,6 +498,7 @@ function workflowRuntimeOptions(input: { logger: input.logger, sandbox: input.sandbox, setRunStage, + usesManagedPreview: input.usesManagedPreview ?? false, workspaceResolver, }), }; diff --git a/packages/agent-core/README.md b/packages/agent-core/README.md index 58a720d7..3af79c44 100644 --- a/packages/agent-core/README.md +++ b/packages/agent-core/README.md @@ -51,8 +51,10 @@ the returned post-action tree as the next actionable state. A click or fill ther 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 +The model-facing action schema is a flat four-field object: method and ref are always present, while +unused value and targetRef fields are explicitly null. Runtime refinement maps that contract to the +driver's no-value, value, or drag shape, so operational timeout policy cannot become a substitute +for the action itself. 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 @@ -79,9 +81,12 @@ abort signal is forwarded through the Morph request. Focused and multi-section e text file use FastApply; new files, binary files, and intentional whole-file rewrites continue to use the deterministic file writer. App-builder runs receive an existing framework workspace and managed preview before model -execution. Their shell tools reject app-initialization commands so a model cannot replace that -canonical root with another scaffold or a nested project; ordinary package installation and build -commands remain available. +execution. Managed template runs do not advertise the dev-server tool, and their shell and file +boundaries reject alternate server launches or replacement of root framework manifests. A model +therefore cannot replace the canonical Next.js or Expo root with another scaffold, package manifest, +or nested project; ordinary source edits, package installation, and build commands remain available. +Imported repositories keep their own dev-server and framework-file capabilities because their stack +is intentionally user-owned rather than image-owned. ## Code Checks diff --git a/packages/agent-core/src/mastra/context.ts b/packages/agent-core/src/mastra/context.ts index 3946192c..5f6daf6c 100644 --- a/packages/agent-core/src/mastra/context.ts +++ b/packages/agent-core/src/mastra/context.ts @@ -1,6 +1,7 @@ export const CONTEXT = { agentDisplayName: "agentDisplayName", anthropicApiKey: "anthropicApiKey", + appBuilderManagedPreview: "appBuilderManagedPreview", browserRunId: "browserRunId", codeRuntime: "codeRuntime", composioApiKey: "composioApiKey", diff --git a/packages/agent-core/src/mastra/durable-agent-step.ts b/packages/agent-core/src/mastra/durable-agent-step.ts index f253bef1..abbe0c9b 100644 --- a/packages/agent-core/src/mastra/durable-agent-step.ts +++ b/packages/agent-core/src/mastra/durable-agent-step.ts @@ -36,6 +36,7 @@ interface GeneralAgentStepResult { interface GenerateGeneralAgentStepOptions { abortSignal?: AbortSignal; activeTools?: string[]; + excludedTools?: string[]; isDeepSeek: boolean; messages: JSONValue[]; requestContext: RequestContext; @@ -56,9 +57,10 @@ export async function generateGeneralAgentStep( options: GenerateGeneralAgentStepOptions, ): Promise { const messages = options.messages.map((message) => modelMessageSchema.parse(message)); + const activeTools = resolveActiveTools(options); const result = await mastra.getAgent("generalStep").generate(messages as never, { ...(options.abortSignal ? { abortSignal: options.abortSignal } : {}), - ...(options.activeTools ? { activeTools: options.activeTools } : {}), + ...(activeTools ? { activeTools } : {}), clientTools: cheatcodeTools, ...(options.isDeepSeek ? { providerOptions: { deepseek: { thinking: { type: "disabled" as const } } } } @@ -81,6 +83,16 @@ export async function generateGeneralAgentStep( }; } +function resolveActiveTools(options: GenerateGeneralAgentStepOptions): string[] | undefined { + if (options.activeTools && options.excludedTools) { + throw new Error("Agent tools cannot be included and excluded in the same model step."); + } + if (options.activeTools) return options.activeTools; + if (!options.excludedTools || options.excludedTools.length === 0) return undefined; + const excludedTools = new Set(options.excludedTools); + return Object.keys(cheatcodeTools).filter((toolName) => !excludedTools.has(toolName)); +} + function toJsonValues(values: ModelMessage[]): JSONValue[] { return values.map(toJsonValue); } diff --git a/packages/agent-core/src/mastra/system-prompt.ts b/packages/agent-core/src/mastra/system-prompt.ts index b0e3a097..86a980fa 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. 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.`; +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). Preserve the existing Next.js or Expo framework: do NOT replace its package manifest, initialize or scaffold another framework, or create a 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. Always pass browser_act all four fields: method, ref, targetRef, and value; use null for targetRef or value when the method does not use it. 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/app-builder-file-policy-support.ts b/packages/agent-core/src/mastra/tool-defs/app-builder-file-policy-support.ts new file mode 100644 index 00000000..13c3e7e5 --- /dev/null +++ b/packages/agent-core/src/mastra/tool-defs/app-builder-file-policy-support.ts @@ -0,0 +1,45 @@ +import { APIError } from "@cheatcode/observability"; +import { CONTEXT } from "../context"; +import { requestContextFromToolContext } from "./tool-runtime-context"; + +const FRAMEWORK_MANIFESTS = new Set([ + "app.json", + "babel.config.js", + "babel.config.mjs", + "metro.config.js", + "metro.config.mjs", + "next.config.js", + "next.config.mjs", + "next.config.ts", + "package-lock.json", + "package.json", + "pnpm-lock.yaml", + "postcss.config.js", + "postcss.config.mjs", + "tsconfig.json", + "yarn.lock", +]); + +/** Keeps the managed Next.js or Expo runtime immutable while source files remain fully editable. */ +export function assertManagedAppFrameworkFileMutable(context: unknown, path: string): void { + const requestContext = requestContextFromToolContext(context); + if (requestContext.get(CONTEXT.appBuilderManagedPreview) !== true) return; + if (!isProjectRootFrameworkManifest(requestContext.get(CONTEXT.promptWorkspaceDir), path)) return; + throw new APIError( + 422, + "tool_validation_failed", + "The managed app framework manifest cannot be replaced or deleted.", + { + hint: "Edit application source files. Use pnpm add or pnpm remove when dependencies need to change.", + retriable: false, + }, + ); +} + +function isProjectRootFrameworkManifest(workspaceDir: unknown, path: string): boolean { + const normalized = path.replace(/\/+$/u, ""); + const filename = normalized.slice(normalized.lastIndexOf("/") + 1); + if (!FRAMEWORK_MANIFESTS.has(filename)) return false; + const parent = normalized.slice(0, normalized.lastIndexOf("/")) || "/"; + return parent === "/workspace" || (typeof workspaceDir === "string" && parent === workspaceDir); +} diff --git a/packages/agent-core/src/mastra/tool-defs/app-builder-shell-policy-support.ts b/packages/agent-core/src/mastra/tool-defs/app-builder-shell-policy-support.ts index 045e7985..8122430a 100644 --- a/packages/agent-core/src/mastra/tool-defs/app-builder-shell-policy-support.ts +++ b/packages/agent-core/src/mastra/tool-defs/app-builder-shell-policy-support.ts @@ -8,6 +8,8 @@ const PACKAGE_RUNNERS = new Set(["bunx", "npx", "pnpx"]); const SCAFFOLD_EXECUTABLE = /^(?:create(?:-[a-z0-9@._/-]+)?|create-next-app)$/u; const SHELL_SCAFFOLD_COMMAND = /(?:^|&&|\|\||;|\n|\()\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+)\s+)*(?:command\s+)?(?:sudo\s+)?(?:corepack\s+)?(?:(?:npm|pnpm|yarn|bun)\s+(?:create|init)\b|(?:npx|pnpx|bunx)\s+(?:--[a-z-]+(?:=[^\s]+)?\s+)*(?:create(?:-[^\s;&|()]+)?|create-next-app|expo\s+init)\b|pnpm\s+(?:dlx|exec)\s+(?:--[a-z-]+(?:=[^\s]+)?\s+)*(?:create(?:-[^\s;&|()]+)?|create-next-app|expo\s+init)\b)/iu; +const MANAGED_PREVIEW_START_COMMAND = + /(?:^|&&|\|\||;|\n|\()\s*(?:(?:[A-Za-z_][A-Za-z0-9_]*=[^\s;&|()]+)\s+)*(?:command\s+)?(?:sudo\s+)?(?:corepack\s+)?(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|web)\b|(?:npx|pnpx|bunx|pnpm\s+(?:dlx|exec))\s+(?:--[a-z-]+(?:=[^\s]+)?\s+)*(?:expo\s+start|next\s+dev|vite)\b|(?:expo\s+start|next\s+dev|vite)(?:\s|$))/iu; /** Prevents model-authored scaffolds from replacing the prepared app-builder workspace. */ export function assertAppBuilderShellCommandAllowed( @@ -17,13 +19,44 @@ export function assertAppBuilderShellCommandAllowed( const requestContext = requestContextFromToolContext(context); const projectMode = requestContext.get(CONTEXT.promptProjectMode); if (typeof projectMode !== "string" || !APP_BUILDER_MODES.has(projectMode)) return; - if (!isScaffoldCommand(command)) return; + if (isScaffoldCommand(command)) { + throw new APIError( + 422, + "tool_validation_failed", + "This app workspace is already scaffolded at the project root.", + { + hint: "Inspect and edit the existing root files. Do not initialize another app or nested project.", + retriable: false, + }, + ); + } + if ( + requestContext.get(CONTEXT.appBuilderManagedPreview) !== true || + !isManagedPreviewStartCommand(command) + ) { + return; + } throw new APIError( 422, "tool_validation_failed", - "This app workspace is already scaffolded at the project root.", + "This app's managed preview is already running.", { - hint: "Inspect and edit the existing root files. Do not initialize another app or nested project.", + hint: "Edit the existing source files and verify the running preview in the browser.", + retriable: false, + }, + ); +} + +/** Prevents a stale or unadvertised dev-server call from replacing a managed template preview. */ +export function assertManagedAppDevServerStartAllowed(context: unknown): void { + const requestContext = requestContextFromToolContext(context); + if (requestContext.get(CONTEXT.appBuilderManagedPreview) !== true) return; + throw new APIError( + 422, + "tool_validation_failed", + "This app's managed preview is already running.", + { + hint: "Edit the existing source files and verify the running preview in the browser.", retriable: false, }, ); @@ -44,6 +77,34 @@ function isScaffoldCommand(command: readonly string[] | string): boolean { return PACKAGE_RUNNERS.has(executable) && runnerStartsScaffold(args); } +function isManagedPreviewStartCommand(command: readonly string[] | string): boolean { + if (typeof command === "string") return MANAGED_PREVIEW_START_COMMAND.test(command); + const normalized = unwrapCommand(command.map((argument) => argument.toLowerCase())); + const executable = basename(normalized[0]); + const args = normalized.slice(1).filter((argument) => !argument.startsWith("-")); + if (["bun", "npm", "pnpm", "yarn"].includes(executable)) { + const commandName = args[0] === "run" ? args[1] : args[0]; + if (["dev", "start", "web"].includes(commandName ?? "")) return true; + if (executable === "pnpm" && ["dlx", "exec"].includes(args[0] ?? "")) { + return runnerStartsPreview(args.slice(1)); + } + return false; + } + if (PACKAGE_RUNNERS.has(executable)) return runnerStartsPreview(args); + return runnerStartsPreview([executable, ...args]); +} + +function runnerStartsPreview(args: readonly string[]): boolean { + const executableIndex = args.findIndex((argument) => !argument.startsWith("-")); + const executable = args[executableIndex]; + const command = args.slice(executableIndex + 1).find((argument) => !argument.startsWith("-")); + return ( + executable === "vite" || + (executable === "next" && command === "dev") || + (executable === "expo" && command === "start") + ); +} + function unwrapCommand(command: readonly string[]): readonly string[] { let offset = 0; while (command[offset] === "env" || command[offset] === "sudo") { 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 a4a74804..5a3452d4 100644 --- a/packages/agent-core/src/mastra/tool-defs/browser-tools.ts +++ b/packages/agent-core/src/mastra/tool-defs/browser-tools.ts @@ -29,13 +29,23 @@ export const mastraBrowserOpen = createTool({ export const mastraBrowserAct = createTool({ id: "browser_act", 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.", + "Execute one deterministic action against an exact element ref from the latest browser_observe or browser_act tree. Always provide method, ref, targetRef, and value; use null for fields the selected method does not use. 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, + method: "click", + ref: "1-2", + targetRef: null, + value: null, + }, + }, + { + input: { + method: "fill", + ref: "1-3", + targetRef: null, + value: "Ada Lovelace", }, }, ], diff --git a/packages/agent-core/src/mastra/tool-defs/code-tools.ts b/packages/agent-core/src/mastra/tool-defs/code-tools.ts index 908cff96..ff13ae18 100644 --- a/packages/agent-core/src/mastra/tool-defs/code-tools.ts +++ b/packages/agent-core/src/mastra/tool-defs/code-tools.ts @@ -49,7 +49,11 @@ import { WriteFileOutputSchema, } from "../../tools/code"; import { containsWorkspaceReference } from "../../tools/code/workspace-paths"; -import { assertAppBuilderShellCommandAllowed } from "./app-builder-shell-policy-support"; +import { assertManagedAppFrameworkFileMutable } from "./app-builder-file-policy-support"; +import { + assertAppBuilderShellCommandAllowed, + assertManagedAppDevServerStartAllowed, +} from "./app-builder-shell-policy-support"; import { resolveMorphApplyRuntime } from "./request-context"; import { codeRuntimeFromContext, @@ -104,6 +108,7 @@ export const mastraShellStartProcess = createTool({ outputSchema: ShellProcessOutputSchema, execute: async (input, context) => { const parsedInput = ShellStartProcessInputSchema.parse(input); + assertAppBuilderShellCommandAllowed(context, parsedInput.command); const runtimeContext = await workspaceRuntimeFromContext(context); return executeShellStartProcess(parsedInput, runtimeContext); }, @@ -151,8 +156,11 @@ export const mastraFsWrite = createTool({ "Create a new file, write binary content, or deliberately regenerate an entire file under /workspace; every call requires both path and the complete content. Use fs_apply for every focused or multi-section edit to an existing text file. Never use fs_write merely as a fallback after fs_apply reports an infrastructure or stale-file error.", inputSchema: WriteFileInputSchema, outputSchema: WriteFileOutputSchema, - execute: async (input, context) => - executeWriteFile(input, await workspaceRuntimeFromContext(context)), + execute: async (input, context) => { + const parsedInput = WriteFileInputSchema.parse(input); + assertManagedAppFrameworkFileMutable(context, parsedInput.path); + return executeWriteFile(parsedInput, await workspaceRuntimeFromContext(context)); + }, }); export const mastraPublishDeliverable = createTool({ @@ -173,9 +181,11 @@ export const mastraFsApply = createTool({ inputSchema: ApplyFileInputSchema, outputSchema: ApplyFileOutputSchema, execute: async (input, context) => { + const parsedInput = ApplyFileInputSchema.parse(input); + assertManagedAppFrameworkFileMutable(context, parsedInput.path); const requestContext = requestContextFromToolContext(context); return executeApplyFile( - input, + parsedInput, await workspaceRuntimeFromContext(context), await resolveMorphApplyRuntime(requestContext), context.abortSignal, @@ -208,6 +218,7 @@ export const mastraFsDelete = createTool({ outputSchema: DeleteFileOutputSchema, execute: async (input, context) => { const parsedInput = DeleteFileInputSchema.parse(input); + assertManagedAppFrameworkFileMutable(context, parsedInput.path); const runtimeContext = await workspaceRuntimeFromContext(context); return executeDeleteFile(parsedInput, runtimeContext); }, @@ -269,6 +280,7 @@ export const mastraStartDevServer = createTool({ outputSchema: StartDevServerOutputSchema, execute: async (input, context) => { const parsedInput = StartDevServerInputSchema.parse(input); + assertManagedAppDevServerStartAllowed(context); const runtimeContext = await workspaceRuntimeFromContext(context); return executePreparedStartDevServer( await prepareStartDevServer( diff --git a/packages/agent-core/src/mastra/tool-defs/request-context.ts b/packages/agent-core/src/mastra/tool-defs/request-context.ts index b53f654c..274b8532 100644 --- a/packages/agent-core/src/mastra/tool-defs/request-context.ts +++ b/packages/agent-core/src/mastra/tool-defs/request-context.ts @@ -13,6 +13,7 @@ type MorphApplyResolver = () => Promise; interface CodeRequestContextOptions { agentDisplayName?: string | undefined; anthropicApiKey?: string | undefined; + appBuilderManagedPreview?: boolean | undefined; composioApiKey?: string | undefined; composioConnectedAccounts?: ComposioConnectedAccounts | undefined; composioQuotaMeter?: ComposioQuotaMeter | undefined; @@ -56,6 +57,7 @@ function contextEntries( [CONTEXT.llmProvider, options.llmProvider], [CONTEXT.llmModelId, options.modelId], [CONTEXT.agentDisplayName, options.agentDisplayName], + [CONTEXT.appBuilderManagedPreview, options.appBuilderManagedPreview], [CONTEXT.globalMemory, options.globalMemory], [CONTEXT.promptProjectMode, options.projectMode], [CONTEXT.runIntent, options.runIntent], diff --git a/packages/agent-core/src/tools/browser/actions.ts b/packages/agent-core/src/tools/browser/actions.ts index 7977672c..a0b4c1a4 100644 --- a/packages/agent-core/src/tools/browser/actions.ts +++ b/packages/agent-core/src/tools/browser/actions.ts @@ -2,10 +2,17 @@ import { APIError } from "@cheatcode/observability"; import type { ArtifactUploadResult } from "@cheatcode/sandbox-contracts"; import { INTERNAL_OUTPUT_FILENAME_PREFIX } from "@cheatcode/types/artifacts"; import { z } from "zod"; +import { + BrowserActInputSchema, + BrowserBoundActionSchema, + browserBoundActionFromInput, +} from "./browser-action-contract-support"; import type { BrowserRuntimeContext } from "./runtime"; import { BrowserRuntimeContextSchema } from "./runtime"; import { inspectBrowserScreenshot } from "./visual-inspection-support"; +export { BrowserActInputSchema } from "./browser-action-contract-support"; + const DRIVER_PROCESS_PREFIX = "cheatcode-browser-driver-"; const DRIVER_LAUNCHER_PATH = "/usr/local/bin/cheatcode-browser-driver"; const DRIVER_PORT_BASE = 20_000; @@ -21,6 +28,7 @@ const MAX_BROWSER_SCREENSHOT_BASE64_CHARACTERS = Math.ceil(MAX_BROWSER_SCREENSHO const DRIVER_REQUEST_OVERHEAD_MS = 30_000; const DRIVER_REQUEST_MAX_MS = 600_000; const DRIVER_HEALTH_TIMEOUT_MS = 5_000; +const BROWSER_ACT_TIMEOUT_MS = 10_000; interface BrowserDriverConnection { authToken: string; @@ -48,80 +56,6 @@ export const BrowserOpenInputSchema = z.strictObject({ waitUntil: WaitUntilSchema.default("domcontentloaded").describe("Navigation wait strategy."), }); -const BrowserElementRefSchema = z - .string() - .regex(/^\d+-\d+$/u) - .max(64) - .describe("Exact hyphenated element ref from the latest browser_observe or browser_act tree."); - -const BrowserActionMethodSchema = z.enum([ - "click", - "doubleClick", - "dragAndDrop", - "fill", - "hover", - "nextChunk", - "press", - "prevChunk", - "scrollTo", - "selectOptionFromDropdown", - "type", -]); - -const BrowserNoValueActionMethodSchema = BrowserActionMethodSchema.exclude([ - "dragAndDrop", - "fill", - "press", - "scrollTo", - "selectOptionFromDropdown", - "type", -]); - -const BrowserValueActionMethodSchema = z.enum([ - "fill", - "press", - "scrollTo", - "selectOptionFromDropdown", - "type", -]); - -const BrowserBoundActionSchema = z.union([ - z.strictObject({ - method: BrowserNoValueActionMethodSchema.describe( - "Deterministic action to perform on the ref.", - ), - ref: BrowserElementRefSchema, - }), - z.strictObject({ - method: BrowserValueActionMethodSchema.describe( - "Deterministic value-taking action to perform on the ref.", - ), - ref: BrowserElementRefSchema, - value: z - .string() - .max(2_000) - .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( - "A ref-bound action chosen from the latest tree returned by browser_observe or browser_act.", - ), - timeoutMs: z - .number() - .int() - .positive() - .max(120_000) - .default(10_000) - .describe("Maximum time for this browser action."), -}); - const BrowserActGuardSchema = z.strictObject({ allowedOrigin: BrowserUrlSchema, expectedUrl: BrowserUrlSchema, @@ -263,11 +197,11 @@ export async function executeBrowserAct( { actions: [ { - action: parsedInput.action, + action: browserBoundActionFromInput(parsedInput), allowedOrigin: parsedGuard.allowedOrigin, expectedUrl: parsedGuard.expectedUrl, type: "act", - timeoutMs: parsedInput.timeoutMs, + timeoutMs: BROWSER_ACT_TIMEOUT_MS, }, ], }, diff --git a/packages/agent-core/src/tools/browser/browser-action-contract-support.ts b/packages/agent-core/src/tools/browser/browser-action-contract-support.ts new file mode 100644 index 00000000..132bb6f7 --- /dev/null +++ b/packages/agent-core/src/tools/browser/browser-action-contract-support.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; + +const BrowserElementRefSchema = z + .string() + .regex(/^\d+-\d+$/u) + .max(64) + .describe("Exact hyphenated element ref from the latest browser_observe or browser_act tree."); + +const BrowserActionMethodSchema = z.enum([ + "click", + "doubleClick", + "dragAndDrop", + "fill", + "hover", + "nextChunk", + "press", + "prevChunk", + "scrollTo", + "selectOptionFromDropdown", + "type", +]); + +const BrowserNoValueActionMethodSchema = BrowserActionMethodSchema.exclude([ + "dragAndDrop", + "fill", + "press", + "scrollTo", + "selectOptionFromDropdown", + "type", +]); + +const BrowserValueActionMethodSchema = z.enum([ + "fill", + "press", + "scrollTo", + "selectOptionFromDropdown", + "type", +]); + +interface BrowserActInput { + method: z.infer; + ref: string; + targetRef: string | null; + value: string | null; +} + +type BrowserValueActionMethod = z.infer; + +export const BrowserBoundActionSchema = z.union([ + z.strictObject({ + method: BrowserNoValueActionMethodSchema.describe( + "Deterministic action to perform on the ref.", + ), + ref: BrowserElementRefSchema, + }), + z.strictObject({ + method: BrowserValueActionMethodSchema.describe( + "Deterministic value-taking action to perform on the ref.", + ), + ref: BrowserElementRefSchema, + value: z + .string() + .max(2_000) + .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({ + method: BrowserActionMethodSchema.describe( + "Deterministic action to perform on the exact ref from the latest browser tree.", + ), + ref: BrowserElementRefSchema, + targetRef: BrowserElementRefSchema.nullable().describe( + "Destination ref for dragAndDrop; null for every other method.", + ), + value: z + .string() + .min(1) + .max(2_000) + .nullable() + .describe( + "Text, key, option, or percentage for fill, press, scrollTo, selectOptionFromDropdown, and type; null for every other method.", + ), + }) + .superRefine(validateBrowserActInput); + +function validateBrowserActInput(input: BrowserActInput, context: z.RefinementCtx): void { + if (input.method === "dragAndDrop") { + requireDragTarget(input, context); + return; + } + if (isValueActionMethod(input.method)) { + if (input.value === null) { + addIssue(context, "value", `${input.method} requires value.`); + } + } else if (input.value !== null) { + addIssue(context, "value", `${input.method} does not accept value.`); + } + if (input.targetRef !== null) { + addIssue(context, "targetRef", `${input.method} does not accept targetRef.`); + } +} + +function requireDragTarget(input: BrowserActInput, context: z.RefinementCtx): void { + if (input.targetRef === null) { + addIssue(context, "targetRef", "dragAndDrop requires targetRef."); + } + if (input.value !== null) { + addIssue(context, "value", "dragAndDrop does not accept value."); + } +} + +function addIssue(context: z.RefinementCtx, path: "targetRef" | "value", message: string): void { + context.addIssue({ code: "custom", message, path: [path] }); +} + +export function browserBoundActionFromInput( + input: z.infer, +): z.infer { + if (input.method === "dragAndDrop") { + if (input.targetRef === null) { + throw new Error("Validated dragAndDrop input is missing targetRef."); + } + return { method: input.method, ref: input.ref, targetRef: input.targetRef }; + } + if (isValueActionMethod(input.method)) { + if (input.value === null) { + throw new Error(`Validated ${input.method} input is missing value.`); + } + return { method: input.method, ref: input.ref, value: input.value }; + } + return { method: input.method, ref: input.ref }; +} + +function isValueActionMethod( + method: BrowserActInput["method"], +): method is BrowserValueActionMethod { + return BrowserValueActionMethodSchema.options.some((candidate) => candidate === method); +}