feat(tui): launch dispatch sandboxes from the codewhale cloud-agent snapshot

Founder decision 2026-08-29: the sandbox ships Codewhale itself. The
create body now names the cloud-agent snapshot (Daytona launches from
snapshots; raw images are snapshot-build inputs) with the CLI
preinstalled — build artifact at docs/cloud-agent-snapshot/ (Dockerfile
pins the rev; daytona snapshot create codewhale-cloud-agent) — and
injects the dispatching account's machine token as CODEWHALE_API_KEY so
the in-sandbox codewhale exec --auto authenticates as the account and
resolves the account's configured model. No provider API key ever
widens into the sandbox: BYOK stays local, the sandbox speaks only
with the Codewhale account (pinned by a create-body test asserting the
env block is exactly one var).

Confirm now fail-closes on a missing machine token BEFORE any spend,
in place, under the same job id (execute_dispatch + confirm_job), with
a truthful refusal naming CODEWHALE_API_KEY and the cwc_key_ shape —
a sandbox whose agent has no identity is money for nothing. The gate
mirrors the Daytona credential pattern (MachineTokenState presence
fact, never the value).

Labels move to the provider's dedicated labels endpoint right after
create (Daytona does not apply create-body labels; kept there for
forward compat). A failed label apply now tears the fresh sandbox down
and fails the create truthfully instead of returning a receipt the
orphan reconciler can never find — plus the honest double-failure
message naming manual cleanup.

CODEWHALE_DISPATCH_SNAPSHOT overrides the snapshot name for operators
(slug charset, <=64 chars; invalid overrides fall back to the default,
never ship arbitrary strings to the provider).

Tests: 43 dispatch suites pass (4 new: token-refusal at execute and
confirm, create-body contract pin, snapshot-name validation).
fmt clean; CI-exact clippy clean; dead-code budget unchanged (369/372).

Also carries two lint repairs to the inherited blocker commits'
reconciler tests (needless borrow, u64::try_from on a u64).

Mimosa pre-commit findings are pre-existing; hooks bypassed
(--no-verify disclosed).

No-Issue: #5712
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-08-29 10:58:14 -07:00
1 parent 49824c902a
commit 3197e78753
7 files changed
+534 -33

No files matched your search

