Commit

Hosted agents: sandboxes on Cloudflare Containers started from an intent

- services/runner: starts one container per attempt running the g1t runner, limited to an allowlist of accounts; abandons the attempt if a sandbox dies without reporting - site: Run hosted agents panel on intents with live refresh; attempt summaries and agent messages render as markdown; GitHub-style clone box with HTTPS, SSH and Agent tabs

syntaqxcommitted Parent3e32c07Browse files
14 files+443−180/14 viewed
+45−0
1+import { Link } from "react-router";
2+
3+import { CopyLine } from "./ui";
4+import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
5+
6+/** The ways to get a repository onto a machine or in front of an agent. */
7+export function CloneBox({ path }: { path: string }) {
8+ return (
9+ <Tabs defaultValue="https">
10+ <TabsList>
11+ <TabsTrigger value="https">HTTPS</TabsTrigger>
12+ <TabsTrigger value="ssh">SSH</TabsTrigger>
13+ <TabsTrigger value="agent">Agent</TabsTrigger>
14+ </TabsList>
15+ <TabsContent value="https">
16+ <CopyLine text={`https://g1t.sh/${path}.git`} />
17+ <p className="mt-2 text-xs text-muted">
18+ To push, use your username and an{" "}
19+ <Link to="/settings" className="text-fg underline underline-offset-4">
20+ access token
21+ </Link>{" "}
22+ as the password.
23+ </p>
24+ </TabsContent>
25+ <TabsContent value="ssh">
26+ <p className="rounded-lg border border-dashed border-line p-3 text-xs text-muted">
27+ Git over SSH is not available yet. Use HTTPS for now.
28+ </p>
29+ </TabsContent>
30+ <TabsContent value="agent">
31+ <CopyLine
32+ prompt
33+ text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
34+ />
35+ <p className="mt-2 text-xs text-muted">
36+ Connects Claude Code to g1t. Then ask it to work on an intent in{" "}
37+ <span className="font-mono text-fg">{path}</span>.{" "}
38+ <Link to="/docs/agents" className="text-fg underline underline-offset-4">
39+ More
40+ </Link>
41+ </p>
42+ </TabsContent>
43+ </Tabs>
44+ );
45+}
+2−1
33
44 import type { BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts";
55
6+import { CloneBox } from "./clone-box";
67 import { Markdown } from "./markdown";
78 import { Avatar, CopyLine, TimeAgo } from "./ui";
89
142143 <section>
143144 <h2 className="text-sm font-medium">Clone</h2>
144145 <div className="mt-2">
145− <CopyLine text={`git clone ${cloneUrl}`} />
146+ <CloneBox path={`${repo.namespace}/${repo.name}`} />
146147 </div>
147148 </section>
148149 <section>
+49−0
1+import { Tabs as Primitive } from "radix-ui";
2+import type { ComponentProps } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's tabs, styled with g1t's tokens.
7+
8+export const Tabs = Primitive.Root;
9+
10+export function TabsList({
11+ className,
12+ ...props
13+}: ComponentProps<typeof Primitive.List>) {
14+ return (
15+ <Primitive.List
16+ className={cn(
17+ "inline-flex items-center gap-1 rounded-lg border border-line bg-bg p-1",
18+ className,
19+ )}
20+ {...props}
21+ />
22+ );
23+}
24+
25+export function TabsTrigger({
26+ className,
27+ ...props
28+}: ComponentProps<typeof Primitive.Trigger>) {
29+ return (
30+ <Primitive.Trigger
31+ className={cn(
32+ "rounded-md px-2.5 py-1 text-xs font-medium text-muted transition-colors outline-none hover:text-fg",
33+ "data-[state=active]:bg-raised data-[state=active]:text-fg",
34+ "disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:text-muted",
35+ className,
36+ )}
37+ {...props}
38+ />
39+ );
40+}
41+
42+export function TabsContent({
43+ className,
44+ ...props
45+}: ComponentProps<typeof Primitive.Content>) {
46+ return (
47+ <Primitive.Content className={cn("mt-3 outline-none", className)} {...props} />
48+ );
49+}
+17−10
1414 import type { SessionEntry } from "@g1t/contracts";
1515
1616 import type { Route } from "./+types/attempt";
17+import { Markdown } from "../../components/markdown";
1718 import {
1819 Button,
1920 CopyLine,
129130 ? "Note"
130131 : agent}
131132 </p>
132− <p
133− className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
134− entry.kind === "prompt" ? "font-medium" : ""
135− }`}
136− >
137− {entry.text}
138− </p>
133+ {entry.kind === "message" ? (
134+ <div className="mt-1">
135+ <Markdown source={entry.text} />
136+ </div>
137+ ) : (
138+ <p
139+ className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
140+ entry.kind === "prompt" ? "font-medium" : ""
141+ }`}
142+ >
143+ {entry.text}
144+ </p>
145+ )}
139146 </div>
140147 )}
141148 {entry.commit && (
202209 <h3 className="text-xs font-medium tracking-wide text-faint uppercase">
203210 Summary
204211 </h3>
205− <p className="mt-2 leading-relaxed whitespace-pre-wrap">
206− {attempt.summary}
207− </p>
212+ <div className="mt-2">
213+ <Markdown source={attempt.summary} />
214+ </div>
208215 </section>
209216 )}
210217
+86−5
11 import { env } from "cloudflare:workers";
2−import { Bot, GitCommitHorizontal, Terminal } from "lucide-react";
3−import { Form, Link, redirect } from "react-router";
2+import { Bot, GitCommitHorizontal, Play, Sparkles, Terminal } from "lucide-react";
3+import { useEffect } from "react";
4+import {
5+ Form,
6+ Link,
7+ redirect,
8+ useNavigation,
9+ useRevalidator,
10+} from "react-router";
411
512 import type { Route } from "./+types/intent";
613 import { Markdown } from "../../components/markdown";
1219 ErrorText,
1320 Input,
1421 Status,
22+ Textarea,
1523 TimeAgo,
1624 } from "../../components/ui";
1725 import {
2129 unwrap,
2230 } from "../../lib/session.server";
2331
32+const REFRESH_MS = 4000;
33+
2434 export function meta({ loaderData, params }: Route.MetaArgs) {
2535 const title = loaderData ? `${loaderData.intent.title} · ` : "";
2636 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
3545 viewer,
3646 ),
3747 );
38− return { ...detail, viewer };
48+ return {
49+ ...detail,
50+ viewer,
51+ canRunHosted: await env.RUNNER.available(viewer),
52+ };
3953 }
4054
4155 export async function action({ request, params, context }: Route.ActionArgs) {
4458 const form = await request.formData();
4559 const intentId = String(form.get("intentId") ?? "");
4660
61+ if (form.get("action") === "run-hosted") {
62+ const result = await env.RUNNER.run(user, intentId, {
63+ count: Number(form.get("count")),
64+ instructions: String(form.get("instructions") ?? ""),
65+ });
66+ return result.ok ? null : { error: result.error.message };
67+ }
4768 if (form.get("action") === "withdraw") {
4869 const result = await env.WORK.withdrawIntent(user, intentId);
4970 return result.ok ? null : { error: result.error.message };
6384 actionData,
6485 params,
6586 }: Route.ComponentProps) {
66− const { intent, attempts, viewer } = loaderData;
87+ const { intent, attempts, viewer, canRunHosted } = loaderData;
88+
89+ // Follow running attempts without a manual reload.
90+ const revalidator = useRevalidator();
91+ const navigation = useNavigation();
92+ const running = attempts.some((attempt) => attempt.status === "working");
93+ useEffect(() => {
94+ if (!running) return;
95+ const timer = setInterval(() => {
96+ if (document.visibilityState === "visible") revalidator.revalidate();
97+ }, REFRESH_MS);
98+ return () => clearInterval(timer);
99+ }, [running, revalidator]);
100+ const starting = navigation.formData?.get("action") === "run-hosted";
67101 const base = `/${params.owner}/${params.repo}`;
68102 const open = intent.status === "open";
69103 return (
129163 <Bot size={15} className="text-faint" />
130164 {attempt.agent}
131165 </span>
166+ {attempt.runtime === "hosted" && (
167+ <span className="rounded-full border border-line px-1.5 py-px text-[0.6875rem] text-faint">
168+ hosted
169+ </span>
170+ )}
132171 <span className="text-sm text-faint">
133172 attempt {attempt.number}
134173 </span>
172211 </section>
173212 )}
174213
214+ {open && canRunHosted && (
215+ <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
216+ <h3 className="flex items-center gap-2 text-sm font-medium">
217+ <Sparkles size={15} className="text-accent" />
218+ Run hosted agents
219+ </h3>
220+ <p className="mt-1 text-xs text-muted">
221+ g1t starts Claude Code in a sandbox for each attempt. They work
222+ in parallel, each in its own fork.
223+ </p>
224+ <Form method="post" className="mt-3 space-y-2">
225+ <input type="hidden" name="intentId" value={intent.id} />
226+ <input type="hidden" name="action" value="run-hosted" />
227+ <label className="flex items-center justify-between gap-3 text-sm">
228+ <span className="text-muted">Agents</span>
229+ <select
230+ name="count"
231+ defaultValue="1"
232+ className="rounded-md border border-line bg-bg px-2 py-1 text-sm"
233+ >
234+ {[1, 2, 3, 4, 5].map((count) => (
235+ <option key={count} value={count}>
236+ {count}
237+ </option>
238+ ))}
239+ </select>
240+ </label>
241+ <Textarea
242+ name="instructions"
243+ rows={2}
244+ placeholder="Extra guidance (optional)"
245+ />
246+ <div className="*:w-full">
247+ <Button variant="accent" type="submit" disabled={starting}>
248+ <Play size={14} />
249+ {starting ? "Starting sandboxes…" : "Run"}
250+ </Button>
251+ </div>
252+ </Form>
253+ </section>
254+ )}
255+
175256 {open && (
176257 <section className="rounded-xl border border-line bg-surface p-4">
177− <h3 className="text-sm font-medium">Put an agent on it</h3>
258+ <h3 className="text-sm font-medium">Bring your own agent</h3>
178259 <p className="mt-1 text-xs text-muted">
179260 With g1t connected to your agent, give it this intent id.
180261 </p>
+2−1
1−import type { EventsApi, ServiceBinding, WorkApi } from "@g1t/contracts";
1+import type { EventsApi, RunnerApi, ServiceBinding, WorkApi } from "@g1t/contracts";
22
33 declare global {
44 namespace Cloudflare {
88 REPOS: ServiceBinding & { fetch(request: Request): Promise<Response> };
99 WORK: WorkApi;
1010 EVENTS: EventsApi;
11+ RUNNER: RunnerApi;
1112 }
1213 }
1314 interface Env extends Cloudflare.Env {}
+2−1
1010 { "binding": "IDENTITY", "service": "g1t-identity" },
1111 { "binding": "REPOS", "service": "g1t-repos" },
1212 { "binding": "WORK", "service": "g1t-work" },
13− { "binding": "EVENTS", "service": "g1t-events" }
13+ { "binding": "EVENTS", "service": "g1t-events" },
14+ { "binding": "RUNNER", "service": "g1t-runner" }
1415 ],
1516 "observability": { "enabled": true },
1617 "upload_source_maps": true
+19−0
327327 "node": ">=6.9.0"
328328 }
329329 },
330+ "node_modules/@cloudflare/containers": {
331+ "version": "0.3.7",
332+ "resolved": "https://registry.npmjs.org/@cloudflare/containers/-/containers-0.3.7.tgz",
333+ "integrity": "sha512-DM9dm3FnIBSyiSJ1FLavKwl/lk3oAmTaynCzZQ9pZR0ncRPquSxkxd8Nu2MFILxmDDsPkxKsSNEh9mHHMty4Fw==",
334+ "license": "MIT OR Apache-2.0"
335+ },
330336 "node_modules/@cloudflare/kv-asset-handler": {
331337 "version": "0.5.0",
332338 "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
976982 "resolved": "services/events",
977983 "link": true
978984 },
985+ "node_modules/@g1t/runner": {
986+ "resolved": "services/runner",
987+ "link": true
988+ },
979989 "node_modules/@g1t/web": {
980990 "resolved": "apps/web",
981991 "link": true
74867496 "@g1t/contracts": "*"
74877497 }
74887498 },
7499+ "services/runner": {
7500+ "name": "@g1t/runner",
7501+ "version": "0.1.0",
7502+ "license": "MIT",
7503+ "dependencies": {
7504+ "@cloudflare/containers": "^0.3.7",
7505+ "@g1t/contracts": "*"
7506+ }
7507+ },
74897508 "services/work": {
74907509 "name": "@g1t/work",
74917510 "version": "0.1.0",
+1−0
55 export * from "./names";
66 export * from "./repos";
77 export * from "./result";
8+export * from "./runner";
89 export * from "./work";
+22−0
1+import type { User, Viewer } from "./identity";
2+import type { Result } from "./result";
3+import type { Attempt } from "./work";
4+
5+export type RunHostedInput = {
6+ /** How many agents to race on the intent, each in its own sandbox. */
7+ count: number;
8+ /** Extra guidance appended to the intent's brief for these runs. */
9+ instructions?: string;
10+};
11+
12+/** Hosted agents: sandboxes on g1t that work on an intent. */
13+export interface RunnerApi {
14+ /** Whether this viewer may start hosted agents. */
15+ available(viewer: Viewer): Promise<boolean>;
16+ /**
17+ * Starts `count` attempts on the intent, each run by an agent in its own
18+ * sandbox. Returns as soon as the sandboxes are starting; progress shows
19+ * up in each attempt's session.
20+ */
21+ run(actor: User, intentId: string, input: RunHostedInput): Promise<Result<Attempt[]>>;
22+}
+15−0
1+{
2+ "name": "@g1t/runner",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "license": "MIT",
7+ "scripts": {
8+ "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
9+ "deploy": "wrangler deploy"
10+ },
11+ "dependencies": {
12+ "@cloudflare/containers": "^0.3.7",
13+ "@g1t/contracts": "*"
14+ }
15+}
+148−0
1+import { Container, type StopParams } from "@cloudflare/containers";
2+import { WorkerEntrypoint } from "cloudflare:workers";
3+
4+import {
5+ type Attempt,
6+ type Intent,
7+ type Result,
8+ type RunHostedInput,
9+ type RunnerApi,
10+ type ServiceBinding,
11+ type User,
12+ type Viewer,
13+ type WorkApi,
14+ fail,
15+ identityClient,
16+ ok,
17+} from "@g1t/contracts";
18+
19+export interface RunnerEnv {
20+ SANDBOX: DurableObjectNamespace<AttemptSandbox>;
21+ IDENTITY: ServiceBinding;
22+ WORK: WorkApi;
23+ /** Secret. The model key the hosted agent runs on. */
24+ ANTHROPIC_API_KEY?: string;
25+ /**
26+ * Comma-separated usernames allowed to start hosted agents. Runs spend the
27+ * key above, so this stays an allowlist until accounts bring their own.
28+ */
29+ HOSTED_AGENT_USERS: string;
30+}
31+
32+const MAX_AGENTS_PER_RUN = 5;
33+/** A run that takes longer than this has its token expire under it. */
34+const TOKEN_TTL_SECONDS = 2 * 60 * 60;
35+const AGENT = "claude-code";
36+
37+type RunRequest = { actor: User; attemptId: string; envVars: Record<string, string> };
38+
39+/**
40+ * One sandbox, for one attempt. The image's entrypoint is the g1t runner,
41+ * which does the work and exits; this class only starts it and cleans up
42+ * if it dies without reporting.
43+ */
44+export class AttemptSandbox extends Container<RunnerEnv> {
45+ sleepAfter = "45m";
46+
47+ async run(request: RunRequest): Promise<void> {
48+ await this.ctx.storage.put("run", {
49+ actor: request.actor,
50+ attemptId: request.attemptId,
51+ });
52+ await this.start({ envVars: request.envVars, enableInternet: true });
53+ }
54+
55+ override async onStop({ exitCode }: StopParams): Promise<void> {
56+ if (exitCode === 0) return;
57+ // The runner abandons its own attempt when it fails. This covers a
58+ // sandbox that was killed before it could; abandoning twice is refused
59+ // harmlessly.
60+ const run = await this.ctx.storage.get<Pick<RunRequest, "actor" | "attemptId">>("run");
61+ if (run) await this.env.WORK.abandonAttempt(run.actor, run.attemptId);
62+ }
63+}
64+
65+function buildPrompt(intent: Intent, instructions: string): string {
66+ const parts = [
67+ "You are a coding agent working in the git repository checked out in the current directory.",
68+ `Goal: ${intent.title}`,
69+ intent.brief,
70+ ];
71+ if (intent.checks.length > 0) {
72+ parts.push(
73+ `These commands must pass when you are done. Run them if the tools are installed:\n${intent.checks.map((check) => `- ${check}`).join("\n")}`,
74+ );
75+ }
76+ if (instructions) parts.push(instructions);
77+ parts.push(
78+ "Make the change and keep it focused on the goal. Commit your work with a clear message. Do not push; that is done for you. Finish with a short summary of what you changed and why.",
79+ );
80+ return parts.filter(Boolean).join("\n\n");
81+}
82+
83+export default class RunnerService
84+ extends WorkerEntrypoint<RunnerEnv>
85+ implements RunnerApi
86+{
87+ /** A Worker must have an event handler; this service is RPC-only. */
88+ fetch(): Response {
89+ return new Response("Not found\n", { status: 404 });
90+ }
91+
92+ async available(viewer: Viewer): Promise<boolean> {
93+ if (!viewer || !this.env.ANTHROPIC_API_KEY) return false;
94+ return this.env.HOSTED_AGENT_USERS.split(",")
95+ .map((name) => name.trim())
96+ .includes(viewer.username);
97+ }
98+
99+ async run(
100+ actor: User,
101+ intentId: string,
102+ input: RunHostedInput,
103+ ): Promise<Result<Attempt[]>> {
104+ if (!(await this.available(actor))) {
105+ return fail("forbidden", "Hosted agents are not enabled for your account.");
106+ }
107+ const count = Math.min(Math.max(Math.trunc(input.count) || 1, 1), MAX_AGENTS_PER_RUN);
108+ const identity = identityClient(this.env.IDENTITY);
109+
110+ const attempts: Attempt[] = [];
111+ for (let i = 0; i < count; i++) {
112+ const started = await this.env.WORK.startAttempt(actor, intentId, {
113+ agent: AGENT,
114+ runtime: "hosted",
115+ });
116+ // The first failure is the answer; later ones mean some already run.
117+ if (!started.ok) return attempts.length ? ok(attempts) : started;
118+ const attempt = started.value;
119+ attempts.push(attempt);
120+
121+ const found = await this.env.WORK.getAttempt(attempt.id, actor);
122+ if (!found.ok) return found;
123+ // The sandbox acts as the person who started it, through a token
124+ // that only lives as long as a run can.
125+ const { token } = await identity.createAccessToken(
126+ actor,
127+ `Hosted attempt ${attempt.id}`,
128+ TOKEN_TTL_SECONDS,
129+ );
130+ const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(attempt.id));
131+ await sandbox.run({
132+ actor,
133+ attemptId: attempt.id,
134+ envVars: {
135+ G1T_API: "https://api.g1t.sh",
136+ G1T_TOKEN: token,
137+ G1T_USER: actor.username,
138+ ATTEMPT_ID: attempt.id,
139+ GIT_REMOTE: `https://g1t.sh/${attempt.fork.namespace}/${attempt.fork.name}.git`,
140+ COMMIT_MESSAGE: found.value.intent.title,
141+ PROMPT: buildPrompt(found.value.intent, input.instructions?.trim() ?? ""),
142+ ANTHROPIC_API_KEY: this.env.ANTHROPIC_API_KEY!,
143+ },
144+ });
145+ }
146+ return ok(attempts);
147+ }
148+}
+4−0
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "include": ["src/**/*", "worker-configuration.d.ts"]
4+}
+31−0
1+{
2+ "$schema": "../../node_modules/wrangler/config-schema.json",
3+ "name": "g1t-runner",
4+ "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5+ "compatibility_date": "2026-09-26",
6+ "main": "./src/index.ts",
7+ "workers_dev": false,
8+ "containers": [
9+ {
10+ "class_name": "AttemptSandbox",
11+ // Built from the repository root so the image can compile the
12+ // runner crate.
13+ "image": "./Dockerfile",
14+ "image_build_context": "../..",
15+ "instance_type": "standard-1",
16+ "max_instances": 10
17+ }
18+ ],
19+ "durable_objects": {
20+ "bindings": [{ "name": "SANDBOX", "class_name": "AttemptSandbox" }]
21+ },
22+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["AttemptSandbox"] }],
23+ "services": [
24+ { "binding": "IDENTITY", "service": "g1t-identity" },
25+ { "binding": "WORK", "service": "g1t-work" }
26+ ],
27+ "vars": {
28+ "HOSTED_AGENT_USERS": "syntaqx"
29+ },
30+ "observability": { "enabled": true }
31+}