返回 CodeWhale
cloud_dispatch.rs
根目录 / crates / tui / src / cloud_dispatch.rs
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
3379 lines RUST