Skip to content
Merged
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
13 changes: 11 additions & 2 deletions kernel/DAEMON-LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@ ambiguous case below. A file is never believed on its own.

### Path

`<data-dir>/connection.json` — `data-dir` is the same directory that already
holds `relayflowd.sock` and `relayflowd.sqlite3`, default `.relayflowd`.
`<data-dir>/connection.json`, default data-dir `.relayflowd`. The data dir also
holds `relayflowd.sqlite3`, `relayflowd.lock`, and `relayflowd.log`.

**The socket itself lives OUTSIDE the data dir**, at a short hashed path
under `$XDG_RUNTIME_DIR` / `$TMPDIR` — see `kernel/relayflowd/src/socket_path.rs`
and `socketPathFor` in `packages/sdk/src/daemon-connection.ts`. The daemon and
the CLI derive the same path from the same absolute data-dir input, so §2
step 3's lexical equality still holds. This decoupling exists so a deep
working directory cannot push the full socket path past `SUN_LEN` (~104 bytes
on macOS), which used to fail the daemon at `bind(2)` before any step could
run (#262).

### Shape

Expand Down
1 change: 1 addition & 0 deletions kernel/relayflowd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod engine;
pub mod exec_det;
pub mod memory;
pub mod server;
pub mod socket_path;
pub mod worker;

pub use engine::{
Expand Down
9 changes: 6 additions & 3 deletions kernel/relayflowd/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,14 @@ pub fn serve(data_dir: &Path) -> Result<()> {
}
Err(lifecycle::AcquireError::Io(error)) => return Err(error.into()),
};
let socket_path = data_dir.join("relayflowd.sock");
lifecycle::remove_residue(data_dir)?;
// Bind outside the data dir at a short hashed path so SUN_LEN cannot be
// violated by a deep working directory (#262). See socket_path.rs and
// DAEMON-LIFECYCLE.md §1.
let socket_path = crate::socket_path::derive_socket_path(data_dir)?;
lifecycle::remove_residue(data_dir, &socket_path)?;
let listener = UnixListener::bind(&socket_path)
.with_context(|| format!("bind socket {}", socket_path.display()))?;
lifecycle::publish(&socket_path)?;
lifecycle::publish(data_dir, &socket_path)?;
let hub = Arc::new(ProtocolHub::default());
reconcile::spawn_reconciler(data_dir.to_path_buf(), hub.clone());
// Trigger-plane liveness sweep (RFC-0001 gate 2, Native silent-death
Expand Down
4 changes: 2 additions & 2 deletions kernel/relayflowd/src/server/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn lifecycle_request_via_socket(
io::{BufRead, BufReader, Write},
os::unix::net::UnixStream,
};
let socket = data_dir.join("relayflowd.sock");
let socket = crate::socket_path::derive_socket_path(data_dir)?;
if !socket.exists() {
return Ok(None);
}
Expand Down Expand Up @@ -110,7 +110,7 @@ pub fn resume_via_socket(data_dir: &Path, run_id: &str) -> Result<Option<crate::
os::unix::net::UnixStream,
};

let socket = data_dir.join("relayflowd.sock");
let socket = crate::socket_path::derive_socket_path(data_dir)?;
if !socket.exists() {
return Ok(None);
}
Expand Down
20 changes: 14 additions & 6 deletions kernel/relayflowd/src/server/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,14 @@ pub(crate) fn acquire(data_dir: &Path) -> std::result::Result<DaemonLock, Acquir
}

