Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -996,7 +996,9 @@ def _tool_project_candidates(messages):
arguments.get("cwd"),
arguments.get("workdir"),
):
if isinstance(candidate, str) and os.path.isabs(os.path.expanduser(candidate)):
if isinstance(candidate, str) and _path_is_absolute(
os.path.expanduser(candidate)
):
candidates.append(candidate)
if name in ("terminal", "bash", "shell", "exec_command"):
candidates.extend(_terminal_cd_candidates(arguments.get("command") or arguments.get("cmd")))
Expand Down Expand Up @@ -1240,9 +1242,18 @@ def _runtime_working_directory():
return candidate
return os.getcwd()

def _path_is_absolute(candidate):
if os.path.isabs(candidate):
return True
# ntpath.isabs rejects "/..." spellings as root-relative, but a host may
# forward POSIX-absolute project roots to a Windows-hosted plugin; the
# containment checks still run on the realpath'd candidate.
return candidate.startswith("/")


def _code_project_root(explicit=None, cwd=None, configured=None, hermes_home=None):
candidate = explicit or cwd or configured or _runtime_working_directory()
if isinstance(candidate, str) and candidate.strip() and os.path.isabs(candidate):
if isinstance(candidate, str) and candidate.strip() and _path_is_absolute(candidate):
candidate = candidate.strip()
try:
candidate_real = os.path.realpath(candidate)
Expand Down
27 changes: 23 additions & 4 deletions crates/tracedecay-application/src/diagnostics_publication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! provider payloads are never copied into a diagnostic record; consumers
//! reach evidence through the authorized expansion path instead.

use std::borrow::Cow;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::{Component, Path, PathBuf};
Expand Down Expand Up @@ -300,17 +301,35 @@ pub fn code_index_logical_path(project_root: &Path, reported: &str) -> Option<St
}
let relative = if is_logical_absolute(&reported_logical) {
let root_logical = project_root.to_str()?.replace('\\', "/");
strip_logical_prefix(&reported_logical, &root_logical)?
let relative: String = strip_logical_prefix(&reported_logical, &root_logical)
.map(str::to_string)
.or_else(|| {
// Alias spellings (Windows 8.3 names, verbatim roots, symlinked
// parents) all resolve to the root the code index records, so
// the raw compare misses them. Canonicalize through the
// deepest existing ancestor — the reported file may not exist
// yet.
let reported_canonical =
tracedecay_runtime_core::path_safety::canonical_root_identity(Path::new(
&reported_logical,
));
let root_canonical =
tracedecay_runtime_core::path_safety::canonical_root_identity(project_root);
let reported_canonical = reported_canonical.to_str()?.replace('\\', "/");
let root_canonical = root_canonical.to_str()?.replace('\\', "/");
strip_logical_prefix(&reported_canonical, &root_canonical).map(str::to_string)
})?;
Cow::Owned(relative)
} else {
reported_logical.as_str()
Cow::Borrowed(reported_logical.as_str())
};
if relative.is_empty()
|| logical_path_components(relative)
|| logical_path_components(&relative)
.any(|component| !is_normal_logical_component(component))
{
return None;
}
Some(relative.to_string())
Some(relative.into_owned())
}

fn is_logical_absolute(path: &str) -> bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ pub fn load_standard_hermes_skill_bridge(
let user_home = user_home.ok_or_else(|| {
config_error("could not determine the user home for Hermes skill inventory")
})?;
load_standard_hermes_skill_bridge_from_user_home(user_home, options)
// Reported paths spell plainly; a verbatim home leaks `\\?\` into every
// serialized skill and usage path.
load_standard_hermes_skill_bridge_from_user_home(
&tracedecay_runtime_core::path_safety::plain_host_path(user_home),
options,
)
}

