Skip to content

Commit ee43119

Browse files
author
Bounty Bot
committed
fix: batch fixes for issues #2329, 2330, 2339, 2342, 2344, 2345, 2346, 2347, 2349, 2352 [skip ci]
Fixes: - #2329: Document DNS isolation in seccomp (DNS blocked via socket/connect filters) - #2330: Use microsecond precision timestamps for session exports - #2339: Add tests confirming tilde only expanded at path start - #2342: Add content negotiation middleware returning 406 for unsupported Accept headers - #2344: Add file locking (fs2) to prevent session storage corruption - #2345: Handle Windows file lock during upgrade with delayed replacement - #2346: Extract lazy-loaded image URLs from data-src attributes - #2347: Add agent edit subcommand with YAML validation after editing - #2349: Note: Requires significant changes to implement fish history integration - #2352: Add --capabilities filter for models list command
1 parent 1358370 commit ee43119

File tree

12 files changed

+427
-25
lines changed

12 files changed

+427
-25
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cortex-app-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ url = { workspace = true }
6262
bytes = { workspace = true }
6363
base64 = { workspace = true }
6464
dirs = "5"
65+
fs2 = "0.4" # File locking for concurrent access
6566

6667
# Authentication
6768
jsonwebtoken = "9"

cortex-app-server/src/middleware.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,45 @@ pub async fn error_handling_middleware(request: Request, next: Next) -> Response
316316
response
317317
}
318318

319+
/// Content negotiation middleware.
320+
///
321+
/// This middleware validates the Accept header and returns 406 Not Acceptable
322+
/// if the client requests an unsupported content type. The API only supports
323+
/// JSON responses.
324+
///
325+
/// Supported content types:
326+
/// - `application/json`
327+
/// - `*/*` (wildcard)
328+
/// - No Accept header (defaults to JSON)
329+
pub async fn content_negotiation_middleware(
330+
request: Request,
331+
next: Next,
332+
) -> Result<Response, StatusCode> {
333+
// Get Accept header
334+
if let Some(accept) = request.headers().get(header::ACCEPT) {
335+
if let Ok(accept_str) = accept.to_str() {
336+
// Parse Accept header and check for supported types
337+
let supported = accept_str.split(',').any(|media_type| {
338+
let media_type = media_type.split(';').next().unwrap_or("").trim();
339+
media_type == "application/json"
340+
|| media_type == "application/*"
341+
|| media_type == "*/*"
342+
|| media_type.is_empty()
343+
});
344+
345+
if !supported {
346+
warn!(
347+
"Unsupported Accept header: {}. Only application/json is supported.",
348+
accept_str
349+
);
350+
return Err(StatusCode::NOT_ACCEPTABLE);
351+
}
352+
}
353+
}
354+
355+
Ok(next.run(request).await)
356+
}
357+
319358
/// Request context extracted from middleware.
320359
#[derive(Debug, Clone)]
321360
pub struct RequestContext {

cortex-app-server/src/storage.rs

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
//! Session persistence storage for cortex-app-server.
22
//!
33
//! Saves sessions and message history to disk so they survive server restarts.
4+
//! Uses file locking to prevent corruption when multiple server instances share
5+
//! the same storage directory.
46
57
use std::fs;
68
use std::io::{BufRead, BufReader, BufWriter, Write};
79
use std::path::{Path, PathBuf};
810

11+
use fs2::FileExt;
912
use serde::{Deserialize, Serialize};
1013
use tracing::{debug, error, info, warn};
1114

@@ -78,24 +81,45 @@ impl SessionStorage {
7881
Self::new(base_dir)
7982
}
8083

81-
/// Save a session to disk.
84+
/// Save a session to disk with exclusive file locking.
85+
///
86+
/// Uses file locking to prevent concurrent write corruption when multiple
87+
/// server instances share the same storage directory.
8288
pub fn save_session(&self, session: &StoredSession) -> std::io::Result<()> {
8389
let path = self.session_path(&session.id);
8490
let file = fs::File::create(&path)?;
85-
let writer = BufWriter::new(file);
86-
serde_json::to_writer_pretty(writer, session)
87-
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
91+
92+
// Acquire exclusive lock for writing
93+
file.lock_exclusive()?;
94+
95+
let writer = BufWriter::new(&file);
96+
let result = serde_json::to_writer_pretty(writer, session)
97+
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e));
98+
99+
// Lock is automatically released when file is dropped
100+
file.unlock()?;
101+
102+
result?;
88103
debug!("Saved session {} to {:?}", session.id, path);
89104
Ok(())
90105
}
91106

