fix(security): tighten paths and output handling
This commit is contained in:
@@ -8,6 +8,9 @@ on:
|
||||
schedule:
|
||||
- cron: '31 6 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
@@ -10,6 +10,9 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: -Dwarnings
|
||||
|
||||
@@ -1088,7 +1088,7 @@ fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) -
|
||||
fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> {
|
||||
match command {
|
||||
ConfigCommand::Get { key } => {
|
||||
if let Some(value) = store.config.get_value(&key) {
|
||||
if let Some(value) = store.config.get_display_value(&key) {
|
||||
println!("{value}");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
@@ -451,6 +451,17 @@ impl ConfigToml {
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_display_value(&self, key: &str) -> Option<String> {
|
||||
self.get_value(key).map(|value| {
|
||||
if is_sensitive_config_key(key) {
|
||||
redact_secret(&value)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
|
||||
match key {
|
||||
"provider" => {
|
||||
@@ -1229,16 +1240,19 @@ pub fn default_secrets() -> &'static Secrets {
|
||||
}
|
||||
|
||||
pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
return Ok(path);
|
||||
}
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let path = if let Some(path) = explicit {
|
||||
path
|
||||
} else if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
PathBuf::from(trimmed)
|
||||
} else {
|
||||
return default_config_path();
|
||||
}
|
||||
}
|
||||
default_config_path()
|
||||
} else {
|
||||
return default_config_path();
|
||||
};
|
||||
normalize_config_file_path(path)
|
||||
}
|
||||
|
||||
pub fn default_config_path() -> Result<PathBuf> {
|
||||
@@ -1307,6 +1321,35 @@ fn redact_secret(secret: &str) -> String {
|
||||
format!("{prefix}***{suffix}")
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_sensitive_config_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"api_key" | "auth.chatgpt_access_token" | "auth.device_code_session"
|
||||
) || key.ends_with(".api_key")
|
||||
}
|
||||
|
||||
fn normalize_config_file_path(path: PathBuf) -> Result<PathBuf> {
|
||||
if path.as_os_str().is_empty() {
|
||||
bail!("config path cannot be empty");
|
||||
}
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
bail!("config path cannot contain '..' components");
|
||||
}
|
||||
if path.file_name().is_none() {
|
||||
bail!("config path must include a file name");
|
||||
}
|
||||
if path.is_absolute() {
|
||||
return Ok(path);
|
||||
}
|
||||
Ok(std::env::current_dir()
|
||||
.context("failed to resolve current directory for config path")?
|
||||
.join(path))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct EnvRuntimeOverrides {
|
||||
provider: Option<ProviderKind>,
|
||||
@@ -1798,6 +1841,38 @@ mod tests {
|
||||
assert_eq!(values.get("api_key").map(String::as_str), Some("********"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_display_value_redacts_sensitive_keys() {
|
||||
let mut config = ConfigToml {
|
||||
api_key: Some("sk-deepseek-secret".to_string()),
|
||||
chatgpt_access_token: Some("chatgpt-access-secret".to_string()),
|
||||
..ConfigToml::default()
|
||||
};
|
||||
config.providers.openrouter.api_key = Some("openrouter-secret-value".to_string());
|
||||
config.model = Some("deepseek-v4-pro".to_string());
|
||||
|
||||
assert_eq!(
|
||||
config.get_display_value("api_key").as_deref(),
|
||||
Some("sk-d***cret")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.get_display_value("auth.chatgpt_access_token")
|
||||
.as_deref(),
|
||||
Some("chat***cret")
|
||||
);
|
||||
assert_eq!(
|
||||
config
|
||||
.get_display_value("providers.openrouter.api_key")
|
||||
.as_deref(),
|
||||
Some("open***alue")
|
||||
);
|
||||
assert_eq!(
|
||||
config.get_display_value("model").as_deref(),
|
||||
Some("deepseek-v4-pro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_values_redacts_unicode_api_key_without_byte_slicing() {
|
||||
let config = ConfigToml {
|
||||
@@ -1813,6 +1888,13 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_config_file_path_rejects_traversal() {
|
||||
let err = normalize_config_file_path(PathBuf::from("../config.toml"))
|
||||
.expect_err("traversal path should fail");
|
||||
assert!(format!("{err:#}").contains("cannot contain '..'"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn save_clamps_existing_config_permissions() {
|
||||
|
||||
+13
-23
@@ -1732,11 +1732,10 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
std::io::stdout().flush().ok();
|
||||
|
||||
match test_api_connectivity(config).await {
|
||||
Ok(model) => {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"\r {} API connection successful (model: {})",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
model
|
||||
"\r {} API connection successful",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -2544,7 +2543,7 @@ async fn run_models(config: &Config, args: ModelsArgs) -> Result<()> {
|
||||
}
|
||||
|
||||
/// Test API connectivity by making a minimal request
|
||||
async fn test_api_connectivity(config: &Config) -> Result<String> {
|
||||
async fn test_api_connectivity(config: &Config) -> Result<()> {
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::models::{ContentBlock, Message, MessageRequest};
|
||||
|
||||
@@ -2576,7 +2575,7 @@ async fn test_api_connectivity(config: &Config) -> Result<String> {
|
||||
// Use tokio timeout to catch hanging requests
|
||||
let timeout_duration = std::time::Duration::from_secs(15);
|
||||
match tokio::time::timeout(timeout_duration, client.create_message(request)).await {
|
||||
Ok(Ok(_response)) => Ok(model),
|
||||
Ok(Ok(_response)) => Ok(()),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Err(_) => anyhow::bail!("Request timeout after 15 seconds"),
|
||||
}
|
||||
@@ -3742,17 +3741,13 @@ fn recover_interrupted_checkpoint_for_resume(launch_workspace: &Path) -> Option<
|
||||
// sessions`, then clear it so the next launch in this folder doesn't
|
||||
// re-trip the nag. Print a one-line notice pointing at the explicit
|
||||
// resume command — but DO NOT auto-load the session here.
|
||||
let session_id_for_notice = session.metadata.id.clone();
|
||||
let _ = manager.save_session(&session);
|
||||
let _ = manager.clear_checkpoint();
|
||||
eprintln!(
|
||||
"Note: an interrupted session ({}…) from another workspace ({}) is \
|
||||
available. Run `deepseek resume {}` from there to recover it, or \
|
||||
use `deepseek sessions` to list all saved sessions. Starting fresh \
|
||||
in {}.",
|
||||
&session_id_for_notice.chars().take(8).collect::<String>(),
|
||||
"Note: an interrupted session from another workspace ({}) is \
|
||||
available. Run `deepseek sessions` to list saved sessions. Starting \
|
||||
fresh in {}.",
|
||||
session_workspace.display(),
|
||||
session_id_for_notice,
|
||||
launch_workspace.display(),
|
||||
);
|
||||
return None;
|
||||
@@ -3786,27 +3781,22 @@ fn preserve_interrupted_checkpoint_for_explicit_resume(launch_workspace: &Path)
|
||||
return;
|
||||
};
|
||||
|
||||
let session_id = session.metadata.id.clone();
|
||||
let session_workspace = session.metadata.workspace.clone();
|
||||
let _ = manager.save_session(&session);
|
||||
let _ = manager.clear_checkpoint();
|
||||
|
||||
let age_str = checkpoint_age_label(age);
|
||||
let short_id = session_id.chars().take(8).collect::<String>();
|
||||
if session_manager::workspace_scope_matches(&session_workspace, launch_workspace) {
|
||||
eprintln!(
|
||||
"Found an in-flight session snapshot ({age_str}, {short_id}…). \
|
||||
Starting a new session. Run `deepseek resume {session_id}` or \
|
||||
`deepseek --continue` to resume it."
|
||||
"Found an in-flight session snapshot ({age_str}). Starting a new \
|
||||
session. Run `deepseek --continue` to resume it."
|
||||
);
|
||||
} else {
|
||||
eprintln!(
|
||||
"Note: an interrupted session ({short_id}…) from another workspace ({}) \
|
||||
is available. Run `deepseek resume {}` from there to recover it, or \
|
||||
use `deepseek sessions` to list all saved sessions. Starting fresh \
|
||||
in {}.",
|
||||
"Note: an interrupted session from another workspace ({}) is \
|
||||
available. Run `deepseek sessions` to list saved sessions. Starting \
|
||||
fresh in {}.",
|
||||
session_workspace.display(),
|
||||
session_id,
|
||||
launch_workspace.display(),
|
||||
);
|
||||
}
|
||||
|
||||
+26
-1
@@ -7,7 +7,7 @@
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -24,6 +24,19 @@ use crate::utils::write_atomic;
|
||||
/// Bytes of a non-2xx response body to surface in connection errors.
|
||||
const ERROR_BODY_PREVIEW_BYTES: usize = 200;
|
||||
|
||||
fn validate_mcp_config_path(path: &Path) -> Result<()> {
|
||||
if path.as_os_str().is_empty() {
|
||||
anyhow::bail!("MCP config path cannot be empty");
|
||||
}
|
||||
if path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
anyhow::bail!("MCP config path cannot contain '..' components");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mask a URL so any embedded credentials in the userinfo portion (e.g.
|
||||
/// `https://user:secret@host`) are replaced with `***`. Failures fall back to
|
||||
/// the original string so we don't lose context — we never want masking to
|
||||
@@ -1046,6 +1059,7 @@ impl McpPool {
|
||||
|
||||
/// Create a pool from a configuration file path
|
||||
pub fn from_config_path(path: &std::path::Path) -> Result<Self> {
|
||||
validate_mcp_config_path(path)?;
|
||||
let config = if path.exists() {
|
||||
let contents = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read MCP config: {}", path.display()))?;
|
||||
@@ -1605,6 +1619,7 @@ pub struct McpManagerSnapshot {
|
||||
}
|
||||
|
||||
pub fn load_config(path: &Path) -> Result<McpConfig> {
|
||||
validate_mcp_config_path(path)?;
|
||||
if !path.exists() {
|
||||
return Ok(McpConfig::default());
|
||||
}
|
||||
@@ -1615,6 +1630,7 @@ pub fn load_config(path: &Path) -> Result<McpConfig> {
|
||||
}
|
||||
|
||||
pub fn save_config(path: &Path, cfg: &McpConfig) -> Result<()> {
|
||||
validate_mcp_config_path(path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| {
|
||||
format!("Failed to create MCP config directory {}", parent.display())
|
||||
@@ -1971,6 +1987,15 @@ mod tests {
|
||||
assert_eq!(snapshot.servers[0].error.as_deref(), Some("disabled"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_config_rejects_traversal_path() {
|
||||
let err = load_config(Path::new("../mcp.json")).expect_err("traversal path should fail");
|
||||
assert!(
|
||||
format!("{err:#}").contains("cannot contain '..'"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_config_manager_actions_round_trip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1602,30 +1602,23 @@ fn run_git(workspace: &std::path::Path, args: &[&str]) -> Option<String> {
|
||||
}
|
||||
|
||||
fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
|
||||
let agents_skills = workspace.join(".agents").join("skills");
|
||||
if agents_skills.exists() {
|
||||
return agents_skills;
|
||||
}
|
||||
let local_skills = workspace.join("skills");
|
||||
if local_skills.exists() {
|
||||
return local_skills;
|
||||
let workspace = fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
||||
for candidate in [
|
||||
workspace.join(".agents").join("skills"),
|
||||
workspace.join("skills"),
|
||||
] {
|
||||
if let Ok(candidate) = fs::canonicalize(candidate)
|
||||
&& candidate.is_dir()
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
config.skills_dir()
|
||||
}
|
||||
|
||||
fn load_mcp_config_or_default(path: &std::path::Path) -> Result<McpConfig, ApiError> {
|
||||
if !path.exists() {
|
||||
return Ok(McpConfig::default());
|
||||
}
|
||||
let raw = fs::read_to_string(path).map_err(|e| {
|
||||
ApiError::internal(format!("Failed to read MCP config {}: {e}", path.display()))
|
||||
})?;
|
||||
serde_json::from_str::<McpConfig>(&raw).map_err(|e| {
|
||||
ApiError::internal(format!(
|
||||
"Failed to parse MCP config {}: {e}",
|
||||
path.display()
|
||||
))
|
||||
})
|
||||
crate::mcp::load_config(path)
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e:#}")))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -3046,11 +3039,10 @@ mod tests {
|
||||
let root = std::env::temp_dir().join(format!("deepseek-session-resume-{}", Uuid::new_v4()));
|
||||
let sessions_dir = root.join("sessions");
|
||||
fs::create_dir_all(&sessions_dir)?;
|
||||
let session_id = "sess_test_resume";
|
||||
let session = json!({
|
||||
"schema_version": 1,
|
||||
"metadata": {
|
||||
"id": session_id,
|
||||
"id": "sess_test_resume",
|
||||
"title": "Test resume session",
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:10:00Z",
|
||||
@@ -3073,7 +3065,7 @@ mod tests {
|
||||
"system_prompt": null
|
||||
});
|
||||
fs::write(
|
||||
sessions_dir.join(format!("{session_id}.json")),
|
||||
sessions_dir.join("sess_test_resume.json"),
|
||||
serde_json::to_string_pretty(&session)?,
|
||||
)?;
|
||||
|
||||
@@ -3086,14 +3078,14 @@ mod tests {
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"http://{addr}/v1/sessions/{session_id}/resume-thread"
|
||||
"http://{addr}/v1/sessions/sess_test_resume/resume-thread"
|
||||
))
|
||||
.json(&json!({ "model": "deepseek-v4-pro" }))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let resumed: serde_json::Value = resp.json().await?;
|
||||
assert_eq!(resumed["session_id"], session_id);
|
||||
assert_eq!(resumed["session_id"], "sess_test_resume");
|
||||
assert_eq!(resumed["message_count"], 2);
|
||||
|
||||
let thread_id = resumed["thread_id"]
|
||||
|
||||
@@ -33,6 +33,24 @@ use crate::tui::app::AppMode;
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
const MAX_ACTIVE_THREADS_DEFAULT: usize = 8;
|
||||
const SUMMARY_LIMIT: usize = 280;
|
||||
|
||||
fn validated_record_id<'a>(id: &'a str, label: &str) -> Result<&'a str> {
|
||||
let trimmed = id.trim();
|
||||
if trimmed.is_empty() {
|
||||
bail!("{label} cannot be empty");
|
||||
}
|
||||
if trimmed != id {
|
||||
bail!("{label} cannot contain leading or trailing whitespace");
|
||||
}
|
||||
if !trimmed
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
bail!("{label} contains unsupported characters");
|
||||
}
|
||||
Ok(trimmed)
|
||||
}
|
||||
|
||||
/// Bumped to 2 for v0.6.6 — see issue #124. The persisted thread/turn/item
|
||||
/// records didn't change shape, but the live engine semantics did: cycle
|
||||
/// boundaries advance the `Session.cycle_count` and produce archived JSONL
|
||||
@@ -241,36 +259,43 @@ impl RuntimeThreadStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn thread_path(&self, thread_id: &str) -> PathBuf {
|
||||
self.threads_dir.join(format!("{thread_id}.json"))
|
||||
fn record_path(base: &Path, id: &str, extension: &str, label: &str) -> Result<PathBuf> {
|
||||
let id = validated_record_id(id, label)?;
|
||||
Ok(base.join(format!("{id}.{extension}")))
|
||||
}
|
||||
|
||||
fn turn_path(&self, turn_id: &str) -> PathBuf {
|
||||
self.turns_dir.join(format!("{turn_id}.json"))
|
||||
fn thread_path(&self, thread_id: &str) -> Result<PathBuf> {
|
||||
Self::record_path(&self.threads_dir, thread_id, "json", "thread id")
|
||||
}
|
||||
|
||||
fn item_path(&self, item_id: &str) -> PathBuf {
|
||||
self.items_dir.join(format!("{item_id}.json"))
|
||||
fn turn_path(&self, turn_id: &str) -> Result<PathBuf> {
|
||||
Self::record_path(&self.turns_dir, turn_id, "json", "turn id")
|
||||
}
|
||||
|
||||
fn events_path(&self, thread_id: &str) -> PathBuf {
|
||||
self.events_dir.join(format!("{thread_id}.jsonl"))
|
||||
fn item_path(&self, item_id: &str) -> Result<PathBuf> {
|
||||
Self::record_path(&self.items_dir, item_id, "json", "item id")
|
||||
}
|
||||
|
||||
fn events_path(&self, thread_id: &str) -> Result<PathBuf> {
|
||||
Self::record_path(&self.events_dir, thread_id, "jsonl", "thread id")
|
||||
}
|
||||
|
||||
pub fn save_thread(&self, thread: &ThreadRecord) -> Result<()> {
|
||||
write_json_atomic(&self.thread_path(&thread.id), thread)
|
||||
write_json_atomic(&self.thread_path(&thread.id)?, thread)
|
||||
}
|
||||
|
||||
pub fn save_turn(&self, turn: &TurnRecord) -> Result<()> {
|
||||
write_json_atomic(&self.turn_path(&turn.id), turn)
|
||||
validated_record_id(&turn.thread_id, "thread id")?;
|
||||
write_json_atomic(&self.turn_path(&turn.id)?, turn)
|
||||
}
|
||||
|
||||
pub fn save_item(&self, item: &TurnItemRecord) -> Result<()> {
|
||||
write_json_atomic(&self.item_path(&item.id), item)
|
||||
validated_record_id(&item.turn_id, "turn id")?;
|
||||
write_json_atomic(&self.item_path(&item.id)?, item)
|
||||
}
|
||||
|
||||
pub fn load_thread(&self, thread_id: &str) -> Result<ThreadRecord> {
|
||||
let path = self.thread_path(thread_id);
|
||||
let path = self.thread_path(thread_id)?;
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read thread {}", path.display()))?;
|
||||
let record: ThreadRecord = serde_json::from_str(&raw)
|
||||
@@ -286,7 +311,7 @@ impl RuntimeThreadStore {
|
||||
}
|
||||
|
||||
pub fn load_turn(&self, turn_id: &str) -> Result<TurnRecord> {
|
||||
let path = self.turn_path(turn_id);
|
||||
let path = self.turn_path(turn_id)?;
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read turn {}", path.display()))?;
|
||||
let record: TurnRecord = serde_json::from_str(&raw)
|
||||
@@ -302,7 +327,7 @@ impl RuntimeThreadStore {
|
||||
}
|
||||
|
||||
pub fn load_item(&self, item_id: &str) -> Result<TurnItemRecord> {
|
||||
let path = self.item_path(item_id);
|
||||
let path = self.item_path(item_id)?;
|
||||
let raw = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read item {}", path.display()))?;
|
||||
let record: TurnItemRecord = serde_json::from_str(&raw)
|
||||
@@ -345,6 +370,7 @@ impl RuntimeThreadStore {
|
||||
}
|
||||
|
||||
pub fn list_turns_for_thread(&self, thread_id: &str) -> Result<Vec<TurnRecord>> {
|
||||
validated_record_id(thread_id, "thread id")?;
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(&self.turns_dir)
|
||||
.with_context(|| format!("Failed to read {}", self.turns_dir.display()))?
|
||||
@@ -374,6 +400,7 @@ impl RuntimeThreadStore {
|
||||
}
|
||||
|
||||
pub fn list_items_for_turn(&self, turn_id: &str) -> Result<Vec<TurnItemRecord>> {
|
||||
validated_record_id(turn_id, "turn id")?;
|
||||
let mut out = Vec::new();
|
||||
for entry in fs::read_dir(&self.items_dir)
|
||||
.with_context(|| format!("Failed to read {}", self.items_dir.display()))?
|
||||
@@ -414,6 +441,15 @@ impl RuntimeThreadStore {
|
||||
event: impl Into<String>,
|
||||
payload: Value,
|
||||
) -> Result<RuntimeEventRecord> {
|
||||
validated_record_id(thread_id, "thread id")?;
|
||||
if let Some(turn_id) = turn_id {
|
||||
validated_record_id(turn_id, "turn id")?;
|
||||
}
|
||||
if let Some(item_id) = item_id {
|
||||
validated_record_id(item_id, "item id")?;
|
||||
}
|
||||
let path = self.events_path(thread_id)?;
|
||||
|
||||
let mut state = self.state.lock().await;
|
||||
let seq = state.next_seq;
|
||||
state.next_seq = state.next_seq.saturating_add(1);
|
||||
@@ -431,7 +467,6 @@ impl RuntimeThreadStore {
|
||||
payload,
|
||||
};
|
||||
|
||||
let path = self.events_path(thread_id);
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
@@ -451,7 +486,7 @@ impl RuntimeThreadStore {
|
||||
thread_id: &str,
|
||||
since_seq: Option<u64>,
|
||||
) -> Result<Vec<RuntimeEventRecord>> {
|
||||
let path = self.events_path(thread_id);
|
||||
let path = self.events_path(thread_id)?;
|
||||
if !path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -3319,6 +3354,37 @@ mod tests {
|
||||
assert_eq!(CURRENT_RUNTIME_SCHEMA_VERSION, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_rejects_path_like_record_ids() {
|
||||
let dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(dir.clone()).expect("open store");
|
||||
|
||||
let err = store
|
||||
.load_thread("../outside")
|
||||
.expect_err("path traversal id should fail");
|
||||
assert!(
|
||||
format!("{err:#}").contains("unsupported characters"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
|
||||
let mut thread = sample_thread("thr_bad/id");
|
||||
let err = store
|
||||
.save_thread(&thread)
|
||||
.expect_err("path separator id should fail");
|
||||
assert!(
|
||||
format!("{err:#}").contains("unsupported characters"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
|
||||
thread.id = " thr_bad".to_string();
|
||||
let err = store
|
||||
.save_thread(&thread)
|
||||
.expect_err("whitespace id should fail");
|
||||
assert!(format!("{err:#}").contains("whitespace"), "got: {err:#}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn store_load_turn_rejects_newer_schema_version() {
|
||||
let dir = test_runtime_dir();
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::utils::write_atomic;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Maximum number of sessions to retain
|
||||
@@ -33,6 +33,31 @@ const fn default_queue_schema_version() -> u32 {
|
||||
CURRENT_QUEUE_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
fn normalize_managed_dir(path: PathBuf) -> std::io::Result<PathBuf> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"managed directory path cannot be empty",
|
||||
));
|
||||
}
|
||||
if path.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::Prefix(_) | Component::RootDir
|
||||
)
|
||||
}) && path.is_relative()
|
||||
{
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"managed directory path cannot contain traversal components",
|
||||
));
|
||||
}
|
||||
if path.is_absolute() {
|
||||
return Ok(path);
|
||||
}
|
||||
std::env::current_dir().map(|cwd| cwd.join(path))
|
||||
}
|
||||
|
||||
/// Persisted queued message for offline/degraded mode.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QueuedSessionMessage {
|
||||
@@ -117,6 +142,7 @@ pub struct SavedSession {
|
||||
}
|
||||
|
||||
/// Manager for session persistence operations
|
||||
#[derive(Debug)]
|
||||
pub struct SessionManager {
|
||||
/// Directory where sessions are stored
|
||||
sessions_dir: PathBuf,
|
||||
@@ -145,6 +171,7 @@ impl SessionManager {
|
||||
|
||||
/// Create a new `SessionManager` with the specified sessions directory
|
||||
pub fn new(sessions_dir: PathBuf) -> std::io::Result<Self> {
|
||||
let sessions_dir = normalize_managed_dir(sessions_dir)?;
|
||||
// Ensure the sessions directory exists
|
||||
fs::create_dir_all(&sessions_dir)?;
|
||||
Ok(Self { sessions_dir })
|
||||
@@ -1060,6 +1087,13 @@ mod tests {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_manager_rejects_relative_traversal_dir() {
|
||||
let err = SessionManager::new(PathBuf::from("../sessions"))
|
||||
.expect_err("relative traversal directory should fail");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_truncate_title() {
|
||||
assert_eq!(truncate_title("Short", 50), "Short");
|
||||
@@ -1234,8 +1268,7 @@ mod tests {
|
||||
.expect("present");
|
||||
assert!(
|
||||
unscoped.session_id.is_none(),
|
||||
"save with None must persist a missing session_id, got {:?}",
|
||||
unscoped.session_id
|
||||
"save with None must persist a missing session_id"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,10 @@ impl SkillRegistry {
|
||||
#[must_use]
|
||||
pub fn discover(dir: &Path) -> Self {
|
||||
let mut registry = Self::default();
|
||||
if !dir.exists() {
|
||||
let Ok(canonical_dir) = fs::canonicalize(dir) else {
|
||||
return registry;
|
||||
};
|
||||
if !canonical_dir.is_dir() {
|
||||
return registry;
|
||||
}
|
||||
|
||||
@@ -403,8 +406,12 @@ pub fn skills_directories(workspace: &Path) -> Vec<PathBuf> {
|
||||
|
||||
fn existing_skill_dirs(candidates: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for path in candidates {
|
||||
if path.is_dir() && !out.iter().any(|p: &PathBuf| p == &path) {
|
||||
let Ok(canonical_path) = fs::canonicalize(&path) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_path.is_dir() && seen.insert(canonical_path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,9 +442,7 @@ fn format_resume_hint(session_id: Option<&str>) -> Option<String> {
|
||||
if session_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"To continue this session, run deepseek resume {session_id}"
|
||||
))
|
||||
Some("To continue this session, run deepseek --continue".to_string())
|
||||
}
|
||||
|
||||
fn terminal_probe_timeout(config: &Config) -> Duration {
|
||||
|
||||
@@ -20,10 +20,7 @@ use tempfile::TempDir;
|
||||
fn format_resume_hint_uses_canonical_resume_command() {
|
||||
assert_eq!(
|
||||
format_resume_hint(Some("019dd9d6-4f44-7c83-9863-59674a12b827")),
|
||||
Some(
|
||||
"To continue this session, run deepseek resume 019dd9d6-4f44-7c83-9863-59674a12b827"
|
||||
.to_string()
|
||||
)
|
||||
Some("To continue this session, run deepseek --continue".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,75 @@ If nothing is drifted, return { "drifts": [] }.
|
||||
|
||||
${VOICE_CONSTRAINTS}`;
|
||||
|
||||
function startsWithAsciiCI(input: string, index: number, needle: string): boolean {
|
||||
if (index + needle.length > input.length) return false;
|
||||
return input.slice(index, index + needle.length).toLowerCase() === needle;
|
||||
}
|
||||
|
||||
function isWhitespace(c: string | undefined): boolean {
|
||||
return c === " " || c === "\n" || c === "\r" || c === "\t" || c === "\f";
|
||||
}
|
||||
|
||||
function tagNameBoundary(input: string, index: number): boolean {
|
||||
const c = input[index];
|
||||
return c === undefined || c === ">" || c === "/" || isWhitespace(c);
|
||||
}
|
||||
|
||||
function findClosingRawTextTag(input: string, from: number, tagName: "script" | "style"): number {
|
||||
const closePrefix = `</${tagName}`;
|
||||
for (let i = from; i < input.length; i += 1) {
|
||||
if (startsWithAsciiCI(input, i, closePrefix) && tagNameBoundary(input, i + closePrefix.length)) {
|
||||
const close = input.indexOf(">", i + closePrefix.length);
|
||||
return close === -1 ? input.length : close + 1;
|
||||
}
|
||||
}
|
||||
return input.length;
|
||||
}
|
||||
|
||||
function collapseWhitespace(input: string): string {
|
||||
let out = "";
|
||||
let pendingSpace = false;
|
||||
for (const c of input) {
|
||||
if (isWhitespace(c)) {
|
||||
pendingSpace = out.length > 0;
|
||||
continue;
|
||||
}
|
||||
if (pendingSpace) out += " ";
|
||||
out += c;
|
||||
pendingSpace = false;
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function stripHtmlForPrompt(input: string): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < input.length;) {
|
||||
if (input[i] !== "<") {
|
||||
out += input[i];
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (startsWithAsciiCI(input, i, "<script") && tagNameBoundary(input, i + "<script".length)) {
|
||||
out += " ";
|
||||
const openEnd = input.indexOf(">", i + 1);
|
||||
i = openEnd === -1 ? input.length : findClosingRawTextTag(input, openEnd + 1, "script");
|
||||
continue;
|
||||
}
|
||||
if (startsWithAsciiCI(input, i, "<style") && tagNameBoundary(input, i + "<style".length)) {
|
||||
out += " ";
|
||||
const openEnd = input.indexOf(">", i + 1);
|
||||
i = openEnd === -1 ? input.length : findClosingRawTextTag(input, openEnd + 1, "style");
|
||||
continue;
|
||||
}
|
||||
|
||||
out += " ";
|
||||
const tagEnd = input.indexOf(">", i + 1);
|
||||
i = tagEnd === -1 ? input.length : tagEnd + 1;
|
||||
}
|
||||
return collapseWhitespace(out).slice(0, 8000);
|
||||
}
|
||||
|
||||
export async function runSemanticDrift(env: WatchEnv): Promise<{ ok: boolean; drafted: number; reason?: string }> {
|
||||
if (!env.CURATED_KV || !env.DEEPSEEK_API_KEY) {
|
||||
return { ok: false, drafted: 0, reason: "missing CURATED_KV or DEEPSEEK_API_KEY" };
|
||||
@@ -160,11 +229,8 @@ export async function runSemanticDrift(env: WatchEnv): Promise<{ ok: boolean; dr
|
||||
return { ok: false, drafted: 0, reason: "no changelog or commits available" };
|
||||
}
|
||||
|
||||
// Strip HTML tags + collapse whitespace to keep prompt size tractable.
|
||||
const stripHtml = (h: string) => h.replace(/<script[\s\S]*?<\/script>/g, "").replace(/<style[\s\S]*?<\/style>/g, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim().slice(0, 8000);
|
||||
|
||||
const homepageText = stripHtml(homepageHtml);
|
||||
const docsText = stripHtml(docsHtml);
|
||||
const homepageText = stripHtmlForPrompt(homepageHtml);
|
||||
const docsText = stripHtmlForPrompt(docsHtml);
|
||||
const changelogHead = changelog.slice(0, 4000);
|
||||
const commitMsgs = commits.slice(0, 30).map((c) => `- ${c.sha.slice(0, 7)}: ${c.commit.message.split("\n")[0]}`).join("\n");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user