fn load_standard_hermes_skill_bridge_from_user_home(
Expand Down
75 changes: 58 additions & 17 deletions crates/tracedecay-cli/src/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -699,26 +699,67 @@ mod tests {

#[test]
fn a_refused_connection_is_network_unreachable() {
// Bound but never listening, and held for the whole test: the port
// refuses connections and cannot be reused, whereas a dropped listener
// stays connectable while a sibling test's forked child still holds
// the inherited descriptor.
let refusing =
socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap();
refusing
.bind(
&"127.0.0.1:0"
.parse::<std::net::SocketAddr>()
.unwrap()
.into(),
// A connection that dies at the transport level before any HTTP
// answer is NetworkUnreachable. Unix refuses a SYN to a port that is
// bound but never listening instantly (the socket is held so the
// port cannot be reused, whereas a dropped listener stays
// connectable while a sibling test's forked child still holds the
// inherited descriptor). The Windows kernel retries a refused SYN
// for about two seconds — longer than the lookup budget — so there
// the same failure class is reached by accepting the connection and
// resetting it with a zero-linger close.
#[cfg(unix)]
let (base, _hold) = {
let refusing =
socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap();
refusing
.bind(
&"127.0.0.1:0"
.parse::<std::net::SocketAddr>()
.unwrap()
.into(),
)
.unwrap();
(
format!(
"http://{}",
refusing.local_addr().unwrap().as_socket().unwrap()
),
refusing,
)
.unwrap();
let base = format!(
"http://{}",
refusing.local_addr().unwrap().as_socket().unwrap()
);
};
#[cfg(windows)]
let (base, _hold) = {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
listener.set_nonblocking(true).unwrap();
let (stop, stopped) = std::sync::mpsc::channel();
let hold = std::thread::spawn(move || {
loop {
match listener.accept() {
Ok((stream, _)) => {
socket2::SockRef::from(&stream)
.set_linger(Some(Duration::ZERO))
.unwrap();
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => panic!("reset listener failed: {error}"),
}
match stopped.recv_timeout(Duration::from_millis(10)) {
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
_ => break,
}
}
});
(base, (stop, hold))
};

let error = latest_release_version(&base, true, None).unwrap_err();
#[cfg(windows)]
{
_hold.0.send(()).unwrap();
_hold.1.join().unwrap();
}

assert!(
matches!(error, ReleaseLookupError::NetworkUnreachable { .. }),
Expand Down
5 changes: 4 additions & 1 deletion crates/tracedecay-cli/src/sessions_cmd/refresh/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ async fn project_refresh_sends_a_relative_project_path_as_the_cli_directory() {
.await
.unwrap();

let cli_directory = std::env::current_dir().unwrap().canonicalize().unwrap();
let cli_directory = tracedecay_runtime_core::path_safety::canonical_existing_identity(
&std::env::current_dir().unwrap(),
)
.unwrap();
assert_eq!(
transport.calls()[0].arguments,
json!({ "path": cli_directory, "format": "json" })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,15 @@ struct ColdMountFinalCommitGateV1 {
release: tokio::sync::oneshot::Receiver<()>,
}

/// Test gates are armed with whatever path spelling the caller holds while the
/// mount path looks them up by the root the scheduler canonicalized (plain
/// `C:\` on Windows vs. the caller's verbatim `\\?\` form). Routing both sides
/// through `canonical_existing_identity` makes either spelling find the gate.
#[cfg(any(test, feature = "test-helpers"))]
pub(super) fn test_gate_root(path: &Path) -> PathBuf {
canonical_existing_identity(path).unwrap_or_else(|_| path.to_path_buf())
}

/// Armed gates keyed by project root, so tests pausing distinct worktrees in
/// one process do not contend for a single slot.
#[cfg(any(test, feature = "test-helpers"))]
Expand Down Expand Up @@ -1962,7 +1971,7 @@ impl CodeIndexSchedulerRegistryV1 {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
project_root,
test_gate_root(&project_root),
ColdMountFinalCommitGateV1 { entered, release },
);
assert!(
Expand All @@ -1977,7 +1986,7 @@ impl CodeIndexSchedulerRegistryV1 {
let gate = cold_mount_final_commit_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(project_root);
.remove(&test_gate_root(project_root));
if let Some(gate) = gate {
let _ = gate.entered.send(());
let _ = gate.release.await;
Expand Down Expand Up @@ -2008,7 +2017,7 @@ impl CodeIndexSchedulerRegistryV1 {
assert!(
gates
.insert(
(project_root.clone(), at),
(test_gate_root(&project_root), at),
RetainedGraphRecoveryGateV1 { entered, release },
)
.is_none(),
Expand All @@ -2026,7 +2035,7 @@ impl CodeIndexSchedulerRegistryV1 {
let gate = retained_graph_recovery_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&(project_root.to_path_buf(), at));
.remove(&(test_gate_root(project_root), at));
if let Some(gate) = gate {
let _ = gate.entered.send(());
let _ = gate.release.await;
Expand Down Expand Up @@ -2054,7 +2063,7 @@ impl CodeIndexSchedulerRegistryV1 {
assert!(
gates
.insert(
project_root.clone(),
test_gate_root(&project_root),
RetainedTextProjectionGateV1 { entered, release },
)
.is_none(),
Expand All @@ -2069,7 +2078,7 @@ impl CodeIndexSchedulerRegistryV1 {
let gate = retained_text_projection_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(project_root);
.remove(&test_gate_root(project_root));
if let Some(gate) = gate {
let _ = gate.entered.send(());
let _ = gate.release.await;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use super::{
ServingGenerationRollbackOutcomeV1, WorkerStepGateV1, cold_mount_admission_barriers,
cold_mount_open_controls, cold_mount_post_check_controls, complete_seat_probe_miss_gate,
graph_decode_gate, published_text_projection_gate, query_admission_controls, serving_swap_gate,
unique_mounted_for_scope, wait_notified_if_unset,
test_gate_root, unique_mounted_for_scope, wait_notified_if_unset,
};
use tracedecay_runtime_core::path_safety::canonical_existing_identity;

Expand All @@ -39,7 +39,10 @@ impl CodeIndexSchedulerRegistryV1 {
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(
gates
.insert(project_root.clone(), WorkerStepGateV1 { entered, release })
.insert(
test_gate_root(&project_root),
WorkerStepGateV1 { entered, release },
)
.is_none(),
"one published text projection gate per worktree: {}",
project_root.display()
Expand All @@ -52,7 +55,7 @@ impl CodeIndexSchedulerRegistryV1 {
let gate = published_text_projection_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(project_root);
.remove(&test_gate_root(project_root));
Self::pass_worker_step_gate(gate).await;
}

Expand All @@ -72,7 +75,10 @@ impl CodeIndexSchedulerRegistryV1 {
let replaced = serving_swap_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(project_root, WorkerStepGateV1 { entered, release });
.insert(
test_gate_root(&project_root),
WorkerStepGateV1 { entered, release },
);
assert!(replaced.is_none(), "one serving swap gate per worktree");
(entered_observed, released)
}
Expand All @@ -82,7 +88,7 @@ impl CodeIndexSchedulerRegistryV1 {
let gate = serving_swap_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(project_root);
.remove(&test_gate_root(project_root));
Self::pass_worker_step_gate(gate).await;
}

Expand Down Expand Up @@ -138,7 +144,7 @@ impl CodeIndexSchedulerRegistryV1 {
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
project_root,
test_gate_root(&project_root),
[
WorkerStepGateV1 {
entered: before_entered,
Expand All @@ -163,7 +169,7 @@ impl CodeIndexSchedulerRegistryV1 {
let gate = graph_decode_gate()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(project_root);
.remove(&test_gate_root(project_root));
let [before, after] = gate?;
Self::pass_worker_step_gate(Some(before)).await;
Some(after)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use tracedecay_domain::{
ManifestDigest, ManifestDigestHasher, WorkExecutableReference, WorkProviderBackendV1,
WorkProviderProtocol,
};
use tracedecay_runtime_core::path_safety::same_canonical_path;

use super::PinnedRuntimeConfiguration;

Expand Down Expand Up @@ -145,7 +146,9 @@ impl WorkExecutableBindingResolver for PinnedWorkExecutableBindingResolver {
executable_id: executable_id.clone(),
}
})?;
if canonical_path != binding.canonical_path() {
// `canonicalize` spells verbatim `\\?\C:\` on Windows while bindings
// record the plain canonical form; compare identities, not spelling.
if !same_canonical_path(&canonical_path, binding.canonical_path()) {
return Err(WorkExecutableBindingError::Stale { executable_id });
}
let (actual_digest, verified_byte_length) =
Expand Down
17 changes: 14 additions & 3 deletions crates/tracedecay-dashboard-api/src/analytics_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use serde_json::Value;
use tracedecay_contracts::ObservatoryReadModelV1;
use tracedecay_contracts::retrieval::{AnalyticsHintCategoryV1, AnalyticsHintsPayloadV1};
use tracedecay_domain::{CoverageStateV1, ObservationScopeV1};
use tracedecay_sessions::runtime::shared::durable_project_path_key;

use tracedecay_automation::analytics::{
ToolUsageObservation, UsageKind, categorize_skill, infer_usage_events,
Expand Down Expand Up @@ -689,6 +690,10 @@ async fn subagent_tree_reading(
let connection = db.read_connection();
let canonical = RegisteredGlobalDb::canonical_project_key(project_root);
let opened = project_root.to_string_lossy().into_owned();
// `project_path` is persisted through `durable_project_path_key`, which
// folds Windows drive/UNC spelling; scoped reads must compare in the same
// stored form or a `C:\` display path never matches the `c:/` row.
let stored_path = durable_project_path_key(&opened);
let rows = query_rows(
&connection,
"SELECT provider,
Expand All @@ -709,10 +714,16 @@ async fn subagent_tree_reading(
-- (`/var` vs `/private/var`) are one project; matching only the
-- canonical key silently empties the tree for rows stored under the
-- alias the host wrote.
WHERE (project_key IN (?1, ?2) OR project_path IN (?1, ?2))
WHERE (project_key IN (?1, ?2) OR project_path IN (?3, ?4))
ORDER BY COALESCE(started_at, 0), provider, session_id
LIMIT ?3",
params![canonical, opened, SUBAGENT_TREE_SESSION_CEILING],
LIMIT ?5",
params![
canonical,
opened.clone(),
stored_path,
opened,
SUBAGENT_TREE_SESSION_CEILING
],
)
.await
.map_err(|error| format!("analytics subagent tree query failed: {error}"))?;
Expand Down
Loading
Loading