返回 CodeWhale
identity.rs
根目录 / crates / tui / src / integrations / dsh / identity.rs
1 //! Exact Codewhale route identity → DeepSeek Harness overlay mapping.
2 //!
3 //! The mapping carries *identity only*: provider id, model id, endpoint,
4 //! reasoning tier, permission posture, and the *name* of the credential
5 //! environment variable. It never carries a credential value, an OAuth
6 //! document, or a base URL that embeds userinfo/query material.
7
8 use serde::{Deserialize, Serialize};
9 use sha2::{Digest, Sha256};
10
11 /// Non-secret facts about the route Codewhale is currently configured to use.
12 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13 pub(crate) struct CodewhaleRouteIdentity {
14 /// Exact configured provider id (e.g. `deepseek`, `ollama`, `zai`, or a
15 /// named custom table key).
16 pub(crate) provider_id: String,
17 /// Human label for the provider.
18 pub(crate) provider_label: String,
19 /// Exact model id put on the wire.
20 pub(crate) model: String,
21 /// Resolved base URL (structural; must not carry credentials).
22 pub(crate) base_url: String,
23 /// Wire protocol Codewhale speaks to this endpoint.
24 pub(crate) protocol: WireProtocol,
25 /// Canonical credential env var name for the provider, if it has one.
26 pub(crate) api_key_env: Option<String>,
27 /// True when the route is a keyless self-hosted endpoint (loopback
28 /// Ollama/LM Studio/vLLM/SGLang): no credential reference is written.
29 pub(crate) keyless_local: bool,
30 /// Codewhale reasoning tier as configured (`off|low|medium|high|xhigh|max|ultra`).
31 pub(crate) reasoning_effort: Option<String>,
32 /// Codewhale sandbox mode (`read-only|workspace-write|danger-full-access|external-sandbox`).
33 pub(crate) sandbox_mode: Option<String>,
34 /// Codewhale approval policy (`suggest|auto|never`).
35 pub(crate) approval_policy: Option<String>,
36 pub(crate) yolo: bool,
37 /// Workspace the launch is bound to.
38 pub(crate) workspace: String,
39 }
40
41 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42 #[serde(rename_all = "kebab-case")]
43 pub(crate) enum WireProtocol {
44 ChatCompletions,
45 Responses,
46 AnthropicMessages,
47 }
48
49 /// DSH permission mode mirrored from the Codewhale posture.
50 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51 #[serde(rename_all = "kebab-case")]
52 pub(crate) enum DshPermissionMode {
53 ReadOnly,
54 WorkspaceWrite,
55 DangerFullAccess,
56 }
57
58 impl DshPermissionMode {
59 pub(crate) fn as_str(self) -> &'static str {
60 match self {
61 Self::ReadOnly => "read-only",
62 Self::WorkspaceWrite => "workspace-write",
63 Self::DangerFullAccess => "danger-full-access",
64 }
65 }
66 }
67
68 /// Which DSH adapter carries the route.
69 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70 #[serde(tag = "kind", rename_all = "snake_case")]
71 pub(crate) enum DshAdapter {
72 /// `@deepseek-ai/dsh-llm-deepseek`, route id `deepseek-official`.
73 DeepseekNative,
74 /// `@deepseek-ai/dsh-llm-pi-ai` hand-declared route. The route names the
75 /// provider's own wire dialect (`openai-completions`, `openai-responses`,
76 /// or `anthropic-messages`); see [`pi_ai_api_for`].
77 PiAiOpenAiCompatible { route_id: String },
78 /// DSH cannot carry this route; nothing is written for it.
79 Unsupported { reason: String },
80 }
81
82 /// The identity as it will be written into the overlay, plus disclosures.
83 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84 pub(crate) struct MappedIdentity {
85 pub(crate) source: CodewhaleRouteIdentity,
86 pub(crate) adapter: DshAdapter,
87 /// DSH `reasoningEffort` (`off|high|max`) for the native adapter; `None`
88 /// when Codewhale has no explicit tier or the adapter cannot express it.
89 pub(crate) dsh_reasoning_effort: Option<String>,
90 pub(crate) permission_mode: DshPermissionMode,
91 /// Facts the user must know that the overlay cannot enforce.
92 pub(crate) disclosures: Vec<String>,
93 }
94
95 impl MappedIdentity {
96 pub(crate) fn mappable(&self) -> bool {
97 !matches!(self.adapter, DshAdapter::Unsupported { .. })
98 }
99
100 /// The `provider` value DSH sees for this route.
101 pub(crate) fn dsh_provider(&self) -> Option<&str> {
102 match &self.adapter {
103 DshAdapter::DeepseekNative => Some("deepseek-official"),
104 DshAdapter::PiAiOpenAiCompatible { route_id } => Some(route_id),
105 DshAdapter::Unsupported { .. } => None,
106 }
107 }
108 }
109
110 /// Map a Codewhale reasoning tier onto DSH's `off | high | max`.
111 pub(crate) fn dsh_reasoning_effort(effort: Option<&str>) -> Option<&'static str> {
112 match effort.map(|e| e.trim().to_ascii_lowercase()).as_deref() {
113 None | Some("") => None,
114 Some("off" | "none" | "disabled" | "false") => Some("off"),
115 Some("minimal" | "low" | "medium" | "mid" | "high" | "auto") => Some("high"),
116 Some("xhigh" | "max" | "maximum" | "highest" | "ultra" | "ultracode") => Some("max"),
117 Some(_) => None,
118 }
119 }
120
121 /// Mirror the Codewhale posture. Full access is only granted when Codewhale
122 /// itself runs with full access *and* the caller confirmed it explicitly.
123 pub(crate) fn permission_mode_for(
124 identity: &CodewhaleRouteIdentity,
125 allow_full_access: bool,
126 ) -> (DshPermissionMode, Option<String>) {
127 let sandbox = identity
128 .sandbox_mode
129 .as_deref()
130 .map(|s| s.trim().to_ascii_lowercase());
131 let approval = identity
132 .approval_policy
133 .as_deref()
134 .map(|s| s.trim().to_ascii_lowercase());
135 let codewhale_full = identity.yolo
136 || matches!(
137 sandbox.as_deref(),
138 Some("danger-full-access" | "external-sandbox")
139 );
140 if matches!(sandbox.as_deref(), Some("read-only")) {
141 return (DshPermissionMode::ReadOnly, None);
142 }
143 if codewhale_full {
144 if allow_full_access {
145 return (
146 DshPermissionMode::DangerFullAccess,
147 Some(
148 "DSH danger-full-access mirrors Codewhale full access; DSH will not ask before file effects."
149 .to_string(),
150 ),
151 );
152 }
153 return (
154 DshPermissionMode::WorkspaceWrite,
155 Some(
156 "Codewhale runs with full access, but the DSH overlay stays at workspace-write; pass --allow-full-access to mirror it."
157 .to_string(),
158 ),
159 );
160 }
161 let note = match approval.as_deref() {
162 Some("never" | "deny" | "denied") => Some(
163 "Codewhale approval policy is `never`; DSH keeps its own ask-before-effects policy at workspace-write."
164 .to_string(),
165 ),
166 _ => None,
167 };
168 (DshPermissionMode::WorkspaceWrite, note)
169 }
170
171 fn base_url_is_structural(url: &str) -> Result<(), String> {
172 let trimmed = url.trim();
173 if trimmed.is_empty() {
174 return Err("empty base URL".to_string());
175 }
176 let Some((scheme, rest)) = trimmed.split_once("://") else {
177 return Err("base URL has no scheme".to_string());
178 };
179 if !matches!(scheme, "http" | "https") {
180 return Err(format!("unsupported base URL scheme `{scheme}`"));
181 }
182 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
183 if authority.contains('@') {
184 return Err("base URL embeds userinfo; refusing to copy it".to_string());
185 }
186 if rest.contains('?') || rest.contains('#') {
187 return Err("base URL carries a query/fragment; refusing to copy it".to_string());
188 }
189 Ok(())
190 }
191
192 /// The `api:` dialect a hand-declared `dsh-llm-pi-ai` route names for a
193 /// Codewhale wire protocol — identity-preserving, never an approximation.
194 ///
195 /// Verified against the installed `@deepseek-ai/dsh@0.1.0-rc.6`:
196 /// `@deepseek-ai/dsh-llm-pi-ai`'s exported profile schema accepts exactly
197 /// `openai-completions | openai-responses | anthropic-messages` for `api:`
198 /// (`lib/index.js`: `const PROTOCOLS = { "openai-completions": …,
199 /// "openai-responses": openAIResponsesApi, "anthropic-messages": … }`),
200 /// while `@deepseek-ai/dsh-llm-deepseek` (the `deepseek-official` route)
201 /// speaks chat completions only — its single wire call posts to
202 /// `<baseURL>/chat/completions` with no protocol switch — so
203 /// Responses-dialect DeepSeek routes (e.g. `deepseek-v4-flash`) ride the
204 /// pi-ai hand-declared route instead.
205 pub(crate) fn pi_ai_api_for(protocol: WireProtocol) -> &'static str {
206 match protocol {
207 WireProtocol::ChatCompletions => "openai-completions",
208 WireProtocol::Responses => "openai-responses",
209 WireProtocol::AnthropicMessages => "anthropic-messages",
210 }
211 }
212
213 fn route_id_for(provider_id: &str) -> String {
214 let mut out = String::from("codewhale-");
215 for ch in provider_id.chars() {
216 if ch.is_ascii_alphanumeric() {
217 out.push(ch.to_ascii_lowercase());
218 } else if !out.ends_with('-') {
219 out.push('-');
220 }
221 }
222 out.trim_end_matches('-').to_string()
223 }
224
225 pub(crate) fn map_identity(
226 identity: &CodewhaleRouteIdentity,
227 allow_full_access: bool,
228 ) -> MappedIdentity {
229 let mut disclosures = Vec::new();
230 let (permission_mode, note) = permission_mode_for(identity, allow_full_access);
231 if let Some(note) = note {
232 disclosures.push(note);
233 }
234 let dsh_effort = dsh_reasoning_effort(identity.reasoning_effort.as_deref()).map(str::to_string);
235
236 if let Err(reason) = base_url_is_structural(&identity.base_url) {
237 return MappedIdentity {
238 source: identity.clone(),
239 adapter: DshAdapter::Unsupported { reason },
240 dsh_reasoning_effort: None,
241 permission_mode,
242 disclosures,
243 };
244 }
245
246 let is_deepseek = matches!(identity.provider_id.as_str(), "deepseek" | "deepseek-cn");
247 if is_deepseek && identity.protocol == WireProtocol::ChatCompletions {
248 if identity.reasoning_effort.is_some() && dsh_effort.is_none() {
249 disclosures.push(format!(
250 "Codewhale reasoning tier `{}` has no DSH equivalent; DSH keeps its default (high).",
251 identity.reasoning_effort.as_deref().unwrap_or("")
252 ));
253 }
254 disclosures.push(
255 "DSH resolves DEEPSEEK_API_KEY from its own environment or $DSH_HOME/.credentials.yaml; Codewhale does not hand over a key."
256 .to_string(),
257 );
258 return MappedIdentity {
259 source: identity.clone(),
260 adapter: DshAdapter::DeepseekNative,
261 dsh_reasoning_effort: dsh_effort,
262 permission_mode,
263 disclosures,
264 };
265 }
266
267 // Every wire dialect Codewhale can speak, `dsh-llm-pi-ai` declares a
268 // hand-declared route for (see `pi_ai_api_for`), so the route is carried
269 // in its own dialect rather than refused or approximated as completions.
270 if identity.protocol != WireProtocol::ChatCompletions {
271 disclosures.push(format!(
272 "Route speaks the {} dialect; DSH carries it through the `@deepseek-ai/dsh-llm-pi-ai` hand-declared route (`api: {}`) — the wire dialect is preserved, never approximated as chat completions.",
273 dialect_label(identity.protocol),
274 pi_ai_api_for(identity.protocol)
275 ));
276 }
277
278 if identity.reasoning_effort.is_some() {
279 disclosures.push(
280 "Reasoning tier is not mapped for hand-declared DSH routes (per-provider wire spellings are not verified); DSH sends no effort parameter."
281 .to_string(),
282 );
283 }
284 if identity.keyless_local {
285 disclosures.push(
286 "Keyless local route: no credential reference is written; DSH talks to the endpoint without a key."
287 .to_string(),
288 );
289 } else if let Some(env) = identity.api_key_env.as_deref() {
290 disclosures.push(format!(
291 "DSH resolves {env} from its own environment or $DSH_HOME/.credentials.yaml; Codewhale does not hand over a key."
292 ));
293 } else {
294 disclosures.push(
295 "No credential env var is known for this provider; DSH will defer to its ambient credential discovery."
296 .to_string(),
297 );
298 }
299 MappedIdentity {
300 source: identity.clone(),
301 adapter: DshAdapter::PiAiOpenAiCompatible {
302 route_id: route_id_for(&identity.provider_id),
303 },
304 dsh_reasoning_effort: None,
305 permission_mode,
306 disclosures,
307 }
308 }
309
310 fn dialect_label(protocol: WireProtocol) -> &'static str {
311 match protocol {
312 WireProtocol::ChatCompletions => "OpenAI Chat Completions",
313 WireProtocol::Responses => "OpenAI Responses",
314 WireProtocol::AnthropicMessages => "Anthropic Messages",
315 }
316 }
317
318 fn yaml_str(value: &str) -> String {
319 // Single-quoted YAML scalar: only `'` needs escaping.
320 format!("'{}'", value.replace('\'', "''"))
321 }
322
323 /// Render the DSH `--patch` overlay for a mapped identity. Deterministic for
324 /// a given identity so its SHA-256 can detect drift.
325 pub(crate) fn render_overlay(mapped: &MappedIdentity) -> Option<String> {
326 let src = &mapped.source;
327 let mut out = String::new();
328 out.push_str("# DeepSeek Harness connected through Codewhale.\n");
329 out.push_str("# Generated by `codewhale integrations dsh connect`; do not edit by hand.\n");
330 out.push_str("# Identity only: no API key, token, or credential document is written here.\n");
331 out.push_str(&format!(
332 "# codewhale.provider={} codewhale.model={} codewhale.workspace={}\n",
333 src.provider_id, src.model, src.workspace
334 ));
335 match &mapped.adapter {
336 DshAdapter::DeepseekNative => {
337 out.push_str("- id: agent-default-model\n");
338 out.push_str(" name: '@deepseek-ai/dsh-agent-default-model'\n");
339 out.push_str(" config:\n");
340 out.push_str(" provider: deepseek-official\n");
341 out.push_str(&format!(" model: {}\n", yaml_str(&src.model)));
342 out.push_str("- id: llm-deepseek\n");
343 out.push_str(" name: '@deepseek-ai/dsh-llm-deepseek'\n");
344 out.push_str(" config:\n");
345 out.push_str(&format!(" baseURL: {}\n", yaml_str(&src.base_url)));
346 if let Some(effort) = mapped.dsh_reasoning_effort.as_deref() {
347 out.push_str(&format!(" reasoningEffort: {effort}\n"));
348 }
349 out.push_str(" models:\n");
350 out.push_str(&format!(" - id: {}\n", yaml_str(&src.model)));
351 out.push_str(&format!(" name: {}\n", yaml_str(&src.model)));
352 }
353 DshAdapter::PiAiOpenAiCompatible { route_id } => {
354 out.push_str("- id: agent-default-model\n");
355 out.push_str(" name: '@deepseek-ai/dsh-agent-default-model'\n");
356 out.push_str(" config:\n");
357 out.push_str(&format!(" provider: {}\n", yaml_str(route_id)));
358 out.push_str(&format!(" model: {}\n", yaml_str(&src.model)));
359 out.push_str("- id: llm-pi-ai\n");
360 out.push_str(" name: '@deepseek-ai/dsh-llm-pi-ai'\n");
361 out.push_str(" config:\n");
362 out.push_str(" providers:\n");
363 out.push_str(&format!(" {}:\n", yaml_str(route_id)));
364 out.push_str(&format!(
365 " displayName: {}\n",
366 yaml_str(&format!("{} (via Codewhale)", src.provider_label))
367 ));
368 if !src.keyless_local
369 && let Some(env) = src.api_key_env.as_deref()
370 {
371 out.push_str(&format!(" apiKeyEnv: {}\n", yaml_str(env)));
372 }
373 out.push_str(&format!(" api: {}\n", pi_ai_api_for(src.protocol)));
374 out.push_str(&format!(" baseURL: {}\n", yaml_str(&src.base_url)));
375 out.push_str(" models:\n");
376 out.push_str(&format!(" - id: {}\n", yaml_str(&src.model)));
377 out.push_str(&format!(" name: {}\n", yaml_str(&src.model)));
378 }
379 DshAdapter::Unsupported { .. } => return None,
380 }
381 Some(out)
382 }
383
384 pub(crate) fn sha256_hex(bytes: &[u8]) -> String {
385 let mut hasher = Sha256::new();
386 hasher.update(bytes);
387 hex_lower(&hasher.finalize())
388 }
389
390 fn hex_lower(bytes: &[u8]) -> String {
391 let mut out = String::with_capacity(bytes.len() * 2);
392 for byte in bytes {
393 out.push_str(&format!("{byte:02x}"));
394 }
395 out
396 }
397
397 lines RUST