Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
872 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.

Topics sit in About on the files pages, under the description1//! The public API: REST at api.g1t.sh and the MCP server at mcp.g1t.sh.
2//!
3//! One Worker, two hostnames. Both are thin adapters over the same
4//! operations (see [`operations::Op`]), which call the services that own
5//! the data. This Worker holds none.
6
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb977mod about;
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.8mod run_artifacts;
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.9mod archives;
Merge branch 'worktree-agent-aaf03bdceac799c89'10mod addresses;
Topics sit in About on the files pages, under the description11mod alerts;
12mod audit;
Usage, Billing settings and prepaid AI credit; fixes from the UX audit13mod billing;
Topics sit in About on the files pages, under the description14mod blobs;
Merge checks: statuses and check runs on every commit15mod checks;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9716mod deployments;
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.17mod telemetry;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca18mod deploy_keys;
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails19mod limits;
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)20mod logs;
Merge branch 'mirroring' into artifacts-mode21mod mirrors;
Topics sit in About on the files pages, under the description22mod mcp;
API: notifications over REST and MCP, with notifications scopes23mod notifications;
Topics sit in About on the files pages, under the description24mod oauth;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R225mod oidc;
Merge packages: roles, Actions access, source label, soft delete, API26mod packages;
Merge main into Artifacts Phase 227mod folios;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar28mod people;
Topics sit in About on the files pages, under the description29mod openapi;
API: pinned projects over REST and MCP30mod pins;
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9731mod projects;
Merge branch 'worktree-agent-a3abfcce648e87dca'32mod protection;
Topics sit in About on the files pages, under the description33mod operations;
34mod renamed;
35#[cfg(test)]
36mod responses;
37mod rest;
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge38mod rules;
Topics sit in About on the files pages, under the description39mod runners;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar40mod security;
Topics sit in About on the files pages, under the description41mod tools;
API and MCP for a workspace's personal access token rules, members' tokens and approvals42mod token_policy;
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R243mod toolkit;
Topics sit in About on the files pages, under the description44
45use g1t_contracts::identity::{
46 DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs,
47};
48use g1t_contracts::work::{
The API and MCP server no longer offer g1t's own agents. g1t is what your coding agents work against, not an agent of its own, so the operations that drove hosted agents are gone: messaging an agent on a pull request (message_agent, take_messages, answer_message), assigning or delegating an issue to g1t (assign_issue, delegate), plans (plan_work, get_plan, apply_plan), the context hub (search_context, get_entity), model routes, agents' computers, AI Gateway requests, "Fix with g1t" on a security alert, and every Customize route (marketplaces, plugins, connectors). The callbacks the agent sandbox reported through (/agent-runs/*, /runs/:id/usage, /plans/:id, /reviews/:id) go with them, update_repo_settings stops accepting agent_review, max_revisions and hold_low_confidence, import_issue stops accepting assign, and an old agent token with no run behind it is refused.49 CheckRun, Mergeable, QueueState, ReportChecksArgs, ReportMergecheckArgs,
50 ReportQueueArgs,
Topics sit in About on the files pages, under the description51};
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)52use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, User, Viewer};
Topics sit in About on the files pages, under the description53use g1t_kit::wire;
54use serde_json::{Value, json};
55use worker::{Context, Env, Method, Request, Response, Result, event};
56
57use operations::Services;
58
59fn method_name(method: Method) -> &'static str {
60 match method {
61 Method::Get => "GET",
62 Method::Post => "POST",
63 Method::Patch => "PATCH",
64 Method::Put => "PUT",
65 Method::Delete => "DELETE",
66 Method::Options => "OPTIONS",
67 Method::Head => "HEAD",
68 _ => "OTHER",
69 }
70}
71
72/// A JSON response. Every body the API sends has its keys in `snake_case`;
73/// the contracts it passes through are `camelCase`, so they are converted
74/// here, on the way out (see [`g1t_kit::wire`]). The OpenAPI document and
75/// the MCP protocol's own envelope keep the spelling their standards use.
76pub(crate) fn reply<T: serde::Serialize>(value: &T) -> Result<Response> {
77 Response::from_json(&wire::snake_case(serde_json::to_value(value)?))
78}
79
80/// The parts of a job's spec (`POST /actions/jobs/{job}/spec`) that are the
81/// workflow file, GitHub's contexts and event, and where to check out, all
82/// passed through as they are.
83const JOB_SPEC_AS_GIVEN: &[&str] = &[
Merge branch 'worktree-agent-a3abfcce648e87dca'84 "spec", "workflow", "github", "event", "contexts", "checkout", "permissions",
Topics sit in About on the files pages, under the description85];
86
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R287/// Puts the toolkit's variables in a job's spec: its runtime token, where
88/// the toolkit's services are, and where to ask for an OIDC token when the
89/// job may have one and this installation issues them. The `runtime` the
90/// actions service sent goes no further.
91fn with_runtime(spec: &mut Value, api: &str, oidc: bool) {
92 let Some(runtime) = spec.as_object_mut().and_then(|s| s.remove("runtime")) else { return };
93 let Some(token) = runtime["token"].as_str().filter(|t| !t.is_empty()) else { return };
94 let id_token = oidc && runtime["id_token"].as_bool() == Some(true);
95 let vars = toolkit::runtime_variables(api, token, id_token);
96 if let Some(variables) = spec.get_mut("variables").and_then(Value::as_object_mut) {
97 variables.extend(vars);
98 }
99}
100
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)101/// What a person whose account has not confirmed its email address may
102/// call: who they are, their addresses, and confirming one with the code
103/// from the email. Nothing over MCP.
104fn pending_may(method: &str, path: &str, on_mcp: bool) -> bool {
105 !on_mcp
106 && matches!(
107 (method, path.trim_end_matches('/')),
108 ("GET", "/user") | ("GET", "/user/emails") | ("POST", "/user/emails/confirm")
109 )
110}
111
Topics sit in About on the files pages, under the description112/// An error in the shape every endpoint uses.
113fn failure(failure: &Failure) -> Result<Response> {
114 Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status()))
115}
116
117fn fail(code: FailureCode, message: &str) -> Result<Response> {
118 failure(&Failure {
119 code,
120 message: message.to_owned(),
121 })
122}
123
124/// A request body as JSON. An empty or malformed body is no input.
125async fn json_body(request: &mut Request) -> Value {
126 request.json().await.unwrap_or(Value::Null)
127}
128
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'129/// Who a request's `Authorization: Bearer g1t_…` (or `token g1t_…`, as
130/// Octokit sends it) names. A missing token is an anonymous viewer; a
131/// wrong one is refused, so that a typo does not silently look signed out.
Topics sit in About on the files pages, under the description132async fn authenticate(
133 request: &Request,
134 services: &Services,
135) -> Result<std::result::Result<Viewer, Response>> {
136 let header = request.headers().get("authorization")?.unwrap_or_default();
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9'137 let Some(token) = limits::bearer(&header) else {
138 return Ok(Ok(None));
Topics sit in About on the files pages, under the description139 };
140 let viewer: Viewer = g1t_kit::call(
141 &services.identity,
142 "user_for_access_token",
143 &TokenArgs {
144 token: token.to_owned(),
145 },
146 )
147 .await?;
148 if viewer.is_some() {
149 return Ok(Ok(viewer));
150 }
151 let mut response = fail(FailureCode::Unauthenticated, "Invalid access token.")?;
152 // Tells an MCP client where to sign in again.
153 response.headers_mut().set(
154 "www-authenticate",
Merge branch 'worktree-agent-aaf03bdceac799c89'155 &format!("{}, error=\"invalid_token\"", services.addresses.mcp_challenge()),
Topics sit in About on the files pages, under the description156 )?;
157 Ok(Err(response))
158}
159
160/// Where everything is, for someone or something exploring the API.
Merge branch 'worktree-agent-aaf03bdceac799c89'161fn index(addresses: &addresses::Addresses) -> Value {
162 let api = &addresses.api;
163 let repo = format!("{api}/repos/{{owner}}/{{name}}");
Topics sit in About on the files pages, under the description164 json!({
165 "documentation_url": "https://docs.g1t.sh/reference/api/",
Merge branch 'worktree-agent-aaf03bdceac799c89'166 "openapi_url": format!("{api}/openapi.json"),
167 "mcp_url": addresses.mcp,
168 "current_user_url": format!("{api}/user"),
169 "workspaces_url": format!("{api}/workspaces"),
170 "repositories_url": format!("{api}/repos{{?q}}"),
171 "search_url": format!("{api}/search{{?q,type,page,per_page}}"),
Topics sit in About on the files pages, under the description172 "repository_url": repo,
173 "repository_events_url": format!("{repo}/events{{?before}}"),
174 "labels_url": format!("{repo}/labels"),
175 "issues_url": format!("{repo}/issues{{?state,label}}"),
176 "issue_url": format!("{repo}/issues/{{number}}"),
177 "issue_comments_url": format!("{repo}/issues/{{number}}/comments"),
178 "pulls_url": format!("{repo}/pulls{{?state}}"),
179 "pull_url": format!("{repo}/pulls/{{number}}"),
180 "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"),
181 "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"),
182 "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"),
Merge branch 'worktree-agent-aaf03bdceac799c89'183 "device_code_url": format!("{api}/device/code"),
184 "device_token_url": format!("{api}/device/token"),
185 "oauth_metadata_url": format!("{api}/.well-known/oauth-authorization-server"),
186 "git_url": format!("{}/{{owner}}/{{name}}.git", addresses.site),
187 "integrations_url": format!("{api}/workspaces/{{workspace}}/integrations"),
Topics sit in About on the files pages, under the description188 "context_url": format!("{repo}/context{{?reference}}"),
189 "import_issue_url": format!("{repo}/issues/import"),
Merge branch 'worktree-agent-aaf03bdceac799c89'190 "hooks_url": format!("{api}/hooks/{{integration}}"),
Topics sit in About on the files pages, under the description191 })
192}
193
194// Signing in from a tool. Accounts are created, and passwords typed, only
195// in a browser; a tool gets its token by having a person approve a code.
196
197/// Passes a request from an outside system to its connection, as it came:
198/// its signature covers the exact bytes of the body.
199async fn receive_hook(request: &mut Request, services: &Services, id: &str) -> Result<Response> {
200 let headers: std::collections::HashMap<String, String> = request
201 .headers()
202 .entries()
203 .map(|(name, value)| (name.to_lowercase(), value))
204 .collect();
205 let body = request.text().await.unwrap_or_default();
206 // A push to GitHub with many commits makes a large payload.
207 let limit = if id == "github" { 10_000_000 } else { 1_000_000 };
208 if body.len() > limit {
209 return Ok(reply(&json!({ "message": "The body is too large." }))?.with_status(413));
210 }
211 // g1t's GitHub App has one webhook for every installation; it is
212 // checked against the app's own secret.
213 let (method, args) = if id == "github" {
214 ("github_receive", json!({ "headers": headers, "body": body }))
215 } else {
216 ("receive", json!({ "id": id, "headers": headers, "body": body }))
217 };
218 let received: g1t_contracts::integrations::Received =
219 g1t_kit::call(&services.integrations, method, &args).await?;
220 Ok(reply(&json!({ "message": received.message }))?.with_status(received.status))
221}
222
223async fn receive_stripe(request: &mut Request, env: &Env) -> Result<Response> {
224 let signature = request.headers().get("stripe-signature")?.unwrap_or_default();
225 let payload = request.text().await.unwrap_or_default();
226 if payload.len() > 1_000_000 || signature.is_empty() {
227 return Ok(reply(&json!({ "message": "Not a Stripe event." }))?.with_status(400));
228 }
229 let handled: g1t_contracts::Outcome<bool> = g1t_kit::call(
230 &env.service("BILLING")?,
231 "stripe_webhook",
232 &g1t_contracts::billing::StripeWebhookArgs { payload, signature },
233 )
234 .await?;
235 // A refusal is a 400, so Stripe shows it as failed; anything handled,
236 // or already handled, is a 200, so Stripe stops sending it.
237 Ok(match handled {
238 g1t_contracts::Outcome::Ok(_) => reply(&json!({ "received": true }))?,
239 g1t_contracts::Outcome::Fail(failure) => {
240 reply(&json!({ "message": failure.message }))?.with_status(400)
241 }
242 })
243}
244
245async fn device_code(request: &mut Request, services: &Services) -> Result<Response> {
246 let body = json_body(request).await;
247 let started: DeviceStart = g1t_kit::call(
248 &services.identity,
249 "device_start",
250 &DeviceStartArgs {
251 client_name: body["client_name"].as_str().unwrap_or_default().to_owned(),
252 },
253 )
254 .await?;
255 reply(&json!({
256 "device_code": started.device_code,
257 "user_code": started.user_code,
Merge branch 'worktree-agent-aaf03bdceac799c89'258 "verification_uri": format!("{}/device", services.addresses.site),
259 "verification_uri_complete": format!("{}/device?code={}", services.addresses.site, started.user_code),
Topics sit in About on the files pages, under the description260 "expires_in": started.expires_in,
261 "interval": started.interval,
262 }))
263}
264
265async fn device_token(request: &mut Request, services: &Services) -> Result<Response> {
266 let body = json_body(request).await;
267 let claim: DeviceClaim = g1t_kit::call(
268 &services.identity,
269 "device_claim",
270 &DeviceClaimArgs {
271 device_code: body["device_code"].as_str().unwrap_or_default().to_owned(),
272 },
273 )
274 .await?;
275 reply(&match claim {
276 DeviceClaim::Approved { token, user } => json!({
277 "status": "approved",
278 "token": token,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar279 "display_username": user.display_username.clone().filter(|display| display.eq_ignore_ascii_case(&user.username)).unwrap_or_else(|| user.username.clone()),
Topics sit in About on the files pages, under the description280 "username": user.username,
281 "verified": user.verified,
282 }),
283 DeviceClaim::Pending => json!({ "status": "pending" }),
284 DeviceClaim::Denied => json!({ "status": "denied" }),
285 DeviceClaim::Expired => json!({ "status": "expired" }),
286 })
287}
288
289/// A sandbox reporting on a run of an issue's commands, from before a pull
290/// request's checks were the workflows run on it.
291/// The run's own token, in the body, is the credential: it was given to
292/// that sandbox and to nothing else.
293async fn report_checks(
294 request: &mut Request,
295 services: &Services,
296 run_id: &str,
297) -> Result<Response> {
298 let body = json_body(request).await;
299 let reported: Outcome<CheckRun> = g1t_kit::call(
300 &services.work,
301 "report_checks",
302 &ReportChecksArgs {
303 run_id: run_id.to_owned(),
304 token: body["token"].as_str().unwrap_or_default().to_owned(),
305 results: serde_json::from_value(body["results"].clone()).unwrap_or_default(),
306 error: body["error"].as_str().map(str::to_owned),
307 skip: false,
308 },
309 )
310 .await?;
311 match reported {
312 Outcome::Ok(run) => reply(&json!({ "status": run.status })),
313 Outcome::Fail(refused) => failure(&refused),
314 }
315}
316
317/// A sandbox reporting one tested state of a merge queue. As with checks,
318/// the entry's own token is the credential.
319async fn report_queue(
320 request: &mut Request,
321 services: &Services,
322 entry_id: &str,
323) -> Result<Response> {
324 let body = json_body(request).await;
325 let reported: Outcome<QueueState> = g1t_kit::call(
326 &services.work,
327 "report_queue",
328 &ReportQueueArgs {
329 entry_id: entry_id.to_owned(),
330 token: body["token"].as_str().unwrap_or_default().to_owned(),
331 combined_commit: body["combinedCommit"].as_str().map(str::to_owned),
332 results: serde_json::from_value(body["results"].clone()).unwrap_or_default(),
333 error: body["error"].as_str().map(str::to_owned),
334 conflict_with: body["conflictWith"].as_u64().map(|n| n as u32),
335 conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(),
336 },
337 )
338 .await?;
339 match reported {
340 Outcome::Ok(state) => reply(&json!({ "state": state })),
341 Outcome::Fail(refused) => failure(&refused),
342 }
343}
344
345/// A sandbox reporting whether a pull request merges cleanly. As with
346/// checks, the probe's own token is the credential.
347async fn report_mergecheck(
348 request: &mut Request,
349 services: &Services,
350 pull_id: &str,
351) -> Result<Response> {
352 let body = json_body(request).await;
353 let reported: Outcome<Mergeable> = g1t_kit::call(
354 &services.work,
355 "report_mergecheck",
356 &ReportMergecheckArgs {
357 pull_id: pull_id.to_owned(),
358 token: body["token"].as_str().unwrap_or_default().to_owned(),
359 conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(),
360 error: body["error"].as_str().map(str::to_owned),
361 },
362 )
363 .await?;
364 match reported {
365 Outcome::Ok(state) => reply(&json!({ "mergeable": state })),
366 Outcome::Fail(refused) => failure(&refused),
367 }
368}
369
Merge branch 'worktree-agent-ac5b181a013e54348'370/// A backup's sandbox, passed on to the repos service, which holds the
371/// job (services/repos/src/backups.rs; the flow is in
372/// `g1t_contracts::backups`):
373///
374/// - `POST /backups/{job}/spec`: what to cut, and a read-only git credential
375/// - `PUT /backups/{job}/parts/{n}`: one part of the bundle, as bytes
376/// - `POST /backups/{job}/complete` with `{ refs, size, sha256, parts, fetched_bytes }`
377/// - `POST /backups/{job}/fail` with `{ error, fetched_bytes }`
378///
379/// Bodies are passed through as they are: snake_case already, and a
380/// bundle's refs are keyed by ref names, which must not be converted.
381async fn backup_job(request: &mut Request, services: &Services, method: &str, path: &str) -> Result<Response> {
382 use g1t_contracts::backups::TOKEN_HEADER;
383 let token = request.headers().get(TOKEN_HEADER)?.unwrap_or_default();
384 let rest = path.trim_start_matches("/backups/");
385 let (job, action) = rest.split_once('/').unwrap_or((rest, ""));
386 if job.is_empty() || token.is_empty() {
387 return fail(FailureCode::Unauthenticated, "A backup job's token is required.");
388 }
389 if method == "PUT" && action.starts_with("parts/") {
390 let bytes = request.bytes().await?;
391 if bytes.len() as u64 > g1t_contracts::backups::PART_BYTES {
392 return fail(FailureCode::Invalid, "A part holds 32 MiB at most.");
393 }
394 let headers = worker::Headers::new();
395 headers.set(TOKEN_HEADER, &token)?;
396 let mut init = worker::RequestInit::new();
397 init.with_method(Method::Put)
398 .with_headers(headers)
399 .with_body(Some(worker::js_sys::Uint8Array::from(bytes.as_slice()).into()));
400 let forwarded = Request::new_with_init(&format!("https://repos/backups/{job}/{action}"), &init)?;
401 let mut answered = services.repos.fetch_request(forwarded).await?;
402 return outcome_as_given(answered.json().await?);
403 }
404 let rpc = match (method, action) {
405 ("POST", "spec") => "backup_spec",
406 ("POST", "complete") => "backup_complete",
407 ("POST", "fail") => "backup_fail",
408 _ => return fail(FailureCode::NotFound, "No such endpoint."),
409 };
410 let mut body = json_body(request).await;
411 if !body.is_object() {
412 body = json!({});
413 }
414 body["job_id"] = json!(job);
415 body["token"] = json!(token);
416 let answered: Value = g1t_kit::call(&services.repos, rpc, &body).await?;
417 outcome_as_given(answered)
418}
419
420/// An `Outcome` from a service whose keys are already the API's: the value,
421/// or the failure in the shape every endpoint uses.
422fn outcome_as_given(answered: Value) -> Result<Response> {
423 match serde_json::from_value::<Outcome<Value>>(answered)? {
424 Outcome::Ok(value) => Response::from_json(&value),
Topics sit in About on the files pages, under the description425 Outcome::Fail(refused) => failure(&refused),
426 }
427}
428
429async fn respond(mut request: Request, env: &Env) -> Result<Response> {
430 let method = method_name(request.method());
431 if method == "OPTIONS" {
432 return Ok(Response::empty()?.with_status(204));
433 }
434 let url = request.url()?;
435 // Paths carry no version. An earlier form began with `/v1`, which is
436 // still accepted so that nothing already written against it breaks.
437 let path = match url.path().strip_prefix("/v1") {
438 Some(rest) if rest.is_empty() || rest.starts_with('/') => rest.to_owned(),
439 _ => url.path().to_owned(),
440 };
441 let mut services = Services::new(env)?;
Merge branch 'worktree-agent-aaf03bdceac799c89'442 // MCP is a host of its own hosted, and may be a path on this one
443 // self-hosted (addresses.rs).
444 let on_mcp = services.addresses.mcp_path(&url).is_some();
Topics sit in About on the files pages, under the description445
446 // Stripe reporting to billing. Signed with the secret of the endpoint
447 // billing registered; the body goes through exactly as received, since
448 // the signature covers its bytes.
449 if method == "POST" && !on_mcp && path == "/stripe/webhook" {
450 return receive_stripe(&mut request, env).await;
451 }
452
453 // Outside systems reporting to a connection. They sign what they send
454 // with the connection's own secret, which is not a g1t token, so this
455 // comes before anything that would read one.
456 if method == "POST" && !on_mcp
457 && let Some(id) = path.strip_prefix("/hooks/").filter(|id| !id.is_empty() && !id.contains('/')) {
458 return receive_hook(&mut request, &services, id).await;
459 }
460
461 // A sandbox building a deployment, reporting with its build's token,
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.462 // which is not a g1t token, or a container app's Worker reporting how
463 // long its container ran, with its own token. The body goes through as
464 // it is: it can carry a Worker's bundled code.
Topics sit in About on the files pages, under the description465 if method == "POST" && !on_mcp
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.466 && let Some(rest) = path
467 .strip_prefix("/deployments/jobs/")
468 .map(|rest| format!("jobs/{rest}"))
469 .or_else(|| path.strip_prefix("/deployments/apps/").filter(|rest| rest.ends_with("/meter")).map(|rest| format!("apps/{rest}")))
Topics sit in About on the files pages, under the description470 {
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.471 let target = format!("https://deployments/{rest}");
Topics sit in About on the files pages, under the description472 let body = request.bytes().await?;
473 let headers = worker::Headers::new();
474 headers.set("content-type", "application/json")?;
475 let mut init = worker::RequestInit::new();
476 init.with_method(Method::Post)
477 .with_headers(headers)
478 .with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into()));
479 let mut answer = env
480 .service("DEPLOYMENTS")?
481 .fetch_request(Request::new_with_init(&target, &init)?)
482 .await?;
483 // A fresh response: a fetched one's headers cannot be changed, and
484 // every response gets the API's own on the way out.
485 let status = answer.status_code();
486 let bytes = answer.bytes().await?;
487 let bytes = match serde_json::from_slice::<Value>(&bytes) {
488 Ok(body) => serde_json::to_vec(&wire::snake_case(body))?,
489 Err(_) => bytes,
490 };
491 return Ok(Response::from_bytes(bytes)?
492 .with_status(status)
493 .with_headers({
494 let headers = worker::Headers::new();
495 headers.set("content-type", "application/json")?;
496 headers
497 }));
498 }
499
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2500 // The services GitHub's toolkit calls from inside a job, with its
501 // runtime token, and the links they hand out (toolkit.rs).
502 if !on_mcp && method == "POST"
503 && let Some(rest) = path.strip_prefix("/twirp/")
504 {
505 let (service, rpc) = rest.split_once('/').unwrap_or((rest, ""));
506 let (service, rpc) = (service.to_owned(), rpc.to_owned());
507 return toolkit::twirp(request, env, &services, &service, &rpc).await;
508 }
509 if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") {
510 let rest = rest.to_owned();
511 return toolkit::cache_v1(request, env, &services, method, &rest).await;
512 }
513 if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") {
514 let token = token.to_owned();
515 return toolkit::blob(request, env, &services, method, &token).await;
516 }
517 // g1t as an OIDC issuer for workflow jobs (oidc.rs).
518 if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") {
519 return oidc::handle(&request, env, &services, &path).await;
520 }
521
Topics sit in About on the files pages, under the description522 // A sandbox's artifacts and cache, with its job's token, which is not a
523 // g1t token either.
524 if !on_mcp
525 && let Some(rest) = path.strip_prefix("/actions/jobs/")
526 && (rest.contains("/artifacts") || rest.ends_with("/cache") || rest.contains("/cache/uploads"))
527 {
528 let rest = rest.to_owned();
529 return blobs::for_job(request, env, &services, method, &rest).await;
530 }
531
532 // A self-hosted runner, with a registration token or its own
533 // credential, neither of which is a g1t access token.
534 if method == "POST"
535 && !on_mcp
536 && path.starts_with("/runners/")
537 && let Some(response) = runners::handle(&mut request, &services, &path).await?
538 {
539 return Ok(response);
540 }
541
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails542 // Per token, or per address without one (limits.rs).
543 if let Some(limited) = limits::limited(&request, env, method, &path, on_mcp).await? {
544 return Ok(limited);
545 }
Topics sit in About on the files pages, under the description546 let viewer = match authenticate(&request, &services).await? {
547 Ok(viewer) => viewer,
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails548 Err(refused) => return Ok(limits::wrong_token(&request, env, on_mcp).await?.unwrap_or(refused)),
Topics sit in About on the files pages, under the description549 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)550 // A person who has not confirmed their email address: who they are,
551 // their addresses, and confirming one, nothing else (REST or MCP).
552 if viewer.as_ref().is_some_and(User::awaits_confirmation) && !pending_may(method, &path, on_mcp) {
553 return fail(
554 FailureCode::Forbidden,
555 &g1t_contracts::accounts::confirm_email_first(&services.addresses.site),
556 );
557 }
Topics sit in About on the files pages, under the description558 services.audit = audit::AuditContext::of(&request, on_mcp);
The API and MCP server no longer offer g1t's own agents. g1t is what your coding agents work against, not an agent of its own, so the operations that drove hosted agents are gone: messaging an agent on a pull request (message_agent, take_messages, answer_message), assigning or delegating an issue to g1t (assign_issue, delegate), plans (plan_work, get_plan, apply_plan), the context hub (search_context, get_entity), model routes, agents' computers, AI Gateway requests, "Fix with g1t" on a security alert, and every Customize route (marketplaces, plugins, connectors). The callbacks the agent sandbox reported through (/agent-runs/*, /runs/:id/usage, /plans/:id, /reviews/:id) go with them, update_repo_settings stops accepting agent_review, max_revisions and hold_low_confidence, import_issue stops accepting assign, and an old agent token with no run behind it is refused.559 // A run's token: what it may do comes with it, on the composite
560 // identity identity resolved it to. Any other token of g1t's own
561 // principal (the agent tokens g1t no longer issues) does nothing.
Topics sit in About on the files pages, under the description562 if let Some(acting) = viewer.as_ref().and_then(|viewer| viewer.acting.as_ref()) {
563 services.scope = Some(acting.scope.clone());
564 } else if viewer.as_ref().is_some_and(|viewer| viewer.kind == PrincipalKind::Agent) {
The API and MCP server no longer offer g1t's own agents. g1t is what your coding agents work against, not an agent of its own, so the operations that drove hosted agents are gone: messaging an agent on a pull request (message_agent, take_messages, answer_message), assigning or delegating an issue to g1t (assign_issue, delegate), plans (plan_work, get_plan, apply_plan), the context hub (search_context, get_entity), model routes, agents' computers, AI Gateway requests, "Fix with g1t" on a security alert, and every Customize route (marketplaces, plugins, connectors). The callbacks the agent sandbox reported through (/agent-runs/*, /runs/:id/usage, /plans/:id, /reviews/:id) go with them, update_repo_settings stops accepting agent_review, max_revisions and hold_low_confidence, import_issue stops accepting assign, and an old agent token with no run behind it is refused.565 return fail(FailureCode::Unauthenticated, "Invalid access token.");
Topics sit in About on the files pages, under the description566 }
567 if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? {
568 return Ok(response);
569 }
570 if on_mcp {
571 return mcp::handle(request, &services, &viewer).await;
572 }
573
Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009)574 // Workflow logs to download: a run's as a zip, a job's as text (logs.rs).
575 if method == "GET" {
576 let text = url.query_pairs().any(|(name, value)| name == "format" && value == "text");
577 if let Some(wanted) = logs::wanted(&path, text) {
578 return logs::download(&services, &viewer, wanted).await;
579 }
580 }
581
Topics sit in About on the files pages, under the description582 match (method, path.trim_end_matches('/')) {
Merge branch 'worktree-agent-aaf03bdceac799c89'583 ("GET", "") => return reply(&index(&services.addresses)),
Topics sit in About on the files pages, under the description584 ("GET", "/openapi.json") => return Response::from_json(&openapi::document()),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2585 // One of a run's artifacts downloaded by name (the run's artifacts
586 // are listed by the REST route in rest.rs).
587 ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => {
Topics sit in About on the files pages, under the description588 let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect();
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2589 if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() {
Merge branch 'worktree-agent-a3abfcce648e87dca'590 // A workflow job's token reaches its own repository only.
591 if viewer
592 .as_ref()
593 .and_then(|user| user.token.as_deref())
594 .is_some_and(|token| !token.reaches(&format!("{owner}/{repo}")))
595 {
596 return fail(FailureCode::NotFound, "No such run.");
597 }
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2598 return blobs::download(env, &services, &viewer, owner, repo, run, name).await;
Topics sit in About on the files pages, under the description599 }
600 }
601 ("POST", "/device/code") => return device_code(&mut request, &services).await,
602 ("POST", "/device/token") => return device_token(&mut request, &services).await,
603 // Where a pull request lives, for a tool that knows only its fork.
604 ("GET", path) if path.starts_with("/pulls/") && !path[7..].contains('/') => {
605 let located: Outcome<Value> = g1t_kit::call(
606 &services.work,
607 "locate_pull",
608 &json!({ "id": &path[7..], "viewer": viewer }),
609 )
610 .await?;
611 return match located {
612 Outcome::Ok(value) => reply(&value),
613 Outcome::Fail(refused) => failure(&refused),
614 };
615 }
616 ("POST", path) if path.starts_with("/mergechecks/") => {
617 let pull_id = path.trim_start_matches("/mergechecks/").to_owned();
618 return report_mergecheck(&mut request, &services, &pull_id).await;
619 }
Merge branch 'worktree-agent-ac5b181a013e54348'620 // A sandbox making a repository's nightly backup. The job's own
621 // token, in its header, is the credential.
622 (method, path) if path.starts_with("/backups/") => {
623 return backup_job(&mut request, &services, method, path).await;
624 }
Topics sit in About on the files pages, under the description625 ("POST", path) if path.starts_with("/queue/") => {
626 let entry_id = path.trim_start_matches("/queue/").to_owned();
627 return report_queue(&mut request, &services, &entry_id).await;
628 }
629 // A sandbox running a GitHub Actions job: fetching the job, and
630 // reporting how it goes. The job's own token is the credential.
Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx'631 // A sandbox downloading an action's archive that g1t keeps
632 // (actions' registry.rs). The job's own token is the credential.
633 ("POST", path) if path.starts_with("/actions/jobs/") && path.ends_with("/archive") => {
634 let job = path.trim_start_matches("/actions/jobs/").trim_end_matches("/archive").to_owned();
635 let body = json_body(&mut request).await;
636 let kept: Outcome<Value> = g1t_kit::call(
637 &services.actions,
638 "job_action_archive",
639 &json!({ "job": job, "token": body["token"], "report": body["report"] }),
640 )
641 .await?;
642 let kept = match kept {
643 Outcome::Ok(kept) => kept,
644 Outcome::Fail(refused) => return failure(&refused),
645 };
646 let bucket = env.bucket("ACTIONS_CACHE")?;
647 let object = kept["object"].as_str().unwrap_or_default();
648 let Some(stored) = bucket.get(object).execute().await? else {
649 return fail(FailureCode::NotFound, "The archive is gone.");
650 };
651 let Some(body) = stored.body() else {
652 return fail(FailureCode::NotFound, "The archive is gone.");
653 };
654 let mut response = Response::from_body(body.response_body()?)?;
655 response.headers_mut().set("content-type", "application/gzip")?;
656 return Ok(response);
657 }
Topics sit in About on the files pages, under the description658 ("POST", path) if path.starts_with("/actions/jobs/") => {
659 let rest = path.trim_start_matches("/actions/jobs/");
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts660 // `/action`: where to fetch another repository's action from (on
661 // g1t, with a read token for a private one, or GitHub).
662 let (job, method) = match (rest.strip_suffix("/spec"), rest.strip_suffix("/action")) {
663 (Some(job), _) => (job.to_owned(), "job_spec"),
664 (_, Some(job)) => (job.to_owned(), "job_action"),
665 _ => (rest.to_owned(), "job_report"),
Topics sit in About on the files pages, under the description666 };
667 let body = json_body(&mut request).await;
668 let answered: Outcome<Value> = g1t_kit::call(
669 &services.actions,
670 method,
671 &json!({ "job": job, "token": body["token"], "report": body["report"] }),
672 )
673 .await?;
674 return match answered {
675 // A job's spec is the workflow and its contexts as GitHub
676 // has them; only g1t's own keys around them are converted.
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2677 Outcome::Ok(value) => {
678 let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN);
679 with_runtime(&mut spec, &services.addresses.api, oidc::configured(env));
680 Response::from_json(&spec)
681 }
Topics sit in About on the files pages, under the description682 Outcome::Fail(refused) => failure(&refused),
683 };
684 }
685 ("POST", path) if path.starts_with("/checks/") => {
686 let run_id = path.trim_start_matches("/checks/").to_owned();
687 return report_checks(&mut request, &services, &run_id).await;
688 }
689 _ => {}
690 }
691
692 let query: Vec<(String, String)> = url
693 .query_pairs()
694 .map(|(name, value)| (name.into_owned(), value.into_owned()))
695 .collect();
696 let body = if method == "GET" {
697 Value::Null
698 } else {
699 snake_case_keys(json_body(&mut request).await)
700 };
701 let Some((route, input)) = rest::resolve(method, &path, &query, body) else {
702 return fail(FailureCode::NotFound, "No such endpoint.");
703 };
704 match audit::run(route.op, &services, &viewer, &input).await? {
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.705 // A download is a redirect to its signed link, as GitHub's is: an
706 // artifact's, and a repository's tarball and zipball.
707 Outcome::Ok(value) if route.op.redirects() => {
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2708 match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) {
709 Some(url) => Response::redirect_with_status(url, 302),
710 None => reply(&value),
711 }
712 }
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts713 // A deleted comment has nothing to say, as GitHub's says nothing.
714 Outcome::Ok(_) if route.no_content() => Ok(Response::empty()?.with_status(204)),
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.715 Outcome::Ok(value) => Response::from_json(&wire::snake_case_keeping(value, route.op.as_given())),
Topics sit in About on the files pages, under the description716 // A token without the scope a call needs is told which one.
717 Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) {
718 (FailureCode::Forbidden, Some(scope)) => Ok(reply(&json!({
719 "error": {
720 "code": refused.code,
721 "message": refused.message,
722 "needed_scope": scope.as_str(),
723 }
724 }))?
725 .with_status(403)),
726 _ => failure(&refused),
727 },
728 }
729}
730
731/// Request bodies take the same keys as the MCP tools, `snake_case`, as
732/// responses use; the `camelCase` spelling is accepted too.
733fn snake_case_keys(body: Value) -> Value {
734 let Value::Object(fields) = body else {
735 return body;
736 };
737 let mut out = serde_json::Map::new();
738 for (key, value) in fields {
739 let mut snake = String::with_capacity(key.len() + 4);
740 for c in key.chars() {
741 if c.is_ascii_uppercase() {
742 snake.push('_');
743 snake.push(c.to_ascii_lowercase());
744 } else {
745 snake.push(c);
746 }
747 }
748 // A key given in both spellings keeps the snake_case one.
749 if snake != key && out.contains_key(&snake) {
750 continue;
751 }
752 out.insert(snake, value);
753 }
754 Value::Object(out)
755}
756
757#[cfg(test)]
758mod tests {
759 use super::snake_case_keys;
760 use serde_json::json;
761
762 #[test]
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)763 fn an_unconfirmed_account_may_only_see_itself_and_confirm_its_address() {
764 assert!(super::pending_may("GET", "/user", false));
765 assert!(super::pending_may("GET", "/user/emails/", false));
766 assert!(super::pending_may("POST", "/user/emails/confirm", false));
767 assert!(!super::pending_may("POST", "/user/emails", false));
768 assert!(!super::pending_may("POST", "/workspaces", false));
769 assert!(!super::pending_may("GET", "/repos/acme/rocket", false));
770 assert!(!super::pending_may("POST", "/user/emails/confirm", true));
771 assert!(!super::pending_may("POST", "/", true));
772 }
773
774 #[test]
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2775 fn a_job_spec_gets_the_toolkits_variables() {
776 // As the actions service sends it, converted as the API does.
777 let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
778 let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN);
779 super::with_runtime(&mut spec, "https://api.g1t.sh", true);
780 assert!(spec.get("runtime").is_none(), "the runner never sees it");
781 let vars = &spec["variables"];
782 assert_eq!(vars["GITHUB_SHA"], "abc");
783 assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s");
784 assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/");
785 assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s");
786 // No OIDC key here: no OIDC variables, whatever the job may do.
787 let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN);
788 super::with_runtime(&mut spec, "https://api.g1t.sh", false);
789 assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none());
790 assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/");
791 }
792
793 #[test]
Topics sit in About on the files pages, under the description794 fn camel_case_keys_are_accepted() {
795 assert_eq!(
796 snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })),
797 json!({ "count_agent_approvals": false, "title": "x" })
798 );
799 }
800
801 #[test]
802 fn snake_case_wins_when_both_are_given() {
803 assert_eq!(
804 snake_case_keys(json!({ "keep_issue_open": true, "keepIssueOpen": false })),
805 json!({ "keep_issue_open": true })
806 );
807 }
808}
809
810// The API is called from browsers too: the reference's explorer, and apps
811// built on g1t. It carries no cookies, so any origin may call it.
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.812/// The signal an OTLP path sends (`/otlp/v1/traces`, `logs` or `metrics`),
813/// if it is one.
814fn otlp_signal(path: &str) -> Option<&'static str> {
815 let path = path.strip_prefix("/v1").filter(|rest| rest.starts_with('/')).unwrap_or(path);
816 match path.trim_end_matches('/') {
817 "/otlp/v1/traces" => Some("traces"),
818 "/otlp/v1/logs" => Some("logs"),
819 "/otlp/v1/metrics" => Some("metrics"),
820 _ => None,
821 }
822}
823
824/// OpenTelemetry sent to the API's own address: a self-hosted install's
825/// only door, and a second one hosted. It goes to the telemetry service as
826/// it came, which checks the project's ingest token itself (not a g1t
827/// token), and its answer comes back as it is, CORS headers included.
828async fn forward_otlp(mut request: Request, env: &Env, signal: &str) -> Result<Response> {
829 let method = request.method();
830 let headers = worker::Headers::new();
831 for name in ["authorization", "x-g1t-token", "content-type", "content-encoding"] {
832 if let Some(value) = request.headers().get(name)? {
833 headers.set(name, &value)?;
834 }
835 }
836 let mut init = worker::RequestInit::new();
837 init.with_method(method.clone()).with_headers(headers);
838 if method == Method::Post {
839 let body = request.bytes().await?;
840 init.with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into()));
841 }
842 let target = format!("https://telemetry/v1/{signal}");
843 let mut answer = env.service("TELEMETRY")?.fetch_request(Request::new_with_init(&target, &init)?).await?;
844 // A fresh response, with the service's headers: a fetched one's cannot
845 // be changed, and nothing should be added to them.
846 let status = answer.status_code();
847 let headers = worker::Headers::new();
848 for (name, value) in answer.headers().entries() {
849 headers.set(&name, &value)?;
850 }
851 let bytes = answer.bytes().await?;
852 let response = if bytes.is_empty() { Response::empty()? } else { Response::from_bytes(bytes)? };
853 Ok(response.with_status(status).with_headers(headers))
854}
855
Topics sit in About on the files pages, under the description856#[event(fetch)]
857async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> {
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.858 // OTLP passes through untouched, before any g1t token is read.
859 if let Some(signal) = otlp_signal(&request.path()) {
860 return forward_otlp(request, &env, signal).await;
861 }
Topics sit in About on the files pages, under the description862 let mut response = respond(request, &env).await?;
863 let headers = response.headers_mut();
864 headers.set("access-control-allow-origin", "*")?;
865 headers.set(
866 "access-control-allow-headers",
867 "authorization, content-type",
868 )?;
869 headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?;
Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails870 headers.set("access-control-expose-headers", "www-authenticate, retry-after")?;
Topics sit in About on the files pages, under the description871 Ok(response)
872}

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