fix: preserve state and report audit failures

D01-02: propagate an existing file's read failure before writing so undo
and diffs cannot treat an unreadable original as empty.
T07-05: replace legacy compaction messages only with a saved checkpoint,
and recognize summary headers by prefix to preserve quoted user text.
T04-05: check implicit approval and sandbox defaults against requirements,
using the engine sandbox resolver and honoring the managed approval lock.
R02-08: return a logout error after reporting credential deletion failures.
T04-12: require temp files to be older than one hour at both doctor planning
and deletion, preserving recent, refreshed, and future-dated files.
T02-05: reject pending, complete, and final SSE lines above 8 MiB through
the shared provider decoder while retaining strict UTF-8 errors.

Add focused regressions and public Fixed changelog entries; synchronize
the embedded TUI changelog. All Cargo commands were sequential, offline,
and limited to four build jobs.

Validation (all prefixed CARGO_BUILD_JOBS=4 CARGO_NET_OFFLINE=true):
cargo test -p codewhale-tui --lib -- tools::file::tests::contract_write_preserves_unreadable_existing_file compaction::tests::restore_ config::tests::requirements_check_implicit_approval_and_sandbox_defaults doctor_fix::tests::stale_temp_files client::wire::tests
- First attempt: compile failed on the unused compaction summary helper;
  reuse that helper for prefix recognition. No tests ran on that attempt.
- Second attempt:
  test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 13461 filtered out; finished in 0.09s
- The file filter matched no test because its module is pdf_tests; the
  corrected focused invocation below covers it and the policy-lock follow-up.
cargo test -p codewhale-tui --lib -- contract_write_preserves_unreadable_existing_file requirements_check_implicit_approval_and_sandbox_defaults
  test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 13471 filtered out; finished in 0.01s
cargo test -p codewhale-cli --lib -- logout_
  test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 399 filtered out; finished in 0.04s
cargo fmt --all: passed.
sh scripts/sync-changelog.sh and --check: passed.
git diff --cached --check: passed.
No pre-fix rebuild or socket-dependent test run. Commit-time Entire/Qoder
session-tracking hooks are bypassed for this local-only task.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-26 23:33:30 -07:00
1 parent 94130d91ae
commit c013203511
11 files changed
+350 -58

No files matched your search