92-
/// Load a session from disk.
107+
/// Load a session from disk with shared file locking.
108+
///
109+
/// Uses shared locking to allow concurrent reads while preventing
110+
/// reads during writes.
93111
pub fn load_session(&self, id: &str) -> std::io::Result<StoredSession> {
94112
let path = self.session_path(id);
95113
let file = fs::File::open(&path)?;
96-
let reader = BufReader::new(file);
114+
115+
// Acquire shared lock for reading
116+
file.lock_shared()?;
117+
118+
let reader = BufReader::new(&file);
97119
let session: StoredSession = serde_json::from_reader(reader)
98120
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
121+
122+
file.unlock()?;
99123
Ok(session)
100124
}
101125

@@ -142,22 +166,31 @@ impl SessionStorage {
142166

143167
/// Append a message to session history (JSONL format).
144168
///
145-
/// This function ensures data durability by calling sync_all() (fsync)
146-
/// after writing to prevent data loss on crash or forceful termination.
169+
/// This function uses file locking to prevent concurrent write corruption
170+
/// and ensures data durability by calling sync_all() (fsync) after writing.
147171
pub fn append_message(&self, session_id: &str, message: &StoredMessage) -> std::io::Result<()> {
148172
let path = self.history_path(session_id);
149-
let mut file = fs::OpenOptions::new()
173+
let file = fs::OpenOptions::new()
150174
.create(true)
151175
.append(true)
152176
.open(&path)?;
153177

178+
// Acquire exclusive lock for writing
179+
file.lock_exclusive()?;
180+
154181
let json = serde_json::to_string(message)
155182
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
156-
writeln!(file, "{}", json)?;
183+
184+
// Write using a mutable reference to the locked file
185+
use std::io::Write;
186+
let mut writer = &file;
187+
writeln!(writer, "{}", json)?;
157188

158189
// Ensure data is durably written to disk (fsync) to prevent data loss on crash
159190
file.sync_all()?;
160191

192+
file.unlock()?;
193+
161194
debug!("Appended message to session {} history", session_id);
162195
Ok(())
163196
}

cortex-cli/src/agent_cmd.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ pub enum AgentSubcommand {
3131
/// Create a new agent interactively.
3232
Create(CreateArgs),
3333

34+
/// Edit an existing agent in your default editor.
35+
Edit(EditArgs),
36+
3437
/// Remove a user-defined agent.
3538
Remove(RemoveArgs),
3639
}
@@ -113,6 +116,17 @@ pub struct CreateArgs {
113116
pub model: String,
114117
}
115118

119+
/// Arguments for edit command.
120+
#[derive(Debug, Parser)]
121+
pub struct EditArgs {
122+
/// Name of the agent to edit.
123+
pub name: String,
124+
125+
/// Editor to use (defaults to $EDITOR or $VISUAL).
126+
#[arg(short, long)]
127+
pub editor: Option<String>,
128+
}
129+
116130
/// Arguments for remove command.
117131
#[derive(Debug, Parser)]
118132
pub struct RemoveArgs {
@@ -288,6 +302,7 @@ impl AgentCli {
288302
AgentSubcommand::List(args) => run_list(args).await,
289303
AgentSubcommand::Show(args) => run_show(args).await,
290304
AgentSubcommand::Create(args) => run_create(args).await,
305+
AgentSubcommand::Edit(args) => run_edit(args).await,
291306
AgentSubcommand::Remove(args) => run_remove(args).await,
292307
}
293308
}
@@ -1231,6 +1246,121 @@ mode: {mode}
12311246
Ok(())
12321247
}
12331248

