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] 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 />