mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 17:10:44 -10:00
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:
1 parent
94130d91ae
commit
c013203511
11 files changed
+350
-58
No files matched your search
@@ -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
@@ -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*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user