diff --git a/.gitattributes b/.gitattributes index a7804b888f..cf98e11d7a 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,6 +19,11 @@ crates/telemetry/tests/golden/*.json text eol=lf # always join rows with LF (golden_harness.rs::render_golden_text), so a # CRLF checkout failed all eight Windows golden tests with a pure \r\n diff. crates/tui/src/tui/goldens/*.txt text eol=lf +# FEAT-025 baseline export goldens (crates/tui/src/commands/fixtures/*.md) are +# include_str!()'d and compared byte for byte against a document the exporter +# builds with LF, so a CRLF checkout failed the four Windows golden tests with a +# pure \r\n diff (Windows CI caught this; Linux cannot see it). +crates/tui/src/commands/fixtures/*.md text eol=lf crates/*/assets/**/*.json text eol=lf crates/*/assets/**/*.md text eol=lf crates/*/locales/*.json text eol=lf diff --git a/Cargo.lock b/Cargo.lock index a69b03ce6f..dd09a5d664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -870,6 +870,7 @@ name = "codewhale-command-contract" version = "0.9.13" dependencies = [ "codewhale-core", + "serde_json", ] [[package]] @@ -1050,12 +1051,14 @@ dependencies = [ "chrono", "codewhale-paths", "keyring", + "regex", "serde", "serde_json", "sha2 0.11.0", "tempfile", "thiserror 2.0.20", "tracing", + "url", ] [[package]] diff --git a/crates/command-contract/Cargo.toml b/crates/command-contract/Cargo.toml index af61912af0..7e98a9bd85 100644 --- a/crates/command-contract/Cargo.toml +++ b/crates/command-contract/Cargo.toml @@ -12,3 +12,7 @@ workspace = true [dependencies] codewhale-core = { path = "../core", version = "0.9.13" } +# FEAT-025 export projections carry JSON tool payloads. The workspace already +# pins serde_json with `preserve_order` (crates/core and crates/tui), so this +# adds no new external dependency to the graph. +serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index f256797d40..5a7e50f187 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use codewhale_core::request::{Message, SystemPrompt}; +use serde_json::Value; use crate::types::{ CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort, @@ -1368,3 +1369,223 @@ pub trait CommandSessionControlContext { /// errors. fn resolve_hosted_work_target(&self) -> Option; } + +// --------------------------------------------------------------------------- +// FEAT-025: session export slice (D1-D9). +// +// One independently optional session-export authority covering exactly the +// host work `/export` (and its `/daochu` alias) consumes. The shared +// `CommandSessionContext`, `CommandSessionLifecycleContext`, and +// `CommandSessionControlContext` facets are deliberately not widened: export +// authority exists only on this facet, and every delegate is an atomic host +// operation or a semantic projection so the portable handler keeps +// byte-identical composition. Hidden payloads are excluded while projections +// are built (D9), so internal reasoning, reasoning signatures, and inline or +// local image bytes never enter these DTOs. No `App`, clipboard handler, +// snapshot repository, history cell, session manager, configuration, client, +// filesystem handle, or host callback crosses this boundary (D1/D3/D5/D7). +// --------------------------------------------------------------------------- + +/// Portable conversation metadata for the export header (D3). +/// +/// Values that already have an authoritative host derivation keep it +/// (session-label truncation, provider identity, model label, mode display, +/// workspace basename, message count, clock); portable rendering adds only +/// export formatting and sanitization (D10). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExportMetadata { + /// Host-truncated session id, or the baseline `unsaved` fallback. + pub session_label: String, + pub provider: String, + pub model: String, + pub mode: String, + /// Workspace directory basename, or the baseline `workspace` fallback. + pub workspace_name: String, + /// `api_messages.len()` when authoritative, otherwise `history.len()`. + pub message_count: usize, + pub exported_at_unix: i64, +} + +/// One tool-call caller projection (D3). Only the fields the baseline export +/// renders cross the boundary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolCallerProjection { + pub caller_type: String, + pub tool_id: Option, +} + +/// One projected content block (D3/D9). +/// +/// Visible text and structured content cross as portable data; internal +/// reasoning bodies, reasoning signatures, and inline or local image payloads +/// are replaced by typed omission markers at projection time and never cross. +#[derive(Clone, Debug, PartialEq)] +pub enum ExportBlock { + /// Visible text block; portable rendering sanitizes it. + Text { + text: String, + }, + /// External image reference (`http`/`https` only); portable rendering + /// redacts credential-bearing URLs. + ImageReference { + url: String, + }, + /// Inline or local image payload excluded at projection time (D9). + ImageOmitted, + /// Internal reasoning body and reasoning signature excluded (D9). + InternalReasoning, + ToolCall { + id: String, + name: String, + caller: Option, + input: Value, + }, + ToolResult { + tool_use_id: String, + content: String, + is_error: bool, + /// `Some` when the host message carried structured result blocks; the + /// host has already applied the safe-result filter (D9). + structured: Option, + }, + ServerToolCall { + id: String, + name: String, + input: Value, + }, + ToolSearchResult { + tool_use_id: String, + content: Value, + }, + CodeExecutionResult { + tool_use_id: String, + content: Value, + }, +} + +/// One projected authoritative message (D3). +/// +/// `prompt_snippet` is the host-computed `snapshot_label_prompt_snippet` of +/// the first visible text block. The parser and snippet algorithm stay +/// TUI-owned (D8), so correlation compares authoritative values instead of +/// re-deriving them portably. +/// +/// `is_user_role` carries the host's exact `Role::User` comparison. `role` is +/// the rendered wire string, and comparing it textually would also match a +/// `Role::Unrecognized("user")`, which the baseline never treated as a user +/// turn. The flag keeps restore-point correlation faithful to the baseline. +#[derive(Clone, Debug, PartialEq)] +pub struct ExportMessage { + pub role: String, + /// Exact `message.role == Role::User`, not a string comparison. + pub is_user_role: bool, + pub blocks: Vec, + pub prompt_snippet: Option, +} + +/// One projected visible-history fallback entry (D3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HistoryEntry { + /// Visible host content that portable rendering must still sanitize. + Sanitized { role: String, body: String }, + /// An already-final baseline marker line that must not be sanitized again. + Literal { role: String, body: String }, +} + +/// Transcript source precedence (D3): authoritative API messages when +/// present, otherwise the sanitized visible-history fallback. +#[derive(Clone, Debug, PartialEq)] +pub enum TranscriptProjection { + Authoritative(Vec), + HistoryFallback(Vec), +} + +/// One snapshot projected to semantic fields (D8). +/// +/// `kind`, `sequence`, and `prompt_snippet` are the host-parsed label fields; +/// the raw `label` is kept only for the human-readable table column. No +/// preformatted correlation line crosses the boundary. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestoreSnapshot { + pub id: String, + pub label: String, + pub timestamp_unix: i64, + pub kind: String, + pub sequence: Option, + pub prompt_snippet: Option, +} + +/// Restore-point projection with distinct baseline states (D3/D8). +/// +/// `None` means no snapshot repository exists, `Unreadable` preserves the host +/// failure reason, and `Recorded` distinguishes an existing-but-empty +/// repository from one with snapshots by the vector length. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RestorePointProjection { + None, + Unreadable { reason: String }, + Recorded { snapshots: Vec }, +} + +/// Full conversation projection (D3/D8/D9). +#[derive(Clone, Debug, PartialEq)] +pub struct ConversationExportProjection { + pub metadata: ExportMetadata, + pub transcript: TranscriptProjection, + pub restore_points: RestorePointProjection, +} + +/// Turn-handoff projection (D2). +/// +/// `markdown` is the unmodified shared TUI renderer output and +/// `workspace_path` is the value the portable handler replaces with `.` after +/// sanitizing; the renderer itself is neither moved nor duplicated. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TurnHandoffProjection { + pub markdown: String, + pub workspace_path: String, +} + +/// Session-export authority for the `/export` slice (FEAT-025 D1-D9). +/// +/// Operation-granular synchronous delegates over the exact minimum host work +/// the command consumes. The portable handler parses the request first, renders +/// the selected scope second, and then uses these delegates in baseline order: +/// clipboard exports call terminal-paste detection, recovery write, and +/// clipboard delivery exactly once each with the same Markdown; file exports +/// resolve the destination before writing it. A recovery-write `None` never +/// prevents the clipboard attempt, and a turn-only export never requests the +/// conversation projection (D6/D7). +pub trait CommandSessionExportContext { + /// Conversation export projection: metadata, authoritative-or-fallback + /// transcript, and restore-point state. Read-only; opens only an existing + /// snapshot repository and never creates one (D8). + fn conversation_projection(&self) -> ConversationExportProjection; + + /// Turn-handoff projection: unmodified shared renderer Markdown plus the + /// workspace path value (D2). + fn turn_handoff_projection(&self) -> TurnHandoffProjection; + + /// Whether clipboard delivery goes through the terminal-client (SSH/OSC 52 + /// via tmux) path (D6). + fn clipboard_requires_terminal_paste(&self) -> bool; + + /// Write the shared `last-copy.md` recovery file. `None` reproduces the + /// baseline silent failure; recovery writing never falls through to an + /// error (D5/D6). + fn write_recovery_copy(&self, markdown: &str) -> Option; + + /// Attempt clipboard delivery. `Err` carries the raw host clipboard error + /// text; the handler composes the exact failure wording (D6). + fn write_clipboard(&self, markdown: &str) -> Result<(), String>; + + /// Resolve a file destination exactly as the baseline does (trim, empty + /// check, `..` rejection, workspace canonicalization and rebasing, filename + /// requirement). Errors are returned unwrapped (D7). + fn resolve_export_path(&self, raw: &str) -> Result; + + /// Write the rendered export to a resolved destination with the baseline + /// protection checks. Errors are returned unwrapped; the handler wraps them + /// in `Failed to export {label} to {path}: {err}` (D7). + fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String>; +} diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index f1cd0dfa25..b116111d4b 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -7,9 +7,9 @@ use crate::facets::{ CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext, CommandModelContext, CommandPluginContext, CommandPresentationContext, CommandProjectContext, - CommandSessionContext, CommandSessionControlContext, CommandSessionLifecycleContext, - CommandSkillGroupContext, CommandSkillsContext, CommandSystemPromptContext, - CommandWorkspaceContext, + CommandSessionContext, CommandSessionControlContext, CommandSessionExportContext, + CommandSessionLifecycleContext, CommandSkillGroupContext, CommandSkillsContext, + CommandSystemPromptContext, CommandWorkspaceContext, }; /// Exact host capabilities exposed to one contextual command handler. @@ -51,6 +51,30 @@ impl CommandCapabilities { /// storage remains `u16` per the resolved maintainer review on FEAT-023 PR /// #5902 — bit 14 is available, so no speculative widening is performed. pub const SESSION_CONTROL: Self = Self(1 << 14); + /// Session-export host data (FEAT-025 D1), the next non-conflicting bit + /// after `SESSION_CONTROL`. Required only by the host-dependent `/export` + /// command and its `/daochu` alias; every concrete App, snapshot, clipboard, + /// filesystem, history, and turn-handoff access stays behind the TUI export + /// adapter. + /// + /// **Capacity: this is the last free bit.** Bits 0-15 are now fully + /// allocated, so another capability cannot be added without widening the + /// backing storage to `u32`. FEAT-026 (session structcopy) needs its own + /// exact-minimum facet and therefore owns that widening decision; reusing + /// `SESSION_EXPORT` for it would break the least-capability invariant. + /// The `export_capability_space_is_exactly_full` test pins the capacity so + /// the next author gets a deliberate decision instead of a compile error + /// with no context. + pub const SESSION_EXPORT: Self = Self(1 << 15); + + /// Raw bit pattern, for tests that pin the capability-space capacity. + /// + /// Kept `#[cfg(test)]` so the `u16` backing stays an implementation detail + /// and nothing can widen it accidentally through a public accessor. + #[cfg(test)] + pub(crate) const fn bits_for_test(self) -> u16 { + self.0 + } pub const fn union(self, other: Self) -> Self { Self(self.0 | other.0) @@ -100,6 +124,7 @@ pub struct CommandContexts<'a> { plugin: Option<&'a mut dyn CommandPluginContext>, lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>, control: Option<&'a mut dyn CommandSessionControlContext>, + export: Option<&'a mut dyn CommandSessionExportContext>, } /// Consumed envelope used when one handler needs several independent facets. @@ -119,6 +144,7 @@ pub struct ContextParts<'a> { pub plugin: Option<&'a mut dyn CommandPluginContext>, pub lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>, pub control: Option<&'a mut dyn CommandSessionControlContext>, + pub export: Option<&'a mut dyn CommandSessionExportContext>, } impl<'a> CommandContexts<'a> { @@ -139,6 +165,7 @@ impl<'a> CommandContexts<'a> { plugin: None, lifecycle: None, control: None, + export: None, } } @@ -159,6 +186,7 @@ impl<'a> CommandContexts<'a> { plugin: self.plugin, lifecycle: self.lifecycle, control: self.control, + export: self.export, } } @@ -278,6 +306,14 @@ impl<'a> CommandContexts<'a> { ); self } + + pub fn with_export(mut self, value: &'a mut dyn CommandSessionExportContext) -> Self { + assert!( + self.export.replace(value).is_none(), + "export facet already set" + ); + self + } } impl Default for CommandContexts<'_> { diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 6145dd660b..ca3d5583f5 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -1,3 +1,4 @@ +use std::cell::RefCell; use std::path::{Path, PathBuf}; use codewhale_core::request::{ContentBlock, Message, SystemPrompt}; @@ -258,15 +259,20 @@ fn new_capabilities_are_object_safe_and_independently_transportable() { presentation(&Presentation); media(&Media); digest_workspace(&DigestWorkspace); + fn export(_: &dyn CommandSessionExportContext) {} + export(&FakeExport::default()); let mut presentation = Presentation; let mut media = Media; + let mut export = FakeExport::default(); let parts = CommandContexts::empty() .with_presentation(&mut presentation) .with_media(&mut media) + .with_export(&mut export) .into_parts(); assert!(parts.presentation.is_some()); assert!(parts.media.is_some()); + assert!(parts.export.is_some()); assert!(parts.session.is_none()); } @@ -2514,3 +2520,541 @@ fn control_surface_does_not_widen_session_or_lifecycle_facets() { assert!(!parts.lifecycle.as_deref_mut().unwrap().transition_blocked()); assert!(parts.control.as_deref_mut().unwrap().transition_blocked()); } + +// --------------------------------------------------------------------------- +// FEAT-025: session export contract (D1/D3/D5/D6/D7/D8/D9). +// --------------------------------------------------------------------------- + +#[test] +fn export_capability_is_stable_distinct_and_non_conflicting() { + let export = CommandCapabilities::SESSION_EXPORT; + let existing = [ + CommandCapabilities::SESSION, + CommandCapabilities::MODEL, + CommandCapabilities::COST, + CommandCapabilities::MODE_POLICY, + CommandCapabilities::SYSTEM_PROMPT, + CommandCapabilities::SKILLS, + CommandCapabilities::WORKSPACE, + CommandCapabilities::PRESENTATION, + CommandCapabilities::MEDIA, + CommandCapabilities::MEMORY, + CommandCapabilities::PROJECT, + CommandCapabilities::SKILL_GROUP, + CommandCapabilities::PLUGIN, + CommandCapabilities::SESSION_LIFECYCLE, + CommandCapabilities::SESSION_CONTROL, + ]; + let mut union = CommandCapabilities::NONE; + for capability in existing { + assert_ne!( + export, capability, + "SESSION_EXPORT must not collide with an existing capability" + ); + union = union.union(capability); + } + assert!( + !union.contains(export), + "SESSION_EXPORT must be a bit outside every existing capability (bits 0-14)" + ); + assert!(!CommandCapabilities::NONE.contains(export)); + assert!(!CommandCapabilities::NONE.contains(CommandCapabilities::NONE)); + assert!(export.contains(export)); + assert!( + export + .union(CommandCapabilities::SESSION_CONTROL) + .contains(export) + ); + assert!( + export + .union(CommandCapabilities::SESSION_CONTROL) + .contains(CommandCapabilities::SESSION_CONTROL) + ); + assert!(!CommandCapabilities::SESSION_CONTROL.contains(export)); + assert!(!export.contains(CommandCapabilities::SESSION_CONTROL)); + // Storage remains `u16`-backed: bit 15 (1 << 15 = 32768) fits without the + // speculative widening FEAT-023's maintainer review ruled out. + assert_eq!( + std::mem::size_of::(), + std::mem::size_of::(), + "CommandCapabilities storage must stay u16" + ); +} + +/// Canary: after FEAT-025 the `u16` capability space is *exactly* full. +/// +/// This is deliberate capacity documentation, not a health check. When FEAT-026 +/// (session structcopy) adds its own facet it must widen the backing storage to +/// `u32`, and this test is expected to be updated in that commit. Until then it +/// guarantees that no capability bit is silently reused, and that anyone who +/// adds a seventeenth capability is told why `1 << 16` on a `u16` will not do. +#[test] +fn export_capability_space_is_exactly_full() { + let all = [ + CommandCapabilities::SESSION, + CommandCapabilities::MODEL, + CommandCapabilities::COST, + CommandCapabilities::MODE_POLICY, + CommandCapabilities::SYSTEM_PROMPT, + CommandCapabilities::SKILLS, + CommandCapabilities::WORKSPACE, + CommandCapabilities::PRESENTATION, + CommandCapabilities::MEDIA, + CommandCapabilities::MEMORY, + CommandCapabilities::PROJECT, + CommandCapabilities::SKILL_GROUP, + CommandCapabilities::PLUGIN, + CommandCapabilities::SESSION_LIFECYCLE, + CommandCapabilities::SESSION_CONTROL, + CommandCapabilities::SESSION_EXPORT, + ]; + + let mut union = CommandCapabilities::NONE; + for (index, capability) in all.iter().enumerate() { + assert_eq!( + capability.bits_for_test(), + 1u16 << index, + "capability {index} must occupy exactly bit {index}" + ); + union = union.union(*capability); + } + + assert_eq!( + all.len(), + u16::BITS as usize, + "the declared capability count must consume the whole u16 space" + ); + assert_eq!( + union.bits_for_test(), + u16::MAX, + "bits 0-15 are fully allocated; FEAT-026 must widen the storage to u32" + ); +} + +/// Deterministic fake export facet: every delegate returns canned portable +/// values or host error text, and effectful delegates record their calls so a +/// later phase can assert sequencing without a real host. +#[derive(Default)] +struct FakeExport { + projection: Option, + turn: Option, + terminal_paste: bool, + recovery: Option>, + clipboard: Option>, + resolved: Option>, + write: Option>, + calls: RefCell>, +} + +impl CommandSessionExportContext for FakeExport { + fn conversation_projection(&self) -> ConversationExportProjection { + self.calls + .borrow_mut() + .push("conversation_projection".to_string()); + self.projection + .clone() + .expect("unexpected conversation_projection() on empty fake") + } + fn turn_handoff_projection(&self) -> TurnHandoffProjection { + self.calls + .borrow_mut() + .push("turn_handoff_projection".to_string()); + self.turn + .clone() + .expect("unexpected turn_handoff_projection() on empty fake") + } + fn clipboard_requires_terminal_paste(&self) -> bool { + self.calls + .borrow_mut() + .push("clipboard_requires_terminal_paste".to_string()); + self.terminal_paste + } + fn write_recovery_copy(&self, markdown: &str) -> Option { + self.calls + .borrow_mut() + .push(format!("write_recovery_copy:{markdown}")); + self.recovery + .clone() + .expect("unexpected write_recovery_copy() on empty fake") + } + fn write_clipboard(&self, markdown: &str) -> Result<(), String> { + self.calls + .borrow_mut() + .push(format!("write_clipboard:{markdown}")); + self.clipboard + .clone() + .unwrap_or_else(|| Err("unexpected write_clipboard() on empty fake".to_string())) + } + fn resolve_export_path(&self, raw: &str) -> Result { + self.calls + .borrow_mut() + .push(format!("resolve_export_path:{raw}")); + self.resolved.clone().unwrap_or_else(|| { + Err(format!( + "unexpected resolve_export_path({raw}) on empty fake" + )) + }) + } + fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> { + self.calls.borrow_mut().push(format!( + "write_export_file:{}:{}:{force}", + path.display(), + contents.len() + )); + self.write + .clone() + .unwrap_or_else(|| Err("unexpected write_export_file() on empty fake".to_string())) + } +} + +fn export_metadata() -> ExportMetadata { + ExportMetadata { + session_label: "abc123".to_string(), + provider: "deepseek".to_string(), + model: "deepseek-chat".to_string(), + mode: "ACT".to_string(), + workspace_name: "workspace".to_string(), + message_count: 2, + exported_at_unix: 1_760_000_000, + } +} + +fn export_recorded_snapshot() -> RestoreSnapshot { + RestoreSnapshot { + id: "0123456789abcdef".to_string(), + label: "pre-turn:3: fix parser".to_string(), + timestamp_unix: 1_759_999_000, + kind: "pre-turn".to_string(), + sequence: Some(3), + prompt_snippet: Some("fix parser".to_string()), + } +} + +#[test] +fn export_facet_is_object_safe_and_transports_every_outcome() { + // Object safety: usable behind a single `dyn` reference. + fn accepts_dyn(_: &dyn CommandSessionExportContext) {} + fn accepts_dyn_mut(_: &mut dyn CommandSessionExportContext) {} + + let mut fake = FakeExport { + projection: Some(ConversationExportProjection { + metadata: export_metadata(), + transcript: TranscriptProjection::Authoritative(vec![ExportMessage { + is_user_role: false, + role: "assistant".to_string(), + prompt_snippet: Some("fix parser".to_string()), + blocks: vec![ + ExportBlock::Text { + text: "visible".to_string(), + }, + ExportBlock::ImageReference { + url: "https://example.test/a.png".to_string(), + }, + ExportBlock::ImageOmitted, + ExportBlock::InternalReasoning, + ExportBlock::ToolCall { + id: "tool-1".to_string(), + name: "read".to_string(), + caller: Some(ToolCallerProjection { + caller_type: "direct".to_string(), + tool_id: Some("caller-1".to_string()), + }), + input: serde_json::json!({"path": "a.txt"}), + }, + ExportBlock::ToolResult { + tool_use_id: "tool-1".to_string(), + content: "ok".to_string(), + is_error: false, + structured: Some(serde_json::json!([{"type": "text", "text": "ok"}])), + }, + ExportBlock::ServerToolCall { + id: "server-1".to_string(), + name: "web_search".to_string(), + input: serde_json::json!({"q": "rust"}), + }, + ExportBlock::ToolSearchResult { + tool_use_id: "search-1".to_string(), + content: serde_json::json!({"results": []}), + }, + ExportBlock::CodeExecutionResult { + tool_use_id: "code-1".to_string(), + content: serde_json::json!({"stdout": "hi"}), + }, + ], + }]), + restore_points: RestorePointProjection::Recorded { + snapshots: vec![export_recorded_snapshot()], + }, + }), + turn: Some(TurnHandoffProjection { + markdown: "# turn handoff".to_string(), + workspace_path: "/workspace/example".to_string(), + }), + terminal_paste: true, + recovery: Some(Some(PathBuf::from( + "/home/u/.codewhale/exports/last-copy.md", + ))), + clipboard: Some(Ok(())), + resolved: Some(Ok(PathBuf::from("/workspace/example/out.md"))), + write: Some(Ok(())), + ..FakeExport::default() + }; + accepts_dyn(&fake); + accepts_dyn_mut(&mut fake); + + let projection = fake.conversation_projection(); + assert_eq!(projection.metadata.session_label, "abc123"); + assert_eq!(projection.metadata.provider, "deepseek"); + assert_eq!(projection.metadata.model, "deepseek-chat"); + assert_eq!(projection.metadata.mode, "ACT"); + assert_eq!(projection.metadata.workspace_name, "workspace"); + assert_eq!(projection.metadata.message_count, 2); + assert_eq!(projection.metadata.exported_at_unix, 1_760_000_000); + let TranscriptProjection::Authoritative(messages) = projection.transcript else { + panic!("expected authoritative transcript"); + }; + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "assistant"); + assert_eq!(messages[0].prompt_snippet.as_deref(), Some("fix parser")); + assert_eq!(messages[0].blocks.len(), 9); + let ExportBlock::ToolCall { + caller: Some(caller), + input, + .. + } = &messages[0].blocks[4] + else { + panic!("expected tool call with caller"); + }; + assert_eq!(caller.caller_type, "direct"); + assert_eq!(caller.tool_id.as_deref(), Some("caller-1")); + assert_eq!(input["path"], "a.txt"); + let ExportBlock::ToolResult { + is_error, + structured, + .. + } = &messages[0].blocks[5] + else { + panic!("expected tool result"); + }; + assert!(!is_error); + assert!(structured.is_some()); + let RestorePointProjection::Recorded { snapshots } = projection.restore_points else { + panic!("expected recorded restore points"); + }; + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].id, "0123456789abcdef"); + assert_eq!(snapshots[0].label, "pre-turn:3: fix parser"); + assert_eq!(snapshots[0].timestamp_unix, 1_759_999_000); + assert_eq!(snapshots[0].kind, "pre-turn"); + assert_eq!(snapshots[0].sequence, Some(3)); + assert_eq!(snapshots[0].prompt_snippet.as_deref(), Some("fix parser")); + + let turn = fake.turn_handoff_projection(); + assert_eq!(turn.markdown, "# turn handoff"); + assert_eq!(turn.workspace_path, "/workspace/example"); + + assert!(fake.clipboard_requires_terminal_paste()); + assert_eq!( + fake.write_recovery_copy("# md"), + Some(PathBuf::from("/home/u/.codewhale/exports/last-copy.md")) + ); + assert!(fake.write_clipboard("# md").is_ok()); + assert_eq!( + fake.resolve_export_path("out.md").expect("resolved"), + PathBuf::from("/workspace/example/out.md") + ); + assert!( + fake.write_export_file(Path::new("/workspace/example/out.md"), b"# md", false) + .is_ok() + ); + // Effectful delegates were exercised exactly once each, in call order. + let expected: Vec = [ + "conversation_projection", + "turn_handoff_projection", + "clipboard_requires_terminal_paste", + "write_recovery_copy:# md", + "write_clipboard:# md", + "resolve_export_path:out.md", + "write_export_file:/workspace/example/out.md:4:false", + ] + .into_iter() + .map(str::to_string) + .collect(); + assert_eq!(fake.calls.borrow().as_slice(), expected.as_slice()); +} + +#[test] +fn export_error_and_empty_states_transport_exactly() { + let fake = FakeExport { + projection: Some(ConversationExportProjection { + metadata: export_metadata(), + transcript: TranscriptProjection::HistoryFallback(vec![ + HistoryEntry::Sanitized { + role: "user".to_string(), + body: "visible history".to_string(), + }, + HistoryEntry::Literal { + role: "system".to_string(), + body: "[internal context omitted]".to_string(), + }, + ]), + restore_points: RestorePointProjection::Unreadable { + reason: "permission denied".to_string(), + }, + }), + recovery: Some(None), + clipboard: Some(Err("clipboard unavailable".to_string())), + resolved: Some(Err("export paths may not contain `..`".to_string())), + write: Some(Err("destination already exists".to_string())), + ..FakeExport::default() + }; + + let projection = fake.conversation_projection(); + let TranscriptProjection::HistoryFallback(entries) = projection.transcript else { + panic!("expected history fallback"); + }; + assert_eq!(entries.len(), 2); + assert!(matches!( + &entries[0], + HistoryEntry::Sanitized { role, body } + if role == "user" && body == "visible history" + )); + assert!(matches!( + &entries[1], + HistoryEntry::Literal { role, body } + if role == "system" && body == "[internal context omitted]" + )); + let RestorePointProjection::Unreadable { reason } = projection.restore_points else { + panic!("expected unreadable restore points"); + }; + assert_eq!(reason, "permission denied"); + + assert!(!fake.clipboard_requires_terminal_paste()); + assert_eq!(fake.write_recovery_copy("# md"), None); + assert_eq!( + fake.write_clipboard("# md").unwrap_err(), + "clipboard unavailable" + ); + assert_eq!( + fake.resolve_export_path("../out.md").unwrap_err(), + "export paths may not contain `..`" + ); + assert_eq!( + fake.write_export_file(Path::new("/tmp/out.md"), b"x", false) + .unwrap_err(), + "destination already exists" + ); +} + +#[test] +fn export_projection_distinguishes_restore_states() { + let states = [ + RestorePointProjection::None, + RestorePointProjection::Unreadable { + reason: "boom".to_string(), + }, + RestorePointProjection::Recorded { snapshots: vec![] }, + RestorePointProjection::Recorded { + snapshots: vec![export_recorded_snapshot()], + }, + ]; + assert!(matches!(&states[0], RestorePointProjection::None)); + assert!(matches!( + &states[1], + RestorePointProjection::Unreadable { reason } if reason == "boom" + )); + let RestorePointProjection::Recorded { snapshots } = &states[2] else { + panic!("expected recorded state"); + }; + assert!(snapshots.is_empty(), "existing-but-empty stays distinct"); + let RestorePointProjection::Recorded { snapshots } = &states[3] else { + panic!("expected recorded state"); + }; + assert_eq!(snapshots.len(), 1); +} + +#[test] +fn export_projection_omission_markers_carry_no_hidden_payload() { + // D9: the projection has no field for a reasoning body, reasoning + // signature, or inline/local image payload. Omission markers are data-free + // unit variants, so prohibited payloads cannot be transported even by + // accident. + let block = ExportBlock::InternalReasoning; + let ExportBlock::InternalReasoning = block else { + panic!("internal reasoning must be a payload-free marker"); + }; + let block = ExportBlock::ImageOmitted; + let ExportBlock::ImageOmitted = block else { + panic!("omitted image must be a payload-free marker"); + }; + + const HIDDEN_REASONING: &str = "signed-thinking-secret-body"; + const HIDDEN_SIGNATURE: &str = "sig_1234567890"; + const HIDDEN_IMAGE: &str = "data:image/png;base64,QUJD"; + + let projection = ConversationExportProjection { + metadata: export_metadata(), + transcript: TranscriptProjection::Authoritative(vec![ExportMessage { + is_user_role: false, + role: "assistant".to_string(), + prompt_snippet: None, + blocks: vec![ExportBlock::InternalReasoning, ExportBlock::ImageOmitted], + }]), + restore_points: RestorePointProjection::None, + }; + let rendered = format!("{projection:?}"); + assert!(!rendered.contains(HIDDEN_REASONING)); + assert!(!rendered.contains(HIDDEN_SIGNATURE)); + assert!(!rendered.contains(HIDDEN_IMAGE)); + assert!(rendered.contains("InternalReasoning")); + assert!(rendered.contains("ImageOmitted")); +} + +#[test] +fn envelope_export_slot_is_independent_and_rejects_duplicates() { + let mut first = FakeExport::default(); + let mut second = FakeExport::default(); + let mut control = FakeControl::default(); + + let parts = CommandContexts::empty() + .with_export(&mut first) + .with_control(&mut control) + .into_parts(); + assert!( + parts.export.is_some(), + "export slot must be present when declared" + ); + assert!( + parts.control.is_some(), + "control slot may coexist with export" + ); + assert!( + parts.session.is_none() + && parts.lifecycle.is_none() + && parts.plugin.is_none() + && parts.skill_group.is_none(), + "unrelated slots must stay absent (exact exposure)" + ); + + let bare = CommandContexts::empty().into_parts(); + assert!(bare.export.is_none(), "undeclared export stays absent"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CommandContexts::empty() + .with_export(&mut first) + .with_export(&mut second); + })); + assert!( + result.is_err(), + "duplicate export slot must assert deterministically" + ); + + // Reading through the dyn facet works after insertion. + let mut projection = FakeExport { + terminal_paste: true, + ..FakeExport::default() + }; + let inserted = CommandContexts::empty().with_export(&mut projection); + let export = inserted.into_parts().export.expect("inserted export"); + assert!(export.clipboard_requires_terminal_paste()); +} diff --git a/crates/config/src/persistence.rs b/crates/config/src/persistence.rs index 0f1a00ab52..a22eaa3297 100644 --- a/crates/config/src/persistence.rs +++ b/crates/config/src/persistence.rs @@ -230,597 +230,14 @@ fn rollback(snapshots: &[Snapshot]) { } } -/// Hints that mark a config/JSON/env key as carrying a secret value. -/// -/// Compound hints (`api_key`, `client_secret`) match as a substring of the -/// normalized key. Single-word hints (`token`, `secret`, `password`) match a -/// whole identifier segment so they describe a credential (`token`, -/// `api_token`) and not an English word (`tokens`, `tokenizer`). -const SENSITIVE_KEY_HINTS: &[&str] = &[ - "api_key", - "apikey", - "api-key", - "secret", - "token", - "password", - "passwd", - "authorization", - "auth_token", - "access_key", - "client_secret", - "private_key", -]; - -/// Known opaque-token prefixes worth masking even when they appear bare (not as -/// `key = value`). Conservative on purpose: only well-known provider/key shapes. -const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"]; - -/// The placeholder substituted for any redacted secret value. -pub const REDACTED: &str = "[redacted]"; - -/// Return a copy of a JSON value with secret-bearing data removed. -/// -/// Object values whose key contains a sensitive hint are replaced wholesale, -/// while all other objects and arrays are traversed recursively. String leaves -/// still pass through [`redact_secrets`] so bare provider tokens and embedded -/// assignments remain covered without treating the serialized JSON document as -/// one flat keyed assignment. -#[must_use] -pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value { - match value { - serde_json::Value::Object(object) => serde_json::Value::Object( - object - .iter() - .map(|(key, value)| { - let value = if key_is_sensitive(key) { - serde_json::Value::String(REDACTED.to_string()) - } else { - redact_json_secrets(value) - }; - (key.clone(), value) - }) - .collect(), - ), - serde_json::Value::Array(items) => { - serde_json::Value::Array(items.iter().map(redact_json_secrets).collect()) - } - serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)), - scalar => scalar.clone(), - } -} - -/// Redact secret-bearing values from arbitrary text so it is safe to put in a -/// setup report, log line, error message, or test snapshot. -/// -/// Two passes, both dependency-free: -/// -/// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped -/// like `key = value`, `key: value`, or `key=value` whose key -/// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS` -/// credential identifier have their value replaced with [`REDACTED`]. The -/// spaced form (`key = value`) is matched anywhere on the line, not only -/// when the sensitive key owns the line's first separator — an `anyhow` -/// chain rendered with `{:#}` puts prose and its own `: ` separators in -/// front of the assignment, and that must not be a hole. Because such a -/// value can span several words (`authorization = Bearer `), -/// everything from the value to the end of the line is dropped, exactly as -/// the whole-line form already does. Token *counts* in diagnostics -/// (`max tokens = 8192`) are not credentials and stay visible. -/// 2. **Bare tokens.** Whitespace-delimited words beginning with a known -/// `SECRET_TOKEN_PREFIXES` are replaced wholesale. -/// -/// The goal is defense in depth: setup state and reports are built from safe -/// summaries that never include secrets in the first place, and this is the -/// backstop for anything that echoes raw config text. -#[must_use] -pub fn redact_secrets(input: &str) -> String { - redact_secrets_with(input, RedactionPolicy::KeyBased) -} - -/// How aggressively [`redact_secrets_with`] treats a sensitive-looking key. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RedactionPolicy { - /// Mask the value of every sensitive-looking key, whatever the value is. - /// Right for logs, previews, exports, and diagnostics: a false positive - /// costs nothing there and a miss leaks a credential. - KeyBased, - /// Mask a keyed value only when the value itself looks like a credential - /// (known prefix, JWT, bearer token, PEM block, long opaque string). - /// Right for text the model must be able to quote back byte-for-byte, - /// such as tool results that feed exact-match edits: `password: - /// credentials?.password`, `"password-validator": "^5.3.0"`, or - /// `token = make_token()` are code, not secrets (#5546). - CredentialShaped, -} - -/// Redact model-bound tool output: exact configured credential values are the -/// caller's job; this masks only values that look like credentials so the -/// model keeps seeing the real bytes of ordinary code and config. -#[must_use] -pub fn redact_model_bound_secrets(input: &str) -> String { - redact_secrets_with(input, RedactionPolicy::CredentialShaped) -} - -/// [`redact_secrets`] with an explicit [`RedactionPolicy`]. -#[must_use] -pub fn redact_secrets_with(input: &str, policy: RedactionPolicy) -> String { - let mut out = String::with_capacity(input.len()); - let mut in_private_key_block = false; - for line in input.split_inclusive('\n') { - // split_inclusive keeps the newline on the previous chunk, so we do - // not need to re-add separators here. - let body = line.strip_suffix('\n').unwrap_or(line); - let trimmed = body.trim(); - if in_private_key_block { - if trimmed.starts_with("-----END") { - in_private_key_block = false; - out.push_str(line); - } else { - out.push_str(REDACTED); - if line.ends_with('\n') { - out.push('\n'); - } - } - continue; - } - if is_private_key_block_start(trimmed) { - in_private_key_block = true; - out.push_str(line); - continue; - } - out.push_str(&redact_line(line, policy)); - } - out -} - -fn is_private_key_block_start(trimmed: &str) -> bool { - trimmed.starts_with("-----BEGIN") && trimmed.contains("PRIVATE KEY") -} - -/// Redact a single line (which may include a trailing newline). -fn redact_line(line: &str, policy: RedactionPolicy) -> String { - // Preserve any trailing newline so callers keep their line structure. - let (body, newline) = match line.strip_suffix('\n') { - Some(rest) => (rest, "\n"), - None => (line, ""), - }; - - if let Some(redacted) = redact_keyed_assignment(body, policy) { - return format!("{redacted}{newline}"); - } - - // Inline-assignment / bare-token pass: mask any whitespace-delimited word - // carrying a sensitive keyed value or a known bare secret prefix, plus the - // spaced `key = value` form that `redact_keyed_assignment` above only sees - // when the sensitive key owns the line's first separator. - let mut changed = false; - let mut spaced = SpacedAssignment::None; - let mut masked: Vec = Vec::new(); - for word in body.split(' ') { - let trimmed = trim_word_punctuation(word); - if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() { - match policy { - RedactionPolicy::KeyBased => { - // The value may run to the end of the line, so drop the - // remainder rather than masking one word and leaking the - // rest. - masked.push(REDACTED.to_string()); - changed = true; - break; - } - RedactionPolicy::CredentialShaped => { - // Only a credential-shaped value is hidden, and only that - // word: the rest of the line stays quotable. An auth scheme - // word (`Bearer`) keeps the assignment open for its token. - if is_auth_scheme_word(trimmed) { - masked.push(word.to_string()); - continue; - } - if value_looks_like_credential(trimmed) { - masked.push(word.replace(trimmed, REDACTED)); - changed = true; - } else { - masked.push(word.to_string()); - } - spaced = SpacedAssignment::None; - continue; - } - } - } - if let Some(redacted) = redact_inline_keyed_assignment(trimmed, policy) { - changed = true; - masked.push(word.replace(trimmed, &redacted)); - spaced = SpacedAssignment::None; - } else if !trimmed.is_empty() && looks_like_secret_token(trimmed) { - changed = true; - masked.push(word.replace(trimmed, REDACTED)); - spaced = SpacedAssignment::None; - } else { - masked.push(word.to_string()); - spaced = spaced.advance(trimmed); - } - } - - if changed { - format!("{}{newline}", masked.join(" ")) - } else { - format!("{body}{newline}") - } -} - -/// Progress through a `key value` assignment as the -/// word-level pass walks a line. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SpacedAssignment { - None, - /// The previous word was a bare sensitive key awaiting its separator. - SensitiveKey, - /// A sensitive key and its separator are both behind us. - AwaitingValue, -} - -impl SpacedAssignment { - fn advance(self, trimmed: &str) -> Self { - // Runs of spaces produce empty words; they neither start nor cancel an - // assignment. - if trimmed.is_empty() { - return self; - } - if matches!(trimmed, "=" | ":") { - return if self == Self::SensitiveKey { - Self::AwaitingValue - } else { - Self::None - }; - } - // `api_key=` / `api_key:` with the value in the next word. A word whose - // separator is *not* final was already offered to - // `redact_inline_keyed_assignment`, so it is not an assignment we own. - if let Some(key) = trimmed - .strip_suffix('=') - .or_else(|| trimmed.strip_suffix(':')) - { - return if key_is_sensitive(key) { - Self::AwaitingValue - } else { - Self::None - }; - } - if key_is_sensitive(trimmed) { - return Self::SensitiveKey; - } - Self::None - } -} - -fn trim_word_punctuation(word: &str) -> &str { - word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';')) -} - -/// Whether `raw`, normalized the way a config/env/JSON key is, matches a -/// [`SENSITIVE_KEY_HINTS`] credential identifier. -fn key_is_sensitive(raw: &str) -> bool { - let key_norm = normalize_sensitive_key(raw); - !key_norm.is_empty() - && SENSITIVE_KEY_HINTS - .iter() - .any(|hint| key_matches_sensitive_hint(&key_norm, hint)) -} - -/// Normalize the identifier boundaries commonly used by config, env, and JSON -/// keys without turning English plurals such as `tokens` into `token`. -/// -/// Punctuation and case transitions become `_`, so `oauth.token`, -/// `accessToken`, and `APIKey` share the same matching surface as -/// `oauth_token`, `access_token`, and `api_key`. -fn normalize_sensitive_key(raw: &str) -> String { - let mut normalized = String::with_capacity(raw.len()); - let mut chars = raw.chars().peekable(); - let mut previous = None; - - while let Some(ch) = chars.next() { - if ch.is_ascii_alphanumeric() { - let next = chars.peek().copied(); - let starts_case_segment = ch.is_ascii_uppercase() - && previous.is_some_and(|previous: char| { - previous.is_ascii_lowercase() - || previous.is_ascii_digit() - || (previous.is_ascii_uppercase() - && next.is_some_and(|next| next.is_ascii_lowercase())) - }); - if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') { - normalized.push('_'); - } - normalized.push(ch.to_ascii_lowercase()); - } else if !normalized.is_empty() && !normalized.ends_with('_') { - normalized.push('_'); - } - previous = Some(ch); - } - - while normalized.ends_with('_') { - normalized.pop(); - } - normalized -} - -fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool { - if key_norm == hint { - return true; - } - // Compound hints already name a credential (`api_key`, `client_secret`). - // Substring is the right match: `openai_api_key` contains `api_key`. - if hint.contains('_') || hint.contains('-') { - return key_norm.contains(hint); - } - if hint == "token" { - // Camel-case normalization turns both credentials (`accessToken`) and - // ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented - // identifiers. A credential token is either the whole key, a suffix - // such as `access_token`, or an explicitly value-bearing `token_*` - // field. Metrics must stay visible in diagnostics and tool previews. - let is_metric_suffix = |suffix: &str| { - matches!( - suffix.split('_').next(), - Some( - "budget" - | "budgets" - | "count" - | "counts" - | "limit" - | "limits" - | "total" - | "totals" - | "usage" - | "used" - | "window" - | "windows" - ) - ) - }; - if key_norm.ends_with("_token") { - return true; - } - if let Some(suffix) = key_norm.strip_prefix("token_") { - return !is_metric_suffix(suffix); - } - if let Some((_, suffix)) = key_norm.rsplit_once("_token_") { - return !is_metric_suffix(suffix); - } - return false; - } - // Single-word hints must be a whole identifier segment so `token` - // redacts `token` / `api_token` and not English `tokens`. - key_norm.split(['_', '-']).any(|segment| segment == hint) -} - -fn redact_inline_keyed_assignment(word: &str, policy: RedactionPolicy) -> Option { - let sep_idx = word.find(['=', ':'])?; - let (raw_key, rest) = word.split_at(sep_idx); - let raw_value = &rest[1..]; - if raw_value.is_empty() { - return None; - } - if !key_is_sensitive(raw_key) { - return None; - } - match policy { - RedactionPolicy::KeyBased => Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED)), - RedactionPolicy::CredentialShaped => { - let (core, quote) = strip_value_quotes(raw_value); - if !value_looks_like_credential(core) { - return None; - } - Some(format!("{}{}{quote}{REDACTED}{quote}", raw_key, &rest[..1])) - } - } -} - -/// Whether a word announces an HTTP auth scheme whose credential follows. -fn is_auth_scheme_word(word: &str) -> bool { - matches!( - word, - "Bearer" | "bearer" | "Basic" | "basic" | "Token" | "token" - ) -} - -/// Split a matching pair of surrounding quotes off a value, returning the -/// inner text and the quote to restore (empty when unquoted or unbalanced). -fn strip_value_quotes(value: &str) -> (&str, &str) { - for quote in ['"', '\''] { - if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) { - return (&value[1..value.len() - 1], &value[..1]); - } - } - // A leading quote without its partner (the word pass strips the outer - // punctuation of `"x",` to `"x`): treat the remainder as the value. - if let Some(inner) = value.strip_prefix(['"', '\'']) { - return (inner, ""); - } - (value, "") -} - -/// Extra bare prefixes that mark a value as a credential even though they are -/// too product-specific to mask as standalone words in prose. -const CREDENTIAL_VALUE_PREFIXES: &[&str] = &[ - "sk-ant-", - "AKIA", - "ASIA", - "AIza", - "ghp_", - "gho_", - "ghu_", - "ghs_", - "ghr_", - "github_pat_", - "glpat-", - "xoxa-", - "xoxb-", - "xoxp-", - "xoxr-", - "xoxs-", - "npm_", - "ya29.", -]; - -/// Whether a keyed value looks like credential material rather than code, -/// configuration, or prose. -/// -/// True for known provider prefixes, JWTs, `Bearer`/`Basic` tokens, PEM -/// headers, and long opaque alphanumeric runs. False for short literals, -/// version strings, identifiers, property/call/env references, and the -/// redaction placeholder itself. -pub(crate) fn value_looks_like_credential(value: &str) -> bool { - let value = value - .trim() - .trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';')); - if value.is_empty() || value == REDACTED { - return false; - } - if looks_like_secret_token(value) - || CREDENTIAL_VALUE_PREFIXES - .iter() - .any(|prefix| value.len() > prefix.len() + 6 && value.starts_with(prefix)) - { - return true; - } - if value.starts_with("-----BEGIN") { - return true; - } - if let Some((scheme, rest)) = value.split_once(' ') - && is_auth_scheme_word(scheme) - { - return value_looks_like_credential(rest); - } - if is_jwt_shaped(value) { - return true; - } - if value.len() < 16 { - return false; - } - if is_version_like(value) || is_reference_like(value) { - return false; - } - is_opaque_run(value) -} - -fn is_jwt_shaped(value: &str) -> bool { - let mut parts = value.split('.'); - match (parts.next(), parts.next(), parts.next(), parts.next()) { - (Some(header), Some(payload), Some(signature), None) => { - header.starts_with("eyJ") - && payload.starts_with("eyJ") - && !signature.is_empty() - && [header, payload, signature].iter().all(|part| { - part.chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') - }) - } - _ => false, - } -} - -fn is_version_like(value: &str) -> bool { - let digits = value.trim_start_matches(['^', '~', '>', '<', '=', 'v', 'V', ' ']); - !digits.is_empty() - && digits - .chars() - .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+') - && digits.chars().next().is_some_and(|c| c.is_ascii_digit()) -} - -fn is_reference_like(value: &str) -> bool { - // Property access, calls, template/env lookups, and plain identifiers are - // code, not credential material. - value.contains("?.") - || value.contains('(') - || value.contains("${") - || value.contains("process.env") - || value.contains("os.environ") - || value.contains("getenv") - || value.contains("://") - || value - .chars() - .all(|c| c.is_ascii_alphabetic() || c == '_' || c == '.') -} - -fn is_opaque_run(value: &str) -> bool { - value.len() >= 20 - && value - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.')) - && value.chars().any(|c| c.is_ascii_alphabetic()) - && value.chars().any(|c| c.is_ascii_digit()) -} - -/// If `body` is a `key value` assignment with a sensitive key, return the -/// line with the value redacted; otherwise `None`. -fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option { - // Find the first `=` or `:` that separates a key from a value. - let sep_idx = body.find(['=', ':'])?; - let (raw_key, rest) = body.split_at(sep_idx); - let sep = &rest[..1]; - let raw_value = &rest[1..]; - - let key_norm = raw_key - .trim() - .trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']')); - if !key_is_sensitive(key_norm) { - return None; - } - - if policy == RedactionPolicy::CredentialShaped { - // Replace only the value span, keep the key bytes, separator spacing, - // quote style, and trailing punctuation, and only when the value is - // credential-shaped: the model must still be able to quote the line. - let value_lead_ws: String = raw_value - .chars() - .take_while(|c| c.is_whitespace()) - .collect(); - let value_rest = raw_value.trim_start(); - let value_core = value_rest.trim_end(); - let trailing_ws = &value_rest[value_core.len()..]; - let literal = value_core.trim_end_matches([',', ';']); - let trailer = &value_core[literal.len()..]; - let (core, quote) = strip_value_quotes(literal); - if core.is_empty() || !value_looks_like_credential(core) { - return None; - } - return Some(format!( - "{raw_key}{sep}{value_lead_ws}{quote}{REDACTED}{quote}{trailer}{trailing_ws}" - )); - } - - // Keep leading whitespace of the key and the original separator spacing so - // the redacted line reads naturally. - let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect(); - let value_lead_ws: String = raw_value - .chars() - .take_while(|c| c.is_whitespace()) - .collect(); - let value_rest = raw_value.trim_start(); - // If the value is empty, there is nothing to hide. - if value_rest.is_empty() { - return None; - } - // Preserve surrounding quotes so structured files stay parseable-looking. - let quoted = value_rest.starts_with('"') || value_rest.starts_with('\''); - let replacement = if quoted { - format!("\"{REDACTED}\"") - } else { - REDACTED.to_string() - }; - Some(format!( - "{key_lead_ws}{}{sep}{value_lead_ws}{replacement}", - raw_key.trim() - )) -} - -fn looks_like_secret_token(word: &str) -> bool { - SECRET_TOKEN_PREFIXES - .iter() - .any(|p| word.len() > p.len() + 6 && word.starts_with(p)) -} +// FEAT-025 D4: the pure secret-redaction primitives moved to +// `codewhale-secrets::redact` so portable command helpers can share one +// implementation without depending on this crate. Re-exported here to keep +// the existing `codewhale_config::persistence::*` public API stable. +pub use codewhale_secrets::redact::{ + REDACTED, RedactionPolicy, redact_json_secrets, redact_model_bound_secrets, redact_secrets, + redact_secrets_with, +}; #[cfg(test)] mod tests { diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml index 7a2d0fcdb0..b590003384 100644 --- a/crates/secrets/Cargo.toml +++ b/crates/secrets/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true rust-version.workspace = true license.workspace = true repository.workspace = true -description = "Secret storage backends for Codewhale, with OS keyring and file fallback" +description = "Secret storage backends for Codewhale, with OS keyring and file fallback, plus the shared pure secret-redaction and output-sanitization primitives" [lints] workspace = true @@ -13,12 +13,18 @@ workspace = true [dependencies] codewhale-paths = { path = "../paths", version = "0.9.13" } chrono.workspace = true +# FEAT-025 D4: the portable output sanitizer owns the regex-based redaction +# passes here so config diagnostics and command helpers share one copy. +regex = "1.11" serde = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } sha2.workspace = true tempfile.workspace = true thiserror = { workspace = true } tracing = { workspace = true } +# FEAT-025 D4: URL credential redaction uses the same `url` parser reqwest +# re-exports, with no HTTP client dependency in this pure crate. +url = "2.5" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index b973ba30a4..a1b6c92539 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -1,6 +1,7 @@ -//! Secret storage for CodeWhale API keys. +//! Secret storage for CodeWhale API keys, plus the shared output-sanitization +//! primitives that keep secrets out of diagnostics and command output. //! -//! Provides a small abstraction (`KeyringStore`) plus a default +//! Secret storage: provides a small abstraction (`KeyringStore`) plus a default //! file-based implementation (`FileKeyringStore`), an opt-in OS keyring //! implementation (`DefaultKeyringStore`), and an in-memory store for tests //! (`InMemoryKeyringStore`). @@ -9,10 +10,31 @@ //! and falls back to environment variables. Config-file precedence lives in the //! config crate so user-facing commands can keep `config -> secret store -> env` //! explicit at the call site. +//! +//! Sanitization: [`redact`] and [`sanitize`] are pure and carry no host types. +//! They live here (FEAT-025 D4) because this is the lowest crate that both the +//! config diagnostics path and the TUI already reach, so `/export`, +//! `/structcopy`, client URL masking, and OSC8 stripping share exactly one +//! implementation instead of drifting copies. `config::persistence` and +//! `tui::client` / `tui::osc8` re-export or delegate to these functions. +//! +//! Note for the command extraction (EPIC-006): this crate is already reachable +//! from `codewhale-command-contract` transitively via +//! `core -> config -> secrets`, so consuming the sanitizer from the future +//! `codewhale-commands` crate adds no new dependency edge. It does mean the +//! sanitizer inherits this crate's OS keyring dependencies; if the surface grows +//! beyond redaction, split a dedicated `codewhale-sanitize` crate rather than +//! widening this one. #![deny(missing_docs)] /// Shared secure-storage contract for the Codewhale account session. pub mod account; +/// Pure secret-redaction primitives shared by config diagnostics and the +/// portable command sanitizer (FEAT-025 D4). +pub mod redact; +/// Pure text/URL/ANSI output sanitization shared by the portable command +/// helpers (FEAT-025 D4). +pub mod sanitize; use std::collections::HashMap; use std::fs; diff --git a/crates/secrets/src/redact.rs b/crates/secrets/src/redact.rs new file mode 100644 index 0000000000..db361dd8ef --- /dev/null +++ b/crates/secrets/src/redact.rs @@ -0,0 +1,599 @@ +//! Pure secret-redaction primitives (FEAT-025 D4). +//! +//! Relocated verbatim from `codewhale-config::persistence` so the portable +//! command sanitizer and the config diagnostic path share exactly one +//! implementation. The algorithm, ordering, sensitive-key vocabulary, and +//! byte-for-byte results are unchanged; `codewhale-config::persistence` +//! re-exports these items to keep its public API stable. + +/// Hints that mark a config/JSON/env key as carrying a secret value. +/// +/// Compound hints (`api_key`, `client_secret`) match as a substring of the +/// normalized key. Single-word hints (`token`, `secret`, `password`) match a +/// whole identifier segment so they describe a credential (`token`, +/// `api_token`) and not an English word (`tokens`, `tokenizer`). +const SENSITIVE_KEY_HINTS: &[&str] = &[ + "api_key", + "apikey", + "api-key", + "secret", + "token", + "password", + "passwd", + "authorization", + "auth_token", + "access_key", + "client_secret", + "private_key", +]; + +/// Known opaque-token prefixes worth masking even when they appear bare (not as +/// `key = value`). Conservative on purpose: only well-known provider/key shapes. +const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"]; + +/// The placeholder substituted for any redacted secret value. +pub const REDACTED: &str = "[redacted]"; + +/// Return a copy of a JSON value with secret-bearing data removed. +/// +/// Object values whose key contains a sensitive hint are replaced wholesale, +/// while all other objects and arrays are traversed recursively. String leaves +/// still pass through [`redact_secrets`] so bare provider tokens and embedded +/// assignments remain covered without treating the serialized JSON document as +/// one flat keyed assignment. +#[must_use] +pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(object) => serde_json::Value::Object( + object + .iter() + .map(|(key, value)| { + let value = if key_is_sensitive(key) { + serde_json::Value::String(REDACTED.to_string()) + } else { + redact_json_secrets(value) + }; + (key.clone(), value) + }) + .collect(), + ), + serde_json::Value::Array(items) => { + serde_json::Value::Array(items.iter().map(redact_json_secrets).collect()) + } + serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(text)), + scalar => scalar.clone(), + } +} + +/// Redact secret-bearing values from arbitrary text so it is safe to put in a +/// setup report, log line, error message, or test snapshot. +/// +/// Two passes, both dependency-free: +/// +/// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped +/// like `key = value`, `key: value`, or `key=value` whose key +/// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS` +/// credential identifier have their value replaced with [`REDACTED`]. The +/// spaced form (`key = value`) is matched anywhere on the line, not only +/// when the sensitive key owns the line's first separator — an `anyhow` +/// chain rendered with `{:#}` puts prose and its own `: ` separators in +/// front of the assignment, and that must not be a hole. Because such a +/// value can span several words (`authorization = Bearer `), +/// everything from the value to the end of the line is dropped, exactly as +/// the whole-line form already does. Token *counts* in diagnostics +/// (`max tokens = 8192`) are not credentials and stay visible. +/// 2. **Bare tokens.** Whitespace-delimited words beginning with a known +/// `SECRET_TOKEN_PREFIXES` are replaced wholesale. +/// +/// The goal is defense in depth: setup state and reports are built from safe +/// summaries that never include secrets in the first place, and this is the +/// backstop for anything that echoes raw config text. +#[must_use] +pub fn redact_secrets(input: &str) -> String { + redact_secrets_with(input, RedactionPolicy::KeyBased) +} + +/// How aggressively [`redact_secrets_with`] treats a sensitive-looking key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RedactionPolicy { + /// Mask the value of every sensitive-looking key, whatever the value is. + /// Right for logs, previews, exports, and diagnostics: a false positive + /// costs nothing there and a miss leaks a credential. + KeyBased, + /// Mask a keyed value only when the value itself looks like a credential + /// (known prefix, JWT, bearer token, PEM block, long opaque string). + /// Right for text the model must be able to quote back byte-for-byte, + /// such as tool results that feed exact-match edits: `password: + /// credentials?.password`, `"password-validator": "^5.3.0"`, or + /// `token = make_token()` are code, not secrets (#5546). + CredentialShaped, +} + +/// Redact model-bound tool output: exact configured credential values are the +/// caller's job; this masks only values that look like credentials so the +/// model keeps seeing the real bytes of ordinary code and config. +#[must_use] +pub fn redact_model_bound_secrets(input: &str) -> String { + redact_secrets_with(input, RedactionPolicy::CredentialShaped) +} + +/// [`redact_secrets`] with an explicit [`RedactionPolicy`]. +#[must_use] +pub fn redact_secrets_with(input: &str, policy: RedactionPolicy) -> String { + let mut out = String::with_capacity(input.len()); + let mut in_private_key_block = false; + for line in input.split_inclusive('\n') { + // split_inclusive keeps the newline on the previous chunk, so we do + // not need to re-add separators here. + let body = line.strip_suffix('\n').unwrap_or(line); + let trimmed = body.trim(); + if in_private_key_block { + if trimmed.starts_with("-----END") { + in_private_key_block = false; + out.push_str(line); + } else { + out.push_str(REDACTED); + if line.ends_with('\n') { + out.push('\n'); + } + } + continue; + } + if is_private_key_block_start(trimmed) { + in_private_key_block = true; + out.push_str(line); + continue; + } + out.push_str(&redact_line(line, policy)); + } + out +} + +fn is_private_key_block_start(trimmed: &str) -> bool { + trimmed.starts_with("-----BEGIN") && trimmed.contains("PRIVATE KEY") +} + +/// Redact a single line (which may include a trailing newline). +fn redact_line(line: &str, policy: RedactionPolicy) -> String { + // Preserve any trailing newline so callers keep their line structure. + let (body, newline) = match line.strip_suffix('\n') { + Some(rest) => (rest, "\n"), + None => (line, ""), + }; + + if let Some(redacted) = redact_keyed_assignment(body, policy) { + return format!("{redacted}{newline}"); + } + + // Inline-assignment / bare-token pass: mask any whitespace-delimited word + // carrying a sensitive keyed value or a known bare secret prefix, plus the + // spaced `key = value` form that `redact_keyed_assignment` above only sees + // when the sensitive key owns the line's first separator. + let mut changed = false; + let mut spaced = SpacedAssignment::None; + let mut masked: Vec = Vec::new(); + for word in body.split(' ') { + let trimmed = trim_word_punctuation(word); + if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() { + match policy { + RedactionPolicy::KeyBased => { + // The value may run to the end of the line, so drop the + // remainder rather than masking one word and leaking the + // rest. + masked.push(REDACTED.to_string()); + changed = true; + break; + } + RedactionPolicy::CredentialShaped => { + // Only a credential-shaped value is hidden, and only that + // word: the rest of the line stays quotable. An auth scheme + // word (`Bearer`) keeps the assignment open for its token. + if is_auth_scheme_word(trimmed) { + masked.push(word.to_string()); + continue; + } + if value_looks_like_credential(trimmed) { + masked.push(word.replace(trimmed, REDACTED)); + changed = true; + } else { + masked.push(word.to_string()); + } + spaced = SpacedAssignment::None; + continue; + } + } + } + if let Some(redacted) = redact_inline_keyed_assignment(trimmed, policy) { + changed = true; + masked.push(word.replace(trimmed, &redacted)); + spaced = SpacedAssignment::None; + } else if !trimmed.is_empty() && looks_like_secret_token(trimmed) { + changed = true; + masked.push(word.replace(trimmed, REDACTED)); + spaced = SpacedAssignment::None; + } else { + masked.push(word.to_string()); + spaced = spaced.advance(trimmed); + } + } + + if changed { + format!("{}{newline}", masked.join(" ")) + } else { + format!("{body}{newline}") + } +} + +/// Progress through a `key value` assignment as the +/// word-level pass walks a line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SpacedAssignment { + None, + /// The previous word was a bare sensitive key awaiting its separator. + SensitiveKey, + /// A sensitive key and its separator are both behind us. + AwaitingValue, +} + +impl SpacedAssignment { + fn advance(self, trimmed: &str) -> Self { + // Runs of spaces produce empty words; they neither start nor cancel an + // assignment. + if trimmed.is_empty() { + return self; + } + if matches!(trimmed, "=" | ":") { + return if self == Self::SensitiveKey { + Self::AwaitingValue + } else { + Self::None + }; + } + // `api_key=` / `api_key:` with the value in the next word. A word whose + // separator is *not* final was already offered to + // `redact_inline_keyed_assignment`, so it is not an assignment we own. + if let Some(key) = trimmed + .strip_suffix('=') + .or_else(|| trimmed.strip_suffix(':')) + { + return if key_is_sensitive(key) { + Self::AwaitingValue + } else { + Self::None + }; + } + if key_is_sensitive(trimmed) { + return Self::SensitiveKey; + } + Self::None + } +} + +fn trim_word_punctuation(word: &str) -> &str { + word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';')) +} + +/// Whether `raw`, normalized the way a config/env/JSON key is, matches a +/// [`SENSITIVE_KEY_HINTS`] credential identifier. +fn key_is_sensitive(raw: &str) -> bool { + let key_norm = normalize_sensitive_key(raw); + !key_norm.is_empty() + && SENSITIVE_KEY_HINTS + .iter() + .any(|hint| key_matches_sensitive_hint(&key_norm, hint)) +} + +/// Normalize the identifier boundaries commonly used by config, env, and JSON +/// keys without turning English plurals such as `tokens` into `token`. +/// +/// Punctuation and case transitions become `_`, so `oauth.token`, +/// `accessToken`, and `APIKey` share the same matching surface as +/// `oauth_token`, `access_token`, and `api_key`. +fn normalize_sensitive_key(raw: &str) -> String { + let mut normalized = String::with_capacity(raw.len()); + let mut chars = raw.chars().peekable(); + let mut previous = None; + + while let Some(ch) = chars.next() { + if ch.is_ascii_alphanumeric() { + let next = chars.peek().copied(); + let starts_case_segment = ch.is_ascii_uppercase() + && previous.is_some_and(|previous: char| { + previous.is_ascii_lowercase() + || previous.is_ascii_digit() + || (previous.is_ascii_uppercase() + && next.is_some_and(|next| next.is_ascii_lowercase())) + }); + if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') { + normalized.push('_'); + } + normalized.push(ch.to_ascii_lowercase()); + } else if !normalized.is_empty() && !normalized.ends_with('_') { + normalized.push('_'); + } + previous = Some(ch); + } + + while normalized.ends_with('_') { + normalized.pop(); + } + normalized +} + +fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool { + if key_norm == hint { + return true; + } + // Compound hints already name a credential (`api_key`, `client_secret`). + // Substring is the right match: `openai_api_key` contains `api_key`. + if hint.contains('_') || hint.contains('-') { + return key_norm.contains(hint); + } + if hint == "token" { + // Camel-case normalization turns both credentials (`accessToken`) and + // ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented + // identifiers. A credential token is either the whole key, a suffix + // such as `access_token`, or an explicitly value-bearing `token_*` + // field. Metrics must stay visible in diagnostics and tool previews. + let is_metric_suffix = |suffix: &str| { + matches!( + suffix.split('_').next(), + Some( + "budget" + | "budgets" + | "count" + | "counts" + | "limit" + | "limits" + | "total" + | "totals" + | "usage" + | "used" + | "window" + | "windows" + ) + ) + }; + if key_norm.ends_with("_token") { + return true; + } + if let Some(suffix) = key_norm.strip_prefix("token_") { + return !is_metric_suffix(suffix); + } + if let Some((_, suffix)) = key_norm.rsplit_once("_token_") { + return !is_metric_suffix(suffix); + } + return false; + } + // Single-word hints must be a whole identifier segment so `token` + // redacts `token` / `api_token` and not English `tokens`. + key_norm.split(['_', '-']).any(|segment| segment == hint) +} + +fn redact_inline_keyed_assignment(word: &str, policy: RedactionPolicy) -> Option { + let sep_idx = word.find(['=', ':'])?; + let (raw_key, rest) = word.split_at(sep_idx); + let raw_value = &rest[1..]; + if raw_value.is_empty() { + return None; + } + if !key_is_sensitive(raw_key) { + return None; + } + match policy { + RedactionPolicy::KeyBased => Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED)), + RedactionPolicy::CredentialShaped => { + let (core, quote) = strip_value_quotes(raw_value); + if !value_looks_like_credential(core) { + return None; + } + Some(format!("{}{}{quote}{REDACTED}{quote}", raw_key, &rest[..1])) + } + } +} + +/// Whether a word announces an HTTP auth scheme whose credential follows. +fn is_auth_scheme_word(word: &str) -> bool { + matches!( + word, + "Bearer" | "bearer" | "Basic" | "basic" | "Token" | "token" + ) +} + +/// Split a matching pair of surrounding quotes off a value, returning the +/// inner text and the quote to restore (empty when unquoted or unbalanced). +fn strip_value_quotes(value: &str) -> (&str, &str) { + for quote in ['"', '\''] { + if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) { + return (&value[1..value.len() - 1], &value[..1]); + } + } + // A leading quote without its partner (the word pass strips the outer + // punctuation of `"x",` to `"x`): treat the remainder as the value. + if let Some(inner) = value.strip_prefix(['"', '\'']) { + return (inner, ""); + } + (value, "") +} + +/// Extra bare prefixes that mark a value as a credential even though they are +/// too product-specific to mask as standalone words in prose. +const CREDENTIAL_VALUE_PREFIXES: &[&str] = &[ + "sk-ant-", + "AKIA", + "ASIA", + "AIza", + "ghp_", + "gho_", + "ghu_", + "ghs_", + "ghr_", + "github_pat_", + "glpat-", + "xoxa-", + "xoxb-", + "xoxp-", + "xoxr-", + "xoxs-", + "npm_", + "ya29.", +]; + +/// Whether a keyed value looks like credential material rather than code, +/// configuration, or prose. +/// +/// True for known provider prefixes, JWTs, `Bearer`/`Basic` tokens, PEM +/// headers, and long opaque alphanumeric runs. False for short literals, +/// version strings, identifiers, property/call/env references, and the +/// redaction placeholder itself. +pub(crate) fn value_looks_like_credential(value: &str) -> bool { + let value = value + .trim() + .trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';')); + if value.is_empty() || value == REDACTED { + return false; + } + if looks_like_secret_token(value) + || CREDENTIAL_VALUE_PREFIXES + .iter() + .any(|prefix| value.len() > prefix.len() + 6 && value.starts_with(prefix)) + { + return true; + } + if value.starts_with("-----BEGIN") { + return true; + } + if let Some((scheme, rest)) = value.split_once(' ') + && is_auth_scheme_word(scheme) + { + return value_looks_like_credential(rest); + } + if is_jwt_shaped(value) { + return true; + } + if value.len() < 16 { + return false; + } + if is_version_like(value) || is_reference_like(value) { + return false; + } + is_opaque_run(value) +} + +fn is_jwt_shaped(value: &str) -> bool { + let mut parts = value.split('.'); + match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(header), Some(payload), Some(signature), None) => { + header.starts_with("eyJ") + && payload.starts_with("eyJ") + && !signature.is_empty() + && [header, payload, signature].iter().all(|part| { + part.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }) + } + _ => false, + } +} + +fn is_version_like(value: &str) -> bool { + let digits = value.trim_start_matches(['^', '~', '>', '<', '=', 'v', 'V', ' ']); + !digits.is_empty() + && digits + .chars() + .all(|c| c.is_ascii_digit() || c == '.' || c == '-' || c == '+') + && digits.chars().next().is_some_and(|c| c.is_ascii_digit()) +} + +fn is_reference_like(value: &str) -> bool { + // Property access, calls, template/env lookups, and plain identifiers are + // code, not credential material. + value.contains("?.") + || value.contains('(') + || value.contains("${") + || value.contains("process.env") + || value.contains("os.environ") + || value.contains("getenv") + || value.contains("://") + || value + .chars() + .all(|c| c.is_ascii_alphabetic() || c == '_' || c == '.') +} + +fn is_opaque_run(value: &str) -> bool { + value.len() >= 20 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '=' | '_' | '-' | '.')) + && value.chars().any(|c| c.is_ascii_alphabetic()) + && value.chars().any(|c| c.is_ascii_digit()) +} + +/// If `body` is a `key value` assignment with a sensitive key, return the +/// line with the value redacted; otherwise `None`. +fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option { + // Find the first `=` or `:` that separates a key from a value. + let sep_idx = body.find(['=', ':'])?; + let (raw_key, rest) = body.split_at(sep_idx); + let sep = &rest[..1]; + let raw_value = &rest[1..]; + + let key_norm = raw_key + .trim() + .trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']')); + if !key_is_sensitive(key_norm) { + return None; + } + + if policy == RedactionPolicy::CredentialShaped { + // Replace only the value span, keep the key bytes, separator spacing, + // quote style, and trailing punctuation, and only when the value is + // credential-shaped: the model must still be able to quote the line. + let value_lead_ws: String = raw_value + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + let value_rest = raw_value.trim_start(); + let value_core = value_rest.trim_end(); + let trailing_ws = &value_rest[value_core.len()..]; + let literal = value_core.trim_end_matches([',', ';']); + let trailer = &value_core[literal.len()..]; + let (core, quote) = strip_value_quotes(literal); + if core.is_empty() || !value_looks_like_credential(core) { + return None; + } + return Some(format!( + "{raw_key}{sep}{value_lead_ws}{quote}{REDACTED}{quote}{trailer}{trailing_ws}" + )); + } + + // Keep leading whitespace of the key and the original separator spacing so + // the redacted line reads naturally. + let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect(); + let value_lead_ws: String = raw_value + .chars() + .take_while(|c| c.is_whitespace()) + .collect(); + let value_rest = raw_value.trim_start(); + // If the value is empty, there is nothing to hide. + if value_rest.is_empty() { + return None; + } + // Preserve surrounding quotes so structured files stay parseable-looking. + let quoted = value_rest.starts_with('"') || value_rest.starts_with('\''); + let replacement = if quoted { + format!("\"{REDACTED}\"") + } else { + REDACTED.to_string() + }; + Some(format!( + "{key_lead_ws}{}{sep}{value_lead_ws}{replacement}", + raw_key.trim() + )) +} + +fn looks_like_secret_token(word: &str) -> bool { + SECRET_TOKEN_PREFIXES + .iter() + .any(|p| word.len() > p.len() + 6 && word.starts_with(p)) +} diff --git a/crates/secrets/src/sanitize.rs b/crates/secrets/src/sanitize.rs new file mode 100644 index 0000000000..abe140511e --- /dev/null +++ b/crates/secrets/src/sanitize.rs @@ -0,0 +1,365 @@ +//! Pure output-sanitization primitives shared by portable command helpers +//! (FEAT-025 D4). +//! +//! These helpers previously lived in the TUI command, client, and OSC8 +//! modules. Relocating the pure algorithms here gives `/export` and +//! `/structcopy` exactly one implementation with no TUI, client, or +//! configuration dependency. TUI callers delegate back to these functions so +//! behavior cannot drift. + +use std::sync::OnceLock; + +use regex::Regex; +use serde_json::Value; + +use crate::redact::redact_secrets; + +/// Strip ANSI/OSC/control sequences from `s` into `out`. +/// +/// Handles CSI (`ESC [ … final`), OSC (`ESC ] … BEL` or `ESC \`), DCS, SOS, +/// PM, APC, and standalone two-byte ESC sequences. OSC 8 hyperlink wrappers +/// (`ESC ] 8 ; … BEL` / `ESC \`) are stripped along with the rest. +pub fn strip_ansi_into(s: &str, out: &mut String) { + strip_ansi_impl(s, out, false); +} + +/// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold, +/// underline, reset) pass through untouched so a renderer that understands +/// them can paint the output as the tool emitted it. Everything else — OSC +/// (including OSC 8 hyperlink wrappers), cursor movement, DCS, lone control +/// bytes — is still removed; only the styling survives. +pub fn strip_ansi_keep_sgr_into(s: &str, out: &mut String) { + strip_ansi_impl(s, out, true); +} + +/// Length in bytes of the UTF-8 sequence that starts with `lead`. Falls back +/// to `1` for continuation bytes / invalid leads so callers always make +/// forward progress. +pub fn utf8_seq_len(lead: u8) -> usize { + if lead < 0xc0 { + 1 + } else if lead < 0xe0 { + 2 + } else if lead < 0xf0 { + 3 + } else { + 4 + } +} + +fn strip_ansi_impl(s: &str, out: &mut String, keep_sgr: bool) { + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == 0x1b && i + 1 < bytes.len() { + let next = bytes[i + 1]; + match next { + // CSI: ESC [ ... + b'[' => { + let mut j = i + 2; + let mut final_byte = 0u8; + while j < bytes.len() { + let b = bytes[j]; + if (0x40..=0x7e).contains(&b) { + final_byte = b; + j += 1; + break; + } + j += 1; + } + if keep_sgr + && final_byte == b'm' + && let Ok(seq) = std::str::from_utf8(&bytes[i..j]) + { + out.push_str(seq); + } + i = j; + continue; + } + // OSC / DCS / SOS / PM / APC: ESC ] | P | X | ^ | _ ... ST(ESC \) or BEL + b']' | b'P' | b'X' | b'^' | b'_' => { + let mut j = i + 2; + while j < bytes.len() { + if bytes[j] == 0x07 { + j += 1; + break; + } + if bytes[j] == 0x1b && j + 1 < bytes.len() && bytes[j + 1] == b'\\' { + j += 2; + break; + } + j += 1; + } + i = j; + continue; + } + // Standalone two-byte ESC sequence (RIS, charset selection, etc.) + _ => { + i += 2; + continue; + } + } + } + // Strip lone control bytes that ratatui would otherwise drop (and which + // mean nothing in transcript output) but keep \n, \r, \t as legitimate + // formatting. + let b = bytes[i]; + if b < 0x80 { + if b < 0x20 && b != b'\n' && b != b'\r' && b != b'\t' { + i += 1; + continue; + } + out.push(b as char); + i += 1; + } else { + // UTF-8 multi-byte sequence: copy the whole code point intact. + // Pushing `b as char` would mis-decode it as Latin-1 and mangle + // non-ASCII text (CJK, accented Latin, emoji, …). + let len = utf8_seq_len(b); + let end = (i + len).min(bytes.len()); + if let Ok(chunk) = std::str::from_utf8(&bytes[i..end]) { + out.push_str(chunk); + } + i = end; + } + } +} + +/// Mask credentials in a URL so it can appear in output or a report. +/// +/// Userinfo is replaced with `***` and query values under sensitive keys are +/// masked. A URL that does not parse is returned unchanged. +pub fn redact_url_for_display(url: &str) -> String { + let Ok(mut parsed) = url::Url::parse(url) else { + return url.to_string(); + }; + if !parsed.username().is_empty() || parsed.password().is_some() { + let _ = parsed.set_username("***"); + let _ = parsed.set_password(Some("***")); + } + if parsed.query().is_none() { + return parsed.to_string(); + } + let pairs: Vec<(String, String)> = parsed + .query_pairs() + .map(|(key, value)| { + let value = if is_sensitive_url_query_key(&key) { + "***".to_string() + } else { + value.into_owned() + }; + (key.into_owned(), value) + }) + .collect(); + parsed.set_query(None); + let mut query = parsed.query_pairs_mut(); + for (key, value) in pairs { + query.append_pair(&key, &value); + } + drop(query); + parsed.to_string() +} + +fn is_sensitive_url_query_key(key: &str) -> bool { + let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase(); + matches!( + normalized.as_str(), + "api_key" + | "apikey" + | "access_token" + | "auth_token" + | "authorization" + | "bearer" + | "client_secret" + | "credential" + | "id_token" + | "password" + | "refresh_token" + | "secret" + | "token" + ) || normalized.ends_with("_api_key") + || normalized.ends_with("_authorization") + || normalized.ends_with("_password") + || normalized.ends_with("_secret") + || normalized.ends_with("_token") +} + +/// True when `role` names an internal, non-user-visible message role. +pub fn is_internal_role(role: &str) -> bool { + matches!( + role.trim().to_ascii_lowercase().as_str(), + "system" | "developer" | "internal" + ) +} + +/// True when a JSON/assignment key names a credential-bearing value. +/// +/// Classification normalizes separators and quotes so obfuscated variants are +/// still caught; the vocabulary is shared with `/structcopy`. +pub fn is_sensitive_key(key: &str) -> bool { + let normalized = key + .trim() + .trim_matches(['\'', '"']) + .replace(['-', '.', ' '], "_") + .to_ascii_lowercase(); + [ + "api_key", + "apikey", + "secret", + "token", + "password", + "passwd", + "authorization", + "access_key", + "client_secret", + "private_key", + "cookie", + "session_key", + ] + .iter() + .any(|hint| normalized.contains(hint)) +} + +/// Sanitize arbitrary text for safe export output. +/// +/// Strips ANSI/control bytes, normalizes newlines, then applies private-key, +/// bearer-token, JWT, URL-credential, and keyed-secret redaction in the exact +/// established order. +pub fn sanitize_text(input: &str) -> String { + let mut visible = String::with_capacity(input.len()); + strip_ansi_into(input, &mut visible); + let visible = visible.replace("\r\n", "\n").replace('\r', "\n"); + let visible: String = visible + .chars() + .filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control()) + .collect(); + let private_keys = private_key_regex().replace_all(&visible, "[redacted private key]"); + let bearer = bearer_regex().replace_all(&private_keys, "Bearer [redacted]"); + let jwt = jwt_regex().replace_all(&bearer, "[redacted token]"); + let urls = url_regex().replace_all(&jwt, |captures: ®ex::Captures<'_>| { + redact_url_match(captures.get(0).map_or("", |value| value.as_str())) + }); + redact_secrets(&urls) +} + +/// Recursively redact a JSON value. +/// +/// A value under a sensitive key is replaced wholesale; other strings pass +/// through [`sanitize_text`], and arrays/objects are traversed in place. +pub fn redact_json(value: &mut Value, key: Option<&str>) { + if key.is_some_and(is_sensitive_key) { + *value = Value::String("[redacted]".to_string()); + return; + } + match value { + Value::String(text) => *text = sanitize_text(text), + Value::Array(items) => { + for item in items { + redact_json(item, None); + } + } + Value::Object(map) => { + for (key, value) in map { + redact_json(value, Some(key)); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +/// Collapse whitespace and neutralize inline backticks for a single-line +/// export field. +pub fn inline_text(input: &str) -> String { + sanitize_text(input) + .split_whitespace() + .collect::>() + .join(" ") + .replace('`', "'") +} + +fn redact_url_match(raw: &str) -> String { + let trimmed = raw.trim_end_matches(['.', ',', ';', '!']); + let suffix = &raw[trimmed.len()..]; + format!("{}{}", redact_url_for_display(trimmed), suffix) +} + +fn private_key_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new( + r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----", + ) + .expect("private-key redaction regex") + }) +} + +fn bearer_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{6,}").expect("bearer redaction regex") + }) +} + +fn jwt_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r"\beyJ[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}(?:\.[a-zA-Z0-9_-]{5,})?\b") + .expect("JWT redaction regex") + }) +} + +fn url_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| { + Regex::new(r#"https?://[^\s<>\"'`\]\[\)\(\}\{]+"#).expect("URL redaction regex") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strip_ansi_removes_control_sequences_but_keeps_text() { + let mut out = String::new(); + strip_ansi_into("a\u{1b}[31mred\u{1b}[0m b", &mut out); + assert_eq!(out, "ared b"); + } + + #[test] + fn url_redaction_masks_userinfo_and_sensitive_query_values() { + assert_eq!( + redact_url_for_display( + "https://alice:password@example.com/path?token=very-secret&ok=1" + ), + "https://***:***@example.com/path?token=***&ok=1" + ); + } + + #[test] + fn sensitive_keys_normalize_separators_and_quotes() { + assert!(is_sensitive_key("API-KEY")); + assert!(is_sensitive_key("\"client secret\"")); + assert!(!is_sensitive_key("monkey")); + } + + #[test] + fn sanitize_text_redacts_private_keys_bearer_and_jwt() { + // Assemble the PEM markers and the provider-token prefix at runtime so + // this source file never contains a literal private-key header (or a + // literal token) for a secret scanner to match. The runtime strings are + // identical to the real shapes, and this mirrors the convention the + // pre-move test used in `config::persistence` - moving that test into + // this crate silently dropped it, which is what GitGuardian caught. + let begin = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); + let end = ["-----END RSA", " PRIVATE KEY-----"].concat(); + let bearer = format!("{} abcdefghijklmnop", "Bearer"); + let opaque = ["sk-", "abcdef1234567890"].concat(); + let text = format!("{begin}\nMII\n{end}\nAuthorization: {bearer}\n{opaque}"); + + let out = sanitize_text(&text); + assert!(!out.contains("MII"), "{out}"); + assert!(!out.contains("abcdefghijklmnop"), "{out}"); + assert!(out.contains("[redacted]"), "{out}"); + } +} diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index f1dea98d88..fa72fab737 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1041,59 +1041,13 @@ fn validate_base_url_security(base_url: &str, provider_allows_insecure_http: boo ) } +/// Mask credentials in a URL for display. +/// +/// Delegates to the single shared implementation in +/// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so command sanitization and +/// client diagnostics cannot drift. pub(crate) fn redact_url_for_display(url: &str) -> String { - let Ok(mut parsed) = reqwest::Url::parse(url) else { - return url.to_string(); - }; - if !parsed.username().is_empty() || parsed.password().is_some() { - let _ = parsed.set_username("***"); - let _ = parsed.set_password(Some("***")); - } - if parsed.query().is_none() { - return parsed.to_string(); - } - let pairs: Vec<(String, String)> = parsed - .query_pairs() - .map(|(key, value)| { - let value = if is_sensitive_url_query_key(&key) { - "***".to_string() - } else { - value.into_owned() - }; - (key.into_owned(), value) - }) - .collect(); - parsed.set_query(None); - let mut query = parsed.query_pairs_mut(); - for (key, value) in pairs { - query.append_pair(&key, &value); - } - drop(query); - parsed.to_string() -} - -fn is_sensitive_url_query_key(key: &str) -> bool { - let normalized = key.trim().replace(['-', '.'], "_").to_ascii_lowercase(); - matches!( - normalized.as_str(), - "api_key" - | "apikey" - | "access_token" - | "auth_token" - | "authorization" - | "bearer" - | "client_secret" - | "credential" - | "id_token" - | "password" - | "refresh_token" - | "secret" - | "token" - ) || normalized.ends_with("_api_key") - || normalized.ends_with("_authorization") - || normalized.ends_with("_password") - || normalized.ends_with("_secret") - || normalized.ends_with("_token") + codewhale_secrets::sanitize::redact_url_for_display(url) } pub(super) fn versioned_base_url(base_url: &str) -> String { diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 049bcb089b..ce5bba753c 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -14,7 +14,7 @@ //! //! ## Authoritative host-proxy design (D1) //! -//! `CommandContexts` holds fifteen independently borrowed facet objects, while +//! `CommandContexts` holds sixteen independently borrowed facet objects, while //! important behavior (mode transitions, model invalidation, cost accounting, //! skill refresh) is authoritative on `App`. The adapters therefore share a //! synchronous TUI-owned host proxy. Each trait call borrows `App` only for the @@ -54,6 +54,11 @@ use codewhale_command_contract::facets::{ SkillSourceKind, SkillSyncEntry, SkillSyncOutcome, SkillTargetScope, SnapshotEntry, TitleReport, TitleSource, TodoProjection, TreeBodyProjection, }; +use codewhale_command_contract::facets::{ + CommandSessionExportContext, ConversationExportProjection, ExportBlock, ExportMessage, + ExportMetadata, HistoryEntry, RestorePointProjection, RestoreSnapshot, ToolCallerProjection, + TranscriptProjection, TurnHandoffProjection, +}; #[cfg(test)] use codewhale_command_contract::handler::ContextParts; use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts}; @@ -61,7 +66,7 @@ use codewhale_command_contract::types::{ CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort, }; use codewhale_config::AppMode; -use codewhale_core::request::{Message, SystemPrompt}; +use codewhale_core::request::{ContentBlock, Message, SystemPrompt}; use codewhale_execpolicy::ApprovalMode; use crate::commands::groups::plugins::plugin_network_policy; @@ -274,7 +279,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { /// Shared TUI host hidden behind the portable command facets. /// -/// The envelope needs fifteen independently borrowed facet objects, while the +/// The envelope needs sixteen independently borrowed facet objects, while the /// authoritative mutation methods live on `App`. Each adapter therefore owns /// an `Rc` clone of this synchronous host proxy. Trait calls borrow `App` only /// for the duration of one method, delegate to the real TUI authority, and @@ -1477,6 +1482,317 @@ fn import_session_container( }) } +// --------------------------------------------------------------------------- +// Session export adapter (FEAT-025 D1/D2/D3/D5/D7/D8/D9) +// +// Sole host owner of concrete export machinery for `/export` and `/daochu`: +// metadata derivation, authoritative/visible-history projection, semantic +// restore-point projection, the shared `turn_handoff_markdown` renderer, +// clipboard mode/recovery/delivery, and protected destination resolution/ +// writing. Every delegate reproduces the baseline order and returns portable +// data or the exact host-error text; no concrete `App`, clipboard, snapshot, +// history, filesystem, or turn-handoff type crosses the boundary. Hidden +// reasoning bodies, signatures, and inline/local image payloads are excluded +// while the projection is built (D9). The shared recovery writer and protected +// file services live in `commands::session_export_host` (outside the future +// portable group) so `/copy` and `/export` reuse one implementation (D5). +// --------------------------------------------------------------------------- +pub(crate) struct SessionExportAdapter<'a> { + host: SharedCommandHost<'a>, +} + +impl CommandSessionExportContext for SessionExportAdapter<'_> { + /// Conversation export projection: metadata, transcript, and restore-point + /// state. + /// + /// Memory note (FEAT-025 audit, finding F3): the projection is an *owned* + /// copy of the transcript, so peak use is roughly the live `api_messages` + /// plus this projection for the duration of one render. That copy is + /// structural, not an oversight: the facet must return owned data because + /// `SharedCommandHost` hands out `App` through a `RefCell`, so no borrow can + /// outlive this method, and a `dyn` facet cannot lend a projection tied to a + /// temporary `Ref`. The baseline rendered straight from `App` and cloned one + /// block at a time, so this is a deliberate D3 cost accepted for the + /// capability boundary. Removing it needs a host proxy that can lend a + /// borrowed projection (tracked with the FEAT-043/046 extraction work); it is + /// not something this slice can fix locally. + fn conversation_projection(&self) -> ConversationExportProjection { + let app = self.host.app.borrow(); + ConversationExportProjection { + metadata: export_metadata(&app), + transcript: project_transcript(&app), + restore_points: project_restore_points(&app.workspace), + } + } + + fn turn_handoff_projection(&self) -> TurnHandoffProjection { + let app = self.host.app.borrow(); + TurnHandoffProjection { + markdown: crate::tui::ui::turn_handoff_markdown(&app), + workspace_path: app.workspace.to_string_lossy().into_owned(), + } + } + + fn clipboard_requires_terminal_paste(&self) -> bool { + self.host.app.borrow().clipboard.requires_terminal_paste() + } + + fn write_recovery_copy(&self, markdown: &str) -> Option { + crate::commands::session_export_host::write_last_copy(markdown) + } + + fn write_clipboard(&self, markdown: &str) -> Result<(), String> { + self.host + .app + .borrow_mut() + .clipboard + .write_text(markdown) + .map_err(|err| err.to_string()) + } + + fn resolve_export_path(&self, raw: &str) -> Result { + let app = self.host.app.borrow(); + crate::commands::session_export_host::resolve_export_path(&app.workspace, raw) + } + + fn write_export_file(&self, path: &Path, contents: &[u8], force: bool) -> Result<(), String> { + crate::commands::session_export_host::write_export_file(path, contents, force) + } +} + +/// Maximum restore points listed in the export summary (baseline bound). +const RESTORE_POINT_SUMMARY_MAX: usize = 100; + +/// Authoritative export metadata, reusing the baseline host derivations. +fn export_metadata(app: &App) -> ExportMetadata { + let message_count = if app.api_messages.is_empty() { + app.history.len() + } else { + app.api_messages.len() + }; + let session_label = app + .current_session_id + .as_deref() + .map(crate::session_manager::truncate_id) + .unwrap_or("unsaved") + .to_string(); + let workspace_name = app + .workspace + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("workspace") + .to_string(); + ExportMetadata { + session_label, + provider: app.provider_identity_for_persistence().to_string(), + model: app.model_display_label(), + mode: app.mode.display_name().to_string(), + workspace_name, + message_count, + exported_at_unix: chrono::Utc::now().timestamp(), + } +} + +/// Authoritative transcript when API messages exist, otherwise the visible +/// history fallback (D3 precedence). +fn project_transcript(app: &App) -> TranscriptProjection { + if app.api_messages.is_empty() { + TranscriptProjection::HistoryFallback( + app.history.iter().map(project_history_cell).collect(), + ) + } else { + TranscriptProjection::Authoritative(app.api_messages.iter().map(project_message).collect()) + } +} + +fn project_message(message: &Message) -> ExportMessage { + ExportMessage { + role: message.role.as_str().to_string(), + // Exact enum identity, not a string comparison: `Role::Unrecognized("user")` + // must not be treated as a user turn (baseline parity, F6). + is_user_role: message.role == codewhale_models::Role::User, + blocks: message.content.iter().map(project_block).collect(), + prompt_snippet: first_text_block(message) + .and_then(crate::core::turn::snapshot_label_prompt_snippet), + } +} + +fn first_text_block(message: &Message) -> Option<&str> { + message.content.iter().find_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) +} + +/// Project one content block; hidden payloads become typed omission markers +/// (D9) and never cross the boundary. +fn project_block(block: &ContentBlock) -> ExportBlock { + match block { + ContentBlock::Text { text, .. } => ExportBlock::Text { text: text.clone() }, + ContentBlock::ImageUrl { image_url } => { + if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") { + ExportBlock::ImageReference { + url: image_url.url.clone(), + } + } else { + ExportBlock::ImageOmitted + } + } + ContentBlock::Thinking { .. } => ExportBlock::InternalReasoning, + ContentBlock::ToolUse { + id, + name, + input, + caller, + .. + } => ExportBlock::ToolCall { + id: id.clone(), + name: name.clone(), + caller: caller.as_ref().map(|caller| ToolCallerProjection { + caller_type: caller.caller_type.clone(), + tool_id: caller.tool_id.clone(), + }), + input: input.clone(), + }, + ContentBlock::ToolResult { + tool_use_id, + content, + is_error, + content_blocks, + } => ExportBlock::ToolResult { + tool_use_id: tool_use_id.clone(), + content: content.clone(), + is_error: is_error.unwrap_or(false), + structured: content_blocks.as_deref().map(|blocks| { + serde_json::Value::Array( + crate::image_attach::safe_tool_result_content_blocks(Some(blocks)) + .unwrap_or_default(), + ) + }), + }, + ContentBlock::ServerToolUse { id, name, input } => ExportBlock::ServerToolCall { + id: id.clone(), + name: name.clone(), + input: input.clone(), + }, + ContentBlock::ToolSearchToolResult { + tool_use_id, + content, + } => ExportBlock::ToolSearchResult { + tool_use_id: tool_use_id.clone(), + content: content.clone(), + }, + ContentBlock::CodeExecutionToolResult { + tool_use_id, + content, + } => ExportBlock::CodeExecutionResult { + tool_use_id: tool_use_id.clone(), + content: content.clone(), + }, + } +} + +fn project_history_cell(cell: &HistoryCell) -> HistoryEntry { + match cell { + HistoryCell::User { content } => HistoryEntry::Sanitized { + role: "user".to_string(), + body: content.clone(), + }, + HistoryCell::Assistant { content, .. } => HistoryEntry::Sanitized { + role: "assistant".to_string(), + body: content.clone(), + }, + HistoryCell::System { .. } => HistoryEntry::Literal { + role: "system".to_string(), + body: "[internal context omitted]".to_string(), + }, + HistoryCell::Error { message, severity } => HistoryEntry::Sanitized { + role: error_severity_role(*severity).to_string(), + body: message.clone(), + }, + HistoryCell::Thinking { .. } => HistoryEntry::Literal { + role: "internal reasoning".to_string(), + body: "[internal reasoning omitted]".to_string(), + }, + HistoryCell::Tool(tool) => HistoryEntry::Sanitized { + role: "tool".to_string(), + body: flatten_history_lines(tool.lines(120)), + }, + HistoryCell::SubAgent(subagent) => HistoryEntry::Sanitized { + role: "sub-agent".to_string(), + body: flatten_history_lines(subagent.lines(120)), + }, + HistoryCell::Automation(cell) => HistoryEntry::Sanitized { + role: "automation".to_string(), + body: flatten_history_lines(cell.render(120)), + }, + HistoryCell::ArchivedContext { + level, + range, + summary, + .. + } => HistoryEntry::Sanitized { + role: "archived context".to_string(), + body: format!("L{level} [{range}]: {summary}"), + }, + } +} + +fn error_severity_role(severity: crate::error_taxonomy::ErrorSeverity) -> &'static str { + match severity { + crate::error_taxonomy::ErrorSeverity::Info => "info", + crate::error_taxonomy::ErrorSeverity::Warning => "warning", + crate::error_taxonomy::ErrorSeverity::Error => "error", + crate::error_taxonomy::ErrorSeverity::Critical => "critical error", + } +} + +/// Flatten host UI lines/spans to plain text, preserving the baseline width +/// and joining behavior (D3). UI rendering stays behind the adapter. +fn flatten_history_lines(lines: Vec>) -> String { + lines + .into_iter() + .map(|line| { + line.spans + .into_iter() + .map(|span| span.content.to_string()) + .collect::() + }) + .collect::>() + .join("\n") +} + +/// Read the workspace snapshot repository read-only and project its state +/// (D8): only an existing repo is opened, never created. +fn project_restore_points(workspace: &Path) -> RestorePointProjection { + match crate::snapshot::SnapshotRepo::open_existing(workspace) { + Ok(None) => RestorePointProjection::None, + Err(err) => RestorePointProjection::Unreadable { + reason: err.to_string(), + }, + Ok(Some(repo)) => match repo.list(RESTORE_POINT_SUMMARY_MAX) { + Ok(snapshots) => RestorePointProjection::Recorded { + snapshots: snapshots.iter().map(project_restore_snapshot).collect(), + }, + Err(err) => RestorePointProjection::Unreadable { + reason: err.to_string(), + }, + }, + } +} + +fn project_restore_snapshot(snapshot: &crate::snapshot::Snapshot) -> RestoreSnapshot { + let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label); + RestoreSnapshot { + id: snapshot.id.as_str().to_string(), + label: snapshot.label.clone(), + timestamp_unix: snapshot.timestamp, + kind: parsed.kind, + sequence: parsed.seq, + prompt_snippet: parsed.prompt_snippet, + } +} + /// Session identity, messages, queue operations, and token totals. pub(crate) struct SessionAdapter<'a> { host: SharedCommandHost<'a>, @@ -3922,7 +4238,7 @@ fn default_codewhale_tools_dir() -> Option { // Envelope construction (D1) // --------------------------------------------------------------------------- -/// Owns fifteen facet objects sharing one synchronous TUI host proxy. +/// Owns sixteen facet objects sharing one synchronous TUI host proxy. /// /// Handlers borrow only these adapters. Every method delegates to the real App /// authority and releases its `RefCell` borrow before returning, so facets can @@ -3943,6 +4259,7 @@ pub(crate) struct CommandContextBundle<'a> { plugin: PluginAdapter<'a>, lifecycle: SessionLifecycleAdapter<'a>, control: SessionControlAdapter<'a>, + export: SessionExportAdapter<'a>, } impl<'a> CommandContextBundle<'a> { @@ -3994,6 +4311,9 @@ impl<'a> CommandContextBundle<'a> { if capabilities.contains(CommandCapabilities::SESSION_CONTROL) { contexts = contexts.with_control(&mut self.control); } + if capabilities.contains(CommandCapabilities::SESSION_EXPORT) { + contexts = contexts.with_export(&mut self.export); + } contexts } @@ -4014,7 +4334,8 @@ impl<'a> CommandContextBundle<'a> { .union(CommandCapabilities::SKILL_GROUP) .union(CommandCapabilities::PLUGIN) .union(CommandCapabilities::SESSION_LIFECYCLE) - .union(CommandCapabilities::SESSION_CONTROL); + .union(CommandCapabilities::SESSION_CONTROL) + .union(CommandCapabilities::SESSION_EXPORT); self.contexts(all_test_capabilities).into_parts() } } @@ -4041,7 +4362,8 @@ impl App { skill_group: SkillGroupAdapter { host: host.clone() }, plugin: PluginAdapter { host: host.clone() }, lifecycle: SessionLifecycleAdapter { host: host.clone() }, - control: SessionControlAdapter { host }, + control: SessionControlAdapter { host: host.clone() }, + export: SessionExportAdapter { host }, } } } diff --git a/crates/tui/src/commands/fixtures/export_conversation_baseline.md b/crates/tui/src/commands/fixtures/export_conversation_baseline.md new file mode 100644 index 0000000000..68a2ea7637 --- /dev/null +++ b/crates/tui/src/commands/fixtures/export_conversation_baseline.md @@ -0,0 +1,129 @@ +# Codewhale conversation export + +- Exported: 2026-09-11T13:31:54Z +- Session: session- +- Provider: deepseek +- Model: deepseek-v4-pro +- Mode: Act +- Workspace: example +- Messages: 8 + +> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it. + +## Restore points + +No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet. + +## 1. system + +[internal context omitted] + +## 2. user + +### Content 1: Text + +Please inspect this output + +## 3. assistant + +### Content 1: Internal reasoning + +[internal reasoning and signature omitted] + +### Content 2: Tool call + +- ID: call-1 +- Name: fetch_url +- Caller type: code_execution_20250825 +- Caller tool ID: server-tool-1 + +Input: + +```json +{ + "url": "https://***:***@example.com/path?token=***&ok=1", + "api_key": "[redacted]", + "nested": { + "authorization": "[redacted]" + } +} +``` + +## 4. user + +### Content 1: Tool result + +- Tool call ID: call-1 +- Error: false + +Result: + +Authorization: [redacted] +result ok + +Structured result blocks: + +```json +[ + { + "type": "image", + "mime_type": "image/png", + "omission_code": "inline_or_local_image_payload", + "omitted_base64_bytes": 25 + }, + { + "session_token": "[redacted]", + "note": "keep me" + } +] +``` + +## 5. assistant + +### Content 1: Image attachment + +- Reference: https://example.com/visible.png?token=*** + +### Content 2: Image attachment + +- Reference omitted (inline or local image payload) + +## 6. assistant + +### Content 1: Server tool call + +- ID: srv-1 +- Name: web_search + +Input: + +```json +{ + "query": "secret token" +} +``` + +## 7. user + +### Content 1: Tool-search result + +- Tool call ID: srv-1 + +```json +{ + "results": [] +} +``` + +## 8. assistant + +### Content 1: Code-execution result + +- Tool call ID: srv-2 + +```json +{ + "stdout": "ok" +} +``` + diff --git a/crates/tui/src/commands/fixtures/export_correlation_recorded_baseline.md b/crates/tui/src/commands/fixtures/export_correlation_recorded_baseline.md new file mode 100644 index 0000000000..0bab0d644f --- /dev/null +++ b/crates/tui/src/commands/fixtures/export_correlation_recorded_baseline.md @@ -0,0 +1,47 @@ +# Codewhale conversation export + +- Exported: 2026-09-11T14:39:58Z +- Session: session- +- Provider: deepseek +- Model: deepseek-v4-pro +- Mode: Act +- Workspace: f025-golden-workspace +- Messages: 3 + +> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it. + +## Restore points + +The 3 most recent workspace restore points, newest first. `/restore ` restores by the index in this table and `/restore list` shows the live list. + +> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift. + +| N | Restore point | Recorded (UTC) | Label | +| --- | --- | --- | --- | +| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test | +| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 | +| 3 | `e24e175a7a57` | 2026-09-11T14:39:58Z | pre-turn:2: Fix the login test | + +## 1. user + +### Content 1: Text + +Fix the login test + +- Restore points: N1 `3d0ad76f0222` (pre-turn turn 3), N3 `e24e175a7a57` (pre-turn turn 2) + - More than one restore point carries this prompt snippet, so the match is ambiguous; compare the recorded times above before restoring. + +## 2. assistant + +### Content 1: Text + +Working on it. + +## 3. user + +### Content 1: Text + +unrelated question + +- Restore points: none recorded for this message within the listed window. + diff --git a/crates/tui/src/commands/fixtures/export_history_fallback_recorded_baseline.md b/crates/tui/src/commands/fixtures/export_history_fallback_recorded_baseline.md new file mode 100644 index 0000000000..6b36be8d0a --- /dev/null +++ b/crates/tui/src/commands/fixtures/export_history_fallback_recorded_baseline.md @@ -0,0 +1,34 @@ +# Codewhale conversation export + +- Exported: 2026-09-11T14:39:58Z +- Session: session- +- Provider: deepseek +- Model: deepseek-v4-pro +- Mode: Act +- Workspace: f025-golden-workspace +- Messages: 2 + +> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it. + +## Restore points + +The 3 most recent workspace restore points, newest first. `/restore ` restores by the index in this table and `/restore list` shows the live list. + +> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift. + +| N | Restore point | Recorded (UTC) | Label | +| --- | --- | --- | --- | +| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test | +| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 | +| 3 | `e24e175a7a57` | 2026-09-11T14:39:58Z | pre-turn:2: Fix the login test | + +> Structured API messages were unavailable; the entries below are a sanitized visible-history fallback. + +## 1. user + +Fix the login test + +## 2. assistant + +Done. + diff --git a/crates/tui/src/commands/fixtures/export_turn_baseline.md b/crates/tui/src/commands/fixtures/export_turn_baseline.md new file mode 100644 index 0000000000..60857dcab4 --- /dev/null +++ b/crates/tui/src/commands/fixtures/export_turn_baseline.md @@ -0,0 +1,23 @@ +# Turn handoff +_Status: completed · generated 2026-09-11 15:31:54_ + +## Intent +Fix the flaky login test + +## Files changed +— + +## Turn timeline +- 1. user prompt: Fix the flaky login test +- 2. assistant result: Fixed the login test. — done +- 3. checkpoint: unavailable — no numbered turn snapshot yet · action: e export handoff + +## Tests / verifier +— + +## Model route + tokens/cost +- Route: DeepSeek · deepseek-v4-pro + +## Result / status +- Status: completed +- Result: Fixed the login test. diff --git a/crates/tui/src/commands/groups/core/copy.rs b/crates/tui/src/commands/groups/core/copy.rs index 9014d42773..6e75244d7e 100644 --- a/crates/tui/src/commands/groups/core/copy.rs +++ b/crates/tui/src/commands/groups/core/copy.rs @@ -38,7 +38,7 @@ fn execute_copy(app: &mut App) -> CommandResult { // Any native-host attempt may fall through to the asynchronous terminal // transport. Preserve /export's durable recovery contract before the // write so every optimistic receipt names (or explicitly lacks) a backup. - let recovery = crate::commands::groups::session::write_last_copy(&content); + let recovery = crate::commands::session_export_host::write_last_copy(&content); match app.clipboard.write_text(&content) { Ok(()) if terminal_client => match recovery { Some(path) => CommandResult::message( diff --git a/crates/tui/src/commands/groups/session/export.rs b/crates/tui/src/commands/groups/session/export.rs index 1991675df2..4b6ddd8535 100644 --- a/crates/tui/src/commands/groups/session/export.rs +++ b/crates/tui/src/commands/groups/session/export.rs @@ -1,65 +1,74 @@ -//! `/export` command. +//! `/export` command — portable handler over the session-export facet +//! (FEAT-025 D1-D9). //! -//! The full-conversation export is a projection of the authoritative API -//! message stream. It deliberately omits hidden reasoning and signed-thinking -//! payloads, redacts secret-shaped values, and never mutates session or Work -//! state. +//! Parsing, document rendering, redaction, operation sequencing, and result +//! composition are portable: this module depends only on the external command +//! contract, `chrono`, the shared pure sanitizer in +//! [`codewhale_secrets::sanitize`], and the temporary FEAT-037 `CommandResult`. +//! Concrete `App`, clipboard, filesystem, snapshot, history, and turn-handoff +//! access stays behind `CommandSessionExportContext` (D1). Helpers, tests, and +//! this handler therefore carry no TUI, client, configuration, or filesystem +//! dependency, so the slice can move to `codewhale-commands` unchanged (D8/D10). use std::fmt::Write as FmtWrite; -use std::fs::{self, OpenOptions}; -use std::io::Write as IoWrite; -use std::path::{Component, Path, PathBuf}; -use std::sync::OnceLock; +use std::path::PathBuf; -use regex::Regex; +use codewhale_command_contract::facets::{ + CommandSessionExportContext, ConversationExportProjection, ExportBlock, ExportMessage, + HistoryEntry, RestorePointProjection, RestoreSnapshot, TranscriptProjection, + TurnHandoffProjection, +}; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; +use codewhale_secrets::sanitize::{ + inline_text, is_internal_role, redact_json, redact_url_for_display, sanitize_text, +}; use serde_json::Value; -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use crate::tui::history::HistoryCell; -use codewhale_localization::MessageId; -use codewhale_models::{ContentBlock, Message, Role}; - use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub(in crate::commands) struct ExportCmd; + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "export", aliases: &["daochu"], usage: "/export [clipboard|file [--force] |turn [clipboard|file [--force] ]]", - description_id: MessageId::CmdExportDescription, + description_key: "cmd_export_description", }; -pub(in crate::commands) struct ExportCmd; - -impl RegisterCommand for ExportCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO +impl ContractRegisterCommand for ExportCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO } - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - execute_export(app, arg) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::SESSION_EXPORT, + handler: export_contextual, + } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ExportScope { - Conversation, - Turn, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ExportDestination { - Clipboard, - File { path: String, force: bool }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ExportRequest { - scope: ExportScope, - destination: ExportDestination, +pub(in crate::commands) fn export_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let parts = contexts.into_parts(); + let Some(export) = parts.export.as_deref() else { + return CommandResult::error("Command capability unavailable: session_export".to_string()); + }; + export_portable(export, arg) } -fn execute_export(app: &mut App, arg: Option<&str>) -> CommandResult { +/// Portable `/export` composed entirely from contract-owned data and facet +/// operations. Parse first, render the selected scope second, then run the +/// destination-specific sequence (D6/D7). +pub(in crate::commands) fn export_portable( + export: &dyn CommandSessionExportContext, + arg: Option<&str>, +) -> CommandResult { let request = match parse_request(arg) { Ok(request) => request, Err(err) => return CommandResult::error(err), @@ -69,21 +78,18 @@ fn execute_export(app: &mut App, arg: Option<&str>) -> CommandResult { ExportScope::Turn => "Turn handoff", }; let markdown = match request.scope { - ExportScope::Conversation => render_conversation(app), - ExportScope::Turn => { - let rendered = crate::tui::ui::turn_handoff_markdown(app); - sanitize_turn_handoff(app, &rendered) - } + ExportScope::Conversation => render_conversation(export.conversation_projection()), + ExportScope::Turn => sanitize_turn_handoff(&export.turn_handoff_projection()), }; match request.destination { - ExportDestination::Clipboard => copy_to_clipboard(app, label, &markdown), + ExportDestination::Clipboard => copy_to_clipboard(export, label, &markdown), ExportDestination::File { path, force } => { - let path = match resolve_export_path(&app.workspace, &path) { + let path = match export.resolve_export_path(&path) { Ok(path) => path, Err(err) => return CommandResult::error(err), }; - match write_export_file(&path, markdown.as_bytes(), force) { + match export.write_export_file(&path, markdown.as_bytes(), force) { Ok(()) => CommandResult::message(format!( "{label} exported to {}{}", path.display(), @@ -102,6 +108,24 @@ fn execute_export(app: &mut App, arg: Option<&str>) -> CommandResult { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ExportScope { + Conversation, + Turn, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExportDestination { + Clipboard, + File { path: String, force: bool }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExportRequest { + scope: ExportScope, + destination: ExportDestination, +} + fn parse_request(arg: Option<&str>) -> Result { let raw = arg.unwrap_or("").trim(); if raw.is_empty() || raw.eq_ignore_ascii_case("clipboard") { @@ -204,16 +228,20 @@ fn export_usage(reason: &str) -> String { ) } -fn copy_to_clipboard(app: &mut App, label: &str, markdown: &str) -> CommandResult { - let terminal_client = app.clipboard.requires_terminal_paste(); - let last_copy = write_last_copy(markdown); +fn copy_to_clipboard( + export: &dyn CommandSessionExportContext, + label: &str, + markdown: &str, +) -> CommandResult { + let terminal_client = export.clipboard_requires_terminal_paste(); + let last_copy = export.write_recovery_copy(markdown); let copy_hint = |path: Option| match path { Some(path) => { format!("; a copy is at {}", path.display()) } None => String::new(), }; - match app.clipboard.write_text(markdown) { + match export.write_clipboard(markdown) { Ok(()) if terminal_client => CommandResult::message(format!( "{label} sent to the terminal-client clipboard over SSH via tmux/OSC 52 ({} lines){}; terminal support and settings determine whether the client accepts it", markdown.lines().count(), @@ -236,225 +264,177 @@ fn copy_to_clipboard(app: &mut App, label: &str, markdown: &str) -> CommandResul } } -/// Write the export to a predictable last-copy file under the Codewhale home -/// (#5555): a clipboard-only export on SSH/headless must never dead-end the -/// user, so the same content lands at `/exports/last-copy.md` and every -/// failure message names it. Returns the path when the write succeeded. -pub(crate) fn write_last_copy(markdown: &str) -> Option { - let home = codewhale_paths::codewhale_home().ok().flatten()?; - let exports_dir = home.join("exports"); - std::fs::create_dir_all(&exports_dir).ok()?; - let physical_home = std::fs::canonicalize(&home).ok()?; - let physical_exports = std::fs::canonicalize(&exports_dir).ok()?; - if !physical_exports.starts_with(&physical_home) { - return None; - } - write_last_copy_to(&exports_dir, markdown).ok() -} - -fn write_last_copy_to(exports_dir: &Path, markdown: &str) -> std::io::Result { - std::fs::create_dir_all(exports_dir)?; - let path = exports_dir.join("last-copy.md"); - // Reuse the private atomic writer: random same-directory temp names, - // restrictive creation mode, symlink-safe replacement, and Windows - // replace retries are all part of the existing persistence contract. - crate::utils::write_atomic(&path, markdown.as_bytes())?; - Ok(path) -} - -fn render_conversation(app: &App) -> String { - let message_count = if app.api_messages.is_empty() { - app.history.len() - } else { - app.api_messages.len() - }; +/// Render the full-conversation export document from portable projection data. +/// +/// Takes the projection by value so the render path can move each block's JSON +/// payload into `push_json` instead of cloning it a second time. The projection +/// itself was already copied once at the facet boundary (F3); cloning the +/// payloads again here would be an avoidable extra copy of every tool input and +/// structured result. +fn render_conversation(projection: ConversationExportProjection) -> String { + let ConversationExportProjection { + metadata, + transcript, + restore_points, + } = projection; let mut out = String::new(); out.push_str("# Codewhale conversation export\n\n"); let _ = writeln!( out, "- Exported: {}", - chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + format_export_time(metadata.exported_at_unix) ); - let session = app - .current_session_id - .as_deref() - .map(crate::session_manager::truncate_id) - .unwrap_or("unsaved"); - let _ = writeln!(out, "- Session: {}", inline_text(session)); + let _ = writeln!(out, "- Session: {}", inline_text(&metadata.session_label)); + let _ = writeln!(out, "- Provider: {}", inline_text(&metadata.provider)); + let _ = writeln!(out, "- Model: {}", inline_text(&metadata.model)); + let _ = writeln!(out, "- Mode: {}", metadata.mode); let _ = writeln!( out, - "- Provider: {}", - inline_text(app.provider_identity_for_persistence()) + "- Workspace: {}", + inline_text(&metadata.workspace_name) ); - let _ = writeln!(out, "- Model: {}", inline_text(&app.model_display_label())); - let _ = writeln!(out, "- Mode: {}", app.mode.display_name()); - let workspace_name = app - .workspace - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("workspace"); - let _ = writeln!(out, "- Workspace: {}", inline_text(workspace_name)); - let _ = writeln!(out, "- Messages: {message_count}"); + let _ = writeln!(out, "- Messages: {}", metadata.message_count); out.push_str( "\n> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.\n\n", ); - let restore_points = RestorePoints::read(&app.workspace); - restore_points.render_summary(&mut out); + render_restore_summary(&mut out, &restore_points); - if app.api_messages.is_empty() { - render_history_fallback(&mut out, &app.history); - } else { - for (index, message) in app.api_messages.iter().enumerate() { - render_message(&mut out, index + 1, message); - restore_points.render_correlation(&mut out, message); + match transcript { + TranscriptProjection::HistoryFallback(entries) => { + render_history_fallback(&mut out, &entries) + } + TranscriptProjection::Authoritative(messages) => { + for (index, message) in messages.into_iter().enumerate() { + // Correlation is derived before `message` is consumed by the + // renderer so the render path can take ownership of its payloads. + let correlation = correlation_markdown(&restore_points, &message); + render_message(&mut out, index + 1, message); + out.push_str(&correlation); + } } } out } -/// Maximum restore points listed in the export summary. The side-git repo is -/// already capped, but the export is a document a person reads, so it gets its -/// own bound rather than inheriting whatever the repo happens to hold. -const RESTORE_POINT_SUMMARY_MAX: usize = 100; +fn format_export_time(timestamp: i64) -> String { + chrono::DateTime::from_timestamp(timestamp, 0) + .map(|time| time.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)) + .unwrap_or_else(|| "unknown".to_string()) +} /// Characters of the snapshot SHA shown as a restore-point id. const RESTORE_POINT_ID_LEN: usize = 12; -/// Restore points (side-git workspace snapshots) recorded for this workspace, -/// read read-only so `/export` never creates a snapshot repo as a side effect. -enum RestorePoints { - /// The repo exists and these are its most recent snapshots (newest first). - Recorded(Vec), - /// No snapshot repo exists for this workspace. - None, - /// The repo exists but could not be read. The reason is reported rather - /// than swallowed — a silent omission would read as "no restore points". - Unreadable(String), -} - -impl RestorePoints { - fn read(workspace: &Path) -> Self { - match crate::snapshot::SnapshotRepo::open_existing(workspace) { - Ok(None) => Self::None, - Err(err) => Self::Unreadable(err.to_string()), - Ok(Some(repo)) => match repo.list(RESTORE_POINT_SUMMARY_MAX) { - Ok(snapshots) => Self::Recorded(snapshots), - Err(err) => Self::Unreadable(err.to_string()), - }, +fn render_restore_summary(out: &mut String, projection: &RestorePointProjection) { + out.push_str("## Restore points\n\n"); + match projection { + RestorePointProjection::None => { + out.push_str( + "No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet.\n\n", + ); } - } - - fn render_summary(&self, out: &mut String) { - out.push_str("## Restore points\n\n"); - match self { - Self::None => { - out.push_str( - "No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet.\n\n", - ); - } - Self::Unreadable(reason) => { - let _ = writeln!( - out, - "Workspace restore points could not be read ({}). Treat the correlation below as unavailable rather than empty.\n", - inline_text(reason) - ); - } - Self::Recorded(snapshots) if snapshots.is_empty() => { - out.push_str( - "A snapshot repository exists for this workspace but records no restore points yet.\n\n", - ); - } - Self::Recorded(snapshots) => { + RestorePointProjection::Unreadable { reason } => { + let _ = writeln!( + out, + "Workspace restore points could not be read ({}). Treat the correlation below as unavailable rather than empty.\n", + inline_text(reason) + ); + } + RestorePointProjection::Recorded { snapshots } if snapshots.is_empty() => { + out.push_str( + "A snapshot repository exists for this workspace but records no restore points yet.\n\n", + ); + } + RestorePointProjection::Recorded { snapshots } => { + let _ = writeln!( + out, + "The {} most recent workspace restore points, newest first. `/restore ` restores by the index in this table and `/restore list` shows the live list.\n", + snapshots.len() + ); + out.push_str( + "> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift.\n\n", + ); + out.push_str("| N | Restore point | Recorded (UTC) | Label |\n"); + out.push_str("| --- | --- | --- | --- |\n"); + for (index, snapshot) in snapshots.iter().enumerate() { let _ = writeln!( out, - "The {} most recent workspace restore points, newest first. `/restore ` restores by the index in this table and `/restore list` shows the live list.\n", - snapshots.len() - ); - out.push_str( - "> The index is the position at export time. Every new turn records another restore point and shifts it, so re-check `/restore list` before restoring from an older export. The snapshot id does not shift.\n\n", + "| {} | `{}` | {} | {} |", + index + 1, + short_restore_id(&snapshot.id), + format_snapshot_time(snapshot.timestamp_unix), + inline_text(&snapshot.label) ); - out.push_str("| N | Restore point | Recorded (UTC) | Label |\n"); - out.push_str("| --- | --- | --- | --- |\n"); - for (index, snapshot) in snapshots.iter().enumerate() { - let _ = writeln!( - out, - "| {} | `{}` | {} | {} |", - index + 1, - short_restore_id(snapshot.id.as_str()), - format_snapshot_time(snapshot.timestamp), - inline_text(&snapshot.label) - ); - } - out.push('\n'); } + out.push('\n'); } } +} - /// Append the restore points correlated to a single user message. - /// - /// Correlation is by the prompt snippet the snapshot label actually - /// embeds, produced by the same function the snapshot writer uses. No - /// message-index-to-turn-sequence mapping is invented: a turn sequence and - /// an export message index are different counters, and asserting they line - /// up would be a guess presented as provenance. - fn render_correlation(&self, out: &mut String, message: &Message) { - if message.role != Role::User { - return; - } - let Self::Recorded(snapshots) = self else { - return; - }; - let Some(text) = first_text_block(message) else { - return; - }; - let Some(snippet) = crate::core::turn::snapshot_label_prompt_snippet(text) else { - return; - }; +/// Append the restore points correlated to a single user message. +/// +/// Correlation is by the prompt snippet the host embedded in the snapshot +/// label, produced by the same function the snapshot writer uses. No +/// message-index-to-turn-sequence mapping is invented: a turn sequence and an +/// export message index are different counters, and asserting they line up +/// would be a guess presented as provenance. +fn correlation_markdown(projection: &RestorePointProjection, message: &ExportMessage) -> String { + let mut out = String::new(); + // F6: exact `Role::User` identity, not the rendered role string. Comparing + // textually against "user" would also match `Role::Unrecognized("user")`, + // which the baseline deliberately did not correlate. + if !message.is_user_role { + return out; + } + let RestorePointProjection::Recorded { snapshots } = projection else { + return out; + }; + let Some(snippet) = message.prompt_snippet.as_deref() else { + return out; + }; - let matches: Vec<(usize, &crate::snapshot::Snapshot)> = snapshots - .iter() - .enumerate() - .filter(|(_, snapshot)| { - let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label); - matches!(parsed.kind.as_str(), "pre-turn" | "post-turn") - && parsed.prompt_snippet.as_deref() == Some(snippet.as_str()) - }) - .collect(); + let matches: Vec<(usize, &RestoreSnapshot)> = snapshots + .iter() + .enumerate() + .filter(|(_, snapshot)| { + matches!(snapshot.kind.as_str(), "pre-turn" | "post-turn") + && snapshot.prompt_snippet.as_deref() == Some(snippet) + }) + .collect(); - if matches.is_empty() { - out.push_str( - "- Restore points: none recorded for this message within the listed window.\n\n", - ); - return; - } + if matches.is_empty() { + out.push_str( + "- Restore points: none recorded for this message within the listed window.\n\n", + ); + return out; + } - let ambiguous = matches.len() > 1; - let rendered: Vec = matches - .iter() - .map(|(index, snapshot)| { - let parsed = crate::core::turn::parse_snapshot_label(&snapshot.label); - let seq = parsed - .seq - .map(|seq| format!(" turn {seq}")) - .unwrap_or_default(); - format!( - "N{} `{}` ({}{})", - index + 1, - short_restore_id(snapshot.id.as_str()), - parsed.kind, - seq - ) - }) - .collect(); - let _ = writeln!(out, "- Restore points: {}", rendered.join(", ")); - if ambiguous { - out.push_str( - " - More than one restore point carries this prompt snippet, so the match is ambiguous; compare the recorded times above before restoring.\n", - ); - } - out.push('\n'); + let ambiguous = matches.len() > 1; + let rendered: Vec = matches + .iter() + .map(|(index, snapshot)| { + let seq = snapshot + .sequence + .map(|seq| format!(" turn {seq}")) + .unwrap_or_default(); + format!( + "N{} `{}` ({}{})", + index + 1, + short_restore_id(&snapshot.id), + snapshot.kind, + seq + ) + }) + .collect(); + let _ = writeln!(out, "- Restore points: {}", rendered.join(", ")); + if ambiguous { + out.push_str( + " - More than one restore point carries this prompt snippet, so the match is ambiguous; compare the recorded times above before restoring.\n", + ); } + out.push('\n'); + out } fn short_restore_id(id: &str) -> String { @@ -467,61 +447,53 @@ fn format_snapshot_time(timestamp: i64) -> String { .unwrap_or_else(|| "unknown".to_string()) } -fn first_text_block(message: &Message) -> Option<&str> { - message.content.iter().find_map(|block| match block { - ContentBlock::Text { text, .. } => Some(text.as_str()), - _ => None, - }) -} - -fn render_message(out: &mut String, index: usize, message: &Message) { - let role = inline_text(message.role.as_str()); +fn render_message(out: &mut String, index: usize, message: ExportMessage) { + let role = inline_text(&message.role); let _ = writeln!(out, "## {index}. {role}\n"); - if is_internal_role(message.role.as_str()) { + if is_internal_role(&message.role) { out.push_str("[internal context omitted]\n\n"); return; } - if message.content.is_empty() { + if message.blocks.is_empty() { out.push_str("[no content]\n\n"); return; } - for (block_index, block) in message.content.iter().enumerate() { + for (block_index, block) in message.blocks.into_iter().enumerate() { render_content_block(out, block_index + 1, block); } } -fn render_content_block(out: &mut String, index: usize, block: &ContentBlock) { +fn render_content_block(out: &mut String, index: usize, block: ExportBlock) { match block { - ContentBlock::Text { text, .. } => { + ExportBlock::Text { text } => { let _ = writeln!(out, "### Content {index}: Text\n"); - push_sanitized_text(out, text); + push_sanitized_text(out, &text); } - ContentBlock::ImageUrl { image_url } => { + ExportBlock::ImageReference { url } => { let _ = writeln!(out, "### Content {index}: Image attachment\n"); - if image_url.url.starts_with("http://") || image_url.url.starts_with("https://") { - let _ = writeln!( - out, - "- Reference: {}\n", - inline_text(&crate::client::redact_url_for_display(&image_url.url)) - ); - } else { - out.push_str("- Reference omitted (inline or local image payload)\n\n"); - } + let _ = writeln!( + out, + "- Reference: {}\n", + inline_text(&redact_url_for_display(&url)) + ); } - ContentBlock::Thinking { .. } => { + ExportBlock::ImageOmitted => { + let _ = writeln!(out, "### Content {index}: Image attachment\n"); + out.push_str("- Reference omitted (inline or local image payload)\n\n"); + } + ExportBlock::InternalReasoning => { let _ = writeln!(out, "### Content {index}: Internal reasoning\n"); out.push_str("[internal reasoning and signature omitted]\n\n"); } - ContentBlock::ToolUse { + ExportBlock::ToolCall { id, name, - input, caller, - .. + input, } => { let _ = writeln!(out, "### Content {index}: Tool call\n"); - let _ = writeln!(out, "- ID: {}", inline_text(id)); - let _ = writeln!(out, "- Name: {}", inline_text(name)); + let _ = writeln!(out, "- ID: {}", inline_text(&id)); + let _ = writeln!(out, "- Name: {}", inline_text(&name)); if let Some(caller) = caller { let _ = writeln!(out, "- Caller type: {}", inline_text(&caller.caller_type)); if let Some(tool_id) = caller.tool_id.as_deref() { @@ -531,116 +503,66 @@ fn render_content_block(out: &mut String, index: usize, block: &ContentBlock) { out.push_str("\nInput:\n\n"); push_json(out, input); } - ContentBlock::ToolResult { + ExportBlock::ToolResult { tool_use_id, content, is_error, - content_blocks, + structured, } => { let _ = writeln!(out, "### Content {index}: Tool result\n"); - let _ = writeln!(out, "- Tool call ID: {}", inline_text(tool_use_id)); - let _ = writeln!(out, "- Error: {}\n", is_error.unwrap_or(false)); + let _ = writeln!(out, "- Tool call ID: {}", inline_text(&tool_use_id)); + let _ = writeln!(out, "- Error: {is_error}\n"); out.push_str("Result:\n\n"); - push_sanitized_text(out, content); - if let Some(blocks) = content_blocks { + push_sanitized_text(out, &content); + if let Some(blocks) = structured { out.push_str("Structured result blocks:\n\n"); - push_json( - out, - &Value::Array( - crate::image_attach::safe_tool_result_content_blocks(Some(blocks)) - .unwrap_or_default(), - ), - ); + push_json(out, blocks); } } - ContentBlock::ServerToolUse { id, name, input } => { + ExportBlock::ServerToolCall { id, name, input } => { let _ = writeln!(out, "### Content {index}: Server tool call\n"); - let _ = writeln!(out, "- ID: {}", inline_text(id)); - let _ = writeln!(out, "- Name: {}\n", inline_text(name)); + let _ = writeln!(out, "- ID: {}", inline_text(&id)); + let _ = writeln!(out, "- Name: {}\n", inline_text(&name)); out.push_str("Input:\n\n"); push_json(out, input); } - ContentBlock::ToolSearchToolResult { + ExportBlock::ToolSearchResult { tool_use_id, content, } => { let _ = writeln!(out, "### Content {index}: Tool-search result\n"); - let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(tool_use_id)); + let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(&tool_use_id)); push_json(out, content); } - ContentBlock::CodeExecutionToolResult { + ExportBlock::CodeExecutionResult { tool_use_id, content, } => { let _ = writeln!(out, "### Content {index}: Code-execution result\n"); - let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(tool_use_id)); + let _ = writeln!(out, "- Tool call ID: {}\n", inline_text(&tool_use_id)); push_json(out, content); } } } -fn render_history_fallback(out: &mut String, history: &[HistoryCell]) { - if history.is_empty() { +fn render_history_fallback(out: &mut String, entries: &[HistoryEntry]) { + if entries.is_empty() { out.push_str("## Conversation\n\n[empty conversation]\n"); return; } out.push_str( "> Structured API messages were unavailable; the entries below are a sanitized visible-history fallback.\n\n", ); - for (index, cell) in history.iter().enumerate() { - let (role, body) = match cell { - HistoryCell::User { content } => ("user", sanitize_text(content)), - HistoryCell::Assistant { content, .. } => ("assistant", sanitize_text(content)), - HistoryCell::System { .. } => ("system", "[internal context omitted]".to_string()), - HistoryCell::Error { message, severity } => { - let role = match severity { - crate::error_taxonomy::ErrorSeverity::Info => "info", - crate::error_taxonomy::ErrorSeverity::Warning => "warning", - crate::error_taxonomy::ErrorSeverity::Error => "error", - crate::error_taxonomy::ErrorSeverity::Critical => "critical error", - }; - (role, sanitize_text(message)) - } - HistoryCell::Thinking { .. } => ( - "internal reasoning", - "[internal reasoning omitted]".to_string(), - ), - HistoryCell::Tool(tool) => ("tool", sanitize_text(&render_lines(tool.lines(120)))), - HistoryCell::SubAgent(subagent) => ( - "sub-agent", - sanitize_text(&render_lines(subagent.lines(120))), - ), - HistoryCell::Automation(cell) => { - ("automation", sanitize_text(&render_lines(cell.render(120)))) - } - HistoryCell::ArchivedContext { - level, - range, - summary, - .. - } => ( - "archived context", - sanitize_text(&format!("L{level} [{range}]: {summary}")), - ), + for (index, entry) in entries.iter().enumerate() { + let (role, body) = match entry { + HistoryEntry::Sanitized { role, body } => (role.as_str(), sanitize_text(body)), + HistoryEntry::Literal { role, body } => (role.as_str(), body.clone()), }; let _ = writeln!(out, "## {}. {}\n", index + 1, inline_text(role)); push_pre_sanitized_text(out, &body); } } -fn render_lines(lines: Vec>) -> String { - lines - .into_iter() - .map(|line| { - line.spans - .into_iter() - .map(|span| span.content.to_string()) - .collect::() - }) - .collect::>() - .join("\n") -} - fn push_sanitized_text(out: &mut String, text: &str) { push_pre_sanitized_text(out, &sanitize_text(text)); } @@ -654,111 +576,16 @@ fn push_pre_sanitized_text(out: &mut String, text: &str) { } } -fn push_json(out: &mut String, value: &Value) { - let mut redacted = value.clone(); - redact_json(&mut redacted, None); - let json = serde_json::to_string_pretty(&redacted) +fn push_json(out: &mut String, mut value: Value) { + // Redact in place: the caller hands over ownership, so there is no need to + // clone the whole payload just to redact it (F3 follow-up). + redact_json(&mut value, None); + let json = serde_json::to_string_pretty(&value) .unwrap_or_else(|_| "\"[structured content unavailable]\"".to_string()); let fence = markdown_fence(&json); let _ = writeln!(out, "{fence}json\n{json}\n{fence}\n"); } -// Widened to `pub(super)` so `/structcopy` (#2033) reuses this exact seam -// instead of copying it. -pub(super) fn redact_json(value: &mut Value, key: Option<&str>) { - if key.is_some_and(is_sensitive_key) { - *value = Value::String("[redacted]".to_string()); - return; - } - match value { - Value::String(text) => *text = sanitize_text(text), - Value::Array(items) => { - for item in items { - redact_json(item, None); - } - } - Value::Object(map) => { - for (key, value) in map { - redact_json(value, Some(key)); - } - } - Value::Null | Value::Bool(_) | Value::Number(_) => {} - } -} - -// Widened to `pub(super)` so `/structcopy` can classify a key again after -// removing control/ANSI obfuscation. Classification before and after -// normalization keeps the shared sensitive-key vocabulary authoritative. -pub(super) fn is_sensitive_key(key: &str) -> bool { - let normalized = key - .trim() - .trim_matches(['\'', '"']) - .replace(['-', '.', ' '], "_") - .to_ascii_lowercase(); - [ - "api_key", - "apikey", - "secret", - "token", - "password", - "passwd", - "authorization", - "access_key", - "client_secret", - "private_key", - "cookie", - "session_key", - ] - .iter() - .any(|hint| normalized.contains(hint)) -} - -// Widened to `pub(super)` so `/structcopy` (#2033) reuses this exact seam -// instead of copying it. -pub(super) fn sanitize_text(input: &str) -> String { - let mut visible = String::with_capacity(input.len()); - crate::tui::osc8::strip_ansi_into(input, &mut visible); - let visible = visible.replace("\r\n", "\n").replace('\r', "\n"); - let visible: String = visible - .chars() - .filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control()) - .collect(); - let private_keys = private_key_regex().replace_all(&visible, "[redacted private key]"); - let bearer = bearer_regex().replace_all(&private_keys, "Bearer [redacted]"); - let jwt = jwt_regex().replace_all(&bearer, "[redacted token]"); - let urls = url_regex().replace_all(&jwt, |captures: ®ex::Captures<'_>| { - redact_url_match(captures.get(0).map_or("", |value| value.as_str())) - }); - codewhale_config::persistence::redact_secrets(&urls) -} - -fn redact_url_match(raw: &str) -> String { - let trimmed = raw.trim_end_matches(['.', ',', ';', '!']); - let suffix = &raw[trimmed.len()..]; - format!( - "{}{}", - crate::client::redact_url_for_display(trimmed), - suffix - ) -} - -fn inline_text(input: &str) -> String { - sanitize_text(input) - .split_whitespace() - .collect::>() - .join(" ") - .replace('`', "'") -} - -// Widened to `pub(super)` so `/structcopy` (#2033) reuses this exact seam -// instead of copying it. -pub(super) fn is_internal_role(role: &str) -> bool { - matches!( - role.trim().to_ascii_lowercase().as_str(), - "system" | "developer" | "internal" - ) -} - fn markdown_fence(content: &str) -> String { let longest = content .split(|ch| ch != '`') @@ -768,803 +595,983 @@ fn markdown_fence(content: &str) -> String { "`".repeat(longest.saturating_add(1).max(3)) } -fn private_key_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new( - r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----", - ) - .expect("private-key redaction regex") - }) -} - -fn bearer_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"(?i)\bbearer\s+[a-z0-9._~+/=-]{6,}").expect("bearer redaction regex") - }) -} - -fn jwt_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r"\beyJ[a-zA-Z0-9_-]{5,}\.[a-zA-Z0-9_-]{5,}(?:\.[a-zA-Z0-9_-]{5,})?\b") - .expect("JWT redaction regex") - }) -} - -fn url_regex() -> &'static Regex { - static RE: OnceLock = OnceLock::new(); - RE.get_or_init(|| { - Regex::new(r#"https?://[^\s<>\"'`\]\[\)\(\}\{]+"#).expect("URL redaction regex") - }) -} - -fn sanitize_turn_handoff(app: &App, markdown: &str) -> String { - let sanitized = sanitize_text(markdown); - let workspace = app.workspace.to_string_lossy(); - if workspace.is_empty() { +fn sanitize_turn_handoff(projection: &TurnHandoffProjection) -> String { + let sanitized = sanitize_text(&projection.markdown); + if projection.workspace_path.is_empty() { sanitized } else { - sanitized.replace(workspace.as_ref(), ".") + sanitized.replace(&projection.workspace_path, ".") } } -fn resolve_export_path(workspace: &Path, raw: &str) -> Result { - let raw = raw.trim(); - if raw.is_empty() { - return Err("export path is empty".to_string()); - } - let requested = PathBuf::from(raw); - if requested - .components() - .any(|component| component == Component::ParentDir) - { - return Err( - "export paths may not contain `..`; use an explicit normalized absolute path instead" - .to_string(), - ); +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::path::Path; + + /// Minimal fake facet: every delegate is deterministic and records calls. + struct FakeExport { + conversation: ConversationExportProjection, + turn: TurnHandoffProjection, + terminal_paste: bool, + recovery: Option, + clipboard: Result<(), String>, + resolve: Result, + write: Result<(), String>, + calls: RefCell>, } - // Resolve the trusted workspace root once so platform aliases such as - // macOS `/var -> /private/var` do not make every workspace-relative - // export look like it traverses a user-controlled symlink. Requested - // components beneath that root remain lexical and are checked below. - let resolved_workspace = - fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); - let path = if requested.is_absolute() { - if let Ok(relative) = requested.strip_prefix(workspace) { - resolved_workspace.join(relative) - } else if let Ok(relative) = requested.strip_prefix(&resolved_workspace) { - resolved_workspace.join(relative) - } else { - requested + + impl Default for FakeExport { + fn default() -> Self { + Self { + conversation: conversation_projection(vec![]), + turn: TurnHandoffProjection { + markdown: String::new(), + workspace_path: String::new(), + }, + terminal_paste: false, + recovery: None, + clipboard: Ok(()), + resolve: Ok(PathBuf::from("/resolved/out.md")), + write: Ok(()), + calls: RefCell::new(Vec::new()), + } } - } else { - resolved_workspace.join(requested) - }; - if path.file_name().is_none() { - return Err(format!("export path must name a file: {}", path.display())); } - Ok(path) -} -fn write_export_file(path: &Path, contents: &[u8], force: bool) -> Result<(), String> { - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .ok_or_else(|| format!("path has no parent directory: {}", path.display()))?; - let parent_metadata = fs::metadata(parent).map_err(|err| { - format!( - "parent directory {} is unavailable: {err}", - parent.display() - ) - })?; - if !parent_metadata.is_dir() { - return Err(format!("parent is not a directory: {}", parent.display())); - } - reject_symlink_components(path)?; + impl CommandSessionExportContext for FakeExport { + fn conversation_projection(&self) -> ConversationExportProjection { + self.calls + .borrow_mut() + .push("conversation_projection".to_string()); + self.conversation.clone() + } - match fs::symlink_metadata(path) { - Ok(_) if !force => { - return Err(format!( - "destination already exists: {}. Re-run with `/export file --force ` to replace it", - path.display() - )); + fn turn_handoff_projection(&self) -> TurnHandoffProjection { + self.calls + .borrow_mut() + .push("turn_handoff_projection".to_string()); + self.turn.clone() } - Ok(metadata) if !metadata.file_type().is_file() => { - return Err(format!( - "refusing to replace a non-regular file: {}", - path.display() - )); + + fn clipboard_requires_terminal_paste(&self) -> bool { + self.calls + .borrow_mut() + .push("clipboard_requires_terminal_paste".to_string()); + self.terminal_paste } - Ok(_) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(format!("could not inspect {}: {err}", path.display())), - } - if force { - crate::utils::write_atomic(path, contents).map_err(|err| err.to_string())?; - set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))?; - return Ok(()); - } + fn write_recovery_copy(&self, markdown: &str) -> Option { + self.calls + .borrow_mut() + .push(format!("write_recovery_copy({markdown})")); + self.recovery.clone() + } - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(path).map_err(|err| { - if err.kind() == std::io::ErrorKind::AlreadyExists { - format!( - "destination already exists: {}. Re-run with `/export file --force ` to replace it", - path.display() - ) - } else { - err.to_string() + fn write_clipboard(&self, markdown: &str) -> Result<(), String> { + self.calls + .borrow_mut() + .push(format!("write_clipboard({markdown})")); + self.clipboard.clone() } - })?; - if let Err(err) = file.write_all(contents).and_then(|()| file.sync_all()) { - drop(file); - let _ = fs::remove_file(path); - return Err(err.to_string()); - } - set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}")) -} -fn reject_symlink_components(path: &Path) -> Result<(), String> { - for component_path in path.ancestors() { - match fs::symlink_metadata(component_path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(format!( - "refusing export through symlink component: {}", - component_path.display() - )); - } - Ok(_) => {} - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => { - return Err(format!( - "could not inspect path component {}: {err}", - component_path.display() - )); - } + fn resolve_export_path(&self, raw: &str) -> Result { + self.calls + .borrow_mut() + .push(format!("resolve_export_path({raw})")); + self.resolve.clone() + } + + fn write_export_file( + &self, + path: &Path, + contents: &[u8], + force: bool, + ) -> Result<(), String> { + self.calls.borrow_mut().push(format!( + "write_export_file({}, {}, {force})", + path.display(), + String::from_utf8_lossy(contents) + )); + self.write.clone() } } - Ok(()) -} -#[cfg(unix)] -fn set_owner_only(path: &Path) -> std::io::Result<()> { - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(path, fs::Permissions::from_mode(0o600)) -} + fn conversation_projection(messages: Vec) -> ConversationExportProjection { + ConversationExportProjection { + metadata: codewhale_command_contract::facets::ExportMetadata { + session_label: "sess12345678".to_string(), + provider: "deepseek".to_string(), + model: "deepseek-v4".to_string(), + mode: "agent".to_string(), + workspace_name: "workspace".to_string(), + message_count: messages.len(), + exported_at_unix: 1_700_000_000, + }, + transcript: TranscriptProjection::Authoritative(messages), + restore_points: RestorePointProjection::None, + } + } -#[cfg(not(unix))] -fn set_owner_only(_path: &Path) -> std::io::Result<()> { - Ok(()) -} + fn user_message(text: &str) -> ExportMessage { + ExportMessage { + is_user_role: true, + role: "user".to_string(), + blocks: vec![ExportBlock::Text { + text: text.to_string(), + }], + prompt_snippet: Some(text.to_string()), + } + } -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use crate::tui::clipboard::ClipboardHandler; - use codewhale_models::{ImageUrlContent, ToolCaller}; - use tempfile::TempDir; - - fn test_app(tmpdir: &TempDir) -> App { - let options = TuiOptions { - skills_dir: tmpdir.path().join("skills"), - memory_path: tmpdir.path().join("memory.md"), - notes_path: tmpdir.path().join("notes.txt"), - mcp_config_path: tmpdir.path().join("mcp.json"), - ..crate::test_support::test_tui_options(tmpdir.path()) - }; - App::new(options, &Config::default()) + fn snapshot( + id: &str, + label: &str, + timestamp: i64, + kind: &str, + sequence: Option, + ) -> RestoreSnapshot { + RestoreSnapshot { + id: id.to_string(), + label: label.to_string(), + timestamp_unix: timestamp, + kind: kind.to_string(), + sequence, + prompt_snippet: label + .split_once(": ") + .map(|(_, snippet)| snippet.to_string()), + } } #[test] - fn last_copy_writes_the_export_without_leaving_a_temp_artifact() { - let tmp = TempDir::new().expect("tempdir"); - let dir = tmp.path().join("exports"); - let path = write_last_copy_to(&dir, "# export\n\nhello\n").expect("write"); - assert_eq!(path, dir.join("last-copy.md")); + fn parser_matrix_is_exact() { assert_eq!( - std::fs::read_to_string(&path).expect("read"), - "# export\n\nhello\n" + parse_request(None).unwrap(), + ExportRequest { + scope: ExportScope::Conversation, + destination: ExportDestination::Clipboard, + } ); - // The next export overwrites the same predictable path. - write_last_copy_to(&dir, "# second\n").expect("rewrite"); - assert_eq!(std::fs::read_to_string(&path).expect("read"), "# second\n"); assert_eq!( - std::fs::read_dir(&dir).expect("read exports").count(), - 1, - "the atomic writer must not leave a temp artifact" + parse_request(Some("clipboard")).unwrap().scope, + ExportScope::Conversation ); - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&path) - .expect("metadata") - .permissions() - .mode(); - assert_eq!(mode & 0o077, 0, "recovery copy must remain private"); - } - } - - #[test] - fn last_copy_stays_inside_an_explicit_codewhale_home() { - let ambient = TempDir::new().expect("ambient home"); - let isolated = TempDir::new().expect("isolated Codewhale home"); - let _env_lock = crate::test_support::lock_test_env(); - let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path()); - let _codewhale_home = - crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path()); - - let path = write_last_copy("isolated response").expect("recovery copy"); - - assert_eq!(path, isolated.path().join("exports/last-copy.md")); assert_eq!( - std::fs::read_to_string(&path).expect("read recovery copy"), - "isolated response" + parse_request(Some("TURN")).unwrap().scope, + ExportScope::Turn ); - assert!( - !ambient.path().join("exports/last-copy.md").exists(), - "explicit CODEWHALE_HOME must prevent ambient-home writes" - ); - } - - #[cfg(unix)] - #[test] - fn last_copy_refuses_an_exports_symlink_outside_codewhale_home() { - use std::os::unix::fs::symlink; - - let ambient = TempDir::new().expect("ambient home"); - let isolated = TempDir::new().expect("isolated Codewhale home"); - let external = TempDir::new().expect("external dir"); - symlink(external.path(), isolated.path().join("exports")).expect("exports symlink"); - let _env_lock = crate::test_support::lock_test_env(); - let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path()); - let _codewhale_home = - crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path()); - - assert_eq!(write_last_copy("must stay isolated"), None); - assert!( - !external.path().join("last-copy.md").exists(), - "recovery content must not escape through a nested symlink" + assert_eq!( + parse_request(Some("file --force reports/chat export.md")).unwrap(), + ExportRequest { + scope: ExportScope::Conversation, + destination: ExportDestination::File { + path: "reports/chat export.md".to_string(), + force: true, + }, + } + ); + assert_eq!( + parse_request(Some("legacy export.md")).unwrap(), + ExportRequest { + scope: ExportScope::Conversation, + destination: ExportDestination::File { + path: "legacy export.md".to_string(), + force: false, + }, + } + ); + assert_eq!( + parse_request(Some("turn file --force handoff.md")).unwrap(), + ExportRequest { + scope: ExportScope::Turn, + destination: ExportDestination::File { + path: "handoff.md".to_string(), + force: true, + }, + } + ); + assert_eq!( + parse_request(Some("turn legacy.md")).unwrap(), + ExportRequest { + scope: ExportScope::Turn, + destination: ExportDestination::File { + path: "legacy.md".to_string(), + force: false, + }, + } + ); + assert_eq!( + parse_request(Some("turn clipboard")).unwrap().destination, + ExportDestination::Clipboard ); - } - - #[test] - fn default_clipboard_export_preserves_structure_and_redacts_secrets() { - let tmpdir = TempDir::new().expect("tempdir"); - let mut app = test_app(&tmpdir); - app.current_session_id = Some("session-123456789".to_string()); - app.api_messages = vec![ - Message { - role: Role::System, - content: vec![ContentBlock::Text { - text: "hidden policy must never export".to_string(), - cache_control: None, - }], - }, - Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: "Please inspect this\u{1b}[31m output\u{1b}[0m".to_string(), - cache_control: None, - }], - }, - Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Thinking { - thinking: "private chain of thought".to_string(), - signature: Some("signature-secret".to_string()), - state: None, - }, - ContentBlock::ToolUse { - id: "call-1".to_string(), - name: "fetch_url".to_string(), - input: serde_json::json!({ - "url": "https://alice:password@example.com/path?token=very-secret&ok=1", - "api_key": "literal-api-secret", - "nested": {"authorization": "Bearer abcdefghijklmnop"}, - }), - caller: Some(ToolCaller { - caller_type: "code_execution_20250825".to_string(), - tool_id: Some("server-tool-1".to_string()), - }), - thought_signature: None, - }, - ], - }, - Message { - role: Role::User, - content: vec![ContentBlock::ToolResult { - tool_use_id: "call-1".to_string(), - content: "Authorization: Bearer another-secret-token\nresult ok".to_string(), - is_error: Some(false), - content_blocks: Some(vec![serde_json::json!({ - "image": "https://example.com/a.png?api_key=hidden", - "session_token": "session-secret", - })]), - }], - }, - Message { - role: Role::Assistant, - content: vec![ContentBlock::ImageUrl { - image_url: ImageUrlContent { - url: "data:image/png;base64,very-secret-image-data".to_string(), - }, - }], - }, - ]; - { - let mut todos = app.todos.try_lock().expect("todos lock"); - todos.add( - "export projection".to_string(), - crate::tools::todo::TodoStatus::InProgress, - ); - } - app.cycle_effort(); - let work_before = app.work_state_snapshot().expect("Work snapshot"); - - let result = execute_export(&mut app, None); - assert!(!result.is_error, "{:?}", result.message); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("local clipboard") - ); - let markdown = app - .clipboard - .last_written_text() - .expect("clipboard payload"); - let system = markdown.find("## 1. system").expect("system role"); - let user = markdown.find("## 2. user").expect("user role"); - let assistant = markdown.find("## 3. assistant").expect("assistant role"); - let tool_result = markdown.find("## 4. user").expect("tool-result role"); - assert!(system < user && user < assistant && assistant < tool_result); - assert!(markdown.contains("[internal context omitted]")); - assert!(markdown.contains("call-1")); - assert!(markdown.contains("fetch_url")); - assert!(markdown.contains("server-tool-1")); - assert!(markdown.contains("[internal reasoning and signature omitted]")); - assert!(markdown.contains("[redacted]")); - assert!(markdown.contains("https://***:***@example.com/path?token=***&ok=1")); - assert!(markdown.contains("Reference omitted (inline or local image payload)")); - let workspace_path = tmpdir.path().to_string_lossy().into_owned(); - for forbidden in [ - "hidden policy must never export", - "private chain of thought", - "signature-secret", - "literal-api-secret", - "very-secret", - "another-secret-token", - "session-secret", - "very-secret-image-data", - "\u{1b}[31m", - workspace_path.as_str(), + for arg in [ + "file", + "file --force", + "clipboard extra.md", + "turn clipboard extra.md", ] { - assert!( - !markdown.contains(forbidden), - "leaked {forbidden:?}: {markdown}" - ); + assert!(parse_request(Some(arg)).is_err(), "{arg}"); } assert_eq!( - app.work_state_snapshot() - .expect("Work snapshot after export"), - work_before, - "export must not mutate Work" + parse_request(Some("file")).unwrap_err(), + export_usage("missing file path") + ); + assert_eq!( + parse_request(Some("clipboard extra.md")).unwrap_err(), + export_usage("clipboard does not accept a path") + ); + } + + #[test] + fn missing_authority_fails_safely_without_effects() { + let result = export_contextual(CommandContexts::empty(), Some("clipboard")); + assert!(result.is_error); + assert_eq!( + result.message.as_deref(), + Some("Error: Command capability unavailable: session_export") ); } #[test] - fn clipboard_export_reports_ssh_terminal_client_and_failure_honestly() { - let tmpdir = TempDir::new().expect("tempdir"); - // Seal both ambient and product homes so the backup cannot escape the - // test sandbox even when the outer process has CODEWHALE_HOME set. - let _env_lock = crate::test_support::lock_test_env(); - let test_home = tmpdir.path().join("home"); - std::fs::create_dir_all(&test_home).expect("home dir"); - let _home = crate::test_support::EnvVarGuard::set("HOME", &test_home); - let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &test_home); - let mut app = test_app(&tmpdir); - app.clipboard = ClipboardHandler::for_test(true, true); - let ssh = execute_export(&mut app, Some("clipboard")); - assert!(!ssh.is_error, "{:?}", ssh.message); + fn conversation_clipboard_renders_full_document_and_sequences_operations() { + let fake = FakeExport { + conversation: conversation_projection(vec![ + ExportMessage { + is_user_role: false, + role: "system".to_string(), + blocks: vec![ExportBlock::Text { + text: "hidden policy must never export".to_string(), + }], + prompt_snippet: None, + }, + user_message("Please inspect this"), + ]), + recovery: Some(PathBuf::from("/home/.codewhale/exports/last-copy.md")), + ..FakeExport::default() + }; + + let result = export_portable(&fake, Some("clipboard")); + + assert!(!result.is_error, "{:?}", result.message); + let message = result.message.as_deref().unwrap_or_default(); assert!( - ssh.message - .as_deref() - .unwrap_or_default() - .contains("terminal-client clipboard over SSH") + message.starts_with("Conversation copied to the local clipboard ("), + "{message}" ); assert!( - ssh.message - .as_deref() - .unwrap_or_default() - .contains("last-copy.md"), - "success must name the backup copy: {:?}", - ssh.message - ); - - app.clipboard = ClipboardHandler::unavailable_for_test(false); - let failed = execute_export(&mut app, Some("clipboard")); - assert!(failed.is_error); - let message = failed.message.as_deref().unwrap_or_default(); + message.contains(" lines; a terminal clipboard fallback may have been used)"), + "{message}" + ); assert!( - message.contains("The full export was written to"), + message.ends_with("; a copy is at /home/.codewhale/exports/last-copy.md"), "{message}" ); - assert!(message.contains("last-copy.md"), "{message}"); - assert!(message.contains("/export file "), "{message}"); - assert!(!tmpdir.path().join("chat_export.md").exists()); - assert!(test_home.join("exports/last-copy.md").exists()); + let read = fake.calls.borrow(); + assert_eq!(read[0], "conversation_projection"); + assert_eq!(read[1], "clipboard_requires_terminal_paste"); + assert!(read[2].starts_with("write_recovery_copy("), "{read:?}"); + assert!(read[3].starts_with("write_clipboard("), "{read:?}"); + assert_eq!(read.len(), 4, "exactly one recovery and one clipboard call"); + let recovery_payload = read[2] + .trim_start_matches("write_recovery_copy(") + .trim_end_matches(')'); + let markdown = read[3] + .trim_start_matches("write_clipboard(") + .trim_end_matches(')'); + assert_eq!( + recovery_payload, markdown, + "both writes receive identical Markdown" + ); + assert!(markdown.starts_with("# Codewhale conversation export\n\n")); + assert!(markdown.contains("## 1. system\n\n[internal context omitted]\n\n")); + assert!(markdown.contains("## 2. user\n\n### Content 1: Text\n\nPlease inspect this\n\n")); + assert!(!markdown.contains("hidden policy must never export")); } #[test] - fn file_export_is_workspace_relative_private_and_no_overwrite_by_default() { - let tmpdir = TempDir::new().expect("tempdir"); - let mut app = test_app(&tmpdir); - app.api_messages.push(Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: "first export".to_string(), - cache_control: None, - }], - }); + fn conversation_document_matches_full_golden_equality() { + let projection = conversation_projection(vec![ + ExportMessage { + is_user_role: false, + role: "system".to_string(), + blocks: vec![ExportBlock::Text { + text: "hidden policy must never export".to_string(), + }], + prompt_snippet: None, + }, + user_message("Please inspect this"), + ]); - let first = execute_export(&mut app, Some("file transcript.md")); - assert!(!first.is_error, "{:?}", first.message); - let path = tmpdir.path().join("transcript.md"); - let original = fs::read_to_string(&path).expect("first export"); - assert!(original.contains("first export")); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - assert_eq!( - fs::metadata(&path).unwrap().permissions().mode() & 0o777, - 0o600 - ); - } + let expected = "# Codewhale conversation export\n\n\ +- Exported: 2023-11-14T22:13:20Z\n\ +- Session: sess12345678\n\ +- Provider: deepseek\n\ +- Model: deepseek-v4\n\ +- Mode: agent\n\ +- Workspace: workspace\n\ +- Messages: 2\n\n\ +> Hidden instructions, internal reasoning, and reasoning signatures are omitted. Secret-like values and credential-bearing URLs are redacted as a defense in depth; review the export before sharing it.\n\n\ +## Restore points\n\n\ +No workspace restore points are recorded for this workspace, so nothing in this export can be correlated to a restorable workspace state. Snapshots may be disabled, or no turn has taken one yet.\n\n\ +## 1. system\n\n[internal context omitted]\n\n\ +## 2. user\n\n### Content 1: Text\n\nPlease inspect this\n\n"; + + assert_eq!(render_conversation(projection), expected); + } + + #[test] + fn ssh_clipboard_uses_terminal_client_wording() { + let fake = FakeExport { + terminal_paste: true, + conversation: conversation_projection(vec![user_message("hi")]), + ..FakeExport::default() + }; - app.api_messages[0].content = vec![ContentBlock::Text { - text: "replacement export".to_string(), - cache_control: None, - }]; - let refused = execute_export(&mut app, Some("transcript.md")); - assert!(refused.is_error); - assert_eq!(fs::read_to_string(&path).unwrap(), original); + let result = export_portable(&fake, Some("clipboard")); - let forced = execute_export(&mut app, Some("file --force transcript.md")); - assert!(!forced.is_error, "{:?}", forced.message); + assert!(!result.is_error); + let message = result.message.as_deref().unwrap_or_default(); assert!( - fs::read_to_string(&path) - .unwrap() - .contains("replacement export") + message.contains("terminal-client clipboard over SSH via tmux/OSC 52"), + "{message}" + ); + assert!( + !message.contains("a copy is at"), + "no recovery path present: {message}" ); } #[test] - fn file_export_rejects_traversal_missing_parent_and_invalid_usage() { - let tmpdir = TempDir::new().expect("tempdir"); - let mut app = test_app(&tmpdir); - for arg in [ - "file ../outside.md", - "file missing/export.md", - "file", - "file --force", - "clipboard extra.md", - "turn clipboard extra.md", - ] { - let result = execute_export(&mut app, Some(arg)); - assert!(result.is_error, "{arg}: {:?}", result.message); - } - assert!(!tmpdir.path().join("outside.md").exists()); - } + fn recovery_failure_still_attempts_clipboard() { + let fake = FakeExport { + conversation: conversation_projection(vec![user_message("hi")]), + recovery: None, + clipboard: Err("no clipboard".to_string()), + ..FakeExport::default() + }; - #[cfg(unix)] - #[test] - fn file_export_rejects_symlink_leaf_and_ancestor() { - use std::os::unix::fs::symlink; - - let tmpdir = TempDir::new().expect("tempdir"); - let mut app = test_app(&tmpdir); - let real_file = tmpdir.path().join("real.md"); - fs::write(&real_file, "keep").expect("fixture file"); - let leaf = tmpdir.path().join("leaf.md"); - symlink(&real_file, &leaf).expect("leaf symlink"); - let leaf_result = - execute_export(&mut app, Some(&format!("file --force {}", leaf.display()))); - assert!(leaf_result.is_error, "{:?}", leaf_result.message); - assert_eq!(fs::read_to_string(&real_file).unwrap(), "keep"); - - let real_dir = tmpdir.path().join("real-dir"); - fs::create_dir(&real_dir).expect("real dir"); - let linked_dir = tmpdir.path().join("linked-dir"); - symlink(&real_dir, &linked_dir).expect("dir symlink"); - let ancestor_result = execute_export( - &mut app, - Some(&format!("file {}", linked_dir.join("out.md").display())), - ); - assert!(ancestor_result.is_error, "{:?}", ancestor_result.message); - assert!(!real_dir.join("out.md").exists()); + let result = export_portable(&fake, Some("clipboard")); + + assert!(result.is_error); + assert_eq!( + result.message.as_deref(), + Some( + "Error: Clipboard export failed: no clipboard. No file was written; use `/export file ` to choose an explicit destination" + ) + ); + let read = fake.calls.borrow(); + assert!( + read.iter() + .any(|call| call.starts_with("write_recovery_copy(")) + ); + assert!(read.iter().any(|call| call.starts_with("write_clipboard("))); } #[test] - fn turn_export_supports_clipboard_and_safe_legacy_file_destination() { - let tmpdir = TempDir::new().expect("tempdir"); - let mut app = test_app(&tmpdir); - app.history.push(HistoryCell::User { - content: "Fix the flaky login test".to_string(), - }); - app.history.push(HistoryCell::Assistant { - content: "Fixed the login test.".to_string(), - streaming: false, - }); - app.runtime_turn_status = Some("completed".to_string()); + fn file_export_renders_resolves_then_writes_once() { + let fake = FakeExport { + conversation: conversation_projection(vec![user_message("first export")]), + resolve: Ok(PathBuf::from("/workspace/transcript.md")), + write: Ok(()), + ..FakeExport::default() + }; + + let result = export_portable(&fake, Some("file transcript.md")); - let clipboard = execute_export(&mut app, Some("turn")); - assert!(!clipboard.is_error, "{:?}", clipboard.message); + assert!(!result.is_error, "{:?}", result.message); + assert_eq!( + result.message.as_deref(), + Some("Conversation exported to /workspace/transcript.md") + ); + let read = fake.calls.borrow(); + assert_eq!(read.len(), 3, "{read:?}"); + assert_eq!(read[0], "conversation_projection"); + assert_eq!(read[1], "resolve_export_path(transcript.md)"); + assert!(read[2].starts_with("write_export_file(/workspace/transcript.md,")); assert!( - app.clipboard - .last_written_text() - .unwrap_or_default() - .contains("# Turn handoff") + !read + .iter() + .any(|call| call.contains("clipboard") || call.contains("recovery")), + "file export must not touch clipboard or recovery: {read:?}" ); + } + + #[test] + fn resolution_failure_prevents_writing() { + let fake = FakeExport { + conversation: conversation_projection(vec![user_message("x")]), + resolve: Err("export paths may not contain `..`".to_string()), + ..FakeExport::default() + }; - let path = tmpdir.path().join("handoff.md"); - let file = execute_export(&mut app, Some(&format!("turn {}", path.display()))); - assert!(!file.is_error, "{:?}", file.message); + let result = export_portable(&fake, Some("file ../escape.md")); + + assert!(result.is_error); + assert_eq!( + result.message.as_deref(), + Some("Error: export paths may not contain `..`") + ); + let read = fake.calls.borrow(); assert!( - fs::read_to_string(&path) - .unwrap() - .contains("Fix the flaky login test") + !read + .iter() + .any(|call| call.starts_with("write_export_file(")), + "no write after resolution failure: {read:?}" ); - let refused = execute_export(&mut app, Some(&format!("turn {}", path.display()))); - assert!(refused.is_error); } #[test] - fn parser_keeps_paths_with_spaces_and_legacy_forms() { + fn write_failure_wraps_exact_baseline_text() { + let fake = FakeExport { + conversation: conversation_projection(vec![user_message("x")]), + resolve: Ok(PathBuf::from("/workspace/out.md")), + write: Err("destination already exists: /workspace/out.md".to_string()), + ..FakeExport::default() + }; + + let result = export_portable(&fake, Some("file out.md")); + + assert!(result.is_error); assert_eq!( - parse_request(Some("file --force reports/chat export.md")).unwrap(), - ExportRequest { - scope: ExportScope::Conversation, - destination: ExportDestination::File { - path: "reports/chat export.md".to_string(), - force: true, - }, - } + result.message.as_deref(), + Some( + "Error: Failed to export Conversation to /workspace/out.md: destination already exists: /workspace/out.md" + ) ); + } + + #[test] + fn forced_file_export_reports_overwrite_suffix() { + let fake = FakeExport { + conversation: conversation_projection(vec![user_message("x")]), + resolve: Ok(PathBuf::from("/workspace/out.md")), + ..FakeExport::default() + }; + + let result = export_portable(&fake, Some("file --force out.md")); + assert_eq!( - parse_request(Some("legacy export.md")).unwrap(), - ExportRequest { - scope: ExportScope::Conversation, - destination: ExportDestination::File { - path: "legacy export.md".to_string(), - force: false, - }, - } + result.message.as_deref(), + Some("Conversation exported to /workspace/out.md (overwrite explicitly allowed)") ); + let read = fake.calls.borrow(); + assert!(read[2].ends_with(", true)"), "{:?}", read[2]); } - fn snapshot(id: &str, label: &str, timestamp: i64) -> crate::snapshot::Snapshot { - crate::snapshot::Snapshot { - id: crate::snapshot::SnapshotId(id.to_string()), - label: label.to_string(), - timestamp, - session_id: None, - } + #[test] + fn turn_export_sanitizes_then_replaces_nonempty_workspace() { + let fake = FakeExport { + turn: TurnHandoffProjection { + markdown: "# Turn handoff\n\n\u{1b}[31m/Users/me/repo\u{1b}[0m done".to_string(), + workspace_path: "/Users/me/repo".to_string(), + }, + ..FakeExport::default() + }; + + let result = export_portable(&fake, Some("turn")); + + assert!(!result.is_error); + let read = fake.calls.borrow(); + let markdown = read + .iter() + .find_map(|call| { + call.strip_prefix("write_clipboard(") + .map(|c| c.trim_end_matches(')')) + }) + .expect("clipboard payload"); + assert_eq!(markdown, "# Turn handoff\n\n. done"); + assert!( + !read + .iter() + .any(|call| call.starts_with("conversation_projection")), + "turn-only export must not read conversation snapshots: {read:?}" + ); } - fn user_message(text: &str) -> Message { - Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: text.to_string(), - cache_control: None, - }], - } + #[test] + fn turn_export_with_empty_workspace_path_skips_replacement() { + let fake = FakeExport { + turn: TurnHandoffProjection { + markdown: "path stays".to_string(), + workspace_path: String::new(), + }, + ..FakeExport::default() + }; + assert_eq!(sanitize_turn_handoff(&fake.turn), "path stays"); } #[test] - fn restore_point_summary_lists_each_point_with_its_restore_index() { - let points = RestorePoints::Recorded(vec![ - snapshot( - "a".repeat(40).as_str(), - "pre-turn:2: second prompt", - 1_700_000_100, - ), - snapshot( - "b".repeat(40).as_str(), - "pre-turn:1: first prompt", - 1_700_000_000, - ), - ]); - let mut out = String::new(); - points.render_summary(&mut out); + fn restore_summary_distinguishes_every_state() { + let mut none = String::new(); + render_restore_summary(&mut none, &RestorePointProjection::None); + assert!( + none.contains("No workspace restore points are recorded"), + "{none}" + ); + + let mut unreadable = String::new(); + render_restore_summary( + &mut unreadable, + &RestorePointProjection::Unreadable { + reason: "permission denied".to_string(), + }, + ); + assert!(unreadable.contains("could not be read"), "{unreadable}"); + assert!( + unreadable.contains("unavailable rather than empty"), + "{unreadable}" + ); - assert!(out.contains("## Restore points"), "{out}"); + let mut empty = String::new(); + render_restore_summary( + &mut empty, + &RestorePointProjection::Recorded { + snapshots: Vec::new(), + }, + ); + assert!(empty.contains("records no restore points yet"), "{empty}"); + + let mut recorded = String::new(); + render_restore_summary( + &mut recorded, + &RestorePointProjection::Recorded { + snapshots: vec![ + snapshot( + &"a".repeat(40), + "pre-turn:2: second prompt", + 1_700_000_100, + "pre-turn", + Some(2), + ), + snapshot( + &"b".repeat(40), + "pre-turn:1: first prompt", + 1_700_000_000, + "pre-turn", + Some(1), + ), + ], + }, + ); assert!( - out.contains( + recorded.contains( "| 1 | `aaaaaaaaaaaa` | 2023-11-14T22:15:00Z | pre-turn:2: second prompt |" ), - "newest point must be restore index 1: {out}" + "{recorded}" ); assert!( - out.contains( + recorded.contains( "| 2 | `bbbbbbbbbbbb` | 2023-11-14T22:13:20Z | pre-turn:1: first prompt |" ), - "{out}" + "{recorded}" ); + } + + #[test] + fn correlation_matches_ambiguity_absence_and_role_rules() { + let recorded = RestorePointProjection::Recorded { + snapshots: vec![ + snapshot( + &"e".repeat(40), + "pre-turn:9: run the tests", + 1_700_000_300, + "pre-turn", + Some(9), + ), + snapshot( + &"f".repeat(40), + "pre-turn:5: run the tests", + 1_700_000_100, + "pre-turn", + Some(5), + ), + snapshot( + &"2".repeat(40), + "tool:call_abc: rename the widget", + 1_700_000_000, + "tool", + None, + ), + ], + }; + + let ambiguous = correlation_markdown(&recorded, &user_message("run the tests")); assert!( - out.contains("`/restore `"), - "the export must name the command that consumes the index: {out}" + ambiguous.contains("N1 `eeeeeeeeeeee` (pre-turn turn 9)"), + "{ambiguous}" ); assert!( - out.contains("position at export time"), - "the index is only valid until the next snapshot; say so: {out}" + ambiguous.contains("N2 `ffffffffffff` (pre-turn turn 5)"), + "{ambiguous}" ); - } + assert!(ambiguous.contains("ambiguous"), "{ambiguous}"); - #[test] - fn user_message_correlates_to_its_own_restore_point() { - let points = RestorePoints::Recorded(vec![ - snapshot( - "c".repeat(40).as_str(), - "pre-turn:4: rename the widget", - 1_700_000_200, - ), - snapshot( - "d".repeat(40).as_str(), - "pre-turn:3: unrelated prompt", - 1_700_000_100, - ), - ]); - let mut out = String::new(); - points.render_correlation(&mut out, &user_message("rename the widget\ndetail line")); + let none = correlation_markdown(&recorded, &user_message("never snapshotted")); + assert!(none.contains("none recorded for this message"), "{none}"); + let tool_only = correlation_markdown(&recorded, &user_message("rename the widget")); assert!( - out.contains("- Restore points: N1 `cccccccccccc` (pre-turn turn 4)"), - "{out}" + tool_only.contains("none recorded for this message"), + "{tool_only}" ); - assert!( - !out.contains("dddddddddddd"), - "an unrelated prompt must not be correlated: {out}" + + let assistant = correlation_markdown( + &recorded, + &ExportMessage { + is_user_role: false, + role: "assistant".to_string(), + blocks: vec![ExportBlock::Text { + text: "run the tests".to_string(), + }], + prompt_snippet: Some("run the tests".to_string()), + }, ); + assert!(assistant.is_empty(), "{assistant}"); } #[test] - fn repeated_prompts_are_reported_as_ambiguous_rather_than_guessed() { - let points = RestorePoints::Recorded(vec![ - snapshot( - "e".repeat(40).as_str(), - "pre-turn:9: run the tests", - 1_700_000_300, - ), - snapshot( - "f".repeat(40).as_str(), - "pre-turn:5: run the tests", - 1_700_000_100, - ), - ]); - let mut out = String::new(); - points.render_correlation(&mut out, &user_message("run the tests")); + fn correlation_requires_exact_user_identity_not_the_role_string() { + let recorded = RestorePointProjection::Recorded { + snapshots: vec![snapshot( + "aaaaaaaaaaaa", + "pre-turn:4: run the tests", + 5, + "pre-turn", + Some(4), + )], + }; + // Same rendered role string as a real user turn, but not `Role::User`. + let unrecognized = ExportMessage { + role: "user".to_string(), + is_user_role: false, + blocks: vec![ExportBlock::Text { + text: "run the tests".to_string(), + }], + prompt_snippet: Some("run the tests".to_string()), + }; + assert!( + correlation_markdown(&recorded, &unrecognized).is_empty(), + "a role that only renders as \"user\" must not correlate" + ); - assert!(out.contains("N1 `eeeeeeeeeeee` (pre-turn turn 9)"), "{out}"); - assert!(out.contains("N2 `ffffffffffff` (pre-turn turn 5)"), "{out}"); + let real_user = ExportMessage { + is_user_role: true, + ..unrecognized + }; assert!( - out.contains("ambiguous"), - "two identical prompts must not be silently resolved to one: {out}" + correlation_markdown(&recorded, &real_user).contains("N1 `aaaaaaaaaaaa`"), + "an exact user turn still correlates" ); } #[test] - fn a_message_with_no_recorded_restore_point_says_none_rather_than_nothing() { - let points = RestorePoints::Recorded(vec![snapshot( - "1".repeat(40).as_str(), - "pre-turn:1: something else", - 1_700_000_000, - )]); + fn history_fallback_marks_literals_and_sanitizes_visible_bodies() { let mut out = String::new(); - points.render_correlation(&mut out, &user_message("never snapshotted")); - assert!(out.contains("none recorded for this message"), "{out}"); + render_history_fallback( + &mut out, + &[ + HistoryEntry::Sanitized { + role: "user".to_string(), + body: "hello\u{1b}[31m world".to_string(), + }, + HistoryEntry::Literal { + role: "system".to_string(), + body: "[internal context omitted]".to_string(), + }, + ], + ); + assert!(out.contains("## 1. user\n\nhello world\n\n"), "{out}"); + assert!( + out.contains("## 2. system\n\n[internal context omitted]\n\n"), + "{out}" + ); + + let mut empty = String::new(); + render_history_fallback(&mut empty, &[]); + assert_eq!(empty, "## Conversation\n\n[empty conversation]\n"); } #[test] - fn tool_snapshots_are_not_correlated_to_user_messages() { - let points = RestorePoints::Recorded(vec![snapshot( - "2".repeat(40).as_str(), - "tool:call_abc: rename the widget", - 1_700_000_000, - )]); + fn json_fence_tracks_longest_backtick_run_and_redacts_secrets() { let mut out = String::new(); - points.render_correlation(&mut out, &user_message("rename the widget")); - assert!( - out.contains("none recorded for this message"), - "a per-tool snapshot is not the turn's restore point: {out}" + push_json( + &mut out, + serde_json::json!({"api_key": "literal-secret", "note": "``` inner"}), ); + assert!(!out.contains("literal-secret"), "{out}"); + assert!(out.starts_with("````json\n"), "{out}"); + assert!(out.contains("\"api_key\": \"[redacted]\""), "{out}"); } #[test] - fn assistant_messages_get_no_correlation_line() { - let points = RestorePoints::Recorded(vec![snapshot( - "3".repeat(40).as_str(), - "pre-turn:1: hello", - 1_700_000_000, - )]); + fn empty_content_and_missing_blocks_use_baseline_markers() { let mut out = String::new(); - points.render_correlation( + render_message( &mut out, - &Message { - role: Role::Assistant, - content: vec![ContentBlock::Text { - text: "hello".to_string(), - cache_control: None, - }], + 1, + ExportMessage { + is_user_role: false, + role: "assistant".to_string(), + blocks: Vec::new(), + prompt_snippet: None, }, ); - assert!(out.is_empty(), "{out}"); + assert!(out.contains("## 1. assistant\n\n[no content]\n\n"), "{out}"); + + let mut empty_text = String::new(); + render_content_block( + &mut empty_text, + 1, + ExportBlock::Text { + text: " ".to_string(), + }, + ); + assert!(empty_text.ends_with("[empty text]\n\n"), "{empty_text}"); } #[test] - fn absent_snapshot_repo_is_reported_as_unavailable_not_as_an_empty_list() { - let mut out = String::new(); - RestorePoints::None.render_summary(&mut out); - assert!( - out.contains("No workspace restore points are recorded"), - "{out}" + fn parser_handles_whitespace_case_and_only_leading_force() { + // Surrounding whitespace is trimmed, keyword matching is ASCII + // case-insensitive, and only a leading `--force` is honored (the + // baseline `strip_word` semantics). + assert_eq!( + parse_request(Some(" clipboard ")).unwrap(), + ExportRequest { + scope: ExportScope::Conversation, + destination: ExportDestination::Clipboard, + } ); - assert!( - out.contains("nothing in this export can be correlated"), - "the export must not imply restorability it does not have: {out}" + assert_eq!( + parse_request(Some(" TURN ")).unwrap(), + ExportRequest { + scope: ExportScope::Turn, + destination: ExportDestination::Clipboard, + } + ); + assert_eq!( + parse_request(Some(" File Report One.md ")).unwrap(), + ExportRequest { + scope: ExportScope::Conversation, + destination: ExportDestination::File { + path: "Report One.md".to_string(), + force: false, + }, + } + ); + assert_eq!( + parse_request(Some("turn FILE --Force handoff.md")).unwrap(), + ExportRequest { + scope: ExportScope::Turn, + destination: ExportDestination::File { + path: "handoff.md".to_string(), + force: true, + }, + } + ); + // A trailing `--force` is not a force flag; it becomes part of the + // literal path exactly like the baseline parser. + assert_eq!( + parse_request(Some("file out.md --force")).unwrap(), + ExportRequest { + scope: ExportScope::Conversation, + destination: ExportDestination::File { + path: "out.md --force".to_string(), + force: false, + }, + } + ); + // The turn branch keeps the same usage errors as the conversation one. + assert_eq!( + parse_request(Some("turn file")).unwrap_err(), + export_usage("missing file path") + ); + assert_eq!( + parse_request(Some("turn file --force")).unwrap_err(), + export_usage("missing file path") + ); + assert_eq!( + parse_request(Some("turn clipboard extra.md")).unwrap_err(), + export_usage("clipboard does not accept a path") ); } #[test] - fn unreadable_snapshot_repo_reports_the_reason_instead_of_omitting_it() { - let mut out = String::new(); - RestorePoints::Unreadable("permission denied".to_string()).render_summary(&mut out); - assert!(out.contains("could not be read"), "{out}"); - assert!(out.contains("permission denied"), "{out}"); + fn header_metadata_keeps_unsaved_and_workspace_fallbacks() { + let projection = ConversationExportProjection { + metadata: codewhale_command_contract::facets::ExportMetadata { + session_label: "unsaved".to_string(), + provider: "unknown".to_string(), + model: "unknown".to_string(), + mode: "agent".to_string(), + workspace_name: "workspace".to_string(), + message_count: 0, + exported_at_unix: 0, + }, + transcript: TranscriptProjection::Authoritative(Vec::new()), + restore_points: RestorePointProjection::None, + }; + + let rendered = render_conversation(projection); + assert!( - out.contains("unavailable rather than empty"), - "unknown must stay unknown: {out}" + rendered.contains("- Exported: 1970-01-01T00:00:00Z\n"), + "{rendered}" ); + assert!(rendered.contains("- Session: unsaved\n"), "{rendered}"); + assert!(rendered.contains("- Provider: unknown\n"), "{rendered}"); + assert!(rendered.contains("- Model: unknown\n"), "{rendered}"); + assert!(rendered.contains("- Workspace: workspace\n"), "{rendered}"); + assert!(rendered.contains("- Messages: 0\n"), "{rendered}"); } #[test] - fn an_existing_repo_with_no_commits_is_distinguished_from_no_repo() { - let mut out = String::new(); - RestorePoints::Recorded(Vec::new()).render_summary(&mut out); - assert!(out.contains("records no restore points yet"), "{out}"); - } + fn every_content_block_variant_renders_exactly() { + // Image reference: URL credentials and sensitive query values are masked. + let mut image = String::new(); + render_content_block( + &mut image, + 1, + ExportBlock::ImageReference { + url: "https://alice:pw@example.com/a.png?api_key=hidden&ok=1".to_string(), + }, + ); + assert_eq!( + image, + "### Content 1: Image attachment\n\n- Reference: https://***:***@example.com/a.png?api_key=***&ok=1\n\n" + ); - #[test] - fn export_does_not_create_a_snapshot_repo_for_a_fresh_workspace() { - let tmpdir = TempDir::new().expect("tempdir"); - let workspace = tmpdir.path().join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); - let before = crate::snapshot::snapshot_git_dir(&workspace); - assert!(!before.exists(), "precondition: no side repo yet"); - - assert!(matches!( - RestorePoints::read(&workspace), - RestorePoints::None - )); + // Inline/local image payloads became an omission marker at projection. + let mut omitted = String::new(); + render_content_block(&mut omitted, 2, ExportBlock::ImageOmitted); + assert_eq!( + omitted, + "### Content 2: Image attachment\n\n- Reference omitted (inline or local image payload)\n\n" + ); - assert!( - !crate::snapshot::snapshot_git_dir(&workspace).exists(), - "reading restore points must never create the side repo" + // Reasoning bodies and signatures are replaced by the baseline marker. + let mut reasoning = String::new(); + render_content_block(&mut reasoning, 3, ExportBlock::InternalReasoning); + assert_eq!( + reasoning, + "### Content 3: Internal reasoning\n\n[internal reasoning and signature omitted]\n\n" + ); + + // Tool call with caller metadata and redacted JSON input. + let mut tool_call = String::new(); + render_content_block( + &mut tool_call, + 4, + ExportBlock::ToolCall { + id: "call-1".to_string(), + name: "fetch_url".to_string(), + caller: Some(codewhale_command_contract::facets::ToolCallerProjection { + caller_type: "code_execution_20250825".to_string(), + tool_id: Some("server-tool-1".to_string()), + }), + input: serde_json::json!({"api_key": "literal-secret"}), + }, + ); + assert_eq!( + tool_call, + "### Content 4: Tool call\n\n- ID: call-1\n- Name: fetch_url\n- Caller type: code_execution_20250825\n- Caller tool ID: server-tool-1\n\nInput:\n\n```json\n{\n \"api_key\": \"[redacted]\"\n}\n```\n\n" + ); + + // Tool call without caller metadata omits only the caller lines. + let mut bare_tool_call = String::new(); + render_content_block( + &mut bare_tool_call, + 5, + ExportBlock::ToolCall { + id: "call-2".to_string(), + name: "read_file".to_string(), + caller: None, + input: serde_json::json!({}), + }, + ); + assert_eq!( + bare_tool_call, + "### Content 5: Tool call\n\n- ID: call-2\n- Name: read_file\n\nInput:\n\n```json\n{}\n```\n\n" + ); + + // Tool result with structured blocks: both the sanitized result text + // and the redacted structured payload are rendered. + let mut structured_result = String::new(); + render_content_block( + &mut structured_result, + 6, + ExportBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "tool output line".to_string(), + is_error: false, + structured: Some(serde_json::json!([{"session_token": "session-secret"}])), + }, + ); + assert_eq!( + structured_result, + "### Content 6: Tool result\n\n- Tool call ID: call-1\n- Error: false\n\nResult:\n\ntool output line\n\nStructured result blocks:\n\n```json\n[\n {\n \"session_token\": \"[redacted]\"\n }\n]\n```\n\n" + ); + + // Tool result without structured blocks ends after the result text, and + // an empty body keeps the baseline empty marker. + let mut plain_result = String::new(); + render_content_block( + &mut plain_result, + 7, + ExportBlock::ToolResult { + tool_use_id: "call-2".to_string(), + content: String::new(), + is_error: true, + structured: None, + }, + ); + assert_eq!( + plain_result, + "### Content 7: Tool result\n\n- Tool call ID: call-2\n- Error: true\n\nResult:\n\n[empty text]\n\n" + ); + + let mut server_call = String::new(); + render_content_block( + &mut server_call, + 8, + ExportBlock::ServerToolCall { + id: "srv-1".to_string(), + name: "web_search".to_string(), + input: serde_json::json!({"query": "rust"}), + }, + ); + assert_eq!( + server_call, + "### Content 8: Server tool call\n\n- ID: srv-1\n- Name: web_search\n\nInput:\n\n```json\n{\n \"query\": \"rust\"\n}\n```\n\n" + ); + + let mut search_result = String::new(); + render_content_block( + &mut search_result, + 9, + ExportBlock::ToolSearchResult { + tool_use_id: "search-1".to_string(), + content: serde_json::json!({"results": []}), + }, + ); + assert_eq!( + search_result, + "### Content 9: Tool-search result\n\n- Tool call ID: search-1\n\n```json\n{\n \"results\": []\n}\n```\n\n" + ); + + let mut execution_result = String::new(); + render_content_block( + &mut execution_result, + 10, + ExportBlock::CodeExecutionResult { + tool_use_id: "exec-1".to_string(), + content: serde_json::json!({"stdout": "ok"}), + }, + ); + assert_eq!( + execution_result, + "### Content 10: Code-execution result\n\n- Tool call ID: exec-1\n\n```json\n{\n \"stdout\": \"ok\"\n}\n```\n\n" ); } } diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index 8cd846e85c..3e4bf5861f 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -4,7 +4,6 @@ mod branch; mod compact; mod export; -pub(crate) use export::write_last_copy; mod fork; mod load; mod new; @@ -88,10 +87,10 @@ impl CommandGroup for SessionCommands { ContextualCommand::from_contract::() .expect("remote_env registration") ), - Box::new(FunctionCommand::new( - export::ExportCmd::info(), - export::ExportCmd::execute, - )), + Box::new( + ContextualCommand::from_contract::() + .expect("export registration") + ), Box::new(FunctionCommand::new( structcopy::StructcopyCmd::info(), structcopy::StructcopyCmd::execute, diff --git a/crates/tui/src/commands/groups/session/structcopy.rs b/crates/tui/src/commands/groups/session/structcopy.rs index 1a9de46520..a421269e3c 100644 --- a/crates/tui/src/commands/groups/session/structcopy.rs +++ b/crates/tui/src/commands/groups/session/structcopy.rs @@ -10,9 +10,9 @@ //! - Human-only. This is a slash command, never a model-visible tool, event, //! or authority, and it writes nothing back into App/session/plan/workflow //! state (see the registry/catalog contract test). -//! - Read-only projection over existing state. Redaction reuses the -//! transcript/export seams (`export::redact_json` for values, -//! `export::sanitize_text` for keys and status labels, which +//! - Read-only projection over existing state. Redaction reuses the shared +//! sanitizer seams in `codewhale_secrets::sanitize` (`redact_json` for +//! values, `sanitize_text` for keys and status labels, which //! `redact_json` does not reach) plus a strict pass that strips URL //! userinfo/query/fragment entirely and folds the workspace and home //! prefixes to labels, removes other absolute paths, and handles generic @@ -28,8 +28,8 @@ //! - It is not a general PII scrubber. Workspace/home paths retain a useful //! labelled suffix; other absolute POSIX, drive-letter, and UNC paths are //! replaced outright. -//! - Redaction is pattern-based (the export seam's private-key/bearer/JWT/ -//! URL/secret regexes plus this module's strict URL pass). A secret that +//! - Redaction is pattern-based (the shared sanitizer's private-key/bearer/ +//! JWT/URL/secret regexes plus this module's strict URL pass). A secret that //! matches none of those patterns and sits under a non-sensitive key is //! copied as-is. //! - Delivery to the clipboard is not confirmed. Terminal-client transports @@ -49,7 +49,10 @@ use codewhale_localization::{Locale, MessageId, tr}; use codewhale_models::{ContentBlock, Message}; use super::CommandResult; -use super::export::{is_internal_role, is_sensitive_key, redact_json, sanitize_text}; +// FEAT-025 D4: the sanitizer helpers moved to the single shared portable +// implementation in `codewhale-secrets`; `/structcopy` stays legacy until +// FEAT-026 and only rewires its import. +use codewhale_secrets::sanitize::{is_internal_role, is_sensitive_key, redact_json, sanitize_text}; pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { name: "structcopy", @@ -720,7 +723,7 @@ fn scrub_string(text: &str, labels: &PathLabels) -> String { scrub_paths(&scrub_urls(&labelled)) } -/// Convert the prose placeholders owned by the shared export seam into stable +/// Convert the prose placeholders owned by the shared sanitizer into stable /// language-neutral codes. Structural JSON is a machine artifact and must not /// change with the UI locale. fn normalize_redaction_codes(value: &mut Value) { @@ -783,7 +786,7 @@ const URL_TRAILING_PUNCTUATION: &[char] = &[ /// Strip URL userinfo, query, and fragment entirely, leaving a /// `scheme://host[:port]/path` label. /// -/// The export seam has already masked credentials in URLs it recognised; +/// The shared sanitizer has already masked credentials in URLs it recognised; /// this pass enforces the stricter structural-copy contract that no /// userinfo, query string, or fragment may survive at all — including for /// URLs that are punctuation-wrapped (`(https://…)`, ``, diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 77cbc65e0f..e38a52a119 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -9,6 +9,12 @@ mod contract; pub mod discovery; mod groups; + +// FEAT-025 host services for the session-export slice: the shared recovery +// writer and the protected export-destination resolver/writer. Declared at the +// `commands` root so they stay outside `groups/session`, which FEAT-043 moves +// to `codewhale-commands`. +mod session_export_host; pub mod traits; pub mod user_commands; pub mod user_registry; @@ -28,6 +34,16 @@ mod session_acceptance; #[cfg(test)] mod session_control_regression_tests; #[cfg(test)] +mod session_export_regression_tests; +// FEAT-025 Phase 5: public command-surface parity lives at the `commands` root +// for the same extraction reason as the host regressions above. +#[cfg(test)] +mod session_export_surface_tests; +// FEAT-025 audit hardening: shared host-bound test support for both export +// test suites (timestamp normalisation and the exhaustive envelope check). +#[cfg(test)] +mod session_export_test_support; +#[cfg(test)] mod session_lifecycle_regression_tests; use std::sync::OnceLock; @@ -2074,6 +2090,8 @@ mod tests { "rc", "remote-env", "title", + // FEAT-025 session export slice. + "export", ]; for info in command_infos() { if info.name == "feat015ctx" || MIGRATED_GROUPS.contains(&info.name) { @@ -2751,13 +2769,11 @@ mod tests { "/{name} must be pure (no host context bundle)" ); } - // Out-of-scope session commands remain legacy for FEAT-025/026. - for name in ["export", "structcopy"] { - assert!( - !registry().has_contextual_handler(name), - "/{name} must stay on the legacy dispatch until its owning FEAT" - ); - } + // Out-of-scope session command remains legacy for FEAT-026. + assert!( + !registry().has_contextual_handler("structcopy"), + "/structcopy must stay on the legacy dispatch until its owning FEAT" + ); } // --------------------------------------------------------------------- @@ -2802,13 +2818,11 @@ mod tests { CommandCapabilities::SESSION_CONTROL.union(CommandCapabilities::PRESENTATION), "/remote-env declares control plus presentation only" ); - // FEAT-025/FEAT-026 leaves remain legacy until their owning FEATs. - for name in ["export", "structcopy"] { - assert!( - !registry().has_contextual_handler(name), - "/{name} must stay on the legacy dispatch" - ); - } + // FEAT-026 leaf remains legacy until its owning FEAT. + assert!( + !registry().has_contextual_handler("structcopy"), + "/structcopy must stay on the legacy dispatch" + ); } #[test] @@ -2921,4 +2935,113 @@ mod tests { assert!(resume.action.is_none()); assert!(resume.message.is_none()); } + + // --------------------------------------------------------------------- + // FEAT-025: session export entry registers through the portable bridge + // (D1/D3/D5). `/export` (alias `/daochu`) declares exactly SESSION_EXPORT; + // `/structcopy` remains a direct host handler for FEAT-026, so the root + // `session` frontier stays pending. + // --------------------------------------------------------------------- + + #[test] + fn feat025_export_entry_registers_through_portable_bridge() { + use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; + + assert!( + registry().has_contextual_handler("export"), + "/export must register through the portable bridge" + ); + assert!( + registry().has_contextual_handler("daochu"), + "/daochu must resolve to the same portable bridge entry" + ); + + let handler = registry() + .get("export") + .expect("entry") + .contextual_handler() + .expect("contextual handler"); + let CommandHandler::Contextual { capabilities, .. } = handler else { + panic!("/export must be contextual"); + }; + assert_eq!( + capabilities, + CommandCapabilities::SESSION_EXPORT, + "/export declares export authority only" + ); + + // Least authority is catalogue-wide: no other registration may declare + // the session-export capability. + let export_declarers: Vec<&str> = registry() + .iter() + .filter(|command| { + command + .contextual_handler() + .is_some_and(|handler| match handler { + CommandHandler::Contextual { capabilities, .. } => { + capabilities.contains(CommandCapabilities::SESSION_EXPORT) + } + CommandHandler::Pure(_) => false, + }) + }) + .map(|command| command.info().name) + .collect(); + assert_eq!( + export_declarers, + vec!["export"], + "exactly one registration may declare SESSION_EXPORT" + ); + + // The legacy function registration was removed for export only: the + // contextual entry has no direct host fallback, while `/structcopy` + // keeps its concrete-App `FunctionCommand` until FEAT-026. + let mut app = create_test_app(); + let legacy = registry() + .get("export") + .expect("entry") + .execute(&mut app, None); + assert_eq!( + legacy.message.as_deref(), + Some("Error: command has no executable handler"), + "/export must not keep a legacy function registration" + ); + assert!( + !registry().has_contextual_handler("structcopy"), + "/structcopy must stay on the legacy dispatch until FEAT-026" + ); + } + + #[test] + fn feat025_export_registered_handler_fails_safely_without_authority() { + // The dispatcher builds the envelope from the declared capabilities and + // calls this exact handler object. A narrower envelope that omits the + // export facet must return the safe error before parsing or performing + // any projection, clipboard, recovery, resolution, or write operation. + let handler = registry() + .get("export") + .expect("entry") + .contextual_handler() + .expect("contextual handler"); + let codewhale_command_contract::handler::CommandHandler::Contextual { + handler: contextual, + .. + } = handler + else { + panic!("/export must be contextual"); + }; + + for arg in [None, Some("clipboard"), Some("file out.md")] { + let result = contextual( + codewhale_command_contract::handler::CommandContexts::empty(), + arg, + ); + assert!(result.is_error, "{arg:?} must fail without authority"); + assert_eq!( + result.message.as_deref(), + Some("Error: Command capability unavailable: session_export"), + "{arg:?} must keep the exact safe error" + ); + assert!(result.action.is_none(), "{arg:?} must produce no action"); + } + } } diff --git a/crates/tui/src/commands/session_export_host.rs b/crates/tui/src/commands/session_export_host.rs new file mode 100644 index 0000000000..c82f3ec1fa --- /dev/null +++ b/crates/tui/src/commands/session_export_host.rs @@ -0,0 +1,273 @@ +//! TUI-owned host services for the session-export slice (FEAT-025 D5/D7). +//! +//! This module deliberately lives outside `commands/groups/session`, which +//! FEAT-043 moves into `codewhale-commands`. It owns the two filesystem +//! services the `/export` slice needs from the host: +//! +//! * the shared `last-copy.md` recovery writer reused by `/export` and `/copy` +//! (D5), and +//! * the protected export-destination resolver/writer used only by `/export` +//! (D7). +//! +//! The algorithm, check order, error text, and platform behavior are the exact +//! baseline implementations relocated unchanged. The portable `/export` +//! handler reaches these services only through +//! `CommandSessionExportContext` — never by importing this module — so the +//! future portable group keeps no host dependency. + +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::path::{Component, Path, PathBuf}; + +/// Write the export to a predictable last-copy file under the Codewhale home +/// (#5555): a clipboard-only export on SSH/headless must never dead-end the +/// user, so the same content lands at `/exports/last-copy.md` and every +/// failure message names it. Returns the path when the write succeeded. +pub(crate) fn write_last_copy(markdown: &str) -> Option { + let home = codewhale_paths::codewhale_home().ok().flatten()?; + let exports_dir = home.join("exports"); + std::fs::create_dir_all(&exports_dir).ok()?; + let physical_home = std::fs::canonicalize(&home).ok()?; + let physical_exports = std::fs::canonicalize(&exports_dir).ok()?; + if !physical_exports.starts_with(&physical_home) { + return None; + } + write_last_copy_to(&exports_dir, markdown).ok() +} + +fn write_last_copy_to(exports_dir: &Path, markdown: &str) -> std::io::Result { + std::fs::create_dir_all(exports_dir)?; + let path = exports_dir.join("last-copy.md"); + // Reuse the private atomic writer: random same-directory temp names, + // restrictive creation mode, symlink-safe replacement, and Windows + // replace retries are all part of the existing persistence contract. + crate::utils::write_atomic(&path, markdown.as_bytes())?; + Ok(path) +} + +/// Resolve a requested export destination against the trusted workspace root. +/// +/// Verbatim baseline algorithm (D7): trim; empty → `export path is empty`; +/// reject `..` components; canonicalize the workspace with its current +/// fallback; rebase workspace-absolute paths (raw or canonicalized); otherwise +/// join workspace-relative; require a file name. Errors are returned unwrapped +/// so the portable handler keeps the exact baseline text. +pub(crate) fn resolve_export_path(workspace: &Path, raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err("export path is empty".to_string()); + } + let requested = PathBuf::from(raw); + if requested + .components() + .any(|component| component == Component::ParentDir) + { + return Err( + "export paths may not contain `..`; use an explicit normalized absolute path instead" + .to_string(), + ); + } + // Resolve the trusted workspace root once so platform aliases such as + // macOS `/var -> /private/var` do not make every workspace-relative + // export look like it traverses a user-controlled symlink. Requested + // components beneath that root remain lexical and are checked below. + let resolved_workspace = + fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf()); + let path = if requested.is_absolute() { + if let Ok(relative) = requested.strip_prefix(workspace) { + resolved_workspace.join(relative) + } else if let Ok(relative) = requested.strip_prefix(&resolved_workspace) { + resolved_workspace.join(relative) + } else { + requested + } + } else { + resolved_workspace.join(requested) + }; + if path.file_name().is_none() { + return Err(format!("export path must name a file: {}", path.display())); + } + Ok(path) +} + +/// Write rendered export bytes to a resolved destination with the exact +/// baseline protections (D7): parent presence/directory checks, symlink +/// rejection (leaf and ancestors), no overwrite by default, non-regular-file +/// rejection, exclusive creation with `0o600` on Unix, `fsync`, cleanup after a +/// failed write, forced atomic replacement, and owner-only permissions. +pub(crate) fn write_export_file(path: &Path, contents: &[u8], force: bool) -> Result<(), String> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .ok_or_else(|| format!("path has no parent directory: {}", path.display()))?; + let parent_metadata = fs::metadata(parent).map_err(|err| { + format!( + "parent directory {} is unavailable: {err}", + parent.display() + ) + })?; + if !parent_metadata.is_dir() { + return Err(format!("parent is not a directory: {}", parent.display())); + } + reject_symlink_components(path)?; + + match fs::symlink_metadata(path) { + Ok(_) if !force => { + return Err(format!( + "destination already exists: {}. Re-run with `/export file --force ` to replace it", + path.display() + )); + } + Ok(metadata) if !metadata.file_type().is_file() => { + return Err(format!( + "refusing to replace a non-regular file: {}", + path.display() + )); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("could not inspect {}: {err}", path.display())), + } + + if force { + crate::utils::write_atomic(path, contents).map_err(|err| err.to_string())?; + set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}"))?; + return Ok(()); + } + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path).map_err(|err| { + if err.kind() == std::io::ErrorKind::AlreadyExists { + format!( + "destination already exists: {}. Re-run with `/export file --force ` to replace it", + path.display() + ) + } else { + err.to_string() + } + })?; + if let Err(err) = file.write_all(contents).and_then(|()| file.sync_all()) { + drop(file); + let _ = fs::remove_file(path); + return Err(err.to_string()); + } + set_owner_only(path).map_err(|err| format!("could not secure file permissions: {err}")) +} + +fn reject_symlink_components(path: &Path) -> Result<(), String> { + for component_path in path.ancestors() { + match fs::symlink_metadata(component_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "refusing export through symlink component: {}", + component_path.display() + )); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "could not inspect path component {}: {err}", + component_path.display() + )); + } + } + } + Ok(()) +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) +} + +#[cfg(not(unix))] +fn set_owner_only(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn last_copy_writes_the_export_without_leaving_a_temp_artifact() { + let tmp = TempDir::new().expect("tempdir"); + let dir = tmp.path().join("exports"); + let path = write_last_copy_to(&dir, "# export\n\nhello\n").expect("write"); + assert_eq!(path, dir.join("last-copy.md")); + assert_eq!( + std::fs::read_to_string(&path).expect("read"), + "# export\n\nhello\n" + ); + // The next export overwrites the same predictable path. + write_last_copy_to(&dir, "# second\n").expect("rewrite"); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "# second\n"); + assert_eq!( + std::fs::read_dir(&dir).expect("read exports").count(), + 1, + "the atomic writer must not leave a temp artifact" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o077, 0, "recovery copy must remain private"); + } + } + + #[test] + fn last_copy_stays_inside_an_explicit_codewhale_home() { + let ambient = TempDir::new().expect("ambient home"); + let isolated = TempDir::new().expect("isolated Codewhale home"); + let _env_lock = crate::test_support::lock_test_env(); + let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path()); + let _codewhale_home = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path()); + + let path = write_last_copy("isolated response").expect("recovery copy"); + + assert_eq!(path, isolated.path().join("exports/last-copy.md")); + assert_eq!( + std::fs::read_to_string(&path).expect("read recovery copy"), + "isolated response" + ); + assert!( + !ambient.path().join("exports/last-copy.md").exists(), + "explicit CODEWHALE_HOME must prevent ambient-home writes" + ); + } + + #[cfg(unix)] + #[test] + fn last_copy_refuses_an_exports_symlink_outside_codewhale_home() { + use std::os::unix::fs::symlink; + + let ambient = TempDir::new().expect("ambient home"); + let isolated = TempDir::new().expect("isolated Codewhale home"); + let external = TempDir::new().expect("external dir"); + symlink(external.path(), isolated.path().join("exports")).expect("exports symlink"); + let _env_lock = crate::test_support::lock_test_env(); + let _home = crate::test_support::EnvVarGuard::set("HOME", ambient.path()); + let _codewhale_home = + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", isolated.path()); + + assert_eq!(write_last_copy("must stay isolated"), None); + assert!( + !external.path().join("last-copy.md").exists(), + "recovery content must not escape through a nested symlink" + ); + } +} diff --git a/crates/tui/src/commands/session_export_regression_tests.rs b/crates/tui/src/commands/session_export_regression_tests.rs new file mode 100644 index 0000000000..da90a7cf31 --- /dev/null +++ b/crates/tui/src/commands/session_export_regression_tests.rs @@ -0,0 +1,1407 @@ +//! FEAT-025 Phase 3: real-host regression coverage for the session-export +//! adapter. +//! +//! These tests deliberately stay outside `groups/session`, which FEAT-043 +//! moves into `codewhale-commands`. They exercise the TUI-owned +//! `SessionExportAdapter` through the capability envelope and assert the +//! baseline host contracts: metadata derivation, data-minimized projections, +//! the shared turn-handoff renderer, read-only restore-point projection, +//! clipboard/recovery ordering inputs, and protected file resolution/writing. +//! +//! No test depends on a manual terminal, GUI, device, or live clipboard. + +use std::path::{Path, PathBuf}; + +use tempfile::TempDir; + +use codewhale_command_contract::facets::{ + ConversationExportProjection, ExportBlock, HistoryEntry, RestorePointProjection, + RestoreSnapshot, TranscriptProjection, TurnHandoffProjection, +}; +use codewhale_command_contract::handler::CommandCapabilities; + +use crate::config::Config; +use crate::error_taxonomy::ErrorSeverity; +use crate::snapshot::SnapshotRepo; +use crate::test_support::{EnvVarGuard, TestEnvLock}; +use crate::tui::app::{App, TuiOptions}; +use crate::tui::clipboard::ClipboardHandler; +use crate::tui::history::HistoryCell; +use codewhale_models::{ContentBlock, ImageUrlContent, Message, Role, ToolCaller}; + +use crate::commands::session_export_test_support::{ + assert_only_export_facet_exposed, normalize_export_time, normalize_recorded_export, + normalize_turn_generated_at, +}; +use crate::commands::{CommandResult, execute}; + +struct ExportHarness { + app: App, + // Guards are declared before the lock and the temporary directory so the + // environment is restored before the lock is released and before the + // temporary files are removed (matches ControlHarness). + _home: EnvVarGuard, + _codewhale_home: EnvVarGuard, + _env_lock: TestEnvLock, + temp: TempDir, +} + +impl ExportHarness { + fn new() -> Self { + let env_lock = crate::test_support::lock_test_env(); + let temp = TempDir::new().expect("tempdir"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home); + let options = TuiOptions { + skills_dir: temp.path().join("skills"), + memory_path: temp.path().join("memory.md"), + notes_path: temp.path().join("notes.txt"), + mcp_config_path: temp.path().join("mcp.json"), + ..crate::test_support::test_tui_options(temp.path()) + }; + let app = App::new(options, &Config::default()); + Self { + app, + _home, + _codewhale_home, + _env_lock: env_lock, + temp, + } + } +} + +fn conversation_projection(app: &mut App) -> ConversationExportProjection { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .conversation_projection() +} + +fn turn_handoff_projection(app: &mut App) -> TurnHandoffProjection { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .turn_handoff_projection() +} + +fn text_message(role: Role, text: &str) -> Message { + Message { + role, + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } +} + +#[test] +fn adapter_projects_authoritative_metadata_and_omits_hidden_payloads() { + let mut harness = ExportHarness::new(); + harness.app.current_session_id = Some("session-123456789".to_string()); + harness.app.api_messages = vec![ + text_message(Role::System, "hidden policy must never export"), + text_message(Role::User, "please inspect\nthe output"), + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "private chain of thought".to_string(), + signature: Some("signature-secret".to_string()), + state: None, + }, + ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "fetch_url".to_string(), + input: serde_json::json!({"url": "https://example.com/a"}), + caller: Some(ToolCaller { + caller_type: "code_execution_20250825".to_string(), + tool_id: Some("server-tool-1".to_string()), + }), + thought_signature: None, + }, + ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "data:image/png;base64,very-secret-image-data".to_string(), + }, + }, + ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "https://example.com/remote.png".to_string(), + }, + }, + ], + }, + ]; + + let projection = conversation_projection(&mut harness.app); + + assert_eq!(projection.metadata.session_label, "session-"); + assert_eq!( + projection.metadata.provider, + harness.app.provider_identity_for_persistence() + ); + assert_eq!(projection.metadata.model, harness.app.model_display_label()); + assert_eq!(projection.metadata.mode, harness.app.mode.display_name()); + assert_eq!( + projection.metadata.workspace_name, + harness + .temp + .path() + .file_name() + .and_then(|name| name.to_str()) + .unwrap() + ); + assert_eq!(projection.metadata.message_count, 3); + assert!(projection.metadata.exported_at_unix > 0); + + let TranscriptProjection::Authoritative(messages) = &projection.transcript else { + panic!("authoritative transcript expected"); + }; + assert_eq!(messages.len(), 3); + assert_eq!(messages[1].role, "user"); + assert_eq!( + messages[1].prompt_snippet.as_deref(), + Some("please inspect") + ); + assert!(matches!( + messages[2].blocks[0], + ExportBlock::InternalReasoning + )); + assert!(matches!(messages[2].blocks[2], ExportBlock::ImageOmitted)); + assert!(matches!( + &messages[2].blocks[3], + ExportBlock::ImageReference { url } if url == "https://example.com/remote.png" + )); + let ExportBlock::ToolCall { caller, .. } = &messages[2].blocks[1] else { + panic!("tool call expected"); + }; + let caller = caller.as_ref().expect("caller projection"); + assert_eq!(caller.caller_type, "code_execution_20250825"); + assert_eq!(caller.tool_id.as_deref(), Some("server-tool-1")); + + let debug = format!("{projection:?}"); + for forbidden in [ + "private chain of thought", + "signature-secret", + "very-secret-image-data", + ] { + assert!( + !debug.contains(forbidden), + "hidden payload {forbidden:?} crossed the projection boundary" + ); + } +} + +#[test] +fn adapter_projects_visible_history_fallback_with_baseline_markers() { + let mut harness = ExportHarness::new(); + harness.app.api_messages.clear(); + harness.app.history = vec![ + HistoryCell::User { + content: "user text".to_string(), + }, + HistoryCell::Assistant { + content: "assistant text".to_string(), + streaming: false, + }, + HistoryCell::System { + content: "hidden system".to_string(), + }, + HistoryCell::Thinking { + content: "hidden reasoning".to_string(), + streaming: false, + duration_secs: None, + }, + HistoryCell::Error { + message: "boom".to_string(), + severity: ErrorSeverity::Warning, + }, + ]; + + let projection = conversation_projection(&mut harness.app); + let TranscriptProjection::HistoryFallback(entries) = &projection.transcript else { + panic!("history fallback expected"); + }; + assert_eq!(projection.metadata.message_count, 5); + assert_eq!( + entries[0], + HistoryEntry::Sanitized { + role: "user".to_string(), + body: "user text".to_string(), + } + ); + assert_eq!( + entries[1], + HistoryEntry::Sanitized { + role: "assistant".to_string(), + body: "assistant text".to_string(), + } + ); + assert_eq!( + entries[2], + HistoryEntry::Literal { + role: "system".to_string(), + body: "[internal context omitted]".to_string(), + } + ); + assert_eq!( + entries[3], + HistoryEntry::Literal { + role: "internal reasoning".to_string(), + body: "[internal reasoning omitted]".to_string(), + } + ); + assert_eq!( + entries[4], + HistoryEntry::Sanitized { + role: "warning".to_string(), + body: "boom".to_string(), + } + ); +} + +#[test] +fn adapter_reuses_turn_handoff_renderer_and_workspace_value() { + let mut harness = ExportHarness::new(); + harness.app.history.push(HistoryCell::User { + content: "Fix the flaky login test".to_string(), + }); + harness.app.history.push(HistoryCell::Assistant { + content: "Fixed the login test.".to_string(), + streaming: false, + }); + harness.app.runtime_turn_status = Some("completed".to_string()); + + let direct = crate::tui::ui::turn_handoff_markdown(&harness.app); + let projection = turn_handoff_projection(&mut harness.app); + + assert_eq!( + projection.markdown, direct, + "renderer output must not drift" + ); + assert!(projection.markdown.contains("# Turn handoff")); + assert_eq!( + projection.workspace_path, + harness.app.workspace.to_string_lossy().into_owned() + ); +} + +#[test] +fn adapter_projects_absent_restore_points_without_creating_a_repo() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + harness.app.workspace = workspace.clone(); + let before = crate::snapshot::snapshot_git_dir(&workspace); + assert!(!before.exists(), "precondition: no side repo yet"); + + let projection = conversation_projection(&mut harness.app); + + assert!(matches!( + projection.restore_points, + RestorePointProjection::None + )); + assert!( + !crate::snapshot::snapshot_git_dir(&workspace).exists(), + "projection must never create the snapshot repo" + ); +} + +#[test] +fn adapter_projects_recorded_restore_points_as_semantic_fields() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + harness.app.workspace = workspace.clone(); + let repo = SnapshotRepo::open_or_init(&workspace).expect("open side repo"); + repo.snapshot("pre-turn:2: second prompt") + .expect("record snapshot"); + + let projection = conversation_projection(&mut harness.app); + + let RestorePointProjection::Recorded { snapshots } = &projection.restore_points else { + panic!("recorded restore points expected"); + }; + assert_eq!(snapshots.len(), 1); + let snapshot: &RestoreSnapshot = &snapshots[0]; + assert_eq!(snapshot.id.len(), 40, "full id crosses; portable truncates"); + assert_eq!(snapshot.kind, "pre-turn"); + assert_eq!(snapshot.sequence, Some(2)); + assert_eq!(snapshot.prompt_snippet.as_deref(), Some("second prompt")); + assert_eq!(snapshot.label, "pre-turn:2: second prompt"); + assert!(snapshot.timestamp_unix > 0); +} + +#[test] +fn adapter_projects_user_identity_from_the_role_enum_not_the_string() { + // F6: the baseline compared `message.role != Role::User`. Projecting only + // the rendered role string would lose that distinction, because + // `Role::Unrecognized("user")` renders as "user" but is not `Role::User`. + let mut harness = ExportHarness::new(); + harness.app.api_messages = vec![ + Message { + role: Role::Unrecognized("user".to_string()), + content: vec![ContentBlock::Text { + text: "looks like a user turn".to_string(), + cache_control: None, + }], + }, + text_message(Role::User, "actually a user turn"), + ]; + + let projection = conversation_projection(&mut harness.app); + let TranscriptProjection::Authoritative(messages) = &projection.transcript else { + panic!("authoritative transcript expected"); + }; + assert_eq!( + messages[0].role, "user", + "the rendered role string is unchanged" + ); + assert!( + !messages[0].is_user_role, + "Role::Unrecognized(\"user\") must not be treated as a user turn" + ); + assert!(messages[1].is_user_role, "Role::User is a user turn"); +} + +fn clipboard_facet(app: &mut App) -> bool { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .clipboard_requires_terminal_paste() +} + +#[test] +fn adapter_exposes_clipboard_mode_recovery_and_delivery_separately() { + let mut harness = ExportHarness::new(); + + harness.app.clipboard = ClipboardHandler::for_test(true, true); + assert!(clipboard_facet(&mut harness.app)); + harness.app.clipboard = ClipboardHandler::for_test(false, false); + assert!(!clipboard_facet(&mut harness.app)); + + // Recovery write is one operation and returns the shared path. + let recovery = { + let mut bundle = harness.app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .write_recovery_copy("recover me") + }; + let recovery = recovery.expect("recovery path"); + assert!(recovery.ends_with("exports/last-copy.md")); + assert_eq!( + std::fs::read_to_string(&recovery).expect("recovery content"), + "recover me" + ); + + // Clipboard delivery is a separate operation and records the payload. + harness.app.clipboard = ClipboardHandler::for_test(false, false); + { + let mut bundle = harness.app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .write_clipboard("deliver me") + .expect("clipboard write"); + } + assert_eq!( + harness.app.clipboard.last_written_text(), + Some("deliver me") + ); + + // A failing clipboard still returns the raw host error text. + harness.app.clipboard = ClipboardHandler::unavailable_for_test(false); + let failure = { + let mut bundle = harness.app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .write_clipboard("nope") + }; + assert!(failure.is_err()); +} + +fn resolve(app: &mut App, raw: &str) -> Result { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .resolve_export_path(raw) +} + +/// Create a workspace directory whose path contains no symlink component. +/// +/// macOS places `TempDir` under `/var/folders/...`, and `/var` is a symlink to +/// `/private/var`. The protected export writer deliberately rejects any path +/// with a symlink component, so an adapter-level test that calls +/// `write_export_file` directly - bypassing `resolve_export_path`, which +/// canonicalizes the workspace for the real command - must hand it an already +/// resolved path. Linux temp dirs contain no symlink, so only macOS CI sees the +/// difference. +fn canonical_workspace(harness: &ExportHarness) -> PathBuf { + let root = std::fs::canonicalize(harness.temp.path()).expect("canonical temp root"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + workspace +} + +fn write_file( + app: &mut App, + path: &std::path::Path, + contents: &[u8], + force: bool, +) -> Result<(), String> { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + parts + .export + .as_mut() + .expect("export facet") + .write_export_file(path, contents, force) +} + +#[test] +fn adapter_resolves_export_paths_with_baseline_errors() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + harness.app.workspace = workspace.clone(); + + assert_eq!( + resolve(&mut harness.app, "transcript.md").expect("workspace relative"), + std::fs::canonicalize(&workspace) + .unwrap() + .join("transcript.md") + ); + assert_eq!( + resolve(&mut harness.app, "").unwrap_err(), + "export path is empty" + ); + assert!( + resolve(&mut harness.app, "../outside.md") + .unwrap_err() + .contains("may not contain `..`") + ); + assert!( + resolve(&mut harness.app, "/") + .unwrap_err() + .starts_with("export path must name a file:") + ); +} + +#[test] +fn adapter_preserves_protected_file_write_and_overwrite_refusal() { + let mut harness = ExportHarness::new(); + let workspace = canonical_workspace(&harness); + harness.app.workspace = workspace.clone(); + let target = workspace.join("transcript.md"); + + write_file(&mut harness.app, &target, b"first", false).expect("first write"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "first"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + let refused = write_file(&mut harness.app, &target, b"second", false).unwrap_err(); + assert!(refused.contains("destination already exists")); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "first"); + + write_file(&mut harness.app, &target, b"third", true).expect("forced write"); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "third"); + + let missing_parent = + write_file(&mut harness.app, &workspace.join("nope/x.md"), b"x", false).unwrap_err(); + assert!(missing_parent.contains("parent directory")); +} + +#[cfg(unix)] +#[test] +fn adapter_rejects_symlink_leaf_and_ancestor_exports() { + use std::os::unix::fs::symlink; + + let mut harness = ExportHarness::new(); + let workspace = canonical_workspace(&harness); + harness.app.workspace = workspace.clone(); + + let real_file = workspace.join("real.md"); + std::fs::write(&real_file, "keep").expect("fixture file"); + let leaf = workspace.join("leaf.md"); + symlink(&real_file, &leaf).expect("leaf symlink"); + let leaf_result = write_file(&mut harness.app, &leaf, b"replace", true).unwrap_err(); + assert!(leaf_result.contains("symlink component")); + assert_eq!(std::fs::read_to_string(&real_file).unwrap(), "keep"); + + let real_dir = workspace.join("real-dir"); + std::fs::create_dir(&real_dir).expect("real dir"); + let linked_dir = workspace.join("linked-dir"); + symlink(&real_dir, &linked_dir).expect("dir symlink"); + let ancestor_result = + write_file(&mut harness.app, &linked_dir.join("out.md"), b"x", false).unwrap_err(); + assert!(ancestor_result.contains("symlink component")); + assert!(!real_dir.join("out.md").exists()); +} + +/// The protected writer refuses any path whose ancestors include a symlink, +/// which on macOS is true of everything under `/var` (and therefore every +/// `TempDir`) because `/var` is a symlink to `/private/var`. An adapter-level +/// test that calls `write_export_file` directly must therefore hand it a +/// resolved path - `resolve_export_path` canonicalizes the workspace for the +/// real command, and `canonical_workspace` does the same for these tests. +/// +/// This test builds the symlink itself, so a Linux run catches the class of bug +/// that macOS CI caught in `adapter_preserves_protected_file_write_and_overwrite_refusal` +/// and `adapter_rejects_directory_destination_and_parent_not_a_directory`. +#[cfg(unix)] +#[test] +fn protected_writer_requires_a_path_free_of_symlink_ancestors() { + use std::os::unix::fs::symlink; + + let harness = ExportHarness::new(); + let real = harness.temp.path().join("real-root"); + std::fs::create_dir_all(&real).expect("real root"); + let link = harness.temp.path().join("link-root"); + symlink(&real, &link).expect("root symlink"); + + // Reached through the symlinked root: refused, and nothing is written. + let refused = + crate::commands::session_export_host::write_export_file(&link.join("out.md"), b"x", false) + .expect_err("a symlinked ancestor must be refused"); + assert!(refused.contains("symlink component"), "{refused}"); + assert!(!real.join("out.md").exists()); + + // The same file through the resolved root is accepted, which is exactly what + // `canonical_workspace` supplies. + let resolved = std::fs::canonicalize(&link).expect("canonical root"); + crate::commands::session_export_host::write_export_file(&resolved.join("out.md"), b"x", false) + .expect("a resolved path must be writable"); + assert_eq!( + std::fs::read_to_string(resolved.join("out.md")).unwrap(), + "x" + ); +} + +#[test] +fn envelope_exposes_export_only_for_the_declared_capability() { + let mut harness = ExportHarness::new(); + + { + let mut bundle = harness.app.command_contexts(); + let export_only = bundle + .contexts(CommandCapabilities::SESSION_EXPORT) + .into_parts(); + // Exhaustive across all sixteen `ContextParts` slots (see the helper), + // so a newly added facet cannot silently join the export envelope. + assert_only_export_facet_exposed(export_only); + } + + { + let mut bundle = harness.app.command_contexts(); + let unrelated_only = bundle.contexts(CommandCapabilities::SESSION).into_parts(); + assert!( + unrelated_only.export.is_none(), + "export must not be exposed without its declared capability" + ); + } +} + +// --------------------------------------------------------------------------- +// FEAT-025 Phase 4: full-command parity regressions relocated from the legacy +// `groups/session/export.rs` tests. They dispatch through the public command +// seam so the portable handler and the TUI export adapter are exercised +// together against real clipboard, filesystem, and snapshot fixtures. +// --------------------------------------------------------------------------- + +fn dispatch(app: &mut App, arg: Option<&str>) -> CommandResult { + match arg { + Some(arg) => execute(&format!("/export {arg}"), app), + None => execute("/export", app), + } +} + +// --------------------------------------------------------------------------- +// FEAT-025 audit hardening: full-document goldens captured from the +// pre-refactor implementation at `3f3aa9ed7` (see +// `fixtures/export_conversation_baseline.md` and `export_turn_baseline.md`). +// +// The goldens were produced by dispatching the same fixture through the +// *baseline* public `/export` seam in a scratch worktree (repository state +// `3f3aa9ed7`), not authored from the migrated implementation, so a +// divergence in the adapter projection or the portable renderer fails here +// with an exact byte offset. Only the two host-derived stamps (the export +// `- Exported:` line and the turn-handoff header) are normalised; every other +// byte, including redaction markers and omission text, is compared verbatim. +// +// To re-capture, reproduce the fixture below against a verified baseline and +// replace the fixture files - never regenerate them from the new code, which +// would turn this into a tautology. +// --------------------------------------------------------------------------- + +/// Workspace pinned by the captured goldens (a path with no snapshot repo, so +/// the baseline restore-point state is `None`). +const BASELINE_WORKSPACE: &str = "/workspace/example"; + +/// Session id pinned by the captured goldens. +const BASELINE_SESSION: &str = "session-123456789"; + +/// The exact transcript fixture the baseline goldens were captured from. +fn baseline_golden_messages() -> Vec { + vec![ + Message { + role: Role::System, + content: vec![ContentBlock::Text { + text: "hidden policy must never export".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Please inspect this\u{1b}[31m output\u{1b}[0m".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "private chain of thought".to_string(), + signature: Some("signature-secret".to_string()), + state: None, + }, + ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "fetch_url".to_string(), + input: serde_json::json!({ + "url": "https://alice:password@example.com/path?token=very-secret&ok=1", + "api_key": "literal-api-secret", + "nested": {"authorization": "Bearer abcdefghijklmnop"}, + }), + caller: Some(ToolCaller { + caller_type: "code_execution_20250825".to_string(), + tool_id: Some("server-tool-1".to_string()), + }), + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "Authorization: Bearer another-secret-token\nresult ok".to_string(), + is_error: Some(false), + content_blocks: Some(vec![ + serde_json::json!({ + "type": "image", + "mime_type": "image/png", + "data": "base64verysecretimagedata", + }), + serde_json::json!({"session_token": "session-secret", "note": "keep me"}), + ]), + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "https://example.com/visible.png?token=very-secret".to_string(), + }, + }, + ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "data:image/png;base64,very-secret-image-data".to_string(), + }, + }, + ], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::ServerToolUse { + id: "srv-1".to_string(), + name: "web_search".to_string(), + input: serde_json::json!({"query": "secret token"}), + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolSearchToolResult { + tool_use_id: "srv-1".to_string(), + content: serde_json::json!({"results": []}), + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::CodeExecutionToolResult { + tool_use_id: "srv-2".to_string(), + content: serde_json::json!({"stdout": "ok"}), + }], + }, + ] +} + +#[test] +fn command_clipboard_export_matches_baseline_conversation_golden() { + let mut harness = ExportHarness::new(); + harness.app.workspace = PathBuf::from(BASELINE_WORKSPACE); + harness.app.current_session_id = Some(BASELINE_SESSION.to_string()); + harness.app.clipboard = ClipboardHandler::for_test(false, false); + harness.app.api_messages = baseline_golden_messages(); + + let result = dispatch(&mut harness.app, None); + assert!(!result.is_error, "{:?}", result.message); + let markdown = harness + .app + .clipboard + .last_written_text() + .expect("clipboard payload") + .to_string(); + + let expected = normalize_export_time(include_str!("fixtures/export_conversation_baseline.md")); + crate::test_support::assert_byte_identical( + "baseline conversation export document", + &normalize_export_time(&markdown), + &expected, + ); +} + +#[test] +fn command_turn_export_matches_baseline_conversation_golden() { + let mut harness = ExportHarness::new(); + harness.app.workspace = PathBuf::from(BASELINE_WORKSPACE); + harness.app.clipboard = ClipboardHandler::for_test(false, false); + harness.app.history.push(HistoryCell::User { + content: "Fix the flaky login test".to_string(), + }); + harness.app.history.push(HistoryCell::Assistant { + content: "Fixed the login test.".to_string(), + streaming: false, + }); + harness.app.runtime_turn_status = Some("completed".to_string()); + + let result = dispatch(&mut harness.app, Some("turn")); + assert!(!result.is_error, "{:?}", result.message); + let markdown = harness + .app + .clipboard + .last_written_text() + .expect("clipboard payload") + .to_string(); + + let expected = normalize_turn_generated_at(include_str!("fixtures/export_turn_baseline.md")); + crate::test_support::assert_byte_identical( + "baseline turn export document", + &normalize_turn_generated_at(&markdown), + &expected, + ); +} + +// --------------------------------------------------------------------------- +// FEAT-025 audit re-pass (finding G1): the first golden covered only the +// `RestorePointProjection::None` state and the authoritative transcript, so the +// `Recorded` restore table, the correlation lines, and the visible-history +// fallback were still asserted by hand-written expectations - the same class of +// weakness the first audit raised. +// +// These two goldens were captured the same way from baseline `3f3aa9ed7`: +// `fixtures/export_history_fallback_recorded_baseline.md` and +// `fixtures/export_correlation_recorded_baseline.md`. Snapshot commits carry a +// wall-clock author date, so the snapshot id and the `Recorded (UTC)` cell are +// normalised; the table structure, id truncation, correlation wording, the +// ambiguity warning, and the no-match line are compared verbatim. +// --------------------------------------------------------------------------- + +/// Workspace **basename** pinned by the recorded-restore goldens. The export +/// header prints only the basename, so a per-test directory with this name +/// reproduces the captured document exactly while staying inside `TempDir`. +const BASELINE_RECORDED_WORKSPACE: &str = "f025-golden-workspace"; + +/// Seed the three snapshots the recorded goldens were captured with. +/// +/// Creation order matters: `SnapshotRepo::list` returns newest first, so this +/// yields `pre-turn:3`, `tool:call-1`, `pre-turn:2` in the table, and two +/// `pre-turn` entries sharing a prompt snippet - which is what makes the +/// correlation line ambiguous. +fn seed_baseline_restore_points(workspace: &Path) { + let repo = SnapshotRepo::open_or_init(workspace).expect("open side repo"); + repo.snapshot("pre-turn:2: Fix the login test") + .expect("snapshot 1"); + repo.snapshot("tool:call-1").expect("snapshot 2"); + repo.snapshot("pre-turn:3: Fix the login test") + .expect("snapshot 3"); +} + +fn recorded_workspace(harness: &ExportHarness) -> PathBuf { + let workspace = harness.temp.path().join(BASELINE_RECORDED_WORKSPACE); + std::fs::create_dir_all(&workspace).expect("workspace"); + seed_baseline_restore_points(&workspace); + workspace +} + +#[test] +fn command_history_fallback_export_matches_baseline_recorded_golden() { + let mut harness = ExportHarness::new(); + let workspace = recorded_workspace(&harness); + harness.app.workspace = workspace; + harness.app.current_session_id = Some("session-fallback".to_string()); + harness.app.clipboard = ClipboardHandler::for_test(false, false); + harness.app.history.push(HistoryCell::User { + content: "Fix the login test".to_string(), + }); + harness.app.history.push(HistoryCell::Assistant { + content: "Done.".to_string(), + streaming: false, + }); + + let result = dispatch(&mut harness.app, None); + assert!(!result.is_error, "{:?}", result.message); + let markdown = harness + .app + .clipboard + .last_written_text() + .expect("clipboard payload") + .to_string(); + + let expected = normalize_recorded_export(include_str!( + "fixtures/export_history_fallback_recorded_baseline.md" + )); + crate::test_support::assert_byte_identical( + "baseline history-fallback recorded export document", + &normalize_recorded_export(&markdown), + &expected, + ); +} + +#[test] +fn command_correlation_export_matches_baseline_recorded_golden() { + let mut harness = ExportHarness::new(); + let workspace = recorded_workspace(&harness); + harness.app.workspace = workspace; + harness.app.current_session_id = Some("session-correlated".to_string()); + harness.app.clipboard = ClipboardHandler::for_test(false, false); + harness.app.api_messages = vec![ + text_message(Role::User, "Fix the login test"), + text_message(Role::Assistant, "Working on it."), + text_message(Role::User, "unrelated question"), + ]; + + let result = dispatch(&mut harness.app, None); + assert!(!result.is_error, "{:?}", result.message); + let markdown = harness + .app + .clipboard + .last_written_text() + .expect("clipboard payload") + .to_string(); + + let expected = normalize_recorded_export(include_str!( + "fixtures/export_correlation_recorded_baseline.md" + )); + crate::test_support::assert_byte_identical( + "baseline correlated recorded export document", + &normalize_recorded_export(&markdown), + &expected, + ); +} + +#[test] +fn export_entry_registers_through_portable_bridge_with_exact_metadata() { + use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; + + let command = crate::commands::registry() + .get("export") + .expect("/export must be registered"); + assert_eq!(command.info().name, "export"); + assert_eq!(command.info().aliases, &["daochu"]); + assert_eq!( + command.info().usage, + "/export [clipboard|file [--force] |turn [clipboard|file [--force] ]]" + ); + assert!( + crate::commands::registry().get("daochu").is_some(), + "the /daochu alias must resolve to /export" + ); + let handler = command + .contextual_handler() + .expect("/export must register through the portable bridge"); + let CommandHandler::Contextual { capabilities, .. } = handler else { + panic!("/export must be contextual") + }; + assert_eq!( + capabilities, + CommandCapabilities::SESSION_EXPORT, + "/export declares export authority only" + ); +} + +#[test] +fn command_clipboard_export_preserves_structure_and_redacts_secrets() { + let mut harness = ExportHarness::new(); + let app = &mut harness.app; + app.current_session_id = Some("session-123456789".to_string()); + app.api_messages = vec![ + Message { + role: Role::System, + content: vec![ContentBlock::Text { + text: "hidden policy must never export".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Please inspect this\u{1b}[31m output\u{1b}[0m".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "private chain of thought".to_string(), + signature: Some("signature-secret".to_string()), + state: None, + }, + ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "fetch_url".to_string(), + input: serde_json::json!({ + "url": "https://alice:password@example.com/path?token=very-secret&ok=1", + "api_key": "literal-api-secret", + "nested": {"authorization": "Bearer abcdefghijklmnop"}, + }), + caller: Some(ToolCaller { + caller_type: "code_execution_20250825".to_string(), + tool_id: Some("server-tool-1".to_string()), + }), + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-1".to_string(), + content: "Authorization: Bearer another-secret-token\nresult ok".to_string(), + is_error: Some(false), + content_blocks: Some(vec![serde_json::json!({ + "image": "https://example.com/a.png?api_key=hidden", + "session_token": "session-secret", + })]), + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::ImageUrl { + image_url: ImageUrlContent { + url: "data:image/png;base64,very-secret-image-data".to_string(), + }, + }], + }, + ]; + { + let mut todos = app.todos.try_lock().expect("todos lock"); + todos.add( + "export projection".to_string(), + crate::tools::todo::TodoStatus::InProgress, + ); + } + app.cycle_effort(); + let work_before = app.work_state_snapshot().expect("Work snapshot"); + + let result = dispatch(app, None); + + assert!(!result.is_error, "{:?}", result.message); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("local clipboard") + ); + let markdown = app + .clipboard + .last_written_text() + .expect("clipboard payload"); + let system = markdown.find("## 1. system").expect("system role"); + let user = markdown.find("## 2. user").expect("user role"); + let assistant = markdown.find("## 3. assistant").expect("assistant role"); + let tool_result = markdown.find("## 4. user").expect("tool-result role"); + assert!(system < user && user < assistant && assistant < tool_result); + assert!(markdown.contains("[internal context omitted]")); + assert!(markdown.contains("call-1")); + assert!(markdown.contains("fetch_url")); + assert!(markdown.contains("server-tool-1")); + assert!(markdown.contains("[internal reasoning and signature omitted]")); + assert!(markdown.contains("[redacted]")); + assert!(markdown.contains("https://***:***@example.com/path?token=***&ok=1")); + assert!(markdown.contains("Reference omitted (inline or local image payload)")); + let workspace_path = harness.temp.path().to_string_lossy().into_owned(); + for forbidden in [ + "hidden policy must never export", + "private chain of thought", + "signature-secret", + "literal-api-secret", + "very-secret", + "another-secret-token", + "session-secret", + "very-secret-image-data", + "\u{1b}[31m", + workspace_path.as_str(), + ] { + assert!( + !markdown.contains(forbidden), + "leaked {forbidden:?}: {markdown}" + ); + } + assert_eq!( + app.work_state_snapshot() + .expect("Work snapshot after export"), + work_before, + "export must not mutate Work" + ); +} + +#[test] +fn command_clipboard_reports_ssh_terminal_client_and_failure_honestly() { + let mut harness = ExportHarness::new(); + let app = &mut harness.app; + app.clipboard = ClipboardHandler::for_test(true, true); + let ssh = dispatch(app, Some("clipboard")); + assert!(!ssh.is_error, "{:?}", ssh.message); + assert!( + ssh.message + .as_deref() + .unwrap_or_default() + .contains("terminal-client clipboard over SSH") + ); + assert!( + ssh.message + .as_deref() + .unwrap_or_default() + .contains("last-copy.md"), + "success must name the backup copy: {:?}", + ssh.message + ); + + app.clipboard = ClipboardHandler::unavailable_for_test(false); + let failed = dispatch(app, Some("clipboard")); + assert!(failed.is_error); + let message = failed.message.as_deref().unwrap_or_default(); + assert!( + message.contains("The full export was written to"), + "{message}" + ); + assert!(message.contains("last-copy.md"), "{message}"); + assert!(message.contains("/export file "), "{message}"); + assert!(!harness.temp.path().join("chat_export.md").exists()); + assert!( + harness + .temp + .path() + .join("home/exports/last-copy.md") + .exists() + ); +} + +#[test] +fn command_file_export_is_workspace_relative_private_and_no_overwrite_by_default() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let app = &mut harness.app; + app.workspace = workspace.clone(); + app.api_messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "first export".to_string(), + cache_control: None, + }], + }); + + let first = dispatch(app, Some("file transcript.md")); + assert!(!first.is_error, "{:?}", first.message); + let path = std::fs::canonicalize(&workspace) + .unwrap() + .join("transcript.md"); + let original = std::fs::read_to_string(&path).expect("first export"); + assert!(original.contains("first export")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + + app.api_messages[0].content = vec![ContentBlock::Text { + text: "replacement export".to_string(), + cache_control: None, + }]; + let refused = dispatch(app, Some("transcript.md")); + assert!(refused.is_error); + assert_eq!(std::fs::read_to_string(&path).unwrap(), original); + + let forced = dispatch(app, Some("file --force transcript.md")); + assert!(!forced.is_error, "{:?}", forced.message); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("replacement export") + ); +} + +#[test] +fn command_file_export_rejects_traversal_missing_parent_and_invalid_usage() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let app = &mut harness.app; + app.workspace = workspace.clone(); + + for arg in [ + "file ../outside.md", + "file missing/export.md", + "file", + "file --force", + "clipboard extra.md", + "turn clipboard extra.md", + ] { + let result = dispatch(app, Some(arg)); + assert!(result.is_error, "{arg}: {:?}", result.message); + } + assert!(!harness.temp.path().join("outside.md").exists()); +} + +#[cfg(unix)] +#[test] +fn command_file_export_rejects_symlink_leaf_and_ancestor() { + use std::os::unix::fs::symlink; + + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let app = &mut harness.app; + app.workspace = workspace.clone(); + + let real_file = workspace.join("real.md"); + std::fs::write(&real_file, "keep").expect("fixture file"); + let leaf = workspace.join("leaf.md"); + symlink(&real_file, &leaf).expect("leaf symlink"); + let leaf_result = dispatch(app, Some(&format!("file --force {}", leaf.display()))); + assert!(leaf_result.is_error, "{:?}", leaf_result.message); + assert_eq!(std::fs::read_to_string(&real_file).unwrap(), "keep"); + + let real_dir = workspace.join("real-dir"); + std::fs::create_dir(&real_dir).expect("real dir"); + let linked_dir = workspace.join("linked-dir"); + symlink(&real_dir, &linked_dir).expect("dir symlink"); + let ancestor_result = dispatch( + app, + Some(&format!("file {}", linked_dir.join("out.md").display())), + ); + assert!(ancestor_result.is_error, "{:?}", ancestor_result.message); + assert!(!real_dir.join("out.md").exists()); +} + +#[test] +fn command_turn_export_supports_clipboard_and_safe_legacy_file_destination() { + let mut harness = ExportHarness::new(); + let app = &mut harness.app; + app.history.push(HistoryCell::User { + content: "Fix the flaky login test".to_string(), + }); + app.history.push(HistoryCell::Assistant { + content: "Fixed the login test.".to_string(), + streaming: false, + }); + app.runtime_turn_status = Some("completed".to_string()); + + let clipboard = dispatch(app, Some("turn")); + assert!(!clipboard.is_error, "{:?}", clipboard.message); + assert!( + app.clipboard + .last_written_text() + .unwrap_or_default() + .contains("# Turn handoff") + ); + + let path = harness.temp.path().join("handoff.md"); + let file = dispatch(app, Some(&format!("turn {}", path.display()))); + assert!(!file.is_error, "{:?}", file.message); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("Fix the flaky login test") + ); + let refused = dispatch(app, Some(&format!("turn {}", path.display()))); + assert!(refused.is_error); +} + +#[test] +fn command_export_does_not_create_a_snapshot_repo_for_a_fresh_workspace() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let app = &mut harness.app; + app.workspace = workspace.clone(); + let before = crate::snapshot::snapshot_git_dir(&workspace); + assert!(!before.exists(), "precondition: no side repo yet"); + + let result = dispatch(app, Some("clipboard")); + assert!(!result.is_error, "{:?}", result.message); + + assert!( + !crate::snapshot::snapshot_git_dir(&workspace).exists(), + "export must never create the side repo" + ); +} + +#[test] +fn adapter_projects_metadata_fallbacks_without_session_or_filename() { + let mut harness = ExportHarness::new(); + // No session id: the baseline label is `unsaved`. A workspace whose final + // component is `..` has no `file_name()`, so the label falls back too. + harness.app.current_session_id = None; + harness.app.workspace = harness.temp.path().join(".."); + + let projection = conversation_projection(&mut harness.app); + + assert_eq!(projection.metadata.session_label, "unsaved"); + assert_eq!(projection.metadata.workspace_name, "workspace"); + assert_eq!(projection.metadata.message_count, 0); +} + +#[test] +fn adapter_bounds_restore_points_to_the_latest_hundred_newest_first() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + harness.app.workspace = workspace.clone(); + let repo = SnapshotRepo::open_or_init(&workspace).expect("open side repo"); + + // 101 recorded points prove the adapter's latest-100 window: exactly one + // entry (the oldest) must be dropped. + for sequence in 0..=100 { + repo.snapshot(&format!("pre-turn:{sequence}: prompt {sequence}")) + .expect("record snapshot"); + } + + let projection = conversation_projection(&mut harness.app); + + let RestorePointProjection::Recorded { snapshots } = &projection.restore_points else { + panic!("recorded restore points expected"); + }; + assert_eq!(snapshots.len(), 100, "the adapter lists at most 100 points"); + assert_eq!( + snapshots.first().map(|snapshot| snapshot.sequence), + Some(Some(100)), + "newest point is first" + ); + assert_eq!( + snapshots.last().map(|snapshot| snapshot.sequence), + Some(Some(1)), + "the window stops at the 100th newest point" + ); + assert!( + snapshots + .iter() + .all(|snapshot| snapshot.sequence != Some(0)), + "the oldest (101st) point must not cross the window" + ); +} + +#[test] +fn adapter_rejects_directory_destination_and_parent_not_a_directory() { + let mut harness = ExportHarness::new(); + let workspace = canonical_workspace(&harness); + harness.app.workspace = workspace.clone(); + + let directory = workspace.join("existing-dir"); + std::fs::create_dir(&directory).expect("directory target"); + let refused = write_file(&mut harness.app, &directory, b"x", true).unwrap_err(); + assert!( + refused.contains("refusing to replace a non-regular file"), + "{refused}" + ); + assert!(directory.is_dir(), "the directory must be untouched"); + + let file_parent = workspace.join("not-a-dir"); + std::fs::write(&file_parent, "file").expect("file parent"); + let parent_error = + write_file(&mut harness.app, &file_parent.join("out.md"), b"x", false).unwrap_err(); + assert!( + parent_error.contains("parent is not a directory"), + "{parent_error}" + ); + assert!(!file_parent.join("out.md").exists()); +} + +#[test] +fn adapter_resolves_absolute_paths_and_keeps_the_lexical_fallback() { + let mut harness = ExportHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + harness.app.workspace = workspace.clone(); + let canonical_workspace = std::fs::canonicalize(&workspace).expect("canonical workspace"); + + // A raw workspace-absolute path rebases onto the resolved workspace. + assert_eq!( + resolve( + &mut harness.app, + &workspace.join("abs.md").to_string_lossy() + ) + .expect("raw absolute"), + canonical_workspace.join("abs.md") + ); + // A canonicalized workspace-absolute path rebases the same way. + assert_eq!( + resolve( + &mut harness.app, + &canonical_workspace.join("nested/abs.md").to_string_lossy() + ) + .expect("canonical absolute"), + canonical_workspace.join("nested/abs.md") + ); + // A path outside the workspace stays absolute and is not rebased. + let outside = harness.temp.path().join("outside.md"); + assert_eq!( + resolve(&mut harness.app, &outside.to_string_lossy()).expect("outside absolute"), + outside + ); + + // A workspace that no longer exists falls back to the lexical path instead + // of failing canonicalization. + let missing = harness.temp.path().join("gone"); + harness.app.workspace = missing.clone(); + assert_eq!( + resolve(&mut harness.app, "relative.md").expect("lexical fallback"), + missing.join("relative.md") + ); +} diff --git a/crates/tui/src/commands/session_export_surface_tests.rs b/crates/tui/src/commands/session_export_surface_tests.rs new file mode 100644 index 0000000000..80d1704eb9 --- /dev/null +++ b/crates/tui/src/commands/session_export_surface_tests.rs @@ -0,0 +1,718 @@ +//! FEAT-025 Phase 5: public command-surface parity coverage for `/export`. +//! +//! Phase 4 proved handler/rendering/adapter parity with fake facets and +//! relocated host regressions. This module proves the *observable command +//! surface* did not move when registration crossed the portable bridge: +//! +//! * registry metadata (name, alias, usage) and registry position, +//! * the `description_key` -> catalog bridge and its English/localized text, +//! * palette and slash-completion discovery, including the `/daochu` alias, +//! * least authority: exactly `SESSION_EXPORT` and no presentation facet, +//! * canonical-name/alias dispatch equivalence through the public `execute` +//! seam, with exact receipts and exact visible errors. +//! +//! Like the Phase 3 host regressions, this file deliberately lives at the +//! `commands` root — outside `groups/session`, which FEAT-043 moves into +//! `codewhale-commands`. No real terminal clipboard, GUI, or device is needed: +//! the harness uses the deterministic in-process clipboard and temporary +//! filesystem fixtures. + +use tempfile::TempDir; + +use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; + +use crate::commands::session_export_test_support::{ + assert_only_export_facet_exposed, normalize_export_time, +}; +use crate::commands::traits::CommandDiscovery; +use crate::commands::{CommandResult, execute}; +use crate::config::{ApiProvider, Config}; +use crate::test_support::{EnvVarGuard, TestEnvLock}; +use crate::tui::app::{App, TuiOptions}; +use crate::tui::clipboard::ClipboardHandler; +use crate::tui::command_palette; +use codewhale_localization::Locale; +use codewhale_models::{ContentBlock, Message, Role}; + +const EXPORT_NAME: &str = "export"; +const EXPORT_ALIAS: &str = "daochu"; +const EXPORT_USAGE: &str = + "/export [clipboard|file [--force] |turn [clipboard|file [--force] ]]"; +// Pinned to the authoritative catalog values (`crates/localization/locales/{en,fr}.json`, +// key `CmdExportDescription`). The `description_key` bridge must keep resolving the +// `/export` metadata to this catalog entry; the wording itself is owned by the catalog. +const EXPORT_ENGLISH: &str = "Copy a safe export, or write it to a file"; +const EXPORT_FRENCH: &str = + "Copier un export sûr de la conversation, ou l'écrire dans un fichier explicite"; + +/// Shared host-isolated harness (same isolation order as the Phase 3 +/// `ExportHarness`: env guards, then the lock, then the temporary directory). +struct SurfaceHarness { + app: App, + _home: EnvVarGuard, + _codewhale_home: EnvVarGuard, + _env_lock: TestEnvLock, + temp: TempDir, +} + +impl SurfaceHarness { + fn new() -> Self { + let env_lock = crate::test_support::lock_test_env(); + let temp = TempDir::new().expect("tempdir"); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).expect("home dir"); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home); + let options = TuiOptions { + skills_dir: temp.path().join("skills"), + memory_path: temp.path().join("memory.md"), + notes_path: temp.path().join("notes.txt"), + mcp_config_path: temp.path().join("mcp.json"), + ..crate::test_support::test_tui_options(temp.path()) + }; + let app = App::new(options, &Config::default()); + Self { + app, + _home, + _codewhale_home, + _env_lock: env_lock, + temp, + } + } + + fn last_copy_path(&self) -> std::path::PathBuf { + self.temp + .path() + .join("home") + .join("exports") + .join("last-copy.md") + } +} + +fn text_message(role: Role, text: &str) -> Message { + Message { + role, + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } +} + +fn export_info() -> &'static crate::commands::CommandInfo { + crate::commands::get_command_info(EXPORT_NAME).expect("/export must be registered") +} + +fn result_message(result: &CommandResult) -> &str { + result.message.as_deref().unwrap_or_default() +} + +#[test] +fn export_registration_metadata_and_registry_position_are_unchanged() { + let info = export_info(); + assert_eq!(info.name, EXPORT_NAME); + assert_eq!(info.aliases, &[EXPORT_ALIAS]); + assert_eq!(info.usage, EXPORT_USAGE); + + // The alias resolves to the same registry entry and canonical metadata. + let registry = crate::commands::registry(); + let via_alias = registry + .get(EXPORT_ALIAS) + .expect("/daochu must resolve to the export entry"); + assert_eq!(via_alias.info().name, EXPORT_NAME); + assert_eq!(via_alias.info().usage, EXPORT_USAGE); + assert!( + registry.get(EXPORT_NAME).is_some(), + "canonical /export must remain registered" + ); + + // Registry order is preserved: remote-env -> export -> structcopy. + let names: Vec<&str> = crate::commands::command_infos() + .iter() + .map(|info| info.name) + .collect(); + let position = |name: &str| { + names + .iter() + .position(|candidate| *candidate == name) + .unwrap_or_else(|| panic!("{name} must be registered; found {names:?}")) + }; + assert!( + position("remote-env") < position(EXPORT_NAME), + "export must stay after remote-env in the session group order" + ); + assert!( + position(EXPORT_NAME) < position("structcopy"), + "export must stay before structcopy in the session group order" + ); +} + +#[test] +fn export_declares_exactly_session_export_without_presentation_authority() { + let command = crate::commands::registry() + .get(EXPORT_NAME) + .expect("/export must be registered"); + let handler = command + .contextual_handler() + .expect("/export must register through the portable bridge"); + let CommandHandler::Contextual { capabilities, .. } = handler else { + panic!("/export must be a contextual handler") + }; + assert_eq!( + capabilities, + CommandCapabilities::SESSION_EXPORT, + "/export declares exactly SESSION_EXPORT" + ); + assert!( + !capabilities.contains(CommandCapabilities::PRESENTATION), + "export must not request presentation authority" + ); + + // The restricted projection exposes only the declared facet. The helper + // destructures all sixteen `ContextParts` slots, so this is exhaustive + // rather than a spot-check of the fields listed below by hand. + let mut harness = SurfaceHarness::new(); + let mut bundle = harness.app.command_contexts(); + let export_only = bundle + .contexts(CommandCapabilities::SESSION_EXPORT) + .into_parts(); + assert_only_export_facet_exposed(export_only); +} + +#[test] +fn export_description_bridge_preserves_english_localized_and_discovery_metadata() { + let info = export_info(); + + // `description_key` -> `key_to_message_id` -> catalog: English reference + // and the shipped French pack both resolve through the same bridge. + assert_eq!(&*info.description_for(Locale::En), EXPORT_ENGLISH); + assert_eq!(&*info.description_for(Locale::Fr), EXPORT_FRENCH); + + // Palette text keeps the canonical description and advertises the alias. + let palette = info.palette_description_for(Locale::En); + assert!( + palette.contains(EXPORT_ENGLISH), + "palette description must keep the catalog text: {palette}" + ); + assert!( + palette.contains(EXPORT_ALIAS), + "palette description must keep the alias: {palette}" + ); + + // Discovery classification and visibility are part of the surface. + assert_eq!(info.discovery(), CommandDiscovery::Primary); + assert!(!info.is_unlisted()); + assert!(info.show_in_empty_discovery()); + assert!(info.show_in_slash_completion("/exp")); + assert!(info.requires_argument()); +} + +#[test] +fn export_is_discoverable_by_name_and_alias_in_palette_and_slash_completion() { + // Pure discovery: a workspace path is all these surfaces need, so this test + // avoids the shared environment lock the host fixtures require. + let workspace_dir = TempDir::new().expect("tempdir"); + let workspace = workspace_dir.path(); + let skills_dir = workspace.join("skills"); + let mcp_config = workspace.join("mcp.json"); + + let entries = command_palette::build_entries( + Locale::En, + &skills_dir, + false, + workspace, + &mcp_config, + None, + ); + let export_row = entries + .iter() + .find(|entry| entry.label == "/export") + .expect("/export must appear in the command palette"); + assert!( + export_row.description.contains(EXPORT_ENGLISH), + "palette row must carry the catalog description: {}", + export_row.description + ); + assert!( + export_row.description.contains(EXPORT_ALIAS), + "palette row must advertise /daochu: {}", + export_row.description + ); + + let by_prefix = crate::tui::widgets::slash_completion_hints( + "/exp", + 64, + &[], + Locale::En, + Some(workspace), + ApiProvider::Deepseek, + ); + assert!( + by_prefix.iter().any(|hint| hint.name == "/export"), + "/exp must complete to /export" + ); + + let by_alias = crate::tui::widgets::slash_completion_hints( + "/daoc", + 64, + &[], + Locale::En, + Some(workspace), + ApiProvider::Deepseek, + ); + let alias_row = by_alias + .iter() + .find(|hint| hint.name == "/export") + .expect("/daoc must surface the export command"); + assert_eq!( + alias_row.alias_hint.as_deref(), + Some(EXPORT_ALIAS), + "slash completion must explain the alias match" + ); + + // The alias token is not a second registry entry. + assert!( + !entries.iter().any(|entry| entry.label == "/daochu"), + "the alias must not create a duplicate palette row" + ); +} + +#[test] +fn public_dispatch_canonical_name_and_alias_are_byte_equivalent() { + let mut harness = SurfaceHarness::new(); + let last_copy = harness.last_copy_path(); + let app = &mut harness.app; + app.current_session_id = Some("session-987654321".to_string()); + app.api_messages = vec![ + text_message(Role::User, "Please export this conversation"), + text_message(Role::Assistant, "Exported on request."), + ]; + app.clipboard = ClipboardHandler::for_test(false, false); + + let canonical = execute("/export clipboard", app); + assert!(!canonical.is_error, "{:?}", canonical.message); + let canonical_markdown = app + .clipboard + .last_written_text() + .expect("canonical clipboard payload") + .to_string(); + let canonical_recovery = std::fs::read_to_string(&last_copy).expect("canonical recovery copy"); + + // Reset the deterministic clipboard so the second dispatch records its own + // delivery; state is otherwise identical. + app.clipboard = ClipboardHandler::for_test(false, false); + + let alias = execute("/daochu clipboard", app); + assert!(!alias.is_error, "{:?}", alias.message); + let alias_markdown = app + .clipboard + .last_written_text() + .expect("alias clipboard payload") + .to_string(); + let alias_recovery = std::fs::read_to_string(&last_copy).expect("alias recovery copy"); + + assert_eq!( + result_message(&canonical), + result_message(&alias), + "canonical and alias receipts must be identical" + ); + assert_eq!( + normalize_export_time(&canonical_markdown), + normalize_export_time(&alias_markdown), + "canonical and alias clipboard payloads must be identical" + ); + assert_eq!( + normalize_export_time(&canonical_recovery), + normalize_export_time(&alias_recovery), + "canonical and alias recovery copies must be identical" + ); + assert!(canonical_markdown.contains("# Codewhale conversation export")); + assert!(canonical_markdown.contains("Please export this conversation")); +} + +#[test] +fn public_dispatch_file_receipts_and_usage_errors_are_exact() { + let mut harness = SurfaceHarness::new(); + let workspace = harness.temp.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace"); + let app = &mut harness.app; + app.workspace = workspace.clone(); + app.api_messages = vec![text_message(Role::User, "file export body")]; + + let resolved = std::fs::canonicalize(&workspace) + .expect("canonical workspace") + .join("transcript.md"); + + let first = execute("/export file transcript.md", app); + assert!(!first.is_error, "{:?}", first.message); + assert_eq!( + result_message(&first), + format!("Conversation exported to {}", resolved.display()) + ); + + let refused = execute("/export file transcript.md", app); + assert!(refused.is_error, "{:?}", refused.message); + assert_eq!( + result_message(&refused), + format!( + "Error: Failed to export Conversation to {}: destination already exists: {}. Re-run with `/export file --force ` to replace it", + resolved.display(), + resolved.display() + ) + ); + + let forced = execute("/export file --force transcript.md", app); + assert!(!forced.is_error, "{:?}", forced.message); + assert_eq!( + result_message(&forced), + format!( + "Conversation exported to {} (overwrite explicitly allowed)", + resolved.display() + ) + ); + + let usage = export_info().usage; + for (arg, reason) in [ + ("file", "missing file path"), + ("file --force", "missing file path"), + ("clipboard extra.md", "clipboard does not accept a path"), + ] { + let result = execute(&format!("/export {arg}"), app); + assert!(result.is_error, "{arg} must be rejected"); + assert_eq!( + result_message(&result), + format!("Error: {reason}. Usage: {usage}"), + "{arg} must keep the baseline usage error" + ); + } +} + +// --------------------------------------------------------------------------- +// FEAT-025 Phase 7 (Task 7.2): extraction-readiness and scope audits. +// +// These audits protect the D4/D5/D8/D9 ownership contract that the portable +// source must satisfy before FEAT-043 can physically move the export slice into +// `codewhale-commands`. They are source-level checks, not runtime behavior +// tests, and they live at the `commands` root outside the movable group. +// --------------------------------------------------------------------------- + +/// Production portion of the portable export source: everything before its +/// `#[cfg(test)]` module, with comments removed (full-line and trailing) so +/// rationale text (`Concrete `App`, clipboard, filesystem, ...`) is never +/// mistaken for an import or a concrete host symbol. +fn portable_production_source(source: &str) -> String { + let mut production = String::new(); + for line in source.lines() { + if line.trim_start().starts_with("#[cfg(test)]") { + break; + } + let code = strip_line_comment(line); + if code.trim().is_empty() { + continue; + } + production.push_str(code.trim_end()); + production.push('\n'); + } + production +} + +/// Strip a trailing `//` comment without touching `//` inside a string or +/// character literal. +/// +/// A naive strip would be wrong in both directions: portable code carries URL +/// literals such as `https://…`, and a marker string could hide a real token. +/// Lifetime ticks (`&'a str`) are not treated as character literals so a +/// following comment is still removed. +fn strip_line_comment(line: &str) -> &str { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'"' => { + i += 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'"' => break, + _ => i += 1, + } + } + } + b'\'' => { + let opens_literal = matches!( + (bytes.get(i + 1), bytes.get(i + 2)), + (Some(b'\\'), _) | (Some(_), Some(b'\'')) + ); + if opens_literal { + i += 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'\'' => break, + _ => i += 1, + } + } + } + } + b'/' if bytes.get(i + 1) == Some(&b'/') => return &line[..i], + _ => {} + } + i += 1; + } + line +} + +/// Word-boundary identifier match. +/// +/// `production.contains("App")` fires on a legitimate `Append`, while an exact +/// equality check would miss `App::new`. `\b` matches the identifier token +/// only, so the audit fails for the right reason. +fn contains_identifier(haystack: &str, identifier: &str) -> bool { + let pattern = format!(r"\b{}\b", regex::escape(identifier)); + regex::Regex::new(&pattern) + .expect("identifier regex") + .is_match(haystack) +} + +/// The audits above are only as trustworthy as their scanners, so pin the two +/// behaviours that decide whether a finding is real: comment stripping must not +/// eat string literals, and identifier matching must not fire on a longer word. +#[test] +fn portable_source_audit_helpers_are_token_aware() { + assert_eq!(strip_line_comment("let x = 1; // App"), "let x = 1; "); + assert_eq!(strip_line_comment("// whole line"), ""); + assert_eq!( + strip_line_comment("let url = \"https://example.test/a\";"), + "let url = \"https://example.test/a\";" + ); + assert_eq!( + strip_line_comment("let c = '/'; let y = 1; // keep"), + "let c = '/'; let y = 1; " + ); + // A lifetime tick must not swallow the rest of the line. + assert_eq!( + strip_line_comment("fn f<'a>(x: &'a str) {} // note"), + "fn f<'a>(x: &'a str) {} " + ); + + assert!(contains_identifier("let app = App::new();", "App")); + assert!(!contains_identifier("values.append(item);", "App")); + assert!(!contains_identifier("struct AppNew;", "App")); + assert!(contains_identifier("use ratatui::text::Line;", "ratatui")); + assert!(contains_identifier("unsafe { x }", "unsafe")); +} + +#[test] +fn portable_export_source_has_no_host_dependency() { + let source = include_str!("groups/session/export.rs"); + let production = portable_production_source(source); + + // Only the external contract, the pure sanitizer, `serde_json`, `std`, and + // the temporary FEAT-037 `CommandResult` exception may be imported. + let allowed_use_prefixes = [ + "use std::", + "use codewhale_command_contract", + "use codewhale_secrets", + "use serde_json", + "use super::CommandResult", + ]; + for line in production.lines() { + let trimmed = line.trim_start(); + if !trimmed.starts_with("use ") { + continue; + } + assert!( + allowed_use_prefixes + .iter() + .any(|prefix| trimmed.starts_with(prefix)), + "portable /export import is not allowed: {trimmed}" + ); + } + + // No concrete host module or type may appear in portable production code. + // Syntactic tokens are matched literally; bare identifiers are matched on + // word boundaries so `Append` cannot false-trigger on `App`. + for token in [ + "use crate::", + "crate::tui", + "crate::client", + "crate::config", + "crate::snapshot", + "crate::session_manager", + "std::fs", + "std::net", + "std::process", + ] { + assert!( + !production.contains(token), + "portable /export must not reference {token}" + ); + } + for identifier in [ + "App", + "AppAction", + "ClipboardHandler", + "SnapshotRepo", + "SessionManager", + "HistoryCell", + "ContentBlock", + "OpenOptions", + "ratatui", + "crossterm", + ] { + assert!( + !contains_identifier(&production, identifier), + "portable /export must not reference {identifier}" + ); + } + + // The bounded FEAT-037 exception is exactly `CommandResult`: no action + // payload, deferred effect, or host receipt type may cross the boundary. + assert!( + !production.contains("super::App"), + "only CommandResult may cross the FEAT-037 boundary" + ); +} + +#[test] +fn portable_export_source_carries_no_hidden_authority() { + let source = include_str!("groups/session/export.rs"); + let production = portable_production_source(source); + + // Host authority must not hide behind a callback, boxed closure, erased + // receipt, or unsafe escape hatch. + for token in [ + "Box<", + "dyn Fn", + "impl Fn", + "fn(&mut dyn", + "&mut dyn", + "transmute", + ] { + assert!( + !production.contains(token), + "portable /export must not contain {token}" + ); + } + assert!( + !contains_identifier(&production, "unsafe"), + "portable /export must not contain unsafe" + ); + + // Missing authority fails with the exact safe error and never panics: the + // facet is destructured, not `.expect()`ed. + for token in [".expect(", ".unwrap(", "panic!(", "unreachable!(", "todo!("] { + assert!( + !production.contains(token), + "portable /export must not contain {token}" + ); + } + assert!( + production.contains( + "return CommandResult::error(\"Command capability unavailable: session_export\".to_string());" + ), + "portable /export must keep the exact safe missing-authority error" + ); +} + +#[test] +fn shared_sanitizer_is_one_pure_acyclic_implementation() { + // Both `/export` and the still-legacy `/structcopy` consume the single + // implementation in the pure `codewhale-secrets` crate. + let export_source = include_str!("groups/session/export.rs"); + let structcopy_source = include_str!("groups/session/structcopy.rs"); + assert!( + export_source.contains("use codewhale_secrets::sanitize::"), + "portable /export must consume the shared sanitizer" + ); + assert!( + structcopy_source.contains("use codewhale_secrets::sanitize::"), + "/structcopy must consume the same shared sanitizer" + ); + // The module prose must name the new owner: a stale `export::` seam + // reference is exactly the comment drift inherited from PR #5525. + for stale in ["export::redact_json", "export::sanitize_text"] { + assert!( + !structcopy_source.contains(stale), + "/structcopy comments must not cite the removed {stale} seam" + ); + } + + // Fast local tripwire only. The authoritative check is the graph scan in + // `scripts/check-command-crate-boundaries.py`, which asserts that neither + // `codewhale-command-contract` nor `codewhale-secrets` reaches the TUI; this + // manifest read just fails sooner when someone edits the manifest by hand. + let secrets_manifest = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../secrets/Cargo.toml" + )) + .expect("the shared sanitizer crate manifest must be readable"); + assert!( + !secrets_manifest.contains("codewhale-tui"), + "the shared sanitizer crate must not depend on codewhale-tui" + ); + + // There is no second sanitizer implementation hiding in the portable slice. + let production = portable_production_source(export_source); + for duplicated in [ + "fn sanitize_text", + "fn redact_json", + "fn redact_url_for_display", + "fn strip_ansi", + ] { + assert!( + !production.contains(duplicated), + "portable /export must not duplicate {duplicated}" + ); + } +} + +#[test] +fn host_bound_fixtures_stay_outside_the_movable_group() { + // FEAT-043 moves `groups/session` into `codewhale-commands`. Real-host + // fixtures and the shared recovery writer must therefore stay at the + // `commands` root, and the portable slice must not embed host fixtures. + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for relative in [ + "src/commands/session_export_regression_tests.rs", + "src/commands/session_export_surface_tests.rs", + "src/commands/session_export_test_support.rs", + "src/commands/session_export_host.rs", + ] { + assert!( + manifest_dir.join(relative).exists(), + "host fixture {relative} must stay outside groups/session" + ); + } + + // The baseline-captured export goldens are host-bound (they include the + // host-derived metadata and redaction output only the real adapter can + // produce), so they must live at the `commands` root with the suites that + // consume them rather than inside the movable group. + for relative in [ + "src/commands/fixtures/export_conversation_baseline.md", + "src/commands/fixtures/export_turn_baseline.md", + "src/commands/fixtures/export_history_fallback_recorded_baseline.md", + "src/commands/fixtures/export_correlation_recorded_baseline.md", + ] { + assert!( + manifest_dir.join(relative).exists(), + "baseline golden {relative} must stay outside groups/session" + ); + } + + let export_source = include_str!("groups/session/export.rs"); + for host_fixture in [ + "SessionExportAdapter", + "ExportHarness", + "tempfile", + "ClipboardHandler", + "SnapshotRepo", + "HistoryCell", + ] { + assert!( + !export_source.contains(host_fixture), + "portable /export must not embed the host fixture {host_fixture}" + ); + } +} diff --git a/crates/tui/src/commands/session_export_test_support.rs b/crates/tui/src/commands/session_export_test_support.rs new file mode 100644 index 0000000000..a5c9837208 --- /dev/null +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -0,0 +1,178 @@ +//! Shared, host-bound test support for the FEAT-025 session-export slice. +//! +//! Lives at the `commands` root — outside `groups/session`, which FEAT-043 +//! moves into `codewhale-commands` — so the public-surface and host-regression +//! suites reuse one implementation instead of drifting copies. + +use codewhale_command_contract::handler::ContextParts; + +use std::sync::OnceLock; + +use regex::Regex; + +/// Replace the host-derived `- Exported:` timestamp value so two exports of +/// identical state compare byte-for-byte. Production timestamp semantics stay +/// untouched (D3/observable-behavior rule: host metadata derivation is +/// preserved; tests control comparison, not the adapter). +pub(crate) fn normalize_export_time(markdown: &str) -> String { + let mut normalized = String::with_capacity(markdown.len()); + for line in markdown.split_inclusive('\n') { + if let Some(rest) = line.strip_prefix("- Exported: ") { + let newline = if rest.ends_with('\n') { "\n" } else { "" }; + normalized.push_str("- Exported: