mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Merge pull request #6096 from aboimpinto/feat/FEAT-025-adopt-command-shapes-in-tui-session-export-slice
FEAT-025: adopt capability shapes in the TUI session-export slice. Structural migration only; no user-visible behaviour change. Merged as three commits rather than squashed so Paulo's curated slice boundaries and authorship survive on main.
This commit is contained in:
30 files changed
+6380
-1973
No files matched your search
@@ -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
|
||||
|
||||
Generated
+3
@@ -874,6 +874,7 @@ name = "codewhale-command-contract"
|
||||
version = "0.9.13"
|
||||
dependencies = [
|
||||
"codewhale-core",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1057,12 +1058,14 @@ dependencies = [
|
||||
"chrono",
|
||||
"codewhale-paths",
|
||||
"keyring",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tracing",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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,
|
||||
@@ -1369,3 +1370,223 @@ pub trait CommandSessionControlContext {
|
||||
/// errors.
|
||||
fn resolve_hosted_work_target(&self) -> Option<HostedWorkTarget>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<ToolCallerProjection>,
|
||||
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<Value>,
|
||||
},
|
||||
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<ExportBlock>,
|
||||
pub prompt_snippet: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<ExportMessage>),
|
||||
HistoryFallback(Vec<HistoryEntry>),
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
pub prompt_snippet: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<RestoreSnapshot> },
|
||||
}
|
||||
|
||||
/// 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<PathBuf>;
|
||||
|
||||
/// 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<PathBuf, String>;
|
||||
|
||||
/// 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>;
|
||||
}
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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::<CommandCapabilities>(),
|
||||
std::mem::size_of::<u16>(),
|
||||
"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<ConversationExportProjection>,
|
||||
turn: Option<TurnHandoffProjection>,
|
||||
terminal_paste: bool,
|
||||
recovery: Option<Option<PathBuf>>,
|
||||
clipboard: Option<Result<(), String>>,
|
||||
resolved: Option<Result<PathBuf, String>>,
|
||||
write: Option<Result<(), String>>,
|
||||
calls: RefCell<Vec<String>>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<PathBuf, String> {
|
||||
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<String> = [
|
||||
"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());
|
||||
}
|
||||
@@ -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 <token>`),
|
||||
/// 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<String> = 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 <space> <sep> <space> 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<String> {
|
||||
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 <sep> value` assignment with a sensitive key, return the
|
||||
/// line with the value redacted; otherwise `None`.
|
||||
fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option<String> {
|
||||
// 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 {
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <token>`),
|
||||
/// 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<String> = 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 <space> <sep> <space> 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<String> {
|
||||
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 <sep> value` assignment with a sensitive key, return the
|
||||
/// line with the value redacted; otherwise `None`.
|
||||
fn redact_keyed_assignment(body: &str, policy: RedactionPolicy) -> Option<String> {
|
||||
// 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))
|
||||
}
|
||||
@@ -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 [ ... <final byte 0x40..=0x7E>
|
||||
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::<Vec<_>>()
|
||||
.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<Regex> = 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<Regex> = 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<Regex> = 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<Regex> = 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:[email protected]/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}");
|
||||
}
|
||||
}
|
||||
@@ -1125,59 +1125,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 {
|
||||
|
||||
@@ -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<MessageId> {
|
||||
|
||||
/// 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
|
||||
@@ -1485,6 +1490,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<PathBuf> {
|
||||
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<PathBuf, String> {
|
||||
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<ratatui::text::Line<'static>>) -> String {
|
||||
lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.into_iter()
|
||||
.map(|span| span.content.to_string())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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>,
|
||||
@@ -3940,7 +4256,7 @@ fn default_codewhale_tools_dir() -> Option<PathBuf> {
|
||||
// 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
|
||||
@@ -3961,6 +4277,7 @@ pub(crate) struct CommandContextBundle<'a> {
|
||||
plugin: PluginAdapter<'a>,
|
||||
lifecycle: SessionLifecycleAdapter<'a>,
|
||||
control: SessionControlAdapter<'a>,
|
||||
export: SessionExportAdapter<'a>,
|
||||
}
|
||||
|
||||
impl<'a> CommandContextBundle<'a> {
|
||||
@@ -4012,6 +4329,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
|
||||
}
|
||||
|
||||
@@ -4032,7 +4352,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()
|
||||
}
|
||||
}
|
||||
@@ -4059,7 +4380,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 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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 <N>` 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.
|
||||
|
||||
@@ -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 <N>` 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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::<remote_env::RemoteEnvCmd>()
|
||||
.expect("remote_env registration")
|
||||
),
|
||||
Box::new(FunctionCommand::new(
|
||||
export::ExportCmd::info(),
|
||||
export::ExportCmd::execute,
|
||||
)),
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<export::ExportCmd>()
|
||||
.expect("export registration")
|
||||
),
|
||||
Box::new(FunctionCommand::new(
|
||||
structcopy::StructcopyCmd::info(),
|
||||
structcopy::StructcopyCmd::execute,
|
||||
|
||||
@@ -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://…)`, `<https://…>`,
|
||||
|
||||
+137
-14
@@ -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;
|
||||
@@ -2089,6 +2105,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) {
|
||||
@@ -2766,13 +2784,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"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -2817,13 +2833,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]
|
||||
@@ -2936,4 +2950,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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `<home>/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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf, String> {
|
||||
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 <path>` 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 <path>` 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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] <path>|turn [clipboard|file [--force] <path>]]";
|
||||
// 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 <path>` 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::<helper>` 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: <time>");
|
||||
normalized.push_str(newline);
|
||||
} else {
|
||||
normalized.push_str(line);
|
||||
}
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Replace the turn-handoff wall-clock generation stamp so a captured golden
|
||||
/// compares byte-for-byte.
|
||||
///
|
||||
/// `turn_handoff_markdown` stamps the header with `generated <now>`; the
|
||||
/// renderer itself is TUI-owned and deliberately not migrated (D2), so tests
|
||||
/// normalise the stamp instead of changing production timestamp semantics.
|
||||
pub(crate) fn normalize_turn_generated_at(markdown: &str) -> String {
|
||||
let mut normalized = String::with_capacity(markdown.len());
|
||||
for line in markdown.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n');
|
||||
let newline = &line[trimmed.len()..];
|
||||
if let Some(head) = trimmed.strip_prefix("_Status: ")
|
||||
&& let Some((status, _)) = head.split_once(" \u{b7} generated ")
|
||||
{
|
||||
normalized.push_str("_Status: ");
|
||||
normalized.push_str(status);
|
||||
normalized.push_str(" \u{b7} generated <timestamp>_");
|
||||
normalized.push_str(newline);
|
||||
continue;
|
||||
}
|
||||
normalized.push_str(line);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
/// Normalise the two volatile fields a recorded-restore-point export carries:
|
||||
/// the snapshot id (a git SHA over a commit whose date is wall-clock) and the
|
||||
/// `Recorded (UTC)` table cell.
|
||||
///
|
||||
/// Snapshot commits are created with `git commit-tree` and no pinned
|
||||
/// author/committer date, so both the SHA and the timestamp change on every run.
|
||||
/// Everything else in the document - the table structure, the 12-character id
|
||||
/// truncation, the correlation wording, the ambiguity warning, and the
|
||||
/// no-match line - is compared verbatim.
|
||||
pub(crate) fn normalize_snapshot_identity(markdown: &str) -> String {
|
||||
fn snapshot_id_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| Regex::new(r"`[0-9a-f]{12}`").expect("snapshot-id regex"))
|
||||
}
|
||||
fn recorded_time_regex() -> &'static Regex {
|
||||
static RE: OnceLock<Regex> = OnceLock::new();
|
||||
RE.get_or_init(|| {
|
||||
Regex::new(r"\| \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z \|").expect("recorded-time regex")
|
||||
})
|
||||
}
|
||||
|
||||
let with_ids = snapshot_id_regex().replace_all(markdown, "`<snapshot-id>`");
|
||||
recorded_time_regex()
|
||||
.replace_all(&with_ids, "| <recorded> |")
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
/// Normalisation for a document captured from the recorded-restore-point state:
|
||||
/// the export stamp plus the snapshot identity.
|
||||
pub(crate) fn normalize_recorded_export(markdown: &str) -> String {
|
||||
normalize_snapshot_identity(&normalize_export_time(markdown))
|
||||
}
|
||||
|
||||
/// Assert that an envelope built from exactly `SESSION_EXPORT` exposes the
|
||||
/// export facet and no other facet.
|
||||
///
|
||||
/// The exhaustive destructuring is deliberate. `ContextParts` is not
|
||||
/// `#[non_exhaustive]`, so adding a facet fails to compile here until the new
|
||||
/// slot is classified, and every slot is asserted absent in one place. That
|
||||
/// turns the "exposure of no unrelated facet" acceptance criterion into a
|
||||
/// structural guarantee rather than a spot-check of a chosen few fields.
|
||||
pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) {
|
||||
let ContextParts {
|
||||
session,
|
||||
model,
|
||||
cost,
|
||||
mode_policy,
|
||||
system_prompt,
|
||||
skills,
|
||||
workspace,
|
||||
presentation,
|
||||
media,
|
||||
memory,
|
||||
project,
|
||||
skill_group,
|
||||
plugin,
|
||||
lifecycle,
|
||||
control,
|
||||
export,
|
||||
} = parts;
|
||||
|
||||
assert!(export.is_some(), "the export facet must be exposed");
|
||||
|
||||
let unrelated = [
|
||||
("session", session.is_some()),
|
||||
("model", model.is_some()),
|
||||
("cost", cost.is_some()),
|
||||
("mode_policy", mode_policy.is_some()),
|
||||
("system_prompt", system_prompt.is_some()),
|
||||
("skills", skills.is_some()),
|
||||
("workspace", workspace.is_some()),
|
||||
("presentation", presentation.is_some()),
|
||||
("media", media.is_some()),
|
||||
("memory", memory.is_some()),
|
||||
("project", project.is_some()),
|
||||
("skill_group", skill_group.is_some()),
|
||||
("plugin", plugin.is_some()),
|
||||
("lifecycle", lifecycle.is_some()),
|
||||
("control", control.is_some()),
|
||||
];
|
||||
for (facet, present) in unrelated {
|
||||
assert!(
|
||||
!present,
|
||||
"{facet} must stay unavailable to a SESSION_EXPORT-only envelope"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The recorded-golden normaliser must replace *only* the wall-clock fields; a
|
||||
/// normaliser that over-matched would turn the golden comparison into a
|
||||
/// tautology, so pin its exact behaviour.
|
||||
#[test]
|
||||
fn recorded_golden_normalisers_replace_only_volatile_fields() {
|
||||
let doc = concat!(
|
||||
"- Exported: 2026-09-11T14:39:58Z\n",
|
||||
"\n",
|
||||
"| 1 | `3d0ad76f0222` | 2026-09-11T14:39:58Z | pre-turn:3: Fix the login test |\n",
|
||||
"- Restore points: N1 `3d0ad76f0222` (pre-turn turn 3)\n",
|
||||
"| 2 | `6a3bc0698866` | 2026-09-11T14:39:58Z | tool:call-1 |\n",
|
||||
);
|
||||
|
||||
let out = normalize_recorded_export(doc);
|
||||
|
||||
assert!(out.contains("- Exported: <time>\n"), "{out}");
|
||||
assert!(
|
||||
out.contains("| 1 | `<snapshot-id>` | <recorded> | pre-turn:3: Fix the login test |"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("- Restore points: N1 `<snapshot-id>` (pre-turn turn 3)"),
|
||||
"{out}"
|
||||
);
|
||||
assert!(
|
||||
out.contains("| 2 | `<snapshot-id>` | <recorded> | tool:call-1 |"),
|
||||
"{out}"
|
||||
);
|
||||
// Nothing volatile survives, and nothing static was touched.
|
||||
assert!(!out.contains("3d0ad76f0222"), "{out}");
|
||||
assert!(!out.contains("6a3bc0698866"), "{out}");
|
||||
assert!(!out.contains("2026-09-11T14:39:58Z"), "{out}");
|
||||
assert!(out.contains("pre-turn:3: Fix the login test"), "{out}");
|
||||
assert!(out.contains("(pre-turn turn 3)"), "{out}");
|
||||
}
|
||||
@@ -1528,8 +1528,27 @@ fi
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&script, perms).unwrap();
|
||||
|
||||
let text = read_text_with_wlpaste_using_argv(script.to_str().unwrap())
|
||||
.expect("read text through wl-paste helper");
|
||||
// A freshly written helper script can transiently report ETXTBSY
|
||||
// ("Text file busy") when a concurrent test in this parallel suite
|
||||
// forks while the write descriptor is still inherited. Retry the
|
||||
// exec briefly so this assertion exercises the helper contract
|
||||
// rather than the fork/exec window; the bound is load tolerance,
|
||||
// not the behavior under test (same class as #5929).
|
||||
let mut attempts = 0;
|
||||
let text = loop {
|
||||
match read_text_with_wlpaste_using_argv(script.to_str().unwrap()) {
|
||||
Ok(text) => break text,
|
||||
Err(error) => {
|
||||
attempts += 1;
|
||||
let busy = error.to_string().contains("Text file busy");
|
||||
assert!(
|
||||
busy && attempts < 100,
|
||||
"read text through wl-paste helper: {error:#}"
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(text, "from-wayland");
|
||||
}
|
||||
|
||||
@@ -234,18 +234,13 @@ pub fn take_frame_links() -> Vec<LinkRegion> {
|
||||
FRAME_LINKS.with(|cell| std::mem::take(&mut *cell.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Strip every ANSI escape sequence from `s` into `out`, preserving only the
|
||||
/// visible characters. ratatui's buffer drops the leading `ESC` byte but
|
||||
/// happily paints every other byte of an escape (`[`, `0`, `;`, `m`, OSC
|
||||
/// payloads, etc.) into a buffer cell, drifting columns. Tool stdout that
|
||||
/// includes ANSI (e.g. `gh`/`git` with color forced on, anything run through
|
||||
/// a PTY) must be sanitized before it enters the transcript.
|
||||
/// 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.
|
||||
/// Delegates to the single shared implementation in
|
||||
/// [`codewhale_secrets::sanitize`] (FEAT-025 D4) so `/export`, `/structcopy`,
|
||||
/// and the renderer cannot drift.
|
||||
pub fn strip_ansi_into(s: &str, out: &mut String) {
|
||||
strip_ansi_impl(s, out, false);
|
||||
codewhale_secrets::sanitize::strip_ansi_into(s, out);
|
||||
}
|
||||
|
||||
/// Like [`strip_ansi_into`], but SGR sequences (`ESC [ … m`: colour, bold,
|
||||
@@ -254,100 +249,16 @@ pub fn strip_ansi_into(s: &str, out: &mut String) {
|
||||
/// (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);
|
||||
}
|
||||
|
||||
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 [ ... <final byte 0x40..=0x7E>
|
||||
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;
|
||||
}
|
||||
}
|
||||
codewhale_secrets::sanitize::strip_ansi_keep_sgr_into(s, out);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Delegates to the shared implementation in [`codewhale_secrets::sanitize`].
|
||||
fn utf8_seq_len(lead: u8) -> usize {
|
||||
if lead < 0xc0 {
|
||||
1
|
||||
} else if lead < 0xe0 {
|
||||
2
|
||||
} else if lead < 0xf0 {
|
||||
3
|
||||
} else {
|
||||
4
|
||||
}
|
||||
codewhale_secrets::sanitize::utf8_seq_len(lead)
|
||||
}
|
||||
|
||||
/// Strip OSC 8 escape sequences from `s` into `out`, preserving the visible
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
|
||||
Enforces the EPIC-006 boundary contract:
|
||||
|
||||
1. `codewhale-command-contract` may not transitively depend on
|
||||
`codewhale-tui` (normal edges, via `cargo metadata`).
|
||||
1. `codewhale-command-contract` and `codewhale-secrets` may not transitively
|
||||
depend on `codewhale-tui` (normal edges, via `cargo metadata`).
|
||||
`codewhale-secrets` owns the shared output sanitizer that portable command
|
||||
helpers consume (FEAT-025 D4), so it must stay TUI-free before
|
||||
`codewhale-commands` depends on it.
|
||||
2. `codewhale-command-contract` source may not import the concrete `App`,
|
||||
widget/renderer/view/event-loop surfaces, or `ratatui`/`crossterm`.
|
||||
3. No composite `CommandContext` symbol (supertrait/struct/enum) may exist in
|
||||
@@ -33,6 +36,13 @@ CONTRACT_DIR = REPO_ROOT / "crates" / "command-contract" / "src"
|
||||
CONTRACT_PACKAGE = "codewhale-command-contract"
|
||||
FORBIDDEN_TUI_PACKAGE = "codewhale-tui"
|
||||
|
||||
# Workspace packages that must stay free of any (normal) path to the TUI. The
|
||||
# contract carries the portable shapes; `codewhale-secrets` owns the shared pure
|
||||
# sanitizer those shapes' handlers consume. Both are prerequisites for
|
||||
# `codewhale-commands` (FEAT-016/043), so a TUI edge here would silently drag
|
||||
# the whole TUI into the extracted command crate.
|
||||
TUI_FREE_PACKAGES = (CONTRACT_PACKAGE, "codewhale-secrets")
|
||||
|
||||
# Import lines that must never appear in the contract (narrowly scoped: real
|
||||
# imports only, comments never match because they do not start with `use`).
|
||||
FORBIDDEN_IMPORT_PATTERNS = [
|
||||
@@ -123,24 +133,27 @@ def reaches_tui(package: str, graph: dict[str, set[str]]) -> bool:
|
||||
|
||||
|
||||
def check_dependency_graph(graph: dict[str, set[str]]) -> list[BoundaryViolation]:
|
||||
"""The prototype contract must not reach codewhale-tui."""
|
||||
if CONTRACT_PACKAGE not in graph:
|
||||
return [
|
||||
BoundaryViolation(
|
||||
"dependency-graph",
|
||||
CONTRACT_PACKAGE,
|
||||
"workspace package missing from the cargo metadata graph",
|
||||
"""No TUI-free package may reach codewhale-tui through normal edges."""
|
||||
violations: list[BoundaryViolation] = []
|
||||
for package in TUI_FREE_PACKAGES:
|
||||
if package not in graph:
|
||||
violations.append(
|
||||
BoundaryViolation(
|
||||
"dependency-graph",
|
||||
package,
|
||||
"workspace package missing from the cargo metadata graph",
|
||||
)
|
||||
)
|
||||
]
|
||||
if reaches_tui(CONTRACT_PACKAGE, graph):
|
||||
return [
|
||||
BoundaryViolation(
|
||||
"dependency-graph",
|
||||
CONTRACT_PACKAGE,
|
||||
f"transitively depends on {FORBIDDEN_TUI_PACKAGE}",
|
||||
continue
|
||||
if reaches_tui(package, graph):
|
||||
violations.append(
|
||||
BoundaryViolation(
|
||||
"dependency-graph",
|
||||
package,
|
||||
f"transitively depends on {FORBIDDEN_TUI_PACKAGE}",
|
||||
)
|
||||
)
|
||||
]
|
||||
return []
|
||||
return violations
|
||||
|
||||
|
||||
def check_contract_source_text(text: str, display_path: str) -> list[BoundaryViolation]:
|
||||
@@ -211,8 +224,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f" {violation}", file=sys.stderr)
|
||||
return 1
|
||||
print(
|
||||
f"[command-crate-boundaries] PASS: {CONTRACT_PACKAGE} has no "
|
||||
f"{FORBIDDEN_TUI_PACKAGE} edge; "
|
||||
f"[command-crate-boundaries] PASS: "
|
||||
f"{', '.join(TUI_FREE_PACKAGES)} have no {FORBIDDEN_TUI_PACKAGE} edge; "
|
||||
"no forbidden import, composite context, or boxed handler in the contract"
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -21,6 +21,8 @@ def valid_graph() -> dict[str, set[str]]:
|
||||
return {
|
||||
"codewhale-command-contract": {"codewhale-core"},
|
||||
"codewhale-core": set(),
|
||||
"codewhale-secrets": {"codewhale-paths"},
|
||||
"codewhale-paths": set(),
|
||||
"codewhale-tui": set(),
|
||||
}
|
||||
|
||||
@@ -46,6 +48,28 @@ class DependencyTests(unittest.TestCase):
|
||||
self.assertEqual(len(violations), 1)
|
||||
self.assertIn("missing", str(violations[0]))
|
||||
|
||||
def test_missing_sanitizer_fails(self) -> None:
|
||||
graph = valid_graph()
|
||||
del graph["codewhale-secrets"]
|
||||
violations = mod.check_dependency_graph(graph)
|
||||
self.assertEqual(len(violations), 1)
|
||||
self.assertIn("codewhale-secrets", str(violations[0]))
|
||||
|
||||
def test_sanitizer_reaching_tui_fails(self) -> None:
|
||||
# The shared sanitizer is consumed by portable command helpers, so a TUI
|
||||
# edge would pull the whole TUI into the extracted command crate.
|
||||
graph = valid_graph()
|
||||
graph["codewhale-paths"].add("codewhale-tui")
|
||||
violations = mod.check_dependency_graph(graph)
|
||||
self.assertEqual(len(violations), 1)
|
||||
self.assertIn("codewhale-secrets", str(violations[0]))
|
||||
|
||||
def test_both_packages_reaching_tui_fails_twice(self) -> None:
|
||||
graph = valid_graph()
|
||||
graph["codewhale-core"].add("codewhale-tui")
|
||||
graph["codewhale-paths"].add("codewhale-tui")
|
||||
self.assertEqual(len(mod.check_dependency_graph(graph)), 2)
|
||||
|
||||
def test_dev_dependency_is_not_a_normal_edge(self) -> None:
|
||||
metadata = {"packages": [
|
||||
{"name": "codewhale-command-contract", "dependencies": [
|
||||
@@ -53,6 +77,7 @@ class DependencyTests(unittest.TestCase):
|
||||
{"name": "codewhale-core", "kind": None},
|
||||
]},
|
||||
{"name": "codewhale-core", "dependencies": []},
|
||||
{"name": "codewhale-secrets", "dependencies": []},
|
||||
{"name": "codewhale-tui", "dependencies": []},
|
||||
]}
|
||||
graph = mod.dependency_graph(metadata)
|
||||
|
||||
Reference in new issue
Block a user