feat(tui): gate /dispatch on Codewhale membership and hide the sandbox brand

Daytona is the provider Codewhale operates, not a user-facing product:
membership (the Operate door) entitles cloud agents, and users never see
or set a provider key. The fail-closed path now checks the account
session (AccountSessionStore) and tells the user to sign in with
`codewhale login` instead of exporting DAYTONA_API_KEY; status/notes/
locale copy say "Codewhale cloud agent" everywhere the brand used to
leak. Credential discovery is unchanged for the service-side path, and
tests now pin the no-brand invariant.

Signed-off-by: Hunter Bown <[email protected]>
This commit is contained in:
Hunter Bown committed 2026-08-28 18:43:23 -07:00
1 parent 0791d83554
commit 4081785fbe
17 files changed
+63 -66

No files matched your search

+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "Activa o desactiva els diagnòstics LSP",
"CmdShareDescription": "Exporta la sessió actual com a URL web compartible",
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
"CmdDispatchDescription": "Envia un agent cloud Daytona per crear una branca i obrir un PR",
"CmdDispatchDescription": "Envia un agent cloud de Codewhale per crear una branca i obrir un PR",
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "LSP-Diagnosen ein- oder ausschalten",
"CmdShareDescription": "Aktuelle Sitzung als teilbare Web-URL exportieren",
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
"CmdDispatchDescription": "Daytona-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
"CmdDispatchDescription": "Codewhale-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
"CmdLoadDescription": "Sitzung aus Datei laden",
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "Toggle LSP diagnostics on or off",
"CmdShareDescription": "Export current session as a shareable web URL",
"CmdJobsDescription": "Inspect and control background Bash jobs",
"CmdDispatchDescription": "Send a Daytona cloud agent to raise a branch and open a PR",
"CmdDispatchDescription": "Send a Codewhale cloud agent to raise a branch and open a PR",
"CmdLinksDescription": "Show Codewhale, community, and provider links",
"CmdLoadDescription": "Load session from file",
"CmdLogoutDescription": "Clear API key and return to setup",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "Alternar diagnóstico LSP encendido o apagado",
"CmdShareDescription": "Exportar la sesión actual como una URL web compartible",
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
"CmdDispatchDescription": "Enviar un agente cloud Daytona para crear una rama y abrir un PR",
"CmdDispatchDescription": "Enviar un agente cloud de Codewhale para crear una rama y abrir un PR",
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
"CmdLoadDescription": "Cargar la sesión desde un archivo",
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "Activer ou désactiver les diagnostics LSP",
"CmdShareDescription": "Exporter la session en cours en URL web partageable",
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
"CmdDispatchDescription": "Envoyer un agent cloud Daytona pour créer une branche et ouvrir une PR",
"CmdDispatchDescription": "Envoyer un agent cloud Codewhale pour créer une branche et ouvrir une PR",
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
"CmdLoadDescription": "Charger une session depuis un fichier",
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "LSP डायग्नोस्टिक्स चालू/बंद करें",
"CmdShareDescription": "वर्तमान सत्र को साझा करने योग्य वेब URL के रूप में एक्सपोर्ट करें",
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Daytona क्लाउड एजेंट भेजें",
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Codewhale क्लाउड एजेंट भेजें",
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "Aktifkan atau nonaktifkan diagnostik LSP",
"CmdShareDescription": "Ekspor sesi saat ini sebagai URL web yang dapat dibagikan",
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
"CmdDispatchDescription": "Kirim agen cloud Daytona untuk membuat branch dan membuka PR",
"CmdDispatchDescription": "Kirim agen cloud Codewhale untuk membuat branch dan membuka PR",
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
"CmdLoadDescription": "Muat sesi dari file",
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "LSP 診断のオン・オフを切り替え",
"CmdShareDescription": "現在のセッションを共有可能な Web URL としてエクスポート",
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
"CmdDispatchDescription": "Daytona クラウドエージェントを送り、ブランチと PR を作成",
"CmdDispatchDescription": "Codewhale クラウドエージェントを送り、ブランチと PR を作成",
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
"CmdLoadDescription": "ファイルからセッションを読み込み",
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "LSP 진단을 켜거나 끕니다",
"CmdShareDescription": "현재 세션을 공유 가능한 웹 URL로 내보냅니다",
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
"CmdDispatchDescription": "Daytona 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
"CmdDispatchDescription": "Codewhale 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "Alternar diagnóstico LSP ligado ou desligado",
"CmdShareDescription": "Exportar a sessão atual como uma URL web compartilhável",
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
"CmdDispatchDescription": "Enviar um agente cloud Daytona para criar um branch e abrir um PR",
"CmdDispatchDescription": "Enviar um agente cloud da Codewhale para criar um branch e abrir um PR",
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
"CmdLoadDescription": "Carregar a sessão de um arquivo",
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "Включить или выключить диагностику LSP",
"CmdShareDescription": "Экспортировать текущую сессию как веб-ссылку для общего доступа",
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
"CmdDispatchDescription": "Отправить облачного агента Daytona, чтобы создать ветку и открыть PR",
"CmdDispatchDescription": "Отправить облачного агента Codewhale, чтобы создать ветку и открыть PR",
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
"CmdLoadDescription": "Загрузить сессию из файла",
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
+1 -1
View File
@@ -397,7 +397,7 @@
"CmdLspDescription": "Увімкнути або вимкнути діагностику LSP",
"CmdShareDescription": "Експортувати поточний сеанс як веб-URL для поширення",
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
"CmdDispatchDescription": "Надіслати хмарного агента Daytona, щоб створити гілку й відкрити PR",
"CmdDispatchDescription": "Надіслати хмарного агента Codewhale, щоб створити гілку й відкрити PR",
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
"CmdLoadDescription": "Завантажити сеанс з файлу",
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "Bật hoặc tắt tính năng chẩn đoán LSP",
"CmdShareDescription": "Xuất phiên hiện tại thành một liên kết web có thể chia sẻ",
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
"CmdDispatchDescription": "Gửi tác nhân đám mây Daytona để tạo nhánh và mở PR",
"CmdDispatchDescription": "Gửi tác nhân đám mây Codewhale để tạo nhánh và mở PR",
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
+1 -1
View File
@@ -400,7 +400,7 @@
"CmdLspDescription": "切换 LSP 诊断的开启或关闭",
"CmdShareDescription": "将当前会话导出为可共享的 Web URL",
"CmdJobsDescription": "查看并管理后台 shell 作业",
"CmdDispatchDescription": "派遣 Daytona 云端代理以创建分支并打开拉取请求",
"CmdDispatchDescription": "派遣 Codewhale 云端代理以创建分支并打开拉取请求",
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
"CmdLoadDescription": "从文件加载会话",
"CmdLogoutDescription": "清除 API 密钥并返回设置",
+1 -1
View File
@@ -288,7 +288,7 @@
"CmdHotbarDescription": "開啟 Hotbar 設定",
"CmdInitDescription": "為項目生成 AGENTS.md",
"CmdJobsDescription": "檢視並管理後台 shell 作業",
"CmdDispatchDescription": "派遣 Daytona 雲端代理以建立分支並開啟拉取請求",
"CmdDispatchDescription": "派遣 Codewhale 雲端代理以建立分支並開啟拉取請求",
"CmdLaneDescription": "檢視與控制持久化的 Lane(執行中的工作流程)",
"CmdLinksDescription": "顯示 Codewhale、社群與提供商鏈接",
"CmdLoadDescription": "從檔案載入工作階段",
+45 -48
View File
@@ -32,7 +32,6 @@ const CWC_DAYTONA_TOKEN_ENV: &str = "CWC_DAYTONA_TOKEN";
const CWC_DAYTONA_ENDPOINT_ENV: &str = "CWC_DAYTONA_ENDPOINT";
const KEYRING_SLOT: &str = "daytona";
const DEFAULT_DAYTONA_API: &str = "https://app.daytona.io/api";
const ENABLE_DOCS: &str = "docs/DAYTONA_CLOUD_DISPATCH.md";
/// Explicit PR forge. Never inferred from a generic "origin means GitHub" rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -412,19 +411,18 @@ pub fn discover_credentials() -> CredentialState {
}
}
/// Daytona CLI presence is not a credential. Used for truthful status copy.
pub fn daytona_cli_version() -> Option<String> {
let output = Command::new("daytona").arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().next()?.trim();
if line.is_empty() || line.len() > 80 {
None
} else {
Some(line.chars().filter(|ch| !ch.is_control()).collect())
}
/// Codewhale membership check. `/dispatch` cloud agents ship with the
/// account, so the fail-closed gate is sign-in — never a provider key.
fn membership_signed_in() -> bool {
let Ok(secrets) = codewhale_secrets::account::secure_account_session_secrets() else {
return false;
};
let store = codewhale_secrets::account::AccountSessionStore::new(
secrets,
None,
codewhale_secrets::account::DEFAULT_ACCOUNT_API_BASE,
);
matches!(store.load(), Ok(Some(_)))
}
/// Auto-decide leftover: Codewhale may propose, but never confirm itself.
@@ -482,7 +480,7 @@ pub fn execute_dispatch(
job.sandbox_id = Some(receipt.sandbox_id);
job.status = CloudJobStatus::Running;
job.pr_url = None;
job.note = "Daytona sandbox created. PR open is not claimed: this slice does not fake a forge pull request.".to_string();
job.note = "Cloud agent sandbox created. PR open is not claimed: this slice does not fake a forge pull request.".to_string();
store.save(&job)?;
Ok(DispatchOutcome::Accepted(job))
}
@@ -490,7 +488,7 @@ pub fn execute_dispatch(
job.status = CloudJobStatus::Failed;
job.refusal = Some(sanitize_error(&error.to_string()));
job.note = format!(
"Daytona launch failed closed. {}",
"Cloud agent launch failed closed. {}",
job.refusal.as_deref().unwrap_or("unknown error")
);
store.save(&job)?;
@@ -536,8 +534,8 @@ pub fn cancel_job(store: &CloudJobStore, id: &str) -> Result<CloudJob> {
return Ok(job);
}
job.status = CloudJobStatus::Canceled;
job.note = "Canceled locally. Live Daytona teardown is leftover when a sandbox was created."
.to_string();
job.note =
"Canceled locally. Live sandbox teardown is leftover when one was created.".to_string();
store.save(&job)?;
Ok(job)
}
@@ -545,7 +543,7 @@ pub fn cancel_job(store: &CloudJobStore, id: &str) -> Result<CloudJob> {
/// Human list used by `/jobs` (cloud kind) and `codewhale dispatch --list`.
pub fn format_job_list(jobs: &[CloudJob]) -> String {
if jobs.is_empty() {
return "Cloud jobs (0)\nNo Daytona cloud-agent jobs. Use `codewhale dispatch <prompt>` or `/dispatch <prompt>`.".to_string();
return "Cloud jobs (0)\nNo cloud-agent jobs yet. Use `codewhale dispatch <prompt>` or `/dispatch <prompt>`.".to_string();
}
let mut lines = vec![
format!("Cloud jobs ({}) kind=cloud", jobs.len()),
@@ -598,30 +596,28 @@ pub fn format_job(job: &CloudJob) -> String {
/// Status card for bare `/dispatch` and `codewhale dispatch --status`.
pub fn format_status(remotes: &[GitRemote], credentials: &CredentialState) -> String {
let mut lines = vec!["Codewhale Daytona cloud dispatch".to_string()];
let mut lines = vec!["Codewhale cloud dispatch".to_string()];
match credentials {
CredentialState::Missing => {
lines.push(format!(
"Daytona: not configured (fail-closed). Set {DAYTONA_API_KEY_ENV} or keyring slot `{KEYRING_SLOT}`. See {ENABLE_DOCS}."
));
if membership_signed_in() {
lines.push(
"Cloud agents are not available for this account yet; cloud dispatch fails closed (no sandbox, no push, no PR)."
.to_string(),
);
} else {
lines.push(
"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(),
);
}
}
CredentialState::Present { source } => {
let source = match source {
CredentialSource::Env => DAYTONA_API_KEY_ENV,
CredentialSource::CwcEnv => CWC_DAYTONA_TOKEN_ENV,
CredentialSource::Keyring => "keyring:daytona",
};
lines.push(format!(
"Daytona: credentials present ({source}). Confirmation is still required before spend or push."
));
CredentialState::Present { .. } => {
lines.push(
"Cloud agents: ready (account-linked). Confirmation is still required before spend or push."
.to_string(),
);
}
}
match daytona_cli_version() {
Some(version) => lines.push(format!(
"Daytona CLI: {version} (installed; not treated as a credential)."
)),
None => lines.push("Daytona CLI: not installed.".to_string()),
}
if remotes.is_empty() {
lines.push("Remotes: none discovered.".to_string());
} else {
@@ -694,10 +690,7 @@ impl DaytonaLauncher for LiveDaytonaLauncher {
let status = response.status();
let text = response.text().unwrap_or_default();
if !status.is_success() {
bail!(
"Daytona create failed (HTTP {}). Enablement: {ENABLE_DOCS}.",
status.as_u16()
);
bail!("Cloud agent create failed (HTTP {status}).",);
}
let parsed: serde_json::Value =
serde_json::from_str(&text).context("Daytona returned invalid JSON")?;
@@ -717,10 +710,13 @@ impl DaytonaLauncher for LiveDaytonaLauncher {
}
/// Status / enablement copy shared by CLI and TUI fail-closed paths.
/// Membership-first: the gate is sign-in, never a provider key.
pub fn missing_credentials_message() -> String {
format!(
"Daytona is not configured. Cloud dispatch fails closed (no sandbox, no push, no PR). Set {DAYTONA_API_KEY_ENV} or store the key in the Codewhale secret slot `{KEYRING_SLOT}` — never models.toml. See {ENABLE_DOCS}."
)
if membership_signed_in() {
"Cloud agents are not available for this account yet; cloud dispatch fails closed (no sandbox, no push, no PR).".to_string()
} else {
"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()
}
}
fn prefer_named(remotes: &[GitRemote], forge: Forge) -> Option<SelectedRemote> {
@@ -1044,7 +1040,8 @@ mod tests {
assert!(job.confirmed);
assert!(job.sandbox_id.is_none());
assert!(job.pr_url.is_none());
assert!(job.note.contains("DAYTONA_API_KEY"));
assert!(job.note.contains("cloud dispatch fails closed"));
assert!(!job.note.contains("DAYTONA"));
assert!(!job.note.contains("sk-"));
}
@@ -1110,8 +1107,8 @@ mod tests {
#[test]
fn missing_credentials_copy_never_embeds_a_secret() {
let message = missing_credentials_message();
assert!(message.contains(DAYTONA_API_KEY_ENV));
assert!(message.contains(ENABLE_DOCS));
assert!(message.contains("cloud dispatch fails closed"));
assert!(!message.contains("DAYTONA"));
assert!(!message.contains("sk-"));
assert!(!message.contains("Bearer"));
}
@@ -1,4 +1,4 @@
//! `/dispatch` — first-class Daytona cloud-agent offload.
//! `/dispatch` — first-class Codewhale cloud-agent offload.
use codewhale_command_contract::facets::CommandWorkspaceContext;
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
@@ -210,8 +210,8 @@ mod tests {
let result = dispatch(&mut workspace, None);
assert!(!result.is_error, "{result:?}");
let message = result.message.expect("status message");
assert!(message.contains("Daytona"));
assert!(message.contains("fail-closed") || message.contains("credentials present"));
assert!(message.contains("Cloud agents"));
assert!(message.contains("fails closed") || message.contains("ready"));
assert!(result.action.is_none());
}
}