Skip to content
1,421 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The docs' Copy page menu keeps its own spacing, with an icon per item1import { Container, type StopParams } from "@cloudflare/containers";
2import { WorkerEntrypoint } from "cloudflare:workers";
3
4import {
5 type BumpArgs,
6 UPDATE_BRANCH_PREFIX,
7 type RunKind,
8 agentsClient,
9 type G1tEvent,
10 type QueueJob,
11 type RepoPath,
12 type Result,
13 type ServiceBinding,
14 type User,
15 type Viewer,
16 type ComputeEntitlements,
17 type ComputeKind,
18 ComputeGate,
19 actualMicros,
20 eventsClient,
21 refusalMessage,
22 sandboxEstimateMicros,
23 billingClient,
24 fail,
25 ok,
26 reposClient,
27 workClient,
28 type InstanceType,
29 STANDARD_INSTANCE,
30 instanceNamed,
31} from "@g1t/contracts";
Merge branch 'main' into actions-toolkit-oidc-artifacts32
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar33import { BUMP_MINUTES, BUMP_TOKEN_TTL_SECONDS, bumpEnv, bumpProblem, bumpSandboxName, systemActor, registryHosts } from "./bump";
Merge branch 'worktree-agent-ac5b181a013e54348'34import { BACKUP_MINUTES, backupEnv, backupPace, backupSandboxName } from "./backup";
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.35import { holdCredentials, revokeCredentials, runCredential } from "./credentials";
The docs' Copy page menu keeps its own spacing, with an icon per item36import { cancelTask, enqueueTask, handedOverStep, selfHostedRoute, taskEnv, taskRepo } from "./self-hosted";
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.37import { answered, capReached, describeError, stoppedShort, tellStopped, withinTimeCap } from "./lifecycle";
The docs' Copy page menu keeps its own spacing, with an icon per item38import {
39 ABUSE_EXIT_CODE,
40 ABUSE_HOST,
41 ABUSE_MESSAGE,
42 ALARM_GRACE_SECONDS,
43 type PlanLimits,
44 type RunGuard,
45 abuse,
46 buildGuardFor,
Merge branch 'main' into actions-toolkit-oidc-artifacts47 dockerFor,
The docs' Copy page menu keeps its own spacing, with an icon per item48 egress,
49 egressHosts,
50 guardFor,
51 harnessEnv,
52 newlyBlocked,
53 reportRun,
54 SANDBOX_BINDINGS,
55 sandboxNamespace,
56 type WorkflowJob,
57 timeCapMessage,
58 withPlanLimits,
59} from "./guard";
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.60import { newlyMisdirected } from "./broker";
Merge branch 'main' into rm-agents61import { relay, relayDeps } from "./broker-relay";
62import { forward } from "./forward";
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.63import { EGRESS_ENV } from "./egress";
The docs' Copy page menu keeps its own spacing, with an icon per item64
65// Outbound interception, which network guardrails use, needs this exported.
66export { ContainerProxy } from "@cloudflare/containers";
67
68export interface RunnerEnv {
69 SANDBOX: DurableObjectNamespace<AttemptSandbox>;
70 /**
71 * Larger machines for workflow jobs that ask for one with `runs-on`
72 * (`g1t-2core`, `g1t-4core`): the same image on a larger instance type.
73 */
74 SANDBOX_2CORE?: DurableObjectNamespace<Sandbox2Core>;
75 SANDBOX_4CORE?: DurableObjectNamespace<Sandbox4Core>;
76 IDENTITY: ServiceBinding;
77 REPOS: ServiceBinding;
78 WORK: ServiceBinding;
79 BILLING: ServiceBinding;
80 /** GitHub Actions jobs: told when a job's sandbox dies without reporting. */
81 ACTIONS: ServiceBinding;
82 /** Told when a deploy sandbox dies without reporting. */
83 DEPLOYMENTS: ServiceBinding;
84 /** The event bus: `abuse.flagged`, for g1t's staff. */
85 EVENTS?: ServiceBinding;
86 CLOUDFLARE_ACCOUNT_ID: string;
87 /**
88 * `off` starts every sandbox with an open network whatever its
89 * guardrails say: a switch for the operator, should egress through the
90 * Worker misbehave. Anything else enforces them.
91 */
92 EGRESS?: string;
93 /**
94 * `off` stops sandboxes watching themselves for mining (crates/runner
95 * abuse.rs): a switch for the operator, should it stop real work.
96 * Anything else leaves it on. Miners named in commands are refused
97 * either way.
98 */
99 ABUSE_WATCH?: string;
Merge branch 'worktree-agent-ac5b181a013e54348'100 /**
Merge branch 'main' into actions-toolkit-oidc-artifacts101 * `off` leaves workflow jobs without a Docker Engine of their own
102 * (crates/runner docker/): a switch for the operator. Anything else
103 * gives each job one, started the first time it is used.
104 */
105 DOCKER?: string;
106 /**
Merge branch 'worktree-agent-ac5b181a013e54348'107 * Nightly backups (backup.ts): how many queued backups one sweep starts
108 * (`0`: none, backups off here), and how many may run at once.
109 */
110 BACKUPS_PER_SWEEP?: string;
111 BACKUPS_RUNNING?: string;
The docs' Copy page menu keeps its own spacing, with an icon per item112}
113
114/**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.115 * What a sandbox is doing. Runs of an issue's commands (`checks`) are from
116 * before checks were workflows; a sandbox that was started for work g1t no
117 * longer does is told apart by its kind and only cleaned up.
The docs' Copy page menu keeps its own spacing, with an icon per item118 */
119type Run =
120 | { kind: "checks"; runId: string; token: string }
121 /** One combined state of a merge queue, being built and checked. */
122 | { kind: "queue"; entryId: string; token: string }
123 /** Whether a pull request merges cleanly: two commits merged, nothing pushed. */
124 | { kind: "mergecheck"; pullId: string; token: string }
125 /** One job of a GitHub Actions workflow. */
126 | { kind: "actions"; jobId: string; token: string }
127 /** A build of one commit, deployed to g1t.page. */
128 | { kind: "deploy"; deployId: string; token: string }
129 /**
130 * A security update: one package raised in its lockfiles and pushed to
131 * its branch. The security service opens the pull request when it hears
132 * the push, so a failure has no one to tell.
133 */
Merge branch 'worktree-agent-ac5b181a013e54348'134 | { kind: "bump"; repo: RepoPath; branch: string }
135 /**
136 * A repository's nightly backup: a bundle cut and sent to the repos
137 * service. g1t's own work, never charged to the workspace.
138 */
139 | { kind: "backup"; jobId: string; token: string };
The docs' Copy page menu keeps its own spacing, with an icon per item140/**
141 * Whose sandbox time it is, reported when the sandbox stops, and the
142 * machine it ran on when it was not the standard one.
143 */
Spend adds up on one ledger. Charged this month has one definition, price less discount, included usage and credit, shared by the plan card, the spend limit, Spend and the top bar; the limit had counted usage not yet closed at full price before the discount, so a comped workspace read as charged a cent. Every agent line on the ledger names the agent and who asked, repository runs by g1t included, so Spent is the sum of its products, Agents is the agent product, and by agent adds up to it; the billing API returns by_agent and by_person. The usage and billing guide says how spend is counted.144type Meter = {
145 workspace: string;
146 repo: string;
147 description: string;
148 instance?: string | null;
149};
The docs' Copy page menu keeps its own spacing, with an icon per item150/**
151 * What billing reserved for a sandbox's work (`ComputeGate.admit`), settled
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.152 * when it stops at what it cost: its seconds.
The docs' Copy page menu keeps its own spacing, with an icon per item153 */
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.154type Held = { id: string; workspace: string; microsPerSecond: number };
The docs' Copy page menu keeps its own spacing, with an icon per item155/**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.156 * A sandbox that is not tracked as a run but still runs under guardrails:
157 * a workflow job, a deploy build or an update, in `repo`, for `minutes` at
158 * most.
The docs' Copy page menu keeps its own spacing, with an icon per item159 */
160type Build = {
161 kind: "actions" | "deploy" | "bump";
162 /** The project whose guardrails apply: never a pull request's working copy. */
163 repo: RepoPath;
164 /** Its id, so it is found even if it moved since. */
165 repoId?: string | null;
166 minutes: number;
167 /** A workflow job's workflow, environment and trust, for workflow-only domains. */
168 job?: WorkflowJob | null;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar169 /** More hosts it may reach: an update's private registries. */
170 hosts?: string[];
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.171 /**
172 * A workflow job that may be given brokered secrets: its HTTP and HTTPS
173 * pass the Worker, which fills them in, even when its network is open.
174 */
175 brokered?: boolean;
The docs' Copy page menu keeps its own spacing, with an icon per item176};
177/** Deploy builds are metered by the Deployments plan, not here. */
178type RunRequest = Run & {
179 envVars: Record<string, string>;
180 meter?: Meter;
181 track?: Track;
182 /** The workspace's plan's caps, applied under its guardrails' (lower of each). */
183 limits?: PlanLimits;
184 reservation?: Held | null;
185 build?: Build;
186 /** Whose sandbox it is, when it has no meter: for `abuse.flagged`. */
187 owner?: { workspace: string; repo: string };
188 /**
189 * The labels of the workspace's self-hosted runners this work goes to
190 * instead of a container (self-hosted.ts). Null or absent: a container.
191 */
192 selfHosted?: string[] | null;
193};
194
195/** What a sandbox is, as billing meters it. */
196function computeKindOf(kind: Run["kind"]): ComputeKind | null {
197 switch (kind) {
198 case "checks":
199 case "mergecheck":
200 // A security update resolves lockfiles, as cheap as a check.
201 case "bump":
202 return "check";
203 case "queue":
204 return "queue";
205 case "actions":
206 return "workflow";
207 case "deploy":
208 return "deploy";
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.209 // Not metered: a backup is g1t's own cost; nor is a sandbox of a kind
210 // g1t no longer starts, which ran before it stopped starting them.
211 default:
Merge branch 'worktree-agent-ac5b181a013e54348'212 return null;
The docs' Copy page menu keeps its own spacing, with an icon per item213 }
214}
215
216/** One gate per isolate, so entitlements and prices are kept between calls. */
217let gate: ComputeGate | null = null;
218function gateFor(env: { BILLING: ServiceBinding }): ComputeGate {
219 gate ??= new ComputeGate(env.BILLING);
220 return gate;
221}
222
223/**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.224 * What to record the sandbox as, so people can watch it: a run of the
225 * merge queue.
The docs' Copy page menu keeps its own spacing, with an icon per item226 */
227type Track = {
228 actor: User;
229 repo: RepoPath;
230 kind: RunKind;
231 number?: number | null;
232 pullId?: string | null;
233 title?: string | null;
234 startedBy?: string | null;
235};
236/** The run a sandbox reports to, kept so it can be closed when it stops. */
237type TrackedRun = { runId: string; token: string };
238
239function meter(repo: RepoPath, description: string): Meter {
240 return { workspace: repo.namespace, repo: `${repo.namespace}/${repo.name}`, description };
241}
242
243/** What the deployments service asks a sandbox to build. */
244type DeployJob = {
245 deployId: string;
246 /** The workspace the project is in, which pays. */
247 workspace?: string;
248 /** What the deployments service reserved for the build, settled when it stops. */
249 reservation?: string | null;
250 /** The price it reserved at, per second. */
251 microsPerSecond?: number | null;
252 /** The plan's longest run, in minutes; the build gets the lower of this and its own. */
253 maxRunMinutes?: number | null;
254 /** Lets the sandbox, and nothing else, report this build. */
255 token: string;
256 /** Whose access reads the commit. */
257 actor: User;
258 /** The repository the commit is in: the pull request's fork, or the repository. */
259 source: RepoPath;
260 /**
261 * The project's repository, whose guardrails the build runs under, and
262 * its id. A preview's `source` is its pull request's working copy, so
263 * the two differ. Older callers send only `source`.
264 */
265 repo?: RepoPath | null;
266 repoId?: string | null;
267 commit: string;
268 /** Where in the repository the project lives; empty for all of it. */
269 rootDir?: string;
270 buildCommand?: string | null;
271 outputDir?: string | null;
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.272 /** What it is built as (`auto`, `static`, `workers` or `docker`), and its Dockerfile's path when set. */
273 buildAs?: string | null;
274 dockerfile?: string | null;
The docs' Copy page menu keeps its own spacing, with an icon per item275 /** The repository's variables for deploy builds. */
276 buildEnv?: Record<string, string>;
277 /** Its secrets for deploy builds: set like variables, and redacted from the log. */
278 buildSecrets?: Record<string, string>;
279};
280
281/** Long enough to install and build; then the read token stops working. */
282const DEPLOY_TOKEN_TTL_SECONDS = 30 * 60;
283
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.284/** Long enough to clone, build and test a merge queue's state; then the token stops working. */
The docs' Copy page menu keeps its own spacing, with an icon per item285const CHECKS_TOKEN_TTL_SECONDS = 45 * 60;
286
287/** Long enough to clone and merge two commits; then the read token stops working. */
288const MERGECHECK_TOKEN_TTL_SECONDS = 10 * 60;
289
290/**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.291 * One sandbox, for one piece of work: a workflow job, a build, a state of
292 * the merge queue, a merge check, an update or a backup. The image's entrypoint
The docs' Copy page menu keeps its own spacing, with an icon per item293 * is the g1t runner, which does the work and exits; this class only starts
294 * it and cleans up if it dies without reporting.
295 */
296export class AttemptSandbox extends Container<RunnerEnv> {
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.297 // Past the longest default time cap (90 minutes) and its alarm. A run whose guardrails allow longer (up to 240 minutes) is kept
Merge remote-tracking branch 'origin/main' into workspace-chat298 // past it by `onActivityExpired`, so only its own cap ends it. A finished
The docs' Copy page menu keeps its own spacing, with an icon per item299 // run's process exits and stops the sandbox well before this.
300 sleepAfter = "100m";
301 // A guarded sandbox's HTTPS goes through `egress` too (guard.ts).
302 interceptHttps = true;
303 static {
304 // Assigned, not declared: a class field would hide the setter that
305 // registers the handler with the containers library.
306 AttemptSandbox.outboundHandlers = { egress, abuse };
307 }
308
309 async run(request: RunRequest): Promise<void> {
310 const { envVars, meter, track, limits, reservation, build, owner, selfHosted, ...run } = request;
311 // What billing reserved is settled however this ends, once.
312 if (reservation) await this.ctx.storage.put("reservation", reservation);
313 let guard: RunGuard | null;
314 try {
315 // A tracked run gets its project's guardrails, and so do workflow
316 // jobs and deploy builds; no sandbox for one starts without them.
317 // The plan's caps apply under them: the lower of each.
318 guard = track
319 ? withPlanLimits(await guardFor(this.env.WORK, track.repo, track.kind), limits)
320 : build
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar321 ? withPlanLimits(await buildGuardFor(this.env.WORK, build.repo, build.kind, build.minutes, build.repoId, build.job, build.hosts), limits)
The docs' Copy page menu keeps its own spacing, with an icon per item322 : null;
323 } catch (error) {
Merge remote-tracking branch 'origin/main' into workspace-chat324 // Thrown to the caller, which says why the work did not start; logged
325 // here too, so a sandbox that never started is traceable on its own.
326 console.error("sandbox not started", run.kind, describeError(error));
The docs' Copy page menu keeps its own spacing, with an icon per item327 await this.settle(0);
328 throw error;
329 }
330 await this.ctx.storage.put("run", run);
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.331 // What an earlier attempt in this object left: its reason, its cap
332 // and its cap's alarm, which would otherwise stop this attempt when
333 // the earlier one's time ran out (re-runs use the same object).
334 await this.ctx.storage.delete(["abuse", "stopReason", "remote", "misdirected", "timeCap"]);
335 this.deleteSchedules("timeUp");
The docs' Copy page menu keeps its own spacing, with an icon per item336 if (meter) await this.ctx.storage.put("meter", { ...meter, started: Date.now() });
337 await this.ctx.storage.put("started", Date.now());
338 const who = meter ? { workspace: meter.workspace, repo: meter.repo } : owner;
339 if (who) await this.ctx.storage.put("owner", { ...who, kind: track?.kind ?? run.kind });
340 const tracked = track ? await this.openRun(track, envVars, guard) : null;
341 // Its credentials are tied to the run, and revoked when it stops.
342 await holdCredentials(this.env.IDENTITY, this.ctx.storage, envVars, tracked?.runId ?? null);
343 try {
344 const vars = tracked ? { ...envVars, AGENT_RUN: tracked.runId, AGENT_RUN_TOKEN: tracked.token } : envVars;
345 // The workspace's own runner, not a container: the same environment,
346 // handed over as a task. Network guardrails cannot be enforced there.
347 const repo = selfHosted?.length ? taskRepo(track, meter, owner) : null;
348 if (selfHosted?.length && repo) {
349 const harness = guard ? harnessEnv(guard, vars, false) : {};
350 const minutes = guard?.minutes ?? limits?.minutes ?? 60;
351 await enqueueTask(this.env.ACTIONS, {
352 sandbox: this.ctx.id.toString(),
353 repo,
354 kind: track?.kind ?? run.kind,
355 title: track?.title ?? meter?.description ?? `${run.kind} in ${repo.namespace}/${repo.name}`,
356 labels: selfHosted,
357 env: taskEnv({ ...vars, ...harness }),
358 timeoutMinutes: minutes,
The Runners page shows the machines a workspace's agents and workflow jobs run on, in Workspace under Compute: g1t's cloud beside your own runners, what each is running now with a link to it, what's waiting, this month's machine time and its cost (your own runners' free), where agent work and workflow jobs run, adding a runner, and groups; runner_activity in Actions says what runs where, work handed to your runners keeps its agent run, and the self-hosted runners guide says how.359 runId: tracked?.runId ?? null,
The docs' Copy page menu keeps its own spacing, with an icon per item360 });
361 await this.ctx.storage.put("remote", true);
362 if (tracked) await reportRun(this.env.WORK, tracked, { steps: [handedOverStep(selfHosted)] });
363 if (guard) {
364 await this.ctx.storage.put("timeCap", guard.minutes);
365 await this.schedule(guard.minutes * 60 + ALARM_GRACE_SECONDS, "timeUp");
366 }
367 return;
368 }
369 const restricted = (guard?.policy.restrictNetwork ?? false) && this.env.EGRESS !== "off";
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.370 // A workflow job's brokered secrets are filled in by `egress` from
371 // the job's stand-ins, which it asks for with the job's token.
372 const job = run.kind === "actions" ? { id: run.jobId, token: run.token } : undefined;
373 // A job whose network is open but which may be given brokered
374 // secrets still sends its HTTP and HTTPS through `egress`, which
375 // reaches every host for it; other protocols go straight out, as
376 // they would.
377 const brokering = !restricted && job !== undefined && build?.brokered === true && this.env.EGRESS !== "off";
The docs' Copy page menu keeps its own spacing, with an icon per item378 if (guard && restricted) {
379 this.enableInternet = false;
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.380 await this.setOutboundHandler("egress", { hosts: egressHosts(guard, {}, vars), job });
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.381 } else if (brokering) {
382 await this.setOutboundHandler("egress", { hosts: [], open: true, job });
The docs' Copy page menu keeps its own spacing, with an icon per item383 } else if (this.env.EGRESS !== "off") {
384 // An open sandbox can still report that it stopped itself for
385 // mining; a guarded one does through `egress`.
386 await this.setOutboundByHost(ABUSE_HOST, "abuse").catch((error: unknown) =>
387 console.log("abuse reports not routed", String(error)),
388 );
389 }
390 const harness = guard ? harnessEnv(guard, vars, restricted) : {};
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.391 // A build needs only the certificate variables, not the rules.
The docs' Copy page menu keeps its own spacing, with an icon per item392 if (build) delete harness.GUARDRAILS;
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.393 // Its HTTPS is re-signed as it passes the Worker, so it trusts that
394 // certificate as a guarded sandbox does.
395 const trust = brokering ? EGRESS_ENV : {};
The docs' Copy page menu keeps its own spacing, with an icon per item396 const watch: Record<string, string> = this.env.ABUSE_WATCH === "off" ? { G1T_ABUSE: "off" } : {};
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.397 await this.start({ envVars: { ...vars, ...harness, ...trust, ...watch }, enableInternet: !restricted });
Merge branch 'worktree-agent-ac5b181a013e54348'398 // A backup has no guardrails, but still a time cap.
399 const cap = guard?.minutes ?? (run.kind === "backup" ? BACKUP_MINUTES : null);
400 if (cap) {
401 await this.ctx.storage.put("timeCap", cap);
402 await this.schedule(cap * 60 + ALARM_GRACE_SECONDS, "timeUp");
The docs' Copy page menu keeps its own spacing, with an icon per item403 }
404 } catch (error) {
Merge remote-tracking branch 'origin/main' into workspace-chat405 console.error("sandbox not started", run.kind, describeError(error));
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.406 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
The docs' Copy page menu keeps its own spacing, with an icon per item407 if (tracked) await this.closeRun("failed", `The sandbox could not start: ${String(error)}`);
408 await this.settle(0);
409 throw error;
410 }
411 }
412
413 /** Settles what billing reserved for this sandbox at `micros`, once. */
414 private async settle(micros: number): Promise<void> {
415 const held = await this.ctx.storage.get<Held>("reservation");
416 if (!held) return;
417 await this.ctx.storage.delete("reservation");
418 await gateFor(this.env).settle(held.id, micros);
419 }
420
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.421 /** Settles the reservation at what the sandbox cost: its seconds at the price billing reserved at. */
422 private async settleStopped(started: number | undefined): Promise<void> {
The docs' Copy page menu keeps its own spacing, with an icon per item423 const held = await this.ctx.storage.get<Held>("reservation");
424 if (!held) return;
425 const seconds = started ? Math.max(1, Math.ceil((Date.now() - started) / 1000)) : 0;
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.426 await this.settle(actualMicros(seconds, held.microsPerSecond));
The docs' Copy page menu keeps its own spacing, with an icon per item427 }
428
429 /**
430 * The sandbox stopped itself because it looked like it was mining
431 * (crates/runner abuse.rs), or exited saying so. Stops the run with
432 * `ABUSE_MESSAGE`, tells g1t's staff with `abuse.flagged`, and destroys
433 * the sandbox. Once.
434 */
435 async flagAbuse(verdict: unknown): Promise<void> {
436 if (await this.ctx.storage.get<boolean>("abuse")) return;
437 await this.ctx.storage.put("abuse", true);
438 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
439 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "abuse", error: ABUSE_MESSAGE });
440 const owner = await this.ctx.storage.get<{ workspace: string; repo: string | null; kind: string }>("owner");
441 console.log("abuse flagged", owner?.workspace, owner?.repo, owner?.kind, JSON.stringify(verdict));
442 if (this.env.EVENTS && owner) {
443 await eventsClient(this.env.EVENTS)
444 .publish([
445 {
446 type: "abuse.flagged",
447 source: "runner",
448 // Never on a repository's timeline or its webhooks.
449 repoId: null,
450 actor: null,
451 data: {
452 workspace: owner.workspace,
453 repo: owner.repo ?? null,
454 run: tracked?.runId ?? null,
455 kind: owner.kind,
456 sandbox: this.ctx.id.toString(),
457 metrics: verdict && typeof verdict === "object" ? (verdict as Record<string, unknown>) : null,
458 },
459 },
460 ])
461 .catch((error: unknown) => console.log("abuse.flagged not published", String(error)));
462 }
463 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed for abuse", String(error)));
464 }
465
466 /**
467 * Records the run, which the sandbox then reports its steps to. Never
468 * stops the sandbox from starting: without a record it just goes unseen.
469 */
470 private async openRun(track: Track, envVars: Record<string, string>, guard: RunGuard | null): Promise<TrackedRun | null> {
471 const opened = await agentsClient(this.env.WORK)
472 .openRun({
473 ...track,
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.474 model: null,
The docs' Copy page menu keeps its own spacing, with an icon per item475 sandbox: this.ctx.id.toString(),
476 budgetUsd: guard?.policy.budgetUsd ?? null,
477 timeCapMinutes: guard?.minutes ?? null,
478 })
479 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
480 if (!opened.ok) {
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.481 console.log("run not recorded", track.kind, opened.error.message);
The docs' Copy page menu keeps its own spacing, with an icon per item482 return null;
483 }
484 await this.ctx.storage.put("agentRun", opened.value);
485 return opened.value;
486 }
487
488 /** A host this sandbox was refused, said once as a step of its run. */
489 async noteBlocked(host: string): Promise<void> {
490 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
491 if (!tracked) return;
492 const noted = newlyBlocked((await this.ctx.storage.get<string[]>("blocked")) ?? [], host);
493 if (!noted) return;
494 await this.ctx.storage.put("blocked", noted.seen);
495 await reportRun(this.env.WORK, tracked, { steps: [noted.step] });
496 }
497
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.498 /**
499 * A workflow job sent a brokered secret's stand-in to a host it is not
500 * for: said once per secret and host, in the job's log.
501 */
502 async noteMisdirected(name: string, host: string): Promise<void> {
503 const run = await this.ctx.storage.get<Run>("run");
504 if (run?.kind !== "actions") return;
505 const noted = newlyMisdirected((await this.ctx.storage.get<string[]>("misdirected")) ?? [], name, host);
506 if (!noted) return;
507 await this.ctx.storage.put("misdirected", noted.seen);
508 try {
509 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
510 method: "POST",
511 headers: { "content-type": "application/json" },
512 body: JSON.stringify({ job: run.jobId, token: run.token, report: { kind: "note", text: noted.note } }),
513 });
514 await answered("job_report", response);
515 } catch (error) {
516 console.log("misdirected secret not noted", name, String(error));
517 }
518 }
519
The docs' Copy page menu keeps its own spacing, with an icon per item520 /** The run's time cap has passed: stop it, as stopped for time. */
521 async timeUp(): Promise<void> {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.522 // It already stopped (nothing to stop), or the run in it now is a later
523 // attempt whose cap has not passed: this alarm is an earlier attempt's,
524 // left by a version of g1t that did not clear it, and the run now has
525 // its own.
526 const started = await this.ctx.storage.get<number>("started");
The docs' Copy page menu keeps its own spacing, with an icon per item527 const minutes = (await this.ctx.storage.get<number>("timeCap")) ?? 0;
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.528 if (!capReached(started, minutes, Date.now())) {
529 if (started) console.log("an earlier attempt's time cap passed; the run now keeps going", this.ctx.id.toString());
530 return;
531 }
532 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
The docs' Copy page menu keeps its own spacing, with an icon per item533 // A workflow job or a build has no run to halt: it fails saying why.
534 await this.ctx.storage.put("stopReason", timeCapMessage(minutes));
535 if (tracked) await reportRun(this.env.WORK, tracked, { halt: "time", error: timeCapMessage(minutes) });
536 if (await this.ctx.storage.get<boolean>("remote")) {
537 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), timeCapMessage(minutes));
538 await this.remoteEnded(1, null);
539 return;
540 }
541 await this.destroy().catch((error: unknown) => console.log("sandbox not destroyed at its time cap", String(error)));
542 }
543
544 /**
545 * Stops this sandbox's work: its container, or the task a self-hosted
546 * runner holds, which it hears about on its next poll.
547 */
548 async halt(reason: string | null): Promise<void> {
549 if (await this.ctx.storage.get<boolean>("remote")) {
550 await cancelTask(this.env.ACTIONS, this.ctx.id.toString(), reason);
551 await this.remoteEnded(1, reason);
552 return;
553 }
Merge remote-tracking branch 'origin/main' into workspace-chat554 // A container already gone has nothing to stop; one that will not stop
555 // is logged, and its time cap still ends it.
556 await this.destroy().catch((error: unknown) => console.error("sandbox not destroyed", reason, describeError(error)));
557 }
558
559 /**
560 * The library's `sleepAfter` has passed. The runner never fetches its
561 * container, so to the library every sandbox looks idle: a run inside its
562 * time cap keeps going, and the cap's own alarm (`timeUp`) ends it. Only
563 * a sandbox with no cap is stopped for inactivity.
564 */
565 override async onActivityExpired(): Promise<void> {
566 const started = await this.ctx.storage.get<number>("started");
567 const cap = await this.ctx.storage.get<number>("timeCap");
568 if (withinTimeCap(started, cap, Date.now(), ALARM_GRACE_SECONDS)) return;
569 await super.onActivityExpired();
570 }
571
572 /**
573 * A container that crashed or could not be reached, as the library tells
574 * it. Logged at error level with the sandbox, never thrown: the library
575 * ignores what this throws, and the stop that follows is handled by
576 * `onStop` or by `run`, which says why the work did not start.
577 */
578 override onError(error: unknown): void {
579 console.error("sandbox container error", this.ctx.id.toString(), describeError(error));
The docs' Copy page menu keeps its own spacing, with an icon per item580 }
581
582 /**
Merge remote-tracking branch 'origin/main' into workspace-chat583 * The library's alarm: scheduled callbacks, the container's keep-alive,
584 * and `onStop` once it has stopped. A failure is logged with the retry it
585 * was, then thrown so Cloudflare tries the alarm again.
586 */
587 override async alarm(alarmProps?: AlarmInvocationInfo): Promise<void> {
588 try {
589 await super.alarm(alarmProps);
590 } catch (error) {
591 console.error("sandbox alarm failed", this.ctx.id.toString(), `retry ${alarmProps?.retryCount ?? 0}`, describeError(error));
592 throw error;
593 }
594 }
595
596 /**
The docs' Copy page menu keeps its own spacing, with an icon per item597 * A self-hosted runner's task ended (the actions service says so, or g1t
598 * stopped it): everything a container's stop does, once.
599 */
600 async remoteEnded(exitCode: number, reason: string | null): Promise<void> {
601 if (!(await this.ctx.storage.get<boolean>("remote"))) return;
602 await this.ctx.storage.delete("remote");
603 await this.ctx.storage.put("selfHostedEnded", true);
604 if (exitCode !== 0 && reason && !(await this.ctx.storage.get<string>("stopReason"))) {
605 await this.ctx.storage.put("stopReason", reason);
606 }
607 await this.onStop({ exitCode, reason: "exit" } as StopParams);
608 await this.ctx.storage.delete("selfHostedEnded");
609 }
610
611 /**
612 * Ends the run's record, once. Returns the status it ended with:
613 * `stopped` when a person stopped it first.
614 */
615 private async closeRun(outcome: "succeeded" | "failed", error?: string): Promise<string | null> {
616 const tracked = await this.ctx.storage.get<TrackedRun>("agentRun");
617 if (!tracked) return null;
618 await this.ctx.storage.delete("agentRun");
619 const closed = await agentsClient(this.env.WORK)
620 .closeRun(tracked.runId, tracked.token, outcome, error)
621 .catch(() => null);
622 return closed?.ok ? closed.value : null;
623 }
624
625 /** Reports how long the sandbox ran, once, whatever it exited with. */
626 private async meterStop(): Promise<void> {
627 const metered = await this.ctx.storage.get<Meter & { started: number }>("meter");
628 if (!metered) return;
629 await this.ctx.storage.delete("meter");
630 const seconds = Math.max(1, Math.ceil((Date.now() - metered.started) / 1000));
631 const run = await this.ctx.storage.get<Run>("run");
632 // On the workspace's own runner: its minutes, at $0.
633 const selfHosted = (await this.ctx.storage.get<boolean>("selfHostedEnded")) ?? false;
634 const recorded = await billingClient(this.env.BILLING)
635 .recordSandbox({
636 workspace: metered.workspace,
637 seconds,
638 description: selfHosted ? `${metered.description} on a self-hosted runner` : metered.description,
639 repo: metered.repo,
640 reference: `sandbox/${this.ctx.id.toString()}/${metered.started}`,
641 // Whether g1t's open-source pool may pay for it.
642 kind: run ? computeKindOf(run.kind) : null,
643 selfHosted,
644 instance: metered.instance ?? null,
645 })
646 .catch((error: unknown) => ({ ok: false as const, error: { message: String(error) } }));
647 if (!recorded.ok) console.log("sandbox time not recorded", metered.workspace, seconds, recorded.error.message);
648 }
649
650 override async onStop({ exitCode, reason }: StopParams): Promise<void> {
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.651 await revokeCredentials(this.env.IDENTITY, this.ctx.storage);
The docs' Copy page menu keeps its own spacing, with an icon per item652 const started = await this.ctx.storage.get<number>("started");
653 await this.meterStop();
654 // It stopped itself for mining, and could not say so before it went.
655 if (exitCode === ABUSE_EXIT_CODE && !(await this.ctx.storage.get<boolean>("abuse"))) {
656 await this.flagAbuse(null);
657 }
658 const flagged = (await this.ctx.storage.get<boolean>("abuse")) ?? false;
659 // Why it stopped, when g1t stopped it: said in place of a plain failure.
660 const why = flagged ? ABUSE_MESSAGE : ((await this.ctx.storage.get<string>("stopReason")) ?? null);
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.661 // Stopped by g1t, it failed whatever exit code the library passed on.
662 const short = stoppedShort(exitCode, why);
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.663 await this.closeRun(
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.664 short ? "failed" : "succeeded",
665 short ? (why ?? `The sandbox exited with ${exitCode}.`) : undefined,
The docs' Copy page menu keeps its own spacing, with an icon per item666 );
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.667 await this.settleStopped(started);
The docs' Copy page menu keeps its own spacing, with an icon per item668 await this.ctx.storage.delete("started");
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.669 if (!short) return;
The docs' Copy page menu keeps its own spacing, with an icon per item670 const run = await this.ctx.storage.get<Run>("run");
671 console.log("sandbox stopped", run?.kind, "exit", exitCode, reason);
672 if (!run) return;
Merge remote-tracking branch 'origin/main' into workspace-chat673 // Never thrown: this runs in the sandbox's alarm, which a throw would
674 // fail, retry and count as an error, running all of the above again.
675 // What could not be told is logged, and the sweep catches it up.
676 await tellStopped(run.kind, () => this.reportStopped(run, why, exitCode));
677 }
678
679 /**
680 * Tells whoever is waiting on the sandbox's work that it stopped without
681 * finishing it. Each is refused harmlessly when the sandbox reported its
682 * end before it stopped. Throws when the service could not be reached.
683 */
684 private async reportStopped(run: Run, why: string | null, exitCode: number): Promise<void> {
The docs' Copy page menu keeps its own spacing, with an icon per item685 if (run.kind === "actions") {
686 // Refused harmlessly if the job reported its end before it stopped.
Merge remote-tracking branch 'origin/main' into workspace-chat687 const response = await this.env.ACTIONS.fetch("https://actions/rpc/job_report", {
The docs' Copy page menu keeps its own spacing, with an icon per item688 method: "POST",
689 headers: { "content-type": "application/json" },
690 body: JSON.stringify({
691 job: run.jobId,
692 token: run.token,
693 report: { kind: "done", conclusion: "failure", reason: why ?? "The runner stopped before the job finished." },
694 }),
695 });
Merge remote-tracking branch 'origin/main' into workspace-chat696 await answered("job_report", response);
The docs' Copy page menu keeps its own spacing, with an icon per item697 return;
698 }
699 // Nothing was pushed, so no pull request opens; why is in its log.
700 if (run.kind === "bump") return;
Merge branch 'worktree-agent-ac5b181a013e54348'701 if (run.kind === "backup") {
702 // Refused harmlessly if the sandbox reported before it stopped; the
703 // job is otherwise tried again later tonight.
Merge remote-tracking branch 'origin/main' into workspace-chat704 await reposClient(this.env.REPOS).failBackup(run.jobId, run.token, why ?? `The sandbox exited with ${exitCode}.`);
Merge branch 'worktree-agent-ac5b181a013e54348'705 return;
706 }
The docs' Copy page menu keeps its own spacing, with an icon per item707 if (run.kind === "deploy") {
708 // Refused harmlessly if the build reported its end before it stopped.
Merge remote-tracking branch 'origin/main' into workspace-chat709 const response = await this.env.DEPLOYMENTS.fetch(`https://deployments/jobs/${run.deployId}/fail`, {
The docs' Copy page menu keeps its own spacing, with an icon per item710 method: "POST",
711 headers: { "content-type": "application/json" },
712 body: JSON.stringify({ token: run.token, message: why ?? "The build stopped before it finished." }),
713 });
Merge remote-tracking branch 'origin/main' into workspace-chat714 await answered("deploy fail", response);
The docs' Copy page menu keeps its own spacing, with an icon per item715 return;
716 }
717 const work = workClient(this.env.WORK);
718 if (run.kind === "checks") {
719 // Refused harmlessly if the run did report before it stopped.
720 await work.reportChecks(run.runId, run.token, {
721 error: why ?? "The sandbox stopped before the checks finished.",
722 });
723 return;
724 }
725 if (run.kind === "queue") {
726 // Refused harmlessly if the state was reported before it stopped.
727 await work.failQueue(run.entryId, run.token, why ?? "The sandbox stopped before the state was checked.");
728 return;
729 }
730 if (run.kind === "mergecheck") {
731 // Refused harmlessly if the probe reported before it stopped.
732 await work.failMergecheck(run.pullId, run.token, why ?? "The sandbox stopped before the merge check finished.");
733 return;
734 }
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.735 // A sandbox of a kind g1t no longer starts, which ran from before: it
736 // has nobody left to tell.
The docs' Copy page menu keeps its own spacing, with an icon per item737 }
738}
739
740/**
741 * What the compute gate decided for one start: go, with what billing
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.742 * reserved and the plan's caps; or not, refused with what to tell people.
The docs' Copy page menu keeps its own spacing, with an icon per item743 */
744type Granted = {
745 ok: true;
746 held: Held | null;
747 limits: PlanLimits;
748 /** The labels of the self-hosted runners it goes to; null for a sandbox. */
749 route: string[] | null;
750};
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.751type Admitted = Granted | { ok: false; code: string; message: string };
The docs' Copy page menu keeps its own spacing, with an icon per item752
753/**
754 * The guardrails' default time cap of each kind of run, for estimating what
755 * it may cost before it starts; the sandbox applies the project's own.
756 */
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.757const DEFAULT_MINUTES: Record<"queue" | "mergecheck", number> = {
The docs' Copy page menu keeps its own spacing, with an icon per item758 queue: 45,
759 mergecheck: 10,
760};
761
762/** A plan's caps on one run, for the sandbox to apply under its guardrails'. */
763function limitsOf(ent: ComputeEntitlements | null): PlanLimits {
764 return {
765 minutes: ent && ent.maxRunMinutes > 0 ? ent.maxRunMinutes : null,
766 budgetUsd: ent && ent.runCapMicros > 0 ? ent.runCapMicros / 1_000_000 : null,
767 };
768}
769
770/** The shorter of a kind's time cap and the plan's, for an estimate. */
771function estimateMinutes(minutes: number, ent: ComputeEntitlements | null): number {
772 return ent && ent.maxRunMinutes > 0 ? Math.min(minutes, ent.maxRunMinutes) : minutes;
773}
774
775/** A start the gate did not let through, as a result for whoever asked. */
776function notAdmitted(admitted: Exclude<Admitted, Granted>): Result<never> {
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.777 return fail("payment_required", admitted.message);
The docs' Copy page menu keeps its own spacing, with an icon per item778}
779
780/**
781 * Larger sandboxes for workflow jobs that ask for one in `runs-on`: the
782 * same image and behaviour on a larger Containers instance type, each a
783 * class of its own (wrangler.jsonc). Outbound handlers are registered by
784 * class, so each registers its own.
785 */
786export class Sandbox2Core extends AttemptSandbox {
787 static {
788 Sandbox2Core.outboundHandlers = { egress, abuse };
789 }
790}
791export class Sandbox4Core extends AttemptSandbox {
792 static {
793 Sandbox4Core.outboundHandlers = { egress, abuse };
794 }
795}
796
797/** What the actions service sends to start a job (`StartJobArgs`). */
798type ActionsJobArgs = {
799 job: string;
800 token: string;
801 repo: RepoPath;
802 timeoutMinutes: number;
803 /** Its workflow file, `.g1t/workflows/deploy.yml`. */
804 workflow?: string | null;
805 /** The environment it names plainly. */
806 environment?: string | null;
807 /** Not a pull request from a fork: only then are workflow-only domains given. */
808 trusted?: boolean;
809 /** The machine its `runs-on` asked for, by label; absent, the standard one. */
810 instance?: string | null;
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.811 /** Whether it may be given brokered secrets, which its requests need filled in. */
812 brokered?: boolean;
The docs' Copy page menu keeps its own spacing, with an icon per item813};
814
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.815export default class RunnerService extends WorkerEntrypoint<RunnerEnv> {
The docs' Copy page menu keeps its own spacing, with an icon per item816 /**
817 * The JSON protocol the Rust services speak: `POST /rpc/<method>` with the
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.818 * arguments as the body. Only bound services can.
The docs' Copy page menu keeps its own spacing, with an icon per item819 */
820 async fetch(request: Request): Promise<Response> {
821 const { pathname } = new URL(request.url);
822 if (request.method === "POST" && pathname === "/rpc/start_actions_job") {
823 return Response.json(await this.startActionsJob((await request.json()) as ActionsJobArgs));
824 }
825 if (request.method === "POST" && pathname === "/rpc/stop_actions_job") {
826 const args = (await request.json()) as { job: string };
827 // Whichever machine it asked for: the job's object in every namespace.
828 await Promise.all(
829 Object.keys(SANDBOX_BINDINGS).map((className) => {
830 const namespace = sandboxNamespace(this.env, className) as unknown as DurableObjectNamespace<AttemptSandbox>;
831 return namespace
832 .get(namespace.idFromName(`actions:${args.job}`))
833 .destroy()
834 .catch(() => undefined);
835 }),
836 );
837 return Response.json(ok(true));
838 }
839 // A self-hosted runner's task ended: the sandbox that handed it over
840 // does what it does when a container stops.
841 if (request.method === "POST" && pathname === "/rpc/task_ended") {
842 const args = (await request.json()) as { sandbox: string; exitCode: number; reason?: string | null };
843 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromString(args.sandbox));
844 await sandbox.remoteEnded(args.exitCode, args.reason ?? null);
845 return Response.json(ok(true));
846 }
Merge branch 'main' into rm-agents847 // A self-hosted job's request to a brokered secret's host, which the
848 // API passes on as it came (broker-relay.ts).
849 if (request.method === "POST" && pathname === "/rpc/broker") {
850 try {
851 return await relay(request, relayDeps(this.env.ACTIONS, (upstream) => forward(upstream)));
852 } catch (error) {
853 console.log("broker relay failed", String(error));
854 return Response.json({ error: { code: "unavailable", message: "g1t could not relay the request." } }, { status: 503 });
855 }
856 }
The docs' Copy page menu keeps its own spacing, with an icon per item857 if (request.method === "POST" && pathname === "/rpc/bump") {
858 return Response.json(await this.startBump(await request.json()));
859 }
860 if (request.method === "POST" && pathname === "/rpc/start_deploy") {
861 return Response.json(await this.startDeploy((await request.json()) as DeployJob));
862 }
863 return new Response("Not found\n", { status: 404 });
864 }
865
866 // ---- The compute gate (@g1t/contracts compute.ts) -------------------------
867
868 /** Whether `repo` is public: what g1t's open-source pool can pay for. */
869 private async isPublic(repo: RepoPath): Promise<boolean> {
870 const found = await reposClient(this.env.REPOS)
871 .get(repo, null)
872 .catch(() => null);
873 return Boolean(found?.ok && !found.value.isPrivate);
874 }
875
876 /**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.877 * Whether a sandbox (the merge queue, a
The docs' Copy page menu keeps its own spacing, with an icon per item878 * merge check, a workflow job) may start in `repo`, with what it may cost
879 * for `minutes` reserved. Public repositories' checks, workflows and
880 * queue can be paid by the open-source pool. Never throws.
881 */
882 private async admitSandbox(
883 kind: ComputeKind,
884 repo: RepoPath,
885 minutes: number,
886 instance: InstanceType = STANDARD_INSTANCE,
887 { selfHosted = true }: { selfHosted?: boolean } = {},
888 ): Promise<Admitted> {
889 const workspace = repo.namespace.toLowerCase();
890 const compute = gateFor(this.env);
891 const ent = await compute.entitlements(workspace);
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.892 if (ent?.paused) return { ok: false, code: "paused", message: refusalMessage("paused", workspace, kind, ent.paused) };
The docs' Copy page menu keeps its own spacing, with an icon per item893 // Checks and the merge queue go to the workspace's own runners when it
894 // says so, and cost nothing there. Workflow jobs choose with `runs-on`.
895 const route = selfHosted && (kind === "check" || kind === "queue") ? await selfHostedRoute(this.env.ACTIONS, repo) : null;
896 if (route) return { ok: true, held: null, limits: limitsOf(ent), route };
897 const [standardMicros, isPublic] = await Promise.all([compute.microsPerSecond(), this.isPublic(repo)]);
898 // A larger machine is reserved for at what it costs with every vCPU busy.
899 const microsPerSecond = standardMicros * instance.estimateScale;
900 const admission = await compute.admit(
901 { workspace, repo, public: isPublic, kind, estimateMicros: sandboxEstimateMicros(estimateMinutes(minutes, ent), microsPerSecond) },
902 ent,
903 );
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.904 if (!admission.ok) return { ok: false, code: admission.code, message: admission.message };
The docs' Copy page menu keeps its own spacing, with an icon per item905 return {
906 ok: true,
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.907 held: admission.reservation ? { id: admission.reservation.id, workspace, microsPerSecond } : null,
The docs' Copy page menu keeps its own spacing, with an icon per item908 limits: limitsOf(ent),
909 route: null,
910 };
911 }
912
913 /** Gives back what was reserved for a start that never reached its sandbox. */
914 private async release(held: Held | null): Promise<void> {
915 if (held) await gateFor(this.env).settle(held.id, 0);
916 }
917
918 /**
919 * Runs `start`, giving back what was reserved if it fails. A sandbox that
920 * could not start has given it back already; settling twice at nothing
921 * is harmless.
922 */
923 private async holding<T>(granted: Granted, start: () => Promise<T>): Promise<T> {
924 try {
925 return await start();
926 } catch (error) {
927 await this.release(granted.held);
928 throw error;
929 }
930 }
931
932 /**
933 * Starts one job of a GitHub Actions workflow in a sandbox of its own.
934 * The sandbox fetches the job, its contexts and its secrets with the
935 * job's token, and reports back to the actions service through the API.
936 * Jobs run on g1t's machines, so only for workspaces that may use them.
937 */
938 private async startActionsJob(args: ActionsJobArgs): Promise<Result<true>> {
939 // The machine its `runs-on` asked for; the standard one otherwise.
940 const instance = instanceNamed(args.instance);
941 // Workflow jobs run on g1t's machines: only as the workspace's plan
942 // allows, or on a public repository, from the open-source pool.
943 const admitted = await this.admitSandbox("workflow", args.repo, args.timeoutMinutes, instance);
944 if (!admitted.ok) return fail("payment_required", `Not started: ${admitted.message}`);
945 const namespace = this.jobNamespace(instance);
946 if (!namespace) {
947 await this.release(admitted.held);
948 return fail("invalid", `Not started: ${instance.label} machines are not available here.`);
949 }
950 const sandbox = namespace.get(namespace.idFromName(`actions:${args.job}`));
951 const on = instance === STANDARD_INSTANCE ? "" : ` on ${instance.label}`;
952 try {
953 await sandbox.run({
954 kind: "actions",
955 jobId: args.job,
956 token: args.token,
957 reservation: admitted.held,
958 limits: admitted.limits,
959 // The project's network list plus what builds need (and, for a
960 // trusted run, the workflow-only domains its workflow and
961 // environment are given), and the job's own time limit.
962 build: {
963 kind: "actions",
964 repo: args.repo,
965 minutes: Math.max(1, args.timeoutMinutes),
966 job: { workflow: args.workflow ?? null, environment: args.environment ?? null, trusted: args.trusted === true },
Merge brokered secrets: a secret a job uses only to call an API can be tied to the hosts it is for, and the job gets a stand-in in its place, which g1t fills in on the way out to those hosts and nowhere else, so the value never enters the sandbox; and a request to an allowed host that fails upstream is retried when it can be, or answered with a 502 that says why, rather than reset.967 brokered: args.brokered === true,
The docs' Copy page menu keeps its own spacing, with an icon per item968 },
969 meter: {
970 ...meter(args.repo, `A workflow job in ${args.repo.namespace}/${args.repo.name}${on}`),
971 instance: instance === STANDARD_INSTANCE ? null : instance.label,
972 },
973 envVars: {
974 MODE: "actions",
975 G1T_API: "https://api.g1t.sh",
976 ACTIONS_JOB: args.job,
977 ACTIONS_TOKEN: args.token,
Merge branch 'main' into actions-toolkit-oidc-artifacts978 // Docker of the job's own, inside its sandbox (crates/runner docker/).
979 G1T_DOCKER: dockerFor(this.env.DOCKER),
The docs' Copy page menu keeps its own spacing, with an icon per item980 },
981 });
982 } catch (error) {
983 // A sandbox that could not start, or stopped at once: the job fails
984 // with why, rather than waiting to be noticed.
985 return {
986 ok: false,
987 error: { code: "conflict", message: `The runner could not start the job: ${String(error).replace(/^Error: /, "")}` },
988 };
989 }
990 // `true`, not null: an outcome needs a value.
991 return ok(true);
992 }
993
994 /** The sandboxes of a machine size: each instance type is a class of its own. */
995 private jobNamespace(instance: InstanceType): DurableObjectNamespace<AttemptSandbox> | null {
996 if (instance === STANDARD_INSTANCE) return this.env.SANDBOX;
997 const bound = instance.label === "g1t-4core" ? this.env.SANDBOX_4CORE : instance.label === "g1t-2core" ? this.env.SANDBOX_2CORE : undefined;
998 return (bound as DurableObjectNamespace<AttemptSandbox> | undefined) ?? null;
999 }
1000
1001 /**
1002 * Builds one commit in a sandbox of its own and deploys it to g1t.page.
1003 * Asked by the deployments service, which has already checked that the
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1004 * workspace pays for Deployments; that plan is what
The docs' Copy page menu keeps its own spacing, with an icon per item1005 * lets a build use g1t's machines.
1006 */
1007 private async startDeploy(job: DeployJob): Promise<Result<true>> {
1008 // To read the commit, which may be private, as whoever pushed it.
1009 const token = await runCredential(this.env.IDENTITY, {
1010 onBehalfOf: job.actor,
1011 repo: job.source,
1012 kind: "deploy",
1013 use: "runner",
1014 read: [job.source],
1015 ttlSeconds: DEPLOY_TOKEN_TTL_SECONDS,
1016 });
1017 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`deploy:${job.deployId}`));
1018 const workspace = (job.workspace ?? job.source.namespace).toLowerCase();
1019 // The project the build is for: its guardrails, and who it is charged to.
1020 const project = job.repo ?? job.source;
1021 try {
1022 await sandbox.run({
1023 kind: "deploy",
1024 deployId: job.deployId,
1025 token: job.token,
1026 reservation: job.reservation
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1027 ? { id: job.reservation, workspace, microsPerSecond: job.microsPerSecond ?? 0 }
The docs' Copy page menu keeps its own spacing, with an icon per item1028 : null,
1029 limits: { minutes: job.maxRunMinutes ?? null },
1030 // The project's network list plus registries and Cloudflare's API,
1031 // for as long as its read token lasts.
1032 build: { kind: "deploy", repo: project, repoId: job.repoId ?? null, minutes: DEPLOY_TOKEN_TTL_SECONDS / 60 },
1033 owner: { workspace, repo: `${project.namespace}/${project.name}` },
1034 envVars: {
1035 MODE: "deploy",
1036 G1T_API: "https://api.g1t.sh",
1037 DEPLOY_ID: job.deployId,
1038 DEPLOY_TOKEN: job.token,
1039 G1T_USER: job.actor.username,
1040 G1T_TOKEN: token,
1041 GIT_REMOTE: `https://g1t.sh/${job.source.namespace}/${job.source.name}.git`,
1042 GIT_COMMIT: job.commit,
1043 ROOT_DIR: job.rootDir ?? "",
1044 BUILD_COMMAND: job.buildCommand ?? "",
1045 OUTPUT_DIR: job.outputDir ?? "",
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1046 BUILD_AS: job.buildAs ?? "auto",
1047 DOCKERFILE: job.dockerfile ?? "",
1048 // A container build's own Docker Engine (crates/runner docker/),
1049 // started only if the project is built as one.
1050 G1T_DOCKER: dockerFor(this.env.DOCKER),
The docs' Copy page menu keeps its own spacing, with an icon per item1051 BUILD_ENV: JSON.stringify(job.buildEnv ?? {}),
1052 BUILD_SECRETS: JSON.stringify(job.buildSecrets ?? {}),
1053 },
1054 });
1055 } catch (error) {
1056 return {
1057 ok: false,
1058 error: { code: "conflict", message: `The runner could not start the build: ${String(error).replace(/^Error: /, "")}` },
1059 };
1060 }
1061 return ok(true);
1062 }
1063
1064 /**
1065 * Makes a security update in a sandbox of its own (crates/runner
1066 * bump.rs): raises one package to a fixed version in the lockfiles
1067 * named, commits that as g1t and pushes it to its `g1t/security/…`
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1068 * branch. A version update (`kind: "version"`) raises one or more
1069 * packages the same way, to the branch its dependency update file names,
1070 * which is never the default one. Asked by the security service, which
1071 * opens the pull request when it hears the push; nothing here opens one.
1072 * Admitted, reserved and metered like checks, always in g1t's sandbox (a
1073 * self-hosted runner may not know the mode), under the project's network
1074 * list plus the package registries. Returns whether the sandbox started.
The docs' Copy page menu keeps its own spacing, with an icon per item1075 */
1076 private async startBump(input: unknown): Promise<Result<boolean>> {
1077 const problem = bumpProblem(input, UPDATE_BRANCH_PREFIX);
1078 if (problem) return fail("invalid", problem);
1079 const args = input as BumpArgs;
1080 const repo = args.repo;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1081 const what = args.kind === "version" ? "version update" : "security update";
The docs' Copy page menu keeps its own spacing, with an icon per item1082 const actor = systemActor(repo.namespace);
1083 const closed = await this.closedRepo(actor, repo);
1084 if (closed) return closed;
1085 const admitted = await this.admitSandbox("check", repo, BUMP_MINUTES, STANDARD_INSTANCE, { selfHosted: false });
1086 if (!admitted.ok) return notAdmitted(admitted);
1087 try {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1088 const defaultBranch = await this.defaultBranch(repo, actor);
1089 // From its `target-branch`, which its pull request merges into, or
1090 // the default branch.
1091 const base = args.base ?? defaultBranch;
1092 // A version update names its own branch, which is never the one it
1093 // starts from, nor the default one.
1094 if (args.kind === "version" && (args.branch === defaultBranch || args.branch === base)) {
1095 await this.release(admitted.held);
1096 return fail("invalid", `A version update cannot push to ${args.branch}, the branch it starts from.`);
1097 }
The docs' Copy page menu keeps its own spacing, with an icon per item1098 // As g1t, for the workspace: reads the repository and pushes this
1099 // branch only, with no API operations.
1100 const token = await runCredential(this.env.IDENTITY, {
1101 onBehalfOf: actor,
1102 repo,
1103 kind: "bump",
1104 use: "runner",
1105 read: [repo],
1106 push: [{ repo, branch: args.branch }],
1107 ttlSeconds: BUMP_TOKEN_TTL_SECONDS,
1108 });
1109 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(bumpSandboxName(args)));
1110 await sandbox.run({
1111 kind: "bump",
1112 repo,
1113 branch: args.branch,
1114 reservation: admitted.held,
1115 limits: admitted.limits,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1116 build: { kind: "bump", repo, minutes: BUMP_MINUTES, hosts: registryHosts(args) },
1117 meter: meter(repo, `${args.kind === "version" ? "Version" : "Security"} update in ${repo.namespace}/${repo.name}`),
The docs' Copy page menu keeps its own spacing, with an icon per item1118 envVars: bumpEnv(args, base, token),
1119 });
1120 } catch (error) {
1121 await this.release(admitted.held);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1122 return fail("conflict", `The runner could not start the ${what}: ${String(error).replace(/^Error: /, "")}`);
The docs' Copy page menu keeps its own spacing, with an icon per item1123 }
1124 return ok(true);
1125 }
1126
1127 /**
1128 * Events from the bus. Each one that could change what a pull request
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1129 * needs next moves it along: the work service decides whether it can
1130 * merge by the repository's settings, and merges it if so. Merge checks
1131 * and the merge queue's states start here too.
The docs' Copy page menu keeps its own spacing, with an icon per item1132 */
1133 async queue(batch: MessageBatch<G1tEvent>): Promise<void> {
1134 for (const message of batch.messages) {
1135 const event = message.body;
1136 switch (event.type) {
1137 case "pull.opened":
1138 case "pull.ready":
1139 case "pull.updated":
1140 case "checks.completed":
1141 case "review.completed":
1142 case "pull.mergeability":
1143 await this.advance(event.data.pullId);
1144 break;
1145 // Its head or its target moved and both changed the same files:
1146 // find out whether it still merges cleanly.
1147 case "pull.mergecheck":
1148 await this.startMergecheck(event.data.pullId);
1149 break;
1150 // Something joined, left or landed: test the next batch if none is.
1151 case "queue.changed":
1152 await this.buildQueue(event.data.repoId);
1153 break;
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1154 // A person approved or asked for changes, which may let it merge.
The docs' Copy page menu keeps its own spacing, with an icon per item1155 case "comment.created":
1156 if (event.data.pullId && event.data.verdict) await this.advance(event.data.pullId);
1157 break;
1158 // The branch the others would land on has moved.
1159 case "pull.merged":
1160 await this.advanceAll(event.data.repoId);
1161 break;
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1162 // Read-only or gone: the runs there stop.
The docs' Copy page menu keeps its own spacing, with an icon per item1163 case "repo.archived":
1164 case "repo.deleted":
1165 await this.stopRunsIn(event.data.repoId);
1166 break;
1167 }
1168 message.ack();
1169 }
1170 }
1171
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1172 /** A sweep, for steps whose trigger was missed or whose sandbox died, and the nightly backups. */
The docs' Copy page menu keeps its own spacing, with an icon per item1173 async scheduled(): Promise<void> {
1174 await this.advanceAll();
Merge branch 'worktree-agent-ac5b181a013e54348'1175 await this.startBackups().catch((error: unknown) => console.log("backups not started", String(error)));
1176 }
1177
1178 /**
1179 * Starts a few of the nightly backups the repos service queued, each in
1180 * a sandbox of its own that holds only its job's token: the sandbox asks
1181 * for a read-only git credential itself, when it is ready to clone. No
1182 * plan is asked and nothing is metered: backups are g1t's own work.
1183 */
1184 private async startBackups(): Promise<void> {
1185 const pace = backupPace(this.env.BACKUPS_PER_SWEEP, this.env.BACKUPS_RUNNING);
1186 if (pace.perSweep === 0) return;
1187 const repos = reposClient(this.env.REPOS);
1188 for (const claim of await repos.claimBackups(pace.perSweep, pace.running)) {
1189 try {
1190 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(backupSandboxName(claim)));
1191 await sandbox.run({
1192 kind: "backup",
1193 jobId: claim.jobId,
1194 token: claim.token,
1195 // For `abuse.flagged`: whose repository it was.
1196 owner: { workspace: claim.path.namespace, repo: `${claim.path.namespace}/${claim.path.name}` },
1197 envVars: backupEnv(claim, "https://api.g1t.sh"),
1198 });
1199 } catch (error) {
1200 await repos.failBackup(claim.jobId, claim.token, `The sandbox could not start: ${String(error)}`).catch(() => null);
1201 }
1202 }
The docs' Copy page menu keeps its own spacing, with an icon per item1203 }
1204
1205 private async advanceAll(repoId?: string): Promise<void> {
1206 const pulls = await workClient(this.env.WORK).managedPulls(repoId);
1207 for (const pullId of pulls) await this.advance(pullId);
1208 }
1209
1210 /**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1211 * Merges a pull request by the repository's settings when it is ready:
1212 * the work service decides and merges, and claims the step, so calling
1213 * this twice merges nothing twice.
The docs' Copy page menu keeps its own spacing, with an icon per item1214 */
1215 private async advance(pullId: string): Promise<void> {
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1216 const next = await workClient(this.env.WORK).advance(pullId);
1217 // g1t no longer reviews, revises or catches up pull requests itself.
1218 if (next.action !== "none") console.log("lifecycle step not taken", pullId, next.action);
The docs' Copy page menu keeps its own spacing, with an icon per item1219 }
1220
1221 /**
1222 * Starts the next batch of a repository's merge queue, if it has one
1223 * ready: a sandbox per entry, all at once, each building the default
1224 * branch with that entry and everything ahead of it.
1225 */
1226 private async buildQueue(repoId: string): Promise<void> {
1227 const work = workClient(this.env.WORK);
1228 const jobs = await work.queueBuild(repoId);
1229 // Merge queue sandboxes, like any other, only as the workspace's plan
1230 // allows: refused states fail at once, saying why. A state whose
1231 // sandbox could not start fails at once too, rather than holding the
1232 // queue until it times out.
1233 await Promise.all(
1234 jobs.map(async (job) => {
1235 const admitted = await this.admitSandbox("queue", job.repo, DEFAULT_MINUTES.queue);
1236 if (!admitted.ok) {
1237 await work.failQueue(job.entryId, job.token, `Not started: ${admitted.message}`);
1238 return;
1239 }
1240 await this.holding(admitted, () => this.startQueueRun(job, admitted)).catch((error: unknown) =>
1241 work.failQueue(job.entryId, job.token, `Its sandbox could not start: ${String(error)}`),
1242 );
1243 }),
1244 );
1245 }
1246
1247 private async startQueueRun(job: QueueJob, granted: Granted): Promise<void> {
1248 // To read the changes and push the tested state, as a member.
1249 // Reads each queued change; pushes only the queue's own branch.
1250 const token = await runCredential(this.env.IDENTITY, {
1251 onBehalfOf: job.actor,
1252 repo: job.repo,
1253 kind: "queue",
1254 use: "runner",
1255 number: job.stack.at(-1)?.number ?? null,
1256 read: job.stack.map((item) => item.source),
1257 push: [{ repo: job.repo, branch: job.branch }],
1258 ttlSeconds: CHECKS_TOKEN_TTL_SECONDS,
1259 });
1260 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1261 const sandbox = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(`queue-${job.entryId}-${job.baseCommit}`));
1262 await sandbox.run({
1263 kind: "queue",
1264 entryId: job.entryId,
1265 token: job.token,
1266 reservation: granted.held,
1267 limits: granted.limits,
1268 selfHosted: granted.route,
1269 track: {
1270 actor: job.actor,
1271 repo: job.repo,
1272 kind: "queue",
1273 number: job.stack.at(-1)?.number ?? null,
1274 title: `Merge queue: ${job.stack.map((item) => `#${item.number}`).join(" + ")}`,
1275 },
1276 meter: meter(job.repo, `Merge queue on ${job.repo.namespace}/${job.repo.name}`),
1277 envVars: {
1278 MODE: "queue",
1279 G1T_API: "https://api.g1t.sh",
1280 QUEUE_ENTRY: job.entryId,
1281 QUEUE_TOKEN: job.token,
1282 G1T_USER: job.actor.username,
1283 G1T_TOKEN: token,
1284 BASE_REMOTE: remote(job.repo),
1285 BASE_COMMIT: job.baseCommit,
1286 QUEUE_BRANCH: job.branch,
1287 STACK: JSON.stringify(
1288 job.stack.map((item) => ({
1289 number: item.number,
1290 title: item.title,
1291 remote: remote(item.source),
1292 branch: item.branch,
1293 commit: item.commit,
1294 })),
1295 ),
1296 CHECKS: JSON.stringify(job.checks),
1297 CONTRACT_CHECKS: JSON.stringify(job.contractChecks),
1298 },
1299 });
1300 }
1301
1302 /**
1303 * Merges a pull request's head into its target in a sandbox of its own,
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1304 * pushing nothing, to find the files that conflict.
The docs' Copy page menu keeps its own spacing, with an icon per item1305 * The work service decides when one is needed and how many may run.
1306 */
1307 private async startMergecheck(pullId: string): Promise<void> {
1308 const work = workClient(this.env.WORK);
1309 const started = await work.startMergecheck(pullId);
1310 if (!started.ok) return;
1311 const job = started.value;
1312 let granted: Granted | null = null;
1313 try {
1314 // Like any sandbox, only as the workspace's plan allows.
1315 const admitted = await this.admitSandbox("check", job.repo, DEFAULT_MINUTES.mergecheck);
1316 if (!admitted.ok) throw new Error(`Not started: ${admitted.message}`);
1317 granted = admitted;
1318 // To read the change, which may be private, as whoever opened it.
1319 const token = await runCredential(this.env.IDENTITY, {
1320 onBehalfOf: job.author,
1321 repo: job.repo,
1322 kind: "mergecheck",
1323 use: "runner",
1324 number: job.number,
1325 read: [job.repo, job.source],
1326 ttlSeconds: MERGECHECK_TOKEN_TTL_SECONDS,
1327 });
1328 const remote = (path: RepoPath) => `https://g1t.sh/${path.namespace}/${path.name}.git`;
1329 // One sandbox per pair of commits: asking twice starts nothing twice.
1330 const sandbox = this.env.SANDBOX.get(
1331 this.env.SANDBOX.idFromName(`mergecheck-${job.pullId}-${job.head}-${job.base}`),
1332 );
1333 await sandbox.run({
1334 kind: "mergecheck",
1335 pullId: job.pullId,
1336 token: job.token,
1337 reservation: granted.held,
1338 limits: granted.limits,
1339 selfHosted: granted.route,
1340 meter: meter(job.repo, `Merge check of ${job.repo.namespace}/${job.repo.name}#${job.number}`),
1341 envVars: {
1342 MODE: "mergecheck",
1343 G1T_API: "https://api.g1t.sh",
1344 MERGECHECK_PULL: job.pullId,
1345 MERGECHECK_TOKEN: job.token,
1346 G1T_USER: job.author.username,
1347 G1T_TOKEN: token,
1348 BASE_REMOTE: remote(job.repo),
1349 BASE_COMMIT: job.base,
1350 HEAD_REMOTE: remote(job.source),
1351 HEAD_BRANCH: job.branch,
1352 HEAD_COMMIT: job.head,
1353 },
1354 });
1355 } catch (error) {
1356 if (granted) await this.release(granted.held);
1357 await work.failMergecheck(job.pullId, job.token, error instanceof Error ? error.message : String(error));
1358 }
1359 }
1360
1361 /**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1362 * A refusal if `repo` takes no work from anyone: it is archived, so
The docs' Copy page menu keeps its own spacing, with an icon per item1363 * read-only, or it was deleted (repos hides a deleted one, so it is not
1364 * found). Null when repos cannot answer now; the other checks still run.
1365 */
1366 private async closedRepo(actor: User, repo: RepoPath): Promise<Result<never> | null> {
1367 const repos = reposClient(this.env.REPOS);
1368 const found = await repos.get(repo, actor).catch(() => null);
1369 if (!found) return null;
1370 if (!found.ok) {
1371 return found.error.code === "not_found"
1372 ? fail("not_found", `There is no repository at ${repo.namespace}/${repo.name}, or it was deleted.`)
1373 : null;
1374 }
1375 const status = await repos.statusById(found.value.id).catch(() => null);
1376 if (status?.deleted) {
1377 return fail("not_found", `${found.value.namespace}/${found.value.name} was deleted. An owner can restore it from the workspace's settings.`);
1378 }
1379 if (status?.archived || found.value.archivedAt) {
1380 return fail(
1381 "forbidden",
1382 `${found.value.namespace}/${found.value.name} is archived, so it is read-only. An owner can unarchive it in its settings.`,
1383 );
1384 }
1385 return null;
1386 }
1387
1388 /**
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.1389 * Stops every run in a repository that was archived or deleted: the
The docs' Copy page menu keeps its own spacing, with an icon per item1390 * work service marks them stopped when it hears of it, and lists them
1391 * here (`runs_in_repo`, by id, so a deleted repository's runs are found
1392 * too), and each sandbox is destroyed. Never throws.
1393 */
1394 private async stopRunsIn(repoId: string): Promise<void> {
1395 try {
1396 const response = await this.env.WORK.fetch("https://work/rpc/runs_in_repo", {
1397 method: "POST",
1398 headers: { "content-type": "application/json" },
1399 body: JSON.stringify({ repoId }),
1400 });
1401 if (!response.ok) return;
1402 const runs = (await response.json()) as { runId: string; sandbox: string | null }[];
1403 for (const run of runs) {
1404 if (!run.sandbox) continue;
1405 try {
1406 await this.env.SANDBOX.get(this.env.SANDBOX.idFromString(run.sandbox)).halt("The repository was archived or deleted.");
1407 } catch (error) {
1408 // Already gone, or never started.
1409 console.log("sandbox not destroyed", run.runId, String(error));
1410 }
1411 }
1412 } catch (error) {
1413 console.error("could not stop the runs in", repoId, error);
1414 }
1415 }
1416
1417 private async defaultBranch(repo: RepoPath, viewer: Viewer): Promise<string> {
1418 const found = await reposClient(this.env.REPOS).get(repo, viewer);
1419 return found.ok ? found.value.defaultBranch : "main";
1420 }
1421}

This file's history is long; its oldest lines are credited to the oldest commit read.