返回 CodeWhale
mod.rs
根目录 / crates / tui / src / integrations / dsh / mod.rs
1 //! DeepSeek Harness (`dsh`) connected through Codewhale.
2 //!
3 //! A thin, reversible adapter around DSH's *documented* seams:
4 //!
5 //! - detection reads `dsh --version` / `--help`, `$DSH_HOME` inventory;
6 //! - connection writes only under `$CODEWHALE_HOME/integrations/dsh/`: a
7 //! `--patch` overlay pinning the exact Codewhale route identity and an
8 //! append-only receipt; the Codewhale palette rides the bundle profile
9 //! via `overrideTokens`, never the overlay;
10 //! - launch runs `dsh --profile <web|headless> --patch <overlay>` with the
11 //! permission posture exported as `DSH_PERMISSION_MODE`, keeping the
12 //! user's own `$DSH_HOME` (credentials, sessions, profiles) untouched;
13 //! - removal deletes only Codewhale-owned files.
14 //!
15 //! Codewhale never copies, prints, or embeds API keys, OAuth documents, or
16 //! filesystem contents; never silently switches the model; never broadens
17 //! permissions. DSH is an integrated harness surface, not a second Fleet
18 //! scheduler.
19
20 pub(crate) mod brand;
21 pub(crate) mod bundle;
22 pub(crate) mod detect;
23 pub(crate) mod identity;
24 pub(crate) mod receipt;
25 pub(crate) mod scene;
26 pub(crate) mod skin;
27
28 #[cfg(test)]
29 mod tests;
30
31 use std::path::{Path, PathBuf};
32
33 use anyhow::{Context, Result};
34 use serde::{Deserialize, Serialize};
35
36 pub(crate) use bundle::{BundleAvailability, DshAppBundle, DshBundleRecord};
37 pub(crate) use detect::{DetectEnv, DshCompatibility, DshDetection, DshRunner, ProcessRunner};
38 pub(crate) use identity::{
39 CodewhaleRouteIdentity, DshAdapter, MappedIdentity, WireProtocol, map_identity, render_overlay,
40 sha256_hex,
41 };
42 pub(crate) use receipt::{
43 DshConnectionRecord, DshReceiptDocument, DshReceiptEntry, DshReceiptEvent, now_rfc3339,
44 write_atomic,
45 };
46
47 pub(crate) const INTEGRATION_DIR: &str = "integrations/dsh";
48 pub(crate) const OVERLAY_FILE: &str = "codewhale.patch.yml";
49 pub(crate) const RECEIPT_FILE: &str = "receipt.json";
50 pub(crate) const SKIN_FILE: &str = "codewhale-dsh-skin.css";
51 pub(crate) const SKIN_PREVIEW_FILE: &str = "codewhale-dsh-skin-preview.html";
52 pub(crate) const RELATIONSHIP_LABEL: &str = "DeepSeek Harness connected through Codewhale";
53 pub(crate) const CLI_COMMAND: &str = "codewhale integrations dsh";
54
55 /// Codewhale-owned files for this integration.
56 #[derive(Debug, Clone, PartialEq, Eq)]
57 pub(crate) struct DshPaths {
58 /// The Codewhale home these paths live under; the integration's audit
59 /// events are written to its `audit.log` (#6534).
60 pub(crate) codewhale_home: PathBuf,
61 pub(crate) root: PathBuf,
62 pub(crate) overlay: PathBuf,
63 pub(crate) receipt: PathBuf,
64 pub(crate) skin: PathBuf,
65 pub(crate) skin_preview: PathBuf,
66 /// Codewhale-owned bundle package directory (documented DSH plugin path).
67 pub(crate) bundle_dir: PathBuf,
68 }
69
70 impl DshPaths {
71 pub(crate) fn under(codewhale_home: &Path) -> Self {
72 let root = codewhale_home.join(INTEGRATION_DIR);
73 Self {
74 overlay: root.join(OVERLAY_FILE),
75 receipt: root.join(RECEIPT_FILE),
76 skin: root.join(SKIN_FILE),
77 skin_preview: root.join(SKIN_PREVIEW_FILE),
78 bundle_dir: root.join(bundle::BUNDLE_DIR),
79 root,
80 codewhale_home: codewhale_home.to_path_buf(),
81 }
82 }
83
84 pub(crate) fn from_process() -> Result<Self> {
85 Ok(Self::under(&codewhale_config::codewhale_home()?))
86 }
87 }
88
89 /// Honest integration state.
90 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91 #[serde(tag = "state", rename_all = "kebab-case")]
92 pub(crate) enum DshIntegrationState {
93 /// No `dsh` on PATH.
94 NotInstalled,
95 /// `dsh` exists but could not report a version.
96 Offline { reason: String },
97 /// `dsh` is older than verified or lacks `--patch`.
98 Incompatible {
99 version: Option<String>,
100 reason: String,
101 },
102 /// Installed and usable, but no Codewhale overlay exists.
103 Detected { version: String },
104 /// Overlay present and matches the current Codewhale route.
105 Connected { version: String },
106 /// Overlay present but the current Codewhale route (or its file) drifted.
107 StaleConfig { version: String, reason: String },
108 /// Connected, but `dsh` is newer than the verified release.
109 StaleVersion { version: String, verified: String },
110 /// Overlay kept on disk but launches are refused.
111 Disabled { version: Option<String> },
112 }
113
114 impl DshIntegrationState {
115 pub(crate) fn label(&self) -> &'static str {
116 match self {
117 Self::NotInstalled => "not-installed",
118 Self::Offline { .. } => "offline",
119 Self::Incompatible { .. } => "incompatible",
120 Self::Detected { .. } => "detected",
121 Self::Connected { .. } => "connected",
122 Self::StaleConfig { .. } => "stale-config",
123 Self::StaleVersion { .. } => "stale-version",
124 Self::Disabled { .. } => "disabled",
125 }
126 }
127
128 pub(crate) fn launchable(&self) -> bool {
129 matches!(self, Self::Connected { .. } | Self::StaleVersion { .. })
130 }
131 }
132
133 /// Everything status/plan/doctor need, computed without side effects.
134 #[derive(Debug, Clone, Serialize, Deserialize)]
135 pub(crate) struct DshStatusReport {
136 pub(crate) state: DshIntegrationState,
137 pub(crate) detection: DshDetection,
138 pub(crate) record: Option<DshConnectionRecord>,
139 pub(crate) overlay_present: bool,
140 pub(crate) overlay_sha256_on_disk: Option<String>,
141 /// The identity Codewhale would write *now* (may be `None` when the route
142 /// could not be resolved).
143 pub(crate) current_identity: Option<MappedIdentity>,
144 pub(crate) current_identity_error: Option<String>,
145 /// DSH `settings.yaml` namespaces that shadow overlay rows per field.
146 pub(crate) shadowing_namespaces: Vec<String>,
147 /// Whether the documented plugin path (pnpm) is usable here.
148 pub(crate) bundle_availability: BundleAvailability,
149 /// The dedicated profile's `dsh.profile.bundles` when the bundle is
150 /// installed (read from DSH's manifest, read-only).
151 pub(crate) bundle_profile_bundles: Option<Vec<String>>,
152 pub(crate) bundle_patch_present: bool,
153 pub(crate) paths_root: PathBuf,
154 pub(crate) overlay_path: PathBuf,
155 pub(crate) receipt_path: PathBuf,
156 }
157
158 const SHADOWING_NAMESPACES: &[&str] = &["agent-default-model", "llm-deepseek", "llm-pi-ai"];
159
160 pub(crate) fn shadowing_namespaces(detection: &DshDetection) -> Vec<String> {
161 detection
162 .settings_namespaces
163 .iter()
164 .filter(|ns| SHADOWING_NAMESPACES.contains(&ns.as_str()))
165 .cloned()
166 .collect()
167 }
168
169 /// Drifted client half or a `cordis.patch.yml` that no longer matches the
170 /// identity overlay plus the optional skin insert row.
171 fn client_or_patch_stale(
172 record: &DshConnectionRecord,
173 overlay_bytes: Option<&[u8]>,
174 disk_patch_sha256: Option<&str>,
175 bundle: &bundle::DshBundleRecord,
176 ) -> Option<String> {
177 if let Some(reason) = bundle::client_half_stale(
178 &bundle.bundle_dir,
179 record.skin_enabled,
180 record.ocean_enabled,
181 ) {
182 return Some(reason);
183 }
184 let overlay_text = overlay_bytes.and_then(|b| std::str::from_utf8(b).ok());
185 let expected_patch =
186 overlay_text.map(|text| bundle::render_bundle_patch(text, record.skin_enabled));
187 let expected_sha = expected_patch
188 .as_ref()
189 .map(|text| sha256_hex(text.as_bytes()));
190 if disk_patch_sha256 != expected_sha.as_deref() {
191 return Some(
192 "bundle cordis.patch.yml was modified outside Codewhale; run `update`".to_string(),
193 );
194 }
195 None
196 }
197
198 pub(crate) fn compute_status(
199 paths: &DshPaths,
200 detection: DshDetection,
201 current_identity: Result<CodewhaleRouteIdentity, String>,
202 allow_full_access: bool,
203 bundle_availability: BundleAvailability,
204 ) -> Result<DshStatusReport> {
205 let doc = DshReceiptDocument::load(&paths.receipt)?;
206 let record = doc.current;
207 let overlay_bytes = std::fs::read(&paths.overlay).ok();
208 let overlay_present = overlay_bytes.is_some();
209 let overlay_sha256_on_disk = overlay_bytes.as_deref().map(sha256_hex);
210 let bundle_patch_bytes = std::fs::read(paths.bundle_dir.join(bundle::BUNDLE_PATCH_FILE)).ok();
211 let bundle_patch_present = bundle_patch_bytes.is_some();
212 let bundle_patch_sha256 = bundle_patch_bytes.as_deref().map(sha256_hex);
213 let bundle_profile_bundles = record
214 .as_ref()
215 .and_then(|r| r.bundle.as_ref())
216 .and_then(|b| bundle::profile_bundles(&b.profile_dir));
217 let (current_identity, current_identity_error) = match current_identity {
218 Ok(identity) => (Some(map_identity(&identity, allow_full_access)), None),
219 Err(error) => (None, Some(error)),
220 };
221 let shadowing = shadowing_namespaces(&detection);
222
223 let state = if !detection.installed() {
224 DshIntegrationState::NotInstalled
225 } else {
226 match &detection.compatibility {
227 DshCompatibility::Offline { reason } => DshIntegrationState::Offline {
228 reason: reason.clone(),
229 },
230 DshCompatibility::Unparsed { raw } => DshIntegrationState::Offline {
231 reason: format!("dsh --version printed unparseable text `{raw}`"),
232 },
233 DshCompatibility::Incompatible { reason } => DshIntegrationState::Incompatible {
234 version: detection.version.clone(),
235 reason: reason.clone(),
236 },
237 DshCompatibility::Verified | DshCompatibility::NewerUnverified { .. } => {
238 let version = detection.version.clone().unwrap_or_default();
239 match record.as_ref() {
240 None => DshIntegrationState::Detected { version },
241 Some(record) if record.disabled => DshIntegrationState::Disabled {
242 version: Some(version),
243 },
244 Some(record) => {
245 let bundle_stale = record.bundle.as_ref().and_then(|b| {
246 if !bundle_patch_present {
247 Some("bundle cordis.patch.yml is missing; run `update`".to_string())
248 } else if let Some(reason) =
249 client_or_patch_stale(record, overlay_bytes.as_deref(), bundle_patch_sha256.as_deref(), b)
250 {
251 Some(reason)
252 } else if b.patch_sha256 != record.overlay_sha256 {
253 Some("bundle and overlay identities differ; run `update`".to_string())
254 } else if !bundle_profile_bundles
255 .as_ref()
256 .is_some_and(|list| list.iter().any(|n| n == bundle::BUNDLE_PACKAGE_NAME))
257 {
258 Some(format!(
259 "DSH profile `{}` no longer lists {}; run `remove-bundle` then `install-bundle`",
260 bundle::BUNDLE_PROFILE,
261 bundle::BUNDLE_PACKAGE_NAME
262 ))
263 } else {
264 None
265 }
266 });
267 let stale_reason = if !overlay_present {
268 Some("overlay file is missing; run `update`".to_string())
269 } else if overlay_sha256_on_disk.as_deref()
270 != Some(record.overlay_sha256.as_str())
271 {
272 Some(
273 "overlay file was modified outside Codewhale; run `update`"
274 .to_string(),
275 )
276 } else if let Some(reason) = bundle_stale {
277 Some(reason)
278 } else {
279 match current_identity.as_ref() {
280 Some(now) if !now.mappable() => Some(
281 "current Codewhale route cannot be carried by DSH; overlay is stale"
282 .to_string(),
283 ),
284 Some(now) => {
285 let expected = render_overlay(now).map(|text| sha256_hex(text.as_bytes()));
286 match expected {
287 Some(expected) if expected == record.overlay_sha256 => None,
288 Some(_) => Some(format!(
289 "Codewhale route is now {}/{}; overlay pins {}/{}; run `update`",
290 now.source.provider_id,
291 now.source.model,
292 record.identity.source.provider_id,
293 record.identity.source.model
294 )),
295 None => Some(
296 "current Codewhale route cannot be carried by DSH; overlay is stale"
297 .to_string(),
298 ),
299 }
300 }
301 None => None,
302 }
303 };
304 match stale_reason {
305 Some(reason) => DshIntegrationState::StaleConfig { version, reason },
306 None => match &detection.compatibility {
307 DshCompatibility::NewerUnverified { verified } => {
308 DshIntegrationState::StaleVersion {
309 version,
310 verified: verified.clone(),
311 }
312 }
313 _ => DshIntegrationState::Connected { version },
314 },
315 }
316 }
317 }
318 }
319 }
320 };
321
322 Ok(DshStatusReport {
323 state,
324 detection,
325 record,
326 overlay_present,
327 overlay_sha256_on_disk,
328 current_identity,
329 current_identity_error,
330 shadowing_namespaces: shadowing,
331 bundle_availability,
332 bundle_profile_bundles,
333 bundle_patch_present,
334 paths_root: paths.root.clone(),
335 overlay_path: paths.overlay.clone(),
336 receipt_path: paths.receipt.clone(),
337 })
338 }
339
340 /// What `connect`/`update` will write, spelled out before any write happens.
341 #[derive(Debug, Clone, Serialize, Deserialize)]
342 pub(crate) struct DshPlan {
343 pub(crate) mapped: MappedIdentity,
344 pub(crate) overlay_path: PathBuf,
345 pub(crate) overlay_text: String,
346 pub(crate) overlay_sha256: String,
347 pub(crate) receipt_path: PathBuf,
348 /// Palette decision for the bundle profile. The `--patch` overlay never
349 /// carries skin code; `skin_path` stays unset (no CSS export).
350 pub(crate) skin: bool,
351 pub(crate) skin_path: Option<PathBuf>,
352 /// Ambient ocean scene inside the bundle's client half; only meaningful
353 /// with `skin`. Default on; `update --ocean false` turns it off.
354 pub(crate) ocean: bool,
355 pub(crate) profile: String,
356 pub(crate) launch_command: String,
357 pub(crate) env_exports: Vec<(String, String)>,
358 pub(crate) shadowing_namespaces: Vec<String>,
359 pub(crate) disclosures: Vec<String>,
360 }
361
362 pub(crate) fn plan(
363 paths: &DshPaths,
364 detection: &DshDetection,
365 identity: &CodewhaleRouteIdentity,
366 profile: &str,
367 allow_full_access: bool,
368 skin: bool,
369 ocean: bool,
370 ) -> Result<DshPlan> {
371 let mapped = map_identity(identity, allow_full_access);
372 let overlay_text = render_overlay(&mapped).ok_or_else(|| match &mapped.adapter {
373 DshAdapter::Unsupported { reason } => anyhow::anyhow!(
374 "current Codewhale route {}/{} cannot be carried by DSH: {reason}",
375 identity.provider_id,
376 identity.model
377 ),
378 _ => anyhow::anyhow!("overlay could not be rendered"),
379 })?;
380 let overlay_sha256 = sha256_hex(overlay_text.as_bytes());
381 let mut disclosures = mapped.disclosures.clone();
382 let shadowing = shadowing_namespaces(detection);
383 if !shadowing.is_empty() {
384 disclosures.push(format!(
385 "$DSH_HOME/settings.yaml has [{}] sections; DSH layers those over the overlay per field, so the saved DSH selection can shadow the pinned identity until you clear it in DSH.",
386 shadowing.join(", ")
387 ));
388 }
389 if !detection.profiles.iter().any(|p| p == profile) {
390 disclosures.push(format!(
391 "DSH profile `{profile}` is not initialized yet; dsh will create $DSH_HOME/profiles/{profile} on first launch (its own documented behavior)."
392 ));
393 }
394 if skin {
395 disclosures.push(
396 "Skin: Codewhale palette is applied through the bundle profile via overrideTokens (on by default for install-bundle). The --patch overlay path is unchanged; launch --profile web|headless stays overlay-only."
397 .to_string(),
398 );
399 if ocean {
400 disclosures.push(format!(
401 "Ocean: an ambient canvas scene (whales, glyph fish, bubbles) is spliced into the bundle's client half and a few DSH background tokens become translucent so it shows through; `update --ocean false` turns it off, and in the browser `localStorage[\"{}\"] = \"off\"` or body class `{}` disables it per machine.",
402 scene::OCEAN_STORAGE_KEY,
403 scene::OCEAN_OFF_CLASS
404 ));
405 }
406 }
407 let env_exports = vec![(
408 "DSH_PERMISSION_MODE".to_string(),
409 mapped.permission_mode.as_str().to_string(),
410 )];
411 let launch_command = format!(
412 "DSH_PERMISSION_MODE={} dsh --profile {profile} --patch {}",
413 mapped.permission_mode.as_str(),
414 paths.overlay.display()
415 );
416 Ok(DshPlan {
417 mapped,
418 overlay_path: paths.overlay.clone(),
419 overlay_text,
420 overlay_sha256,
421 receipt_path: paths.receipt.clone(),
422 skin,
423 skin_path: None,
424 ocean: skin && ocean,
425 profile: profile.to_string(),
426 launch_command,
427 env_exports,
428 shadowing_namespaces: shadowing,
429 disclosures,
430 })
431 }
432
433 fn codewhale_version() -> String {
434 env!("CARGO_PKG_VERSION").to_string()
435 }
436
437 fn identity_summary(mapped: &MappedIdentity) -> String {
438 format!(
439 "{}/{} via {}",
440 mapped.source.provider_id,
441 mapped.source.model,
442 mapped.dsh_provider().unwrap_or("unsupported")
443 )
444 }
445
446 /// Write the overlay and the receipt. `event` is `Connect` for a first
447 /// connection or `Update` for a rewrite. Skin is a receipt decision for the
448 /// bundle profile; no stylesheet is written.
449 pub(crate) fn apply_plan(
450 paths: &DshPaths,
451 detection: &DshDetection,
452 plan: &DshPlan,
453 event: DshReceiptEvent,
454 ) -> Result<DshConnectionRecord> {
455 std::fs::create_dir_all(&paths.root)
456 .with_context(|| format!("create {}", paths.root.display()))?;
457 #[cfg(unix)]
458 {
459 use std::os::unix::fs::PermissionsExt;
460 let _ = std::fs::set_permissions(&paths.root, std::fs::Permissions::from_mode(0o700));
461 }
462 write_atomic(&paths.overlay, plan.overlay_text.as_bytes())?;
463 // The 0.9.8 CSS/preview export is gone; drop leftovers so `remove` stays
464 // the only cleanup path for those names.
465 for leftover in [&paths.skin, &paths.skin_preview] {
466 let _ = std::fs::remove_file(leftover);
467 }
468 let skin_sha256 = plan.skin.then(skin::skin_tokens_sha256);
469 let mut doc = DshReceiptDocument::load(&paths.receipt)?;
470 let now = now_rfc3339();
471 let connected_at = doc
472 .current
473 .as_ref()
474 .map(|r| r.connected_at.clone())
475 .filter(|_| event == DshReceiptEvent::Update)
476 .unwrap_or_else(|| now.clone());
477 // An installed bundle is a `link:` to our directory: rewriting its patch
478 // is the whole update, no pnpm needed.
479 let bundle_record = match doc.current.as_ref().and_then(|r| r.bundle.clone()) {
480 Some(mut b) if event == DshReceiptEvent::Update => {
481 let sha = bundle::write_bundle(
482 &paths.bundle_dir,
483 &codewhale_version(),
484 &plan.overlay_text,
485 plan.skin,
486 plan.ocean,
487 )?;
488 b.patch_sha256 = sha.clone();
489 b.package_version = bundle::bundle_version(&codewhale_version(), &sha);
490 b.updated_at = now.clone();
491 Some(b)
492 }
493 _ => None,
494 };
495 let record = DshConnectionRecord {
496 connected_at,
497 updated_at: now.clone(),
498 dsh_version: detection.version.clone(),
499 dsh_binary: detection.binary.clone(),
500 dsh_home: detection.dsh_home.clone(),
501 profile: plan.profile.clone(),
502 overlay_path: paths.overlay.clone(),
503 overlay_sha256: plan.overlay_sha256.clone(),
504 skin_enabled: plan.skin,
505 skin_path: None,
506 skin_sha256: skin_sha256.clone(),
507 ocean_enabled: plan.ocean,
508 disabled: false,
509 bundle: bundle_record,
510 identity: plan.mapped.clone(),
511 };
512 doc.push(DshReceiptEntry {
513 event: event.clone(),
514 at: now,
515 codewhale_version: codewhale_version(),
516 dsh_version: detection.version.clone(),
517 dsh_home: detection.dsh_home.clone(),
518 overlay_sha256: Some(plan.overlay_sha256.clone()),
519 skin_sha256,
520 identity_summary: Some(identity_summary(&plan.mapped)),
521 permission_mode: Some(plan.mapped.permission_mode.as_str().to_string()),
522 note: None,
523 });
524 doc.current = Some(record.clone());
525 doc.save(&paths.receipt)?;
526 crate::audit::log_sensitive_event_in(
527 &paths.codewhale_home,
528 &format!("integration.dsh.{}", event.as_str()),
529 serde_json::json!({
530 "overlay_path": paths.overlay.display().to_string(),
531 "overlay_sha256": plan.overlay_sha256,
532 "identity": identity_summary(&plan.mapped),
533 "permission_mode": plan.mapped.permission_mode.as_str(),
534 "skin": plan.skin,
535 "ocean": plan.ocean,
536 }),
537 );
538 Ok(record)
539 }
540
541 pub(crate) fn set_disabled(paths: &DshPaths, disabled: bool) -> Result<DshConnectionRecord> {
542 let mut doc = DshReceiptDocument::load(&paths.receipt)?;
543 let Some(mut record) = doc.current.take() else {
544 anyhow::bail!(
545 "DSH is not connected; nothing to {}",
546 if disabled { "disable" } else { "enable" }
547 );
548 };
549 record.disabled = disabled;
550 record.updated_at = now_rfc3339();
551 let event = if disabled {
552 DshReceiptEvent::Disable
553 } else {
554 DshReceiptEvent::Enable
555 };
556 doc.push(DshReceiptEntry {
557 event: event.clone(),
558 at: record.updated_at.clone(),
559 codewhale_version: codewhale_version(),
560 dsh_version: record.dsh_version.clone(),
561 dsh_home: record.dsh_home.clone(),
562 overlay_sha256: Some(record.overlay_sha256.clone()),
563 skin_sha256: record.skin_sha256.clone(),
564 identity_summary: Some(identity_summary(&record.identity)),
565 permission_mode: Some(record.identity.permission_mode.as_str().to_string()),
566 note: None,
567 });
568 doc.current = Some(record.clone());
569 doc.save(&paths.receipt)?;
570 crate::audit::log_sensitive_event_in(
571 &paths.codewhale_home,
572 &format!("integration.dsh.{}", event.as_str()),
573 serde_json::json!({ "overlay_path": paths.overlay.display().to_string() }),
574 );
575 Ok(record)
576 }
577
578 /// Delete only Codewhale-owned files; keep the receipt history with a
579 /// terminal `remove` entry. Never touches `$DSH_HOME`.
580 pub(crate) fn remove(paths: &DshPaths) -> Result<Vec<PathBuf>> {
581 let mut doc = DshReceiptDocument::load(&paths.receipt)?;
582 if doc.current.as_ref().is_some_and(|r| r.bundle.is_some()) {
583 anyhow::bail!(
584 "the Codewhale bundle is still installed in DSH profile `{}`; run `{CLI_COMMAND} remove-bundle` first",
585 bundle::BUNDLE_PROFILE
586 );
587 }
588 let mut removed = Vec::new();
589 for path in [&paths.overlay, &paths.skin, &paths.skin_preview] {
590 match std::fs::remove_file(path) {
591 Ok(()) => removed.push(path.clone()),
592 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
593 Err(error) => return Err(error).with_context(|| format!("remove {}", path.display())),
594 }
595 }
596 let previous = doc.current.take();
597 let now = now_rfc3339();
598 doc.push(DshReceiptEntry {
599 event: DshReceiptEvent::Remove,
600 at: now,
601 codewhale_version: codewhale_version(),
602 dsh_version: previous.as_ref().and_then(|r| r.dsh_version.clone()),
603 dsh_home: previous
604 .as_ref()
605 .map(|r| r.dsh_home.clone())
606 .unwrap_or_default(),
607 overlay_sha256: previous.as_ref().map(|r| r.overlay_sha256.clone()),
608 skin_sha256: previous.as_ref().and_then(|r| r.skin_sha256.clone()),
609 identity_summary: previous.as_ref().map(|r| identity_summary(&r.identity)),
610 permission_mode: previous
611 .as_ref()
612 .map(|r| r.identity.permission_mode.as_str().to_string()),
613 note: Some(format!(
614 "removed {} Codewhale-owned file(s); $DSH_HOME untouched",
615 removed.len()
616 )),
617 });
618 doc.save(&paths.receipt)?;
619 crate::audit::log_sensitive_event_in(
620 &paths.codewhale_home,
621 "integration.dsh.remove",
622 serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
623 );
624 Ok(removed)
625 }
626
627 /// The exact process a launch runs. Returned (not spawned) so callers and
628 /// tests can inspect it; `spawn_launch` executes it with inherited stdio.
629 #[derive(Debug, Clone, PartialEq, Eq)]
630 pub(crate) struct LaunchSpec {
631 pub(crate) binary: PathBuf,
632 pub(crate) args: Vec<String>,
633 pub(crate) env: Vec<(String, String)>,
634 /// Variables Codewhale itself injected into this process (a `--api-key`
635 /// or keyring-bridged credential) that must not leak into the child.
636 pub(crate) strip_env: Vec<String>,
637 pub(crate) cwd: PathBuf,
638 }
639
640 /// Names to strip from the child environment when Codewhale's own dispatcher
641 /// materialized a credential into this process. A key the user exported in
642 /// their shell is theirs and is left alone; a key Codewhale bridged from
643 /// `--api-key` or the keyring is Codewhale's and is not handed over.
644 pub(crate) fn launch_env_strip_list(
645 api_key_source: Option<&str>,
646 provider_env_vars: &[String],
647 ) -> Vec<String> {
648 let mut out = vec![
649 codewhale_config::CLI_API_KEY_ENV.to_string(),
650 codewhale_config::CLI_API_KEY_SOURCE_ENV.to_string(),
651 codewhale_config::LEGACY_CLI_API_KEY_SOURCE_ENV.to_string(),
652 ];
653 if matches!(api_key_source, Some("cli" | "keyring")) {
654 for var in provider_env_vars {
655 if !out.contains(var) {
656 out.push(var.clone());
657 }
658 }
659 }
660 out
661 }
662
663 impl LaunchSpec {
664 pub(crate) fn display(&self) -> String {
665 let mut out = String::new();
666 for (k, v) in &self.env {
667 out.push_str(&format!("{k}={v} "));
668 }
669 out.push_str(&self.binary.display().to_string());
670 for arg in &self.args {
671 out.push(' ');
672 out.push_str(arg);
673 }
674 out
675 }
676 }
677
678 pub(crate) fn launch_spec(
679 report: &DshStatusReport,
680 profile_override: Option<&str>,
681 extra_args: &[String],
682 workspace: &Path,
683 ) -> Result<LaunchSpec> {
684 let record = report.record.as_ref().ok_or_else(|| {
685 anyhow::anyhow!("DSH is not connected; run `{CLI_COMMAND} connect` first")
686 })?;
687 if !report.state.launchable() {
688 match &report.state {
689 DshIntegrationState::Connected { .. } | DshIntegrationState::StaleVersion { .. } => {}
690 DshIntegrationState::Disabled { .. } => {
691 anyhow::bail!(
692 "DSH integration is disabled; run `{CLI_COMMAND} enable` to launch again"
693 )
694 }
695 DshIntegrationState::StaleConfig { reason, .. } => {
696 anyhow::bail!(
697 "DSH overlay is stale ({reason}); run `{CLI_COMMAND} update` before launching"
698 )
699 }
700 DshIntegrationState::Incompatible { reason, .. } => {
701 anyhow::bail!("installed dsh is incompatible: {reason}")
702 }
703 DshIntegrationState::Offline { reason } => anyhow::bail!("dsh is offline: {reason}"),
704 DshIntegrationState::NotInstalled => anyhow::bail!("dsh is not installed"),
705 DshIntegrationState::Detected { .. } => {
706 anyhow::bail!("DSH is detected but not connected; run `{CLI_COMMAND} connect`")
707 }
708 }
709 }
710 let binary = report
711 .detection
712 .binary
713 .clone()
714 .ok_or_else(|| anyhow::anyhow!("dsh binary path is unknown"))?;
715 let bundle_installed = record.bundle.is_some();
716 let profile = profile_override.unwrap_or(if bundle_installed {
717 bundle::BUNDLE_PROFILE
718 } else {
719 record.profile.as_str()
720 });
721 if !matches!(profile, "web" | "headless") && profile != bundle::BUNDLE_PROFILE {
722 anyhow::bail!(
723 "DSH profile must be `web`, `headless`, or `{}` (bundle), got `{profile}`",
724 bundle::BUNDLE_PROFILE
725 );
726 }
727 if profile == bundle::BUNDLE_PROFILE && !bundle_installed {
728 anyhow::bail!(
729 "the `{}` profile carries identity only after `{CLI_COMMAND} install-bundle`",
730 bundle::BUNDLE_PROFILE
731 );
732 }
733 let mut args = vec!["--profile".to_string(), profile.to_string()];
734 if profile != bundle::BUNDLE_PROFILE {
735 // The dedicated bundle profile carries the identity itself; the
736 // shipped profiles get it through the overlay.
737 args.push("--patch".to_string());
738 args.push(report.overlay_path.display().to_string());
739 }
740 args.extend(extra_args.iter().cloned());
741 let provider_env_vars: Vec<String> =
742 record.identity.source.api_key_env.iter().cloned().collect();
743 let strip_env = launch_env_strip_list(
744 crate::config::cli_api_key_source().as_deref(),
745 &provider_env_vars,
746 );
747 Ok(LaunchSpec {
748 binary,
749 args,
750 env: vec![(
751 "DSH_PERMISSION_MODE".to_string(),
752 record.identity.permission_mode.as_str().to_string(),
753 )],
754 strip_env,
755 cwd: workspace.to_path_buf(),
756 })
757 }
758
759 pub(crate) fn spawn_launch(spec: &LaunchSpec) -> Result<i32> {
760 let mut command = std::process::Command::new(&spec.binary);
761 command.args(&spec.args).current_dir(&spec.cwd);
762 for name in &spec.strip_env {
763 command.env_remove(name);
764 }
765 for (k, v) in &spec.env {
766 command.env(k, v);
767 }
768 let status = command
769 .status()
770 .with_context(|| format!("launch {}", spec.binary.display()))?;
771 Ok(status.code().unwrap_or(1))
772 }
773
774 /// Install the Codewhale bundle into the dedicated `codewhale` DSH profile via
775 /// the documented `dsh plugin --profile codewhale add <path>` (pnpm).
776 pub(crate) fn install_bundle(
777 paths: &DshPaths,
778 detection: &DshDetection,
779 runner: &dyn DshRunner,
780 availability: &BundleAvailability,
781 app: DshAppBundle,
782 ) -> Result<DshBundleRecord> {
783 let pnpm_version = match availability {
784 BundleAvailability::Available { pnpm_version } => pnpm_version.clone(),
785 BundleAvailability::NotAvailable { reason } => {
786 anyhow::bail!("DSH plugin path not available: {reason}")
787 }
788 };
789 let mut doc = DshReceiptDocument::load(&paths.receipt)?;
790 let Some(mut record) = doc.current.take() else {
791 anyhow::bail!("DSH is not connected; run `{CLI_COMMAND} connect` first");
792 };
793 if record.bundle.is_some() {
794 anyhow::bail!("bundle already installed; use `{CLI_COMMAND} update` or `remove-bundle`");
795 }
796 let overlay_text = std::fs::read_to_string(&paths.overlay)
797 .with_context(|| format!("read {}", paths.overlay.display()))?;
798 if sha256_hex(overlay_text.as_bytes()) != record.overlay_sha256 {
799 anyhow::bail!("overlay is stale; run `{CLI_COMMAND} update` before install-bundle");
800 }
801 // install-bundle defaults the palette on; `update --skin false` is the
802 // off switch. `connect --skin` records the same decision for a later update.
803 let skin = true;
804 // The ocean scene follows the recorded decision (default on).
805 let ocean = record.ocean_enabled;
806 let patch_sha = bundle::write_bundle(
807 &paths.bundle_dir,
808 &codewhale_version(),
809 &overlay_text,
810 skin,
811 ocean,
812 )?;
813 let (app_source, outcomes) =
814 match bundle::install_into_profile(runner, detection, app, &paths.bundle_dir) {
815 Ok(ok) => ok,
816 Err(error) => {
817 // Leave DSH state as dsh left it; drop our half-written package.
818 let _ = bundle::remove_bundle_files(&paths.bundle_dir);
819 return Err(error);
820 }
821 };
822 let now = now_rfc3339();
823 let mut digest_input = String::new();
824 for outcome in &outcomes {
825 digest_input.push_str(&outcome.output_sha256);
826 digest_input.push('\n');
827 }
828 let bundle_record = DshBundleRecord {
829 installed_at: now.clone(),
830 updated_at: now.clone(),
831 profile: bundle::BUNDLE_PROFILE.to_string(),
832 profile_dir: detection
833 .dsh_home
834 .join("profiles")
835 .join(bundle::BUNDLE_PROFILE),
836 bundle_dir: paths.bundle_dir.clone(),
837 package_name: bundle::BUNDLE_PACKAGE_NAME.to_string(),
838 package_version: bundle::bundle_version(&codewhale_version(), &patch_sha),
839 patch_sha256: patch_sha.clone(),
840 app_bundle: app,
841 app_bundle_source: app_source,
842 pnpm_version,
843 pnpm_output_sha256: sha256_hex(digest_input.as_bytes()),
844 };
845 record.bundle = Some(bundle_record.clone());
846 record.skin_enabled = skin;
847 record.skin_path = None;
848 record.skin_sha256 = Some(skin::skin_tokens_sha256());
849 record.ocean_enabled = ocean;
850 record.updated_at = now.clone();
851 doc.push(DshReceiptEntry {
852 event: DshReceiptEvent::InstallBundle,
853 at: now,
854 codewhale_version: codewhale_version(),
855 dsh_version: detection.version.clone(),
856 dsh_home: detection.dsh_home.clone(),
857 overlay_sha256: Some(record.overlay_sha256.clone()),
858 skin_sha256: record.skin_sha256.clone(),
859 identity_summary: Some(identity_summary(&record.identity)),
860 permission_mode: Some(record.identity.permission_mode.as_str().to_string()),
861 note: Some(format!(
862 "dsh plugin --profile {} add {} + {}; pnpm {}; output sha256 {}",
863 bundle::BUNDLE_PROFILE,
864 app.package_name(),
865 bundle::BUNDLE_PACKAGE_NAME,
866 bundle_record.pnpm_version,
867 bundle_record.pnpm_output_sha256
868 )),
869 });
870 doc.current = Some(record);
871 doc.save(&paths.receipt)?;
872 crate::audit::log_sensitive_event_in(
873 &paths.codewhale_home,
874 "integration.dsh.install_bundle",
875 serde_json::json!({
876 "profile_dir": bundle_record.profile_dir.display().to_string(),
877 "bundle_dir": paths.bundle_dir.display().to_string(),
878 "patch_sha256": bundle_record.patch_sha256,
879 "app_bundle": app.package_name(),
880 }),
881 );
882 Ok(bundle_record)
883 }
884
885 /// `dsh plugin --profile codewhale remove codewhale-dsh-bundle`, then delete
886 /// only the Codewhale-owned bundle files. The DSH profile directory (and the
887 /// app bundle link dsh recorded there) is DSH-owned and is left in place.
888 pub(crate) fn remove_bundle(
889 paths: &DshPaths,
890 detection: &DshDetection,
891 runner: &dyn DshRunner,
892 ) -> Result<Vec<PathBuf>> {
893 let mut doc = DshReceiptDocument::load(&paths.receipt)?;
894 let Some(mut record) = doc.current.take() else {
895 anyhow::bail!("DSH is not connected; nothing to remove");
896 };
897 let Some(bundle_record) = record.bundle.take() else {
898 anyhow::bail!("no bundle is installed");
899 };
900 let outcome = bundle::remove_from_profile(runner, detection)?;
901 let removed = bundle::remove_bundle_files(&paths.bundle_dir)?;
902 let now = now_rfc3339();
903 record.updated_at = now.clone();
904 doc.push(DshReceiptEntry {
905 event: DshReceiptEvent::RemoveBundle,
906 at: now,
907 codewhale_version: codewhale_version(),
908 dsh_version: detection.version.clone(),
909 dsh_home: detection.dsh_home.clone(),
910 overlay_sha256: Some(record.overlay_sha256.clone()),
911 skin_sha256: record.skin_sha256.clone(),
912 identity_summary: Some(identity_summary(&record.identity)),
913 permission_mode: Some(record.identity.permission_mode.as_str().to_string()),
914 note: Some(format!(
915 "dsh plugin --profile {} remove {} (output sha256 {}); removed {} owned file(s); profile dir {} left in place (DSH-owned)",
916 bundle::BUNDLE_PROFILE,
917 bundle::BUNDLE_PACKAGE_NAME,
918 outcome.output_sha256,
919 removed.len(),
920 bundle_record.profile_dir.display()
921 )),
922 });
923 doc.current = Some(record);
924 doc.save(&paths.receipt)?;
925 crate::audit::log_sensitive_event_in(
926 &paths.codewhale_home,
927 "integration.dsh.remove_bundle",
928 serde_json::json!({ "removed": removed.iter().map(|p| p.display().to_string()).collect::<Vec<_>>() }),
929 );
930 Ok(removed)
931 }
932 /// Probe the documented plugin path (pnpm on `PATH`) with the real runner.
933 pub(crate) fn bundle_availability_now() -> BundleAvailability {
934 bundle::bundle_availability(std::env::var_os("PATH").as_ref(), &ProcessRunner)
935 }
936
937 /// Derive the non-secret route identity from a loaded Codewhale config.
938 pub(crate) fn codewhale_route_identity(
939 config: &crate::config::Config,
940 workspace: &Path,
941 ) -> Result<CodewhaleRouteIdentity, String> {
942 let identity = config.active_provider_identity()?;
943 let provider = identity.provider;
944 let configured_model = config.default_model();
945 let route = crate::route_runtime::resolve_runtime_route_for_identity(
946 config,
947 &identity,
948 Some(&configured_model),
949 )?;
950 let candidate = &route.candidate;
951 let base_url = candidate.endpoint().base_url.clone();
952 let protocol = match candidate.protocol() {
953 codewhale_config::provider::WireFormat::ChatCompletions => WireProtocol::ChatCompletions,
954 codewhale_config::provider::WireFormat::Responses => WireProtocol::Responses,
955 codewhale_config::provider::WireFormat::AnthropicMessages => {
956 WireProtocol::AnthropicMessages
957 }
958 };
959 let keyless_local = crate::config::provider_route_is_keyless_self_hosted(provider, &base_url);
960 let api_key_env = provider
961 .provider()
962 .env_vars()
963 .first()
964 .map(|s| (*s).to_string());
965 Ok(CodewhaleRouteIdentity {
966 provider_id: candidate.provider_id().as_str().to_string(),
967 provider_label: route
968 .identity
969 .compatibility()
970 .map_or(route.identity.key.as_str(), |row| row.label)
971 .to_string(),
972 model: candidate.wire_model_id().as_str().to_string(),
973 base_url,
974 protocol,
975 api_key_env,
976 keyless_local,
977 reasoning_effort: config.reasoning_effort().map(str::to_string),
978 sandbox_mode: config.sandbox_mode.clone(),
979 approval_policy: config.approval_policy.clone(),
980 yolo: config.yolo.unwrap_or(false),
981 workspace: workspace.display().to_string(),
982 })
983 }
984
985 /// One-line status for the TUI setup on-ramp and doctor. Side-effect free.
986 pub(crate) fn status_line(report: &DshStatusReport) -> String {
987 let version = report.detection.version.as_deref().unwrap_or("?");
988 match &report.state {
989 DshIntegrationState::NotInstalled => {
990 format!("not installed — `dsh` not on PATH; connect later with `{CLI_COMMAND} connect`")
991 }
992 DshIntegrationState::Offline { reason } => format!("offline — {reason}"),
993 DshIntegrationState::Incompatible { reason, .. } => {
994 format!("incompatible — dsh {version}: {reason}")
995 }
996 DshIntegrationState::Detected { .. } => {
997 // Surface route carry-ability before `plan` is ever run, so a
998 // refuse-at-plan-time surprise is visible in `status`/doctor.
999 let carry = match report.current_identity.as_ref() {
1000 Some(now) if now.mappable() => format!(
1001 "current route {}/{} is carryable via {}",
1002 now.source.provider_id,
1003 now.source.model,
1004 now.dsh_provider().unwrap_or("(unknown adapter)")
1005 ),
1006 Some(now) => match &now.adapter {
1007 DshAdapter::Unsupported { reason } => format!(
1008 "current route {}/{} cannot be carried by DSH: {reason}",
1009 now.source.provider_id, now.source.model
1010 ),
1011 _ => String::new(),
1012 },
1013 None => String::new(),
1014 };
1015 let carry = if carry.is_empty() {
1016 String::new()
1017 } else {
1018 format!("; {carry}")
1019 };
1020 format!(
1021 "detected — dsh {version}, not connected{carry}; `{CLI_COMMAND} plan` explains what would be written"
1022 )
1023 }
1024 DshIntegrationState::Connected { .. } => {
1025 let identity = report
1026 .record
1027 .as_ref()
1028 .map(|r| identity_summary(&r.identity))
1029 .unwrap_or_default();
1030 let bundle = report
1031 .record
1032 .as_ref()
1033 .and_then(|r| r.bundle.as_ref())
1034 .map(|b| format!(", bundle in profile `{}`", b.profile))
1035 .unwrap_or_default();
1036 format!("connected — dsh {version}, {identity}{bundle} ({RELATIONSHIP_LABEL})")
1037 }
1038 DshIntegrationState::StaleConfig { reason, .. } => {
1039 format!("stale-config — dsh {version}: {reason}")
1040 }
1041 DshIntegrationState::StaleVersion { verified, .. } => format!(
1042 "stale-version — dsh {version} is newer than verified {verified}; connected but unverified"
1043 ),
1044 DshIntegrationState::Disabled { .. } => {
1045 format!("disabled — overlay kept, launches refused; `{CLI_COMMAND} enable`")
1046 }
1047 }
1048 }
1049
1049 lines RUST