Skip to content

Commit 456c496

Browse files
committed
fix(agent): enforce managed project previews
Route high-confidence first-run app builds through the managed builder. Confine virtual workspace paths to the canonical project and keep projectless probes ephemeral so weaker models cannot produce blank Computer panels.
1 parent 2e35bd2 commit 456c496

12 files changed

Lines changed: 141 additions & 22 deletions

File tree

‎apps/agent-worker/README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ answer segmentation. The Workflow controller owns admission, execution identity,
109109
at-most-once fence, and ownership leases. The shell retains only Durable Object identity,
110110
cancellation, status, and dependency wiring.
111111

112+
An explicit app-builder mode remains authoritative. On a projectless first run, a narrowly
113+
matched imperative such as “build a website” or “create a mobile app” also enters the matching
114+
app-builder path before model execution. That high-confidence fallback materializes the project,
115+
scaffolds its canonical workspace, and registers the managed preview even when the selected model
116+
would otherwise attempt generic shell work and finish without a Computer target.
117+
112118
AgentRun keeps one compact exact SQLite shape for run identity, replay parts, and
113119
coordination state. Dormant objects are reconciled transactionally on activation;
114120
target detection checks column order, affinity, nullability, primary keys, and

‎apps/agent-worker/src/durable-objects/agent-run-path.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ type ProjectBoundAgentRunPathOptions = AgentRunPathOptions & {
3333
export async function executeAgentRunPath(
3434
options: AgentRunPathOptions,
3535
): Promise<"completed" | "continue"> {
36-
if (isAppBuilderMode(options.input.projectMode)) {
36+
const appBuilderMode = appBuilderModeForRun(options.input);
37+
if (appBuilderMode) {
38+
options.input.projectMode = appBuilderMode;
3739
await options.workspaceResolver();
3840
return executeAppBuilderPath({
3941
...options,
@@ -92,6 +94,27 @@ function requireProjectBinding(input: StartRunInput): ProjectBoundStartRunInput
9294
return input as ProjectBoundStartRunInput;
9395
}
9496

95-
function isAppBuilderMode(mode: StartRunInput["projectMode"]): boolean {
97+
function isAppBuilderMode(
98+
mode: StartRunInput["projectMode"],
99+
): mode is "app-builder" | "app-builder-mobile" {
96100
return mode === "app-builder" || mode === "app-builder-mobile";
97101
}
102+
103+
const IMPERATIVE_BUILD_PATTERN =
104+
/^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:build|create|make|design|develop|implement|code|scaffold|redesign|clone)\b/iu;
105+
const MOBILE_APP_PATTERN = /\b(?:mobile app|expo|react native|ios app|android app|iphone app)\b/iu;
106+
const WEB_APP_PATTERN =
107+
/\b(?:web ?app|website|web ?site|landing ?page|home ?page|web ?page|dashboard|next\.?js|frontend|front-end|saas)\b/iu;
108+
109+
function appBuilderModeForRun(input: StartRunInput): "app-builder" | "app-builder-mobile" | null {
110+
if (isAppBuilderMode(input.projectMode)) {
111+
return input.projectMode;
112+
}
113+
if (!input.isFirstRun || input.projectId || !IMPERATIVE_BUILD_PATTERN.test(input.messageText)) {
114+
return null;
115+
}
116+
if (MOBILE_APP_PATTERN.test(input.messageText)) {
117+
return "app-builder-mobile";
118+
}
119+
return WEB_APP_PATTERN.test(input.messageText) ? "app-builder" : null;
120+
}

‎apps/agent-worker/src/durable-objects/agent-run-workspace.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ async function materializeWorkspaceProject(input: WorkspaceResolverInput) {
7777
materializeThreadProject(
7878
tx,
7979
{
80+
...(input.input.projectMode === "general"
81+
? {}
82+
: { projectMode: input.input.projectMode }),
8083
threadId: toThreadId(input.input.threadId),
8184
userId,
8285
},

‎packages/agent-core/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ project's allocated port when necessary, and is distinct from generic background
3636
process tools so idle recovery always has a canonical process record.
3737
Browser-only runs use the account sandbox without materializing a persistent project;
3838
workspace-backed file, shell, document, chart, or artifact work resolves the thread's
39-
project lazily when durable project storage is actually needed.
39+
project lazily when durable project storage is actually needed. Code tools expose `/workspace`
40+
as a virtual project root and remap path references inside argv, shell payloads, and inline code
41+
to the canonical project folder. Projectless calculations and environment probes run from `/tmp`,
42+
so a weaker model cannot accidentally leave durable files outside a project.
4043

4144
## Code Checks
4245

‎packages/agent-core/src/mastra/tool-defs/code-tools.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
WriteFileInputSchema,
4343
WriteFileOutputSchema,
4444
} from "../../tools/code";
45+
import { containsWorkspaceReference } from "../../tools/code/workspace-paths";
4546
import { codeRuntimeFromContext, workspaceRuntimeFromContext } from "./tool-runtime-context";
4647
import { StartDevServerInputSchema, StartDevServerOutputSchema } from "./tool-schemas";
4748

@@ -52,8 +53,12 @@ export const mastraRunCode = createTool({
5253
inputSchema: RunCodeInputSchema,
5354
outputSchema: RunCodeOutputSchema,
5455
execute: async (input, context) => {
55-
const runtimeContext = codeRuntimeFromContext(context);
5656
const parsedInput = RunCodeInputSchema.parse(input);
57+
const baseRuntime = codeRuntimeFromContext(context);
58+
const runtimeContext =
59+
baseRuntime.workspaceDir || containsWorkspaceReference(parsedInput.code)
60+
? await workspaceRuntimeFromContext(context)
61+
: baseRuntime;
5762
const output = await executeRunCode(parsedInput, runtimeContext);
5863
return RunCodeOutputSchema.parse(output);
5964
},
@@ -69,9 +74,11 @@ export const mastraShellExec = createTool({
6974
const parsedInput = ShellExecInputSchema.parse(input);
7075
const baseRuntime = codeRuntimeFromContext(context);
7176
const runtimeContext =
72-
baseRuntime.workspaceDir || parsedInput.cwd
77+
baseRuntime.workspaceDir ||
78+
parsedInput.cwd ||
79+
parsedInput.command.some(containsWorkspaceReference)
7380
? await workspaceRuntimeFromContext(context)
74-
: { ...baseRuntime, workspaceDir: "/workspace" };
81+
: baseRuntime;
7582
return executeShellExec(parsedInput, runtimeContext);
7683
},
7784
});

‎packages/agent-core/src/tools/code/preview.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ import {
55
type SandboxStartProcessInput,
66
} from "@cheatcode/sandbox-contracts";
77
import { z } from "zod";
8-
import { resolveProjectWorkspacePath, WorkspacePathSchema } from "./workspace-paths";
8+
import {
9+
remapProjectWorkspaceReferences,
10+
resolveProjectWorkspacePath,
11+
WorkspacePathSchema,
12+
} from "./workspace-paths";
913

1014
const StartDevServerInputSchema = z.strictObject({
1115
command: z.array(z.string().min(1).max(8_192)).min(1).max(128),
@@ -76,7 +80,10 @@ export async function prepareStartDevServer(
7680
const isExpo = isExpoStartCommand(parsedInput.command);
7781
const isMobile = isExpo || parsedInput.isMobile;
7882
const port = await allocateDevServerPort(runtimeContext, slug, isMobile);
79-
const command = remapRequestedDevServerPort(parsedInput.command, parsedInput.port, port);
83+
const workspaceCommand = parsedInput.command.map((argument) =>
84+
remapProjectWorkspaceReferences(argument, runtimeContext.workspaceDir),
85+
);
86+
const command = remapRequestedDevServerPort(workspaceCommand, parsedInput.port, port);
8087
return {
8188
mayUseNetwork: isExpo,
8289
port,

‎packages/agent-core/src/tools/code/run-code.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { APIError } from "@cheatcode/observability";
22
import type { CodeRuntimeContextFor } from "@cheatcode/sandbox-contracts";
33
import { z } from "zod";
4-
import { resolveProjectWorkspacePath } from "./workspace-paths";
4+
import { remapProjectWorkspaceReferences, resolveProjectWorkspacePath } from "./workspace-paths";
55

66
export const RunCodeInputSchema = z.strictObject({
77
language: z
@@ -25,10 +25,11 @@ export async function executeRunCode(
2525
runtimeContext: CodeRuntimeContextFor<"runCode">,
2626
): Promise<RunCodeOutput> {
2727
const parsedInput = RunCodeInputSchema.parse(input);
28+
const workspaceDir = runtimeContext.workspaceDir;
2829
const result = await runtimeContext.sandbox.runCode({
2930
language: parsedInput.language,
30-
code: parsedInput.code,
31-
cwd: resolveProjectWorkspacePath(undefined, runtimeContext.workspaceDir),
31+
code: remapProjectWorkspaceReferences(parsedInput.code, workspaceDir),
32+
cwd: workspaceDir ? resolveProjectWorkspacePath(undefined, workspaceDir) : "/tmp",
3233
});
3334

3435
const output = {

‎packages/agent-core/src/tools/code/shell.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
EnvironmentVariablesSchema,
55
} from "@cheatcode/sandbox-contracts";
66
import { z } from "zod";
7-
import { resolveProjectWorkspacePath, WorkspacePathSchema } from "./workspace-paths";
7+
import {
8+
remapProjectWorkspaceReferences,
9+
resolveProjectWorkspacePath,
10+
WorkspacePathSchema,
11+
} from "./workspace-paths";
812

913
export const ShellExecInputSchema = z.strictObject({
1014
command: z
@@ -97,9 +101,13 @@ export async function executeShellExec(
97101
runtimeContext: CodeRuntimeContextFor<"exec">,
98102
): Promise<ShellExecOutput> {
99103
const parsedInput = ShellExecInputSchema.parse(input);
104+
const workspaceDir = runtimeContext.workspaceDir;
100105
const result = await runtimeContext.sandbox.exec({
101-
command: parsedInput.command,
102-
cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir),
106+
command: remapCommandWorkspaceReferences(parsedInput.command, workspaceDir),
107+
cwd:
108+
workspaceDir || parsedInput.cwd
109+
? resolveProjectWorkspacePath(parsedInput.cwd, workspaceDir)
110+
: "/tmp",
103111
...(parsedInput.env ? { env: parsedInput.env } : {}),
104112
...(parsedInput.timeoutMs ? { timeoutMs: parsedInput.timeoutMs } : {}),
105113
});
@@ -130,7 +138,7 @@ export async function executeShellStartProcess(
130138
: undefined;
131139
return ShellProcessOutputSchema.parse(
132140
await runtimeContext.sandbox.startProcess({
133-
command: parsedInput.command,
141+
command: remapCommandWorkspaceReferences(parsedInput.command, runtimeContext.workspaceDir),
134142
cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir),
135143
...(parsedInput.env ? { env: parsedInput.env } : {}),
136144
keepAliveTimeoutMs: parsedInput.keepAliveTimeoutMs,
@@ -162,9 +170,20 @@ export async function executeShellTerminal(
162170
const parsedInput = ShellTerminalInputSchema.parse(input);
163171
return ShellExecOutputSchema.parse(
164172
await runtimeContext.sandbox.exec({
165-
command: ["sh", "-lc", parsedInput.command],
173+
command: [
174+
"sh",
175+
"-lc",
176+
remapProjectWorkspaceReferences(parsedInput.command, runtimeContext.workspaceDir),
177+
],
166178
cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir),
167179
timeoutMs: parsedInput.timeoutMs,
168180
}),
169181
);
170182
}
183+
184+
function remapCommandWorkspaceReferences(
185+
command: readonly string[],
186+
workspaceDir: string | undefined,
187+
): string[] {
188+
return command.map((argument) => remapProjectWorkspaceReferences(argument, workspaceDir));
189+
}

‎packages/agent-core/src/tools/code/workspace-paths.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function resolveProjectWorkspacePath(
4848
});
4949
}
5050
const requested = canonicalWorkspacePath(value ?? projectRoot);
51-
const resolved = requested === "/workspace" ? projectRoot : requested;
51+
const resolved = resolveVirtualWorkspacePath(requested, projectRoot);
5252
if (resolved !== projectRoot && !resolved.startsWith(`${projectRoot}/`)) {
5353
throw new APIError(
5454
400,
@@ -63,6 +63,55 @@ export function resolveProjectWorkspacePath(
6363
return resolved;
6464
}
6565

66+
/**
67+
* Rewrites the virtual `/workspace` namespace inside a command or inline program to the active
68+
* project's real folder. References that already use the real project root remain unchanged.
69+
*/
70+
export function remapProjectWorkspaceReferences(
71+
value: string,
72+
workspaceDir: string | undefined,
73+
): string {
74+
const projectRoot = canonicalWorkspacePath(workspaceDir ?? "/workspace");
75+
if (projectRoot === "/workspace") {
76+
return value;
77+
}
78+
return value.replace(WORKSPACE_REFERENCE_PATTERN, (reference, offset: number) =>
79+
isProjectRootReference(value, offset, projectRoot) ? reference : projectRoot,
80+
);
81+
}
82+
83+
export function containsWorkspaceReference(value: string): boolean {
84+
WORKSPACE_REFERENCE_PATTERN.lastIndex = 0;
85+
return WORKSPACE_REFERENCE_PATTERN.test(value);
86+
}
87+
88+
const WORKSPACE_REFERENCE_PATTERN = /(?<![\p{L}\p{N}_./-])\/workspace(?=\/|$|[\s"'`,;:)}\]])/gu;
89+
90+
function resolveVirtualWorkspacePath(requested: string, projectRoot: string): string {
91+
if (requested === projectRoot || requested.startsWith(`${projectRoot}/`)) {
92+
return requested;
93+
}
94+
if (requested === "/workspace") {
95+
return projectRoot;
96+
}
97+
if (requested.startsWith("/workspace/")) {
98+
return `${projectRoot}${requested.slice("/workspace".length)}`;
99+
}
100+
return requested;
101+
}
102+
103+
function isProjectRootReference(value: string, offset: number, projectRoot: string): boolean {
104+
if (!value.startsWith(projectRoot, offset)) {
105+
return false;
106+
}
107+
const nextCharacter = value.at(offset + projectRoot.length);
108+
return nextCharacter === undefined || isPathBoundary(nextCharacter);
109+
}
110+
111+
function isPathBoundary(value: string): boolean {
112+
return /[/\s"'`,;:)}\]]/u.test(value);
113+
}
114+
66115
function isSafeWorkspaceRelativePath(path: string): boolean {
67116
if (path.startsWith("/") || path.includes("\0")) {
68117
return false;

‎packages/db/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,8 @@ Public exports include:
9393
- BYOK and integration helpers
9494
- entitlement and usage helpers
9595
- caller-configured user-skill list primitives plus locked count/insert/update composition
96-
- entitlement-read/project-lock composition for billing-owned lazy-materialization limits
96+
- entitlement-read/project-lock composition for billing-owned lazy-materialization limits and
97+
the run's resolved project mode
9798
- lifecycle job discovery, claim, renewal, progression, and completion helpers
9899
- locked refund-intent reads/writes that execute caller-owned transition policy in-transaction
99100
- audit and maintenance helpers

0 commit comments

Comments
 (0)