| 1 | //! Daytona cloud-agent dispatch contract. |
| 2 | //! |
| 3 | //! Local `cw` / Codewhale may propose sending a coding agent to Daytona so |
| 4 | //! heavy work can raise a branch and open a PR while the TUI stays responsive. |
| 5 | //! This module is the only first-class offload seam: |
| 6 | //! |
| 7 | //! - remotes are explicit forges: `github`, `cnb`, `gitee` |
| 8 | //! - CWC's convention is preserved: a remote *named* `github` is authoritative |
| 9 | //! GitHub; `origin` is classified by URL and is often the CNB mirror |
| 10 | //! - confirmation is required; nothing spends or pushes silently |
| 11 | //! - missing Daytona credentials fail closed; success is never faked |
| 12 | //! |
| 13 | //! The engine that drives a confirmed job end to end (sandbox → harness → |
| 14 | //! forge PR → teardown) lives in [`crate::dispatch_runner`]; this module owns |
| 15 | //! the persisted contract, the launcher seam, and the fail-closed gates. |
| 16 | //! Auto-decide heuristics remain leftover: Codewhale may propose, never |
| 17 | //! confirm itself. |
| 18 | //! |
| 19 | //! A sandbox create receipt is infrastructure identity, not Computer |
| 20 | //! entitlement. Metering requires [`crate::computer_meter`] admission plus a |
| 21 | //! provider-accepted active observation. |
| 22 | |
| 23 | use std::fs; |
| 24 | use std::io::Write; |
| 25 | use std::path::{Path, PathBuf}; |
| 26 | use std::process::Command; |
| 27 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 28 | |
| 29 | use anyhow::{Context, Result, anyhow, bail}; |
| 30 | use codewhale_paths::codewhale_home; |
| 31 | use codewhale_secrets::Secrets; |
| 32 | use serde::{Deserialize, Serialize}; |
| 33 | |
| 34 | use crate::computer_meter::{ |
| 35 | ComputerAdmission, ComputerMeterError, ComputerMeterReceipt, ProviderObservation, |
| 36 | issue_computer_meter_receipt, |
| 37 | }; |
| 38 | |
| 39 | const MAX_PROMPT_CHARS: usize = 4_000; |
| 40 | const MAX_REMOTE_BYTES: usize = 4 * 1024; |
| 41 | const JOB_KIND: &str = "cloud"; |
| 42 | const DAYTONA_API_KEY_ENV: &str = "DAYTONA_API_KEY"; |
| 43 | const DAYTONA_API_URL_ENV: &str = "DAYTONA_API_URL"; |
| 44 | const CWC_DAYTONA_TOKEN_ENV: &str = "CWC_DAYTONA_TOKEN"; |
| 45 | const CWC_DAYTONA_ENDPOINT_ENV: &str = "CWC_DAYTONA_ENDPOINT"; |
| 46 | const KEYRING_SLOT: &str = "daytona"; |
| 47 | const DEFAULT_DAYTONA_API: &str = "https://app.daytona.io/api"; |
| 48 | /// Path inside the sandbox where the target repository is cloned. |
| 49 | pub const SANDBOX_WORKSPACE: &str = "/workspace"; |
| 50 | const MAX_HARNESS_OUTPUT_CHARS: usize = 200_000; |
| 51 | const READY_POLL_ATTEMPTS: u32 = 40; |
| 52 | const READY_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(3); |
| 53 | /// Sandbox label key carrying the cloud job id (see |
| 54 | /// [`LiveDaytonaLauncher::create_sandbox`]); the reconciler joins sandbox |
| 55 | /// labels back to job records through it. |
| 56 | pub const SANDBOX_JOB_LABEL: &str = "codewhale.job"; |
| 57 | /// Sandbox label marking Codewhale dispatch sandboxes — the product tag the |
| 58 | /// label reconciler filters the provider's sandbox list on. |
| 59 | pub const SANDBOX_PRODUCT_LABEL: &str = "codewhale.product"; |
| 60 | pub const SANDBOX_PRODUCT_VALUE: &str = "dispatch"; |
| 61 | /// The only environment variable that carries the Codewhale account machine |
| 62 | /// token (`cwc_key_…`) into the sandbox, so the preinstalled `codewhale` |
| 63 | /// authenticates as the dispatching account. Same name the CLI's CI path |
| 64 | /// reads — one token, one meaning, every host. |
| 65 | pub const CLOUD_AGENT_TOKEN_ENV: &str = "CODEWHALE_API_KEY"; |
| 66 | /// Operator override for the cloud-agent snapshot name. |
| 67 | pub const CLOUD_AGENT_SNAPSHOT_ENV: &str = "CODEWHALE_DISPATCH_SNAPSHOT"; |
| 68 | /// Default Daytona snapshot name. The image definition is maintained in |
| 69 | /// `computer/snapshots/cloud-agent/Dockerfile`; its pinned Engine version and |
| 70 | /// credential/acceptance limitations are documented beside it. |
| 71 | pub const DEFAULT_CLOUD_AGENT_SNAPSHOT: &str = "codewhale-cloud-agent"; |
| 72 | /// Active jobs older than this are stale. The declared harness budget for |
| 73 | /// one cloud-agent turn is an hour (`HARNESS_TIMEOUT_SECS`), so an active |
| 74 | /// record with no terminal state after 90 minutes (harness budget plus |
| 75 | /// control-plane slack) cannot be a healthy run — its runner is gone (TUI |
| 76 | /// quit, crash, killed process) and the record is the only witness. The |
| 77 | /// sweep fails such records and tears their sandboxes down. |
| 78 | pub const STALE_ACTIVE_JOB_SECS: u64 = 90 * 60; |
| 79 | |
| 80 | /// Explicit PR forge. Never inferred from a generic "origin means GitHub" rule. |
| 81 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 82 | #[serde(rename_all = "lowercase")] |
| 83 | pub enum Forge { |
| 84 | Github, |
| 85 | Cnb, |
| 86 | Gitee, |
| 87 | } |
| 88 | |
| 89 | impl Forge { |
| 90 | /// Stable CLI / TUI slug. |
| 91 | pub fn as_str(self) -> &'static str { |
| 92 | match self { |
| 93 | Self::Github => "github", |
| 94 | Self::Cnb => "cnb", |
| 95 | Self::Gitee => "gitee", |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// Parse a user-supplied forge slug. |
| 100 | pub fn parse(value: &str) -> Option<Self> { |
| 101 | match value.trim().to_ascii_lowercase().as_str() { |
| 102 | "github" | "gh" => Some(Self::Github), |
| 103 | "cnb" => Some(Self::Cnb), |
| 104 | "gitee" => Some(Self::Gitee), |
| 105 | _ => None, |
| 106 | } |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// One `git remote` row after fetch/push duplicates are collapsed. |
| 111 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 112 | pub struct GitRemote { |
| 113 | pub name: String, |
| 114 | pub url: String, |
| 115 | } |
| 116 | |
| 117 | /// A remote that has been classified as a supported forge. |
| 118 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 119 | pub struct SelectedRemote { |
| 120 | pub forge: Forge, |
| 121 | pub name: String, |
| 122 | pub url: String, |
| 123 | } |
| 124 | |
| 125 | /// Where a Daytona API key was found. Never carries the secret. |
| 126 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 127 | pub enum CredentialSource { |
| 128 | Env, |
| 129 | CwcEnv, |
| 130 | Keyring, |
| 131 | } |
| 132 | |
| 133 | /// Presence of Daytona credentials. Absence is fail-closed. |
| 134 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 135 | pub enum CredentialState { |
| 136 | Missing, |
| 137 | Present { source: CredentialSource }, |
| 138 | } |
| 139 | |
| 140 | /// First-class cloud job lifecycle. `kind` is always `cloud`. |
| 141 | /// |
| 142 | /// The runner path is `Proposed` (queued for an explicit confirm) → |
| 143 | /// `Launching` → `Running` (harness turn in the sandbox) → `OpeningPr` → |
| 144 | /// `Done`, with `Failed` / `Canceled` reachable from every active state and |
| 145 | /// `Refused` reserved for the fail-closed membership/credential gate. |
| 146 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 147 | #[serde(rename_all = "lowercase")] |
| 148 | pub enum CloudJobStatus { |
| 149 | Proposed, |
| 150 | Refused, |
| 151 | Launching, |
| 152 | Running, |
| 153 | #[serde(rename = "openingpr")] |
| 154 | OpeningPr, |
| 155 | Done, |
| 156 | Failed, |
| 157 | Canceled, |
| 158 | } |
| 159 | |
| 160 | /// Durable cloud job record, listed on the same `/jobs` surface as Bash jobs. |
| 161 | /// |
| 162 | /// Fields added after the first landing carry `#[serde(default)]` so job |
| 163 | /// records written by earlier builds still load. |
| 164 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 165 | pub struct CloudJob { |
| 166 | pub id: String, |
| 167 | pub kind: String, |
| 168 | pub status: CloudJobStatus, |
| 169 | pub prompt: String, |
| 170 | pub forge: Forge, |
| 171 | pub remote_name: String, |
| 172 | pub remote_url: String, |
| 173 | pub branch: String, |
| 174 | pub confirmed: bool, |
| 175 | pub sandbox_id: Option<String>, |
| 176 | pub pr_url: Option<String>, |
| 177 | pub refusal: Option<String>, |
| 178 | pub note: String, |
| 179 | pub created_unix: u64, |
| 180 | /// Default branch of the agent's clone (the PR base), when known. |
| 181 | #[serde(default)] |
| 182 | pub base_branch: Option<String>, |
| 183 | /// Head commit the agent produced, when known. |
| 184 | #[serde(default)] |
| 185 | pub head_sha: Option<String>, |
| 186 | /// One-line truthful summary of what the agent did, when reported. |
| 187 | #[serde(default)] |
| 188 | pub agent_summary: Option<String>, |
| 189 | /// When the job reached a terminal state (`done`/`failed`/`canceled`). |
| 190 | #[serde(default)] |
| 191 | pub finished_unix: Option<u64>, |
| 192 | /// Intent record: a sandbox create POST is in flight for this job. Set |
| 193 | /// and persisted *before* the POST so that a create whose response is |
| 194 | /// slow (client timeout, process death) is still reconcilable by |
| 195 | /// sandbox label even though the sandbox id never arrived. Cleared once |
| 196 | /// the id is recorded. |
| 197 | #[serde(default)] |
| 198 | pub sandbox_pending: bool, |
| 199 | } |
| 200 | |
| 201 | /// Validated plan that still requires an explicit confirm to spend or push. |
| 202 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 203 | pub struct DispatchPlan { |
| 204 | pub prompt: String, |
| 205 | pub remote: SelectedRemote, |
| 206 | pub branch: String, |
| 207 | } |
| 208 | |
| 209 | /// Result of proposing or confirming a dispatch. |
| 210 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 211 | pub enum DispatchOutcome { |
| 212 | Proposal(CloudJob), |
| 213 | Refused(CloudJob), |
| 214 | Accepted(CloudJob), |
| 215 | } |
| 216 | |
| 217 | /// Why a plan or launch cannot proceed. |
| 218 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 219 | pub enum DispatchError { |
| 220 | EmptyPrompt, |
| 221 | PromptTooLong, |
| 222 | InvalidBranch, |
| 223 | UnknownForge, |
| 224 | AmbiguousRemote, |
| 225 | RemoteMissing(Forge), |
| 226 | NoSupportedRemote, |
| 227 | UnsafeRemote, |
| 228 | } |
| 229 | |
| 230 | impl std::fmt::Display for DispatchError { |
| 231 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 232 | match self { |
| 233 | Self::EmptyPrompt => write!(f, "A cloud dispatch needs a non-empty task prompt."), |
| 234 | Self::PromptTooLong => write!( |
| 235 | f, |
| 236 | "A cloud dispatch prompt must be at most {MAX_PROMPT_CHARS} characters." |
| 237 | ), |
| 238 | Self::InvalidBranch => write!( |
| 239 | f, |
| 240 | "The requested branch is not a safe git ref (no leading '-', no '..', no shell metacharacters)." |
| 241 | ), |
| 242 | Self::UnknownForge => write!( |
| 243 | f, |
| 244 | "Remote must be one of github, cnb, or gitee. CWC treats the `github` remote as GitHub and `origin` as the CNB mirror when that URL is cnb.cool." |
| 245 | ), |
| 246 | Self::AmbiguousRemote => write!( |
| 247 | f, |
| 248 | "This workspace has more than one forge remote. Pass --remote github|cnb|gitee (CWC: `github` is GitHub, `origin` is often CNB)." |
| 249 | ), |
| 250 | Self::RemoteMissing(forge) => write!( |
| 251 | f, |
| 252 | "No {0} remote is configured. Add a `{0}` remote or a URL on {1}.", |
| 253 | forge.as_str(), |
| 254 | forge_host(*forge) |
| 255 | ), |
| 256 | Self::NoSupportedRemote => write!( |
| 257 | f, |
| 258 | "No GitHub, CNB, or Gitee remote was found. Remotes are classified by name (`github`/`cnb`/`gitee`) then by host." |
| 259 | ), |
| 260 | Self::UnsafeRemote => write!( |
| 261 | f, |
| 262 | "The selected remote URL is unsafe (leading-dash, embedded userinfo, or a non-forge host). Refusing to clone or show it." |
| 263 | ), |
| 264 | } |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | impl std::error::Error for DispatchError {} |
| 269 | |
| 270 | /// File-backed store under `$CODEWHALE_HOME/cloud-jobs`. |
| 271 | #[derive(Debug, Clone)] |
| 272 | pub struct CloudJobStore { |
| 273 | root: PathBuf, |
| 274 | } |
| 275 | |
| 276 | impl CloudJobStore { |
| 277 | /// Resolve the process Codewhale home. |
| 278 | pub fn from_env() -> Result<Self> { |
| 279 | let home = codewhale_home() |
| 280 | .map_err(|err| anyhow!(err.to_string()))? |
| 281 | .ok_or_else(|| anyhow!("CODEWHALE_HOME / user home is unavailable"))?; |
| 282 | Ok(Self::from_path(home.join("cloud-jobs"))) |
| 283 | } |
| 284 | |
| 285 | /// Test and injected-root constructor. |
| 286 | pub fn from_path(root: PathBuf) -> Self { |
| 287 | Self { root } |
| 288 | } |
| 289 | |
| 290 | /// Persist a job atomically. Never writes credentials. |
| 291 | pub fn save(&self, job: &CloudJob) -> Result<()> { |
| 292 | fs::create_dir_all(&self.root).context("failed to create cloud-jobs directory")?; |
| 293 | let path = self.job_path(&job.id)?; |
| 294 | let tmp = path.with_extension("json.tmp"); |
| 295 | let body = serde_json::to_vec_pretty(job).context("failed to encode cloud job")?; |
| 296 | { |
| 297 | let mut file = |
| 298 | fs::File::create(&tmp).context("failed to start a private cloud job write")?; |
| 299 | file.write_all(&body) |
| 300 | .context("failed to write the cloud job record")?; |
| 301 | file.sync_all().ok(); |
| 302 | } |
| 303 | #[cfg(unix)] |
| 304 | { |
| 305 | use std::os::unix::fs::PermissionsExt; |
| 306 | let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600)); |
| 307 | } |
| 308 | fs::rename(&tmp, &path).context("failed to commit the cloud job record")?; |
| 309 | Ok(()) |
| 310 | } |
| 311 | |
| 312 | /// Cancel-authoritative save: refuse to overwrite a `canceled` record. |
| 313 | /// |
| 314 | /// The runner does read-modify-write phase saves while `/dispatch |
| 315 | /// cancel` (or `--cancel`) can flip the record concurrently; an |
| 316 | /// unconditional phase save landing after the cancel would resurrect a |
| 317 | /// dead run — including its branch push and PR. Returns `Ok(false)` |
| 318 | /// (leaving the cancellation exactly as the user left it) when the |
| 319 | /// persisted record is already `canceled`, `Ok(true)` after a normal |
| 320 | /// save. |
| 321 | /// |
| 322 | /// This is load-check-save: the store is file-backed with no |
| 323 | /// cross-process lock, so the check cannot remove the load→save window |
| 324 | /// entirely — it narrows the clobber window from a whole phase (seconds |
| 325 | /// to minutes) to the span of one save, which is the single-writer |
| 326 | /// discipline this store assumes elsewhere. |
| 327 | pub fn save_unless_canceled(&self, job: &CloudJob) -> Result<bool> { |
| 328 | if let Ok(current) = self.load(&job.id) |
| 329 | && current.status == CloudJobStatus::Canceled |
| 330 | { |
| 331 | return Ok(false); |
| 332 | } |
| 333 | self.save(job)?; |
| 334 | Ok(true) |
| 335 | } |
| 336 | |
| 337 | /// Load one job by id. |
| 338 | pub fn load(&self, id: &str) -> Result<CloudJob> { |
| 339 | let path = self.job_path(id)?; |
| 340 | let body = fs::read(&path).with_context(|| format!("cloud job {id} was not found"))?; |
| 341 | serde_json::from_slice(&body).context("cloud job record is invalid JSON") |
| 342 | } |
| 343 | |
| 344 | /// Newest-first listing. |
| 345 | pub fn list(&self) -> Result<Vec<CloudJob>> { |
| 346 | if !self.root.exists() { |
| 347 | return Ok(Vec::new()); |
| 348 | } |
| 349 | let mut jobs = Vec::new(); |
| 350 | for entry in fs::read_dir(&self.root).context("failed to read cloud-jobs")? { |
| 351 | let entry = entry?; |
| 352 | let name = entry.file_name(); |
| 353 | let name = name.to_string_lossy(); |
| 354 | if !name.starts_with("cloud_") || !name.ends_with(".json") { |
| 355 | continue; |
| 356 | } |
| 357 | let body = fs::read(entry.path())?; |
| 358 | if let Ok(job) = serde_json::from_slice::<CloudJob>(&body) { |
| 359 | jobs.push(job); |
| 360 | } |
| 361 | } |
| 362 | jobs.sort_by_key(|a| std::cmp::Reverse(a.created_unix)); |
| 363 | Ok(jobs) |
| 364 | } |
| 365 | |
| 366 | fn job_path(&self, id: &str) -> Result<PathBuf> { |
| 367 | if !valid_job_id(id) { |
| 368 | bail!("cloud job id must look like cloud_<hex>"); |
| 369 | } |
| 370 | Ok(self.root.join(format!("{id}.json"))) |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | /// Classify a git remote. Named `github` / `cnb` / `gitee` win over URL. |
| 375 | pub fn classify_remote(name: &str, url: &str) -> Option<Forge> { |
| 376 | match name.trim().to_ascii_lowercase().as_str() { |
| 377 | "github" => Some(Forge::Github), |
| 378 | "cnb" => Some(Forge::Cnb), |
| 379 | "gitee" => Some(Forge::Gitee), |
| 380 | _ => classify_url(url), |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | /// True when `branch` is a forge default that a non-force push could |
| 385 | /// fast-forward past review (`main` / `master` / `HEAD`). |
| 386 | pub fn is_forge_default_branch(branch: &str) -> bool { |
| 387 | matches!( |
| 388 | branch.trim().to_ascii_lowercase().as_str(), |
| 389 | "main" | "master" | "head" |
| 390 | ) |
| 391 | } |
| 392 | |
| 393 | /// Whether a git remote is safe to clone, show, or hand to a sandbox. |
| 394 | /// |
| 395 | /// Rejects leading-dash injection (`--upload-pack=…`), embedded userinfo, |
| 396 | /// and network remotes that do not classify as a supported forge. Local |
| 397 | /// path remotes (offline fixtures) are allowed when they do not start |
| 398 | /// with `-` and carry no userinfo. |
| 399 | pub fn safe_git_remote_url(raw: &str) -> bool { |
| 400 | validate_git_remote_url(raw).is_ok() |
| 401 | } |
| 402 | |
| 403 | /// Classify and validate `job.remote_url` before any `git clone` or |
| 404 | /// sandbox clone. Returns the trimmed URL on success. |
| 405 | pub fn validate_git_remote_url(raw: &str) -> Result<String> { |
| 406 | let url = raw.trim(); |
| 407 | if url.is_empty() || url.len() > MAX_REMOTE_BYTES { |
| 408 | bail!("remote url is empty or oversized"); |
| 409 | } |
| 410 | if url.starts_with('-') { |
| 411 | bail!("remote url must not start with '-'"); |
| 412 | } |
| 413 | if url.chars().any(char::is_control) { |
| 414 | bail!("remote url contains control characters"); |
| 415 | } |
| 416 | if remote_has_userinfo(url) { |
| 417 | bail!("remote url must not embed userinfo"); |
| 418 | } |
| 419 | if looks_like_network_git_url(url) && classify_url(url).is_none() { |
| 420 | bail!("remote url is not a supported forge"); |
| 421 | } |
| 422 | Ok(url.to_string()) |
| 423 | } |
| 424 | |
| 425 | /// Display form of a remote: userinfo is never printed. |
| 426 | pub fn redact_remote_url(raw: &str) -> String { |
| 427 | redact_url_userinfo(raw) |
| 428 | } |
| 429 | |
| 430 | fn remote_has_userinfo(url: &str) -> bool { |
| 431 | if let Ok(parsed) = reqwest::Url::parse(url) { |
| 432 | return !parsed.username().is_empty() || parsed.password().is_some(); |
| 433 | } |
| 434 | // scp-style `user:token@host:path` (plain `git@host:path` is identity, not a secret). |
| 435 | if let Some((userinfo, _host)) = url.split_once('@') { |
| 436 | return userinfo.contains(':') && !userinfo.eq_ignore_ascii_case("git"); |
| 437 | } |
| 438 | false |
| 439 | } |
| 440 | |
| 441 | fn looks_like_network_git_url(url: &str) -> bool { |
| 442 | url.contains("://") || url.contains('@') |
| 443 | } |
| 444 | |
| 445 | /// Strip `user:token@` (and URL userinfo) from free-form text for notes. |
| 446 | pub fn redact_url_userinfo(text: &str) -> String { |
| 447 | if let Ok(parsed) = reqwest::Url::parse(text) |
| 448 | && (!parsed.username().is_empty() || parsed.password().is_some()) |
| 449 | { |
| 450 | let mut redacted = parsed; |
| 451 | let _ = redacted.set_username(""); |
| 452 | let _ = redacted.set_password(None); |
| 453 | return redacted.to_string(); |
| 454 | } |
| 455 | // scp-style or pasted `scheme://user:token@host`. |
| 456 | if let Some(scheme_end) = text.find("://") { |
| 457 | let rest = &text[scheme_end + 3..]; |
| 458 | if let Some(at) = rest.find('@') { |
| 459 | let userinfo = &rest[..at]; |
| 460 | if userinfo.contains(':') { |
| 461 | return format!("{}://[redacted]@{}", &text[..scheme_end], &rest[at + 1..]); |
| 462 | } |
| 463 | } |
| 464 | } else if let Some(at) = text.find('@') { |
| 465 | let userinfo = &text[..at]; |
| 466 | if userinfo.contains(':') && !userinfo.eq_ignore_ascii_case("git") { |
| 467 | return format!("[redacted]@{}", &text[at + 1..]); |
| 468 | } |
| 469 | } |
| 470 | text.to_string() |
| 471 | } |
| 472 | |
| 473 | /// Classify a clone URL by host. `origin` uses this path. |
| 474 | pub fn classify_url(url: &str) -> Option<Forge> { |
| 475 | let host = remote_host(url)?; |
| 476 | match host.as_str() { |
| 477 | "github.com" | "www.github.com" => Some(Forge::Github), |
| 478 | "cnb.cool" | "www.cnb.cool" => Some(Forge::Cnb), |
| 479 | "gitee.com" | "www.gitee.com" => Some(Forge::Gitee), |
| 480 | _ => None, |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | /// Parse `git remote -v` text into unique remotes (fetch URL preferred). |
| 485 | pub fn parse_remote_listing(text: &str) -> Vec<GitRemote> { |
| 486 | let mut remotes = Vec::new(); |
| 487 | for line in text.lines() { |
| 488 | let line = line.trim(); |
| 489 | if line.is_empty() || line.len() > MAX_REMOTE_BYTES { |
| 490 | continue; |
| 491 | } |
| 492 | let mut parts = line.split_whitespace(); |
| 493 | let Some(name) = parts.next() else { continue }; |
| 494 | let Some(url) = parts.next() else { continue }; |
| 495 | if name.is_empty() || url.is_empty() || name.chars().any(char::is_control) { |
| 496 | continue; |
| 497 | } |
| 498 | if remotes.iter().any(|remote: &GitRemote| remote.name == name) { |
| 499 | continue; |
| 500 | } |
| 501 | remotes.push(GitRemote { |
| 502 | name: name.to_string(), |
| 503 | url: url.to_string(), |
| 504 | }); |
| 505 | } |
| 506 | remotes |
| 507 | } |
| 508 | |
| 509 | /// Read remotes from a workspace. Missing git is an empty list, not a panic. |
| 510 | pub fn discover_remotes(workspace: &Path) -> Vec<GitRemote> { |
| 511 | let output = Command::new("git") |
| 512 | .args(["-C", &workspace.to_string_lossy(), "remote", "-v"]) |
| 513 | .output(); |
| 514 | match output { |
| 515 | Ok(output) if output.status.success() => { |
| 516 | parse_remote_listing(&String::from_utf8_lossy(&output.stdout)) |
| 517 | } |
| 518 | _ => Vec::new(), |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | /// Choose the forge remote. Multiple forges require an explicit request. |
| 523 | pub fn select_remote( |
| 524 | remotes: &[GitRemote], |
| 525 | requested: Option<Forge>, |
| 526 | ) -> Result<SelectedRemote, DispatchError> { |
| 527 | let classified: Vec<SelectedRemote> = remotes |
| 528 | .iter() |
| 529 | .filter_map(|remote| { |
| 530 | classify_remote(&remote.name, &remote.url).map(|forge| SelectedRemote { |
| 531 | forge, |
| 532 | name: remote.name.clone(), |
| 533 | url: remote.url.clone(), |
| 534 | }) |
| 535 | }) |
| 536 | .collect(); |
| 537 | |
| 538 | if let Some(forge) = requested { |
| 539 | return classified |
| 540 | .into_iter() |
| 541 | .find(|remote| remote.forge == forge) |
| 542 | .or_else(|| prefer_named(remotes, forge)) |
| 543 | .ok_or(DispatchError::RemoteMissing(forge)); |
| 544 | } |
| 545 | |
| 546 | let mut unique = Vec::new(); |
| 547 | for remote in classified { |
| 548 | if !unique |
| 549 | .iter() |
| 550 | .any(|seen: &SelectedRemote| seen.forge == remote.forge) |
| 551 | { |
| 552 | unique.push(remote); |
| 553 | } |
| 554 | } |
| 555 | match unique.len() { |
| 556 | 0 => Err(DispatchError::NoSupportedRemote), |
| 557 | 1 => Ok(unique.remove(0)), |
| 558 | _ => Err(DispatchError::AmbiguousRemote), |
| 559 | } |
| 560 | } |
| 561 | |
| 562 | /// Build a plan. Does not spend, push, or write a job. |
| 563 | pub fn plan_dispatch( |
| 564 | remotes: &[GitRemote], |
| 565 | prompt: &str, |
| 566 | requested: Option<Forge>, |
| 567 | branch: Option<&str>, |
| 568 | ) -> Result<DispatchPlan, DispatchError> { |
| 569 | let prompt = validate_prompt(prompt)?; |
| 570 | let remote = select_remote(remotes, requested)?; |
| 571 | if !safe_git_remote_url(&remote.url) { |
| 572 | return Err(DispatchError::UnsafeRemote); |
| 573 | } |
| 574 | let branch = match branch.map(str::trim).filter(|value| !value.is_empty()) { |
| 575 | Some(value) => { |
| 576 | if !valid_branch(value) { |
| 577 | return Err(DispatchError::InvalidBranch); |
| 578 | } |
| 579 | value.to_string() |
| 580 | } |
| 581 | None => default_branch(), |
| 582 | }; |
| 583 | Ok(DispatchPlan { |
| 584 | prompt, |
| 585 | remote, |
| 586 | branch, |
| 587 | }) |
| 588 | } |
| 589 | |
| 590 | /// Discover Daytona credentials without returning or logging the secret. |
| 591 | pub fn discover_credentials() -> CredentialState { |
| 592 | if env_present(DAYTONA_API_KEY_ENV) { |
| 593 | return CredentialState::Present { |
| 594 | source: CredentialSource::Env, |
| 595 | }; |
| 596 | } |
| 597 | if env_present(CWC_DAYTONA_TOKEN_ENV) { |
| 598 | return CredentialState::Present { |
| 599 | source: CredentialSource::CwcEnv, |
| 600 | }; |
| 601 | } |
| 602 | match Secrets::auto_detect().get(KEYRING_SLOT) { |
| 603 | Ok(Some(value)) if !value.trim().is_empty() => CredentialState::Present { |
| 604 | source: CredentialSource::Keyring, |
| 605 | }, |
| 606 | _ => CredentialState::Missing, |
| 607 | } |
| 608 | } |
| 609 | |
| 610 | /// Codewhale membership check. `/dispatch` cloud agents ship with the |
| 611 | /// account, so the fail-closed gate is sign-in — never a provider key. |
| 612 | fn membership_signed_in() -> bool { |
| 613 | let Ok(secrets) = codewhale_secrets::account::secure_account_session_secrets() else { |
| 614 | return false; |
| 615 | }; |
| 616 | let store = codewhale_secrets::account::AccountSessionStore::new( |
| 617 | secrets, |
| 618 | None, |
| 619 | codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE, |
| 620 | ); |
| 621 | matches!(store.load(), Ok(Some(_))) |
| 622 | } |
| 623 | |
| 624 | /// Auto-decide leftover: Codewhale may propose, but never confirm itself. |
| 625 | pub fn should_auto_confirm(_plan: &DispatchPlan) -> bool { |
| 626 | false |
| 627 | } |
| 628 | |
| 629 | /// Presence of the Codewhale account machine token that authenticates the |
| 630 | /// IN-SANDBOX agent. Mirrors [`CredentialState`]: a fact about availability, |
| 631 | /// never the value — the token is used in the create body and nothing else. |
| 632 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 633 | pub enum MachineTokenState { |
| 634 | Present, |
| 635 | Missing, |
| 636 | } |
| 637 | |
| 638 | /// Discover the account machine token without returning or logging it. |
| 639 | /// The sandbox's `codewhale` resolves the account's configured model and |
| 640 | /// raises the PR as this identity, so a dispatch without it would spend on |
| 641 | /// a sandbox whose agent cannot authenticate — confirm refuses instead. |
| 642 | pub fn discover_machine_token() -> MachineTokenState { |
| 643 | if read_cloud_agent_token().is_some() { |
| 644 | MachineTokenState::Present |
| 645 | } else { |
| 646 | MachineTokenState::Missing |
| 647 | } |
| 648 | } |
| 649 | |
| 650 | /// Truthful refusal for a confirmed dispatch with no account machine token. |
| 651 | /// Names the requirement and the fix, never a provider brand, never a secret. |
| 652 | pub fn missing_machine_token_message() -> String { |
| 653 | "cloud dispatch fails closed: the cloud agent runs Codewhale itself, so it \ |
| 654 | needs a Codewhale account machine token to act as your account. Set \ |
| 655 | CODEWHALE_API_KEY to a `cwc_key_…` machine key (Account → API keys in the \ |
| 656 | web app) and confirm again." |
| 657 | .to_string() |
| 658 | } |
| 659 | |
| 660 | /// Propose or launch. Confirmation and credentials are enforced here. |
| 661 | /// |
| 662 | /// With `confirm`, the job is persisted as `launching` and returned as |
| 663 | /// `Accepted`; the caller then starts the remote runner |
| 664 | /// ([`crate::dispatch_runner::run_confirmed_job`]) so this function stays |
| 665 | /// synchronous and offline-testable. |
| 666 | pub fn execute_dispatch( |
| 667 | store: &CloudJobStore, |
| 668 | plan: DispatchPlan, |
| 669 | confirm: bool, |
| 670 | credentials: &CredentialState, |
| 671 | machine_token: &MachineTokenState, |
| 672 | ) -> Result<DispatchOutcome> { |
| 673 | let mut job = CloudJob { |
| 674 | id: allocate_job_id(&plan), |
| 675 | kind: JOB_KIND.to_string(), |
| 676 | status: CloudJobStatus::Proposed, |
| 677 | prompt: plan.prompt.clone(), |
| 678 | forge: plan.remote.forge, |
| 679 | remote_name: plan.remote.name.clone(), |
| 680 | remote_url: plan.remote.url.clone(), |
| 681 | branch: plan.branch.clone(), |
| 682 | confirmed: false, |
| 683 | sandbox_id: None, |
| 684 | pr_url: None, |
| 685 | refusal: None, |
| 686 | note: proposal_note(&plan), |
| 687 | created_unix: unix_now(), |
| 688 | base_branch: None, |
| 689 | head_sha: None, |
| 690 | agent_summary: None, |
| 691 | finished_unix: None, |
| 692 | sandbox_pending: false, |
| 693 | }; |
| 694 | |
| 695 | if !confirm { |
| 696 | job.note = format!( |
| 697 | "{}. Confirm with `codewhale dispatch --confirm {}` or `/dispatch confirm {}`. Never silent spend, never silent push.", |
| 698 | job.note, job.id, job.id |
| 699 | ); |
| 700 | store.save(&job)?; |
| 701 | return Ok(DispatchOutcome::Proposal(job)); |
| 702 | } |
| 703 | |
| 704 | job.confirmed = true; |
| 705 | if matches!(credentials, CredentialState::Missing) { |
| 706 | job.status = CloudJobStatus::Refused; |
| 707 | job.refusal = Some(missing_credentials_message()); |
| 708 | job.note = missing_credentials_message(); |
| 709 | job.finished_unix = Some(unix_now()); |
| 710 | store.save(&job)?; |
| 711 | return Ok(DispatchOutcome::Refused(job)); |
| 712 | } |
| 713 | if matches!(machine_token, MachineTokenState::Missing) { |
| 714 | job.status = CloudJobStatus::Refused; |
| 715 | job.refusal = Some(missing_machine_token_message()); |
| 716 | job.note = missing_machine_token_message(); |
| 717 | job.finished_unix = Some(unix_now()); |
| 718 | store.save(&job)?; |
| 719 | return Ok(DispatchOutcome::Refused(job)); |
| 720 | } |
| 721 | |
| 722 | job.status = CloudJobStatus::Launching; |
| 723 | job.note = "Cloud agent confirmed; the sandbox is launching and the runner will raise the branch and open the PR. Watch `codewhale dispatch --show` or `/dispatch show`.".to_string(); |
| 724 | store.save(&job)?; |
| 725 | Ok(DispatchOutcome::Accepted(job)) |
| 726 | } |
| 727 | |
| 728 | /// Confirm a previously proposed job — in place, under the SAME id. |
| 729 | /// |
| 730 | /// The record is mutated (`proposed` → `launching`, `confirmed = true`) and |
| 731 | /// saved as itself; a second confirm finds `launching` and refuses. Routing |
| 732 | /// through `execute_dispatch` instead would allocate a fresh job id (its ids |
| 733 | /// hash the plan plus `unix_now()` at second granularity), leaving the |
| 734 | /// proposal re-confirmable without limit — every confirm another sandbox |
| 735 | /// and another PR. |
| 736 | pub fn confirm_job( |
| 737 | store: &CloudJobStore, |
| 738 | id: &str, |
| 739 | credentials: &CredentialState, |
| 740 | machine_token: &MachineTokenState, |
| 741 | ) -> Result<DispatchOutcome> { |
| 742 | let mut job = store.load(id)?; |
| 743 | if job.status != CloudJobStatus::Proposed { |
| 744 | bail!( |
| 745 | "Cloud job {} is {} and cannot be confirmed.", |
| 746 | job.id, |
| 747 | status_label(job.status) |
| 748 | ); |
| 749 | } |
| 750 | job.confirmed = true; |
| 751 | if matches!(credentials, CredentialState::Missing) { |
| 752 | job.status = CloudJobStatus::Refused; |
| 753 | job.refusal = Some(missing_credentials_message()); |
| 754 | job.note = missing_credentials_message(); |
| 755 | job.finished_unix = Some(unix_now()); |
| 756 | store.save(&job)?; |
| 757 | return Ok(DispatchOutcome::Refused(job)); |
| 758 | } |
| 759 | if matches!(machine_token, MachineTokenState::Missing) { |
| 760 | job.status = CloudJobStatus::Refused; |
| 761 | job.refusal = Some(missing_machine_token_message()); |
| 762 | job.note = missing_machine_token_message(); |
| 763 | job.finished_unix = Some(unix_now()); |
| 764 | store.save(&job)?; |
| 765 | return Ok(DispatchOutcome::Refused(job)); |
| 766 | } |
| 767 | |
| 768 | job.status = CloudJobStatus::Launching; |
| 769 | job.note = "Cloud agent confirmed; the sandbox is launching and the runner will raise the branch and open the PR. Watch `codewhale dispatch --show` or `/dispatch show`.".to_string(); |
| 770 | store.save(&job)?; |
| 771 | Ok(DispatchOutcome::Accepted(job)) |
| 772 | } |
| 773 | |
| 774 | /// True while the job may still hold a live sandbox. |
| 775 | pub fn job_is_active(status: CloudJobStatus) -> bool { |
| 776 | matches!( |
| 777 | status, |
| 778 | CloudJobStatus::Launching | CloudJobStatus::Running | CloudJobStatus::OpeningPr |
| 779 | ) |
| 780 | } |
| 781 | |
| 782 | /// Cancel a job, tearing down a live sandbox when one exists. |
| 783 | /// |
| 784 | /// The record flips to `canceled` first (so a concurrent runner step sees it), |
| 785 | /// then teardown runs best-effort through the launcher; a teardown failure is |
| 786 | /// reported in the note, never silently dropped. |
| 787 | pub fn cancel_job( |
| 788 | store: &CloudJobStore, |
| 789 | id: &str, |
| 790 | launcher: &dyn DaytonaLauncher, |
| 791 | ) -> Result<CloudJob> { |
| 792 | let mut job = store.load(id)?; |
| 793 | if matches!( |
| 794 | job.status, |
| 795 | CloudJobStatus::Canceled | CloudJobStatus::Failed | CloudJobStatus::Refused |
| 796 | ) { |
| 797 | return Ok(job); |
| 798 | } |
| 799 | let had_sandbox = job.sandbox_id.is_some(); |
| 800 | let sandbox_may_exist = had_sandbox || job.sandbox_pending; |
| 801 | job.status = CloudJobStatus::Canceled; |
| 802 | job.finished_unix = Some(unix_now()); |
| 803 | job.note = if sandbox_may_exist { |
| 804 | "Canceled locally; the cloud agent sandbox is being torn down.".to_string() |
| 805 | } else { |
| 806 | "Canceled locally before a sandbox was created.".to_string() |
| 807 | }; |
| 808 | store.save(&job)?; |
| 809 | if let Some(sandbox_id) = job.sandbox_id.clone() { |
| 810 | let receipt = SandboxReceipt { |
| 811 | sandbox_id, |
| 812 | toolbox_url: None, |
| 813 | }; |
| 814 | match launcher.teardown(&receipt) { |
| 815 | Ok(()) => { |
| 816 | job.note = "Canceled locally; the cloud agent sandbox was torn down.".to_string(); |
| 817 | } |
| 818 | Err(error) => { |
| 819 | job.note = format!( |
| 820 | "Canceled locally; sandbox teardown failed and may need a retry: {}", |
| 821 | sanitize_error(&error.to_string()) |
| 822 | ); |
| 823 | } |
| 824 | } |
| 825 | store.save(&job)?; |
| 826 | } |
| 827 | // A create whose POST landed but whose response never arrived leaves no |
| 828 | // recorded id (`sandbox_pending` with no `sandbox_id`). Best-effort |
| 829 | // label pass: delete any sandbox the provider still holds for this job. |
| 830 | if job.sandbox_pending && job.sandbox_id.is_none() { |
| 831 | match reconcile_job_sandboxes(launcher, &job.id) { |
| 832 | Ok(0) => {} |
| 833 | Ok(count) => { |
| 834 | job.note = format!( |
| 835 | "Canceled locally; {count} unrecorded cloud agent sandbox(es) labeled for this job were torn down." |
| 836 | ); |
| 837 | let _ = store.save(&job); |
| 838 | } |
| 839 | Err(error) => { |
| 840 | job.note = format!( |
| 841 | "{} Unrecorded sandbox reconcile failed and may need a retry: {}", |
| 842 | job.note, |
| 843 | sanitize_error(&error.to_string()) |
| 844 | ); |
| 845 | let _ = store.save(&job); |
| 846 | } |
| 847 | } |
| 848 | } |
| 849 | Ok(job) |
| 850 | } |
| 851 | |
| 852 | /// Outcome of one label-reconcile pass. |
| 853 | #[derive(Debug, Clone, PartialEq, Eq, Default)] |
| 854 | pub struct ReconcileReport { |
| 855 | /// Sandboxes deleted because their job is terminal or unknown. |
| 856 | pub deleted: Vec<String>, |
| 857 | /// Sandboxes left running because their job is still active. |
| 858 | pub live: usize, |
| 859 | } |
| 860 | |
| 861 | /// Delete dispatch-labeled sandboxes whose job no longer needs them. |
| 862 | /// |
| 863 | /// Every sandbox [`LiveDaytonaLauncher::create_sandbox`] makes is labeled |
| 864 | /// with its job id and the dispatch product tag, so the provider's sandbox |
| 865 | /// list can be joined back to the store even when a record died mid-create. |
| 866 | /// A sandbox is deletable when its job label is missing or invalid, its job |
| 867 | /// record is absent from the store, or that job is terminal (`refused`, |
| 868 | /// `failed`, `canceled`, `done`). Sandboxes for active jobs are left alone — |
| 869 | /// their runner owns them. One sandbox failing to delete never stops the |
| 870 | /// rest; the report records what actually happened. |
| 871 | pub fn reconcile_sandboxes( |
| 872 | store: &CloudJobStore, |
| 873 | launcher: &dyn DaytonaLauncher, |
| 874 | ) -> Result<ReconcileReport> { |
| 875 | let mut report = ReconcileReport::default(); |
| 876 | for sandbox in launcher.list_job_sandboxes()? { |
| 877 | let deletable = match sandbox |
| 878 | .job_id |
| 879 | .as_deref() |
| 880 | .filter(|job_id| valid_job_id(job_id)) |
| 881 | { |
| 882 | None => true, |
| 883 | Some(job_id) => match store.load(job_id) { |
| 884 | Err(_) => true, |
| 885 | Ok(job) => !job_is_active(job.status), |
| 886 | }, |
| 887 | }; |
| 888 | if !deletable { |
| 889 | report.live += 1; |
| 890 | continue; |
| 891 | } |
| 892 | let receipt = SandboxReceipt { |
| 893 | sandbox_id: sandbox.sandbox_id.clone(), |
| 894 | toolbox_url: None, |
| 895 | }; |
| 896 | if launcher.teardown(&receipt).is_ok() { |
| 897 | report.deleted.push(sandbox.sandbox_id); |
| 898 | } |
| 899 | } |
| 900 | Ok(report) |
| 901 | } |
| 902 | |
| 903 | /// Best-effort: delete every dispatch sandbox labeled for one job id. Used |
| 904 | /// by cancel when the create POST may have landed without a receipt. Same |
| 905 | /// failure rule as [`reconcile_sandboxes`]: a sandbox that cannot be |
| 906 | /// deleted is skipped, not fatal. |
| 907 | pub fn reconcile_job_sandboxes(launcher: &dyn DaytonaLauncher, job_id: &str) -> Result<usize> { |
| 908 | let mut deleted = 0; |
| 909 | for sandbox in launcher.list_job_sandboxes()? { |
| 910 | if sandbox.job_id.as_deref() != Some(job_id) { |
| 911 | continue; |
| 912 | } |
| 913 | let receipt = SandboxReceipt { |
| 914 | sandbox_id: sandbox.sandbox_id.clone(), |
| 915 | toolbox_url: None, |
| 916 | }; |
| 917 | if launcher.teardown(&receipt).is_ok() { |
| 918 | deleted += 1; |
| 919 | } |
| 920 | } |
| 921 | Ok(deleted) |
| 922 | } |
| 923 | |
| 924 | /// Startup sweep: fail stale active jobs and tear their sandboxes down. |
| 925 | /// |
| 926 | /// The TUI spawns the runner detached, so quitting the TUI (or crashing) |
| 927 | /// leaves a `launching`/`running`/`openingpr` record whose runner is gone — |
| 928 | /// nothing else reconciles it, and any sandbox it made bills forever. On |
| 929 | /// startup, every active job older than [`STALE_ACTIVE_JOB_SECS`] is marked |
| 930 | /// `failed` with a truthful note, its recorded sandbox is torn down, and the |
| 931 | /// record is saved *before* teardown so a crash mid-sweep still leaves a |
| 932 | /// terminal record the label reconciler will clean up after. Returns the |
| 933 | /// swept records (empty when the store is unreadable — never fatal). |
| 934 | pub fn sweep_stale_jobs( |
| 935 | store: &CloudJobStore, |
| 936 | launcher: &dyn DaytonaLauncher, |
| 937 | now_unix: u64, |
| 938 | ) -> Vec<CloudJob> { |
| 939 | let Ok(jobs) = store.list() else { |
| 940 | return Vec::new(); |
| 941 | }; |
| 942 | let mut swept = Vec::new(); |
| 943 | for mut job in jobs { |
| 944 | if !job_is_active(job.status) { |
| 945 | continue; |
| 946 | } |
| 947 | let age_secs = now_unix.saturating_sub(job.created_unix); |
| 948 | if age_secs < STALE_ACTIVE_JOB_SECS { |
| 949 | continue; |
| 950 | } |
| 951 | let sandbox_note = if job.sandbox_id.is_some() || job.sandbox_pending { |
| 952 | "Sandbox teardown was attempted; any sandbox left behind is deleted by the label reconciler on this startup." |
| 953 | } else { |
| 954 | "No sandbox was recorded for this job." |
| 955 | }; |
| 956 | job.status = CloudJobStatus::Failed; |
| 957 | job.finished_unix = Some(now_unix); |
| 958 | job.refusal = |
| 959 | Some("stale: the runner stopped without recording a terminal state".to_string()); |
| 960 | job.note = format!( |
| 961 | "Marked stale by the startup sweep: no terminal state for {} minutes and the declared harness budget is 60. {sandbox_note}", |
| 962 | age_secs / 60, |
| 963 | ); |
| 964 | // Persist the terminal record first: a crash mid-teardown must still |
| 965 | // leave a failed job the label reconciler can clean up. |
| 966 | if store.save(&job).is_ok() { |
| 967 | if let Some(sandbox_id) = job.sandbox_id.clone() { |
| 968 | let receipt = SandboxReceipt { |
| 969 | sandbox_id, |
| 970 | toolbox_url: None, |
| 971 | }; |
| 972 | let _ = launcher.teardown(&receipt); |
| 973 | } |
| 974 | swept.push(job); |
| 975 | } |
| 976 | } |
| 977 | swept |
| 978 | } |
| 979 | |
| 980 | /// Quit-path warning for the TUI: names live cloud jobs that quitting would |
| 981 | /// leave behind. `None` when no job is `launching`/`running`/`openingpr` or |
| 982 | /// the store cannot be read (a warning must never block the quit path). |
| 983 | pub fn live_job_quit_warning(store: &CloudJobStore) -> Option<String> { |
| 984 | let jobs = store.list().ok()?; |
| 985 | let live: Vec<&str> = jobs |
| 986 | .iter() |
| 987 | .filter(|job| job_is_active(job.status)) |
| 988 | .map(|job| job.id.as_str()) |
| 989 | .take(3) |
| 990 | .collect(); |
| 991 | if live.is_empty() { |
| 992 | return None; |
| 993 | } |
| 994 | Some(format!( |
| 995 | "Cloud job(s) {} still running: quitting now leaves the sandbox up until the next startup sweep reconciles it; /dispatch cancel <id> tears it down immediately.", |
| 996 | live.join(", ") |
| 997 | )) |
| 998 | } |
| 999 | |
| 1000 | /// Human list used by `/jobs` (cloud kind) and `codewhale dispatch --list`. |
| 1001 | pub fn format_job_list(jobs: &[CloudJob]) -> String { |
| 1002 | if jobs.is_empty() { |
| 1003 | return "Cloud jobs (0)\nNo cloud-agent jobs yet. Use `codewhale dispatch <prompt>` or `/dispatch <prompt>`.".to_string(); |
| 1004 | } |
| 1005 | let mut lines = vec![ |
| 1006 | format!("Cloud jobs ({}) kind=cloud", jobs.len()), |
| 1007 | "----------------------------------------".to_string(), |
| 1008 | ]; |
| 1009 | for job in jobs { |
| 1010 | lines.push(format!( |
| 1011 | "{} {:9} {} {} branch={}", |
| 1012 | job.id, |
| 1013 | status_label(job.status), |
| 1014 | job.forge.as_str(), |
| 1015 | job.remote_name, |
| 1016 | job.branch |
| 1017 | )); |
| 1018 | lines.push(format!(" prompt: {}", one_line(&job.prompt, 120))); |
| 1019 | if let Some(sandbox) = job.sandbox_id.as_ref() { |
| 1020 | lines.push(format!(" sandbox: {sandbox}")); |
| 1021 | } |
| 1022 | if let Some(pr) = job.pr_url.as_ref() { |
| 1023 | lines.push(format!(" pr: {pr}")); |
| 1024 | } |
| 1025 | if let Some(minutes) = runtime_minutes(job) { |
| 1026 | lines.push(format!(" runtime: {minutes}m")); |
| 1027 | } |
| 1028 | } |
| 1029 | lines.push( |
| 1030 | "Controls: /dispatch show <id>, /dispatch confirm <id>, /dispatch cancel <id>, /jobs list." |
| 1031 | .to_string(), |
| 1032 | ); |
| 1033 | lines.join("\n") |
| 1034 | } |
| 1035 | |
| 1036 | /// Whole minutes a terminal job was active, when internally known. This is |
| 1037 | /// local bookkeeping (created → finished), not a provider billing figure; |
| 1038 | /// sub-minute runs are omitted rather than rounded up. |
| 1039 | pub fn runtime_minutes(job: &CloudJob) -> Option<u64> { |
| 1040 | job.finished_unix |
| 1041 | .map(|end| end.saturating_sub(job.created_unix) / 60) |
| 1042 | .filter(|minutes| *minutes > 0) |
| 1043 | } |
| 1044 | |
| 1045 | /// Job inspector used by `/dispatch show` and `/jobs show cloud_*`. |
| 1046 | pub fn format_job(job: &CloudJob) -> String { |
| 1047 | let mut lines = vec![ |
| 1048 | format!("Cloud job {}", job.id), |
| 1049 | format!("Kind: {}", job.kind), |
| 1050 | format!("Status: {}", status_label(job.status)), |
| 1051 | format!("Forge: {}", job.forge.as_str()), |
| 1052 | format!( |
| 1053 | "Remote: {} {}", |
| 1054 | job.remote_name, |
| 1055 | redact_remote_url(&job.remote_url) |
| 1056 | ), |
| 1057 | format!("Branch: {}", job.branch), |
| 1058 | format!( |
| 1059 | "Base: {}", |
| 1060 | job.base_branch |
| 1061 | .as_deref() |
| 1062 | .unwrap_or("(detected at run time)") |
| 1063 | ), |
| 1064 | format!("Confirmed: {}", job.confirmed), |
| 1065 | format!("Sandbox: {}", job.sandbox_id.as_deref().unwrap_or("(none)")), |
| 1066 | format!("PR: {}", job.pr_url.as_deref().unwrap_or("(not opened)")), |
| 1067 | format!("Head: {}", job.head_sha.as_deref().unwrap_or("(pending)")), |
| 1068 | ]; |
| 1069 | if let Some(minutes) = runtime_minutes(job) { |
| 1070 | lines.push(format!( |
| 1071 | "Runtime: {minutes}m (Codewhale bookkeeping, not a bill)" |
| 1072 | )); |
| 1073 | } |
| 1074 | if let Some(summary) = job.agent_summary.as_deref() { |
| 1075 | lines.push(format!("Agent: {}", one_line(summary, 200))); |
| 1076 | } |
| 1077 | lines.push(format!("Prompt: {}", job.prompt)); |
| 1078 | lines.push(format!("Note: {}", job.note)); |
| 1079 | if let Some(refusal) = job.refusal.as_ref() { |
| 1080 | lines.push(format!("Refusal: {refusal}")); |
| 1081 | } |
| 1082 | lines.join("\n") |
| 1083 | } |
| 1084 | |
| 1085 | /// Status card for bare `/dispatch` and `codewhale dispatch --status`. |
| 1086 | /// |
| 1087 | /// `recent` is the newest slice of the job store; when the runner has |
| 1088 | /// receipts (sandbox id, PR URL, runtime) they are surfaced here verbatim. |
| 1089 | pub fn format_status( |
| 1090 | remotes: &[GitRemote], |
| 1091 | credentials: &CredentialState, |
| 1092 | recent: &[CloudJob], |
| 1093 | ) -> String { |
| 1094 | let mut lines = vec!["Codewhale cloud dispatch".to_string()]; |
| 1095 | match credentials { |
| 1096 | CredentialState::Missing => { |
| 1097 | if membership_signed_in() { |
| 1098 | lines.push( |
| 1099 | "Cloud agents are not available for this account yet; cloud dispatch fails closed (no sandbox, no push, no PR)." |
| 1100 | .to_string(), |
| 1101 | ); |
| 1102 | } else { |
| 1103 | lines.push( |
| 1104 | "Cloud agents are included with your Codewhale membership. Sign in with `codewhale login` to enable `/dispatch`; cloud dispatch fails closed until then (no sandbox, no push, no PR)." |
| 1105 | .to_string(), |
| 1106 | ); |
| 1107 | } |
| 1108 | } |
| 1109 | CredentialState::Present { .. } => { |
| 1110 | lines.push( |
| 1111 | "Cloud agents: ready (account-linked). Confirmation is still required before spend or push." |
| 1112 | .to_string(), |
| 1113 | ); |
| 1114 | } |
| 1115 | } |
| 1116 | if !recent.is_empty() { |
| 1117 | lines.push("Recent cloud jobs:".to_string()); |
| 1118 | for job in recent.iter().take(5) { |
| 1119 | let mut line = format!( |
| 1120 | " {} {} {}", |
| 1121 | job.id, |
| 1122 | status_label(job.status), |
| 1123 | one_line(&job.prompt, 60) |
| 1124 | ); |
| 1125 | if let Some(pr) = job.pr_url.as_deref() { |
| 1126 | line.push_str(&format!(" pr: {pr}")); |
| 1127 | } else if let Some(sandbox) = job.sandbox_id.as_deref() { |
| 1128 | line.push_str(&format!(" sandbox: {sandbox}")); |
| 1129 | } |
| 1130 | if let Some(minutes) = runtime_minutes(job) { |
| 1131 | line.push_str(&format!(" {minutes}m")); |
| 1132 | } |
| 1133 | lines.push(line); |
| 1134 | } |
| 1135 | } |
| 1136 | if remotes.is_empty() { |
| 1137 | lines.push("Remotes: none discovered.".to_string()); |
| 1138 | } else { |
| 1139 | lines.push("Remotes:".to_string()); |
| 1140 | for remote in remotes { |
| 1141 | let forge = classify_remote(&remote.name, &remote.url) |
| 1142 | .map(Forge::as_str) |
| 1143 | .unwrap_or("unsupported"); |
| 1144 | lines.push(format!( |
| 1145 | " {} {forge} {}", |
| 1146 | remote.name, |
| 1147 | redact_remote_url(&remote.url) |
| 1148 | )); |
| 1149 | } |
| 1150 | } |
| 1151 | lines.push( |
| 1152 | "Offload: `codewhale dispatch \"<prompt>\" --remote github|cnb|gitee` then `--confirm`, or `/dispatch <prompt>` then `/dispatch confirm <id>`." |
| 1153 | .to_string(), |
| 1154 | ); |
| 1155 | lines.join("\n") |
| 1156 | } |
| 1157 | |
| 1158 | /// Resolve the API origin used by the live launcher. Never logs credentials. |
| 1159 | pub fn daytona_api_url() -> String { |
| 1160 | std::env::var(DAYTONA_API_URL_ENV) |
| 1161 | .ok() |
| 1162 | .or_else(|| std::env::var(CWC_DAYTONA_ENDPOINT_ENV).ok()) |
| 1163 | .map(|value| value.trim().trim_end_matches('/').to_string()) |
| 1164 | .filter(|value| !value.is_empty()) |
| 1165 | .unwrap_or_else(|| DEFAULT_DAYTONA_API.to_string()) |
| 1166 | } |
| 1167 | |
| 1168 | /// Launch seam. Tests inject a recorder; production uses [`LiveDaytonaLauncher`]. |
| 1169 | /// |
| 1170 | /// The methods after `create_sandbox` default to "unsupported" so partial |
| 1171 | /// fixtures keep compiling; the real runner (and the recording tests) drive |
| 1172 | /// the full protocol. |
| 1173 | pub trait DaytonaLauncher { |
| 1174 | /// Create the sandbox and return its id plus the (validated) toolbox URL. |
| 1175 | fn create_sandbox(&self, job: &CloudJob) -> Result<SandboxReceipt>; |
| 1176 | /// Block until the sandbox accepts toolbox calls (bounded poll). |
| 1177 | fn wait_ready(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 1178 | Ok(()) |
| 1179 | } |
| 1180 | /// Clone the target forge repository inside the sandbox. |
| 1181 | fn clone_repository(&self, _receipt: &SandboxReceipt, _url: &str, _path: &str) -> Result<()> { |
| 1182 | bail!("this launcher does not support repository clones") |
| 1183 | } |
| 1184 | /// Run one harness command inside the sandbox and return bounded stdout. |
| 1185 | fn run_harness(&self, _receipt: &SandboxReceipt, _command: &HarnessCommand) -> Result<String> { |
| 1186 | bail!("this launcher does not support harness execution") |
| 1187 | } |
| 1188 | /// Collect the agent's work product from the sandbox. |
| 1189 | fn collect_patch(&self, _receipt: &SandboxReceipt) -> Result<PatchReceipt> { |
| 1190 | bail!("this launcher does not support patch collection") |
| 1191 | } |
| 1192 | /// Tear the sandbox down. Called on cancel, failure, and completion. |
| 1193 | fn teardown(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 1194 | bail!("this launcher does not support teardown") |
| 1195 | } |
| 1196 | /// List Codewhale-dispatch sandboxes with their job labels. Used by the |
| 1197 | /// reconciler (startup sweep and cancel); not part of the run protocol. |
| 1198 | fn list_job_sandboxes(&self) -> Result<Vec<LabeledSandbox>> { |
| 1199 | bail!("this launcher does not support sandbox listing") |
| 1200 | } |
| 1201 | } |
| 1202 | |
| 1203 | /// One dispatch-labeled sandbox discovered by the reconciler. |
| 1204 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1205 | pub struct LabeledSandbox { |
| 1206 | pub sandbox_id: String, |
| 1207 | /// Job id from the `codewhale.job` label, when the label is present. |
| 1208 | pub job_id: Option<String>, |
| 1209 | } |
| 1210 | |
| 1211 | /// Provider receipt for a created sandbox. `toolbox_url` is the validated |
| 1212 | /// per-sandbox toolbox origin returned by the create call, when present. |
| 1213 | /// `pr_url` is intentionally omitted from this slice. |
| 1214 | /// |
| 1215 | /// This is the infrastructure id of a created sandbox. It is not Computer |
| 1216 | /// entitlement and must not be treated as provider-accepted active seconds. |
| 1217 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1218 | pub struct SandboxReceipt { |
| 1219 | pub sandbox_id: String, |
| 1220 | pub toolbox_url: Option<String>, |
| 1221 | } |
| 1222 | |
| 1223 | /// One command the runner asks the sandbox to execute. `argv` is exact: the |
| 1224 | /// recording tests pin it so the live protocol cannot drift silently. |
| 1225 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1226 | pub struct HarnessCommand { |
| 1227 | pub argv: Vec<String>, |
| 1228 | pub cwd: String, |
| 1229 | pub timeout_secs: u32, |
| 1230 | } |
| 1231 | |
| 1232 | /// The agent's work product collected from the sandbox clone. |
| 1233 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 1234 | pub struct PatchReceipt { |
| 1235 | /// Base branch of the clone (the PR base), e.g. `main`. |
| 1236 | pub base_branch: String, |
| 1237 | /// Head commit the agent produced. |
| 1238 | pub head_sha: String, |
| 1239 | /// One-line truthful summary of the agent's commit. |
| 1240 | pub summary: String, |
| 1241 | /// `git format-patch --stdout` payload (bounded) of the agent's commits. |
| 1242 | pub patch: String, |
| 1243 | } |
| 1244 | |
| 1245 | /// Validate an outbound origin for credential-bearing HTTP calls. |
| 1246 | /// |
| 1247 | /// Rules: |
| 1248 | /// - `https` only for public hosts. |
| 1249 | /// - explicit loopback hosts (`localhost`, `127.0.0.1`, `::1`) are allowed |
| 1250 | /// only in debug builds, as the escape hatch for local smoke tests against |
| 1251 | /// a self-hosted sandbox service; release builds reject them outright. |
| 1252 | /// - the host must not be a private / link-local / reserved / multicast |
| 1253 | /// address or a `.local` / `.internal` name, and no userinfo may ride |
| 1254 | /// along. |
| 1255 | /// |
| 1256 | /// DNS-resolved rebinding is out of scope and documented as such. |
| 1257 | pub fn validate_outbound_origin(raw: &str) -> Result<reqwest::Url> { |
| 1258 | let trimmed = raw.trim(); |
| 1259 | if trimmed.is_empty() || trimmed.len() > MAX_REMOTE_BYTES { |
| 1260 | bail!("outbound origin is empty or oversized"); |
| 1261 | } |
| 1262 | let url = reqwest::Url::parse(trimmed).context("outbound origin is not a valid URL")?; |
| 1263 | if !matches!(url.scheme(), "http" | "https") { |
| 1264 | bail!("outbound origin must be http or https"); |
| 1265 | } |
| 1266 | if !url.username().is_empty() || url.password().is_some() { |
| 1267 | bail!("outbound origin must not embed credentials"); |
| 1268 | } |
| 1269 | let host = url |
| 1270 | .host_str() |
| 1271 | .context("outbound origin has no host")? |
| 1272 | .trim_end_matches('.') |
| 1273 | .to_ascii_lowercase(); |
| 1274 | // `Url::host_str` keeps IPv6 brackets; strip them for the checks below. |
| 1275 | let host = host |
| 1276 | .strip_prefix('[') |
| 1277 | .and_then(|inner| inner.strip_suffix(']')) |
| 1278 | .map(str::to_string) |
| 1279 | .unwrap_or(host); |
| 1280 | let loopback_name = host == "localhost" || host == "127.0.0.1" || host == "::1"; |
| 1281 | if loopback_name { |
| 1282 | if cfg!(debug_assertions) { |
| 1283 | return Ok(url); |
| 1284 | } |
| 1285 | bail!("loopback origins are not allowed in release builds"); |
| 1286 | } |
| 1287 | if host.ends_with(".local") || host.ends_with(".internal") { |
| 1288 | bail!("outbound origin must be a public service host"); |
| 1289 | } |
| 1290 | if let Ok(ip) = host.parse::<std::net::IpAddr>() { |
| 1291 | let blocked = match ip { |
| 1292 | std::net::IpAddr::V4(v4) => { |
| 1293 | let octets = v4.octets(); |
| 1294 | v4.is_loopback() |
| 1295 | || v4.is_private() |
| 1296 | || v4.is_link_local() |
| 1297 | || v4.is_unspecified() |
| 1298 | || v4.is_broadcast() |
| 1299 | || v4.is_multicast() |
| 1300 | || v4.is_documentation() |
| 1301 | // 100.64.0.0/10 (carrier-grade NAT, `is_shared` is |
| 1302 | // not stable yet) |
| 1303 | || (octets[0] == 100 && (octets[1] & 0b1100_0000) == 0b0100_0000) |
| 1304 | } |
| 1305 | std::net::IpAddr::V6(v6) => { |
| 1306 | if let Some(v4) = v6.to_ipv4_mapped() { |
| 1307 | let octets = v4.octets(); |
| 1308 | v4.is_loopback() |
| 1309 | || v4.is_private() |
| 1310 | || v4.is_link_local() |
| 1311 | || v4.is_unspecified() |
| 1312 | || v4.is_broadcast() |
| 1313 | || v4.is_multicast() |
| 1314 | || v4.is_documentation() |
| 1315 | || (octets[0] == 100 && (octets[1] & 0b1100_0000) == 0b0100_0000) |
| 1316 | } else { |
| 1317 | v6.is_loopback() |
| 1318 | || v6.is_unspecified() |
| 1319 | || v6.is_multicast() |
| 1320 | || (v6.segments()[0] & 0xfe00) == 0xfc00 |
| 1321 | || (v6.segments()[0] & 0xffc0) == 0xfe80 |
| 1322 | } |
| 1323 | } |
| 1324 | }; |
| 1325 | if blocked { |
| 1326 | bail!("outbound origin must not target a loopback, private, or reserved address"); |
| 1327 | } |
| 1328 | } |
| 1329 | if url.scheme() != "https" { |
| 1330 | bail!("outbound origin must use https"); |
| 1331 | } |
| 1332 | Ok(url) |
| 1333 | } |
| 1334 | |
| 1335 | /// Meter one closed interval on a dispatched cloud job. |
| 1336 | /// |
| 1337 | /// The job's sandbox id must match the provider observation. Wall-clock after |
| 1338 | /// create is not enough: the observation has to be provider-accepted active |
| 1339 | /// time bound to the immutable admission. |
| 1340 | pub fn meter_cloud_job( |
| 1341 | job: &CloudJob, |
| 1342 | admission: &ComputerAdmission, |
| 1343 | observation: ProviderObservation, |
| 1344 | ) -> Result<ComputerMeterReceipt, ComputerMeterError> { |
| 1345 | match job.sandbox_id.as_deref() { |
| 1346 | Some(sandbox_id) if sandbox_id == observation.provider_sandbox_id => { |
| 1347 | issue_computer_meter_receipt(admission, observation) |
| 1348 | } |
| 1349 | _ => Err(ComputerMeterError::AllocationMismatch { |
| 1350 | message: "Cloud job sandbox id does not match the provider allocation snapshot." |
| 1351 | .to_string(), |
| 1352 | }), |
| 1353 | } |
| 1354 | } |
| 1355 | |
| 1356 | /// Real Daytona HTTP launcher. Fails closed on every step; never invents a |
| 1357 | /// PR URL; never logs or returns the API key. |
| 1358 | /// |
| 1359 | /// API shape (pinned against the published OpenAPI specs, see |
| 1360 | /// docs/DAYTONA_CLOUD_DISPATCH.md): |
| 1361 | /// - control plane `POST /sandbox`, `GET /sandbox/{id}`, `DELETE /sandbox/{id}` |
| 1362 | /// - toolbox `{toolboxProxyUrl}/{sandboxId}` with `POST /git/clone` and |
| 1363 | /// `POST /process/execute` (`{command, cwd, timeout}` → `{exitCode, result}`) |
| 1364 | pub struct LiveDaytonaLauncher; |
| 1365 | |
| 1366 | impl LiveDaytonaLauncher { |
| 1367 | /// Total timeout for short control-plane calls (create/status/delete/ |
| 1368 | /// list). A dispatched harness turn is NOT a short call — see |
| 1369 | /// [`Self::harness_client_budget_secs`]. |
| 1370 | const CONTROL_PLANE_TIMEOUT_SECS: u64 = 120; |
| 1371 | |
| 1372 | /// Slack added to a harness command's declared budget for the client |
| 1373 | /// that carries it: process start, clone drift, and response transfer |
| 1374 | /// are not part of the declared turn budget, but the client must still |
| 1375 | /// cut off eventually so a hung execute cannot hold a runner forever. |
| 1376 | const HARNESS_CLIENT_SLACK_SECS: u64 = 120; |
| 1377 | |
| 1378 | fn blocking_client() -> Result<reqwest::blocking::Client> { |
| 1379 | // #6208: one client for the process. A `reqwest` client owns a |
| 1380 | // connection pool and a TLS configuration, so building one per call |
| 1381 | // paid a fresh TCP+TLS handshake on all nine call sites (one of them a |
| 1382 | // poll loop). `clone()` here is a refcount bump on that shared pool. |
| 1383 | // |
| 1384 | // The total timeout is attached per request instead, because a harness |
| 1385 | // turn carries a budget derived from its own command and must not |
| 1386 | // inherit the control-plane cap. |
| 1387 | static CLIENT: std::sync::OnceLock<Result<reqwest::blocking::Client, String>> = |
| 1388 | std::sync::OnceLock::new(); |
| 1389 | CLIENT |
| 1390 | .get_or_init(|| { |
| 1391 | crate::tls::reqwest_blocking_client_builder() |
| 1392 | .connect_timeout(std::time::Duration::from_secs(8)) |
| 1393 | .redirect(reqwest::redirect::Policy::none()) |
| 1394 | .build() |
| 1395 | .map_err(|error| error.to_string()) |
| 1396 | }) |
| 1397 | .clone() |
| 1398 | .map_err(|message| anyhow!("failed to initialize the cloud agent client: {message}")) |
| 1399 | } |
| 1400 | |
| 1401 | /// The total-timeout budget for a harness-carrying client, in seconds. |
| 1402 | /// Public to the crate so the runner's tests can pin it against the |
| 1403 | /// declared `HARNESS_TIMEOUT_SECS`. |
| 1404 | pub(crate) fn harness_client_budget_secs(command: &HarnessCommand) -> u64 { |
| 1405 | u64::from(command.timeout_secs).saturating_add(Self::HARNESS_CLIENT_SLACK_SECS) |
| 1406 | } |
| 1407 | |
| 1408 | fn api_key() -> Result<String> { |
| 1409 | read_api_key().ok_or_else(|| anyhow!(missing_credentials_message())) |
| 1410 | } |
| 1411 | |
| 1412 | /// Control-plane URL under the validated base. |
| 1413 | fn control_plane_url(path: &str) -> Result<reqwest::Url> { |
| 1414 | let base = validate_outbound_origin(&daytona_api_url())?; |
| 1415 | join_api_path(base, path).context("failed to build the cloud agent request URL") |
| 1416 | } |
| 1417 | |
| 1418 | /// Toolbox base for one sandbox: `{toolboxProxyUrl}/{sandboxId}`. |
| 1419 | fn toolbox_base(receipt: &SandboxReceipt) -> Result<reqwest::Url> { |
| 1420 | if !valid_sandbox_id(&receipt.sandbox_id) { |
| 1421 | bail!("the sandbox id is not a usable path token"); |
| 1422 | } |
| 1423 | let fallback = format!("{}/toolbox", DEFAULT_DAYTONA_API); |
| 1424 | let raw = receipt.toolbox_url.as_deref().unwrap_or(&fallback); |
| 1425 | let base = validate_outbound_origin(raw)?; |
| 1426 | join_api_path(base, &receipt.sandbox_id).context("failed to build the sandbox toolbox URL") |
| 1427 | } |
| 1428 | |
| 1429 | /// Apply the dispatch labels via Daytona's dedicated labels endpoint. |
| 1430 | fn put_sandbox_labels(sandbox_id: &str, api_key: &str, job: &CloudJob) -> Result<()> { |
| 1431 | if !valid_sandbox_id(sandbox_id) { |
| 1432 | bail!("the sandbox id is not a usable path token"); |
| 1433 | } |
| 1434 | let url = Self::control_plane_url(&format!("sandbox/{sandbox_id}/labels"))?; |
| 1435 | let body = serde_json::json!({ |
| 1436 | "labels": { |
| 1437 | SANDBOX_JOB_LABEL: job.id, |
| 1438 | "codewhale.forge": job.forge.as_str(), |
| 1439 | SANDBOX_PRODUCT_LABEL: SANDBOX_PRODUCT_VALUE, |
| 1440 | } |
| 1441 | }); |
| 1442 | let response = Self::send_json(reqwest::Method::PUT, &url, api_key, body)?; |
| 1443 | let status = response.status(); |
| 1444 | if status.is_success() { |
| 1445 | Ok(()) |
| 1446 | } else { |
| 1447 | bail!("cloud agent label apply failed (HTTP {status}).") |
| 1448 | } |
| 1449 | } |
| 1450 | |
| 1451 | fn send_json( |
| 1452 | method: reqwest::Method, |
| 1453 | url: &reqwest::Url, |
| 1454 | api_key: &str, |
| 1455 | body: serde_json::Value, |
| 1456 | ) -> Result<reqwest::blocking::Response> { |
| 1457 | Self::send_json_on( |
| 1458 | &Self::blocking_client()?, |
| 1459 | Self::CONTROL_PLANE_TIMEOUT_SECS, |
| 1460 | method, |
| 1461 | url, |
| 1462 | api_key, |
| 1463 | body, |
| 1464 | ) |
| 1465 | } |
| 1466 | |
| 1467 | /// [`Self::send_json`] with an explicit total timeout, so a call whose |
| 1468 | /// declared budget differs from the control-plane cap (the harness turn) |
| 1469 | /// can carry its own without needing a client of its own. |
| 1470 | fn send_json_on( |
| 1471 | client: &reqwest::blocking::Client, |
| 1472 | total_secs: u64, |
| 1473 | method: reqwest::Method, |
| 1474 | url: &reqwest::Url, |
| 1475 | api_key: &str, |
| 1476 | body: serde_json::Value, |
| 1477 | ) -> Result<reqwest::blocking::Response> { |
| 1478 | client |
| 1479 | .request(method, url.clone()) |
| 1480 | .timeout(std::time::Duration::from_secs(total_secs)) |
| 1481 | .bearer_auth(api_key) |
| 1482 | .json(&body) |
| 1483 | .send() |
| 1484 | .context("could not reach the cloud agent service") |
| 1485 | } |
| 1486 | } |
| 1487 | |
| 1488 | impl DaytonaLauncher for LiveDaytonaLauncher { |
| 1489 | fn create_sandbox(&self, job: &CloudJob) -> Result<SandboxReceipt> { |
| 1490 | let api_key = Self::api_key()?; |
| 1491 | let url = Self::control_plane_url("sandbox")?; |
| 1492 | let machine_token = |
| 1493 | read_cloud_agent_token().ok_or_else(|| anyhow!(missing_machine_token_message()))?; |
| 1494 | let body = create_sandbox_body(job, &machine_token, &cloud_agent_snapshot()); |
| 1495 | let response = Self::send_json(reqwest::Method::POST, &url, &api_key, body)?; |
| 1496 | let status = response.status(); |
| 1497 | let text = response.text().unwrap_or_default(); |
| 1498 | if !status.is_success() { |
| 1499 | bail!("Cloud agent create failed (HTTP {status})."); |
| 1500 | } |
| 1501 | let parsed: serde_json::Value = |
| 1502 | serde_json::from_str(&text).context("the cloud agent service returned invalid JSON")?; |
| 1503 | let sandbox_id = parsed |
| 1504 | .get("id") |
| 1505 | .or_else(|| parsed.get("sandboxId")) |
| 1506 | .and_then(serde_json::Value::as_str) |
| 1507 | .unwrap_or("") |
| 1508 | .trim() |
| 1509 | .to_string(); |
| 1510 | if !valid_sandbox_id(&sandbox_id) { |
| 1511 | // The provider says the sandbox exists (2xx) but gave us an id |
| 1512 | // we cannot safely interpolate into a path, so explicit teardown |
| 1513 | // is impossible. Best-effort delete with the raw string when it |
| 1514 | // is at least non-empty (the DELETE path itself validates and |
| 1515 | // will refuse dangerous shapes), and always name it in the |
| 1516 | // error so an operator can clean it up. |
| 1517 | let raw = parsed |
| 1518 | .get("id") |
| 1519 | .or_else(|| parsed.get("sandboxId")) |
| 1520 | .and_then(serde_json::Value::as_str) |
| 1521 | .unwrap_or("") |
| 1522 | .trim(); |
| 1523 | if !raw.is_empty() && valid_sandbox_id(raw) { |
| 1524 | let _ = Self::send_json( |
| 1525 | reqwest::Method::DELETE, |
| 1526 | &Self::control_plane_url(&format!("sandbox/{raw}"))?, |
| 1527 | &api_key, |
| 1528 | serde_json::Value::Null, |
| 1529 | ); |
| 1530 | } |
| 1531 | bail!( |
| 1532 | "Cloud agent create succeeded but returned no usable sandbox id (raw: \"{raw}\"). \ |
| 1533 | A sandbox may need manual cleanup at the provider." |
| 1534 | ); |
| 1535 | } |
| 1536 | let toolbox_url = parsed |
| 1537 | .get("toolboxProxyUrl") |
| 1538 | .and_then(serde_json::Value::as_str) |
| 1539 | .map(str::trim) |
| 1540 | .filter(|value| !value.is_empty() && value.len() <= MAX_REMOTE_BYTES) |
| 1541 | .and_then(|value| validate_outbound_origin(value).ok()) |
| 1542 | .map(|url| url.to_string()); |
| 1543 | // Daytona applies labels via a dedicated PUT, not the create body |
| 1544 | // (kept there for forward compatibility). Labels are load-bearing: |
| 1545 | // the orphan reconciler joins them back to job records, so a |
| 1546 | // sandbox without them is untraceable spend. If the PUT fails, the |
| 1547 | // already-created sandbox is torn down immediately and create |
| 1548 | // fails truthfully — no orphan, retryable — rather than returning |
| 1549 | // a receipt the reconciler can never find again. |
| 1550 | if let Err(label_error) = Self::put_sandbox_labels(&sandbox_id, &api_key, job) { |
| 1551 | let undo = Self::send_json( |
| 1552 | reqwest::Method::DELETE, |
| 1553 | &Self::control_plane_url(&format!("sandbox/{sandbox_id}"))?, |
| 1554 | &api_key, |
| 1555 | serde_json::Value::Null, |
| 1556 | ) |
| 1557 | .and_then(|response| { |
| 1558 | let status = response.status(); |
| 1559 | if status.is_success() || status.as_u16() == 404 { |
| 1560 | Ok(()) |
| 1561 | } else { |
| 1562 | bail!("HTTP {status}") |
| 1563 | } |
| 1564 | }); |
| 1565 | match undo { |
| 1566 | Ok(()) => bail!( |
| 1567 | "cloud agent created but its labels could not be applied ({}); \ |
| 1568 | the sandbox was torn down — retry the job.", |
| 1569 | sanitize_error(&label_error.to_string()) |
| 1570 | ), |
| 1571 | Err(undo_error) => bail!( |
| 1572 | "cloud agent {sandbox_id} was created but its labels could not be \ |
| 1573 | applied ({}) and teardown also failed ({}); the sandbox needs \ |
| 1574 | manual cleanup at the provider.", |
| 1575 | sanitize_error(&label_error.to_string()), |
| 1576 | sanitize_error(&undo_error.to_string()) |
| 1577 | ), |
| 1578 | } |
| 1579 | } |
| 1580 | Ok(SandboxReceipt { |
| 1581 | sandbox_id, |
| 1582 | toolbox_url, |
| 1583 | }) |
| 1584 | } |
| 1585 | |
| 1586 | fn wait_ready(&self, receipt: &SandboxReceipt) -> Result<()> { |
| 1587 | let api_key = Self::api_key()?; |
| 1588 | if !valid_sandbox_id(&receipt.sandbox_id) { |
| 1589 | bail!("the sandbox id is not a usable path token"); |
| 1590 | } |
| 1591 | let url = Self::control_plane_url(&format!("sandbox/{}", receipt.sandbox_id))?; |
| 1592 | for _ in 0..READY_POLL_ATTEMPTS { |
| 1593 | let response = Self::send_json( |
| 1594 | reqwest::Method::GET, |
| 1595 | &url, |
| 1596 | &api_key, |
| 1597 | serde_json::Value::Null, |
| 1598 | ); |
| 1599 | match response { |
| 1600 | Ok(response) if response.status().is_success() => { |
| 1601 | let text = response.text().unwrap_or_default(); |
| 1602 | if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) |
| 1603 | && let Some(state) = parsed.get("state").and_then(|v| v.as_str()) |
| 1604 | { |
| 1605 | match state { |
| 1606 | "started" | "ready" => return Ok(()), |
| 1607 | "error" | "destroyed" | "archived" => { |
| 1608 | bail!("Cloud agent sandbox entered state '{state}'."); |
| 1609 | } |
| 1610 | _ => {} |
| 1611 | } |
| 1612 | } else { |
| 1613 | return Ok(()); |
| 1614 | } |
| 1615 | } |
| 1616 | Err(error) => return Err(error), |
| 1617 | Ok(response) => { |
| 1618 | if response.status().as_u16() == 404 { |
| 1619 | bail!("Cloud agent sandbox disappeared before it was ready."); |
| 1620 | } |
| 1621 | } |
| 1622 | } |
| 1623 | std::thread::sleep(READY_POLL_INTERVAL); |
| 1624 | } |
| 1625 | bail!("Cloud agent sandbox was not ready in time."); |
| 1626 | } |
| 1627 | |
| 1628 | fn clone_repository(&self, receipt: &SandboxReceipt, repo_url: &str, path: &str) -> Result<()> { |
| 1629 | let repo_url = validate_git_remote_url(repo_url)?; |
| 1630 | let api_key = Self::api_key()?; |
| 1631 | let url = Self::toolbox_base(receipt)?.join("git/clone")?; |
| 1632 | let body = serde_json::json!({ "url": repo_url, "path": path }); |
| 1633 | let response = Self::send_json(reqwest::Method::POST, &url, &api_key, body)?; |
| 1634 | let status = response.status(); |
| 1635 | if !status.is_success() { |
| 1636 | bail!("Cloud agent repository clone failed (HTTP {status})."); |
| 1637 | } |
| 1638 | Ok(()) |
| 1639 | } |
| 1640 | |
| 1641 | fn run_harness(&self, receipt: &SandboxReceipt, command: &HarnessCommand) -> Result<String> { |
| 1642 | let api_key = Self::api_key()?; |
| 1643 | let url = Self::toolbox_base(receipt)?.join("process/execute")?; |
| 1644 | // The toolbox executes one shell command string, so every argv |
| 1645 | // element is POSIX-single-quoted — a prompt cannot interpolate. |
| 1646 | let body = serde_json::json!({ |
| 1647 | "command": shell_quote_join(&command.argv), |
| 1648 | "cwd": command.cwd, |
| 1649 | "timeout": command.timeout_secs, |
| 1650 | }); |
| 1651 | // This call carries the declared turn budget (an hour for the agent |
| 1652 | // entry), so it asks for that budget plus slack per request — never the |
| 1653 | // 120s control-plane cap that used to bound it. |
| 1654 | let response = Self::send_json_on( |
| 1655 | &Self::blocking_client()?, |
| 1656 | Self::harness_client_budget_secs(command), |
| 1657 | reqwest::Method::POST, |
| 1658 | &url, |
| 1659 | &api_key, |
| 1660 | body, |
| 1661 | )?; |
| 1662 | let status = response.status(); |
| 1663 | let text = response.text().unwrap_or_default(); |
| 1664 | if !status.is_success() { |
| 1665 | bail!("Cloud agent harness execution failed (HTTP {status})."); |
| 1666 | } |
| 1667 | let parsed: serde_json::Value = serde_json::from_str(&text) |
| 1668 | .context("the sandbox returned an unreadable harness result")?; |
| 1669 | let exit_code = parsed |
| 1670 | .get("exitCode") |
| 1671 | .and_then(serde_json::Value::as_i64) |
| 1672 | .unwrap_or(0); |
| 1673 | let result = parsed |
| 1674 | .get("result") |
| 1675 | .and_then(serde_json::Value::as_str) |
| 1676 | .unwrap_or(""); |
| 1677 | if exit_code != 0 { |
| 1678 | bail!( |
| 1679 | "Cloud agent harness exited with code {exit_code}: {}", |
| 1680 | sanitize_error(result) |
| 1681 | ); |
| 1682 | } |
| 1683 | Ok(result.chars().take(MAX_HARNESS_OUTPUT_CHARS).collect()) |
| 1684 | } |
| 1685 | |
| 1686 | fn collect_patch(&self, receipt: &SandboxReceipt) -> Result<PatchReceipt> { |
| 1687 | let base_branch = self |
| 1688 | .run_harness( |
| 1689 | receipt, |
| 1690 | &HarnessCommand { |
| 1691 | argv: vec![ |
| 1692 | "git".to_string(), |
| 1693 | "rev-parse".to_string(), |
| 1694 | "--abbrev-ref".to_string(), |
| 1695 | "origin/HEAD".to_string(), |
| 1696 | ], |
| 1697 | cwd: SANDBOX_WORKSPACE.to_string(), |
| 1698 | timeout_secs: 30, |
| 1699 | }, |
| 1700 | )? |
| 1701 | .trim() |
| 1702 | .trim_start_matches("origin/") |
| 1703 | .to_string(); |
| 1704 | let head_sha = self |
| 1705 | .run_harness( |
| 1706 | receipt, |
| 1707 | &HarnessCommand { |
| 1708 | argv: vec![ |
| 1709 | "git".to_string(), |
| 1710 | "rev-parse".to_string(), |
| 1711 | "HEAD".to_string(), |
| 1712 | ], |
| 1713 | cwd: SANDBOX_WORKSPACE.to_string(), |
| 1714 | timeout_secs: 30, |
| 1715 | }, |
| 1716 | )? |
| 1717 | .trim() |
| 1718 | .to_string(); |
| 1719 | let summary = self |
| 1720 | .run_harness( |
| 1721 | receipt, |
| 1722 | &HarnessCommand { |
| 1723 | argv: vec![ |
| 1724 | "git".to_string(), |
| 1725 | "log".to_string(), |
| 1726 | "-1".to_string(), |
| 1727 | "--format=%s".to_string(), |
| 1728 | ], |
| 1729 | cwd: SANDBOX_WORKSPACE.to_string(), |
| 1730 | timeout_secs: 30, |
| 1731 | }, |
| 1732 | )? |
| 1733 | .trim() |
| 1734 | .to_string(); |
| 1735 | let patch = self.run_harness( |
| 1736 | receipt, |
| 1737 | &HarnessCommand { |
| 1738 | argv: vec![ |
| 1739 | "git".to_string(), |
| 1740 | "format-patch".to_string(), |
| 1741 | "origin/HEAD..HEAD".to_string(), |
| 1742 | "--stdout".to_string(), |
| 1743 | ], |
| 1744 | cwd: SANDBOX_WORKSPACE.to_string(), |
| 1745 | timeout_secs: 60, |
| 1746 | }, |
| 1747 | )?; |
| 1748 | if base_branch.is_empty() || head_sha.len() < 7 { |
| 1749 | bail!("Cloud agent produced no branch head to raise."); |
| 1750 | } |
| 1751 | if patch.trim().is_empty() { |
| 1752 | bail!("Cloud agent produced an empty patch; refusing to open a PR."); |
| 1753 | } |
| 1754 | Ok(PatchReceipt { |
| 1755 | base_branch, |
| 1756 | head_sha, |
| 1757 | summary, |
| 1758 | patch, |
| 1759 | }) |
| 1760 | } |
| 1761 | |
| 1762 | fn teardown(&self, receipt: &SandboxReceipt) -> Result<()> { |
| 1763 | let api_key = Self::api_key()?; |
| 1764 | if !valid_sandbox_id(&receipt.sandbox_id) { |
| 1765 | bail!("the sandbox id is not a usable path token"); |
| 1766 | } |
| 1767 | let url = Self::control_plane_url(&format!("sandbox/{}", receipt.sandbox_id))?; |
| 1768 | let response = Self::send_json( |
| 1769 | reqwest::Method::DELETE, |
| 1770 | &url, |
| 1771 | &api_key, |
| 1772 | serde_json::Value::Null, |
| 1773 | )?; |
| 1774 | // Daytona returns 204 on delete; treat 404 as already-gone success so |
| 1775 | // cancel/complete teardown is idempotent. |
| 1776 | let status = response.status(); |
| 1777 | if status.is_success() || status.as_u16() == 404 { |
| 1778 | Ok(()) |
| 1779 | } else { |
| 1780 | bail!("Cloud agent sandbox teardown failed (HTTP {status})."); |
| 1781 | } |
| 1782 | } |
| 1783 | |
| 1784 | fn list_job_sandboxes(&self) -> Result<Vec<LabeledSandbox>> { |
| 1785 | let api_key = Self::api_key()?; |
| 1786 | // The provider's list call takes a JSON-encoded exact-match labels |
| 1787 | // filter (same OpenAPI family as create/get/delete above); filtering |
| 1788 | // on the product tag keeps the response to Codewhale dispatch |
| 1789 | // sandboxes only — never the user's own sandboxes on a shared key. |
| 1790 | let mut url = Self::control_plane_url("sandbox")?; |
| 1791 | url.query_pairs_mut().append_pair( |
| 1792 | "labels", |
| 1793 | &format!("{{\"{SANDBOX_PRODUCT_LABEL}\":\"{SANDBOX_PRODUCT_VALUE}\"}}"), |
| 1794 | ); |
| 1795 | let response = Self::send_json( |
| 1796 | reqwest::Method::GET, |
| 1797 | &url, |
| 1798 | &api_key, |
| 1799 | serde_json::Value::Null, |
| 1800 | )?; |
| 1801 | let status = response.status(); |
| 1802 | let text = response.text().unwrap_or_default(); |
| 1803 | if !status.is_success() { |
| 1804 | bail!("Cloud agent sandbox listing failed (HTTP {status})."); |
| 1805 | } |
| 1806 | let parsed: serde_json::Value = serde_json::from_str(&text) |
| 1807 | .context("the cloud agent service returned an unreadable sandbox list")?; |
| 1808 | let rows = parsed.as_array().cloned().unwrap_or_default(); |
| 1809 | let mut sandboxes = Vec::new(); |
| 1810 | for row in rows { |
| 1811 | let sandbox_id = row |
| 1812 | .get("id") |
| 1813 | .and_then(serde_json::Value::as_str) |
| 1814 | .unwrap_or("") |
| 1815 | .trim() |
| 1816 | .to_string(); |
| 1817 | if !valid_sandbox_id(&sandbox_id) { |
| 1818 | continue; |
| 1819 | } |
| 1820 | let job_id = row |
| 1821 | .get("labels") |
| 1822 | .and_then(|labels| labels.get(SANDBOX_JOB_LABEL)) |
| 1823 | .and_then(serde_json::Value::as_str) |
| 1824 | .map(str::trim) |
| 1825 | .filter(|value| !value.is_empty()) |
| 1826 | .map(str::to_string); |
| 1827 | sandboxes.push(LabeledSandbox { sandbox_id, job_id }); |
| 1828 | } |
| 1829 | Ok(sandboxes) |
| 1830 | } |
| 1831 | } |
| 1832 | |
| 1833 | /// Status / enablement copy shared by CLI and TUI fail-closed paths. |
| 1834 | /// Membership-first: the gate is sign-in, never a provider key. |
| 1835 | pub fn missing_credentials_message() -> String { |
| 1836 | if membership_signed_in() { |
| 1837 | "Cloud agents are not available for this account yet; cloud dispatch fails closed (no sandbox, no push, no PR).".to_string() |
| 1838 | } else { |
| 1839 | "Cloud agents are included with your Codewhale membership. Sign in with `codewhale login` to enable `/dispatch`; cloud dispatch fails closed until then (no sandbox, no push, no PR).".to_string() |
| 1840 | } |
| 1841 | } |
| 1842 | |
| 1843 | fn prefer_named(remotes: &[GitRemote], forge: Forge) -> Option<SelectedRemote> { |
| 1844 | remotes |
| 1845 | .iter() |
| 1846 | .find(|remote| remote.name.eq_ignore_ascii_case(forge.as_str())) |
| 1847 | .map(|remote| SelectedRemote { |
| 1848 | forge, |
| 1849 | name: remote.name.clone(), |
| 1850 | url: remote.url.clone(), |
| 1851 | }) |
| 1852 | } |
| 1853 | |
| 1854 | fn validate_prompt(prompt: &str) -> Result<String, DispatchError> { |
| 1855 | let prompt = prompt.trim(); |
| 1856 | if prompt.is_empty() { |
| 1857 | return Err(DispatchError::EmptyPrompt); |
| 1858 | } |
| 1859 | if prompt.chars().count() > MAX_PROMPT_CHARS { |
| 1860 | return Err(DispatchError::PromptTooLong); |
| 1861 | } |
| 1862 | Ok(prompt.to_string()) |
| 1863 | } |
| 1864 | |
| 1865 | fn valid_branch(branch: &str) -> bool { |
| 1866 | if is_forge_default_branch(branch) { |
| 1867 | return false; |
| 1868 | } |
| 1869 | if branch.is_empty() |
| 1870 | || branch.len() > 255 |
| 1871 | || branch.starts_with('-') |
| 1872 | || branch.starts_with('/') |
| 1873 | || branch.ends_with('/') |
| 1874 | || branch.ends_with('.') |
| 1875 | || branch.contains("..") |
| 1876 | || branch.contains("//") |
| 1877 | || branch.contains("@{") |
| 1878 | { |
| 1879 | return false; |
| 1880 | } |
| 1881 | branch |
| 1882 | .bytes() |
| 1883 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'/' | b'-')) |
| 1884 | } |
| 1885 | |
| 1886 | fn valid_job_id(id: &str) -> bool { |
| 1887 | let Some(rest) = id.strip_prefix("cloud_") else { |
| 1888 | return false; |
| 1889 | }; |
| 1890 | (8..=32).contains(&rest.len()) && rest.bytes().all(|byte| byte.is_ascii_hexdigit()) |
| 1891 | } |
| 1892 | |
| 1893 | fn allocate_job_id(plan: &DispatchPlan) -> String { |
| 1894 | use std::hash::{Hash, Hasher}; |
| 1895 | let mut hasher = std::collections::hash_map::DefaultHasher::new(); |
| 1896 | plan.prompt.hash(&mut hasher); |
| 1897 | plan.remote.forge.as_str().hash(&mut hasher); |
| 1898 | plan.branch.hash(&mut hasher); |
| 1899 | unix_now().hash(&mut hasher); |
| 1900 | format!("cloud_{:016x}", hasher.finish()) |
| 1901 | } |
| 1902 | |
| 1903 | fn default_branch() -> String { |
| 1904 | format!("codewhale/cloud-{}", unix_now()) |
| 1905 | } |
| 1906 | |
| 1907 | fn unix_now() -> u64 { |
| 1908 | SystemTime::now() |
| 1909 | .duration_since(UNIX_EPOCH) |
| 1910 | .map(|duration| duration.as_secs()) |
| 1911 | .unwrap_or(0) |
| 1912 | } |
| 1913 | |
| 1914 | /// Current unix time, shared with the runner for receipt timestamps. |
| 1915 | pub fn unix_timestamp() -> u64 { |
| 1916 | unix_now() |
| 1917 | } |
| 1918 | |
| 1919 | fn env_present(name: &str) -> bool { |
| 1920 | std::env::var(name).is_ok_and(|value| !value.trim().is_empty()) |
| 1921 | } |
| 1922 | |
| 1923 | fn read_api_key() -> Option<String> { |
| 1924 | for name in [DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV] { |
| 1925 | if let Ok(value) = std::env::var(name) { |
| 1926 | let value = value.trim().to_string(); |
| 1927 | if !value.is_empty() { |
| 1928 | return Some(value); |
| 1929 | } |
| 1930 | } |
| 1931 | } |
| 1932 | Secrets::auto_detect() |
| 1933 | .get(KEYRING_SLOT) |
| 1934 | .ok() |
| 1935 | .flatten() |
| 1936 | .map(|value| value.trim().to_string()) |
| 1937 | .filter(|value| !value.is_empty()) |
| 1938 | } |
| 1939 | |
| 1940 | /// The account machine token for the in-sandbox agent. Read at create |
| 1941 | /// time only; used in the create body and never returned, logged, or |
| 1942 | /// persisted by the store. Shape-checked (`cwc_key_…`) so a misconfigured |
| 1943 | /// env var refuses at the confirm gate instead of paying for a sandbox |
| 1944 | /// whose agent can never authenticate. |
| 1945 | fn read_cloud_agent_token() -> Option<String> { |
| 1946 | let raw = std::env::var(CLOUD_AGENT_TOKEN_ENV).ok()?; |
| 1947 | machine_token_from_value(&raw) |
| 1948 | } |
| 1949 | |
| 1950 | /// Pure core of [`read_cloud_agent_token`] and |
| 1951 | /// [`discover_machine_token`]: trim, shape-check, bound. |
| 1952 | pub(crate) fn machine_token_from_value(raw: &str) -> Option<String> { |
| 1953 | let value = raw.trim(); |
| 1954 | let rest = value.strip_prefix("cwc_key_")?; |
| 1955 | // Documented shape: `cwc_key_` + 24 hex + separator + secret. |
| 1956 | if rest.len() < 25 { |
| 1957 | return None; |
| 1958 | } |
| 1959 | let (head, tail) = rest.split_at(24); |
| 1960 | if !head.bytes().all(|byte| byte.is_ascii_hexdigit()) { |
| 1961 | return None; |
| 1962 | } |
| 1963 | if !matches!(tail.as_bytes().first(), Some(b'_' | b'-')) { |
| 1964 | return None; |
| 1965 | } |
| 1966 | if !(MACHINE_TOKEN_MIN_BYTES..=MAX_MACHINE_TOKEN_BYTES).contains(&value.len()) { |
| 1967 | return None; |
| 1968 | } |
| 1969 | Some(value.to_string()) |
| 1970 | } |
| 1971 | |
| 1972 | /// `cwc_key_` (8) + at least the 24-hex id + one separator + a secret. |
| 1973 | const MACHINE_TOKEN_MIN_BYTES: usize = 40; |
| 1974 | |
| 1975 | /// Bound on the injected machine token: real `cwc_key_…` keys are 76 |
| 1976 | /// chars; the bound exists so a misconfigured env var cannot balloon the |
| 1977 | /// create body. |
| 1978 | const MAX_MACHINE_TOKEN_BYTES: usize = 512; |
| 1979 | |
| 1980 | /// The cloud-agent snapshot to launch from. `CODEWHALE_DISPATCH_SNAPSHOT` |
| 1981 | /// overrides the default for operators; an invalid override falls back to |
| 1982 | /// the default rather than shipping an arbitrary string to the provider. |
| 1983 | fn cloud_agent_snapshot() -> String { |
| 1984 | std::env::var(CLOUD_AGENT_SNAPSHOT_ENV) |
| 1985 | .ok() |
| 1986 | .map(|value| value.trim().to_string()) |
| 1987 | .filter(|value| valid_snapshot_name(value)) |
| 1988 | .unwrap_or_else(|| DEFAULT_CLOUD_AGENT_SNAPSHOT.to_string()) |
| 1989 | } |
| 1990 | |
| 1991 | /// Snapshot names are provider path tokens: slug charset, bounded length. |
| 1992 | /// Same policy shape as [`valid_sandbox_id`]. |
| 1993 | fn valid_snapshot_name(name: &str) -> bool { |
| 1994 | !name.is_empty() |
| 1995 | && name.len() <= 64 |
| 1996 | && !name.starts_with(['.', '-']) |
| 1997 | && !name.contains("..") |
| 1998 | && name |
| 1999 | .chars() |
| 2000 | .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') |
| 2001 | } |
| 2002 | |
| 2003 | /// The cloud-agent sandbox create body (pure so tests pin the contract). |
| 2004 | /// |
| 2005 | /// Founder decision 2026-08-29: the sandbox launches from the Codewhale |
| 2006 | /// cloud-agent snapshot — Daytona snapshots are the only restorable |
| 2007 | /// create source (raw images are snapshot-build inputs) — with the CLI |
| 2008 | /// preinstalled, and the account machine token injected as |
| 2009 | /// `CODEWHALE_API_KEY` so the in-sandbox `codewhale exec` authenticates as |
| 2010 | /// the dispatching account and resolves the account's configured model. |
| 2011 | /// No provider API key ever widens into the sandbox (BYOK stays local); |
| 2012 | /// the sandbox speaks only with the Codewhale account. |
| 2013 | fn create_sandbox_body(job: &CloudJob, machine_token: &str, snapshot: &str) -> serde_json::Value { |
| 2014 | serde_json::json!({ |
| 2015 | "name": format!("cw-{}", job.id.replace('_', "-")), |
| 2016 | "snapshot": snapshot, |
| 2017 | "env": { |
| 2018 | CLOUD_AGENT_TOKEN_ENV: machine_token, |
| 2019 | }, |
| 2020 | "labels": { |
| 2021 | SANDBOX_JOB_LABEL: job.id, |
| 2022 | "codewhale.forge": job.forge.as_str(), |
| 2023 | SANDBOX_PRODUCT_LABEL: SANDBOX_PRODUCT_VALUE, |
| 2024 | } |
| 2025 | }) |
| 2026 | } |
| 2027 | |
| 2028 | /// Join an API path onto a base WITHOUT dropping the base's own path. |
| 2029 | /// `Url::join` with a relative path replaces the base's last segment |
| 2030 | /// (`https://host/api` + "sandbox" -> `https://host/sandbox`), which would |
| 2031 | /// silently drop the `/api` prefix every control-plane call needs — so the |
| 2032 | /// base is normalized to a trailing slash first. |
| 2033 | fn join_api_path(base: reqwest::Url, path: &str) -> Result<reqwest::Url> { |
| 2034 | let mut base = base; |
| 2035 | if !base.path().ends_with('/') { |
| 2036 | let path = format!("{}/", base.path()); |
| 2037 | base.set_path(&path); |
| 2038 | } |
| 2039 | base.join(path.trim_start_matches('/')) |
| 2040 | .context("failed to join the cloud agent request path") |
| 2041 | } |
| 2042 | |
| 2043 | fn remote_host(url: &str) -> Option<String> { |
| 2044 | let url = url.trim(); |
| 2045 | if url.len() > MAX_REMOTE_BYTES { |
| 2046 | return None; |
| 2047 | } |
| 2048 | let host = if let Some(rest) = url |
| 2049 | .strip_prefix("https://") |
| 2050 | .or_else(|| url.strip_prefix("http://")) |
| 2051 | .or_else(|| url.strip_prefix("ssh://")) |
| 2052 | { |
| 2053 | let authority = rest.split('/').next()?; |
| 2054 | authority |
| 2055 | .rsplit_once('@') |
| 2056 | .map_or(authority, |(_, host)| host) |
| 2057 | } else if let Some((_, rest)) = url.split_once('@') { |
| 2058 | rest.split(':').next()? |
| 2059 | } else { |
| 2060 | return None; |
| 2061 | }; |
| 2062 | let host = host.split(':').next()?.trim().to_ascii_lowercase(); |
| 2063 | if host.is_empty() { None } else { Some(host) } |
| 2064 | } |
| 2065 | |
| 2066 | fn forge_host(forge: Forge) -> &'static str { |
| 2067 | match forge { |
| 2068 | Forge::Github => "github.com", |
| 2069 | Forge::Cnb => "cnb.cool", |
| 2070 | Forge::Gitee => "gitee.com", |
| 2071 | } |
| 2072 | } |
| 2073 | |
| 2074 | fn proposal_note(plan: &DispatchPlan) -> String { |
| 2075 | format!( |
| 2076 | "Proposed Codewhale cloud-agent offload to {} ({}) raising branch {}.", |
| 2077 | plan.remote.forge.as_str(), |
| 2078 | plan.remote.name, |
| 2079 | plan.branch |
| 2080 | ) |
| 2081 | } |
| 2082 | |
| 2083 | fn status_label(status: CloudJobStatus) -> &'static str { |
| 2084 | match status { |
| 2085 | CloudJobStatus::Proposed => "proposed", |
| 2086 | CloudJobStatus::Refused => "refused", |
| 2087 | CloudJobStatus::Launching => "launching", |
| 2088 | CloudJobStatus::Running => "running", |
| 2089 | CloudJobStatus::OpeningPr => "openingpr", |
| 2090 | CloudJobStatus::Done => "done", |
| 2091 | CloudJobStatus::Failed => "failed", |
| 2092 | CloudJobStatus::Canceled => "canceled", |
| 2093 | } |
| 2094 | } |
| 2095 | |
| 2096 | fn one_line(value: &str, max: usize) -> String { |
| 2097 | let flat: String = value |
| 2098 | .chars() |
| 2099 | .map(|ch| if ch.is_control() { ' ' } else { ch }) |
| 2100 | .collect(); |
| 2101 | if flat.chars().count() <= max { |
| 2102 | flat |
| 2103 | } else { |
| 2104 | let mut out: String = flat.chars().take(max.saturating_sub(1)).collect(); |
| 2105 | out.push('…'); |
| 2106 | out |
| 2107 | } |
| 2108 | } |
| 2109 | |
| 2110 | /// Sanitized (control-character-free, bounded) error text for job notes. |
| 2111 | pub fn sanitize_error(message: &str) -> String { |
| 2112 | redact_machine_tokens(&redact_url_userinfo( |
| 2113 | &message |
| 2114 | .chars() |
| 2115 | .filter(|ch| !ch.is_control()) |
| 2116 | .collect::<String>(), |
| 2117 | )) |
| 2118 | .chars() |
| 2119 | .take(240) |
| 2120 | .collect() |
| 2121 | } |
| 2122 | |
| 2123 | /// Replace anything shaped like a Codewhale account machine token with its |
| 2124 | /// non-secret head + `[redacted]`. The sandbox environment carries |
| 2125 | /// `CODEWHALE_API_KEY`, so harness output and provider errors must never be |
| 2126 | /// able to echo a live token into a job record, note, or summary. |
| 2127 | pub fn redact_machine_tokens(text: &str) -> String { |
| 2128 | let mut out = String::with_capacity(text.len()); |
| 2129 | let bytes = text.as_bytes(); |
| 2130 | let mut i = 0; |
| 2131 | while i < bytes.len() { |
| 2132 | if text[i..].starts_with("cwc_key_") { |
| 2133 | let rest = &text[i + "cwc_key_".len()..]; |
| 2134 | let end = rest |
| 2135 | .char_indices() |
| 2136 | .find(|(_, ch)| !ch.is_ascii_alphanumeric() && *ch != '_' && *ch != '-') |
| 2137 | .map(|(idx, _)| idx) |
| 2138 | .unwrap_or(rest.len()); |
| 2139 | // The 24-hex id head is non-secret by design; the secret tail is not. |
| 2140 | if end >= 24 { |
| 2141 | // Non-secret head by design: `cwc_key_` + the 24-hex id. |
| 2142 | out.push_str("cwc_key_"); |
| 2143 | out.push_str(&rest[..24]); |
| 2144 | out.push_str("_[redacted]"); |
| 2145 | i += "cwc_key_".len() + end; |
| 2146 | continue; |
| 2147 | } |
| 2148 | } |
| 2149 | let ch = text[i..].chars().next().unwrap(); |
| 2150 | out.push(ch); |
| 2151 | i += ch.len_utf8(); |
| 2152 | } |
| 2153 | out |
| 2154 | } |
| 2155 | |
| 2156 | /// Join argv into one POSIX shell command with every element single-quoted, |
| 2157 | /// so nothing (the prompt included) can interpolate when the sandbox toolbox |
| 2158 | /// executes the string. |
| 2159 | pub fn shell_quote_join(argv: &[String]) -> String { |
| 2160 | argv.iter() |
| 2161 | .map(|part| format!("'{}'", part.replace('\'', "'\\''"))) |
| 2162 | .collect::<Vec<_>>() |
| 2163 | .join(" ") |
| 2164 | } |
| 2165 | |
| 2166 | /// Sandbox ids must be plain URL-path-safe tokens before they are used in |
| 2167 | /// control-plane or toolbox paths. |
| 2168 | fn valid_sandbox_id(id: &str) -> bool { |
| 2169 | !id.is_empty() |
| 2170 | && id.len() <= 128 |
| 2171 | && id |
| 2172 | .bytes() |
| 2173 | .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) |
| 2174 | } |
| 2175 | |
| 2176 | #[cfg(test)] |
| 2177 | mod tests { |
| 2178 | use super::*; |
| 2179 | |
| 2180 | fn remotes(rows: &[(&str, &str)]) -> Vec<GitRemote> { |
| 2181 | rows.iter() |
| 2182 | .map(|(name, url)| GitRemote { |
| 2183 | name: (*name).to_string(), |
| 2184 | url: (*url).to_string(), |
| 2185 | }) |
| 2186 | .collect() |
| 2187 | } |
| 2188 | |
| 2189 | #[test] |
| 2190 | fn named_github_is_authoritative_even_when_origin_is_cnb() { |
| 2191 | assert_eq!( |
| 2192 | classify_remote("github", "https://cnb.cool/mirror/app.git"), |
| 2193 | Some(Forge::Github) |
| 2194 | ); |
| 2195 | assert_eq!( |
| 2196 | classify_remote("origin", "https://cnb.cool/mirror/app.git"), |
| 2197 | Some(Forge::Cnb) |
| 2198 | ); |
| 2199 | assert_eq!( |
| 2200 | classify_remote("origin", "https://github.com/Hmbown/CodeWhale.git"), |
| 2201 | Some(Forge::Github) |
| 2202 | ); |
| 2203 | assert_eq!( |
| 2204 | classify_remote("gitee", "git@gitee.com:org/app.git"), |
| 2205 | Some(Forge::Gitee) |
| 2206 | ); |
| 2207 | assert_eq!( |
| 2208 | classify_remote("upstream", "https://example.test/x.git"), |
| 2209 | None |
| 2210 | ); |
| 2211 | } |
| 2212 | |
| 2213 | #[test] |
| 2214 | fn ambiguous_github_and_cnb_require_an_explicit_remote() { |
| 2215 | let rows = remotes(&[ |
| 2216 | ("github", "https://github.com/Hmbown/CodeWhale.git"), |
| 2217 | ("origin", "https://cnb.cool/codewhale.net/codewhale.git"), |
| 2218 | ]); |
| 2219 | assert_eq!( |
| 2220 | select_remote(&rows, None), |
| 2221 | Err(DispatchError::AmbiguousRemote) |
| 2222 | ); |
| 2223 | let github = select_remote(&rows, Some(Forge::Github)).unwrap(); |
| 2224 | assert_eq!(github.name, "github"); |
| 2225 | let cnb = select_remote(&rows, Some(Forge::Cnb)).unwrap(); |
| 2226 | assert_eq!(cnb.name, "origin"); |
| 2227 | } |
| 2228 | |
| 2229 | #[test] |
| 2230 | fn plan_rejects_empty_prompt_and_hostile_branch() { |
| 2231 | let rows = remotes(&[("github", "https://github.com/org/repo.git")]); |
| 2232 | assert_eq!( |
| 2233 | plan_dispatch(&rows, " ", None, None).unwrap_err(), |
| 2234 | DispatchError::EmptyPrompt |
| 2235 | ); |
| 2236 | assert_eq!( |
| 2237 | plan_dispatch(&rows, "fix flake", None, Some("-bad;rm")).unwrap_err(), |
| 2238 | DispatchError::InvalidBranch |
| 2239 | ); |
| 2240 | assert_eq!( |
| 2241 | plan_dispatch(&rows, "fix flake", None, Some("main")).unwrap_err(), |
| 2242 | DispatchError::InvalidBranch, |
| 2243 | "a non-force push must never target the forge default branch" |
| 2244 | ); |
| 2245 | let plan = plan_dispatch( |
| 2246 | &rows, |
| 2247 | "fix flake", |
| 2248 | Some(Forge::Github), |
| 2249 | Some("codewhale/cloud-1"), |
| 2250 | ) |
| 2251 | .unwrap(); |
| 2252 | assert_eq!(plan.remote.forge, Forge::Github); |
| 2253 | assert_eq!(plan.branch, "codewhale/cloud-1"); |
| 2254 | } |
| 2255 | |
| 2256 | #[test] |
| 2257 | fn plan_rejects_leading_dash_and_userinfo_remotes() { |
| 2258 | assert_eq!( |
| 2259 | plan_dispatch( |
| 2260 | &remotes(&[("github", "--upload-pack=evil")]), |
| 2261 | "fix flake", |
| 2262 | Some(Forge::Github), |
| 2263 | None, |
| 2264 | ) |
| 2265 | .unwrap_err(), |
| 2266 | DispatchError::UnsafeRemote |
| 2267 | ); |
| 2268 | assert_eq!( |
| 2269 | plan_dispatch( |
| 2270 | &remotes(&[("github", "https://user:token@github.com/org/repo.git")]), |
| 2271 | "fix flake", |
| 2272 | Some(Forge::Github), |
| 2273 | None, |
| 2274 | ) |
| 2275 | .unwrap_err(), |
| 2276 | DispatchError::UnsafeRemote |
| 2277 | ); |
| 2278 | assert!(validate_git_remote_url("https://github.com/org/repo.git").is_ok()); |
| 2279 | assert!(validate_git_remote_url("--upload-pack=evil").is_err()); |
| 2280 | assert!(validate_git_remote_url("https://user:ghp_x@github.com/org/repo.git").is_err()); |
| 2281 | } |
| 2282 | |
| 2283 | #[test] |
| 2284 | fn unconfirmed_dispatch_writes_a_proposal_and_never_launches() { |
| 2285 | let temp = tempfile::tempdir().unwrap(); |
| 2286 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2287 | let plan = plan_dispatch( |
| 2288 | &remotes(&[("github", "https://github.com/org/repo.git")]), |
| 2289 | "open a PR for the flake", |
| 2290 | Some(Forge::Github), |
| 2291 | Some("codewhale/cloud-test"), |
| 2292 | ) |
| 2293 | .unwrap(); |
| 2294 | let outcome = execute_dispatch( |
| 2295 | &store, |
| 2296 | plan, |
| 2297 | false, |
| 2298 | &CredentialState::Present { |
| 2299 | source: CredentialSource::Env, |
| 2300 | }, |
| 2301 | &MachineTokenState::Present, |
| 2302 | ) |
| 2303 | .unwrap(); |
| 2304 | let DispatchOutcome::Proposal(job) = outcome else { |
| 2305 | panic!("expected proposal"); |
| 2306 | }; |
| 2307 | assert_eq!(job.status, CloudJobStatus::Proposed); |
| 2308 | assert!(!job.confirmed); |
| 2309 | assert!(job.sandbox_id.is_none()); |
| 2310 | assert!(job.pr_url.is_none()); |
| 2311 | assert_eq!(job.kind, "cloud"); |
| 2312 | assert!(!should_auto_confirm(&DispatchPlan { |
| 2313 | prompt: job.prompt.clone(), |
| 2314 | remote: SelectedRemote { |
| 2315 | forge: job.forge, |
| 2316 | name: job.remote_name.clone(), |
| 2317 | url: job.remote_url.clone(), |
| 2318 | }, |
| 2319 | branch: job.branch.clone(), |
| 2320 | })); |
| 2321 | } |
| 2322 | |
| 2323 | #[test] |
| 2324 | fn confirmed_dispatch_fails_closed_without_credentials() { |
| 2325 | let temp = tempfile::tempdir().unwrap(); |
| 2326 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2327 | let plan = plan_dispatch( |
| 2328 | &remotes(&[("origin", "https://cnb.cool/org/repo.git")]), |
| 2329 | "raise a CNB PR", |
| 2330 | Some(Forge::Cnb), |
| 2331 | Some("codewhale/cloud-cnb"), |
| 2332 | ) |
| 2333 | .unwrap(); |
| 2334 | let outcome = execute_dispatch( |
| 2335 | &store, |
| 2336 | plan, |
| 2337 | true, |
| 2338 | &CredentialState::Missing, |
| 2339 | &MachineTokenState::Present, |
| 2340 | ) |
| 2341 | .unwrap(); |
| 2342 | let DispatchOutcome::Refused(job) = outcome else { |
| 2343 | panic!("expected refuse"); |
| 2344 | }; |
| 2345 | assert_eq!(job.status, CloudJobStatus::Refused); |
| 2346 | assert!(job.confirmed); |
| 2347 | assert!(job.sandbox_id.is_none()); |
| 2348 | assert!(job.pr_url.is_none()); |
| 2349 | assert!(job.note.contains("cloud dispatch fails closed")); |
| 2350 | assert!(!job.note.contains("DAYTONA")); |
| 2351 | assert!(!job.note.contains("sk-")); |
| 2352 | } |
| 2353 | |
| 2354 | #[test] |
| 2355 | fn confirmed_dispatch_without_machine_token_refuses_truthfully() { |
| 2356 | let temp = tempfile::tempdir().unwrap(); |
| 2357 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2358 | let plan = plan_dispatch( |
| 2359 | &remotes(&[("origin", "https://cnb.cool/org/repo.git")]), |
| 2360 | "raise a CNB PR", |
| 2361 | Some(Forge::Cnb), |
| 2362 | Some("codewhale/cloud-cnb"), |
| 2363 | ) |
| 2364 | .unwrap(); |
| 2365 | // Daytona credentials ARE present: the machine token is the missing |
| 2366 | // fact, so the refusal must be the account-token one. |
| 2367 | let outcome = execute_dispatch( |
| 2368 | &store, |
| 2369 | plan, |
| 2370 | true, |
| 2371 | &CredentialState::Present { |
| 2372 | source: CredentialSource::Env, |
| 2373 | }, |
| 2374 | &MachineTokenState::Missing, |
| 2375 | ) |
| 2376 | .unwrap(); |
| 2377 | let DispatchOutcome::Refused(job) = outcome else { |
| 2378 | panic!("expected refuse"); |
| 2379 | }; |
| 2380 | assert_eq!(job.status, CloudJobStatus::Refused); |
| 2381 | assert!(job.confirmed); |
| 2382 | assert!(job.sandbox_id.is_none()); |
| 2383 | assert!(job.finished_unix.is_some(), "the refusal is terminal"); |
| 2384 | assert!(job.note.contains("CODEWHALE_API_KEY")); |
| 2385 | assert!(job.note.contains("cwc_key_")); |
| 2386 | assert!(job.note.contains("cloud dispatch fails closed")); |
| 2387 | // No provider brand in user copy, no secret-shaped text. |
| 2388 | assert!(!job.note.contains("Daytona")); |
| 2389 | assert!(!job.note.contains("sk-")); |
| 2390 | } |
| 2391 | |
| 2392 | #[test] |
| 2393 | fn confirm_without_machine_token_refuses_in_place() { |
| 2394 | let temp = tempfile::tempdir().unwrap(); |
| 2395 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2396 | let plan = plan_dispatch( |
| 2397 | &remotes(&[("gitee", "https://gitee.com/org/repo.git")]), |
| 2398 | "gitee offload", |
| 2399 | Some(Forge::Gitee), |
| 2400 | Some("codewhale/cloud-confirm-token"), |
| 2401 | ) |
| 2402 | .unwrap(); |
| 2403 | let DispatchOutcome::Proposal(job) = execute_dispatch( |
| 2404 | &store, |
| 2405 | plan, |
| 2406 | false, |
| 2407 | &CredentialState::Present { |
| 2408 | source: CredentialSource::Env, |
| 2409 | }, |
| 2410 | &MachineTokenState::Present, |
| 2411 | ) |
| 2412 | .unwrap() else { |
| 2413 | panic!("expected proposal"); |
| 2414 | }; |
| 2415 | let id = job.id.clone(); |
| 2416 | |
| 2417 | let refused = match confirm_job( |
| 2418 | &store, |
| 2419 | &id, |
| 2420 | &CredentialState::Present { |
| 2421 | source: CredentialSource::Env, |
| 2422 | }, |
| 2423 | &MachineTokenState::Missing, |
| 2424 | ) |
| 2425 | .unwrap() |
| 2426 | { |
| 2427 | DispatchOutcome::Refused(job) => job, |
| 2428 | other => panic!("expected refuse, got {other:?}"), |
| 2429 | }; |
| 2430 | // Refused in place: SAME id, one record, terminal. |
| 2431 | assert_eq!(refused.id, id); |
| 2432 | assert_eq!(refused.status, CloudJobStatus::Refused); |
| 2433 | assert!(refused.note.contains("CODEWHALE_API_KEY")); |
| 2434 | let listed = store.list().unwrap(); |
| 2435 | assert_eq!(listed.len(), 1); |
| 2436 | assert_eq!(listed[0].id, id); |
| 2437 | // A second confirm cannot re-mint a run from the refusal. |
| 2438 | assert!( |
| 2439 | confirm_job( |
| 2440 | &store, |
| 2441 | &id, |
| 2442 | &CredentialState::Present { |
| 2443 | source: CredentialSource::Env, |
| 2444 | }, |
| 2445 | &MachineTokenState::Present, |
| 2446 | ) |
| 2447 | .is_err() |
| 2448 | ); |
| 2449 | } |
| 2450 | |
| 2451 | #[test] |
| 2452 | fn create_body_launches_from_the_cloud_agent_snapshot_with_the_account_token() { |
| 2453 | let job = CloudJob { |
| 2454 | id: "cloud_deadbeef".to_string(), |
| 2455 | kind: JOB_KIND.to_string(), |
| 2456 | status: CloudJobStatus::Launching, |
| 2457 | prompt: "pin the create contract".to_string(), |
| 2458 | forge: Forge::Github, |
| 2459 | remote_name: "origin".to_string(), |
| 2460 | remote_url: "https://github.com/org/repo.git".to_string(), |
| 2461 | branch: "codewhale/cloud-create-body".to_string(), |
| 2462 | confirmed: true, |
| 2463 | sandbox_id: None, |
| 2464 | pr_url: None, |
| 2465 | refusal: None, |
| 2466 | note: String::new(), |
| 2467 | created_unix: 1, |
| 2468 | base_branch: None, |
| 2469 | head_sha: None, |
| 2470 | agent_summary: None, |
| 2471 | finished_unix: None, |
| 2472 | sandbox_pending: false, |
| 2473 | }; |
| 2474 | let token = "cwc_key_0123456789abcdef01234567_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; |
| 2475 | let body = create_sandbox_body(&job, token, DEFAULT_CLOUD_AGENT_SNAPSHOT); |
| 2476 | // The sandbox launches from the codewhale cloud-agent snapshot… |
| 2477 | assert_eq!( |
| 2478 | body.get("snapshot").and_then(serde_json::Value::as_str), |
| 2479 | Some(DEFAULT_CLOUD_AGENT_SNAPSHOT) |
| 2480 | ); |
| 2481 | // …with exactly one injected env var: the account machine token. |
| 2482 | let env = body.get("env").expect("env present"); |
| 2483 | assert_eq!( |
| 2484 | env.get(CLOUD_AGENT_TOKEN_ENV) |
| 2485 | .and_then(serde_json::Value::as_str), |
| 2486 | Some(token) |
| 2487 | ); |
| 2488 | let env_len = env.as_object().map(|map| map.len()).unwrap_or(0); |
| 2489 | assert_eq!(env_len, 1, "no provider key widens into the sandbox"); |
| 2490 | // Labels stay: the reconciler's join keys. |
| 2491 | let labels = body.get("labels").expect("labels present"); |
| 2492 | assert_eq!( |
| 2493 | labels |
| 2494 | .get(SANDBOX_JOB_LABEL) |
| 2495 | .and_then(serde_json::Value::as_str), |
| 2496 | Some("cloud_deadbeef") |
| 2497 | ); |
| 2498 | assert_eq!( |
| 2499 | labels |
| 2500 | .get(SANDBOX_PRODUCT_LABEL) |
| 2501 | .and_then(serde_json::Value::as_str), |
| 2502 | Some(SANDBOX_PRODUCT_VALUE) |
| 2503 | ); |
| 2504 | // No provider API key ever ships into the sandbox. |
| 2505 | let serialized = body.to_string(); |
| 2506 | assert!(!serialized.contains("DAYTONA_API_KEY")); |
| 2507 | assert!(!serialized.contains("sk-")); |
| 2508 | } |
| 2509 | |
| 2510 | #[test] |
| 2511 | fn join_api_path_preserves_the_base_path_segment() { |
| 2512 | let base = reqwest::Url::parse("https://app.daytona.io/api").unwrap(); |
| 2513 | let joined = join_api_path(base, "sandbox").unwrap(); |
| 2514 | assert_eq!( |
| 2515 | joined.as_str(), |
| 2516 | "https://app.daytona.io/api/sandbox", |
| 2517 | "Url::join would drop the /api segment without the trailing-slash fix" |
| 2518 | ); |
| 2519 | let base = reqwest::Url::parse("https://proxy.internal/control/").unwrap(); |
| 2520 | let joined = join_api_path(base, "sandbox/abc/labels").unwrap(); |
| 2521 | assert_eq!( |
| 2522 | joined.as_str(), |
| 2523 | "https://proxy.internal/control/sandbox/abc/labels" |
| 2524 | ); |
| 2525 | } |
| 2526 | |
| 2527 | #[test] |
| 2528 | fn machine_tokens_are_shape_checked_before_they_can_spend() { |
| 2529 | let good = "cwc_key_0123456789abcdef01234567_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; |
| 2530 | assert_eq!(machine_token_from_value(good).as_deref(), Some(good)); |
| 2531 | // A bare env var (wrong secret family) never authorizes a sandbox. |
| 2532 | assert_eq!(machine_token_from_value("sk-not-a-machine-key"), None); |
| 2533 | assert_eq!(machine_token_from_value(""), None); |
| 2534 | assert_eq!(machine_token_from_value("cwc_key_short"), None); |
| 2535 | assert_eq!( |
| 2536 | machine_token_from_value(&format!("cwc_key_{}", "x".repeat(600))), |
| 2537 | None, |
| 2538 | "over-long values are refused, not truncated" |
| 2539 | ); |
| 2540 | assert_eq!( |
| 2541 | machine_token_from_value(&format!(" {good} ")).as_deref(), |
| 2542 | Some(good), |
| 2543 | "surrounding whitespace is trimmed" |
| 2544 | ); |
| 2545 | assert_eq!( |
| 2546 | machine_token_from_value("cwc_key_zzzzzzzzzzzzzzzzzzzzzzzz_nothex"), |
| 2547 | None, |
| 2548 | "the 24-char id must be hex" |
| 2549 | ); |
| 2550 | assert_eq!( |
| 2551 | machine_token_from_value("cwc_key_0123456789abcdef01234567XXXX"), |
| 2552 | None, |
| 2553 | "documented shape requires a separator after the 24-hex id" |
| 2554 | ); |
| 2555 | } |
| 2556 | |
| 2557 | #[test] |
| 2558 | fn machine_tokens_never_survive_into_errors_or_summaries() { |
| 2559 | let token = "cwc_key_0123456789abcdef01234567_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; |
| 2560 | assert_eq!( |
| 2561 | redact_machine_tokens(&format!("auth failed for {token}")), |
| 2562 | "auth failed for cwc_key_0123456789abcdef01234567_[redacted]" |
| 2563 | ); |
| 2564 | // Too short to be a real key head stays untouched (no false positives). |
| 2565 | assert_eq!( |
| 2566 | redact_machine_tokens("prefix cwc_key_abc suffix"), |
| 2567 | "prefix cwc_key_abc suffix" |
| 2568 | ); |
| 2569 | // The dispatch runner's summary path inherits the redaction. |
| 2570 | let summary = crate::dispatch_runner::summary_line(&format!("done with {token}")); |
| 2571 | assert!(!summary.contains("AAAAAAAA")); |
| 2572 | assert!(summary.contains("[redacted]")); |
| 2573 | assert!( |
| 2574 | !sanitize_error("clone failed https://user:token@github.com/org/repo.git") |
| 2575 | .contains("token"), |
| 2576 | "userinfo must not survive sanitize_error" |
| 2577 | ); |
| 2578 | } |
| 2579 | |
| 2580 | #[test] |
| 2581 | fn snapshot_names_are_slug_charset_and_bounded() { |
| 2582 | assert!(valid_snapshot_name("codewhale-cloud-agent")); |
| 2583 | assert!(valid_snapshot_name("team.agent_2026")); |
| 2584 | assert!(!valid_snapshot_name("")); |
| 2585 | assert!(!valid_snapshot_name("has space")); |
| 2586 | assert!(!valid_snapshot_name("unicodé")); |
| 2587 | assert!(!valid_snapshot_name(".hidden")); |
| 2588 | assert!(!valid_snapshot_name("-leading-dash")); |
| 2589 | assert!(!valid_snapshot_name("dot..dot")); |
| 2590 | assert!(!valid_snapshot_name("a".repeat(65).as_str())); |
| 2591 | assert!(valid_snapshot_name("a".repeat(64).as_str())); |
| 2592 | } |
| 2593 | |
| 2594 | #[test] |
| 2595 | fn confirmed_dispatch_queues_launching_without_touching_the_forge() { |
| 2596 | let temp = tempfile::tempdir().unwrap(); |
| 2597 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2598 | let plan = plan_dispatch( |
| 2599 | &remotes(&[("gitee", "https://gitee.com/org/repo.git")]), |
| 2600 | "gitee offload", |
| 2601 | Some(Forge::Gitee), |
| 2602 | Some("codewhale/cloud-gitee"), |
| 2603 | ) |
| 2604 | .unwrap(); |
| 2605 | let outcome = execute_dispatch( |
| 2606 | &store, |
| 2607 | plan, |
| 2608 | true, |
| 2609 | &CredentialState::Present { |
| 2610 | source: CredentialSource::Keyring, |
| 2611 | }, |
| 2612 | &MachineTokenState::Present, |
| 2613 | ) |
| 2614 | .unwrap(); |
| 2615 | let DispatchOutcome::Accepted(job) = outcome else { |
| 2616 | panic!("expected accept"); |
| 2617 | }; |
| 2618 | assert_eq!(job.status, CloudJobStatus::Launching); |
| 2619 | assert!(job.confirmed); |
| 2620 | assert!(job.sandbox_id.is_none()); |
| 2621 | assert!(job.pr_url.is_none()); |
| 2622 | assert!(job.note.contains("runner will raise the branch")); |
| 2623 | let listed = store.list().unwrap(); |
| 2624 | assert_eq!(listed.len(), 1); |
| 2625 | assert_eq!(listed[0].id, job.id); |
| 2626 | // No sandbox exists yet, so cancel is a pure record flip. |
| 2627 | let canceled = cancel_job(&store, &job.id, &NoopLauncher).unwrap(); |
| 2628 | assert_eq!(canceled.status, CloudJobStatus::Canceled); |
| 2629 | assert!(canceled.note.contains("before a sandbox")); |
| 2630 | } |
| 2631 | |
| 2632 | #[test] |
| 2633 | fn confirm_job_confirms_in_place_under_the_same_id_and_is_not_reconfirmable() { |
| 2634 | let temp = tempfile::tempdir().unwrap(); |
| 2635 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2636 | let plan = plan_dispatch( |
| 2637 | &remotes(&[("github", "https://github.com/org/repo.git")]), |
| 2638 | "open a PR for the flake", |
| 2639 | Some(Forge::Github), |
| 2640 | Some("codewhale/cloud-confirm"), |
| 2641 | ) |
| 2642 | .unwrap(); |
| 2643 | let DispatchOutcome::Proposal(job) = execute_dispatch( |
| 2644 | &store, |
| 2645 | plan, |
| 2646 | false, |
| 2647 | &CredentialState::Present { |
| 2648 | source: CredentialSource::Env, |
| 2649 | }, |
| 2650 | &MachineTokenState::Present, |
| 2651 | ) |
| 2652 | .unwrap() else { |
| 2653 | panic!("expected proposal"); |
| 2654 | }; |
| 2655 | let id = job.id.clone(); |
| 2656 | |
| 2657 | let confirmed = match confirm_job( |
| 2658 | &store, |
| 2659 | &id, |
| 2660 | &CredentialState::Present { |
| 2661 | source: CredentialSource::Env, |
| 2662 | }, |
| 2663 | &MachineTokenState::Present, |
| 2664 | ) |
| 2665 | .unwrap() |
| 2666 | { |
| 2667 | DispatchOutcome::Accepted(job) => job, |
| 2668 | other => panic!("expected accept, got {other:?}"), |
| 2669 | }; |
| 2670 | // Same id, one record, launching: the proposal became the run. |
| 2671 | assert_eq!(confirmed.id, id); |
| 2672 | assert_eq!(confirmed.status, CloudJobStatus::Launching); |
| 2673 | assert!(confirmed.confirmed); |
| 2674 | let listed = store.list().unwrap(); |
| 2675 | assert_eq!(listed.len(), 1, "confirm must not mint a second record"); |
| 2676 | assert_eq!(listed[0].id, id); |
| 2677 | |
| 2678 | // A second confirm is refused — the status gate, not the id, is the |
| 2679 | // guard (ids hash unix_now() at second granularity and can collide |
| 2680 | // across confirms). |
| 2681 | let again = confirm_job( |
| 2682 | &store, |
| 2683 | &id, |
| 2684 | &CredentialState::Present { |
| 2685 | source: CredentialSource::Env, |
| 2686 | }, |
| 2687 | &MachineTokenState::Present, |
| 2688 | ) |
| 2689 | .unwrap_err() |
| 2690 | .to_string(); |
| 2691 | assert!(again.contains("cannot be confirmed"), "{again}"); |
| 2692 | assert_eq!(store.list().unwrap().len(), 1); |
| 2693 | } |
| 2694 | |
| 2695 | #[test] |
| 2696 | fn confirm_job_without_credentials_refuses_in_place_under_the_same_id() { |
| 2697 | let temp = tempfile::tempdir().unwrap(); |
| 2698 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 2699 | let plan = plan_dispatch( |
| 2700 | &remotes(&[("github", "https://github.com/org/repo.git")]), |
| 2701 | "refuse me in place", |
| 2702 | Some(Forge::Github), |
| 2703 | Some("codewhale/cloud-refuse"), |
| 2704 | ) |
| 2705 | .unwrap(); |
| 2706 | let DispatchOutcome::Proposal(job) = execute_dispatch( |
| 2707 | &store, |
| 2708 | plan, |
| 2709 | false, |
| 2710 | &CredentialState::Present { |
| 2711 | source: CredentialSource::Env, |
| 2712 | }, |
| 2713 | &MachineTokenState::Present, |
| 2714 | ) |
| 2715 | .unwrap() else { |
| 2716 | panic!("expected proposal"); |
| 2717 | }; |
| 2718 | let id = job.id.clone(); |
| 2719 | let refused = match confirm_job( |
| 2720 | &store, |
| 2721 | &id, |
| 2722 | &CredentialState::Missing, |
| 2723 | &MachineTokenState::Present, |
| 2724 | ) |
| 2725 | .unwrap() |
| 2726 | { |
| 2727 | DispatchOutcome::Refused(job) => job, |
| 2728 | other => panic!("expected refuse, got {other:?}"), |
| 2729 | }; |
| 2730 | assert_eq!(refused.id, id); |
| 2731 | assert_eq!(refused.status, CloudJobStatus::Refused); |
| 2732 | assert!(refused.confirmed); |
| 2733 | assert!(refused.finished_unix.is_some()); |
| 2734 | assert_eq!(store.list().unwrap().len(), 1); |
| 2735 | // And it cannot be confirmed again either. |
| 2736 | assert!( |
| 2737 | confirm_job( |
| 2738 | &store, |
| 2739 | &id, |
| 2740 | &CredentialState::Missing, |
| 2741 | &MachineTokenState::Present |
| 2742 | ) |
| 2743 | .is_err() |
| 2744 | ); |
| 2745 | } |
| 2746 | |
| 2747 | /// Launcher that can create but never tear down; used to pin the |
| 2748 | /// cancel-without-sandbox path without any network surface. |
| 2749 | struct NoopLauncher; |
| 2750 | |
| 2751 | impl DaytonaLauncher for NoopLauncher { |
| 2752 | fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> { |
| 2753 | bail!("no sandbox in this fixture") |
| 2754 | } |
| 2755 | } |
| 2756 | |
| 2757 | #[test] |
| 2758 | fn old_job_records_without_runner_fields_still_load() { |
| 2759 | let temp = tempfile::tempdir().unwrap(); |
| 2760 | let root = temp.path().join("jobs"); |
| 2761 | std::fs::create_dir_all(&root).unwrap(); |
| 2762 | // Exactly the JSON shape written by the first landing of the slice. |
| 2763 | let legacy = serde_json::json!({ |
| 2764 | "id": "cloud_00000000000000ff", |
| 2765 | "kind": "cloud", |
| 2766 | "status": "running", |
| 2767 | "prompt": "legacy job", |
| 2768 | "forge": "github", |
| 2769 | "remote_name": "github", |
| 2770 | "remote_url": "https://github.com/org/repo.git", |
| 2771 | "branch": "codewhale/cloud-legacy", |
| 2772 | "confirmed": true, |
| 2773 | "sandbox_id": "sandbox_legacy", |
| 2774 | "pr_url": null, |
| 2775 | "refusal": null, |
| 2776 | "note": "legacy note", |
| 2777 | "created_unix": 1_000_u64 |
| 2778 | }); |
| 2779 | std::fs::write( |
| 2780 | root.join("cloud_00000000000000ff.json"), |
| 2781 | serde_json::to_vec_pretty(&legacy).unwrap(), |
| 2782 | ) |
| 2783 | .unwrap(); |
| 2784 | let store = CloudJobStore::from_path(root); |
| 2785 | let jobs = store.list().unwrap(); |
| 2786 | assert_eq!(jobs.len(), 1); |
| 2787 | assert_eq!(jobs[0].sandbox_id.as_deref(), Some("sandbox_legacy")); |
| 2788 | assert!(jobs[0].base_branch.is_none()); |
| 2789 | assert!(jobs[0].finished_unix.is_none()); |
| 2790 | assert!(jobs[0].status == CloudJobStatus::Running); |
| 2791 | } |
| 2792 | |
| 2793 | #[test] |
| 2794 | fn shell_quoting_and_sandbox_id_guards_hold() { |
| 2795 | // A hostile prompt stays one single-quoted argument: every embedded |
| 2796 | // quote becomes '\'' and no metacharacter can escape the quoting. |
| 2797 | let hostile = "fix it'; rm -rf /; echo '$(whoami)'"; |
| 2798 | let joined = shell_quote_join(&[ |
| 2799 | "codewhale".to_string(), |
| 2800 | "exec".to_string(), |
| 2801 | "--auto".to_string(), |
| 2802 | hostile.to_string(), |
| 2803 | ]); |
| 2804 | assert_eq!( |
| 2805 | joined, |
| 2806 | "'codewhale' 'exec' '--auto' 'fix it'\\''; rm -rf /; echo '\\''$(whoami)'\\'''" |
| 2807 | ); |
| 2808 | // Behavioral pin: a real shell sees the prompt as ONE argument. |
| 2809 | let printed = std::process::Command::new("sh") |
| 2810 | .arg("-c") |
| 2811 | .arg(format!( |
| 2812 | "printf %s {}", |
| 2813 | shell_quote_join(&[hostile.to_string()]) |
| 2814 | )) |
| 2815 | .output() |
| 2816 | .expect("sh is available in test environments"); |
| 2817 | assert!(printed.status.success()); |
| 2818 | assert_eq!(String::from_utf8_lossy(&printed.stdout), hostile); |
| 2819 | assert_eq!(shell_quote_join(&["a'b".to_string()]), "'a'\\''b'"); |
| 2820 | assert!(valid_sandbox_id("sbx-123_abc")); |
| 2821 | for bad in ["", "../../evil", "sbx 1"] { |
| 2822 | assert!(!valid_sandbox_id(bad), "{bad:?} must be rejected"); |
| 2823 | } |
| 2824 | } |
| 2825 | |
| 2826 | #[test] |
| 2827 | fn net_guard_rejects_private_and_non_https_origins() { |
| 2828 | assert!(validate_outbound_origin("https://app.daytona.io/api").is_ok()); |
| 2829 | assert!(validate_outbound_origin("https://gitee.com/api/v5").is_ok()); |
| 2830 | assert!(validate_outbound_origin("ftp://example.com").is_err()); |
| 2831 | assert!(validate_outbound_origin("https://user:pw@example.com/x").is_err()); |
| 2832 | for blocked in [ |
| 2833 | "http://example.com", |
| 2834 | "https://10.1.2.3/api", |
| 2835 | "https://192.168.1.10/api", |
| 2836 | "https://172.16.0.1/api", |
| 2837 | "https://169.254.169.254/latest/meta-data", |
| 2838 | "https://100.64.0.1/api", |
| 2839 | "https://0.0.0.0/api", |
| 2840 | "https://[fc00::1]/api", |
| 2841 | "https://[fe80::1]/api", |
| 2842 | "https://[::ffff:10.0.0.1]/api", |
| 2843 | "https://[::ffff:169.254.169.254]/latest/meta-data", |
| 2844 | "https://router.internal/api", |
| 2845 | "https://printer.local/api", |
| 2846 | ] { |
| 2847 | assert!( |
| 2848 | validate_outbound_origin(blocked).is_err(), |
| 2849 | "expected {blocked} to be rejected" |
| 2850 | ); |
| 2851 | } |
| 2852 | // Loopback is the debug-only escape hatch for local smoke tests; |
| 2853 | // release builds reject it (pinned by the cfg! branch above). |
| 2854 | if cfg!(debug_assertions) { |
| 2855 | assert!(validate_outbound_origin("http://127.0.0.1:3986/api").is_ok()); |
| 2856 | assert!(validate_outbound_origin("https://localhost/api").is_ok()); |
| 2857 | } |
| 2858 | } |
| 2859 | |
| 2860 | #[test] |
| 2861 | fn status_card_surfaces_runner_receipts_without_provider_branding() { |
| 2862 | let rows = remotes(&[("github", "https://github.com/org/repo.git")]); |
| 2863 | let jobs = vec![CloudJob { |
| 2864 | id: "cloud_00000000000000aa".to_string(), |
| 2865 | kind: "cloud".to_string(), |
| 2866 | status: CloudJobStatus::Done, |
| 2867 | prompt: "fix the flake".to_string(), |
| 2868 | forge: Forge::Github, |
| 2869 | remote_name: "github".to_string(), |
| 2870 | remote_url: "https://github.com/org/repo.git".to_string(), |
| 2871 | branch: "codewhale/cloud-1".to_string(), |
| 2872 | confirmed: true, |
| 2873 | sandbox_id: Some("sandbox_receipt_1".to_string()), |
| 2874 | pr_url: Some("https://github.com/org/repo/pull/7".to_string()), |
| 2875 | refusal: None, |
| 2876 | note: "done".to_string(), |
| 2877 | created_unix: 1_000, |
| 2878 | base_branch: Some("main".to_string()), |
| 2879 | head_sha: Some("abc1234def".to_string()), |
| 2880 | agent_summary: Some("Fixed the flake".to_string()), |
| 2881 | finished_unix: Some(1_960), |
| 2882 | sandbox_pending: false, |
| 2883 | }]; |
| 2884 | let card = format_status( |
| 2885 | &rows, |
| 2886 | &CredentialState::Present { |
| 2887 | source: CredentialSource::Env, |
| 2888 | }, |
| 2889 | &jobs, |
| 2890 | ); |
| 2891 | assert!(card.contains("cloud_00000000000000aa")); |
| 2892 | assert!(card.contains("done")); |
| 2893 | assert!(card.contains("https://github.com/org/repo/pull/7")); |
| 2894 | assert!(card.contains("16m")); |
| 2895 | assert!(!card.contains("Daytona")); |
| 2896 | assert!(!card.contains("daytona")); |
| 2897 | let detail = format_job(&jobs[0]); |
| 2898 | assert!(detail.contains("Sandbox: sandbox_receipt_1")); |
| 2899 | assert!(detail.contains("PR: https://github.com/org/repo/pull/7")); |
| 2900 | assert!(detail.contains("Runtime: 16m")); |
| 2901 | assert!(detail.contains("Agent: Fixed the flake")); |
| 2902 | for banned in ["Daytona", "daytona"] { |
| 2903 | assert!( |
| 2904 | !detail.contains(banned), |
| 2905 | "the job card must not brand the sandbox operator: {banned}" |
| 2906 | ); |
| 2907 | } |
| 2908 | } |
| 2909 | |
| 2910 | #[test] |
| 2911 | fn parse_git_remote_listing_prefers_first_url_per_name() { |
| 2912 | let parsed = parse_remote_listing( |
| 2913 | "github\thttps://github.com/Hmbown/CodeWhale.git (fetch)\n\ |
| 2914 | github\thttps://github.com/Hmbown/CodeWhale.git (push)\n\ |
| 2915 | origin\thttps://cnb.cool/codewhale.net/codewhale.git (fetch)\n", |
| 2916 | ); |
| 2917 | assert_eq!(parsed.len(), 2); |
| 2918 | assert_eq!(parsed[0].name, "github"); |
| 2919 | assert_eq!(parsed[1].name, "origin"); |
| 2920 | assert_eq!( |
| 2921 | classify_remote(&parsed[0].name, &parsed[0].url), |
| 2922 | Some(Forge::Github) |
| 2923 | ); |
| 2924 | assert_eq!( |
| 2925 | classify_remote(&parsed[1].name, &parsed[1].url), |
| 2926 | Some(Forge::Cnb) |
| 2927 | ); |
| 2928 | } |
| 2929 | |
| 2930 | #[test] |
| 2931 | fn missing_credentials_copy_never_embeds_a_secret() { |
| 2932 | let message = missing_credentials_message(); |
| 2933 | assert!(message.contains("cloud dispatch fails closed")); |
| 2934 | assert!(!message.contains("DAYTONA")); |
| 2935 | assert!(!message.contains("sk-")); |
| 2936 | assert!(!message.contains("Bearer")); |
| 2937 | } |
| 2938 | |
| 2939 | /// Launcher fixture for sweep/reconcile tests: records teardowns and |
| 2940 | /// lists a configurable sandbox set; never touches a network. |
| 2941 | struct SweepLauncher { |
| 2942 | torn_down: std::sync::Mutex<Vec<String>>, |
| 2943 | listed: Vec<LabeledSandbox>, |
| 2944 | } |
| 2945 | |
| 2946 | impl SweepLauncher { |
| 2947 | fn new(listed: Vec<LabeledSandbox>) -> Self { |
| 2948 | Self { |
| 2949 | torn_down: std::sync::Mutex::new(Vec::new()), |
| 2950 | listed, |
| 2951 | } |
| 2952 | } |
| 2953 | |
| 2954 | fn torn_down(&self) -> Vec<String> { |
| 2955 | self.torn_down |
| 2956 | .lock() |
| 2957 | .map(|ids| ids.clone()) |
| 2958 | .unwrap_or_default() |
| 2959 | } |
| 2960 | } |
| 2961 | |
| 2962 | /// Records the persisted job status at the moment teardown runs, so |
| 2963 | /// tests can prove the terminal record was saved first. |
| 2964 | struct ObservingSweepLauncher { |
| 2965 | store: CloudJobStore, |
| 2966 | id: String, |
| 2967 | status_at_teardown: std::sync::Mutex<Option<CloudJobStatus>>, |
| 2968 | } |
| 2969 | |
| 2970 | impl DaytonaLauncher for ObservingSweepLauncher { |
| 2971 | fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> { |
| 2972 | bail!("no sandbox create in this fixture") |
| 2973 | } |
| 2974 | fn teardown(&self, _receipt: &SandboxReceipt) -> Result<()> { |
| 2975 | let status = self.store.load(&self.id).ok().map(|job| job.status); |
| 2976 | if let Ok(mut slot) = self.status_at_teardown.lock() { |
| 2977 | *slot = status; |
| 2978 | } |
| 2979 | Ok(()) |
| 2980 | } |
| 2981 | fn list_job_sandboxes(&self) -> Result<Vec<LabeledSandbox>> { |
| 2982 | Ok(Vec::new()) |
| 2983 | } |
| 2984 | } |
| 2985 | |
| 2986 | impl DaytonaLauncher for SweepLauncher { |
| 2987 | fn create_sandbox(&self, _job: &CloudJob) -> Result<SandboxReceipt> { |
| 2988 | bail!("no sandbox create in this fixture") |
| 2989 | } |
| 2990 | fn teardown(&self, receipt: &SandboxReceipt) -> Result<()> { |
| 2991 | if let Ok(mut ids) = self.torn_down.lock() { |
| 2992 | ids.push(receipt.sandbox_id.clone()); |
| 2993 | } |
| 2994 | Ok(()) |
| 2995 | } |
| 2996 | fn list_job_sandboxes(&self) -> Result<Vec<LabeledSandbox>> { |
| 2997 | Ok(self.listed.clone()) |
| 2998 | } |
| 2999 | } |
| 3000 | |
| 3001 | fn stored_job(status: CloudJobStatus, created_unix: u64) -> CloudJob { |
| 3002 | CloudJob { |
| 3003 | id: "cloud_00000000000000e1".to_string(), |
| 3004 | kind: "cloud".to_string(), |
| 3005 | status, |
| 3006 | prompt: "fix the flake".to_string(), |
| 3007 | forge: Forge::Github, |
| 3008 | remote_name: "github".to_string(), |
| 3009 | remote_url: "https://github.com/org/repo.git".to_string(), |
| 3010 | branch: "codewhale/cloud-1".to_string(), |
| 3011 | confirmed: true, |
| 3012 | sandbox_id: None, |
| 3013 | pr_url: None, |
| 3014 | refusal: None, |
| 3015 | note: "n".to_string(), |
| 3016 | created_unix, |
| 3017 | base_branch: None, |
| 3018 | head_sha: None, |
| 3019 | agent_summary: None, |
| 3020 | finished_unix: None, |
| 3021 | sandbox_pending: false, |
| 3022 | } |
| 3023 | } |
| 3024 | |
| 3025 | #[test] |
| 3026 | fn proposal_and_job_cards_never_carry_a_provider_brand() { |
| 3027 | // The proposal note is user copy (it rides `/dispatch show` and the |
| 3028 | // CLI card from the moment a job is proposed) — it names the |
| 3029 | // Codewhale cloud agent, never the sandbox operator. |
| 3030 | let plan = DispatchPlan { |
| 3031 | prompt: "offload me".to_string(), |
| 3032 | remote: SelectedRemote { |
| 3033 | forge: Forge::Github, |
| 3034 | name: "github".to_string(), |
| 3035 | url: "https://github.com/org/repo.git".to_string(), |
| 3036 | }, |
| 3037 | branch: "codewhale/cloud-x".to_string(), |
| 3038 | }; |
| 3039 | let note = proposal_note(&plan); |
| 3040 | assert!(note.contains("cloud-agent"), "{note}"); |
| 3041 | let mut job = stored_job(CloudJobStatus::Proposed, 1); |
| 3042 | job.note = note.clone(); |
| 3043 | let card = format_job(&job); |
| 3044 | for banned in ["Daytona", "daytona"] { |
| 3045 | assert!( |
| 3046 | !card.contains(banned), |
| 3047 | "the job card must not brand the sandbox operator: {banned}" |
| 3048 | ); |
| 3049 | } |
| 3050 | assert!(card.contains("cloud-agent")); |
| 3051 | // And the list view (format_job_list) over the same record. |
| 3052 | let listing = format_job_list(&[job]); |
| 3053 | for banned in ["Daytona", "daytona"] { |
| 3054 | assert!( |
| 3055 | !listing.contains(banned), |
| 3056 | "listing must not brand: {banned}" |
| 3057 | ); |
| 3058 | } |
| 3059 | } |
| 3060 | |
| 3061 | #[test] |
| 3062 | fn harness_client_budget_covers_the_declared_turn_budget() { |
| 3063 | let hour = HarnessCommand { |
| 3064 | argv: vec!["codewhale".to_string()], |
| 3065 | cwd: SANDBOX_WORKSPACE.to_string(), |
| 3066 | timeout_secs: 3_600, |
| 3067 | }; |
| 3068 | let budget = LiveDaytonaLauncher::harness_client_budget_secs(&hour); |
| 3069 | assert!( |
| 3070 | budget >= u64::from(hour.timeout_secs), |
| 3071 | "the client budget must cover the declared budget ({budget} < {})", |
| 3072 | hour.timeout_secs |
| 3073 | ); |
| 3074 | assert!( |
| 3075 | budget > LiveDaytonaLauncher::CONTROL_PLANE_TIMEOUT_SECS, |
| 3076 | "an hour-long dispatched turn must not ride the 120s control-plane client" |
| 3077 | ); |
| 3078 | // The budget scales with the declared timeout, not a fixed cap. |
| 3079 | let double = HarnessCommand { |
| 3080 | timeout_secs: 7_200, |
| 3081 | ..hour.clone() |
| 3082 | }; |
| 3083 | assert!(LiveDaytonaLauncher::harness_client_budget_secs(&double) >= 7_200); |
| 3084 | // Short helper commands (collect_patch's git probes) keep a sane |
| 3085 | // bounded budget too. |
| 3086 | let probe = HarnessCommand { |
| 3087 | timeout_secs: 30, |
| 3088 | ..hour |
| 3089 | }; |
| 3090 | assert!(LiveDaytonaLauncher::harness_client_budget_secs(&probe) >= 30); |
| 3091 | } |
| 3092 | |
| 3093 | #[test] |
| 3094 | fn save_unless_canceled_refuses_to_resurrect_a_canceled_record() { |
| 3095 | let temp = tempfile::tempdir().unwrap(); |
| 3096 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3097 | let mut job = stored_job(CloudJobStatus::Running, 10_000_000); |
| 3098 | store.save(&job).unwrap(); |
| 3099 | // A phase save while the record is still active goes through. |
| 3100 | job.note = "phase save".to_string(); |
| 3101 | assert!(store.save_unless_canceled(&job).unwrap()); |
| 3102 | assert_eq!(store.load(&job.id).unwrap().note, "phase save"); |
| 3103 | // The user cancels; the runner's next phase save must be refused and |
| 3104 | // leave the cancellation exactly as written. |
| 3105 | let mut canceled = store.load(&job.id).unwrap(); |
| 3106 | canceled.status = CloudJobStatus::Canceled; |
| 3107 | canceled.note = "Canceled locally".to_string(); |
| 3108 | canceled.finished_unix = Some(10_000_060); |
| 3109 | store.save(&canceled).unwrap(); |
| 3110 | let mut stale_runner_copy = job.clone(); |
| 3111 | stale_runner_copy.status = CloudJobStatus::OpeningPr; |
| 3112 | stale_runner_copy.note = "the runner's read-modify-write".to_string(); |
| 3113 | assert!(!store.save_unless_canceled(&stale_runner_copy).unwrap()); |
| 3114 | let persisted = store.load(&job.id).unwrap(); |
| 3115 | assert_eq!(persisted.status, CloudJobStatus::Canceled); |
| 3116 | assert_eq!(persisted.note, "Canceled locally"); |
| 3117 | assert_eq!(persisted.finished_unix, Some(10_000_060)); |
| 3118 | } |
| 3119 | |
| 3120 | #[test] |
| 3121 | fn sweep_fails_stale_active_jobs_and_tears_down_their_sandboxes() { |
| 3122 | let temp = tempfile::tempdir().unwrap(); |
| 3123 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3124 | let now = 10_000_000_u64; |
| 3125 | // Stale: active well past the harness budget plus slack. |
| 3126 | let mut stale = stored_job(CloudJobStatus::Running, now - STALE_ACTIVE_JOB_SECS - 60); |
| 3127 | stale.sandbox_id = Some("sandbox_stale".to_string()); |
| 3128 | store.save(&stale).unwrap(); |
| 3129 | // Fresh: active but young; must be left exactly as it is. |
| 3130 | let mut fresh = stored_job(CloudJobStatus::Launching, now - 60); |
| 3131 | fresh.id = "cloud_00000000000000e2".to_string(); |
| 3132 | fresh.sandbox_id = Some("sandbox_fresh".to_string()); |
| 3133 | store.save(&fresh).unwrap(); |
| 3134 | // Terminal: old but already done; never touched. |
| 3135 | let mut done = stored_job(CloudJobStatus::Done, now - STALE_ACTIVE_JOB_SECS * 2); |
| 3136 | done.id = "cloud_00000000000000e3".to_string(); |
| 3137 | store.save(&done).unwrap(); |
| 3138 | |
| 3139 | let launcher = SweepLauncher::new(Vec::new()); |
| 3140 | let swept = sweep_stale_jobs(&store, &launcher, now); |
| 3141 | assert_eq!(swept.len(), 1, "only the stale active job is swept"); |
| 3142 | assert_eq!(swept[0].id, "cloud_00000000000000e1"); |
| 3143 | let record = store.load("cloud_00000000000000e1").unwrap(); |
| 3144 | assert_eq!(record.status, CloudJobStatus::Failed); |
| 3145 | assert_eq!(record.finished_unix, Some(now)); |
| 3146 | assert!(record.note.contains("startup sweep")); |
| 3147 | assert!(record.note.contains("teardown was attempted")); |
| 3148 | assert_eq!(launcher.torn_down(), vec!["sandbox_stale".to_string()]); |
| 3149 | // The untouched records keep their state. |
| 3150 | assert_eq!( |
| 3151 | store.load("cloud_00000000000000e2").unwrap().status, |
| 3152 | CloudJobStatus::Launching |
| 3153 | ); |
| 3154 | assert_eq!( |
| 3155 | store.load("cloud_00000000000000e3").unwrap().status, |
| 3156 | CloudJobStatus::Done |
| 3157 | ); |
| 3158 | } |
| 3159 | |
| 3160 | #[test] |
| 3161 | fn sweep_persists_the_terminal_record_before_teardown() { |
| 3162 | let temp = tempfile::tempdir().unwrap(); |
| 3163 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3164 | let now = 10_000_000_u64; |
| 3165 | let mut stale = stored_job(CloudJobStatus::Running, now - STALE_ACTIVE_JOB_SECS - 60); |
| 3166 | stale.sandbox_id = Some("sandbox_stale".to_string()); |
| 3167 | store.save(&stale).unwrap(); |
| 3168 | let launcher = ObservingSweepLauncher { |
| 3169 | store: store.clone(), |
| 3170 | id: stale.id.clone(), |
| 3171 | status_at_teardown: std::sync::Mutex::new(None), |
| 3172 | }; |
| 3173 | let swept = sweep_stale_jobs(&store, &launcher, now); |
| 3174 | assert_eq!(swept.len(), 1); |
| 3175 | let seen = *launcher.status_at_teardown.lock().unwrap(); |
| 3176 | assert_eq!( |
| 3177 | seen, |
| 3178 | Some(CloudJobStatus::Failed), |
| 3179 | "teardown must observe an already-terminal record" |
| 3180 | ); |
| 3181 | } |
| 3182 | |
| 3183 | #[test] |
| 3184 | fn format_job_and_status_redact_remote_userinfo() { |
| 3185 | let mut job = stored_job(CloudJobStatus::Proposed, 10); |
| 3186 | job.remote_url = "https://user:token@github.com/org/repo.git".to_string(); |
| 3187 | let card = format_job(&job); |
| 3188 | assert!(!card.contains("token"), "{card}"); |
| 3189 | assert!(!card.contains("user:"), "{card}"); |
| 3190 | assert!(card.contains("github.com/org/repo.git"), "{card}"); |
| 3191 | let status = format_status( |
| 3192 | &remotes(&[("github", "https://user:token@github.com/org/repo.git")]), |
| 3193 | &CredentialState::Missing, |
| 3194 | &[], |
| 3195 | ); |
| 3196 | assert!(!status.contains("token"), "{status}"); |
| 3197 | } |
| 3198 | |
| 3199 | #[test] |
| 3200 | fn reconcile_deletes_sandboxes_for_terminal_or_absent_jobs_and_keeps_active() { |
| 3201 | let temp = tempfile::tempdir().unwrap(); |
| 3202 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3203 | let now = 10_000_000_u64; |
| 3204 | // Terminal job: its sandbox must go. |
| 3205 | let mut terminal = stored_job(CloudJobStatus::Canceled, now - 600); |
| 3206 | terminal.id = "cloud_00000000000000f1".to_string(); |
| 3207 | terminal.sandbox_id = Some("sandbox_terminal".to_string()); |
| 3208 | store.save(&terminal).unwrap(); |
| 3209 | // Active job: its sandbox must stay. |
| 3210 | let mut active = stored_job(CloudJobStatus::Running, now - 60); |
| 3211 | active.id = "cloud_00000000000000f2".to_string(); |
| 3212 | store.save(&active).unwrap(); |
| 3213 | |
| 3214 | let launcher = SweepLauncher::new(vec![ |
| 3215 | LabeledSandbox { |
| 3216 | sandbox_id: "sandbox_terminal".to_string(), |
| 3217 | job_id: Some("cloud_00000000000000f1".to_string()), |
| 3218 | }, |
| 3219 | LabeledSandbox { |
| 3220 | sandbox_id: "sandbox_active".to_string(), |
| 3221 | job_id: Some("cloud_00000000000000f2".to_string()), |
| 3222 | }, |
| 3223 | // Labeled for a job that no longer exists in the store. |
| 3224 | LabeledSandbox { |
| 3225 | sandbox_id: "sandbox_ghost".to_string(), |
| 3226 | job_id: Some("cloud_0000000000000bad".to_string()), |
| 3227 | }, |
| 3228 | // No usable job label at all. |
| 3229 | LabeledSandbox { |
| 3230 | sandbox_id: "sandbox_unlabeled".to_string(), |
| 3231 | job_id: None, |
| 3232 | }, |
| 3233 | ]); |
| 3234 | let report = reconcile_sandboxes(&store, &launcher).unwrap(); |
| 3235 | assert_eq!(report.deleted.len(), 3); |
| 3236 | assert!(report.deleted.contains(&"sandbox_terminal".to_string())); |
| 3237 | assert!(report.deleted.contains(&"sandbox_ghost".to_string())); |
| 3238 | assert!(report.deleted.contains(&"sandbox_unlabeled".to_string())); |
| 3239 | assert_eq!(report.live, 1); |
| 3240 | assert!(!launcher.torn_down().contains(&"sandbox_active".to_string())); |
| 3241 | } |
| 3242 | |
| 3243 | #[test] |
| 3244 | fn cancel_deletes_an_unrecorded_sandbox_by_label() { |
| 3245 | let temp = tempfile::tempdir().unwrap(); |
| 3246 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3247 | let mut pending = stored_job(CloudJobStatus::Running, 10_000_000); |
| 3248 | // The create POST landed but its response never arrived: the intent |
| 3249 | // is persisted and the id is unknowable — only the label can find it. |
| 3250 | pending.sandbox_pending = true; |
| 3251 | store.save(&pending).unwrap(); |
| 3252 | let launcher = SweepLauncher::new(vec![ |
| 3253 | LabeledSandbox { |
| 3254 | sandbox_id: "sandbox_lost".to_string(), |
| 3255 | job_id: Some(pending.id.clone()), |
| 3256 | }, |
| 3257 | LabeledSandbox { |
| 3258 | sandbox_id: "sandbox_other".to_string(), |
| 3259 | job_id: Some("cloud_00000000000000f9".to_string()), |
| 3260 | }, |
| 3261 | ]); |
| 3262 | let canceled = cancel_job(&store, &pending.id, &launcher).unwrap(); |
| 3263 | assert_eq!(canceled.status, CloudJobStatus::Canceled); |
| 3264 | assert!(canceled.note.contains("unrecorded")); |
| 3265 | assert!(canceled.note.contains("torn down")); |
| 3266 | assert_eq!( |
| 3267 | launcher.torn_down(), |
| 3268 | vec!["sandbox_lost".to_string()], |
| 3269 | "cancel deletes only this job's labeled sandbox" |
| 3270 | ); |
| 3271 | } |
| 3272 | |
| 3273 | #[test] |
| 3274 | fn quit_warning_names_live_jobs_and_stays_quiet_otherwise() { |
| 3275 | let temp = tempfile::tempdir().unwrap(); |
| 3276 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3277 | assert_eq!(live_job_quit_warning(&store), None); |
| 3278 | let mut live = stored_job(CloudJobStatus::OpeningPr, 10_000_000); |
| 3279 | live.id = "cloud_00000000000000aa".to_string(); |
| 3280 | store.save(&live).unwrap(); |
| 3281 | let warning = live_job_quit_warning(&store).expect("live job must warn"); |
| 3282 | assert!(warning.contains("cloud_00000000000000aa")); |
| 3283 | assert!(warning.contains("/dispatch cancel")); |
| 3284 | assert!(!warning.contains("Daytona")); |
| 3285 | let mut done = stored_job(CloudJobStatus::Done, 10_000_000); |
| 3286 | done.id = "cloud_00000000000000ab".to_string(); |
| 3287 | store.save(&done).unwrap(); |
| 3288 | let mut dead = stored_job(CloudJobStatus::Failed, 10_000_000); |
| 3289 | dead.id = "cloud_00000000000000ac".to_string(); |
| 3290 | store.save(&dead).unwrap(); |
| 3291 | // Still exactly one live job after adding terminal siblings. |
| 3292 | let warning = live_job_quit_warning(&store).expect("live job must warn"); |
| 3293 | assert!(warning.contains("cloud_00000000000000aa")); |
| 3294 | assert!(!warning.contains("cloud_00000000000000ab")); |
| 3295 | assert!(!warning.contains("cloud_00000000000000ac")); |
| 3296 | } |
| 3297 | |
| 3298 | #[test] |
| 3299 | fn sandbox_create_is_not_computer_entitlement() { |
| 3300 | use crate::computer_meter::{ |
| 3301 | ComputerAdmissionRequest, MeterBasis, bind_computer_admission, |
| 3302 | }; |
| 3303 | |
| 3304 | let temp = tempfile::tempdir().unwrap(); |
| 3305 | let store = CloudJobStore::from_path(temp.path().join("jobs")); |
| 3306 | let plan = plan_dispatch( |
| 3307 | &remotes(&[("github", "https://github.com/org/repo.git")]), |
| 3308 | "meter honesty", |
| 3309 | Some(Forge::Github), |
| 3310 | Some("codewhale/cloud-meter"), |
| 3311 | ) |
| 3312 | .unwrap(); |
| 3313 | let outcome = execute_dispatch( |
| 3314 | &store, |
| 3315 | plan, |
| 3316 | true, |
| 3317 | &CredentialState::Present { |
| 3318 | source: CredentialSource::Keyring, |
| 3319 | }, |
| 3320 | &MachineTokenState::Present, |
| 3321 | ) |
| 3322 | .unwrap(); |
| 3323 | let DispatchOutcome::Accepted(mut job) = outcome else { |
| 3324 | panic!("expected accept"); |
| 3325 | }; |
| 3326 | job.sandbox_id = Some("sbox_std8_a".to_string()); |
| 3327 | let admission = bind_computer_admission(ComputerAdmissionRequest { |
| 3328 | admission_id: "adm_dispatch".to_string(), |
| 3329 | account_id: "acct_demo".to_string(), |
| 3330 | computer_id: "cmp_dispatch".to_string(), |
| 3331 | run_id: job.id.clone(), |
| 3332 | provider: "daytona".to_string(), |
| 3333 | profile_id: "standard-8".to_string(), |
| 3334 | funding_authority: "coding_membership_included".to_string(), |
| 3335 | quote_id: "quote_dispatch".to_string(), |
| 3336 | expires_at: "2026-08-31T18:00:00.000Z".to_string(), |
| 3337 | meter_revision: String::new(), |
| 3338 | catalog_revision: String::new(), |
| 3339 | }) |
| 3340 | .unwrap(); |
| 3341 | let idle = ProviderObservation { |
| 3342 | provider: "daytona".to_string(), |
| 3343 | provider_sandbox_id: "sbox_std8_a".to_string(), |
| 3344 | provider_event_ref: "daytona:sbox_std8_a:idle".to_string(), |
| 3345 | state: "running".to_string(), |
| 3346 | idle: true, |
| 3347 | provider_accepted: true, |
| 3348 | meter_basis: MeterBasis::WallClock, |
| 3349 | cpu: 2, |
| 3350 | memory_gib: 8, |
| 3351 | disk_gib: 8, |
| 3352 | started_at: "2026-08-31T12:00:00.000Z".to_string(), |
| 3353 | ended_at: "2026-08-31T13:00:00.000Z".to_string(), |
| 3354 | }; |
| 3355 | assert_eq!( |
| 3356 | meter_cloud_job(&job, &admission, idle).unwrap_err().code(), |
| 3357 | "computer_meter_wall_clock_idle" |
| 3358 | ); |
| 3359 | let accepted = ProviderObservation { |
| 3360 | provider: "daytona".to_string(), |
| 3361 | provider_sandbox_id: "sbox_std8_a".to_string(), |
| 3362 | provider_event_ref: "daytona:sbox_std8_a:running".to_string(), |
| 3363 | state: "running".to_string(), |
| 3364 | idle: false, |
| 3365 | provider_accepted: true, |
| 3366 | meter_basis: MeterBasis::ProviderAcceptedActive, |
| 3367 | cpu: 2, |
| 3368 | memory_gib: 8, |
| 3369 | disk_gib: 8, |
| 3370 | started_at: "2026-08-31T12:00:00.000Z".to_string(), |
| 3371 | ended_at: "2026-08-31T12:10:00.000Z".to_string(), |
| 3372 | }; |
| 3373 | let receipt = meter_cloud_job(&job, &admission, accepted).unwrap(); |
| 3374 | assert_eq!(receipt.accepted_seconds, 600); |
| 3375 | assert_eq!(receipt.standard_equivalent_seconds, 600); |
| 3376 | assert_eq!(receipt.admission_id, admission.admission_id); |
| 3377 | } |
| 3378 | } |
| 3379 |