diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c1e98e..8c4e631 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,15 @@ jobs: - run: npm ci - run: npm run check + # `render blueprints validate` needs a Render login, which a fork's pull + # request cannot have. The published schema catches the same shape errors. + blueprint: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - run: pipx run check-jsonschema --schemafile https://render.com/schema/render.yaml.json render.yaml + # A template nobody builds is a template that quietly stops building, and the # first thing to notice would be a customer's failed run. templates: diff --git a/README.md b/README.md index 45231bf..7462d67 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,11 @@ Before you deploy, prepare: - a Render workspace with Workflows and [Sandboxes early access](https://render.com/docs/sandboxes); -- an empty GitHub repository for generated apps, with Render's GitHub app - installed on it; -- a GitHub App or fine-grained token with Contents: write on that repository; +- an empty GitHub repository for generated apps, with the + [Render GitHub app](https://github.com/apps/render) installed on it so + Render can read the repository for Blueprint sync; +- a separate GitHub App or fine-grained token with Contents: write on that + repository, which the factory uses to push generated apps; - Anthropic and Render API keys, plus the Render workspace ID; and - a lowercase UI username and a UI password of at least 16 characters. @@ -91,8 +93,8 @@ product: see [Current limitations](docs/0_QUICKSTART.md#current-limitations). and the workspace ID (it starts with `tea-`) - An Anthropic API key - An empty GitHub repository for generated apps -- A GitHub connection in Render with access to that repository, so its - Blueprint can sync +- The [Render GitHub app](https://github.com/apps/render) installed on that + repository, so Render can read it for Blueprint sync - A credential that can push to the repository: a fine-grained personal access token or a GitHub App, with Contents: write on that repository diff --git a/app/agents.ts b/app/agents.ts index cbfeb6a..fb2a4f1 100644 --- a/app/agents.ts +++ b/app/agents.ts @@ -23,7 +23,9 @@ export const architect: Agent = { which Render primitives it needs. You may inspect the Render workspace with the read-only tools you have. - Use them to see what already exists so your plan fits alongside it. You + Every call reads the factory's own workspace, so do not pass or look for + a workspace ID. Use them to see what already exists so your plan fits + alongside it. You cannot create or change anything: workflow code writes a Blueprint from your plan, and Render deploys that. diff --git a/app/claude.ts b/app/claude.ts index 31e9a7f..271690c 100644 --- a/app/claude.ts +++ b/app/claude.ts @@ -7,8 +7,12 @@ import { import type { ModelUsage, Options } from "@anthropic-ai/claude-agent-sdk"; import { z } from "zod"; import type { ModelTier } from "../factory.config.js"; -import { requireEnv } from "./config.js"; -import { checkToolCall, RENDER_MCP_SERVER } from "./policy.js"; +import { renderWorkspaceId, requireEnv } from "./config.js"; +import { + checkToolCall, + pinRenderWorkspace, + RENDER_MCP_SERVER, +} from "./policy.js"; import { renderMcpUrl } from "./render.js"; import type { Sandbox } from "./sandbox.js"; import type { Tool, ToolResult } from "./tools.js"; @@ -88,6 +92,7 @@ export async function runClaude(opts: RunClaudeOptions): Promise { } const renderTools = opts.renderTools ?? []; + const workspaceId = renderTools.length > 0 ? renderWorkspaceId() : undefined; const allowed = [ ...tools.map((tool) => `mcp__${MCP_SERVER}__${tool.name}`), ...renderTools.map((name) => `mcp__${RENDER_MCP_SERVER}__${name}`), @@ -106,7 +111,7 @@ export async function runClaude(opts: RunClaudeOptions): Promise { env: Object.fromEntries( ENV_ALLOWLIST.map((name) => [name, process.env[name]]), ), - hooks: { PreToolUse: [{ hooks: [policyHook(workDir)] }] }, + hooks: { PreToolUse: [{ hooks: [policyHook(workDir, workspaceId)] }] }, }; if (opts.outputSchema) { @@ -227,19 +232,23 @@ function bareToolName(name: string): string { return name.startsWith(prefix) ? name.slice(prefix.length) : name; } -/** The PreToolUse hook. Tool paths must land in `workDir` or in /tmp. */ -function policyHook(workDir: string | undefined) { +/** + * The PreToolUse hook. Tool paths must land in `workDir` or in /tmp, and a + * Render tool call reads `workspaceId`. + */ +function policyHook( + workDir: string | undefined, + workspaceId: string | undefined, +) { return async (input: { hook_event_name: string; tool_name?: string; tool_input?: unknown; }) => { if (input.hook_event_name !== "PreToolUse") return {}; - const reason = checkToolCall( - bareToolName(input.tool_name ?? ""), - isRecord(input.tool_input) ? input.tool_input : {}, - workDir, - ); + const name = bareToolName(input.tool_name ?? ""); + const toolInput = isRecord(input.tool_input) ? input.tool_input : {}; + const reason = checkToolCall(name, toolInput, workDir); return { hookSpecificOutput: reason ? { @@ -250,6 +259,9 @@ function policyHook(workDir: string | undefined) { : { hookEventName: "PreToolUse" as const, permissionDecision: "allow" as const, + updatedInput: workspaceId + ? pinRenderWorkspace(name, toolInput, workspaceId) + : undefined, }, }; }; diff --git a/app/gateway.ts b/app/gateway.ts index a1bb8f0..64e9233 100644 --- a/app/gateway.ts +++ b/app/gateway.ts @@ -106,6 +106,7 @@ export function createGateway(): Hono { app.get("/", ...uiFile("index.html")); app.get("/app.js", ...uiFile("app.js")); app.get("/style.css", ...uiFile("style.css")); + app.get("/favicon.svg", ...uiFile("favicon.svg")); return app; } diff --git a/app/policy.ts b/app/policy.ts index 6e433ff..0904832 100644 --- a/app/policy.ts +++ b/app/policy.ts @@ -29,10 +29,11 @@ const SCRATCH_DIR = "/tmp"; * * The list has no log tool: logs can hold secrets, and no code can redact * what an agent reads. Workflow code gives the builder redacted deploy logs. + * + * It has no workspace tool either. pinRenderWorkspace names the workspace in + * every call, so each tool here must accept `workspaceId`. */ export const RENDER_READ_ONLY_TOOLS = [ - "list_workspaces", - "get_selected_workspace", "list_services", "get_service", "list_deploys", @@ -80,6 +81,21 @@ export function isRenderReadOnlyTool(name: string): boolean { return (RENDER_READ_ONLY_TOOLS as readonly string[]).includes(name); } +/** + * The input of a Render tool call, set to read the factory's workspace, or + * undefined for any other tool. The server fails a call that names no + * workspace with "no workspace selected" when the API key can reach more + * than one, and an agent-chosen workspace could be the wrong one. + */ +export function pinRenderWorkspace( + name: string, + input: Record, + workspaceId: string, +): Record | undefined { + if (!name.startsWith(RENDER_PREFIX)) return undefined; + return { ...input, workspaceId }; +} + export type ResolvedPath = { path: string } | { error: string }; /** diff --git a/docs/0_QUICKSTART.md b/docs/0_QUICKSTART.md index 98acbe8..d79b7f6 100644 --- a/docs/0_QUICKSTART.md +++ b/docs/0_QUICKSTART.md @@ -22,7 +22,9 @@ You need: - A Render API key and that workspace's ID (`tea-...`). - An Anthropic API key. - An empty GitHub repository for generated apps (the **apps repository**). -- A GitHub connection in Render that can read the apps repository. +- The [Render GitHub app](https://github.com/apps/render) installed on the + apps repository, so Render can read it for Blueprint sync. This is not the + push credential below. - A credential that can push to the apps repository: - a fine-grained personal access token with Contents: write, or - a GitHub App installed on the repository with Contents: write. diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..65a4da8 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + Vibe Coding App + + + + diff --git a/public/index.html b/public/index.html index e8e04a5..14c6dfb 100644 --- a/public/index.html +++ b/public/index.html @@ -5,6 +5,7 @@ Vibe Coding App +