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