diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b60a031..3ef1eb8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -121,8 +121,9 @@ Eve's built-ins are the base. The local additions are deliberately narrow: - **`edit_file`** applies batched, exact, unique, non-overlapping replacements to one snapshot and stores a context-limited unified diff. - **`start_dev`** starts the model-selected server command, exposes its port, verifies - the public route, and returns the sandbox ID and URL. It stops an unreachable - process so the agent can fix its host configuration and retry cleanly. + the public route for up to 45 seconds, and returns the sandbox ID and URL. It races + startup against early process exit, captures bounded stdout and stderr, and stops + an unreachable process so the agent can diagnose the real error and retry cleanly. - **Instructions** require reading before editing, finite Bash commands, `start_dev` for long-lived servers, and a build or test before claiming success. diff --git a/README.md b/README.md index a35922c..fa3d351 100644 --- a/README.md +++ b/README.md @@ -2,16 +2,19 @@ An open-source coding agent built with [Eve](https://eve.dev) and Vercel Sandbox. -Eve Code is a compact starting point for building a browser-based coding agent. It -combines Eve's durable sessions and streaming with isolated Vercel Sandboxes, -Convex persistence, and a small web interface around the core coding loop. Web -projects run directly from the sandbox with a live preview and hot reload. The -codebase stays deliberately small so its model, instructions, tools, and interface -can be adapted to different use cases. - -> Eve Code is a starting point, not a hosted multi-user product. Authentication, -> session ownership, and pull request workflows are out of scope. Keep deployments -> private until you add the necessary product boundaries. +Eve Code is a compact reference implementation that demonstrates how to build a +browser-based coding agent with Eve. It combines Eve's durable sessions and +streaming with isolated Vercel Sandboxes, Convex persistence, and a small web +interface around the core coding loop. Web projects run directly from the sandbox +with a live preview and hot reload. The codebase stays deliberately small so its +model, instructions, tools, and interface can be understood and adapted. + +> [!WARNING] +> Eve Code is an unauthenticated reference implementation, not a hosted multi-user +> product. In a public deployment, every visitor can access the shared sessions and +> workspaces. Sandbox preview URLs are also public and unauthenticated. Do not use +> private code, credentials, secrets, or sensitive data without first adding the +> necessary security boundaries. ## Features @@ -40,6 +43,23 @@ the Convex checkpoint becomes the durable history and synchronizes every open client. The workspace browser, command logs, and preview controls all connect to the same sandbox, so every surface reflects the environment the agent is using. +## Security and scope + +Eve Code intentionally leaves authentication, session ownership, quotas, and pull +request workflows out of scope: + +- A publicly reachable deployment has no user isolation. Conversations and + workspace files are shared with anyone who can access the app. +- Starting a preview exposes the selected sandbox port through a public, + unauthenticated URL. Anyone with that URL can access everything the development + server makes available. +- A Vercel Sandbox isolates code execution from the host. It does not make the + preview private or provide access control between visitors. + +Keep deployments private and use only non-sensitive code until authentication, +authorization, ownership, and preview protection are implemented. These boundaries +can be added by applications that use Eve Code as a starting point. + ## Run locally Install the dependencies and connect the project to Vercel and Convex: diff --git a/agent/instructions.md b/agent/instructions.md index 59b7a31..444cd31 100644 --- a/agent/instructions.md +++ b/agent/instructions.md @@ -4,7 +4,7 @@ You are Eve Code, a concise coding agent that helps users build and improve soft # Work -- Build projects inside the persistent `/workspace`; bash already starts there. +- The working directory is `/workspace`; every sandbox command already starts there. - Every `bash` command must finish on its own; a command that waits forever hangs the whole turn. Servers, watchers, and REPLs never run through `bash` — only through `start_dev`. - In an existing repository, begin by reading and following every applicable `AGENTS.md`. @@ -15,6 +15,7 @@ You are Eve Code, a concise coding agent that helps users build and improve soft - Use `edit_file` for targeted changes and `write_file` for new files or intentional complete replacements. - Batch non-overlapping changes to one file into one `edit_file` call. - For an existing web project, restore its preview with `start_dev` before editing. +- For a new web app with no requested framework, use Vite with React and TypeScript. - For an empty workspace, determine the requested stack, initialize it, then call `start_dev` as soon as a runnable server exists. Ask when the choice is consequential and unspecified. - Verify with the project's build or tests before claiming success. - Use `ask_question` only when a real decision is required. @@ -26,5 +27,7 @@ You are Eve Code, a concise coding agent that helps users build and improve soft - Never rely on a framework's default host. Inspect its server configuration before `start_dev`; Vite requires `server: { host: "0.0.0.0", allowedHosts: true }`. - Eve restores files after idle, not processes; call `start_dev` again to restore the preview. -- `start_dev` verifies the public URL. If it fails, fix the server configuration and retry it before finishing. +- `start_dev` verifies the public URL and returns bounded startup logs when it fails. Read the + real error, load the `run-web-project` skill, use finite Bash diagnostics, fix the cause, and + retry `start_dev` before finishing. - Never start a long-lived server with `bash`. diff --git a/agent/lib/preview.ts b/agent/lib/preview.ts index 2075271..f4cd360 100644 --- a/agent/lib/preview.ts +++ b/agent/lib/preview.ts @@ -1,14 +1,41 @@ -const previewAttemptsMax = 20; -const previewRetryDelayMs = 500; +import type { SandboxProcess } from "eve/sandbox"; + +const previewLogCharactersMax = 6_000; +const previewRequestTimeoutMs = 5_000; +const previewRetryDelayMs = 750; +const previewTimeoutMs = 45_000; function getAbortError(signal: AbortSignal): Error { if (signal.reason instanceof Error) return signal.reason; return new Error("Preview start was aborted."); } -async function waitForRetry(signal?: AbortSignal): Promise { +function getErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return "The preview check failed."; +} + +async function fetchPreview( + url: string, + timeoutMs: number, + signal?: AbortSignal, +): Promise { + const request = new AbortController(); + const timeout = setTimeout(() => request.abort(), timeoutMs); + const requestSignal = signal ? AbortSignal.any([signal, request.signal]) : request.signal; + try { + return await fetch(url, { signal: requestSignal }); + } catch { + if (signal?.aborted) throw getAbortError(signal); + return; + } finally { + clearTimeout(timeout); + } +} + +async function waitForRetry(delayMs: number, signal?: AbortSignal): Promise { if (!signal) { - await new Promise((resolve) => setTimeout(resolve, previewRetryDelayMs)); + await new Promise((resolve) => setTimeout(resolve, delayMs)); return; } if (signal.aborted) throw getAbortError(signal); @@ -20,26 +47,142 @@ async function waitForRetry(signal?: AbortSignal): Promise { const timeout = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); - }, previewRetryDelayMs); + }, delayMs); signal.addEventListener("abort", onAbort, { once: true }); }); } +type OutputTail = { + read(): string; + stop(): Promise; +}; + +function collectOutputTail(stream: ReadableStream): OutputTail { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let output = ""; + let truncated = false; + + function append(chunk: string): void { + output += chunk; + if (output.length <= previewLogCharactersMax) return; + output = output.slice(-previewLogCharactersMax); + truncated = true; + } + + const done = (async () => { + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + append(decoder.decode(next.value, { stream: true })); + } + append(decoder.decode()); + } catch { + // The reader is cancelled once the preview is ready or the process is killed. + } finally { + reader.releaseLock(); + } + })(); + + return { + read() { + const text = output.trim(); + if (!truncated || !text) return text; + return `[truncated: showing the last ${previewLogCharactersMax} characters]\n${text}`; + }, + async stop() { + await reader.cancel().catch(() => undefined); + await done; + }, + }; +} + +function formatPreviewFailure( + reason: string, + port: number, + stdout: string, + stderr: string, +): string { + const sections = [reason]; + if (stderr) sections.push(`Dev server stderr:\n${stderr}`); + if (stdout) sections.push(`Dev server stdout:\n${stdout}`); + if (!stderr && !stdout) sections.push("The dev server produced no startup output."); + sections.push( + [ + "Fix the startup failure, then call start_dev again:", + "- Use bash for finite diagnostics such as dependency installation, build, typecheck, and port checks.", + `- Make the server listen on 0.0.0.0 and the exact port ${port}.`, + "- Vite also needs server.allowedHosts: true; Next.js accepts -H 0.0.0.0; Astro accepts --host 0.0.0.0.", + "- Check the project manifest, lockfile, working directory, required environment variables, and the logs above.", + ].join("\n"), + ); + return sections.join("\n\n"); +} + export async function waitForPreview( url: string, port: number, signal?: AbortSignal, ): Promise { - for (let attempt = 0; attempt < previewAttemptsMax; attempt++) { - const response = await fetch(url, { signal }).catch((error: unknown) => { - if (signal?.aborted && signal.reason instanceof Error) throw signal.reason; - if (signal?.aborted) throw error; - return undefined; - }); + const deadline = Date.now() + previewTimeoutMs; + while (Date.now() < deadline) { + const remainingMs = deadline - Date.now(); + const response = await fetchPreview( + url, + Math.min(previewRequestTimeoutMs, remainingMs), + signal, + ); if (response && response.status !== 403 && response.status !== 502) return; - await waitForRetry(signal); + const retryDelayMs = Math.min(previewRetryDelayMs, deadline - Date.now()); + if (retryDelayMs > 0) await waitForRetry(retryDelayMs, signal); } throw new Error( - `Preview is not publicly reachable on port ${port}. Configure the server to listen on 0.0.0.0 and allow the public sandbox hostname, then call start_dev again. For Vite, set server: { host: "0.0.0.0", allowedHosts: true }.`, + `Preview was not publicly reachable on port ${port} within ${previewTimeoutMs / 1_000} seconds.`, ); } + +type PreviewProcess = Pick; + +type PreviewOutcome = + | { readonly type: "ready" } + | { readonly exitCode: number; readonly type: "exit" }; + +export async function verifyPreviewProcess( + process: PreviewProcess, + url: string, + port: number, + signal?: AbortSignal, +): Promise { + const stdout = collectOutputTail(process.stdout); + const stderr = collectOutputTail(process.stderr); + const preview = new AbortController(); + const previewSignal = signal ? AbortSignal.any([signal, preview.signal]) : preview.signal; + let failure: unknown; + let outcome: PreviewOutcome | undefined; + + try { + outcome = await Promise.race([ + waitForPreview(url, port, previewSignal).then((): PreviewOutcome => ({ type: "ready" })), + Promise.resolve(process.wait()).then( + ({ exitCode }): PreviewOutcome => ({ exitCode, type: "exit" }), + ), + ]); + } catch (error) { + failure = error; + } + + preview.abort(); + const ready = outcome?.type === "ready" && !signal?.aborted; + if (!ready) await Promise.resolve(process.kill()).catch(() => undefined); + await Promise.all([stdout.stop(), stderr.stop()]); + + if (ready) return; + if (signal?.aborted) throw getAbortError(signal); + + const reason = + outcome?.type === "exit" + ? `Dev server exited with code ${outcome.exitCode} before the preview became reachable.` + : getErrorMessage(failure); + throw new Error(formatPreviewFailure(reason, port, stdout.read(), stderr.read())); +} diff --git a/agent/skills/run-web-project.md b/agent/skills/run-web-project.md new file mode 100644 index 0000000..88994d2 --- /dev/null +++ b/agent/skills/run-web-project.md @@ -0,0 +1,33 @@ +--- +description: Diagnose and run an existing web project when its development server or public sandbox preview does not start. +--- + +Inspect before changing anything: + +1. Read every applicable `AGENTS.md`, the README, package manifest, lockfiles, framework config, + and environment examples. +2. Identify the package manager from the lockfile and inspect the available scripts. Work from + the package containing the web app, not automatically from the repository root. +3. Install dependencies with the repository's package manager. Never replace its lockfile or + package manager only to make startup easier. +4. Use finite `bash` commands to run the relevant build, typecheck, or framework diagnostic. + Resolve missing environment variables and generated clients before starting a server. + +Call `start_dev` with the real dev command and its exact port. If it fails, treat its captured +stdout and stderr as the primary diagnosis. Fix that error before trying unrelated host settings. + +The server must bind to `0.0.0.0`: + +- Vite: configure `server.host = "0.0.0.0"` and `server.allowedHosts = true`. For HMR through + the public route, use `hmr: { protocol: "wss", clientPort: 443 }`. +- Next.js: pass `-H 0.0.0.0 -p ` to `next dev`. +- Astro: pass `--host 0.0.0.0 --port ` to `astro dev`. +- Other servers: inspect their current CLI help or official configuration and set both host and + port explicitly. + +Useful finite checks include the package-manager build script, framework version command, +`test -f` for expected env files, and `ss -ltnp` after a failed attempt. Do not run servers, +watchers, or REPLs through `bash`. + +Once the underlying error is fixed, call `start_dev` again. Do not claim the preview works until +the tool returns its public URL. diff --git a/agent/tools/start_dev.ts b/agent/tools/start_dev.ts index 91740de..05bcd2e 100644 --- a/agent/tools/start_dev.ts +++ b/agent/tools/start_dev.ts @@ -1,11 +1,12 @@ import { Sandbox } from "@vercel/sandbox"; import { defineTool } from "eve/tools"; -import { waitForPreview } from "@/agent/lib/preview"; +import { verifyPreviewProcess } from "@/agent/lib/preview"; import { previewOutputSchema, previewRunSchema } from "@/lib/preview"; export default defineTool({ - description: "Start a development server and verify its public sandbox preview.", + description: + "Start a development server and verify its public sandbox preview. Failed startup returns bounded stdout and stderr so you can fix the cause and retry.", inputSchema: previewRunSchema, outputSchema: previewOutputSchema, async execute({ command, port }, ctx) { @@ -14,12 +15,7 @@ export default defineTool({ await sandbox.update({ ports: [port] }, { signal: ctx.abortSignal }); const server = await session.spawn({ command }); const url = sandbox.domain(port); - try { - await waitForPreview(url, port, ctx.abortSignal); - return { sandboxId: session.id, url }; - } catch (error) { - await Promise.resolve(server.kill()).catch(() => undefined); - throw error; - } + await verifyPreviewProcess(server, url, port, ctx.abortSignal); + return { sandboxId: session.id, url }; }, }); diff --git a/app/index.html b/app/index.html index 5578aab..3b756fc 100644 --- a/app/index.html +++ b/app/index.html @@ -8,7 +8,7 @@ /> - eve-code + Eve Code
diff --git a/bun.lock b/bun.lock index 9c4f8da..9ff8bdd 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router": "^8.2.0", + "react-router": "8.3.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", @@ -869,7 +869,7 @@ "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], - "react-router": ["react-router@8.2.0", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-uP1LgGNHyilL1u+ZkecQk74DJOKOb6q4NLNYLRJcD/fjoogftNb5/Rj/07o/QBFsY/5MbAKPm1peTCQuVPv9cQ=="], + "react-router": ["react-router@8.3.0", "", { "dependencies": { "cookie-es": "^3.1.1" }, "peerDependencies": { "react": ">=19.2.7", "react-dom": ">=19.2.7" }, "optionalPeers": ["react-dom"] }, "sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], diff --git a/docs/sandbox.md b/docs/sandbox.md index 51a9c01..0b22bb3 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -15,8 +15,9 @@ sandbox.domain(3000); // "https://sb-.vercel.run" - Ports can also be added later with `sandbox.update({ ports })`. `start_dev` uses this to expose the port selected by the model before it launches the server. - URLs are stable across stop and resume. -- `start_dev` probes the public URL before returning success. An unreachable server - is stopped so its port does not block a corrected retry. +- `start_dev` probes the public URL for up to 45 seconds while watching for early + process exit. An unreachable server is stopped so its port does not block a + corrected retry, and bounded stdout/stderr are returned to the agent for diagnosis. ## Resolving the URL from Eve diff --git a/package.json b/package.json index 3ccc553..490ac7a 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router": "^8.2.0", + "react-router": "^8.3.0", "streamdown": "^2.5.0", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", diff --git a/tests/preview.test.ts b/tests/preview.test.ts index 3b7100e..4cd6b50 100644 --- a/tests/preview.test.ts +++ b/tests/preview.test.ts @@ -1,6 +1,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { waitForPreview } from "@/agent/lib/preview"; +import { verifyPreviewProcess, waitForPreview } from "@/agent/lib/preview"; + +const encoder = new TextEncoder(); + +function outputStream(output = ""): ReadableStream { + return new ReadableStream({ + start(controller) { + if (output) controller.enqueue(encoder.encode(output)); + controller.close(); + }, + }); +} + +function pendingOutputStream(): ReadableStream { + return new ReadableStream(); +} + +function abortableFetch(_input: unknown, init?: RequestInit): Promise { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { + once: true, + }); + }); +} afterEach(() => { vi.restoreAllMocks(); @@ -20,9 +43,71 @@ describe("preview", () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 502 }))); const result = expect(waitForPreview("https://preview.test", 5173)).rejects.toThrow( - 'server: { host: "0.0.0.0", allowedHosts: true }', + "within 45 seconds", + ); + await vi.runAllTimersAsync(); + await result; + }); + + it("waits for a slow server without killing it", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValueOnce(new Response("", { status: 502 })) + .mockResolvedValueOnce(new Response("ready")), + ); + const kill = vi.fn().mockResolvedValue(undefined); + const process = { + kill, + stderr: pendingOutputStream(), + stdout: pendingOutputStream(), + wait: vi.fn(() => new Promise<{ exitCode: number }>(() => undefined)), + }; + + const result = verifyPreviewProcess(process, "https://preview.test", 5173); + await vi.runAllTimersAsync(); + + await expect(result).resolves.toBeUndefined(); + expect(kill).not.toHaveBeenCalled(); + }); + + it("returns startup logs when the server exits early", async () => { + vi.stubGlobal("fetch", vi.fn(abortableFetch)); + const kill = vi.fn().mockResolvedValue(undefined); + const process = { + kill, + stderr: outputStream("Error: missing DATABASE_URL\n"), + stdout: outputStream("Starting application\n"), + wait: vi.fn().mockResolvedValue({ exitCode: 1 }), + }; + + await expect(verifyPreviewProcess(process, "https://preview.test", 3000)).rejects.toThrow( + /exited with code 1[\s\S]*missing DATABASE_URL[\s\S]*Starting application[\s\S]*Next\.js accepts -H 0\.0\.0\.0/, + ); + expect(kill).toHaveBeenCalledOnce(); + }); + + it("kills an unreachable server and returns its startup logs", async () => { + vi.useFakeTimers(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 502 }))); + const kill = vi.fn().mockResolvedValue(undefined); + const process = { + kill, + stderr: outputStream("Blocked host: preview.test\n"), + stdout: outputStream("Local: http://localhost:5173\n"), + wait: vi.fn(() => new Promise<{ exitCode: number }>(() => undefined)), + }; + + const result = expect( + verifyPreviewProcess(process, "https://preview.test", 5173), + ).rejects.toThrow( + /within 45 seconds[\s\S]*Blocked host: preview\.test[\s\S]*Local: http:\/\/localhost:5173/, ); await vi.runAllTimersAsync(); await result; + + expect(kill).toHaveBeenCalledOnce(); }); });