From 942dff5f9c3f59e5811e4c7a025c0a83dfcc7b54 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:05:03 +0530 Subject: [PATCH 1/3] fix: bound remote agent execution and secure demo state --- experiments/agent-changes/README.md | 4 ++-- experiments/agent-changes/package.json | 1 - experiments/agent-changes/pnpm-lock.yaml | 6 ++---- experiments/agent-changes/pnpm-workspace.yaml | 2 -- experiments/agent-changes/src/project.ts | 17 +++++++++++++++++ experiments/agent-changes/src/task-agent.ts | 15 ++++++++++++++- experiments/agent-changes/src/worker.ts | 6 ++++++ experiments/agent-changes/web/App.tsx | 12 ++++++------ 8 files changed, 47 insertions(+), 16 deletions(-) delete mode 100644 experiments/agent-changes/pnpm-workspace.yaml diff --git a/experiments/agent-changes/README.md b/experiments/agent-changes/README.md index 99e1b9e0f..153ff175e 100644 --- a/experiments/agent-changes/README.md +++ b/experiments/agent-changes/README.md @@ -22,7 +22,7 @@ pnpm wrangler secret put DEMO_TOKEN pnpm deploy ``` -Generate a long random `DEMO_TOKEN` and enter it using Wrangler's secret prompt. **Never place it in source, `.env`, a URL parameter, or commit history.** The browser prompts for the token and retains it in `sessionStorage` for the current tab session. A demo token gates all `/api/*` endpoints; this is intentionally not multi-user authentication. +Generate a long random `DEMO_TOKEN` and enter it using Wrangler's secret prompt. **Never place it in source, `.env`, a URL parameter, or commit history.** The browser keeps this token only in React memory until page refresh. A demo token gates all `/api/*` endpoints; this is intentionally not multi-user authentication. If `wrangler artifacts namespaces list` reports **10004 Access denied**, first resolve Artifacts availability/permission on the account. No subsequent steps can validate real repository operations until this works. @@ -52,7 +52,7 @@ All `/api/*` routes require `Authorization: Bearer `. | `POST /api/projects/:id/accept` | `{ "taskId": "..." }`, merges one completed proposal | | `GET /health` | Unauthenticated health response | -Project IDs are durable and are kept in the browser URL (`?project=`). All runnable task contexts are assigned their own DO storage. Only **two agents and one comparison per project** are supported in v0. Diff previews are capped at 32 KB; agent turns are capped at eight, and the Worker does not expose Git tokens in API responses. +Project IDs are durable and are kept in the browser URL (`?project=`). All runnable task contexts are assigned their own DO storage. Only **two agents and one comparison per project** are supported in v0. Diff previews are capped at 32 KB; agent turns are capped at eight, with a maximum of 24 tool calls and a four-minute deadline checked between turns. In-flight shell execution can exceed that deadline. The Worker does not expose Git tokens in API responses. ## Intentional limits diff --git a/experiments/agent-changes/package.json b/experiments/agent-changes/package.json index dabc52d7f..74b7c4378 100644 --- a/experiments/agent-changes/package.json +++ b/experiments/agent-changes/package.json @@ -24,7 +24,6 @@ "@vitejs/plugin-react": "^6.0.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", - "@cloudflare/workers-types": "5.20261009.1", "@types/node": "^26.6.4", "tsx": "^4.22.3", "typescript": "^6.0.3", diff --git a/experiments/agent-changes/pnpm-lock.yaml b/experiments/agent-changes/pnpm-lock.yaml index 6f47c99d3..a17a870ad 100644 --- a/experiments/agent-changes/pnpm-lock.yaml +++ b/experiments/agent-changes/pnpm-lock.yaml @@ -27,9 +27,6 @@ importers: specifier: ^19.2.0 version: 19.3.0(react@19.3.0) devDependencies: - '@cloudflare/workers-types': - specifier: 5.20261009.1 - version: 5.20261009.1 '@tailwindcss/vite': specifier: ^4.1.0 version: 4.3.3(vite@8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1)) @@ -2086,7 +2083,8 @@ snapshots: '@cloudflare/workerd-windows-64@1.20261006.1': optional: true - '@cloudflare/workers-types@5.20261009.1': {} + '@cloudflare/workers-types@5.20261009.1': + optional: true '@cspotcode/source-map-support@0.8.1': dependencies: diff --git a/experiments/agent-changes/pnpm-workspace.yaml b/experiments/agent-changes/pnpm-workspace.yaml deleted file mode 100644 index db5c03c2f..000000000 --- a/experiments/agent-changes/pnpm-workspace.yaml +++ /dev/null @@ -1,2 +0,0 @@ -minimumReleaseAgeExclude: - - '@cloudflare/workers-types@5.20261009.1' diff --git a/experiments/agent-changes/src/project.ts b/experiments/agent-changes/src/project.ts index a530fea94..4624fb9cd 100644 --- a/experiments/agent-changes/src/project.ts +++ b/experiments/agent-changes/src/project.ts @@ -15,6 +15,23 @@ export class ProjectCoordinator extends DurableObject { return (await this.ctx.storage.get("project")) ?? null; } + /** Claims at most one comparison so duplicate requests cannot launch extra paid runs. */ + async claimComparison(): Promise { + return this.ctx.storage.transaction(async (storage) => { + if (await storage.get("comparison:claimed")) return false; + await storage.put("comparison:claimed", true); + return true; + }); + } + + async claimIntegration(): Promise { + return this.ctx.storage.transaction(async (storage) => { + if (await storage.get("integration:claimed")) return false; + await storage.put("integration:claimed", true); + return true; + }); + } + async addTask(task: Task): Promise { if (await this.ctx.storage.get(`task:${task.id}`)) throw new Error("Duplicate task"); await this.ctx.storage.put(`task:${task.id}`, task); diff --git a/experiments/agent-changes/src/task-agent.ts b/experiments/agent-changes/src/task-agent.ts index 10050f8ca..a1f3dbd09 100644 --- a/experiments/agent-changes/src/task-agent.ts +++ b/experiments/agent-changes/src/task-agent.ts @@ -128,7 +128,15 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { try { await coordinator.updateTask(input.id, { status: "running" }); const authorization = `http.extraHeader=Authorization: Bearer ${input.token}`; - await this.git(sh`git -c ${authorization} clone ${input.forkRemote} ${PROJECT_DIR}`); + for (let attempt = 0; attempt < 4; attempt++) { + try { + await this.git(sh`git -c ${authorization} clone ${input.forkRemote} ${PROJECT_DIR}`); + break; + } catch (error) { + if (attempt === 3) throw error; + await new Promise((resolve) => setTimeout(resolve, 1500)); + } + } const { tools, execute } = createPiTools({ workspace: this.workspace }); const models = createModels(); @@ -138,7 +146,10 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { const messages: Message[] = [{ role: "user", content: input.prompt, timestamp: Date.now() }]; let response = ""; + let toolCalls = 0; + const deadline = Date.now() + 4 * 60_000; for (let turn = 0; turn < 8; turn++) { + if (Date.now() > deadline) throw new Error("Agent run deadline reached"); const reply = await models.complete(model, { systemPrompt: [ `You are a coding agent working on a repository at ${PROJECT_DIR}.`, @@ -151,6 +162,8 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { }); messages.push(reply); const calls = reply.content.filter((part) => part.type === "toolCall"); + toolCalls += calls.length; + if (calls.length > 5 || toolCalls > 24) throw new Error("Agent tool budget exceeded"); if (!calls.length) { response = reply.content.filter((part) => part.type === "text").map((part) => part.text).join(""); break; diff --git a/experiments/agent-changes/src/worker.ts b/experiments/agent-changes/src/worker.ts index 4e0955826..f6780016a 100644 --- a/experiments/agent-changes/src/worker.ts +++ b/experiments/agent-changes/src/worker.ts @@ -61,6 +61,7 @@ async function compare(request: Request, env: AppEnv, projectId: string): Promis using source = await env.ARTIFACTS.get(project.baseRepo); const baseCommit = (await source.log({ ref: project.defaultBranch, limit: 1 }))[0]?.hash; if (!baseCommit) return jsonError("Source repository has no initial commit", 409); + if (!(await stub.claimComparison())) return jsonError("A comparison is already in progress", 409); const tasks: Task[] = []; for (const prompt of prompts) { @@ -104,6 +105,7 @@ async function accept(request: Request, env: AppEnv, projectId: string): Promise const task = await coordinator.task(body.taskId); if (!task || task.status !== "completed" || !task.headCommit) return jsonError("Proposal not ready", 409); if (task.integrationStatus) return jsonError("Proposal already integrated or conflicted", 409); + if (!(await coordinator.claimIntegration())) return jsonError("Integration is already in progress", 409); using base = await env.ARTIFACTS.get(project.baseRepo); using fork = await env.ARTIFACTS.get(task.forkRepo); @@ -143,6 +145,10 @@ export default { if (acceptRoute && request.method === "POST") return await accept(request, env, acceptRoute[1]); return jsonError("Not found", 404); } catch (error) { + const code = error instanceof Error && "code" in error ? String(error.code) : ""; + if (code === "IMPORT_IN_PROGRESS" || code === "FORK_IN_PROGRESS") { + return jsonError("Repository is still being prepared; retry shortly", 409); + } return jsonError(error, error instanceof SyntaxError ? 400 : 502); } }, diff --git a/experiments/agent-changes/web/App.tsx b/experiments/agent-changes/web/App.tsx index 77dc8306c..feb2d0568 100644 --- a/experiments/agent-changes/web/App.tsx +++ b/experiments/agent-changes/web/App.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, Check, CircleDot, Code2, GitBranch, GitMerge, KeyRound, LoaderCircle, ShieldCheck, Terminal, TriangleAlert } from "lucide-react"; import type { Task } from "../src/domain.js"; @@ -46,8 +46,8 @@ function TaskPanel({ task, selected, select }: { task: Task; selected: boolean; export function App() { const queryClient = useQueryClient(); - const [token, setToken] = useState(() => sessionStorage.getItem("devspace-demo-token") ?? ""); - const [tokenDraft, setTokenDraft] = useState(token); + const [token, setToken] = useState(""); + const [tokenDraft, setTokenDraft] = useState(""); const [projectId, setProjectId] = useState(() => new URLSearchParams(location.search).get("project") ?? ""); const [sourceUrl, setSourceUrl] = useState(""); const [prompts, setPrompts] = useState<[string, string]>(["", ""]); @@ -65,7 +65,7 @@ export function App() { queryFn: () => endpoints.proposals(token, projectId), enabled: enabled && taskList.some((t) => t.status === "completed"), }); - const selected = useMemo(() => taskList.find((t) => t.id === selectedId) ?? taskList[0], [selectedId, taskList]); + const selected = taskList.find((t) => t.id === selectedId) ?? taskList[0]; const selectedDiff = proposals.data?.proposals.find(({ task }) => task.id === selected?.id)?.diff; const createProject = useMutation({ @@ -111,8 +111,8 @@ export function App() {

Unlock your workspace

-

Enter the demo access token configured on the Worker. It stays in this browser tab's session storage.

-
{ event.preventDefault(); sessionStorage.setItem("devspace-demo-token", tokenDraft); setToken(tokenDraft); }} className="flex gap-2"> +

Enter the demo access token configured on the Worker. It is kept only in page memory and cleared on refresh.

+ { event.preventDefault(); setToken(tokenDraft); setTokenDraft(""); }} className="flex gap-2"> setTokenDraft(event.target.value)} placeholder="Access token" className={field} required />
From 6a380618e2eeb5f42bdd8db2d452203d33ecb12e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:52:25 +0530 Subject: [PATCH 2/3] fix: make agent Git operations and proposal recovery work --- experiments/agent-changes/src/domain.ts | 15 ++- .../agent-changes/src/git-integration.ts | 63 +++++++++++ experiments/agent-changes/src/project.ts | 52 +++++++-- experiments/agent-changes/src/task-agent.ts | 73 +++++++------ experiments/agent-changes/src/worker.ts | 103 +++++++++++------- .../test/git-integration.test.ts | 76 +++++++++++++ 6 files changed, 291 insertions(+), 91 deletions(-) create mode 100644 experiments/agent-changes/src/git-integration.ts create mode 100644 experiments/agent-changes/test/git-integration.test.ts diff --git a/experiments/agent-changes/src/domain.ts b/experiments/agent-changes/src/domain.ts index d7435f949..d9a9b0ae6 100644 --- a/experiments/agent-changes/src/domain.ts +++ b/experiments/agent-changes/src/domain.ts @@ -29,29 +29,32 @@ export interface ProposalDiff { truncated: boolean; } +export class InputError extends Error {} + export function publicGitUrl(value: unknown): string { - if (typeof value !== "string") throw new Error("sourceUrl must be a public HTTPS Git URL"); - const url = new URL(value); + if (typeof value !== "string") throw new InputError("sourceUrl must be a public HTTPS Git URL"); + let url: URL; + try { url = new URL(value); } catch { throw new InputError("sourceUrl must be a public HTTPS Git URL"); } if (url.protocol !== "https:" || url.username || url.password || url.port || !url.hostname || /^(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|\[)/i.test(url.hostname)) { - throw new Error("sourceUrl must be a public HTTPS Git URL"); + throw new InputError("sourceUrl must be a public HTTPS Git URL"); } // V0 intentionally supports GitHub public repositories only; no server-side arbitrary URL fetches. if (url.hostname !== "github.com" || !/^\/[\w.-]+\/[\w.-]+(?:\.git)?\/?$/.test(url.pathname)) { - throw new Error("V0 accepts public github.com repository URLs only"); + throw new InputError("V0 accepts public github.com repository URLs only"); } return url.toString(); } export function repoName(id: string, suffix?: string): string { const raw = `dsa-${id}${suffix ? `-${suffix}` : ""}`.toLowerCase(); - if (!/^[a-z0-9-]{1,64}$/.test(raw)) throw new Error("Invalid repository identifier"); + if (!/^[a-z0-9-]{1,64}$/.test(raw)) throw new InputError("Invalid repository identifier"); return raw; } export function requirePrompt(value: unknown): string { if (typeof value !== "string" || !value.trim() || value.length > 2000) { - throw new Error("Task must contain 1–2000 characters"); + throw new InputError("Task must contain 1–2000 characters"); } return value.trim(); } diff --git a/experiments/agent-changes/src/git-integration.ts b/experiments/agent-changes/src/git-integration.ts new file mode 100644 index 000000000..83d324264 --- /dev/null +++ b/experiments/agent-changes/src/git-integration.ts @@ -0,0 +1,63 @@ +/** Quote interpolations as single POSIX shell arguments, including Git bearer credentials. */ +function sh(parts: TemplateStringsArray, ...values: unknown[]): string { + return parts.reduce((command, part, index) => { + const arg = index < values.length + ? `'${String(values[index]).replaceAll("'", "'\\''")}'` + : ""; + return command + part + arg; + }, ""); +} + +export interface GitIntegrationInput { + baseRemote: string; + forkRemote: string; + baseCommit: string; + headCommit: string; + branch: string; + baseToken: string; + forkToken: string; +} + +export interface GitCommandResult { exitCode: number; stdout: string } + +/** An isolated, one-shot Git operation. The checkout is always removed, even on failure. */ +export async function integrateGit( + input: GitIntegrationInput, + dir: string, + run: (command: string) => Promise, +): Promise<{ status: "merged" | "conflicted"; commit?: string }> { + if (![input.baseCommit, input.headCommit].every((sha) => /^[a-f0-9]{40}$/.test(sha))) { + throw new Error("Invalid proposal commit"); + } + if (!/^\/workspace\/integration-[a-f0-9-]+$/.test(dir) && !/^\/tmp\/agent-integration-[a-f0-9-]+$/.test(dir)) { + throw new Error("Invalid integration directory"); + } + const baseAuth = `http.extraHeader=Authorization: Bearer ${input.baseToken}`; + const forkAuth = `http.extraHeader=Authorization: Bearer ${input.forkToken}`; + const checked = async (command: string): Promise => { + const result = await run(command); + if (result.exitCode !== 0) throw new Error("Git integration command failed"); + return result.stdout.trim(); + }; + + try { + await checked(sh`git -c ${baseAuth} clone ${input.baseRemote} ${dir}`); + const current = await checked(sh`git -C ${dir} rev-parse HEAD`); + if (current !== input.baseCommit) throw new Error("Project baseline has changed"); + + await checked(sh`git -C ${dir} -c ${forkAuth} fetch ${input.forkRemote} ${input.branch}`); + const applied = await run(sh`git -C ${dir} -c ${"user.name=DevSpace Integrator"} -c ${"user.email=merge@devspace.invalid"} cherry-pick ${input.headCommit}`); + if (applied.exitCode !== 0) { + const conflicts = await checked(sh`git -C ${dir} diff --name-only --diff-filter=U`); + if (conflicts) return { status: "conflicted" }; + throw new Error("Git integration command failed"); + } + + const commit = await checked(sh`git -C ${dir} rev-parse HEAD`); + await checked(sh`git -C ${dir} -c ${baseAuth} push origin ${`HEAD:${input.branch}`}`); + return { status: "merged", commit }; + } finally { + // Never let a leftover checkout turn a transient failure into a permanent one. + try { await run(sh`rm -rf -- ${dir}`); } catch { /* Best-effort cleanup. */ } + } +} diff --git a/experiments/agent-changes/src/project.ts b/experiments/agent-changes/src/project.ts index 4624fb9cd..3e9087353 100644 --- a/experiments/agent-changes/src/project.ts +++ b/experiments/agent-changes/src/project.ts @@ -1,5 +1,12 @@ import { DurableObject } from "cloudflare:workers"; -import type { Project, Task } from "./domain.js"; +import { InputError, type Project, type Task } from "./domain.js"; + +interface Comparison { + prompts: [string, string]; + taskIds: [string, string]; + phase: "preparing" | "ready"; + leaseUntil: number; +} /** The per-project Durable Object is the authority for task and proposal metadata. */ export class ProjectCoordinator extends DurableObject { @@ -15,25 +22,50 @@ export class ProjectCoordinator extends DurableObject { return (await this.ctx.storage.get("project")) ?? null; } - /** Claims at most one comparison so duplicate requests cannot launch extra paid runs. */ - async claimComparison(): Promise { + /** Stable task IDs make a failed second fork resumable without launching a third agent. */ + async beginComparison(prompts: [string, string], newTaskIds: [string, string]): Promise< + { state: "busy" | "ready" | "claimed"; taskIds: [string, string] } + > { return this.ctx.storage.transaction(async (storage) => { - if (await storage.get("comparison:claimed")) return false; - await storage.put("comparison:claimed", true); - return true; + const current = await storage.get("comparison"); + if (current && current.prompts.some((p, index) => p !== prompts[index])) { + throw new InputError("This project already has a different comparison"); + } + if (current?.phase === "ready") return { state: "ready", taskIds: current.taskIds }; + if (current && current.leaseUntil > Date.now()) return { state: "busy", taskIds: current.taskIds }; + const taskIds = current?.taskIds ?? newTaskIds; + await storage.put("comparison", { + prompts, taskIds, phase: "preparing", leaseUntil: Date.now() + 120_000, + } satisfies Comparison); + return { state: "claimed", taskIds }; + }); + } + + async finishComparison(success: boolean): Promise { + await this.ctx.storage.transaction(async (storage) => { + const current = await storage.get("comparison"); + if (!current) return; + await storage.put("comparison", { + ...current, phase: success ? "ready" : "preparing", leaseUntil: 0, + } satisfies Comparison); }); } async claimIntegration(): Promise { return this.ctx.storage.transaction(async (storage) => { - if (await storage.get("integration:claimed")) return false; - await storage.put("integration:claimed", true); + const lease = await storage.get("integration:lease"); + if (lease && lease > Date.now()) return false; + await storage.put("integration:lease", Date.now() + 10 * 60_000); return true; }); } + async releaseIntegration(): Promise { + await this.ctx.storage.delete("integration:lease"); + } + async addTask(task: Task): Promise { - if (await this.ctx.storage.get(`task:${task.id}`)) throw new Error("Duplicate task"); + if (await this.ctx.storage.get(`task:${task.id}`)) return; await this.ctx.storage.put(`task:${task.id}`, task); } @@ -46,7 +78,7 @@ export class ProjectCoordinator extends DurableObject { } async tasks(): Promise { - const entries = await this.ctx.storage.list({ prefix: "task:", limit: 20 }); + const entries = await this.ctx.storage.list({ prefix: "task:", limit: 2 }); return [...entries.values()]; } diff --git a/experiments/agent-changes/src/task-agent.ts b/experiments/agent-changes/src/task-agent.ts index a1f3dbd09..8e95c62dc 100644 --- a/experiments/agent-changes/src/task-agent.ts +++ b/experiments/agent-changes/src/task-agent.ts @@ -6,6 +6,7 @@ import { createPiTools } from "@cloudflare/computer/tools/pi-ai"; import { createModels, type Message } from "@earendil-works/pi-ai"; import { WORKERS_AI_PROVIDER, workersAI } from "./workers-ai.js"; import type { ProposalDiff } from "./domain.js"; +import { integrateGit, type GitIntegrationInput } from "./git-integration.js"; export { WorkspaceProxy, WorkspaceServiceProxy }; @@ -55,14 +56,18 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { } async start(input: AgentInput): Promise { - if (await this.ctx.storage.get("started")) throw new Error("Agent already started"); - await this.ctx.storage.put("started", true); + const claimed = await this.ctx.storage.transaction(async (storage) => { + if (await storage.get("started")) return false; + await storage.put("started", true); + return true; + }); + if (!claimed) return; this.ctx.waitUntil(this.execute(input)); } - /** Never expose command output from invocations carrying a Git credential. */ + /** The Worker-shell Git shim does not implement -c or the full diff CLI. Use real Linux Git. */ private async git(command: string): Promise { - const execution = await this.workspace.runtime.exec(command, { backend: "shell", encoding: "utf8" }); + const execution = await this.workspace.runtime.exec(command, { backend: "container", encoding: "utf8" }); try { const result = await execution.result(); if (result.exitCode !== 0) throw new Error("Git operation failed"); @@ -78,7 +83,7 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { const patch = await this.git(sh`git -C ${PROJECT_DIR} diff ${baseCommit} HEAD --`); const maxLength = 32_000; return { - taskId, files: files ? files.split("\n").slice(0, 100) : [], + taskId, files: files ? files.split("\n") : [], patch: patch.slice(0, maxLength), truncated: patch.length > maxLength, }; } @@ -93,34 +98,8 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { } } - async integrate(input: { - baseRemote: string; forkRemote: string; baseCommit: string; - headCommit: string; branch: string; baseToken: string; forkToken: string; - }): Promise<{ status: "merged" | "conflicted"; commit?: string }> { - if (![input.baseCommit, input.headCommit].every((sha) => /^[a-f0-9]{40}$/.test(sha))) { - throw new Error("Invalid proposal commit"); - } - const dir = "/workspace/integration"; - const baseAuth = `http.extraHeader=Authorization: Bearer ${input.baseToken}`; - const forkAuth = `http.extraHeader=Authorization: Bearer ${input.forkToken}`; - const run = async (command: string) => { - const result = await this.native(command); - if (result.exitCode !== 0) throw new Error("Git integration command failed"); - return result.stdout; - }; - await run(sh`git -c ${baseAuth} clone ${input.baseRemote} ${dir}`); - const current = await run(sh`git -C ${dir} rev-parse HEAD`); - if (current !== input.baseCommit) throw new Error("Project baseline has changed"); - await run(sh`git -C ${dir} -c ${forkAuth} fetch ${input.forkRemote} ${input.branch}`); - const applied = await this.native(sh`git -C ${dir} -c ${"user.name=DevSpace Integrator"} -c ${"user.email=merge@devspace.invalid"} cherry-pick ${input.headCommit}`); - if (applied.exitCode !== 0) { - const conflicts = await run(sh`git -C ${dir} diff --name-only --diff-filter=U`); - if (conflicts) return { status: "conflicted" }; - throw new Error("Git integration command failed"); - } - const commit = await run(sh`git -C ${dir} rev-parse HEAD`); - await run(sh`git -C ${dir} -c ${baseAuth} push origin ${`HEAD:${input.branch}`}`); - return { status: "merged", commit }; + async integrate(input: GitIntegrationInput): Promise<{ status: "merged" | "conflicted"; commit?: string }> { + return integrateGit(input, `/workspace/integration-${crypto.randomUUID()}`, (command) => this.native(command)); } private async execute(input: AgentInput): Promise { @@ -134,11 +113,22 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { break; } catch (error) { if (attempt === 3) throw error; + await this.git(sh`rm -rf -- ${PROJECT_DIR}`); await new Promise((resolve) => setTimeout(resolve, 1500)); } } - const { tools, execute } = createPiTools({ workspace: this.workspace }); + const { tools, execute } = createPiTools({ + workspace: this.workspace, + shell: { + backends: { + shell: { description: "Fast text and Git inspection using the Worker shell" }, + container: { description: "Full Linux terminal with git, node, npm, and test runners" }, + }, + defaultBackend: "container", + maxBytes: 32_000, + }, + }); const models = createModels(); models.setProvider(workersAI(this.env.AI, MODEL)); const model = models.getModel(WORKERS_AI_PROVIDER, MODEL); @@ -146,6 +136,7 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { const messages: Message[] = [{ role: "user", content: input.prompt, timestamp: Date.now() }]; let response = ""; + let finished = false; let toolCalls = 0; const deadline = Date.now() + 4 * 60_000; for (let turn = 0; turn < 8; turn++) { @@ -160,15 +151,22 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { ].join("\n"), messages, tools, }); + if (Date.now() > deadline) throw new Error("Agent run deadline reached"); + if (reply.stopReason === "error" || reply.stopReason === "aborted") { + throw new Error("Model response failed"); + } + if (reply.stopReason === "length") throw new Error("Model response exceeded token budget"); messages.push(reply); const calls = reply.content.filter((part) => part.type === "toolCall"); toolCalls += calls.length; if (calls.length > 5 || toolCalls > 24) throw new Error("Agent tool budget exceeded"); if (!calls.length) { response = reply.content.filter((part) => part.type === "text").map((part) => part.text).join(""); + finished = true; break; } for (const call of calls) { + if (Date.now() > deadline) throw new Error("Agent run deadline reached"); const result = await execute(call); messages.push({ role: "toolResult", toolCallId: call.id, toolName: call.name, @@ -177,8 +175,11 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { } } + if (!finished) throw new Error("Agent turn budget exceeded"); + if (Date.now() > deadline) throw new Error("Agent run deadline reached"); + await this.git(sh`git -C ${PROJECT_DIR} add -A`); - const status = await this.workspace.runtime.exec(sh`git -C ${PROJECT_DIR} diff --cached --quiet`, { backend: "shell" }); + const status = await this.workspace.runtime.exec(sh`git -C ${PROJECT_DIR} diff --cached --quiet`, { backend: "container" }); const exitCode = (await status.result()).exitCode; status[Symbol.dispose]?.(); if (exitCode !== 1) throw new Error(exitCode === 0 ? "Agent did not change files" : "Unable to inspect changes"); @@ -192,7 +193,7 @@ export class TaskAgent extends withWorkspaceContainer(AgentBase) { await coordinator.updateTask(input.id, { status: "failed", // Avoid returning raw provider and shell errors: some may contain repo credentials. - error: error instanceof Error && ["Git operation failed", "Agent did not change files", "Model unavailable", "Unable to inspect changes"].includes(error.message) + error: error instanceof Error && ["Git operation failed", "Agent did not change files", "Model unavailable", "Unable to inspect changes", "Agent run deadline reached", "Agent tool budget exceeded", "Agent turn budget exceeded", "Model response failed", "Model response exceeded token budget"].includes(error.message) ? error.message : "Agent execution failed; inspect Cloudflare logs", }); } diff --git a/experiments/agent-changes/src/worker.ts b/experiments/agent-changes/src/worker.ts index f6780016a..239b4a783 100644 --- a/experiments/agent-changes/src/worker.ts +++ b/experiments/agent-changes/src/worker.ts @@ -1,5 +1,5 @@ import { ProjectCoordinator } from "./project.js"; -import { publicGitUrl, repoName, requirePrompt, type Project, type Task } from "./domain.js"; +import { InputError, publicGitUrl, repoName, requirePrompt, type Project, type Task } from "./domain.js"; import { TaskAgent, WorkspaceProxy, WorkspaceServiceProxy } from "./task-agent.js"; import { overlappingFiles } from "./review.js"; @@ -9,7 +9,7 @@ type AppEnv = Env & { DEMO_TOKEN: string }; type ProjectStub = DurableObjectStub; function jsonError(error: unknown, status = 400): Response { - return Response.json({ error: error instanceof Error ? error.message : "Unexpected error" }, { status }); + return Response.json({ error: error instanceof Error ? error.message : typeof error === "string" ? error : "Unexpected error" }, { status }); } function authorized(request: Request, env: AppEnv): boolean { @@ -50,37 +50,59 @@ async function compare(request: Request, env: AppEnv, projectId: string): Promis const stub = projectStub(env, projectId); const project = await stub.load(); if (!project) return jsonError("Project not found", 404); - if ((await stub.tasks()).length) return jsonError("V0 supports one comparison per project", 409); const body = (await request.json()) as { prompts?: unknown }; if (!Array.isArray(body.prompts) || body.prompts.length !== 2) { return jsonError("Provide exactly two prompts", 400); } - const prompts = body.prompts.map(requirePrompt); + const prompts = body.prompts.map(requirePrompt) as [string, string]; using source = await env.ARTIFACTS.get(project.baseRepo); const baseCommit = (await source.log({ ref: project.defaultBranch, limit: 1 }))[0]?.hash; if (!baseCommit) return jsonError("Source repository has no initial commit", 409); - if (!(await stub.claimComparison())) return jsonError("A comparison is already in progress", 409); - - const tasks: Task[] = []; - for (const prompt of prompts) { - const id = crypto.randomUUID().replaceAll("-", "").slice(0, 12); - const forkRepo = repoName(projectId, id); - const fork = await source.fork(forkRepo, { defaultBranchOnly: true }); - const task: Task = { - id, projectId, prompt, forkRepo, forkRemote: fork.remote, - baseCommit, status: "queued", - }; - await stub.addTask(task); - tasks.push(task); - // The initial token is passed internally to the run DO, never to the browser. - await env.RUNS.get(env.RUNS.idFromName(id)).start({ - id, projectId, prompt, forkRemote: fork.remote, - token: fork.token, branch: project.defaultBranch, - }); + const ids = [crypto.randomUUID().replaceAll("-", "").slice(0, 12), + crypto.randomUUID().replaceAll("-", "").slice(0, 12)] as [string, string]; + const claim = await stub.beginComparison(prompts, ids); + if (claim.state === "busy") return jsonError("A comparison is already being prepared", 409); + if (claim.state === "ready") return Response.json({ tasks: await stub.tasks() }, { status: 202 }); + let finished = false; + try { + for (const [index, prompt] of prompts.entries()) { + const id = claim.taskIds[index]; + const existing = await stub.task(id); + if (existing && existing.status !== "queued") continue; + const forkRepo = repoName(projectId, id); + let remote: string; + let token: string; + // If the fork completed but our request failed before saving the task, reuse it. + try { + using prior = await env.ARTIFACTS.get(forkRepo); + const info = await prior.info(); + const access = await prior.createToken("write", 1800); + remote = info.remote; + token = access.plaintext; + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "NOT_FOUND")) throw error; + const fork = await source.fork(forkRepo, { defaultBranchOnly: true }); + remote = fork.remote; + token = fork.token; + } + const task: Task = existing ?? { + id, projectId, prompt, forkRepo, forkRemote: remote, + baseCommit, status: "queued", + }; + await stub.addTask(task); + // The token crosses a DO RPC, not the browser API. The run owns its fork. + await env.RUNS.get(env.RUNS.idFromName(id)).start({ + id, projectId, prompt, forkRemote: remote, + token, branch: project.defaultBranch, + }); + } + finished = true; + return Response.json({ tasks: await stub.tasks() }, { status: 202 }); + } finally { + await stub.finishComparison(finished); } - return Response.json({ tasks }, { status: 202 }); } async function proposals(env: AppEnv, projectId: string): Promise { @@ -106,21 +128,24 @@ async function accept(request: Request, env: AppEnv, projectId: string): Promise if (!task || task.status !== "completed" || !task.headCommit) return jsonError("Proposal not ready", 409); if (task.integrationStatus) return jsonError("Proposal already integrated or conflicted", 409); if (!(await coordinator.claimIntegration())) return jsonError("Integration is already in progress", 409); - - using base = await env.ARTIFACTS.get(project.baseRepo); - using fork = await env.ARTIFACTS.get(task.forkRepo); - const [baseToken, forkToken] = await Promise.all([ - base.createToken("write", 900), fork.createToken("read", 900), - ]); - const result = await env.RUNS.get(env.RUNS.idFromName(task.id)).integrate({ - baseRemote: project.baseRemote, forkRemote: task.forkRemote, - baseCommit: task.baseCommit, headCommit: task.headCommit, - branch: project.defaultBranch, baseToken: baseToken.plaintext, forkToken: forkToken.plaintext, - }); - await coordinator.updateTask(task.id, { - integrationStatus: result.status, integratedCommit: result.commit, - }); - return Response.json(result); + try { + using base = await env.ARTIFACTS.get(project.baseRepo); + using fork = await env.ARTIFACTS.get(task.forkRepo); + const [baseToken, forkToken] = await Promise.all([ + base.createToken("write", 900), fork.createToken("read", 900), + ]); + const result = await env.RUNS.get(env.RUNS.idFromName(task.id)).integrate({ + baseRemote: project.baseRemote, forkRemote: task.forkRemote, + baseCommit: task.baseCommit, headCommit: task.headCommit, + branch: project.defaultBranch, baseToken: baseToken.plaintext, forkToken: forkToken.plaintext, + }); + await coordinator.updateTask(task.id, { + integrationStatus: result.status, integratedCommit: result.commit, + }); + return Response.json(result); + } finally { + await coordinator.releaseIntegration(); + } } export default { @@ -149,7 +174,7 @@ export default { if (code === "IMPORT_IN_PROGRESS" || code === "FORK_IN_PROGRESS") { return jsonError("Repository is still being prepared; retry shortly", 409); } - return jsonError(error, error instanceof SyntaxError ? 400 : 502); + return jsonError(error, error instanceof SyntaxError || error instanceof InputError ? 400 : 502); } }, } satisfies ExportedHandler; diff --git a/experiments/agent-changes/test/git-integration.test.ts b/experiments/agent-changes/test/git-integration.test.ts new file mode 100644 index 000000000..6f5310997 --- /dev/null +++ b/experiments/agent-changes/test/git-integration.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { test } from "node:test"; +import { integrateGit, type GitCommandResult } from "../src/git-integration.js"; + +const git = (cwd: string, ...args: string[]) => execFileSync("git", args, { + cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], +}).trim(); + +function run(command: string): Promise { + try { + const stdout = execFileSync("/bin/sh", ["-c", command], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + return Promise.resolve({ exitCode: 0, stdout }); + } catch (error) { + const code = (error as { status?: number }).status; + return Promise.resolve({ exitCode: code ?? 1, stdout: "" }); + } +} + +test("integrates a real agent fork and cleans failed attempts", async () => { + const root = mkdtempSync(join(tmpdir(), "agent-proposals-")); + const base = join(root, "base.git"); + const fork = join(root, "fork.git"); + const source = join(root, "source"); + const candidate = join(root, "candidate"); + const integrationDir = `/tmp/agent-integration-${randomUUID()}`; + try { + git(root, "init", "--bare", "-b", "main", base); + git(root, "init", "-b", "main", source); + git(source, "config", "user.name", "Test Agent"); + git(source, "config", "user.email", "agent@example.invalid"); + writeFileSync(join(source, "feature.txt"), "baseline\n"); + git(source, "add", "feature.txt"); + git(source, "commit", "-m", "baseline"); + const baseline = git(source, "rev-parse", "HEAD"); + git(source, "remote", "add", "origin", base); + git(source, "push", "origin", "main"); + git(root, "clone", "--bare", base, fork); + git(root, "clone", fork, candidate); + git(candidate, "config", "user.name", "Candidate Agent"); + git(candidate, "config", "user.email", "candidate@example.invalid"); + writeFileSync(join(candidate, "feature.txt"), "agent implementation\n"); + git(candidate, "commit", "-am", "Agent implementation"); + const head = git(candidate, "rev-parse", "HEAD"); + git(candidate, "push", "origin", "main"); + + const input = { + baseRemote: base, forkRemote: fork, baseCommit: baseline, + headCommit: head, branch: "main", baseToken: "local-test", forkToken: "local-test", + }; + let failed = false; + await assert.rejects(integrateGit(input, integrationDir, async (command) => { + if (!failed && command.includes(" push origin ")) { + failed = true; + throw new Error("Simulated transport failure"); + } + return run(command); + }), /Simulated transport failure/); + assert.equal(existsSync(integrationDir), false, "failed attempt must not leave a checkout"); + assert.equal(git(root, "--git-dir", base, "rev-parse", "main"), baseline); + + const accepted = await integrateGit(input, integrationDir, run); + assert.equal(accepted.status, "merged"); + assert.ok(accepted.commit); + assert.equal(git(root, "--git-dir", base, "show", "main:feature.txt"), "agent implementation"); + assert.equal(existsSync(integrationDir), false, "successful attempt must not leave a checkout"); + await assert.rejects(integrateGit(input, integrationDir, run), /baseline has changed/); + } finally { + rmSync(integrationDir, { recursive: true, force: true }); + rmSync(root, { recursive: true, force: true }); + } +}); From 9d712ac20ff47f37fe4e803cc0994f403e4ba4ee Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Mon, 12 Oct 2026 01:52:25 +0530 Subject: [PATCH 3/3] fix: harden review approval and demo configuration --- .gitignore | 1 + experiments/agent-changes/README.md | 4 +++- experiments/agent-changes/package.json | 2 +- experiments/agent-changes/pnpm-lock.yaml | 2 +- experiments/agent-changes/web/App.tsx | 9 ++++++--- 5 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index e3f73eec4..0d56fa0d2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ releases/ experiments/agent-changes/.wrangler/ experiments/agent-changes/worker-configuration.d.ts experiments/agent-changes/.dev.vars +experiments/agent-changes/.dev.vars.* experiments/agent-changes/.env* diff --git a/experiments/agent-changes/README.md b/experiments/agent-changes/README.md index 153ff175e..167568708 100644 --- a/experiments/agent-changes/README.md +++ b/experiments/agent-changes/README.md @@ -24,6 +24,8 @@ pnpm deploy Generate a long random `DEMO_TOKEN` and enter it using Wrangler's secret prompt. **Never place it in source, `.env`, a URL parameter, or commit history.** The browser keeps this token only in React memory until page refresh. A demo token gates all `/api/*` endpoints; this is intentionally not multi-user authentication. +For local development, create an ignored `experiments/agent-changes/.dev.vars` file containing `DEMO_TOKEN=your-local-token`, run `pnpm build`, and then `pnpm dev`. Local Durable Object state is not shared with deployed state, but the Artifacts binding uses `remote: true`: local requests still create repositories in the *real* `devspace-agent-changes` namespace. Use disposable repositories and clean up any test repos manually in Wrangler or the Cloudflare dashboard. `wrangler secret put` applies to the remote deployment and may publish a Worker version; it does not configure local secrets. + If `wrangler artifacts namespaces list` reports **10004 Access denied**, first resolve Artifacts availability/permission on the account. No subsequent steps can validate real repository operations until this works. ## Walkthrough @@ -60,7 +62,7 @@ Project IDs are durable and are kept in the browser URL (`?project=`). All r - No persisted full agent transcript or live tool-call log in the UI. Agent summaries and committed Git changes are exposed. - Automated test outcomes are **not recorded** as a verified separate artifact. The UI says so rather than claiming tests passed. - No AI conflict resolution. Accepting an outdated proposal returns an error; genuine cherry-pick conflicts remain local to the integration directory. -- Compute retry/restart recovery, timeouts, quota enforcement, cleanup of stale forks, and private execution isolation need substantial hardening before any production use. +- Agent execution can continue across container replacement because files live in Computer's DO-backed filesystem, but autonomous recovery of interrupted model loops is not implemented. In-flight shell commands can outlast the four-minute turn deadline; quota enforcement, fork garbage collection, and production-grade isolation need further hardening. - Cloudflare resource usage and Workers AI inference are billable. Do not expose this demo token publicly or use it with repositories containing secrets. The Workers AI transport adapter in `src/workers-ai.ts` is adapted from the MIT-licensed [`cloudflare/computer` pi-ai example](https://github.com/cloudflare/computer/tree/main/examples/pi-ai); the `Dockerfile` is based on its MIT-licensed Think/Linux example. diff --git a/experiments/agent-changes/package.json b/experiments/agent-changes/package.json index 74b7c4378..d7d4d8869 100644 --- a/experiments/agent-changes/package.json +++ b/experiments/agent-changes/package.json @@ -29,6 +29,6 @@ "typescript": "^6.0.3", "tailwindcss": "^4.1.0", "vite": "^8.0.0", - "wrangler": "^4.137.0" + "wrangler": "^4.145.0" } } diff --git a/experiments/agent-changes/pnpm-lock.yaml b/experiments/agent-changes/pnpm-lock.yaml index a17a870ad..9e2c41798 100644 --- a/experiments/agent-changes/pnpm-lock.yaml +++ b/experiments/agent-changes/pnpm-lock.yaml @@ -55,7 +55,7 @@ importers: specifier: ^8.0.0 version: 8.3.3(@types/node@26.6.4)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15)(yaml@2.9.1) wrangler: - specifier: ^4.137.0 + specifier: ^4.145.0 version: 4.148.0(@cloudflare/workers-types@5.20261009.1)(@types/node@26.6.4) packages: diff --git a/experiments/agent-changes/web/App.tsx b/experiments/agent-changes/web/App.tsx index feb2d0568..9bd29e3c4 100644 --- a/experiments/agent-changes/web/App.tsx +++ b/experiments/agent-changes/web/App.tsx @@ -96,7 +96,10 @@ export function App() {
DevSpace / Agent Changes
Cloudflare Artifacts experiment
- Hackathon V0 +
+ {token && } + Hackathon V0 +
@@ -212,9 +215,9 @@ function WorkspaceView(props: WorkspaceProps) { ) : ( <>
-

Proposal {selected.id.slice(0, 7)}

{selectedDiff?.files.length ?? 0} changed files · automated tests not recorded

+

Proposal {selected.id.slice(0, 7)}

{selectedDiff ? `${selectedDiff.files.length} changed files` : "Loading changed files"} · automated tests not recorded

{selected.integrationStatus === "merged" ? Merged : - } + }
{selected.integrationStatus === "conflicted" &&

Git reported a merge conflict. No changes were pushed to the base repository.

}