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