返回 CodeWhale
share.rs
根目录 / crates / tui / src / commands / groups / project / share.rs
1 //! /share command — publish a redacted copy of the session as a secret gist.
2 //!
3 //! The page is rendered through the same projection and renderer as
4 //! `/export`, so hidden instructions, internal reasoning, and reasoning
5 //! signatures are dropped and secret-like values are redacted before anything
6 //! leaves the machine. The raw `api_messages` JSON never reaches the page.
7 //!
8 //! # Usage
9 //!
10 //! - `/share` — preview only: what would be uploaded; no network call
11 //! - `/share confirm` — upload as a secret gist (unlisted, not private)
12 //! - `/share help` — show usage
13
14 use std::io::Write;
15 use std::path::Path;
16
17 use codewhale_command_contract::facets::{
18 CommandSessionExportContext, ConversationExportProjection, ExportBlock, RestorePointProjection,
19 TranscriptProjection,
20 };
21 use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler};
22 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
23
24 use crate::commands::CommandResult;
25 use crate::commands::groups::session::export::render_conversation;
26 use crate::dependencies::ExternalTool;
27 use crate::tui::app::AppAction;
28
29 const VISIBILITY_NOTE: &str =
30 "a secret GitHub gist (unlisted, not private: anyone with the link can read it)";
31
32 /// Preview or share the current session.
33 fn share(export: &dyn CommandSessionExportContext, arg: Option<&str>) -> CommandResult {
34 let raw = arg.map(str::trim).unwrap_or("");
35
36 match raw {
37 "" => preview(export),
38 "confirm" => confirm(export),
39 "help" | "--help" | "-h" => CommandResult::message(format!(
40 "/share — Share a redacted copy of this session.\n\
41 \n\
42 Usage:\n\
43 /share Preview what would be uploaded (nothing leaves the machine)\n\
44 /share confirm Upload it as {VISIBILITY_NOTE}\n\
45 \n\
46 The page uses the same redacted rendering as /export: hidden\n\
47 instructions and reasoning are omitted and secret-like values are\n\
48 masked. Uploading needs the `gh` CLI, installed and signed in."
49 )),
50 _ => CommandResult::error(format!(
51 "Unknown /share argument `{raw}`. Use `/share`, `/share confirm`, or `/share help`."
52 )),
53 }
54 }
55
56 /// What `/share` would publish, measured on the exact page it would upload.
57 #[derive(Debug, Clone, PartialEq, Eq)]
58 struct SharePlan {
59 markdown: String,
60 model: String,
61 mode: String,
62 message_count: usize,
63 /// `None` when the page is the visible-history fallback, which may carry
64 /// tool output it cannot count.
65 tool_results: Option<usize>,
66 redactions: usize,
67 }
68
69 /// Project the conversation the way `/export` does, minus the local
70 /// restore-point table (snapshot ids mean nothing to a reader of the link).
71 fn plan(export: &dyn CommandSessionExportContext) -> Option<SharePlan> {
72 let mut projection: ConversationExportProjection = export.conversation_projection();
73 if projection.metadata.message_count == 0 {
74 return None;
75 }
76 projection.restore_points = RestorePointProjection::None;
77 // The visible-history fallback cannot tell tool output apart from the
78 // rest, so it is not counted as "none".
79 let tool_results = match &projection.transcript {
80 TranscriptProjection::Authoritative(messages) => Some(
81 messages
82 .iter()
83 .flat_map(|message| &message.blocks)
84 .filter(|block| matches!(block, ExportBlock::ToolResult { .. }))
85 .count(),
86 ),
87 TranscriptProjection::HistoryFallback(_) => None,
88 };
89 let model = projection.metadata.model.clone();
90 let mode = projection.metadata.mode.clone();
91 let message_count = projection.metadata.message_count;
92 let markdown = render_conversation(projection);
93 let redactions = count_redactions(&markdown);
94 Some(SharePlan {
95 markdown,
96 model,
97 mode,
98 message_count,
99 tool_results,
100 redactions,
101 })
102 }
103
104 /// Count the redaction markers the shared sanitizer leaves behind: `[redacted…]`
105 /// for secrets and keys, `***:***@` for URL credentials, and an encoded `***`
106 /// for sensitive URL query values.
107 fn count_redactions(markdown: &str) -> usize {
108 ["[redacted", "***:***@", "=%2A%2A%2A"]
109 .iter()
110 .map(|marker| markdown.matches(marker).count())
111 .sum()
112 }
113
114 const NOTHING_TO_SHARE: &str = "Nothing to share. The current session is empty.";
115
116 /// The counts both steps print, measured on the page itself. `confirm`
117 /// re-renders, so it states what it actually uploads rather than repeating
118 /// the preview's numbers (messages that arrived in between are included).
119 fn plan_summary(plan: &SharePlan) -> String {
120 let tool_output = match plan.tool_results {
121 Some(0) => "none".to_string(),
122 Some(count) => format!("included ({count} tool result(s), redacted)"),
123 None => "may be included (visible history, redacted)".to_string(),
124 };
125 format!(
126 "Messages: {}\n\
127 Tool output: {tool_output}\n\
128 Redacted: {} item(s)\n\
129 Omitted: hidden instructions, internal reasoning, reasoning signatures",
130 plan.message_count, plan.redactions
131 )
132 }
133
134 /// Plain `/share`: describe the page, upload nothing.
135 fn preview(export: &dyn CommandSessionExportContext) -> CommandResult {
136 let Some(plan) = plan(export) else {
137 return CommandResult::error(NOTHING_TO_SHARE);
138 };
139 CommandResult::message(format!(
140 "Share preview — nothing has been uploaded.\n\
141 \n\
142 {}\n\
143 \n\
144 Run `/share confirm` to upload this page as {VISIBILITY_NOTE}.\n\
145 Use `/export file <path>` to read the exact text first.",
146 plan_summary(&plan)
147 ))
148 }
149
150 /// `/share confirm`: hand the rendered, redacted page to the host to upload.
151 fn confirm(export: &dyn CommandSessionExportContext) -> CommandResult {
152 let Some(plan) = plan(export) else {
153 return CommandResult::error(NOTHING_TO_SHARE);
154 };
155 let html = render_session_html(&plan.markdown, &plan.model, &plan.mode);
156 CommandResult::with_message_and_action(
157 format!(
158 "Uploading this page as {VISIBILITY_NOTE}...\n\
159 \n\
160 {}",
161 plan_summary(&plan)
162 ),
163 AppAction::ShareSession { html },
164 )
165 }
166
167 /// Upload a rendered page, then delete the local temp copy.
168 ///
169 /// Called from the UI loop for `AppAction::ShareSession`. The upload runs on a
170 /// blocking thread because it shells out to `gh`.
171 pub async fn perform_share(html: String) -> Result<String, String> {
172 tokio::task::spawn_blocking(move || upload_via_temp_file(&html, upload_gist))
173 .await
174 .map_err(|join_err| format!("share upload panicked: {join_err}"))?
175 }
176
177 /// Write `html` to a private temp file, run `upload` on its path, and remove
178 /// the file whatever the upload returned.
179 fn upload_via_temp_file(
180 html: &str,
181 upload: impl FnOnce(&Path) -> Result<String, String>,
182 ) -> Result<String, String> {
183 let tmp = write_temp_html(html).map_err(|e| format!("Failed to write temp file: {e}"))?;
184 let result = upload(tmp.path());
185 let removed = tmp.close();
186 let url = result?;
187 removed
188 .map_err(|e| format!("Shared at {url}, but the local temp copy was not removed: {e}"))?;
189 Ok(url)
190 }
191
192 /// Render the (already redacted) conversation as a standalone HTML page.
193 fn render_session_html(markdown: &str, model: &str, mode: &str) -> String {
194 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
195 let escaped_model = html_escape(model);
196 let escaped_mode = html_escape(mode);
197 let escaped_body = html_escape(markdown);
198
199 format!(
200 r#"<!DOCTYPE html>
201 <html lang="en">
202 <head>
203 <meta charset="UTF-8">
204 <meta name="viewport" content="width=device-width, initial-scale=1.0">
205 <title>codewhale Session Export</title>
206 <style>
207 body {{
208 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
209 max-width: 800px; margin: 2rem auto; padding: 0 1rem;
210 background: #0d1117; color: #c9d1d9;
211 }}
212 h1 {{ color: #58a6ff; border-bottom: 1px solid #30363d; padding-bottom: 0.5rem; }}
213 .meta {{ color: #8b949e; font-size: 0.9rem; margin-bottom: 2rem; }}
214 pre {{ white-space: pre-wrap; word-wrap: break-word; margin: 0; }}
215 .footer {{ margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #30363d; color: #8b949e; font-size: 0.8rem; }}
216 </style>
217 </head>
218 <body>
219 <h1>codewhale Session</h1>
220 <div class="meta">
221 <strong>Model:</strong> {escaped_model} · <strong>Mode:</strong> {escaped_mode}<br>
222 <strong>Exported:</strong> {timestamp}
223 </div>
224 <pre>{escaped_body}</pre>
225 <div class="footer">
226 Generated by codewhale · https://github.com/codewhale-hq/CodeWhale
227 </div>
228 </body>
229 </html>"#,
230 )
231 }
232
233 /// HTML-escape special characters.
234 fn html_escape(s: &str) -> String {
235 s.replace('&', "&amp;")
236 .replace('<', "&lt;")
237 .replace('>', "&gt;")
238 .replace('"', "&quot;")
239 .replace('\'', "&#39;")
240 }
241
242 /// Write HTML to a secure temp file and keep it alive for upload.
243 fn write_temp_html(html: &str) -> Result<tempfile::NamedTempFile, String> {
244 let mut tmp = tempfile::Builder::new()
245 .prefix("codewhale-share-")
246 .suffix(".html")
247 .tempfile()
248 .map_err(|e| format!("{e}"))?;
249 tmp.write_all(html.as_bytes()).map_err(|e| format!("{e}"))?;
250 Ok(tmp)
251 }
252
253 /// Upload a file as a secret GitHub gist using the `gh` CLI. `gh gist
254 /// create` makes secret gists unless `--public` is passed; it never is here.
255 fn upload_gist(path: &Path) -> Result<String, String> {
256 let mut cmd = crate::dependencies::Gh::command()
257 .ok_or_else(|| "the `gh` CLI was not found".to_string())?;
258 let output = cmd
259 .args(gist_create_args(path))
260 .output()
261 .map_err(|e| format!("Failed to run `gh gist create`: {e}"))?;
262
263 if !output.status.success() {
264 let stderr = String::from_utf8_lossy(&output.stderr);
265 return Err(format!("`gh gist create` failed: {stderr}"));
266 }
267
268 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
269 if stdout.is_empty() {
270 return Err("`gh gist create` returned no output".to_string());
271 }
272
273 Ok(stdout)
274 }
275
276 fn gist_create_args(path: &Path) -> Vec<String> {
277 vec![
278 "gist".to_string(),
279 "create".to_string(),
280 path.to_string_lossy().into_owned(),
281 "--filename".to_string(),
282 "session-export.html".to_string(),
283 "--desc".to_string(),
284 "codewhale Session Export".to_string(),
285 ]
286 }
287
288 pub(in crate::commands) const SHARE_INFO: CommandInfo = CommandInfo {
289 name: "share",
290 aliases: &[],
291 usage: "/share [confirm]",
292 description_key: "cmd_share_description",
293 };
294
295 pub(in crate::commands) struct ShareCmd;
296
297 impl RegisterCommand<CommandResult> for ShareCmd {
298 fn info() -> &'static CommandInfo {
299 &SHARE_INFO
300 }
301
302 fn handler() -> CommandHandler<CommandResult> {
303 CommandHandler::Contextual {
304 capabilities: CommandCapabilities::SESSION_EXPORT,
305 handler: share_contextual,
306 }
307 }
308 }
309
310 /// Contextual `/share` dispatch. `/share` reads the conversation through the
311 /// session-export facet, the same authority `/export` uses.
312 fn share_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
313 let parts = contexts.into_parts();
314 let Some(export) = parts.export.as_deref() else {
315 return CommandResult::error("Command capability unavailable: session_export");
316 };
317 share(export, arg)
318 }
319
320 #[cfg(test)]
321 mod tests {
322 use super::*;
323 use codewhale_command_contract::facets::{
324 ExportMessage, ExportMetadata, TurnHandoffProjection,
325 };
326 use std::cell::Cell;
327 use std::path::PathBuf;
328
329 const FAKE_KEY: &str = "sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD";
330
331 /// Fake export facet: serves one projection and fails loudly if `/share`
332 /// ever reaches for clipboard or filesystem effects.
333 struct FakeExport {
334 messages: Vec<ExportMessage>,
335 projections: Cell<usize>,
336 }
337
338 impl FakeExport {
339 fn new(messages: Vec<ExportMessage>) -> Self {
340 Self {
341 messages,
342 projections: Cell::new(0),
343 }
344 }
345 }
346
347 impl CommandSessionExportContext for FakeExport {
348 fn conversation_projection(&self) -> ConversationExportProjection {
349 self.projections.set(self.projections.get() + 1);
350 ConversationExportProjection {
351 metadata: ExportMetadata {
352 session_label: "sess1234".to_string(),
353 provider: "deepseek".to_string(),
354 model: "deepseek-v4-pro".to_string(),
355 mode: "agent".to_string(),
356 workspace_name: "workspace".to_string(),
357 message_count: self.messages.len(),
358 exported_at_unix: 1_700_000_000,
359 },
360 transcript: TranscriptProjection::Authoritative(self.messages.clone()),
361 restore_points: RestorePointProjection::Unreadable {
362 reason: "local-only snapshot detail".to_string(),
363 },
364 }
365 }
366
367 fn turn_handoff_projection(&self) -> TurnHandoffProjection {
368 panic!("/share must not read the turn handoff");
369 }
370
371 fn clipboard_requires_terminal_paste(&self) -> bool {
372 panic!("/share must not touch the clipboard");
373 }
374
375 fn write_recovery_copy(&self, _markdown: &str) -> Option<PathBuf> {
376 panic!("/share must not write a recovery copy");
377 }
378
379 fn write_clipboard(&self, _markdown: &str) -> Result<(), String> {
380 panic!("/share must not touch the clipboard");
381 }
382
383 fn resolve_export_path(&self, _raw: &str) -> Result<PathBuf, String> {
384 panic!("/share must not resolve export paths");
385 }
386
387 fn write_export_file(
388 &self,
389 _path: &Path,
390 _contents: &[u8],
391 _force: bool,
392 ) -> Result<(), String> {
393 panic!("/share must not write export files");
394 }
395 }
396
397 fn message(role: &str, blocks: Vec<ExportBlock>) -> ExportMessage {
398 ExportMessage {
399 role: role.to_string(),
400 is_user_role: role == "user",
401 blocks,
402 prompt_snippet: None,
403 }
404 }
405
406 fn session_with_secret_tool_result() -> FakeExport {
407 FakeExport::new(vec![
408 message(
409 "system",
410 vec![ExportBlock::Text {
411 text: "HIDDEN SYSTEM PROMPT".to_string(),
412 }],
413 ),
414 message(
415 "user",
416 vec![ExportBlock::Text {
417 text: "print my env".to_string(),
418 }],
419 ),
420 message(
421 "assistant",
422 vec![
423 ExportBlock::InternalReasoning,
424 ExportBlock::ToolCall {
425 id: "call_1".to_string(),
426 name: "exec_shell".to_string(),
427 caller: None,
428 input: serde_json::json!({"command": "env"}),
429 },
430 ],
431 ),
432 message(
433 "user",
434 vec![ExportBlock::ToolResult {
435 tool_use_id: "call_1".to_string(),
436 content: format!("PATH=/usr/bin\nOPENAI_API_KEY={FAKE_KEY}\nleaked {FAKE_KEY}"),
437 is_error: false,
438 structured: None,
439 }],
440 ),
441 ])
442 }
443
444 fn share_html(result: &CommandResult) -> &str {
445 match result.action.as_ref() {
446 Some(AppAction::ShareSession { html }) => html,
447 other => panic!("expected ShareSession, got {other:?}"),
448 }
449 }
450
451 #[test]
452 fn plain_share_previews_without_an_upload_action() {
453 let export = session_with_secret_tool_result();
454 let result = share(&export, Some(""));
455 assert!(!result.is_error, "{result:?}");
456 assert!(
457 result.action.is_none(),
458 "plain /share must not start an upload: {:?}",
459 result.action
460 );
461 let msg = result.message.unwrap();
462 assert!(msg.contains("nothing has been uploaded"), "{msg}");
463 assert!(msg.contains("Messages: 4"), "{msg}");
464 assert!(
465 msg.contains("Tool output: included (1 tool result(s), redacted)"),
466 "{msg}"
467 );
468 assert!(msg.contains("Redacted: 2 item(s)"), "{msg}");
469 assert!(msg.contains("/share confirm"), "{msg}");
470 assert!(msg.contains("unlisted, not private"), "{msg}");
471 assert!(!msg.contains(FAKE_KEY), "{msg}");
472 }
473
474 #[test]
475 fn confirm_renders_the_redacted_export_projection_not_raw_messages() {
476 let export = session_with_secret_tool_result();
477 let result = share(&export, Some("confirm"));
478 assert!(!result.is_error, "{result:?}");
479 assert!(
480 result
481 .message
482 .as_deref()
483 .unwrap()
484 .contains("unlisted, not private"),
485 "{result:?}"
486 );
487 let msg = result.message.as_deref().unwrap();
488 assert!(msg.contains("Messages: 4"), "{msg}");
489 assert!(
490 msg.contains("Tool output: included (1 tool result(s), redacted)"),
491 "{msg}"
492 );
493 assert!(msg.contains("Redacted: 2 item(s)"), "{msg}");
494 let html = share_html(&result);
495 assert!(!html.contains(FAKE_KEY), "secret leaked into the page");
496 assert!(
497 !html.contains("sk-proj-"),
498 "secret prefix leaked into the page"
499 );
500 assert!(!html.contains("HIDDEN SYSTEM PROMPT"), "system text leaked");
501 assert!(html.contains("[internal context omitted]"));
502 assert!(html.contains("[internal reasoning and signature omitted]"));
503 assert!(
504 html.contains("PATH=/usr/bin"),
505 "ordinary tool output is kept"
506 );
507 assert!(
508 !html.contains("local-only snapshot detail"),
509 "restore-point table is local-only"
510 );
511 assert!(html.contains("deepseek-v4-pro"));
512 assert_eq!(export.projections.get(), 1);
513 }
514
515 #[test]
516 fn empty_session_errors_for_preview_and_confirm() {
517 let export = FakeExport::new(Vec::new());
518 for arg in ["", "confirm"] {
519 let result = share(&export, Some(arg));
520 assert!(result.is_error, "{arg}: {result:?}");
521 assert!(result.message.unwrap().contains("Nothing to share"));
522 assert!(result.action.is_none());
523 }
524 }
525
526 #[test]
527 fn help_and_unknown_routes() {
528 let export = session_with_secret_tool_result();
529 for arg in ["help", "--help", "-h"] {
530 let result = share(&export, Some(arg));
531 assert!(!result.is_error);
532 assert!(result.action.is_none());
533 let msg = result.message.unwrap();
534 assert!(msg.contains("/share confirm"), "{msg}");
535 assert!(msg.contains("unlisted, not private"), "{msg}");
536 }
537 let result = share(&export, Some("bogus"));
538 assert!(result.is_error);
539 assert!(
540 result
541 .message
542 .unwrap()
543 .contains("Unknown /share argument `bogus`")
544 );
545 assert_eq!(export.projections.get(), 0, "help/unknown read nothing");
546 }
547
548 #[test]
549 fn missing_export_facet_fails_safely() {
550 let result = share_contextual(CommandContexts::empty(), Some("confirm"));
551 assert!(result.is_error);
552 assert!(result.action.is_none());
553 assert!(
554 result
555 .message
556 .unwrap()
557 .contains("Command capability unavailable: session_export")
558 );
559 }
560
561 #[test]
562 fn gist_is_created_without_public_flag() {
563 let args = gist_create_args(Path::new("/tmp/page.html"));
564 assert!(!args.iter().any(|arg| arg == "--public"), "{args:?}");
565 assert_eq!(&args[..3], ["gist", "create", "/tmp/page.html"]);
566 }
567
568 #[test]
569 fn temp_page_is_removed_after_upload_success_and_failure() {
570 let mut seen = None;
571 let url = upload_via_temp_file("<html>ok</html>", |path| {
572 assert_eq!(std::fs::read_to_string(path).unwrap(), "<html>ok</html>");
573 seen = Some(path.to_path_buf());
574 Ok("https://gist.github.com/x".to_string())
575 })
576 .unwrap();
577 assert_eq!(url, "https://gist.github.com/x");
578 assert!(
579 !seen.unwrap().exists(),
580 "temp page must be deleted after upload"
581 );
582
583 let mut seen = None;
584 let err = upload_via_temp_file("<html>no</html>", |path| {
585 seen = Some(path.to_path_buf());
586 Err("gh failed".to_string())
587 })
588 .unwrap_err();
589 assert_eq!(err, "gh failed");
590 assert!(
591 !seen.unwrap().exists(),
592 "temp page must be deleted on failure"
593 );
594 }
595
596 #[test]
597 fn html_escapes_the_rendered_markdown() {
598 let html = render_session_html("<script>x</script> & \"q\"", "m<1>", "agent");
599 assert!(html.contains("&lt;script&gt;x&lt;/script&gt; &amp; &quot;q&quot;"));
600 assert!(html.contains("m&lt;1&gt;"));
601 assert!(!html.contains("<script>"));
602 }
603 }
604
604 lines RUST