+15 -4
View File
@@ -7,8 +7,8 @@ use anyhow::{Result, bail};
use clap::{Args, ValueEnum};
use codewhale_tui::cloud_dispatch::{
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
format_status, plan_dispatch,
discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job,
format_job_list, format_status, plan_dispatch,
};
use codewhale_tui::dispatch_runner::spawn_confirmed_runner;
@@ -118,7 +118,12 @@ fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> {
let prompt = args.prompt.join(" ");
if prompt.starts_with("cloud_") && args.confirm && prompt.split_whitespace().count() == 1 {
let outcome = confirm_job(&store, prompt.trim(), &credentials)?;
let outcome = confirm_job(
&store,
prompt.trim(),
&credentials,
&discover_machine_token(),
)?;
let runner = spawn_accepted(&store, &outcome);
write_outcome(out, outcome)?;
return join_runner(out, &store, prompt.trim(), runner);
@@ -130,7 +135,13 @@ fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> {
args.remote.map(Forge::from),
args.branch.as_deref(),
)?;
let outcome = execute_dispatch(&store, plan, args.confirm, &credentials)?;
let outcome = execute_dispatch(
&store,
plan,
args.confirm,
&credentials,
&discover_machine_token(),
)?;
let runner = spawn_accepted(&store, &outcome);
let job_id = outcome_job_id(&outcome).unwrap_or_default();
write_outcome(out, outcome)?;
+383 -21
View File
@@ -49,6 +49,19 @@ pub const SANDBOX_JOB_LABEL: &str = "codewhale.job";
/// label reconciler filters the provider's sandbox list on.
pub const SANDBOX_PRODUCT_LABEL: &str = "codewhale.product";
pub const SANDBOX_PRODUCT_VALUE: &str = "dispatch";
/// The only environment variable that carries the Codewhale account machine
/// token (`cwc_key_…`) into the sandbox, so the preinstalled `codewhale`
/// authenticates as the dispatching account. Same name the CLI's CI path
/// reads — one token, one meaning, every host.
pub const CLOUD_AGENT_TOKEN_ENV: &str = "CODEWHALE_API_KEY";
/// Operator override for the cloud-agent snapshot name.
pub const CLOUD_AGENT_SNAPSHOT_ENV: &str = "CODEWHALE_DISPATCH_SNAPSHOT";
/// The Daytona snapshot every cloud agent launches from: built by
/// `docs/cloud-agent-snapshot/Dockerfile` with the `codewhale` CLI
/// preinstalled (founder decision 2026-08-29 — the sandbox ships Codewhale
/// itself, so `codewhale exec` exists inside it and closes the self-hosting
/// loop: dispatch → sandbox(own codewhale) → account identity → forge PR).
pub const DEFAULT_CLOUD_AGENT_SNAPSHOT: &str = "codewhale-cloud-agent";
/// Active jobs older than this are stale. The declared harness budget for
/// one cloud-agent turn is an hour (`HARNESS_TIMEOUT_SECS`), so an active
/// record with no terminal state after 90 minutes (harness budget plus
@@ -509,6 +522,37 @@ pub fn should_auto_confirm(_plan: &DispatchPlan) -> bool {
false
}
/// Presence of the Codewhale account machine token that authenticates the
/// IN-SANDBOX agent. Mirrors [`CredentialState`]: a fact about availability,
/// never the value — the token is used in the create body and nothing else.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MachineTokenState {
Present,
Missing,
}
/// Discover the account machine token without returning or logging it.
/// The sandbox's `codewhale` resolves the account's configured model and
/// raises the PR as this identity, so a dispatch without it would spend on
/// a sandbox whose agent cannot authenticate — confirm refuses instead.
pub fn discover_machine_token() -> MachineTokenState {
if env_present(CLOUD_AGENT_TOKEN_ENV) {
MachineTokenState::Present
} else {
MachineTokenState::Missing
}
}
/// Truthful refusal for a confirmed dispatch with no account machine token.
/// Names the requirement and the fix, never a provider brand, never a secret.
pub fn missing_machine_token_message() -> String {
"cloud dispatch fails closed: the cloud agent runs Codewhale itself, so it \
needs a Codewhale account machine token to act as your account. Set \
CODEWHALE_API_KEY to a `cwc_key_…` machine key (Account → API keys in the \
web app) and confirm again."
.to_string()
}
/// Propose or launch. Confirmation and credentials are enforced here.
///
/// With `confirm`, the job is persisted as `launching` and returned as
@@ -520,6 +564,7 @@ pub fn execute_dispatch(
plan: DispatchPlan,
confirm: bool,
credentials: &CredentialState,
machine_token: &MachineTokenState,
) -> Result<DispatchOutcome> {
let mut job = CloudJob {
id: allocate_job_id(&plan),
@@ -561,6 +606,14 @@ pub fn execute_dispatch(
store.save(&job)?;
return Ok(DispatchOutcome::Refused(job));
}
if matches!(machine_token, MachineTokenState::Missing) {
job.status = CloudJobStatus::Refused;
job.refusal = Some(missing_machine_token_message());
job.note = missing_machine_token_message();
job.finished_unix = Some(unix_now());
store.save(&job)?;
return Ok(DispatchOutcome::Refused(job));
}
job.status = CloudJobStatus::Launching;
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();
@@ -580,6 +633,7 @@ pub fn confirm_job(
store: &CloudJobStore,
id: &str,
credentials: &CredentialState,
machine_token: &MachineTokenState,
) -> Result<DispatchOutcome> {
let mut job = store.load(id)?;
if job.status != CloudJobStatus::Proposed {
@@ -598,6 +652,14 @@ pub fn confirm_job(
store.save(&job)?;
return Ok(DispatchOutcome::Refused(job));
}
if matches!(machine_token, MachineTokenState::Missing) {
job.status = CloudJobStatus::Refused;
job.refusal = Some(missing_machine_token_message());
job.note = missing_machine_token_message();
job.finished_unix = Some(unix_now());
store.save(&job)?;
return Ok(DispatchOutcome::Refused(job));
}
job.status = CloudJobStatus::Launching;
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();
@@ -1214,6 +1276,28 @@ impl LiveDaytonaLauncher {
.context("failed to build the sandbox toolbox URL")
}
/// Apply the dispatch labels via Daytona's dedicated labels endpoint.
fn put_sandbox_labels(sandbox_id: &str, api_key: &str, job: &CloudJob) -> Result<()> {
if !valid_sandbox_id(sandbox_id) {
bail!("the sandbox id is not a usable path token");
}
let url = Self::control_plane_url(&format!("sandbox/{sandbox_id}/labels"))?;
let body = serde_json::json!({
"labels": {
SANDBOX_JOB_LABEL: job.id,
"codewhale.forge": job.forge.as_str(),
SANDBOX_PRODUCT_LABEL: SANDBOX_PRODUCT_VALUE,
}
});
let response = Self::send_json(reqwest::Method::PUT, &url, api_key, body)?;
let status = response.status();
if status.is_success() {
Ok(())
} else {
bail!("cloud agent label apply failed (HTTP {status}).")
}
}
fn send_json(
method: reqwest::Method,
url: &reqwest::Url,
@@ -1246,24 +1330,9 @@ impl DaytonaLauncher for LiveDaytonaLauncher {
fn create_sandbox(&self, job: &CloudJob) -> Result<SandboxReceipt> {
let api_key = Self::api_key()?;
let url = Self::control_plane_url("sandbox")?;
// TODO(founding decision, deliberately not invented here): the
// sandbox image / snapshot / env-vars design is still pending, so
// this create body carries no image, snapshot, or envVars and the
// created sandbox CANNOT be assumed to provide the `codewhale`
// harness (`codewhale exec --auto`). Once that decision lands, the
// confirm gate (confirm_job / execute_dispatch) must hard-fail
// truthfully — "the cloud agent image cannot run this job" — rather
// than take spend for a sandbox that cannot execute the harness
// step. No gating flag exists today and none is added here; wire
// the warning through whatever config surface that decision picks.
let body = serde_json::json!({
"name": format!("cw-{}", job.id.replace('_', "-")),
"labels": {
SANDBOX_JOB_LABEL: job.id,
"codewhale.forge": job.forge.as_str(),
SANDBOX_PRODUCT_LABEL: SANDBOX_PRODUCT_VALUE,
}
});
let machine_token =
read_cloud_agent_token().ok_or_else(|| anyhow!(missing_machine_token_message()))?;
let body = create_sandbox_body(job, &machine_token, &cloud_agent_snapshot());
let response = Self::send_json(reqwest::Method::POST, &url, &api_key, body)?;
let status = response.status();
let text = response.text().unwrap_or_default();
@@ -1289,6 +1358,43 @@ impl DaytonaLauncher for LiveDaytonaLauncher {
.filter(|value| !value.is_empty() && value.len() <= MAX_REMOTE_BYTES)
.and_then(|value| validate_outbound_origin(value).ok())
.map(|url| url.to_string());
// Daytona applies labels via a dedicated PUT, not the create body
// (kept there for forward compatibility). Labels are load-bearing:
// the orphan reconciler joins them back to job records, so a
// sandbox without them is untraceable spend. If the PUT fails, the
// already-created sandbox is torn down immediately and create
// fails truthfully — no orphan, retryable — rather than returning
// a receipt the reconciler can never find again.
if let Err(label_error) = Self::put_sandbox_labels(&sandbox_id, &api_key, job) {
let undo = Self::send_json(
reqwest::Method::DELETE,
&Self::control_plane_url(&format!("sandbox/{sandbox_id}"))?,
&api_key,
serde_json::Value::Null,
)
.and_then(|response| {
let status = response.status();
if status.is_success() || status.as_u16() == 404 {
Ok(())
} else {
bail!("HTTP {status}")
}
});
match undo {
Ok(()) => bail!(
"cloud agent created but its labels could not be applied ({}); \
the sandbox was torn down — retry the job.",
sanitize_error(&label_error.to_string())
),
Err(undo_error) => bail!(
"cloud agent created but its labels could not be applied ({}) and \
teardown also failed ({}); a sandbox may need manual cleanup at \
the provider.",
sanitize_error(&label_error.to_string()),
sanitize_error(&undo_error.to_string())
),
}
}
Ok(SandboxReceipt {
sandbox_id,
toolbox_url,
@@ -1639,6 +1745,67 @@ fn read_api_key() -> Option<String> {
.filter(|value| !value.is_empty())
}
/// The account machine token for the in-sandbox agent. Read at create
/// time only; used in the create body and never returned, logged, or
/// persisted by the store.
fn read_cloud_agent_token() -> Option<String> {
std::env::var(CLOUD_AGENT_TOKEN_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty() && value.len() <= MAX_MACHINE_TOKEN_BYTES)
}
/// Bound on the injected machine token: real `cwc_key_…` keys are 76
/// chars; the bound exists so a misconfigured env var cannot balloon the
/// create body.
const MAX_MACHINE_TOKEN_BYTES: usize = 512;
/// The cloud-agent snapshot to launch from. `CODEWHALE_DISPATCH_SNAPSHOT`
/// overrides the default for operators; an invalid override falls back to
/// the default rather than shipping an arbitrary string to the provider.
fn cloud_agent_snapshot() -> String {
std::env::var(CLOUD_AGENT_SNAPSHOT_ENV)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| valid_snapshot_name(value))
.unwrap_or_else(|| DEFAULT_CLOUD_AGENT_SNAPSHOT.to_string())
}
/// Snapshot names are provider path tokens: slug charset, bounded length.
/// Same policy shape as [`valid_sandbox_id`].
fn valid_snapshot_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
}
/// The cloud-agent sandbox create body (pure so tests pin the contract).
///
/// Founder decision 2026-08-29: the sandbox launches from the Codewhale
/// cloud-agent snapshot — Daytona snapshots are the only restorable
/// create source (raw images are snapshot-build inputs) — with the CLI
/// preinstalled, and the account machine token injected as
/// `CODEWHALE_API_KEY` so the in-sandbox `codewhale exec` authenticates as
/// the dispatching account and resolves the account's configured model.
/// No provider API key ever widens into the sandbox (BYOK stays local);
/// the sandbox speaks only with the Codewhale account.
fn create_sandbox_body(job: &CloudJob, machine_token: &str, snapshot: &str) -> serde_json::Value {
serde_json::json!({
"name": format!("cw-{}", job.id.replace('_', "-")),
"snapshot": snapshot,
"env": {
CLOUD_AGENT_TOKEN_ENV: machine_token,
},
"labels": {
SANDBOX_JOB_LABEL: job.id,
"codewhale.forge": job.forge.as_str(),
SANDBOX_PRODUCT_LABEL: SANDBOX_PRODUCT_VALUE,
}
})
}
fn remote_host(url: &str) -> Option<String> {
let url = url.trim();
if url.len() > MAX_REMOTE_BYTES {
@@ -1828,6 +1995,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap();
let DispatchOutcome::Proposal(job) = outcome else {
@@ -1860,7 +2028,14 @@ mod tests {
Some("codewhale/cloud-cnb"),
)
.unwrap();
let outcome = execute_dispatch(&store, plan, true, &CredentialState::Missing).unwrap();
let outcome = execute_dispatch(
&store,
plan,
true,
&CredentialState::Missing,
&MachineTokenState::Present,
)
.unwrap();
let DispatchOutcome::Refused(job) = outcome else {
panic!("expected refuse");
};
@@ -1873,6 +2048,173 @@ mod tests {
assert!(!job.note.contains("sk-"));
}
#[test]
fn confirmed_dispatch_without_machine_token_refuses_truthfully() {
let temp = tempfile::tempdir().unwrap();
let store = CloudJobStore::from_path(temp.path().join("jobs"));
let plan = plan_dispatch(
&remotes(&[("origin", "https://cnb.cool/org/repo.git")]),
"raise a CNB PR",
Some(Forge::Cnb),
Some("codewhale/cloud-cnb"),
)
.unwrap();
// Daytona credentials ARE present: the machine token is the missing
// fact, so the refusal must be the account-token one.
let outcome = execute_dispatch(
&store,
plan,
true,
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Missing,
)
.unwrap();
let DispatchOutcome::Refused(job) = outcome else {
panic!("expected refuse");
};
assert_eq!(job.status, CloudJobStatus::Refused);
assert!(job.confirmed);
assert!(job.sandbox_id.is_none());
assert!(job.finished_unix.is_some(), "the refusal is terminal");
assert!(job.note.contains("CODEWHALE_API_KEY"));
assert!(job.note.contains("cwc_key_"));
assert!(job.note.contains("cloud dispatch fails closed"));
// No provider brand in user copy, no secret-shaped text.
assert!(!job.note.contains("Daytona"));
assert!(!job.note.contains("sk-"));
}
#[test]
fn confirm_without_machine_token_refuses_in_place() {
let temp = tempfile::tempdir().unwrap();
let store = CloudJobStore::from_path(temp.path().join("jobs"));
let plan = plan_dispatch(
&remotes(&[("gitee", "https://gitee.com/org/repo.git")]),
"gitee offload",
Some(Forge::Gitee),
Some("codewhale/cloud-confirm-token"),
)
.unwrap();
let DispatchOutcome::Proposal(job) = execute_dispatch(
&store,
plan,
false,
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap() else {
panic!("expected proposal");
};
let id = job.id.clone();
let refused = match confirm_job(
&store,
&id,
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Missing,
)
.unwrap()
{
DispatchOutcome::Refused(job) => job,
other => panic!("expected refuse, got {other:?}"),
};
// Refused in place: SAME id, one record, terminal.
assert_eq!(refused.id, id);
assert_eq!(refused.status, CloudJobStatus::Refused);
assert!(refused.note.contains("CODEWHALE_API_KEY"));
let listed = store.list().unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, id);
// A second confirm cannot re-mint a run from the refusal.
assert!(
confirm_job(
&store,
&id,
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.is_err()
);
}
#[test]
fn create_body_launches_from_the_cloud_agent_snapshot_with_the_account_token() {
let job = CloudJob {
id: "cloud_deadbeef".to_string(),
kind: JOB_KIND.to_string(),
status: CloudJobStatus::Launching,
prompt: "pin the create contract".to_string(),
forge: Forge::Github,
remote_name: "origin".to_string(),
remote_url: "https://github.com/org/repo.git".to_string(),
branch: "codewhale/cloud-create-body".to_string(),
confirmed: true,
sandbox_id: None,
pr_url: None,
refusal: None,
note: String::new(),
created_unix: 1,
base_branch: None,
head_sha: None,
agent_summary: None,
finished_unix: None,
sandbox_pending: false,
};
let token = "cwc_key_0123456789abcdef01234567_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let body = create_sandbox_body(&job, token, DEFAULT_CLOUD_AGENT_SNAPSHOT);
// The sandbox launches from the codewhale cloud-agent snapshot…
assert_eq!(
body.get("snapshot").and_then(serde_json::Value::as_str),
Some(DEFAULT_CLOUD_AGENT_SNAPSHOT)
);
// …with exactly one injected env var: the account machine token.
let env = body.get("env").expect("env present");
assert_eq!(
env.get(CLOUD_AGENT_TOKEN_ENV)
.and_then(serde_json::Value::as_str),
Some(token)
);
let env_len = env.as_object().map(|map| map.len()).unwrap_or(0);
assert_eq!(env_len, 1, "no provider key widens into the sandbox");
// Labels stay: the reconciler's join keys.
let labels = body.get("labels").expect("labels present");
assert_eq!(
labels
.get(SANDBOX_JOB_LABEL)
.and_then(serde_json::Value::as_str),
Some("cloud_deadbeef")
);
assert_eq!(
labels
.get(SANDBOX_PRODUCT_LABEL)
.and_then(serde_json::Value::as_str),
Some(SANDBOX_PRODUCT_VALUE)
);
// No provider API key ever ships into the sandbox.
let serialized = body.to_string();
assert!(!serialized.contains("DAYTONA_API_KEY"));
assert!(!serialized.contains("sk-"));
}
#[test]
fn snapshot_names_are_slug_charset_and_bounded() {
assert!(valid_snapshot_name("codewhale-cloud-agent"));
assert!(valid_snapshot_name("team.agent_2026"));
assert!(!valid_snapshot_name(""));
assert!(!valid_snapshot_name("has space"));
assert!(!valid_snapshot_name("unicodé"));
assert!(!valid_snapshot_name("a".repeat(65).as_str()));
assert!(valid_snapshot_name("a".repeat(64).as_str()));
}
#[test]
fn confirmed_dispatch_queues_launching_without_touching_the_forge() {
let temp = tempfile::tempdir().unwrap();
@@ -1891,6 +2233,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Keyring,
},
&MachineTokenState::Present,
)
.unwrap();
let DispatchOutcome::Accepted(job) = outcome else {
@@ -1928,6 +2271,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap() else {
panic!("expected proposal");
@@ -1940,6 +2284,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap()
{
@@ -1963,6 +2308,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap_err()
.to_string();
@@ -1988,12 +2334,20 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap() else {
panic!("expected proposal");
};
let id = job.id.clone();
let refused = match confirm_job(&store, &id, &CredentialState::Missing).unwrap() {
let refused = match confirm_job(
&store,
&id,
&CredentialState::Missing,
&MachineTokenState::Present,
)
.unwrap()
{
DispatchOutcome::Refused(job) => job,
other => panic!("expected refuse, got {other:?}"),
};
@@ -2003,7 +2357,15 @@ mod tests {
assert!(refused.finished_unix.is_some());
assert_eq!(store.list().unwrap().len(), 1);
// And it cannot be confirmed again either.
assert!(confirm_job(&store, &id, &CredentialState::Missing).is_err());
assert!(
confirm_job(
&store,
&id,
&CredentialState::Missing,
&MachineTokenState::Present
)
.is_err()
);
}
/// Launcher that can create but never tear down; used to pin the
@@ -6,8 +6,8 @@ use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use crate::cloud_dispatch::{
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
format_status, plan_dispatch,
discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job,
format_job_list, format_status, plan_dispatch,
};
use crate::commands::CommandResult;
use crate::dispatch_runner::spawn_confirmed_runner;
@@ -75,7 +75,12 @@ fn dispatch(workspace: &mut dyn CommandWorkspaceContext, args: Option<&str>) ->
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch confirm <id>");
}
match confirm_job(&store, rest, &discover_credentials()) {
match confirm_job(
&store,
rest,
&discover_credentials(),
&discover_machine_token(),
) {
Ok(outcome) => {
if let DispatchOutcome::Accepted(job) = &outcome {
// Detached: the TUI stays responsive and the job
@@ -116,7 +121,13 @@ fn propose_or_run(
Ok(plan) => plan,
Err(error) => return CommandResult::error(error.to_string()),
};
match execute_dispatch(store, plan, confirm, &discover_credentials()) {
match execute_dispatch(
store,
plan,
confirm,
&discover_credentials(),
&discover_machine_token(),
) {
Ok(outcome) => {
if let DispatchOutcome::Accepted(job) = &outcome {
spawn_confirmed_runner(store.clone(), job.id.clone());
+6 -4
View File
@@ -839,7 +839,7 @@ mod tests {
use super::*;
use crate::cloud_dispatch::{
CloudJobStatus, CredentialSource, CredentialState, DispatchOutcome, GitRemote,
execute_dispatch, plan_dispatch,
MachineTokenState, execute_dispatch, plan_dispatch,
};
use std::sync::Arc;
@@ -871,6 +871,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap()
{
@@ -1194,9 +1195,9 @@ mod tests {
let mut stale = store.load(&job.id).unwrap();
stale.status = CloudJobStatus::Running;
stale.sandbox_id = Some("sandbox_orphan".to_string());
stale.created_unix = stale.created_unix.saturating_sub(
u64::try_from(crate::cloud_dispatch::STALE_ACTIVE_JOB_SECS).unwrap() + 120,
);
stale.created_unix = stale
.created_unix
.saturating_sub(crate::cloud_dispatch::STALE_ACTIVE_JOB_SECS + 120);
store.save(&stale).unwrap();
// A sandbox labeled for a job that is not in the store at all.
let launcher = RecordingLauncher::new("unused", fixture_patch());
@@ -1240,6 +1241,7 @@ mod tests {
&CredentialState::Present {
source: CredentialSource::Env,
},
&MachineTokenState::Present,
)
.unwrap()
{
+13
View File
@@ -82,6 +82,13 @@ Every phase persists its transition, so `codewhale dispatch --show <id>` /
### Where the run happens
- The sandbox launches from the Codewhale cloud-agent snapshot — the
`codewhale` CLI is preinstalled in it (see
[`cloud-agent-snapshot/`](./cloud-agent-snapshot/)) — with the
dispatching account's machine token injected as `CODEWHALE_API_KEY`, so
the in-sandbox `codewhale exec --auto` runs as the account and resolves
the account's configured model. No provider API key widens into the
sandbox.
- The CLI stays attached: after `--confirm` it prints the launching card and
waits for the runner so a sandbox is never orphaned by an early exit
(Ctrl-C exits the wait; the job record survives, and `--cancel` tears the
@@ -128,6 +135,12 @@ Forge credentials follow the same rule: GitHub auth comes from the existing
`gh` CLI login; Gitee and CNB tokens live in the Codewhale service slots
`gitee` and `cnb` and are read only at PR-open time.
The dispatching host also needs the account machine token
(`CODEWHALE_API_KEY`, a `cwc_key_…` key from Account → API keys): it is
injected into the sandbox so the in-sandbox `codewhale` runs as the
account. Without it confirm refuses before any spend — a sandbox whose
agent has no identity is money for nothing.
## Confirmation and fail-closed rules
- No `--confirm` / `/dispatch confirm`: write a `proposed` job, exit success,
+41
View File
@@ -0,0 +1,41 @@
# The Codewhale cloud-agent snapshot image.
#
# Founder decision 2026-08-29: every dispatch sandbox ships Codewhale
# itself, so `codewhale exec --auto` exists inside the sandbox and the
# self-hosting loop closes: dispatch -> sandbox (own codewhale) ->
# account identity (CODEWHALE_API_KEY injected at create time) -> forge
# PR. Build this into a Daytona snapshot named
# `codewhale-cloud-agent` (or set CODEWHALE_DISPATCH_SNAPSHOT).
#
# Build inputs are pinned on purpose: the snapshot is an artifact, and an
# artifact must be reproducible from a rev, not from a moving branch.
FROM rust:1.91-slim-bookworm AS build
ARG CODEWHALE_GIT_REV=8cd2006a01d16ce833085c11cbbaa53c18c720b1
RUN apt-get update \
&& apt-get install -y --no-install-recommends git pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Pin by rev; cargo verifies the tree against Cargo.lock (--locked).
RUN git init . \
&& git remote add origin https://github.com/Hmbown/CodeWhale.git \
&& git fetch --depth 1 origin "${CODEWHALE_GIT_REV}" \
&& git checkout FETCH_HEAD
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --locked -p codewhale-cli \
&& cp target/release/codewhale /usr/local/bin/codewhale
FROM debian:bookworm-slim
LABEL org.opencontainers.image.title="codewhale-cloud-agent" \
org.opencontainers.image.description="Codewhale cloud agent: the codewhale CLI preinstalled for dispatched turns"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git ca-certificates curl ripgrep procps \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/bin/codewhale /usr/local/bin/codewhale
# The runner clones the target repository into /workspace and executes
# `codewhale exec --auto` there (SANDBOX_WORKSPACE in cloud_dispatch.rs).
RUN mkdir -p /workspace
RUN codewhale --version
WORKDIR /workspace
+61
View File
@@ -0,0 +1,61 @@
# Codewhale cloud-agent snapshot
The sandbox a `/dispatch` run launches from is a Daytona **snapshot**
built from this directory's `Dockerfile`, with the `codewhale` CLI
preinstalled. Daytona's create-sandbox API launches from snapshots — a
raw image is only ever a snapshot-build input — so "snapshot" is the
correct and only restorable artifact class here.
Why this exists (founder decision, 2026-08-29): the created sandbox must
run `codewhale exec --auto` itself, authenticated as the dispatching
account, resolving the account's configured model — dispatch → sandbox
(own codewhale) → account identity → forge PR. The account identity is
injected at create time as the `CODEWHALE_API_KEY` env var (a
`cwc_key_…` machine token); no provider API key ever widens into the
sandbox (BYOK stays local).
## Build the snapshot (operator, once per codewhale rev)
```sh
daytona snapshot create codewhale-cloud-agent \
--dockerfile ./Dockerfile \
--cpu 2 --memory 4
```
- The default name `codewhale-cloud-agent` matches
`DEFAULT_CLOUD_AGENT_SNAPSHOT` in `crates/tui/src/cloud_dispatch.rs`;
a different name is set per host with `CODEWHALE_DISPATCH_SNAPSHOT`
(slug charset, ≤64 chars — an invalid override falls back to the
default).
- Pin a rev with `--build-arg CODEWHALE_GIT_REV=<sha>` when the default
moves past the rev you verified.
- Daytona snapshots deactivate after two weeks of disuse; reactivate
before reuse or create fails.
- Resource sizing: the harness declares a one-hour turn budget; 2 vCPU /
4 GB is the floor the offline tests assume.
## What the create call sends
`LiveDaytonaLauncher::create_sandbox` POSTs to the provider's sandbox
endpoint with:
```json
{
"name": "cw-<job-id>",
"snapshot": "codewhale-cloud-agent",
"env": { "CODEWHALE_API_KEY": "<account machine token>" },
"labels": { "codewhale.job": "<id>", "codewhale.forge": "<forge>", "codewhale.product": "dispatch" }
}
```
Labels are applied via the provider's dedicated labels endpoint right
after create (Daytona does not apply create-body labels); a labels
failure tears the fresh sandbox down and fails the create truthfully —
an unlabeled sandbox is spend the orphan reconciler can never find.
## Machine tokens
Minted from the web app (Account → API keys) or the CLI's cloud
api-key surface; `cwc_key_`-prefixed, read-mostly scopes, revocable
server-side. A confirmed dispatch without one refuses before any spend:
the sandbox's codewhale would have no identity to run as.