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.
| Keep g1t's own runs off the model proxy until it holds g1t's key | 1 | //! 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-a69aeabc4b0deeb97 | 7 | mod about; |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 8 | mod artifacts; |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 9 | mod addresses; |
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 10 | mod alerts; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 11 | mod audit; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 12 | mod billing; |
| A repository has its own sidebar, as settings do | 13 | mod blobs; |
| Merge checks: statuses and check runs on every commit | 14 | mod checks; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 15 | mod deployments; |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 16 | mod deploy_keys; |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 17 | mod limits; |
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 18 | mod logs; |
| Merge branch 'mirroring' into artifacts-mode | 19 | mod mirrors; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 20 | mod mcp; |
| API: notifications over REST and MCP, with notifications scopes | 21 | mod notifications; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 22 | mod oauth; |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 23 | mod oidc; |
| Merge packages: roles, Actions access, source label, soft delete, API | 24 | mod packages; |
| The API answers for artifacts: the artifact MCP tool and /workspaces/{workspace}/artifacts list, search, read and edit a workspace's docs as the person sees them, make, move, trash, restore and purge them, show their versions and who can open them, and share them, each action offered only to tokens whose artifacts scope allows it, through a DOCS binding to the docs service, with the OpenAPI document and its examples. | 25 | mod folios; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 26 | mod people; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 27 | mod openapi; |
| API: pinned projects over REST and MCP | 28 | mod pins; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 29 | mod projects; |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 30 | mod protection; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 31 | mod operations; |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 32 | mod renamed; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 33 | #[cfg(test)] |
| 34 | mod responses; | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 35 | mod rest; |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 36 | mod rules; |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 37 | mod runners; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 38 | mod security; |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 39 | mod tools; |
| API and MCP for a workspace's personal access token rules, members' tokens and approvals | 40 | mod token_policy; |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 41 | mod toolkit; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 42 | |
| Merge branch 'model-routing' | 43 | use g1t_contracts::billing::{FinishRunArgs, RunTokens}; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 44 | use g1t_contracts::identity::{ |
| 45 | DeviceClaim, DeviceClaimArgs, DeviceStart, DeviceStartArgs, TokenArgs, | |
| 46 | }; | |
| 47 | use g1t_contracts::work::{ | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 48 | CheckRun, Mergeable, QueueState, ReportChecksArgs, ReportMergecheckArgs, ReportPlanArgs, |
| 49 | ReportQueueArgs, ReportReviewArgs, | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 50 | }; |
| 51 | use g1t_contracts::identity::AgentScope; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 52 | use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, User, Viewer}; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 53 | use g1t_kit::wire; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 54 | use serde_json::{Value, json}; |
| 55 | use worker::{Context, Env, Method, Request, Response, Result, event}; | |
| 56 | ||
| 57 | use operations::Services; | |
| 58 | ||
| 59 | fn 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 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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. | |
| 76 | pub(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. | |
| 83 | const JOB_SPEC_AS_GIVEN: &[&str] = &[ | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 84 | "spec", "workflow", "github", "event", "contexts", "checkout", "permissions", |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 85 | ]; |
| 86 | ||
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 87 | /// 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. | |
| 91 | fn 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. | |
| 104 | fn 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 | ||
| Keep g1t's own runs off the model proxy until it holds g1t's key | 112 | /// An error in the shape every endpoint uses. |
| 113 | fn failure(failure: &Failure) -> Result<Response> { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 114 | Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status())) |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 115 | } |
| 116 | ||
| 117 | fn 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. | |
| 125 | async fn json_body(request: &mut Request) -> Value { | |
| 126 | request.json().await.unwrap_or(Value::Null) | |
| 127 | } | |
| 128 | ||
| 129 | /// Who a request's `Authorization: Bearer g1t_…` names. A missing token is | |
| 130 | /// an anonymous viewer; a wrong one is refused, so that a typo does not | |
| 131 | /// silently look signed out. | |
| 132 | async 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(); | |
| 137 | let token = match header.split_once(' ') { | |
| 138 | Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() => { | |
| 139 | token.trim() | |
| 140 | } | |
| 141 | _ => return Ok(Ok(None)), | |
| 142 | }; | |
| 143 | let viewer: Viewer = g1t_kit::call( | |
| 144 | &services.identity, | |
| 145 | "user_for_access_token", | |
| 146 | &TokenArgs { | |
| 147 | token: token.to_owned(), | |
| 148 | }, | |
| 149 | ) | |
| 150 | .await?; | |
| 151 | if viewer.is_some() { | |
| 152 | return Ok(Ok(viewer)); | |
| 153 | } | |
| 154 | let mut response = fail(FailureCode::Unauthenticated, "Invalid access token.")?; | |
| 155 | // Tells an MCP client where to sign in again. | |
| 156 | response.headers_mut().set( | |
| 157 | "www-authenticate", | |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 158 | &format!("{}, error=\"invalid_token\"", services.addresses.mcp_challenge()), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 159 | )?; |
| 160 | Ok(Err(response)) | |
| 161 | } | |
| 162 | ||
| 163 | /// Where everything is, for someone or something exploring the API. | |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 164 | fn index(addresses: &addresses::Addresses) -> Value { |
| 165 | let api = &addresses.api; | |
| 166 | let repo = format!("{api}/repos/{{owner}}/{{name}}"); | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 167 | json!({ |
| Merge branch 'worktree-agent-ab2e39e11a6493412' | 168 | "documentation_url": "https://docs.g1t.sh/reference/api/", |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 169 | "openapi_url": format!("{api}/openapi.json"), |
| 170 | "mcp_url": addresses.mcp, | |
| 171 | "current_user_url": format!("{api}/user"), | |
| 172 | "workspaces_url": format!("{api}/workspaces"), | |
| 173 | "repositories_url": format!("{api}/repos{{?q}}"), | |
| 174 | "search_url": format!("{api}/search{{?q,type,page,per_page}}"), | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 175 | "repository_url": repo, |
| 176 | "repository_events_url": format!("{repo}/events{{?before}}"), | |
| 177 | "labels_url": format!("{repo}/labels"), | |
| 178 | "issues_url": format!("{repo}/issues{{?state,label}}"), | |
| 179 | "issue_url": format!("{repo}/issues/{{number}}"), | |
| 180 | "issue_comments_url": format!("{repo}/issues/{{number}}/comments"), | |
| 181 | "pulls_url": format!("{repo}/pulls{{?state}}"), | |
| 182 | "pull_url": format!("{repo}/pulls/{{number}}"), | |
| 183 | "pull_changes_url": format!("{repo}/pulls/{{number}}/changes"), | |
| 184 | "pull_reviews_url": format!("{repo}/pulls/{{number}}/reviews"), | |
| 185 | "pull_session_url": format!("{repo}/pulls/{{number}}/session{{?after}}"), | |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 186 | "device_code_url": format!("{api}/device/code"), |
| 187 | "device_token_url": format!("{api}/device/token"), | |
| 188 | "oauth_metadata_url": format!("{api}/.well-known/oauth-authorization-server"), | |
| 189 | "git_url": format!("{}/{{owner}}/{{name}}.git", addresses.site), | |
| 190 | "integrations_url": format!("{api}/workspaces/{{workspace}}/integrations"), | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 191 | "context_url": format!("{repo}/context{{?reference}}"), |
| 192 | "import_issue_url": format!("{repo}/issues/import"), | |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 193 | "hooks_url": format!("{api}/hooks/{{integration}}"), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 194 | }) |
| 195 | } | |
| 196 | ||
| 197 | // Signing in from a tool. Accounts are created, and passwords typed, only | |
| 198 | // in a browser; a tool gets its token by having a person approve a code. | |
| 199 | ||
| 200 | /// Passes a request from an outside system to its connection, as it came: | |
| 201 | /// its signature covers the exact bytes of the body. | |
| 202 | async fn receive_hook(request: &mut Request, services: &Services, id: &str) -> Result<Response> { | |
| 203 | let headers: std::collections::HashMap<String, String> = request | |
| 204 | .headers() | |
| 205 | .entries() | |
| 206 | .map(|(name, value)| (name.to_lowercase(), value)) | |
| 207 | .collect(); | |
| 208 | let body = request.text().await.unwrap_or_default(); | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 209 | // A push to GitHub with many commits makes a large payload. |
| 210 | let limit = if id == "github" { 10_000_000 } else { 1_000_000 }; | |
| 211 | if body.len() > limit { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 212 | return Ok(reply(&json!({ "message": "The body is too large." }))?.with_status(413)); |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 213 | } |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 214 | // g1t's GitHub App has one webhook for every installation; it is |
| 215 | // checked against the app's own secret. | |
| 216 | let (method, args) = if id == "github" { | |
| 217 | ("github_receive", json!({ "headers": headers, "body": body })) | |
| 218 | } else { | |
| 219 | ("receive", json!({ "id": id, "headers": headers, "body": body })) | |
| 220 | }; | |
| 221 | let received: g1t_contracts::integrations::Received = | |
| 222 | g1t_kit::call(&services.integrations, method, &args).await?; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 223 | Ok(reply(&json!({ "message": received.message }))?.with_status(received.status)) |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 224 | } |
| 225 | ||
| Stripe webhooks, enterprise invoices, and sudo for both | 226 | async fn receive_stripe(request: &mut Request, env: &Env) -> Result<Response> { |
| 227 | let signature = request.headers().get("stripe-signature")?.unwrap_or_default(); | |
| 228 | let payload = request.text().await.unwrap_or_default(); | |
| 229 | if payload.len() > 1_000_000 || signature.is_empty() { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 230 | return Ok(reply(&json!({ "message": "Not a Stripe event." }))?.with_status(400)); |
| Stripe webhooks, enterprise invoices, and sudo for both | 231 | } |
| 232 | let handled: g1t_contracts::Outcome<bool> = g1t_kit::call( | |
| 233 | &env.service("BILLING")?, | |
| 234 | "stripe_webhook", | |
| 235 | &g1t_contracts::billing::StripeWebhookArgs { payload, signature }, | |
| 236 | ) | |
| 237 | .await?; | |
| 238 | // A refusal is a 400, so Stripe shows it as failed; anything handled, | |
| 239 | // or already handled, is a 200, so Stripe stops sending it. | |
| 240 | Ok(match handled { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 241 | g1t_contracts::Outcome::Ok(_) => reply(&json!({ "received": true }))?, |
| Stripe webhooks, enterprise invoices, and sudo for both | 242 | g1t_contracts::Outcome::Fail(failure) => { |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 243 | reply(&json!({ "message": failure.message }))?.with_status(400) |
| Stripe webhooks, enterprise invoices, and sudo for both | 244 | } |
| 245 | }) | |
| 246 | } | |
| 247 | ||
| Keep g1t's own runs off the model proxy until it holds g1t's key | 248 | async fn device_code(request: &mut Request, services: &Services) -> Result<Response> { |
| 249 | let body = json_body(request).await; | |
| 250 | let started: DeviceStart = g1t_kit::call( | |
| 251 | &services.identity, | |
| 252 | "device_start", | |
| 253 | &DeviceStartArgs { | |
| 254 | client_name: body["client_name"].as_str().unwrap_or_default().to_owned(), | |
| 255 | }, | |
| 256 | ) | |
| 257 | .await?; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 258 | reply(&json!({ |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 259 | "device_code": started.device_code, |
| 260 | "user_code": started.user_code, | |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 261 | "verification_uri": format!("{}/device", services.addresses.site), |
| 262 | "verification_uri_complete": format!("{}/device?code={}", services.addresses.site, started.user_code), | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 263 | "expires_in": started.expires_in, |
| 264 | "interval": started.interval, | |
| 265 | })) | |
| 266 | } | |
| 267 | ||
| 268 | async fn device_token(request: &mut Request, services: &Services) -> Result<Response> { | |
| 269 | let body = json_body(request).await; | |
| 270 | let claim: DeviceClaim = g1t_kit::call( | |
| 271 | &services.identity, | |
| 272 | "device_claim", | |
| 273 | &DeviceClaimArgs { | |
| 274 | device_code: body["device_code"].as_str().unwrap_or_default().to_owned(), | |
| 275 | }, | |
| 276 | ) | |
| 277 | .await?; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 278 | reply(&match claim { |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 279 | DeviceClaim::Approved { token, user } => json!({ |
| 280 | "status": "approved", | |
| 281 | "token": token, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 282 | "display_username": user.display_username.clone().filter(|display| display.eq_ignore_ascii_case(&user.username)).unwrap_or_else(|| user.username.clone()), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 283 | "username": user.username, |
| 284 | "verified": user.verified, | |
| 285 | }), | |
| 286 | DeviceClaim::Pending => json!({ "status": "pending" }), | |
| 287 | DeviceClaim::Denied => json!({ "status": "denied" }), | |
| 288 | DeviceClaim::Expired => json!({ "status": "expired" }), | |
| 289 | }) | |
| 290 | } | |
| 291 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 292 | /// A sandbox reporting on a run of an issue's commands, from before a pull |
| 293 | /// request's checks were the workflows run on it. | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 294 | /// The run's own token, in the body, is the credential: it was given to |
| 295 | /// that sandbox and to nothing else. | |
| 296 | async fn report_checks( | |
| 297 | request: &mut Request, | |
| 298 | services: &Services, | |
| 299 | run_id: &str, | |
| 300 | ) -> Result<Response> { | |
| 301 | let body = json_body(request).await; | |
| 302 | let reported: Outcome<CheckRun> = g1t_kit::call( | |
| 303 | &services.work, | |
| 304 | "report_checks", | |
| 305 | &ReportChecksArgs { | |
| 306 | run_id: run_id.to_owned(), | |
| 307 | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 308 | results: serde_json::from_value(body["results"].clone()).unwrap_or_default(), | |
| 309 | error: body["error"].as_str().map(str::to_owned), | |
| 310 | skip: false, | |
| 311 | }, | |
| 312 | ) | |
| 313 | .await?; | |
| 314 | match reported { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 315 | Outcome::Ok(run) => reply(&json!({ "status": run.status })), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 316 | Outcome::Fail(refused) => failure(&refused), |
| 317 | } | |
| 318 | } | |
| 319 | ||
| 320 | /// A sandbox reporting one tested state of a merge queue. As with checks, | |
| 321 | /// the entry's own token is the credential. | |
| 322 | async fn report_queue( | |
| 323 | request: &mut Request, | |
| 324 | services: &Services, | |
| 325 | entry_id: &str, | |
| 326 | ) -> Result<Response> { | |
| 327 | let body = json_body(request).await; | |
| 328 | let reported: Outcome<QueueState> = g1t_kit::call( | |
| 329 | &services.work, | |
| 330 | "report_queue", | |
| 331 | &ReportQueueArgs { | |
| 332 | entry_id: entry_id.to_owned(), | |
| 333 | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 334 | combined_commit: body["combinedCommit"].as_str().map(str::to_owned), | |
| 335 | results: serde_json::from_value(body["results"].clone()).unwrap_or_default(), | |
| 336 | error: body["error"].as_str().map(str::to_owned), | |
| 337 | conflict_with: body["conflictWith"].as_u64().map(|n| n as u32), | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 338 | conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 339 | }, |
| 340 | ) | |
| 341 | .await?; | |
| 342 | match reported { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 343 | Outcome::Ok(state) => reply(&json!({ "state": state })), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 344 | Outcome::Fail(refused) => failure(&refused), |
| 345 | } | |
| 346 | } | |
| 347 | ||
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 348 | /// A sandbox reporting whether a pull request merges cleanly. As with |
| 349 | /// checks, the probe's own token is the credential. | |
| 350 | async fn report_mergecheck( | |
| 351 | request: &mut Request, | |
| 352 | services: &Services, | |
| 353 | pull_id: &str, | |
| 354 | ) -> Result<Response> { | |
| 355 | let body = json_body(request).await; | |
| 356 | let reported: Outcome<Mergeable> = g1t_kit::call( | |
| 357 | &services.work, | |
| 358 | "report_mergecheck", | |
| 359 | &ReportMergecheckArgs { | |
| 360 | pull_id: pull_id.to_owned(), | |
| 361 | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 362 | conflicts: serde_json::from_value(body["conflicts"].clone()).unwrap_or_default(), | |
| 363 | error: body["error"].as_str().map(str::to_owned), | |
| 364 | }, | |
| 365 | ) | |
| 366 | .await?; | |
| 367 | match reported { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 368 | Outcome::Ok(state) => reply(&json!({ "mergeable": state })), |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 369 | Outcome::Fail(refused) => failure(&refused), |
| 370 | } | |
| 371 | } | |
| 372 | ||
| Merge branch 'worktree-agent-ac5b181a013e54348' | 373 | /// A backup's sandbox, passed on to the repos service, which holds the |
| 374 | /// job (services/repos/src/backups.rs; the flow is in | |
| 375 | /// `g1t_contracts::backups`): | |
| 376 | /// | |
| 377 | /// - `POST /backups/{job}/spec`: what to cut, and a read-only git credential | |
| 378 | /// - `PUT /backups/{job}/parts/{n}`: one part of the bundle, as bytes | |
| 379 | /// - `POST /backups/{job}/complete` with `{ refs, size, sha256, parts, fetched_bytes }` | |
| 380 | /// - `POST /backups/{job}/fail` with `{ error, fetched_bytes }` | |
| 381 | /// | |
| 382 | /// Bodies are passed through as they are: snake_case already, and a | |
| 383 | /// bundle's refs are keyed by ref names, which must not be converted. | |
| 384 | async fn backup_job(request: &mut Request, services: &Services, method: &str, path: &str) -> Result<Response> { | |
| 385 | use g1t_contracts::backups::TOKEN_HEADER; | |
| 386 | let token = request.headers().get(TOKEN_HEADER)?.unwrap_or_default(); | |
| 387 | let rest = path.trim_start_matches("/backups/"); | |
| 388 | let (job, action) = rest.split_once('/').unwrap_or((rest, "")); | |
| 389 | if job.is_empty() || token.is_empty() { | |
| 390 | return fail(FailureCode::Unauthenticated, "A backup job's token is required."); | |
| 391 | } | |
| 392 | if method == "PUT" && action.starts_with("parts/") { | |
| 393 | let bytes = request.bytes().await?; | |
| 394 | if bytes.len() as u64 > g1t_contracts::backups::PART_BYTES { | |
| 395 | return fail(FailureCode::Invalid, "A part holds 32 MiB at most."); | |
| 396 | } | |
| 397 | let headers = worker::Headers::new(); | |
| 398 | headers.set(TOKEN_HEADER, &token)?; | |
| 399 | let mut init = worker::RequestInit::new(); | |
| 400 | init.with_method(Method::Put) | |
| 401 | .with_headers(headers) | |
| 402 | .with_body(Some(worker::js_sys::Uint8Array::from(bytes.as_slice()).into())); | |
| 403 | let forwarded = Request::new_with_init(&format!("https://repos/backups/{job}/{action}"), &init)?; | |
| 404 | let mut answered = services.repos.fetch_request(forwarded).await?; | |
| 405 | return outcome_as_given(answered.json().await?); | |
| 406 | } | |
| 407 | let rpc = match (method, action) { | |
| 408 | ("POST", "spec") => "backup_spec", | |
| 409 | ("POST", "complete") => "backup_complete", | |
| 410 | ("POST", "fail") => "backup_fail", | |
| 411 | _ => return fail(FailureCode::NotFound, "No such endpoint."), | |
| 412 | }; | |
| 413 | let mut body = json_body(request).await; | |
| 414 | if !body.is_object() { | |
| 415 | body = json!({}); | |
| 416 | } | |
| 417 | body["job_id"] = json!(job); | |
| 418 | body["token"] = json!(token); | |
| 419 | let answered: Value = g1t_kit::call(&services.repos, rpc, &body).await?; | |
| 420 | outcome_as_given(answered) | |
| 421 | } | |
| 422 | ||
| 423 | /// An `Outcome` from a service whose keys are already the API's: the value, | |
| 424 | /// or the failure in the shape every endpoint uses. | |
| 425 | fn outcome_as_given(answered: Value) -> Result<Response> { | |
| 426 | match serde_json::from_value::<Outcome<Value>>(answered)? { | |
| 427 | Outcome::Ok(value) => Response::from_json(&value), | |
| 428 | Outcome::Fail(refused) => failure(&refused), | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| Keep g1t's own runs off the model proxy until it holds g1t's key | 432 | /// A sandbox reporting the review its agent wrote. As with checks, the |
| 433 | /// run's own token is the credential. | |
| 434 | async fn report_review( | |
| 435 | request: &mut Request, | |
| 436 | services: &Services, | |
| 437 | run_id: &str, | |
| 438 | ) -> Result<Response> { | |
| 439 | let body = json_body(request).await; | |
| 440 | let reported: Outcome<bool> = g1t_kit::call( | |
| 441 | &services.work, | |
| 442 | "report_review", | |
| 443 | &ReportReviewArgs { | |
| 444 | run_id: run_id.to_owned(), | |
| 445 | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 446 | verdict: serde_json::from_value(body["verdict"].clone()).unwrap_or(None), | |
| 447 | body: body["body"].as_str().unwrap_or_default().to_owned(), | |
| 448 | comments: serde_json::from_value(body["comments"].clone()).unwrap_or_default(), | |
| 449 | model: body["model"].as_str().map(str::to_owned), | |
| 450 | error: body["error"].as_str().map(str::to_owned), | |
| 451 | }, | |
| 452 | ) | |
| 453 | .await?; | |
| 454 | match reported { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 455 | Outcome::Ok(_) => reply(&json!({ "recorded": true })), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 456 | Outcome::Fail(refused) => failure(&refused), |
| 457 | } | |
| 458 | } | |
| 459 | ||
| 460 | /// A sandbox reporting the plan its agent wrote. As with checks, the | |
| 461 | /// plan's own token is the credential. | |
| 462 | async fn report_plan( | |
| 463 | request: &mut Request, | |
| 464 | services: &Services, | |
| 465 | plan_id: &str, | |
| 466 | ) -> Result<Response> { | |
| 467 | let body = json_body(request).await; | |
| 468 | let reported: Outcome<bool> = g1t_kit::call( | |
| 469 | &services.work, | |
| 470 | "report_plan", | |
| 471 | &ReportPlanArgs { | |
| 472 | plan_id: plan_id.to_owned(), | |
| 473 | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 474 | summary: body["summary"].as_str().unwrap_or_default().to_owned(), | |
| 475 | issues: serde_json::from_value(body["issues"].clone()).unwrap_or_default(), | |
| 476 | error: body["error"].as_str().map(str::to_owned), | |
| 477 | }, | |
| 478 | ) | |
| 479 | .await?; | |
| 480 | match reported { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 481 | Outcome::Ok(_) => reply(&json!({ "recorded": true })), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 482 | Outcome::Fail(refused) => failure(&refused), |
| 483 | } | |
| 484 | } | |
| 485 | ||
| 486 | /// A sandbox reporting what its agent's run cost, so that the workspace | |
| 487 | /// it worked for is charged. As with checks, the run's own token is the | |
| 488 | /// credential. | |
| 489 | async fn report_usage( | |
| 490 | request: &mut Request, | |
| 491 | services: &Services, | |
| 492 | run_id: &str, | |
| 493 | ) -> Result<Response> { | |
| 494 | let body = json_body(request).await; | |
| 495 | let charged: Outcome<bool> = g1t_kit::call( | |
| 496 | &services.billing, | |
| 497 | "finish_run", | |
| 498 | &FinishRunArgs { | |
| 499 | run_id: run_id.to_owned(), | |
| 500 | token: body["token"].as_str().unwrap_or_default().to_owned(), | |
| 501 | cost_usd: body["cost_usd"].as_f64().unwrap_or_default(), | |
| 502 | turns: body["turns"].as_u64().unwrap_or_default() as u32, | |
| Merge branch 'model-routing' | 503 | // What the harness counted; the agent rate is charged on no |
| 504 | // fewer, on a workspace's own model key too. | |
| 505 | tokens: body.get("tokens").filter(|t| t.is_object()).map(|t| RunTokens { | |
| 506 | input: t["input"].as_u64().unwrap_or_default(), | |
| 507 | output: t["output"].as_u64().unwrap_or_default(), | |
| 508 | cache_read: t["cache_read"].as_u64().unwrap_or_default(), | |
| 509 | cache_write: t["cache_write"].as_u64().unwrap_or_default(), | |
| 510 | }), | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 511 | }, |
| 512 | ) | |
| 513 | .await?; | |
| 514 | match charged { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 515 | Outcome::Ok(_) => reply(&json!({ "recorded": true })), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 516 | Outcome::Fail(refused) => failure(&refused), |
| 517 | } | |
| 518 | } | |
| 519 | ||
| 520 | async fn respond(mut request: Request, env: &Env) -> Result<Response> { | |
| 521 | let method = method_name(request.method()); | |
| 522 | if method == "OPTIONS" { | |
| 523 | return Ok(Response::empty()?.with_status(204)); | |
| 524 | } | |
| 525 | let url = request.url()?; | |
| 526 | // Paths carry no version. An earlier form began with `/v1`, which is | |
| 527 | // still accepted so that nothing already written against it breaks. | |
| 528 | let path = match url.path().strip_prefix("/v1") { | |
| 529 | Some(rest) if rest.is_empty() || rest.starts_with('/') => rest.to_owned(), | |
| 530 | _ => url.path().to_owned(), | |
| 531 | }; | |
| 532 | let mut services = Services::new(env)?; | |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 533 | // MCP is a host of its own hosted, and may be a path on this one |
| 534 | // self-hosted (addresses.rs). | |
| 535 | let on_mcp = services.addresses.mcp_path(&url).is_some(); | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 536 | |
| Stripe webhooks, enterprise invoices, and sudo for both | 537 | // Stripe reporting to billing. Signed with the secret of the endpoint |
| 538 | // billing registered; the body goes through exactly as received, since | |
| 539 | // the signature covers its bytes. | |
| 540 | if method == "POST" && !on_mcp && path == "/stripe/webhook" { | |
| 541 | return receive_stripe(&mut request, env).await; | |
| 542 | } | |
| 543 | ||
| Keep g1t's own runs off the model proxy until it holds g1t's key | 544 | // Outside systems reporting to a connection. They sign what they send |
| 545 | // with the connection's own secret, which is not a g1t token, so this | |
| 546 | // comes before anything that would read one. | |
| Polish: phones, copy boxes, the plan page, the landing page, a real glide | 547 | if method == "POST" && !on_mcp |
| 548 | && let Some(id) = path.strip_prefix("/hooks/").filter(|id| !id.is_empty() && !id.contains('/')) { | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 549 | return receive_hook(&mut request, &services, id).await; |
| 550 | } | |
| 551 | ||
| Deployments: a preview for every pull request, production on g1t.page | 552 | // A sandbox building a deployment, reporting with its build's token, |
| 553 | // which is not a g1t token. The body goes through as it is: it can | |
| 554 | // carry a Worker's bundled code. | |
| 555 | if method == "POST" && !on_mcp | |
| 556 | && let Some(rest) = path.strip_prefix("/deployments/jobs/") | |
| 557 | { | |
| 558 | let target = format!("https://deployments/jobs/{rest}"); | |
| 559 | let body = request.bytes().await?; | |
| 560 | let headers = worker::Headers::new(); | |
| 561 | headers.set("content-type", "application/json")?; | |
| 562 | let mut init = worker::RequestInit::new(); | |
| 563 | init.with_method(Method::Post) | |
| 564 | .with_headers(headers) | |
| 565 | .with_body(Some(worker::js_sys::Uint8Array::from(body.as_slice()).into())); | |
| Deployments work end to end: fixes from the first live run | 566 | let mut answer = env |
| Deployments: a preview for every pull request, production on g1t.page | 567 | .service("DEPLOYMENTS")? |
| 568 | .fetch_request(Request::new_with_init(&target, &init)?) | |
| Deployments work end to end: fixes from the first live run | 569 | .await?; |
| 570 | // A fresh response: a fetched one's headers cannot be changed, and | |
| 571 | // every response gets the API's own on the way out. | |
| 572 | let status = answer.status_code(); | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 573 | let bytes = answer.bytes().await?; |
| 574 | let bytes = match serde_json::from_slice::<Value>(&bytes) { | |
| 575 | Ok(body) => serde_json::to_vec(&wire::snake_case(body))?, | |
| 576 | Err(_) => bytes, | |
| 577 | }; | |
| 578 | return Ok(Response::from_bytes(bytes)? | |
| Deployments work end to end: fixes from the first live run | 579 | .with_status(status) |
| 580 | .with_headers({ | |
| 581 | let headers = worker::Headers::new(); | |
| 582 | headers.set("content-type", "application/json")?; | |
| 583 | headers | |
| 584 | })); | |
| Deployments: a preview for every pull request, production on g1t.page | 585 | } |
| 586 | ||
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 587 | // The services GitHub's toolkit calls from inside a job, with its |
| 588 | // runtime token, and the links they hand out (toolkit.rs). | |
| 589 | if !on_mcp && method == "POST" | |
| 590 | && let Some(rest) = path.strip_prefix("/twirp/") | |
| 591 | { | |
| 592 | let (service, rpc) = rest.split_once('/').unwrap_or((rest, "")); | |
| 593 | let (service, rpc) = (service.to_owned(), rpc.to_owned()); | |
| 594 | return toolkit::twirp(request, env, &services, &service, &rpc).await; | |
| 595 | } | |
| 596 | if !on_mcp && let Some(rest) = path.strip_prefix("/actions/toolkit/_apis/artifactcache/") { | |
| 597 | let rest = rest.to_owned(); | |
| 598 | return toolkit::cache_v1(request, env, &services, method, &rest).await; | |
| 599 | } | |
| 600 | if !on_mcp && let Some(token) = path.strip_prefix("/actions/toolkit/blobs/") { | |
| 601 | let token = token.to_owned(); | |
| 602 | return toolkit::blob(request, env, &services, method, &token).await; | |
| 603 | } | |
| 604 | // g1t as an OIDC issuer for workflow jobs (oidc.rs). | |
| 605 | if !on_mcp && method == "GET" && path.starts_with("/actions/oidc/") { | |
| 606 | return oidc::handle(&request, env, &services, &path).await; | |
| 607 | } | |
| 608 | ||
| A repository has its own sidebar, as settings do | 609 | // A sandbox's artifacts and cache, with its job's token, which is not a |
| 610 | // g1t token either. | |
| 611 | if !on_mcp | |
| 612 | && let Some(rest) = path.strip_prefix("/actions/jobs/") | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 613 | && (rest.contains("/artifacts") || rest.ends_with("/cache") || rest.contains("/cache/uploads")) |
| A repository has its own sidebar, as settings do | 614 | { |
| 615 | let rest = rest.to_owned(); | |
| 616 | return blobs::for_job(request, env, &services, method, &rest).await; | |
| 617 | } | |
| 618 | ||
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 619 | // A self-hosted runner, with a registration token or its own |
| 620 | // credential, neither of which is a g1t access token. | |
| 621 | if method == "POST" | |
| 622 | && !on_mcp | |
| 623 | && path.starts_with("/runners/") | |
| 624 | && let Some(response) = runners::handle(&mut request, &services, &path).await? | |
| 625 | { | |
| 626 | return Ok(response); | |
| 627 | } | |
| 628 | ||
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 629 | // Per token, or per address without one (limits.rs). |
| 630 | if let Some(limited) = limits::limited(&request, env, method, &path, on_mcp).await? { | |
| 631 | return Ok(limited); | |
| 632 | } | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 633 | let viewer = match authenticate(&request, &services).await? { |
| 634 | Ok(viewer) => viewer, | |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 635 | Err(refused) => return Ok(limits::wrong_token(&request, env, on_mcp).await?.unwrap_or(refused)), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 636 | }; |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 637 | // A person who has not confirmed their email address: who they are, |
| 638 | // their addresses, and confirming one, nothing else (REST or MCP). | |
| 639 | if viewer.as_ref().is_some_and(User::awaits_confirmation) && !pending_may(method, &path, on_mcp) { | |
| 640 | return fail( | |
| 641 | FailureCode::Forbidden, | |
| 642 | &g1t_contracts::accounts::confirm_email_first(&services.addresses.site), | |
| 643 | ); | |
| 644 | } | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 645 | services.audit = audit::AuditContext::of(&request, on_mcp); |
| 646 | // An agent's token: what it may do comes with it, on the composite | |
| 647 | // identity identity resolved it to. | |
| 648 | if let Some(acting) = viewer.as_ref().and_then(|viewer| viewer.acting.as_ref()) { | |
| 649 | services.scope = Some(acting.scope.clone()); | |
| 650 | } else if viewer.as_ref().is_some_and(|viewer| viewer.kind == PrincipalKind::Agent) { | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 651 | let header = request.headers().get("authorization")?.unwrap_or_default(); |
| 652 | let token = header.split_once(' ').map(|(_, token)| token.trim()).unwrap_or_default(); | |
| 653 | let scope: Option<AgentScope> = g1t_kit::call( | |
| 654 | &services.identity, | |
| 655 | "agent_scope", | |
| 656 | &TokenArgs { | |
| 657 | token: token.to_owned(), | |
| 658 | }, | |
| 659 | ) | |
| 660 | .await?; | |
| 661 | // A scope is what lets an agent's token do anything at all. | |
| 662 | let Some(scope) = scope else { | |
| 663 | return fail(FailureCode::Unauthenticated, "Invalid access token."); | |
| 664 | }; | |
| 665 | services.scope = Some(scope); | |
| 666 | } | |
| 667 | if let Some(response) = oauth::handle(&mut request, &services, method, &path).await? { | |
| 668 | return Ok(response); | |
| 669 | } | |
| 670 | if on_mcp { | |
| 671 | return mcp::handle(request, &services, &viewer).await; | |
| 672 | } | |
| 673 | ||
| Merge Actions runs: summaries, attempts and re-runs, graceful cancel, log downloads, badges (actions 0009) | 674 | // Workflow logs to download: a run's as a zip, a job's as text (logs.rs). |
| 675 | if method == "GET" { | |
| 676 | let text = url.query_pairs().any(|(name, value)| name == "format" && value == "text"); | |
| 677 | if let Some(wanted) = logs::wanted(&path, text) { | |
| 678 | return logs::download(&services, &viewer, wanted).await; | |
| 679 | } | |
| 680 | } | |
| 681 | ||
| Keep g1t's own runs off the model proxy until it holds g1t's key | 682 | match (method, path.trim_end_matches('/')) { |
| Merge branch 'worktree-agent-aaf03bdceac799c89' | 683 | ("GET", "") => return reply(&index(&services.addresses)), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 684 | ("GET", "/openapi.json") => return Response::from_json(&openapi::document()), |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 685 | // One of a run's artifacts downloaded by name (the run's artifacts |
| 686 | // are listed by the REST route in rest.rs). | |
| 687 | ("GET", path) if path.starts_with("/repos/") && path.contains("/actions/runs/") && path.contains("/artifacts/") => { | |
| A repository has its own sidebar, as settings do | 688 | let parts: Vec<&str> = path.trim_start_matches("/repos/").split('/').collect(); |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 689 | if let [owner, repo, "actions", "runs", run, "artifacts", name] = parts.as_slice() { |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 690 | // A workflow job's token reaches its own repository only. |
| 691 | if viewer | |
| 692 | .as_ref() | |
| 693 | .and_then(|user| user.token.as_deref()) | |
| 694 | .is_some_and(|token| !token.reaches(&format!("{owner}/{repo}"))) | |
| 695 | { | |
| 696 | return fail(FailureCode::NotFound, "No such run."); | |
| 697 | } | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 698 | return blobs::download(env, &services, &viewer, owner, repo, run, name).await; |
| A repository has its own sidebar, as settings do | 699 | } |
| 700 | } | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 701 | ("POST", "/device/code") => return device_code(&mut request, &services).await, |
| 702 | ("POST", "/device/token") => return device_token(&mut request, &services).await, | |
| 703 | // Where a pull request lives, for a tool that knows only its fork. | |
| 704 | ("GET", path) if path.starts_with("/pulls/") && !path[7..].contains('/') => { | |
| 705 | let located: Outcome<Value> = g1t_kit::call( | |
| 706 | &services.work, | |
| 707 | "locate_pull", | |
| 708 | &json!({ "id": &path[7..], "viewer": viewer }), | |
| 709 | ) | |
| 710 | .await?; | |
| 711 | return match located { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 712 | Outcome::Ok(value) => reply(&value), |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 713 | Outcome::Fail(refused) => failure(&refused), |
| 714 | }; | |
| 715 | } | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 716 | ("POST", path) if path.starts_with("/mergechecks/") => { |
| 717 | let pull_id = path.trim_start_matches("/mergechecks/").to_owned(); | |
| 718 | return report_mergecheck(&mut request, &services, &pull_id).await; | |
| 719 | } | |
| Merge branch 'worktree-agent-ac5b181a013e54348' | 720 | // A sandbox making a repository's nightly backup. The job's own |
| 721 | // token, in its header, is the credential. | |
| 722 | (method, path) if path.starts_with("/backups/") => { | |
| 723 | return backup_job(&mut request, &services, method, path).await; | |
| 724 | } | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 725 | ("POST", path) if path.starts_with("/queue/") => { |
| 726 | let entry_id = path.trim_start_matches("/queue/").to_owned(); | |
| 727 | return report_queue(&mut request, &services, &entry_id).await; | |
| 728 | } | |
| GitHub Actions on g1t, part two: running workflows | 729 | // A sandbox running a GitHub Actions job: fetching the job, and |
| 730 | // reporting how it goes. The job's own token is the credential. | |
| 731 | ("POST", path) if path.starts_with("/actions/jobs/") => { | |
| 732 | let rest = path.trim_start_matches("/actions/jobs/"); | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 733 | // `/action`: where to fetch another repository's action from (on |
| 734 | // g1t, with a read token for a private one, or GitHub). | |
| 735 | let (job, method) = match (rest.strip_suffix("/spec"), rest.strip_suffix("/action")) { | |
| 736 | (Some(job), _) => (job.to_owned(), "job_spec"), | |
| 737 | (_, Some(job)) => (job.to_owned(), "job_action"), | |
| 738 | _ => (rest.to_owned(), "job_report"), | |
| GitHub Actions on g1t, part two: running workflows | 739 | }; |
| 740 | let body = json_body(&mut request).await; | |
| 741 | let answered: Outcome<Value> = g1t_kit::call( | |
| 742 | &services.actions, | |
| 743 | method, | |
| 744 | &json!({ "job": job, "token": body["token"], "report": body["report"] }), | |
| 745 | ) | |
| 746 | .await?; | |
| 747 | return match answered { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 748 | // A job's spec is the workflow and its contexts as GitHub |
| 749 | // has them; only g1t's own keys around them are converted. | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 750 | Outcome::Ok(value) => { |
| 751 | let mut spec = wire::snake_case_keeping(value, JOB_SPEC_AS_GIVEN); | |
| 752 | with_runtime(&mut spec, &services.addresses.api, oidc::configured(env)); | |
| 753 | Response::from_json(&spec) | |
| 754 | } | |
| GitHub Actions on g1t, part two: running workflows | 755 | Outcome::Fail(refused) => failure(&refused), |
| 756 | }; | |
| 757 | } | |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 758 | // A sandbox reporting its agent run's steps, cost and end. As with |
| 759 | // checks, the run's own token, in the body, is the credential. | |
| 760 | ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/report") => { | |
| 761 | let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/report"); | |
| 762 | let mut body = json_body(&mut request).await; | |
| 763 | if !body.is_object() { | |
| 764 | body = json!({}); | |
| 765 | } | |
| 766 | body["runId"] = json!(run_id); | |
| 767 | let reported: Outcome<Value> = g1t_kit::call(&services.work, "report_run", &body).await?; | |
| 768 | return match reported { | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 769 | Outcome::Ok(status) => reply(&json!({ "status": status })), |
| Agents and memory, checks and conflicts, profiles, slug renames, custom domains | 770 | Outcome::Fail(refused) => failure(&refused), |
| 771 | }; | |
| 772 | } | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 773 | // What a run's agent learned, as memory candidates; the same token. |
| 774 | ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/learned") => { | |
| 775 | let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/learned"); | |
| 776 | let body = json_body(&mut request).await; | |
| 777 | let learned = json!({ | |
| 778 | "runId": run_id, | |
| 779 | "token": body["token"].as_str().unwrap_or_default(), | |
| 780 | "items": body["items"].as_array().cloned().unwrap_or_default(), | |
| 781 | }); | |
| 782 | let captured: Outcome<Value> = g1t_kit::call(&services.work, "report_learned", &learned).await?; | |
| 783 | return match captured { | |
| 784 | Outcome::Ok(captured) => reply(&captured), | |
| 785 | Outcome::Fail(refused) => failure(&refused), | |
| 786 | }; | |
| 787 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 788 | // How sure a run's agent is of its change; the same token. |
| 789 | ("POST", path) if path.starts_with("/agent-runs/") && path.ends_with("/confidence") => { | |
| 790 | let run_id = path.trim_start_matches("/agent-runs/").trim_end_matches("/confidence"); | |
| 791 | let body = json_body(&mut request).await; | |
| 792 | let said = json!({ | |
| 793 | "runId": run_id, | |
| 794 | "token": body["token"].as_str().unwrap_or_default(), | |
| 795 | "confidence": body["confidence"].as_str().unwrap_or_default(), | |
| 796 | "uncertainAbout": body["uncertain_about"] | |
| 797 | .as_array() | |
| 798 | .map(|items| items.iter().filter_map(Value::as_str).collect::<Vec<_>>()) | |
| 799 | .unwrap_or_default(), | |
| 800 | }); | |
| 801 | let recorded: Outcome<Value> = g1t_kit::call(&services.work, "report_confidence", &said).await?; | |
| 802 | return match recorded { | |
| 803 | Outcome::Ok(recorded) => reply(&json!({ "recorded": recorded })), | |
| 804 | Outcome::Fail(refused) => failure(&refused), | |
| 805 | }; | |
| 806 | } | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 807 | ("POST", path) if path.starts_with("/checks/") => { |
| 808 | let run_id = path.trim_start_matches("/checks/").to_owned(); | |
| 809 | return report_checks(&mut request, &services, &run_id).await; | |
| 810 | } | |
| 811 | ("POST", path) if path.starts_with("/runs/") && path.ends_with("/usage") => { | |
| 812 | let run_id = path | |
| 813 | .trim_start_matches("/runs/") | |
| 814 | .trim_end_matches("/usage") | |
| 815 | .to_owned(); | |
| 816 | return report_usage(&mut request, &services, &run_id).await; | |
| 817 | } | |
| 818 | ("POST", path) if path.starts_with("/plans/") => { | |
| 819 | let plan_id = path.trim_start_matches("/plans/").to_owned(); | |
| 820 | return report_plan(&mut request, &services, &plan_id).await; | |
| 821 | } | |
| 822 | ("POST", path) if path.starts_with("/reviews/") => { | |
| 823 | let run_id = path.trim_start_matches("/reviews/").to_owned(); | |
| 824 | return report_review(&mut request, &services, &run_id).await; | |
| 825 | } | |
| 826 | _ => {} | |
| 827 | } | |
| 828 | ||
| 829 | let query: Vec<(String, String)> = url | |
| 830 | .query_pairs() | |
| 831 | .map(|(name, value)| (name.into_owned(), value.into_owned())) | |
| 832 | .collect(); | |
| 833 | let body = if method == "GET" { | |
| 834 | Value::Null | |
| 835 | } else { | |
| 836 | snake_case_keys(json_body(&mut request).await) | |
| 837 | }; | |
| 838 | let Some((route, input)) = rest::resolve(method, &path, &query, body) else { | |
| 839 | return fail(FailureCode::NotFound, "No such endpoint."); | |
| 840 | }; | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 841 | match audit::run(route.op, &services, &viewer, &input).await? { |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 842 | // A download is a redirect to its signed link, as GitHub's is. |
| 843 | Outcome::Ok(value) if route.op == operations::Op::Artifacts(artifacts::ArtifactsOp::DownloadArtifact) => { | |
| 844 | match value["url"].as_str().and_then(|url| worker::Url::parse(url).ok()) { | |
| 845 | Some(url) => Response::redirect_with_status(url, 302), | |
| 846 | None => reply(&value), | |
| 847 | } | |
| 848 | } | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 849 | // A deleted comment has nothing to say, as GitHub's says nothing. |
| 850 | Outcome::Ok(_) if route.no_content() => Ok(Response::empty()?.with_status(204)), | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 851 | Outcome::Ok(value) => reply(&value), |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 852 | // A token without the scope a call needs is told which one. |
| 853 | Outcome::Fail(refused) => match (refused.code, audit::missing_scope(route.op, &viewer, &input)) { | |
| 854 | (FailureCode::Forbidden, Some(scope)) => Ok(reply(&json!({ | |
| 855 | "error": { | |
| 856 | "code": refused.code, | |
| 857 | "message": refused.message, | |
| 858 | "needed_scope": scope.as_str(), | |
| 859 | } | |
| 860 | }))? | |
| 861 | .with_status(403)), | |
| 862 | _ => failure(&refused), | |
| 863 | }, | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 864 | } |
| 865 | } | |
| 866 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 867 | /// Request bodies take the same keys as the MCP tools, `snake_case`, as |
| 868 | /// responses use; the `camelCase` spelling is accepted too. | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 869 | fn snake_case_keys(body: Value) -> Value { |
| 870 | let Value::Object(fields) = body else { | |
| 871 | return body; | |
| 872 | }; | |
| 873 | let mut out = serde_json::Map::new(); | |
| 874 | for (key, value) in fields { | |
| 875 | let mut snake = String::with_capacity(key.len() + 4); | |
| 876 | for c in key.chars() { | |
| 877 | if c.is_ascii_uppercase() { | |
| 878 | snake.push('_'); | |
| 879 | snake.push(c.to_ascii_lowercase()); | |
| 880 | } else { | |
| 881 | snake.push(c); | |
| 882 | } | |
| 883 | } | |
| 884 | // A key given in both spellings keeps the snake_case one. | |
| 885 | if snake != key && out.contains_key(&snake) { | |
| 886 | continue; | |
| 887 | } | |
| 888 | out.insert(snake, value); | |
| 889 | } | |
| 890 | Value::Object(out) | |
| 891 | } | |
| 892 | ||
| 893 | #[cfg(test)] | |
| 894 | mod tests { | |
| 895 | use super::snake_case_keys; | |
| 896 | use serde_json::json; | |
| 897 | ||
| 898 | #[test] | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 899 | fn an_unconfirmed_account_may_only_see_itself_and_confirm_its_address() { |
| 900 | assert!(super::pending_may("GET", "/user", false)); | |
| 901 | assert!(super::pending_may("GET", "/user/emails/", false)); | |
| 902 | assert!(super::pending_may("POST", "/user/emails/confirm", false)); | |
| 903 | assert!(!super::pending_may("POST", "/user/emails", false)); | |
| 904 | assert!(!super::pending_may("POST", "/workspaces", false)); | |
| 905 | assert!(!super::pending_may("GET", "/repos/acme/rocket", false)); | |
| 906 | assert!(!super::pending_may("POST", "/user/emails/confirm", true)); | |
| 907 | assert!(!super::pending_may("POST", "/", true)); | |
| 908 | } | |
| 909 | ||
| 910 | #[test] | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 911 | fn a_job_spec_gets_the_toolkits_variables() { |
| 912 | // As the actions service sends it, converted as the API does. | |
| 913 | let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } }); | |
| 914 | let mut spec = g1t_kit::wire::snake_case_keeping(sent.clone(), super::JOB_SPEC_AS_GIVEN); | |
| 915 | super::with_runtime(&mut spec, "https://api.g1t.sh", true); | |
| 916 | assert!(spec.get("runtime").is_none(), "the runner never sees it"); | |
| 917 | let vars = &spec["variables"]; | |
| 918 | assert_eq!(vars["GITHUB_SHA"], "abc"); | |
| 919 | assert_eq!(vars["ACTIONS_RUNTIME_TOKEN"], "h.p.s"); | |
| 920 | assert_eq!(vars["ACTIONS_CACHE_URL"], "https://api.g1t.sh/actions/toolkit/"); | |
| 921 | assert_eq!(vars["ACTIONS_ID_TOKEN_REQUEST_TOKEN"], "h.p.s"); | |
| 922 | // No OIDC key here: no OIDC variables, whatever the job may do. | |
| 923 | let mut spec = g1t_kit::wire::snake_case_keeping(sent, super::JOB_SPEC_AS_GIVEN); | |
| 924 | super::with_runtime(&mut spec, "https://api.g1t.sh", false); | |
| 925 | assert!(spec["variables"].get("ACTIONS_ID_TOKEN_REQUEST_URL").is_none()); | |
| 926 | assert_eq!(spec["variables"]["ACTIONS_RESULTS_URL"], "https://api.g1t.sh/"); | |
| 927 | } | |
| 928 | ||
| 929 | #[test] | |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 930 | fn camel_case_keys_are_accepted() { |
| 931 | assert_eq!( | |
| 932 | snake_case_keys(json!({ "countAgentApprovals": false, "title": "x" })), | |
| 933 | json!({ "count_agent_approvals": false, "title": "x" }) | |
| 934 | ); | |
| 935 | } | |
| 936 | ||
| 937 | #[test] | |
| 938 | fn snake_case_wins_when_both_are_given() { | |
| 939 | assert_eq!( | |
| 940 | snake_case_keys(json!({ "keep_issue_open": true, "keepIssueOpen": false })), | |
| 941 | json!({ "keep_issue_open": true }) | |
| 942 | ); | |
| 943 | } | |
| 944 | } | |
| 945 | ||
| 946 | // The API is called from browsers too: the reference's explorer, and apps | |
| 947 | // built on g1t. It carries no cookies, so any origin may call it. | |
| 948 | #[event(fetch)] | |
| 949 | async fn fetch(request: Request, env: Env, _ctx: Context) -> Result<Response> { | |
| 950 | let mut response = respond(request, &env).await?; | |
| 951 | let headers = response.headers_mut(); | |
| 952 | headers.set("access-control-allow-origin", "*")?; | |
| 953 | headers.set( | |
| 954 | "access-control-allow-headers", | |
| 955 | "authorization, content-type", | |
| 956 | )?; | |
| 957 | headers.set("access-control-allow-methods", "GET, POST, PATCH, OPTIONS")?; | |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 958 | headers.set("access-control-expose-headers", "www-authenticate, retry-after")?; |
| Keep g1t's own runs off the model proxy until it holds g1t's key | 959 | Ok(response) |
| 960 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.