Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/agent-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ answer segmentation. The Workflow controller owns admission, execution identity,
at-most-once fence, and ownership leases. The shell retains only Durable Object identity,
cancellation, status, and dependency wiring.

An explicit app-builder mode remains authoritative. On a projectless first run, a narrowly
matched imperative such as “build a website” or “create a mobile app” also enters the matching
app-builder path before model execution. That 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.

AgentRun keeps one compact exact SQLite shape for run identity, replay parts, and
coordination state. Dormant objects are reconciled transactionally on activation;
target detection checks column order, affinity, nullability, primary keys, and
Expand Down
27 changes: 25 additions & 2 deletions apps/agent-worker/src/durable-objects/agent-run-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ type ProjectBoundAgentRunPathOptions = AgentRunPathOptions & {
export async function executeAgentRunPath(
options: AgentRunPathOptions,
): Promise<"completed" | "continue"> {
if (isAppBuilderMode(options.input.projectMode)) {
const appBuilderMode = appBuilderModeForRun(options.input);
if (appBuilderMode) {
options.input.projectMode = appBuilderMode;
await options.workspaceResolver();
return executeAppBuilderPath({
...options,
Expand Down Expand Up @@ -92,6 +94,27 @@ function requireProjectBinding(input: StartRunInput): ProjectBoundStartRunInput
return input as ProjectBoundStartRunInput;
}

function isAppBuilderMode(mode: StartRunInput["projectMode"]): boolean {
function isAppBuilderMode(
mode: StartRunInput["projectMode"],
): mode is "app-builder" | "app-builder-mobile" {
return mode === "app-builder" || mode === "app-builder-mobile";
}

const IMPERATIVE_BUILD_PATTERN =
/^(?:please\s+)?(?:(?:can|could|would)\s+you\s+)?(?:build|create|make|design|develop|implement|code|scaffold|redesign|clone)\b/iu;
const MOBILE_APP_PATTERN = /\b(?:mobile app|expo|react native|ios app|android app|iphone app)\b/iu;
const WEB_APP_PATTERN =
/\b(?:web ?app|website|web ?site|landing ?page|home ?page|web ?page|dashboard|next\.?js|frontend|front-end|saas)\b/iu;

function appBuilderModeForRun(input: StartRunInput): "app-builder" | "app-builder-mobile" | null {
if (isAppBuilderMode(input.projectMode)) {
return input.projectMode;
}
if (!input.isFirstRun || input.projectId || !IMPERATIVE_BUILD_PATTERN.test(input.messageText)) {
return null;
}
if (MOBILE_APP_PATTERN.test(input.messageText)) {
return "app-builder-mobile";
}
return WEB_APP_PATTERN.test(input.messageText) ? "app-builder" : null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ async function materializeWorkspaceProject(input: WorkspaceResolverInput) {
materializeThreadProject(
tx,
{
...(input.input.projectMode === "general"
? {}
: { projectMode: input.input.projectMode }),
threadId: toThreadId(input.input.threadId),
userId,
},
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,10 @@ project's allocated port when necessary, and is distinct from generic background
process tools so idle recovery always has a canonical process record.
Browser-only runs use the account sandbox without materializing a persistent project;
workspace-backed file, shell, document, chart, or artifact work resolves the thread's
project lazily when durable project storage is actually needed.
project lazily when durable project storage is actually needed. Code tools expose `/workspace`
as a virtual project root and remap path references inside argv, shell payloads, and inline code
to the canonical project folder. Projectless calculations and environment probes run from `/tmp`,
so a weaker model cannot accidentally leave durable files outside a project.

## Code Checks

Expand Down
13 changes: 10 additions & 3 deletions packages/agent-core/src/mastra/tool-defs/code-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
WriteFileInputSchema,
WriteFileOutputSchema,
} from "../../tools/code";
import { containsWorkspaceReference } from "../../tools/code/workspace-paths";
import { codeRuntimeFromContext, workspaceRuntimeFromContext } from "./tool-runtime-context";
import { StartDevServerInputSchema, StartDevServerOutputSchema } from "./tool-schemas";

Expand All @@ -52,8 +53,12 @@ export const mastraRunCode = createTool({
inputSchema: RunCodeInputSchema,
outputSchema: RunCodeOutputSchema,
execute: async (input, context) => {
const runtimeContext = codeRuntimeFromContext(context);
const parsedInput = RunCodeInputSchema.parse(input);
const baseRuntime = codeRuntimeFromContext(context);
const runtimeContext =
baseRuntime.workspaceDir || containsWorkspaceReference(parsedInput.code)
? await workspaceRuntimeFromContext(context)
: baseRuntime;
const output = await executeRunCode(parsedInput, runtimeContext);
return RunCodeOutputSchema.parse(output);
},
Expand All @@ -69,9 +74,11 @@ export const mastraShellExec = createTool({
const parsedInput = ShellExecInputSchema.parse(input);
const baseRuntime = codeRuntimeFromContext(context);
const runtimeContext =
baseRuntime.workspaceDir || parsedInput.cwd
baseRuntime.workspaceDir ||
parsedInput.cwd ||
parsedInput.command.some(containsWorkspaceReference)
? await workspaceRuntimeFromContext(context)
: { ...baseRuntime, workspaceDir: "/workspace" };
: baseRuntime;
return executeShellExec(parsedInput, runtimeContext);
},
});
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-core/src/tools/code/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
type SandboxStartProcessInput,
} from "@cheatcode/sandbox-contracts";
import { z } from "zod";
import { resolveProjectWorkspacePath, WorkspacePathSchema } from "./workspace-paths";
import {
remapProjectWorkspaceReferences,
resolveProjectWorkspacePath,
WorkspacePathSchema,
} from "./workspace-paths";

const StartDevServerInputSchema = z.strictObject({
command: z.array(z.string().min(1).max(8_192)).min(1).max(128),
Expand Down Expand Up @@ -76,7 +80,10 @@ export async function prepareStartDevServer(
const isExpo = isExpoStartCommand(parsedInput.command);
const isMobile = isExpo || parsedInput.isMobile;
const port = await allocateDevServerPort(runtimeContext, slug, isMobile);
const command = remapRequestedDevServerPort(parsedInput.command, parsedInput.port, port);
const workspaceCommand = parsedInput.command.map((argument) =>
remapProjectWorkspaceReferences(argument, runtimeContext.workspaceDir),
);
const command = remapRequestedDevServerPort(workspaceCommand, parsedInput.port, port);
return {
mayUseNetwork: isExpo,
port,
Expand Down
7 changes: 4 additions & 3 deletions packages/agent-core/src/tools/code/run-code.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { APIError } from "@cheatcode/observability";
import type { CodeRuntimeContextFor } from "@cheatcode/sandbox-contracts";
import { z } from "zod";
import { resolveProjectWorkspacePath } from "./workspace-paths";
import { remapProjectWorkspaceReferences, resolveProjectWorkspacePath } from "./workspace-paths";

export const RunCodeInputSchema = z.strictObject({
language: z
Expand All @@ -25,10 +25,11 @@ export async function executeRunCode(
runtimeContext: CodeRuntimeContextFor<"runCode">,
): Promise<RunCodeOutput> {
const parsedInput = RunCodeInputSchema.parse(input);
const workspaceDir = runtimeContext.workspaceDir;
const result = await runtimeContext.sandbox.runCode({
language: parsedInput.language,
code: parsedInput.code,
cwd: resolveProjectWorkspacePath(undefined, runtimeContext.workspaceDir),
code: remapProjectWorkspaceReferences(parsedInput.code, workspaceDir),
cwd: workspaceDir ? resolveProjectWorkspacePath(undefined, workspaceDir) : "/tmp",
});

const output = {
Expand Down
29 changes: 24 additions & 5 deletions packages/agent-core/src/tools/code/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import {
EnvironmentVariablesSchema,
} from "@cheatcode/sandbox-contracts";
import { z } from "zod";
import { resolveProjectWorkspacePath, WorkspacePathSchema } from "./workspace-paths";
import {
remapProjectWorkspaceReferences,
resolveProjectWorkspacePath,
WorkspacePathSchema,
} from "./workspace-paths";

export const ShellExecInputSchema = z.strictObject({
command: z
Expand Down Expand Up @@ -97,9 +101,13 @@ export async function executeShellExec(
runtimeContext: CodeRuntimeContextFor<"exec">,
): Promise<ShellExecOutput> {
const parsedInput = ShellExecInputSchema.parse(input);
const workspaceDir = runtimeContext.workspaceDir;
const result = await runtimeContext.sandbox.exec({
command: parsedInput.command,
cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir),
command: remapCommandWorkspaceReferences(parsedInput.command, workspaceDir),
cwd:
workspaceDir || parsedInput.cwd
? resolveProjectWorkspacePath(parsedInput.cwd, workspaceDir)
: "/tmp",
...(parsedInput.env ? { env: parsedInput.env } : {}),
...(parsedInput.timeoutMs ? { timeoutMs: parsedInput.timeoutMs } : {}),
});
Expand Down Expand Up @@ -130,7 +138,7 @@ export async function executeShellStartProcess(
: undefined;
return ShellProcessOutputSchema.parse(
await runtimeContext.sandbox.startProcess({
command: parsedInput.command,
command: remapCommandWorkspaceReferences(parsedInput.command, runtimeContext.workspaceDir),
cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir),
...(parsedInput.env ? { env: parsedInput.env } : {}),
keepAliveTimeoutMs: parsedInput.keepAliveTimeoutMs,
Expand Down Expand Up @@ -162,9 +170,20 @@ export async function executeShellTerminal(
const parsedInput = ShellTerminalInputSchema.parse(input);
return ShellExecOutputSchema.parse(
await runtimeContext.sandbox.exec({
command: ["sh", "-lc", parsedInput.command],
command: [
"sh",
"-lc",
remapProjectWorkspaceReferences(parsedInput.command, runtimeContext.workspaceDir),
],
cwd: resolveProjectWorkspacePath(parsedInput.cwd, runtimeContext.workspaceDir),
timeoutMs: parsedInput.timeoutMs,
}),
);
}

function remapCommandWorkspaceReferences(
command: readonly string[],
workspaceDir: string | undefined,
): string[] {
return command.map((argument) => remapProjectWorkspaceReferences(argument, workspaceDir));
}
51 changes: 50 additions & 1 deletion packages/agent-core/src/tools/code/workspace-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function resolveProjectWorkspacePath(
});
}
const requested = canonicalWorkspacePath(value ?? projectRoot);
const resolved = requested === "/workspace" ? projectRoot : requested;
const resolved = resolveVirtualWorkspacePath(requested, projectRoot);
if (resolved !== projectRoot && !resolved.startsWith(`${projectRoot}/`)) {
throw new APIError(
400,
Expand All @@ -63,6 +63,55 @@ export function resolveProjectWorkspacePath(
return resolved;
}

/**
* Rewrites the virtual `/workspace` namespace inside a command or inline program to the active
* project's real folder. References that already use the real project root remain unchanged.
*/
export function remapProjectWorkspaceReferences(
value: string,
workspaceDir: string | undefined,
): string {
const projectRoot = canonicalWorkspacePath(workspaceDir ?? "/workspace");
if (projectRoot === "/workspace") {
return value;
}
return value.replace(WORKSPACE_REFERENCE_PATTERN, (reference, offset: number) =>
isProjectRootReference(value, offset, projectRoot) ? reference : projectRoot,
);
}

export function containsWorkspaceReference(value: string): boolean {
WORKSPACE_REFERENCE_PATTERN.lastIndex = 0;
return WORKSPACE_REFERENCE_PATTERN.test(value);
}

const WORKSPACE_REFERENCE_PATTERN = /(?<![\p{L}\p{N}_./-])\/workspace(?=\/|$|[\s"'`,;:)}\]])/gu;

function resolveVirtualWorkspacePath(requested: string, projectRoot: string): string {
if (requested === projectRoot || requested.startsWith(`${projectRoot}/`)) {
return requested;
}
if (requested === "/workspace") {
return projectRoot;
}
if (requested.startsWith("/workspace/")) {
return `${projectRoot}${requested.slice("/workspace".length)}`;
}
return requested;
}

function isProjectRootReference(value: string, offset: number, projectRoot: string): boolean {
if (!value.startsWith(projectRoot, offset)) {
return false;
}
const nextCharacter = value.at(offset + projectRoot.length);
return nextCharacter === undefined || isPathBoundary(nextCharacter);
}

function isPathBoundary(value: string): boolean {
return /[/\s"'`,;:)}\]]/u.test(value);
}

function isSafeWorkspaceRelativePath(path: string): boolean {
if (path.startsWith("/") || path.includes("\0")) {
return false;
Expand Down
3 changes: 2 additions & 1 deletion packages/db/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ Public exports include:
- BYOK and integration helpers
- entitlement and usage helpers
- caller-configured user-skill list primitives plus locked count/insert/update composition
- entitlement-read/project-lock composition for billing-owned lazy-materialization limits
- entitlement-read/project-lock composition for billing-owned lazy-materialization limits and
the run's resolved project mode
- lifecycle job discovery, claim, renewal, progression, and completion helpers
- locked refund-intent reads/writes that execute caller-owned transition policy in-transaction
- audit and maintenance helpers
Expand Down
6 changes: 3 additions & 3 deletions packages/db/src/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async function lockRunIdempotencyKey(db: Database, input: CreateAgentRunInput):
/** Materialize a project atomically when a workspace-backed tool first needs it. */
export async function materializeThreadProject(
db: Database,
input: { threadId: ThreadId; userId: UserId },
input: { projectMode?: ProjectMode; threadId: ThreadId; userId: UserId },
resolveMaxActiveProjects: (entitlement: AgentEntitlementRecord | null) => number,
): Promise<MaterializeThreadProjectResult> {
return db.transaction(async (tx) => {
Expand All @@ -226,7 +226,7 @@ export async function materializeThreadProject(

async function materializeThreadProjectInLockedTx(
db: Database,
input: { threadId: ThreadId; userId: UserId },
input: { projectMode?: ProjectMode; threadId: ThreadId; userId: UserId },
maxActiveProjects: number,
): Promise<MaterializeThreadProjectResult> {
const [locked] = await db
Expand Down Expand Up @@ -267,7 +267,7 @@ async function materializeThreadProjectInLockedTx(
}
const intent: ThreadLaunchIntent = locked.launchIntent ?? {};
const project = await createProject(db, {
mode: intent.mode ?? "general",
mode: input.projectMode ?? intent.mode ?? "general",
name: projectNameFromTitle(locked.title),
userId: input.userId,
...(intent.defaultModel ? { defaultModel: intent.defaultModel } : {}),
Expand Down
2 changes: 1 addition & 1 deletion packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const GitHubRepoUrlSchema = z
const PROJECT_MODES = ["app-builder", "app-builder-mobile", "general"] as const;
export const ProjectModeSchema = z.enum(PROJECT_MODES);

/** Explicit one-run product modes. These are UI intent, never inferred from prompt text. */
/** Product modes selected by UI intent or a high-confidence first-run build imperative. */
const RUN_INTENTS = ["skill-creator"] as const;
export const RunIntentSchema = z.enum(RUN_INTENTS);

Expand Down