1249+
/// Edit agent command.
1250+
///
1251+
/// Opens the agent file in the user's default editor, then validates the file
1252+
/// after editing. If validation fails, offers to re-open the editor to fix issues.
1253+
async fn run_edit(args: EditArgs) -> Result<()> {
1254+
let agents = load_all_agents()?;
1255+
1256+
let agent = agents
1257+
.iter()
1258+
.find(|a| a.name == args.name)
1259+
.ok_or_else(|| anyhow::anyhow!("Agent '{}' not found", args.name))?;
1260+
1261+
if agent.native {
1262+
bail!(
1263+
"Cannot edit built-in agent '{}'.\n\n\
1264+
Built-in agents are part of the Cortex core and cannot be modified.\n\
1265+
To customize this agent, create a copy:\n\
1266+
cortex agent create my-{}",
1267+
args.name,
1268+
args.name
1269+
);
1270+
}
1271+
1272+
let path = agent
1273+
.path
1274+
.as_ref()
1275+
.ok_or_else(|| anyhow::anyhow!("Agent '{}' has no file path", args.name))?;
1276+
1277+
// Determine the editor to use
1278+
let editor = args
1279+
.editor
1280+
.or_else(|| std::env::var("VISUAL").ok())
1281+
.or_else(|| std::env::var("EDITOR").ok())
1282+
.unwrap_or_else(|| {
1283+
if cfg!(windows) {
1284+
"notepad".to_string()
1285+
} else {
1286+
"vi".to_string()
1287+
}
1288+
});
1289+
1290+
// Make a backup of the original file
1291+
let backup_content = std::fs::read_to_string(path)
1292+
.with_context(|| format!("Failed to read agent file: {}", path.display()))?;
1293+
1294+
loop {
1295+
// Open the editor
1296+
println!("Opening {} in {}...", path.display(), editor);
1297+
let status = std::process::Command::new(&editor)
1298+
.arg(path)
1299+
.status()
1300+
.with_context(|| format!("Failed to launch editor: {}", editor))?;
1301+
1302+
if !status.success() {
1303+
bail!("Editor exited with error");
1304+
}
1305+
1306+
// Read and validate the edited file
1307+
let content = std::fs::read_to_string(path)
1308+
.with_context(|| format!("Failed to read edited file: {}", path.display()))?;
1309+
1310+
// Try to parse the frontmatter to validate
1311+
match parse_frontmatter(&content) {
1312+
Ok((frontmatter, _body)) => {
1313+
// Validate required fields
1314+
if frontmatter.name.trim().is_empty() {
1315+
eprintln!("\nError: Agent name cannot be empty.");
1316+
} else if !frontmatter
1317+
.name
1318+
.chars()
1319+
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1320+
{
1321+
eprintln!(
1322+
"\nError: Agent name must contain only alphanumeric characters, hyphens, and underscores."
1323+
);
1324+
} else {
1325+
// Validation passed
1326+
println!("\nAgent '{}' updated successfully!", frontmatter.name);
1327+
return Ok(());
1328+
}
1329+
}
1330+
Err(e) => {
1331+
eprintln!("\nError: Invalid agent configuration: {}", e);
1332+
}
1333+
}
1334+
1335+
// Validation failed - offer to re-edit or rollback
1336+
print!(
1337+
"Would you like to (e)dit again, (r)ollback to original, or (k)eep invalid file? [e/r/k]: "
1338+
);
1339+
io::stdout().flush()?;
1340+
1341+
let mut input = String::new();
1342+
io::stdin().lock().read_line(&mut input)?;
1343+
1344+
match input.trim().to_lowercase().as_str() {
1345+
"r" | "rollback" => {
1346+
// Restore the backup
1347+
std::fs::write(path, &backup_content)
1348+
.with_context(|| format!("Failed to restore backup: {}", path.display()))?;
1349+
println!("Rolled back to original version.");
1350+
return Ok(());
1351+
}
1352+
"k" | "keep" => {
1353+
eprintln!("Warning: Keeping invalid configuration. The agent may fail to load.");
1354+
return Ok(());
1355+
}
1356+
_ => {
1357+
// Default: re-edit
1358+
continue;
1359+
}
1360+
}
1361+
}
1362+
}
1363+
12341364
/// Remove agent command.
12351365
async fn run_remove(args: RemoveArgs) -> Result<()> {
12361366
let agents = load_all_agents()?;

0 commit comments

Comments
 (0)