/// Safe only after `acquire`: no other daemon can own these paths.
pub(crate) fn remove_residue(data_dir: &Path) -> Result<()> {
for name in ["relayflowd.sock", "connection.json"] {
let path = data_dir.join(name);
match std::fs::remove_file(&path) {
///
/// `socket_path` is now outside the data dir (see socket_path.rs and
/// DAEMON-LIFECYCLE.md §1), so it is passed explicitly rather than derived
/// from the data dir alone. The lock proves nobody else owns it.
pub(crate) fn remove_residue(data_dir: &Path, socket_path: &Path) -> Result<()> {
let connection = data_dir.join("connection.json");
for path in [socket_path, connection.as_path()] {
match std::fs::remove_file(path) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => {
Expand All @@ -86,7 +90,12 @@ struct ConnectionFile<'a> {
}

/// Publish only after `UnixListener::bind`, which has already called listen(2).
pub(crate) fn publish(socket_path: &Path) -> Result<()> {
///
/// `data_dir` and `socket_path` used to be one and the same directory
/// (`connection.json` sat next to `relayflowd.sock`). #262 moves the socket
/// outside the data dir so they diverge; the connection file still lives in
/// the data dir, alongside the lock and journal.
pub(crate) fn publish(data_dir: &Path, socket_path: &Path) -> Result<()> {
// `absolute` preserves the caller's data-dir spelling (unlike
// `canonicalize`, which would turn `/var` into macOS's `/private/var` and
// fail the client's lexical `resolve(dataDir)` comparison).
Expand All @@ -95,7 +104,6 @@ pub(crate) fn publish(socket_path: &Path) -> Result<()> {
let socket_text = socket_path
.to_str()
.context("socket path is not valid UTF-8")?;
let data_dir = socket_path.parent().context("socket path has no parent")?;
let connection_path = data_dir.join("connection.json");
let temporary_path = data_dir.join(format!("connection.json.tmp.{}", std::process::id()));
let record = ConnectionFile {
Expand Down
126 changes: 126 additions & 0 deletions kernel/relayflowd/src/socket_path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
//! Where the unix-domain socket lives, and why not inside the data dir.
//!
//! `<data-dir>/relayflowd.sock` was the naive location, and it fails hard when
//! the working directory pushes the full socket path past the OS `SUN_LEN`
//! limit (~104 bytes on macOS, ~108 on Linux). A user cloning an example into
//! a nested tree hits `Error: bind socket ...: path must be shorter than
//! SUN_LEN` before any step can run (#262).
//!
//! The daemon socket now binds under `$XDG_RUNTIME_DIR` / `$TMPDIR` /
//! `std::env::temp_dir()` at a short, stable path keyed by a 12-hex-char
//! SHA-256 of the *absolute* data-dir path. Anti-hijack still holds because
//! both the daemon and the CLI derive the same path from the same input:
//! [`derive_socket_path`] is the Rust side, `socketPathFor` in
//! `packages/sdk/src/daemon-connection.ts` is the identical JS side. The
//! `socket_path` field in `connection.json` still names the bound socket, and
//! the client's §2 step-3 check still refuses a file that names anything else.
//!
//! The lock (`relayflowd.lock`), the journal (`relayflowd.sqlite3`), the log
//! (`relayflowd.log`), and `connection.json` continue to live in the data dir
//! — nothing there has a length problem, and moving them would break the
//! `flock(2)`-based mutex that DAEMON-LIFECYCLE.md §3 depends on.

use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use sha2::{Digest, Sha256};

/// Derive the socket path from the data dir.
///
/// Uses `std::path::absolute` — never `canonicalize` — so the JS side's
/// lexical `path.resolve(dataDir)` produces the same input string. Symlink
/// resolution would introduce a divergence (macOS's `/var` → `/private/var`
/// is the classic one), and the client validates by string equality.
pub fn derive_socket_path(data_dir: &Path) -> Result<PathBuf> {
let absolute = std::path::absolute(data_dir)
.with_context(|| format!("make data dir absolute {}", data_dir.display()))?;
let hash = hash_data_dir(&absolute);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash inputs diverge on unclean paths

Medium Severity

The hash input is std::path::absolute on Rust and path.resolve on JS. POSIX absolute keeps .. and trailing slashes; path.resolve collapses both. A data dir with a trailing slash or .. therefore hashes differently, and the CLI will not attach to a daemon started with that spelling.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c1305f. Configure here.

Ok(runtime_dir().join(format!("relayflowd-{hash}.sock")))
}

fn hash_data_dir(absolute_data_dir: &Path) -> String {
let mut hasher = Sha256::new();
hasher.update(absolute_data_dir.as_os_str().as_encoded_bytes());
let digest = hasher.finalize();
let mut out = String::with_capacity(12);
for byte in &digest[..6] {
use std::fmt::Write;
// Fixed-width lowercase hex, matching Node's `digest('hex').slice(0, 12)`.
write!(&mut out, "{byte:02x}").expect("write to String is infallible");
}
out
}

fn runtime_dir() -> PathBuf {
// XDG first (Linux, typically `/run/user/<uid>/`, ~14 chars — the shortest
// per-user private option). TMPDIR next (macOS, `/var/folders/xx/YYY/T/`,
// ~50 chars, still per-user private). Only fall back to the system-wide
// `std::env::temp_dir()` when neither is set; on macOS that resolves to
// `/var/folders/.../T` too, and on Linux to `/tmp` which is world-writable.
if let Some(value) = env_nonempty("XDG_RUNTIME_DIR") {
return PathBuf::from(value);
}
if let Some(value) = env_nonempty("TMPDIR") {
return PathBuf::from(value);
}
std::env::temp_dir()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime directory fallbacks can diverge

Medium Severity

When XDG_RUNTIME_DIR and TMPDIR are unset, Rust falls back to std::env::temp_dir() and JS to os.tmpdir(). Those APIs disagree on macOS (confstr user temp vs /tmp) and when only TMP or TEMP is set, so the daemon and CLI derive different socket paths and attach fails.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2c1305f. Configure here.


fn env_nonempty(name: &str) -> Option<std::ffi::OsString> {
match std::env::var_os(name) {
Some(value) if !value.is_empty() => Some(value),
_ => None,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn same_data_dir_yields_same_socket() {
let a = derive_socket_path(Path::new("/tmp/deep")).unwrap();
let b = derive_socket_path(Path::new("/tmp/deep")).unwrap();
assert_eq!(a, b);
}

#[test]
fn different_data_dirs_yield_different_sockets() {
let a = derive_socket_path(Path::new("/tmp/one")).unwrap();
let b = derive_socket_path(Path::new("/tmp/two")).unwrap();
assert_ne!(a, b);
}

#[test]
fn deep_data_dir_produces_short_socket_path() {
// The whole point of #262: the socket path stays short regardless of
// how deep the data dir is. Assert against a padding well past the
// 104-byte macOS SUN_LEN.
let deep = "/tmp/".to_string() + &"a/".repeat(80) + "data";
assert!(deep.len() > 104);
let socket = derive_socket_path(Path::new(&deep)).unwrap();
// The socket path is bounded by `<runtime_dir> + "/relayflowd-" +
// 12 hex chars + ".sock"`, which is ~60 chars on macOS worst case.
assert!(
socket.as_os_str().len() < 104,
"derived socket path must fit SUN_LEN, got {} bytes: {}",
socket.as_os_str().len(),
socket.display(),
);
}

#[test]
fn relative_and_absolute_data_dirs_agree() {
// A caller passing a relative `--data-dir` must produce the same
// socket as a caller passing the same absolute path, or the CLI's
// lexical anti-hijack check would refuse a matching daemon.
// `absolute` resolves relative paths against the process CWD, and
// that is exactly what Node's `path.resolve` does on the other side.
let cwd = std::env::current_dir().unwrap();
let absolute = cwd.join("some-data");
assert_eq!(
derive_socket_path(Path::new("some-data")).unwrap(),
derive_socket_path(&absolute).unwrap(),
);
}
}
2 changes: 1 addition & 1 deletion kernel/relayflowd/tests/crash_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ fn sigkill_under_serve_resumes_the_socket_started_run() {
.process_group(0)
.spawn()
.unwrap();
let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
wait_until("serve socket", || socket.exists());

let spec: Value = serde_json::from_slice(&fs::read(&fixture.spec_path).unwrap()).unwrap();
Expand Down
3 changes: 2 additions & 1 deletion kernel/relayflowd/tests/crash_resume/agent_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ impl AgentFixture {
}

pub fn socket(&self) -> PathBuf {
self.data_dir.join("relayflowd.sock")
relayflowd::socket_path::derive_socket_path(&self.data_dir)
.expect("derive socket path")
}

pub fn server(&self) -> ServerGuard {
Expand Down
10 changes: 5 additions & 5 deletions kernel/relayflowd/tests/crash_resume/concurrency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ fn run_start_dispatches_every_independent_lane_before_any_completion() {

// A live resume sees both leases held and must neither abandon nor
// redispatch either attempt.
let mut control = ProtocolClient::connect(&fixture.data_dir.join("relayflowd.sock"));
let mut control = ProtocolClient::connect(&fixture.socket());
let resumed = control
.request("run.resume", json!({"run_id": run_id}))
.unwrap();
Expand Down Expand Up @@ -161,7 +161,7 @@ fn concurrent_resumes_lease_exactly_one_attempt() {
let run_id = start_run(&fixture);
let mut worker = attached_worker(&fixture, "race-stub");

let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
let barrier = Arc::new(Barrier::new(2));
let resumes = (0..2)
.map(|_| {
Expand Down Expand Up @@ -231,7 +231,7 @@ fn live_resume_leaves_an_active_lease_running() {
.unwrap();
assert!(heartbeat["lease_deadline_ms"].as_i64().unwrap() > 0);

let mut control = ProtocolClient::connect(&fixture.data_dir.join("relayflowd.sock"));
let mut control = ProtocolClient::connect(&fixture.socket());
let resumed = control
.request("run.resume", json!({"run_id": run_id}))
.unwrap();
Expand Down Expand Up @@ -266,7 +266,7 @@ fn cancel_closes_the_lease_and_rejects_a_late_completion() {
let mut worker = attached_worker(&fixture, "cancel-stub");
let run_id = start_run(&fixture);
let dispatch = worker.event("step.dispatch").unwrap();
let mut control = ProtocolClient::connect(&fixture.data_dir.join("relayflowd.sock"));
let mut control = ProtocolClient::connect(&fixture.socket());

let canceled = control
.request("run.cancel", json!({"run_id": run_id}))
Expand Down Expand Up @@ -298,7 +298,7 @@ fn cancel_and_completion_race_has_one_terminal_fact() {
let mut worker = attached_worker(&fixture, "race-stub");
let run_id = start_run(&fixture);
let dispatch = worker.event("step.dispatch").unwrap();
let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
let barrier = Arc::new(Barrier::new(2));

let cancel_barrier = barrier.clone();
Expand Down
2 changes: 1 addition & 1 deletion kernel/relayflowd/tests/crash_resume/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn serve_plumbs_watch_events_and_replayable_stream_verbs() {
let fixture = LlmFixture::new("protocol-verbs", false);
let _server = ServerGuard::start(&fixture);
let run_id = start_run(&fixture);
let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
let mut watcher = ProtocolClient::connect(&socket);
watcher
.request("run.watch", json!({"run_id": run_id}))
Expand Down
5 changes: 3 additions & 2 deletions kernel/relayflowd/tests/crash_resume/llm_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,9 @@ impl LlmFixture {
fixture
}

fn socket(&self) -> PathBuf {
self.data_dir.join("relayflowd.sock")
pub fn socket(&self) -> PathBuf {
relayflowd::socket_path::derive_socket_path(&self.data_dir)
.expect("derive socket path")
}
}

Expand Down
6 changes: 3 additions & 3 deletions kernel/relayflowd/tests/crash_resume/parallel_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use super::{
};

fn attached_agent(fixture: &LlmFixture, id: &str) -> ProtocolClient {
let mut worker = ProtocolClient::connect(&fixture.data_dir.join("relayflowd.sock"));
let mut worker = ProtocolClient::connect(&fixture.socket());
worker
.request(
"worker.attach",
Expand Down Expand Up @@ -101,7 +101,7 @@ fn renewed_parallel_leases_survive_the_original_grant_and_remain_distinct() {
thread::sleep(Duration::from_millis(40));
let lane_a_deadline = renew(&mut worker, &dispatches[1]);
assert_ne!(lane_b_deadline, lane_a_deadline);
let mut control = ProtocolClient::connect(&fixture.data_dir.join("relayflowd.sock"));
let mut control = ProtocolClient::connect(&fixture.socket());
let snapshot = control
.request("run.get", json!({"run_id": run_id}))
.unwrap();
Expand Down Expand Up @@ -302,7 +302,7 @@ fn terminal_failure_drains_or_explains_every_live_sibling() {
== 2
})
});
let mut control = ProtocolClient::connect(&fixture.data_dir.join("relayflowd.sock"));
let mut control = ProtocolClient::connect(&fixture.socket());
assert_eq!(
control
.request("run.resume", json!({"run_id": run_id}))
Expand Down
2 changes: 1 addition & 1 deletion kernel/relayflowd/tests/crash_resume/pin_projection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::{
fn rejected_completion_cannot_forge_inspect_retry_pins_over_the_real_socket() {
let fixture = LlmFixture::parallel("rejected-pin-projection");
let _server = ServerGuard::start(&fixture);
let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
let mut worker = ProtocolClient::connect(&socket);
worker
.request(
Expand Down
2 changes: 1 addition & 1 deletion kernel/relayflowd/tests/crash_resume/protocol_admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use super::{
fn every_mutating_run_verb_refuses_terminal_before_changing_state() {
let fixture = LlmFixture::completed("terminal-admission");
let _server = ServerGuard::start(&fixture);
let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
let mut client = ProtocolClient::connect(&socket);
let started = client
.request(
Expand Down
5 changes: 5 additions & 0 deletions kernel/relayflowd/tests/crash_resume/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ impl Fixture {
Self::new(name, true)
}

pub fn socket(&self) -> PathBuf {
relayflowd::socket_path::derive_socket_path(&self.data_dir)
.expect("derive socket path")
}

fn new(name: &str, blocking_second: bool) -> Self {
let directory = tempdir().unwrap();
let data_dir = directory.path().join("data");
Expand Down
2 changes: 1 addition & 1 deletion kernel/relayflowd/tests/crash_resume/surface_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fn complete(worker: &mut ProtocolClient, dispatch: &Value) -> Value {
fn aliases_are_rejected_and_external_ancestors_serialize_over_real_sockets() {
let fixture = LlmFixture::parallel("surface-identity");
let _server = ServerGuard::start(&fixture);
let socket = fixture.data_dir.join("relayflowd.sock");
let socket = fixture.socket();
let mut worker = ProtocolClient::connect(&socket);
worker
.request(
Expand Down
Loading
Loading