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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion app/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
32 changes: 22 additions & 10 deletions app/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -88,6 +92,7 @@ export async function runClaude(opts: RunClaudeOptions): Promise<ClaudeRun> {
}

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}`),
Expand All @@ -106,7 +111,7 @@ export async function runClaude(opts: RunClaudeOptions): Promise<ClaudeRun> {
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) {
Expand Down Expand Up @@ -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
? {
Expand All @@ -250,6 +259,9 @@ function policyHook(workDir: string | undefined) {
: {
hookEventName: "PreToolUse" as const,
permissionDecision: "allow" as const,
updatedInput: workspaceId
? pinRenderWorkspace(name, toolInput, workspaceId)
: undefined,
},
};
};
Expand Down
1 change: 1 addition & 0 deletions app/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
20 changes: 18 additions & 2 deletions app/policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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<string, unknown>,
workspaceId: string,
): Record<string, unknown> | undefined {
if (!name.startsWith(RENDER_PREFIX)) return undefined;
return { ...input, workspaceId };
}

export type ResolvedPath = { path: string } | { error: string };

/**
Expand Down
4 changes: 3 additions & 1 deletion docs/0_QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions public/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Turn a prompt into a deployed application on Render." />
<title>Vibe Coding App</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<!--
Manrope and Roboto Mono are the open fallbacks of Render's brand faces.
A machine that has Roobert and PP Neue Montreal uses those first.
Expand Down
50 changes: 41 additions & 9 deletions scripts/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,8 @@ async function checkBlueprint(): Promise<void> {

/**
* The architect reads Render through the MCP server. If the server lacks a
* tool on the allowlist, the architect silently loses it.
* tool on the allowlist, or the tool cannot name a workspace, the architect
* silently loses it.
*/
async function checkRenderMcp(): Promise<void> {
heading("Render MCP");
Expand All @@ -312,27 +313,58 @@ async function checkRenderMcp(): Promise<void> {
requestInit: { headers: { authorization: `Bearer ${apiKey}` } },
}),
);
const tools = new Set<string>();
const tools = new Map<string, string[]>();
let cursor: string | undefined;
do {
const page = await client.listTools({ cursor });
for (const tool of page.tools) tools.add(tool.name);
for (const tool of page.tools) {
tools.set(tool.name, Object.keys(tool.inputSchema.properties ?? {}));
}
cursor = page.nextCursor;
} while (cursor);

const missing = RENDER_READ_ONLY_TOOLS.filter((name) => !tools.has(name));
const unpinnable = RENDER_READ_ONLY_TOOLS.filter(
(name) => tools.has(name) && !tools.get(name)?.includes("workspaceId"),
);
record(
missing.length === 0
missing.length > 0
? {
level: "ok",
message: `MCP reachable, with the ${RENDER_READ_ONLY_TOOLS.length} read-only tools that the agents use`,
}
: {
level: "fail",
message: `The Render MCP server has no tool named ${missing.join(", ")}`,
fix: "Change RENDER_READ_ONLY_TOOLS in app/policy.ts to the names that the server uses.",
},
}
: unpinnable.length > 0
? {
level: "fail",
message: `These Render MCP tools take no workspaceId: ${unpinnable.join(", ")}`,
fix: "Remove them from RENDER_READ_ONLY_TOOLS in app/policy.ts. Without a workspace, a call fails or reads the wrong one.",
}
: {
level: "ok",
message: `MCP reachable, with the ${RENDER_READ_ONLY_TOOLS.length} read-only tools that the agents use`,
},
);

const workspaceId = process.env.RENDER_WORKSPACE_ID?.trim();
if (workspaceId) {
const result = await client.callTool({
name: "list_services",
arguments: { workspaceId },
});
record(
result.isError
? {
level: "fail",
message: `The Render MCP server cannot read workspace ${workspaceId}`,
fix: "Use a RENDER_API_KEY that can reach RENDER_WORKSPACE_ID.",
}
: {
level: "ok",
message: `The Render MCP server reads workspace ${workspaceId}`,
},
);
}
} catch (error) {
record({
level: "fail",
Expand Down
2 changes: 1 addition & 1 deletion tests/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ describe("browser UI", () => {
const pages = ["/", "/app.js"];

it("requires Basic Auth for every UI file and route, and serves no file that holds FACTORY_API_KEY", async () => {
for (const path of [...pages, "/style.css", "/ui/apps"]) {
for (const path of [...pages, "/style.css", "/favicon.svg", "/ui/apps"]) {
expect((await request(path)).status, path).toBe(401);
}
for (const path of pages) {
Expand Down
28 changes: 27 additions & 1 deletion tests/policy.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { checkToolCall, redactSecrets } from "../app/policy.js";
import {
checkToolCall,
pinRenderWorkspace,
redactSecrets,
} from "../app/policy.js";

const APP_DIR = "/home/user/repo/apps/demo/shop";

Expand Down Expand Up @@ -68,6 +72,28 @@ describe("checkToolCall", () => {
});
});

describe("pinRenderWorkspace", () => {
// With a key that reaches several workspaces, a call without one fails.
it("names the factory's workspace in every Render tool call, over the agent's choice", () => {
expect(
pinRenderWorkspace("mcp__render__list_services", {}, "tea-factory"),
).toEqual({ workspaceId: "tea-factory" });
expect(
pinRenderWorkspace(
"mcp__render__get_service",
{ serviceId: "srv-1", workspaceId: "tea-other" },
"tea-factory",
),
).toEqual({ serviceId: "srv-1", workspaceId: "tea-factory" });
});

it("leaves the input of other tools alone", () => {
expect(
pinRenderWorkspace("sandbox__exec", { command: "ls" }, "tea-factory"),
).toBeUndefined();
});
});

describe("redactSecrets", () => {
it("removes Anthropic, GitHub, and Render keys and Postgres URLs, and leaves other text", () => {
const secrets = [
Expand Down
Loading