+7
View File
@@ -51,6 +51,13 @@ quieter, and Fleet runs can be checked before they spend anything.
### Fixed
- File writes stop if the original contents cannot be read, keeping undo and
diffs from recording an empty original file.
- Resuming a session preserves user messages that quote a compaction marker.
- Requirements allow-lists now check unset approval and sandbox defaults.
- Logout returns an error when stored credentials could not be deleted.
- `doctor --fix` keeps temporary files modified within the last hour.
- Provider streams stop with a clear error if an SSE line exceeds 8 MiB.
- The TUI keeps redrawing while its terminal is unfocused. v0.10.0 held
every frame on focus loss, so on Windows Terminal, macOS and other
terminals a window sitting behind another one looked frozen until you
+34 -1
View File
@@ -2688,7 +2688,7 @@ fn run_logout_command_with_secrets_unlocked(
"failed to delete stored credentials for: {}",
keyring_failures.join(", ")
);
println!("logged out (some stored credentials could not be deleted)");
anyhow::bail!("logout incomplete: some stored credentials could not be deleted");
}
Ok(())
}
@@ -5802,6 +5802,7 @@ mod tests {
struct RecordingKeyringStore {
gets: Mutex<Vec<String>>,
values: Mutex<std::collections::BTreeMap<String, String>>,
fail_deletes: bool,
}
impl RecordingKeyringStore {
@@ -5844,6 +5845,11 @@ mod tests {
}
fn delete(&self, key: &str) -> std::result::Result<(), codewhale_secrets::SecretsError> {
if self.fail_deletes {
return Err(codewhale_secrets::SecretsError::Keyring(
"test delete failure".into(),
));
}
self.values
.lock()
.expect("recording values lock")
@@ -10252,6 +10258,33 @@ verbosity = "project-imported"
let _ = std::fs::remove_file(path);
}
#[test]
fn logout_returns_error_when_keyring_deletion_fails() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let home = dir
.path()
.canonicalize()
.expect("canonical temp root")
.join("codewhale-home");
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
let mut store = ConfigStore::load(Some(home.join("config.toml"))).expect("load config");
let keyring = RecordingKeyringStore {
fail_deletes: true,
..RecordingKeyringStore::default()
};
for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
keyring.set_value(provider_slot(provider), "test-credential");
}
let secrets = Secrets::new(std::sync::Arc::new(keyring));
let error = run_logout_command_with_secrets(&mut store, &secrets, None)
.expect_err("partial logout must fail");
assert!(error.to_string().contains("logout incomplete"));
for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
assert!(secrets.get(provider_slot(provider)).unwrap().is_some());
}
}
#[test]
fn logout_clears_keyring_credentials_for_all_providers() {
// Logout used to delete the keyring secret only for the *active*
+7
View File
@@ -51,6 +51,13 @@ quieter, and Fleet runs can be checked before they spend anything.
### Fixed
- File writes stop if the original contents cannot be read, keeping undo and
diffs from recording an empty original file.
- Resuming a session preserves user messages that quote a compaction marker.
- Requirements allow-lists now check unset approval and sandbox defaults.
- Logout returns an error when stored credentials could not be deleted.
- `doctor --fix` keeps temporary files modified within the last hour.
- Provider streams stop with a clear error if an SSE line exceeds 8 MiB.
- The TUI keeps redrawing while its terminal is unfocused. v0.10.0 held
every frame on focus loss, so on Windows Terminal, macOS and other
terminals a window sitting behind another one looked frozen until you
@@ -4,7 +4,7 @@
//! harness (issue #69 tracks that). For #103 we exercise the chunk decoder
//! directly to verify each "class of stream failure" the engine relies on.
use super::*;
use crate::client::wire::{InvalidSseUtf8, SseLineDecoder};
use crate::client::wire::{SseLineDecoder, SseLineError};
use codewhale_models::{ContentBlockStart, Delta, StreamEvent};
/// Decode a raw SSE-data JSON chunk into our internal events, mirroring
@@ -61,7 +61,7 @@ fn decode_chunks_with_style(
/// Drive the Chat Completions SSE path with raw byte chunks so tests can
/// split a multi-byte UTF-8 character across HTTP/2-style DATA boundaries.
fn decode_sse_byte_chunks(chunks: &[&[u8]]) -> Result<Vec<StreamEvent>, InvalidSseUtf8> {
fn decode_sse_byte_chunks(chunks: &[&[u8]]) -> Result<Vec<StreamEvent>, SseLineError> {
struct FrameState {
line_buf: String,
content_index: u32,
+84 -26
View File
@@ -95,34 +95,42 @@ pub(super) fn extract_sse_data_value(line: &str) -> Option<&str> {
.map(|value| value.strip_prefix(' ').unwrap_or(value))
}
/// Genuine invalid UTF-8 in an SSE line (or an unterminated flush).
/// Hard ceiling for one pending SSE line, matching the MCP frame limit.
/// This does not bound the size of a multi-line event or the whole stream.
const MAX_SSE_LINE_BYTES: usize = 8 * 1024 * 1024;
/// Invalid or oversized SSE line (or unterminated flush).
///
/// HTTP/2 DATA and other transports may split a multi-byte character across
/// chunks. That is not this error: callers must buffer raw bytes until a
/// complete line (or stream end) before decoding. This type is only returned
/// when `str::from_utf8` rejects the assembled bytes. We never substitute
/// complete line (or stream end) before decoding. We never substitute
/// U+FFFD — fail closed so garbled CJK cannot enter the transcript.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct InvalidSseUtf8 {
valid_up_to: usize,
pub(super) enum SseLineError {
InvalidUtf8 { valid_up_to: usize },
TooLong,
}
impl std::fmt::Display for InvalidSseUtf8 {
impl std::fmt::Display for SseLineError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"invalid UTF-8 in SSE stream at byte {}",
self.valid_up_to
)
match self {
Self::InvalidUtf8 { valid_up_to } => {
write!(f, "invalid UTF-8 in SSE stream at byte {valid_up_to}")
}
Self::TooLong => write!(
f,
"SSE line exceeded {MAX_SSE_LINE_BYTES} bytes (8 MiB) — aborting stream"
),
}
}
}
impl std::error::Error for InvalidSseUtf8 {}
impl std::error::Error for SseLineError {}
/// Decode one assembled SSE line (or stream-end tail) with `str::from_utf8`.
/// Does not substitute U+FFFD.
fn decode_sse_line_bytes(bytes: &[u8]) -> Result<&str, InvalidSseUtf8> {
std::str::from_utf8(bytes).map_err(|err| InvalidSseUtf8 {
fn decode_sse_line_bytes(bytes: &[u8]) -> Result<&str, SseLineError> {
std::str::from_utf8(bytes).map_err(|err| SseLineError::InvalidUtf8 {
valid_up_to: err.valid_up_to(),
})
}
@@ -134,10 +142,15 @@ fn decode_sse_line_bytes(bytes: &[u8]) -> Result<&str, InvalidSseUtf8> {
/// split across two reads is never corrupted to U+FFFD, since the `\n`
/// delimiter is ASCII and can never fall inside a multi-byte sequence.
///
/// Genuine invalid bytes fail closed (`Err(InvalidSseUtf8)`); we do not
/// Genuine invalid bytes fail closed (`Err(SseLineError)`); we do not
/// substitute U+FFFD.
pub(super) fn take_sse_line(buffer: &mut Vec<u8>) -> Result<Option<String>, InvalidSseUtf8> {
let Some(line_end) = buffer.iter().position(|&b| b == b'\n') else {
pub(super) fn take_sse_line(buffer: &mut Vec<u8>) -> Result<Option<String>, SseLineError> {
let line_end = buffer.iter().position(|&b| b == b'\n');
if line_end.unwrap_or(buffer.len()) > MAX_SSE_LINE_BYTES {
buffer.clear();
return Err(SseLineError::TooLong);
}
let Some(line_end) = line_end else {
return Ok(None);
};
// Strip a preceding `\r` so CRLF-delimited SSE frames do not leave CR.
@@ -154,7 +167,11 @@ pub(super) fn take_sse_line(buffer: &mut Vec<u8>) -> Result<Option<String>, Inva
///
/// Same fail-closed UTF-8 contract as [`take_sse_line`]. Empty / whitespace-only
/// tails yield `Ok(None)`.
pub(super) fn flush_sse_line(buffer: &mut Vec<u8>) -> Result<Option<String>, InvalidSseUtf8> {
pub(super) fn flush_sse_line(buffer: &mut Vec<u8>) -> Result<Option<String>, SseLineError> {
if buffer.len() > MAX_SSE_LINE_BYTES {
buffer.clear();
return Err(SseLineError::TooLong);
}
if buffer.is_empty() {
return Ok(None);
}
@@ -172,7 +189,7 @@ pub(super) fn flush_sse_line(buffer: &mut Vec<u8>) -> Result<Option<String>, Inv
pub(super) fn next_sse_line(
buffer: &mut Vec<u8>,
at_end: bool,
) -> Result<Option<String>, InvalidSseUtf8> {
) -> Result<Option<String>, SseLineError> {
match take_sse_line(buffer)? {
Some(line) => Ok(Some(line)),
None if at_end => flush_sse_line(buffer),
@@ -194,7 +211,7 @@ impl SseLineDecoder {
Self { buffer: Vec::new() }
}
pub(super) fn push(&mut self, chunk: &[u8]) -> Result<Vec<String>, InvalidSseUtf8> {
pub(super) fn push(&mut self, chunk: &[u8]) -> Result<Vec<String>, SseLineError> {
self.buffer.extend_from_slice(chunk);
let mut lines = Vec::new();
while let Some(line) = take_sse_line(&mut self.buffer)? {
@@ -203,7 +220,7 @@ impl SseLineDecoder {
Ok(lines)
}
pub(super) fn finish(mut self) -> Result<Option<String>, InvalidSseUtf8> {
pub(super) fn finish(mut self) -> Result<Option<String>, SseLineError> {
flush_sse_line(&mut self.buffer)
}
}
@@ -213,6 +230,47 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn sse_line_limit_rejects_oversized_pending_complete_and_final_lines() {
let mut decoder = SseLineDecoder::new();
let at_limit = vec![b'x'; MAX_SSE_LINE_BYTES];
assert!(decoder.push(&at_limit).unwrap().is_empty());
let error = decoder
.push(b"x")
.expect_err("pending line exceeds the limit");
assert_eq!(error, SseLineError::TooLong);
assert!(error.to_string().contains("8 MiB"));
assert!(decoder.buffer.is_empty());
let mut complete = at_limit.clone();
complete.push(b'\n');
assert_eq!(
take_sse_line(&mut complete).unwrap().unwrap().len(),
MAX_SSE_LINE_BYTES
);
assert!(complete.is_empty());
let mut oversized = vec![b'x'; MAX_SSE_LINE_BYTES + 1];
assert_eq!(flush_sse_line(&mut oversized), Err(SseLineError::TooLong));
assert!(oversized.is_empty());
oversized.resize(MAX_SSE_LINE_BYTES + 1, b'x');
oversized.push(b'\n');
assert_eq!(
next_sse_line(&mut oversized, false),
Err(SseLineError::TooLong)
);
assert!(oversized.is_empty());
// The cap applies per line, not to a chunk containing several lines.
let mut multiple = at_limit;
multiple.extend_from_slice(b"\nnext\n");
assert!(next_sse_line(&mut multiple, false).unwrap().is_some());
assert_eq!(
next_sse_line(&mut multiple, false).unwrap().as_deref(),
Some("next")
);
}
#[test]
fn parse_usage_scenario() {
// Scenario consolidation of: parse_usage_reads_deepseek_cache_and_reasoning_tokens, parse_usage_saturates_every_u64_token_field, parse_usage_counts_reasoning_tokens_when_completion_tokens_are_zero, parse_usage_derives_completion_tokens_from_total_tokens_when_needed, parse_usage_reads_v4_prompt_tokens_details_cached_tokens, parse_usage_infers_cache_miss_from_selected_hit_source
@@ -493,7 +551,7 @@ mod tests {
invalid.push(b'\n');
let err = take_sse_line(&mut invalid).expect_err("invalid bytes must fail closed");
assert!(!err.to_string().contains('\u{FFFD}'));
assert_eq!(err.valid_up_to, 8);
assert_eq!(err, SseLineError::InvalidUtf8 { valid_up_to: 8 });
assert!(
invalid.is_empty(),
"invalid line is consumed so retries cannot loop"
@@ -505,7 +563,7 @@ mod tests {
buffer.push(0xFF);
buffer.extend_from_slice(b"\n");
let err = take_sse_line(&mut buffer).expect_err("0xFF is not UTF-8");
assert_eq!(err.valid_up_to, 8);
assert_eq!(err, SseLineError::InvalidUtf8 { valid_up_to: 8 });
assert!(!err.to_string().contains('\u{FFFD}'));
assert!(buffer.is_empty(), "invalid line must be drained");
}
@@ -532,7 +590,7 @@ mod tests {
let mut invalid = vec![0x80, 0xBF];
let err = flush_sse_line(&mut invalid).expect_err("invalid flush must fail closed");
assert!(!err.to_string().contains('\u{FFFD}'));
assert_eq!(err.valid_up_to, 0);
assert_eq!(err, SseLineError::InvalidUtf8 { valid_up_to: 0 });
assert!(invalid.is_empty());
}
// from flush_sse_line_preserves_unterminated_cjk
@@ -550,7 +608,7 @@ mod tests {
let mut buffer = "data: ".as_bytes().to_vec();
buffer.extend_from_slice(&"好".as_bytes()[..2]);
let err = flush_sse_line(&mut buffer).expect_err("truncated UTF-8");
assert_eq!(err.valid_up_to, 6);
assert_eq!(err, SseLineError::InvalidUtf8 { valid_up_to: 6 });
assert!(!err.to_string().contains('\u{FFFD}'));
assert!(buffer.is_empty());
}
@@ -564,7 +622,7 @@ mod tests {
let err = decode_sse_line_bytes(&[0xFF]).expect_err("bare 0xFF is invalid");
assert!(!err.to_string().contains('\u{FFFD}'));
assert_eq!(err.valid_up_to, 0);
assert_eq!(err, SseLineError::InvalidUtf8 { valid_up_to: 0 });
}
#[test]
+42 -6
View File
@@ -201,10 +201,11 @@ const COMPACTION_CHECKPOINT_PROVENANCE: &str = "<!-- codewhale.compaction-checkp
const COMPACTION_SUMMARY_BEGIN: &str = "<!-- compaction-summary:begin -->";
const COMPACTION_SUMMARY_END: &str = "<!-- compaction-summary:end -->";
/// Whether a system-prompt text block is a committed compaction summary.
/// Whether text starts with a committed compaction-summary header.
#[must_use]
pub fn is_compaction_summary_text(text: &str) -> bool {
text.contains(COMPACTION_SUMMARY_MARKER) || text.contains(LEGACY_COMPACTION_SUMMARY_MARKER)
text.starts_with(COMPACTION_SUMMARY_MARKER)
|| text.starts_with(LEGACY_COMPACTION_SUMMARY_MARKER)
}
fn summary_section(text: &str) -> Option<&str> {
@@ -365,9 +366,12 @@ pub(crate) fn restore_compaction_checkpoint(
messages.retain(|message| !is_wire_compaction_checkpoint_message(message));
index
} else {
// Legacy sessions have no independent provenance. Preserve their
// existing broad cleanup behavior; identical user text is ambiguous.
messages.retain(|message| !is_compaction_checkpoint_message(message));
// Legacy sessions have no independent provenance. Only replace
// header-prefixed messages when a saved checkpoint exists; identical
// user text is still ambiguous in that legacy format.
if checkpoint.is_some() {
messages.retain(|message| !is_compaction_checkpoint_message(message));
}
messages.len()
};
if let Some(checkpoint) = checkpoint {
@@ -1814,6 +1818,38 @@ mod quota_tests;
mod tests {
use codewhale_models::{ImageUrlContent, Message};
#[test]
fn restore_without_typed_checkpoint_preserves_user_marker_quotes() {
for marker in [COMPACTION_SUMMARY_MARKER, LEGACY_COMPACTION_SUMMARY_MARKER] {
let quoted = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: format!("Explain this marker: {marker}"),
cache_control: None,
}],
};
let legacy = Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: format!("{marker}\nold summary"),
cache_control: None,
}],
};
let messages = vec![quoted.clone(), legacy.clone()];
assert_eq!(
restore_compaction_checkpoint(messages.clone(), None),
messages
);
let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", ""));
let restored =
restore_compaction_checkpoint(vec![quoted.clone(), legacy], Some(&summary));
assert_eq!(restored.len(), 2);
assert_eq!(restored[0], quoted);
assert!(is_wire_compaction_checkpoint_message(&restored[1]));
}
}
#[test]
fn restore_replaces_duplicate_generated_checkpoints_without_deleting_user_quote() {
let summary = SystemPrompt::Text(build_compaction_summary_block_text("Summary", ""));
@@ -1834,7 +1870,7 @@ mod tests {
assert!(is_wire_compaction_checkpoint_message(&restored[0]));
assert_eq!(restored[1], user_quote);
// No provenance means the historical broad cleanup remains in force.
// With a saved summary, legacy header-prefixed copies are replaced.
let legacy = Message {
role: Role::User,
content: vec![ContentBlock::Text {
+47 -8
View File
@@ -11714,10 +11714,12 @@ fn apply_requirements(config: &mut Config) -> Result<()> {
)
})?;
if !requirements.allowed_approval_policies.is_empty()
&& let Some(policy) = config.approval_policy.as_ref()
{
let policy = policy.to_ascii_lowercase();
if !requirements.allowed_approval_policies.is_empty() {
let policy = config
.approval_policy
.as_deref()
.unwrap_or("on-request")
.to_ascii_lowercase();
if !requirements
.allowed_approval_policies
.iter()
@@ -11729,10 +11731,47 @@ fn apply_requirements(config: &mut Config) -> Result<()> {
);
}
}
if !requirements.allowed_sandbox_modes.is_empty()
&& let Some(mode) = config.sandbox_mode.as_ref()
{
let mode = mode.to_ascii_lowercase();
if !requirements.allowed_sandbox_modes.is_empty() {
// At config load there is no live turn mode yet. Check the Agent
// baseline with the engine's resolver; Plan can narrow it later.
// Explicit settings retain their existing allow-list check.
let mode = config
.sandbox_mode
.as_deref()
.map(str::to_ascii_lowercase)
.unwrap_or_else(|| {
use crate::core::authority::{SandboxNetworkAccess, sandbox_policy_for_turn};
use crate::sandbox::SandboxPolicy;
use codewhale_execpolicy::ApprovalMode;
// A configured or requirements-managed approval policy
// prevents the interactive YOLO override at startup.
let approval = if config.yolo.unwrap_or(false)
&& config.approval_policy.is_none()
&& requirements.allowed_approval_policies.is_empty()
{
ApprovalMode::Bypass
} else {
config
.approval_policy
.as_deref()
.and_then(ApprovalMode::from_config_value)
.unwrap_or_default()
};
match sandbox_policy_for_turn(
codewhale_config::AppMode::Agent,
approval,
None,
Path::new("."),
SandboxNetworkAccess::from_config(config.sandbox_network_access),
) {
SandboxPolicy::ReadOnly => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ExternalSandbox { .. } => "external-sandbox",
}
.to_string()
});
if !requirements
.allowed_sandbox_modes
.iter()
+41
View File
@@ -9,6 +9,47 @@ use std::sync::mpsc;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn requirements_check_implicit_approval_and_sandbox_defaults() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("requirements.toml");
let mut config = Config {
requirements_path: Some(path.display().to_string()),
..Config::default()
};
fs::write(&path, "allowed_approval_policies = ['never']\n").unwrap();
let error = apply_requirements(&mut config).expect_err("default approval must be checked");
assert!(error.to_string().contains("approval_policy 'on-request'"));
config.approval_policy = Some("never".into());
apply_requirements(&mut config).expect("explicit allowed policy");
fs::write(&path, "allowed_sandbox_modes = ['read-only']\n").unwrap();
let error = apply_requirements(&mut config).expect_err("default sandbox must be checked");
assert!(error.to_string().contains("sandbox_mode 'workspace-write'"));
config.sandbox_mode = Some("read-only".into());
apply_requirements(&mut config).expect("explicit allowed sandbox");
config.approval_policy = None;
config.sandbox_mode = None;
fs::write(
&path,
"allowed_approval_policies = ['ON-REQUEST']\nallowed_sandbox_modes = ['WORKSPACE-WRITE']\n",
)
.unwrap();
apply_requirements(&mut config).expect("allowed implicit defaults");
config.yolo = Some(true);
apply_requirements(&mut config).expect("managed approvals prevent the YOLO override");
fs::write(&path, "allowed_sandbox_modes = ['workspace-write']\n").unwrap();
let error =
apply_requirements(&mut config).expect_err("full access changes the sandbox default");
assert!(
error
.to_string()
.contains("sandbox_mode 'danger-full-access'")
);
}
#[test]
fn remembered_deepseek_cn_and_layered_root_models_keep_their_precedence() {
let _lock = lock_test_env();
+53 -14
View File
@@ -6,7 +6,7 @@
//! narrowly scoped and reversible:
//!
//! - delete stale `.tmp*` files left behind by interrupted atomic writes in
//! the Codewhale home;
//! the Codewhale home, only when last modified more than an hour ago;
//! - tighten secret-store file permissions to `0600` on Unix when group or
//! world bits are set (the secret store writes private files, but a file
//! restored from backup or moved from another machine may have drifted);
@@ -31,7 +31,8 @@ use crate::mcp;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum DoctorFixAction {
/// Delete an interrupted-atomic-write leftover. Only ever a regular file
/// whose name starts with `.tmp` directly inside the Codewhale home.
/// whose name starts with `.tmp` directly inside the Codewhale home and
/// whose last modification was more than an hour ago.
DeleteStaleTempFile { path: PathBuf },
/// Restrict a secret-store file to owner-only on Unix.
#[cfg(unix)]
@@ -95,7 +96,22 @@ impl DoctorFixPlan {
}
}
fn is_stale_temp_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(".tmp"))
&& std::fs::symlink_metadata(path).is_ok_and(|meta| {
meta.is_file()
&& meta.modified().is_ok_and(|modified| {
modified
.elapsed()
.is_ok_and(|age| age > std::time::Duration::from_secs(60 * 60))
})
})
}
/// Stale `.tmp*` regular files directly inside the Codewhale home.
/// Recent files and files with unknown or future modification times are kept.
fn stale_temp_files() -> Vec<PathBuf> {
let Ok(home) = codewhale_config::codewhale_home() else {
return Vec::new();
@@ -105,11 +121,8 @@ fn stale_temp_files() -> Vec<PathBuf> {
};
let mut files = entries
.flatten()
.filter(|entry| {
entry.file_name().to_string_lossy().starts_with(".tmp")
&& entry.file_type().is_ok_and(|kind| kind.is_file())
})
.map(|entry| entry.path())
.filter(|path| is_stale_temp_file(path))
.collect::<Vec<_>>();
files.sort();
files
@@ -244,14 +257,7 @@ fn apply_one(action: &DoctorFixAction) -> DoctorFixOutcome {
DoctorFixAction::DeleteStaleTempFile { path } => {
// Re-verify the deletion guard at apply time: the plan was
// computed before consent, so the file may have changed.
let still_stale = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with(".tmp"))
&& std::fs::symlink_metadata(path)
.ok()
.is_some_and(|meta| meta.is_file());
if !still_stale {
if !is_stale_temp_file(path) {
return DoctorFixOutcome::Applied;
}
match std::fs::remove_file(path) {
@@ -402,6 +408,23 @@ mod tests {
let (home, config) = ScratchHome::new();
std::fs::write(home.path().join(".tmpAbC123"), b"orphaned").expect("write stale");
std::fs::write(home.path().join(".tmpOther"), b"orphaned 2").expect("write stale 2");
let now = std::time::SystemTime::now();
for name in [".tmpAbC123", ".tmpOther"] {
std::fs::File::options()
.write(true)
.open(home.path().join(name))
.unwrap()
.set_modified(now - std::time::Duration::from_secs(2 * 60 * 60))
.unwrap();
}
for (name, modified) in [
(".tmpLive", now),
(".tmpRecent", now - std::time::Duration::from_secs(30 * 60)),
(".tmpFuture", now + std::time::Duration::from_secs(60 * 60)),
] {
let file = std::fs::File::create(home.path().join(name)).expect("temp file");
file.set_modified(modified).expect("mtime");
}
std::fs::write(home.path().join("keep.txt"), b"keep").expect("write keep");
std::fs::create_dir_all(home.path().join(".tmpDir")).expect("mkdir .tmpDir");
@@ -418,6 +441,13 @@ mod tests {
.count();
assert_eq!(temp_deletions, 2, "{:?}", plan.actions);
// A stale candidate may become active while waiting for consent.
std::fs::File::options()
.write(true)
.open(home.path().join(".tmpOther"))
.unwrap()
.set_modified(now)
.unwrap();
let results = apply_fixes(&DoctorFixPlan {
actions: plan
.actions
@@ -428,6 +458,15 @@ mod tests {
});
assert!(results.iter().all(|(_, o)| *o == DoctorFixOutcome::Applied));
assert!(!home.path().join(".tmpAbC123").exists());
for name in [
".tmpOther",
".tmpLive",
".tmpRecent",
".tmpFuture",
".tmpDir",
] {
assert!(home.path().join(name).exists(), "must preserve {name}");
}
assert!(home.path().join("keep.txt").exists());
}
+6 -1
View File
@@ -1496,7 +1496,12 @@ impl WriteFileTool {
let existed_before = tokio::fs::try_exists(&file_path).await.unwrap_or(false);
let prior_bytes = if existed_before {
tokio::fs::read(&file_path).await.unwrap_or_default()
tokio::fs::read(&file_path).await.map_err(|error| {
ToolError::execution_failed(format!(
"Failed to read {}: {error}",
file_path.display()
))
})?
} else {
Vec::new()
};
+27
View File
@@ -692,6 +692,33 @@ async fn cancelled_queued_pi_write_never_starts() {
drop(held);
}
#[cfg(unix)]
#[tokio::test]
async fn contract_write_preserves_unreadable_existing_file() {
use std::os::unix::fs::PermissionsExt;
let temporary = tempfile::tempdir().expect("tempdir");
let path = temporary.path().join("write-only.txt");
std::fs::write(&path, "original\n").expect("fixture");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o200))
.expect("make unreadable");
let context = ToolContext::new(temporary.path());
let result = WriteFileTool::execute_contract_write(
json!({"path": "write-only.txt", "content": "replacement\n"}),
&context,
)
.await;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.expect("restore permissions");
let error = result.expect_err("cannot overwrite without the prior contents");
assert!(error.to_string().contains("Failed to read"), "{error}");
assert_eq!(
std::fs::read_to_string(path).expect("unchanged"),
"original\n"
);
}
#[cfg(unix)]
#[tokio::test]
async fn contract_edit_rejects_read_only_target_before_atomic_replace() {