fix(security): tighten paths and output handling

This commit is contained in:
Hunter Bown
2026-05-08 14:13:55 -05:00
parent 4de726abc5
commit 8380784308
13 changed files with 352 additions and 90 deletions
+3
View File
@@ -8,6 +8,9 @@ on:
schedule:
- cron: '31 6 * * 1'
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
+3
View File
@@ -10,6 +10,9 @@ on:
required: true
type: string
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
+1 -1
View File
@@ -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(());
}
+90 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+16 -24
View File
@@ -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"]
+82 -16
View File
@@ -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();
+36 -3
View File
@@ -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"
);
}
+9 -2
View File
@@ -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);
}
}
+1 -3
View File
@@ -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 {
+1 -4
View File
@@ -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())
);
}
+71 -5
View File
@@ -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");