Skip to content

Commit 5f394f6

Browse files
jlongsterCBEPX
andauthored
feat(process-daemon): support a private Unix socket directory (#59697)
Co-authored-by: CBEPX <458940+CBEPX@users.noreply.github.com>
1 parent f4ecbf3 commit 5f394f6

4 files changed

Lines changed: 153 additions & 27 deletions

File tree

‎packages/process-daemon/README.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,18 @@ directory, `${XDG_STATE_HOME:-~/.local/state}/opencode/pty`. Names are a single
4444
path component of letters, digits, `.`, `_`, or `-`. The registration
4545
(`service.json`) and lock (`service.lock`) live in that directory. They only
4646
default to a temporary directory when no home directory exists, because macOS deletes unaccessed regular files
47-
there after three days. The socket stays under `/tmp/opencode-pty-<uid>/` to fit
47+
there after three days. The socket defaults to `/tmp/opencode-pty-<uid>/` to fit
4848
socket path limits; temporary cleaners skip sockets. A stopping daemon removes
4949
its own files and runtime directory, and a starting daemon removes abandoned
5050
sibling runtime directories older than ten minutes.
5151

52+
On Unix, set `OPENCODE_PTY_SOCKET_DIR` to use a different socket directory. The
53+
value must be an absolute path without `..` components. Sockets go in an
54+
`opencode-pty-<uid>` child of that directory, which gets the existing ownership
55+
and symlink checks and mode `0700`; the directory itself is left unchanged.
56+
Socket path length limits still apply. Registration and lock files continue to
57+
use the runtime directory.
58+
5259
### Local transport boundary
5360

5461
`src/transport/` owns endpoint listening/connecting, byte-stream I/O,

‎packages/process-daemon/src/daemon/sweep.rs‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -76,22 +76,20 @@ pub(super) fn runtime(directory: &Path, now: SystemTime) -> Result<()> {
7676
}
7777
Some(lock)
7878
}
79-
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
80-
let Some(registration) = fs::read(registration_path(directory))
81-
.ok()
82-
.and_then(|data| serde_json::from_slice::<Registration>(&data).ok())
83-
else {
84-
return Ok(());
85-
};
86-
if platform::process_exists(registration.pid) {
87-
return Ok(());
88-
}
89-
None
90-
}
79+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
9180
Err(error) => return Err(error.into()),
9281
};
82+
let registration = fs::read(registration_path(directory))
83+
.ok()
84+
.and_then(|data| serde_json::from_slice::<Registration>(&data).ok());
85+
if lock.is_none() {
86+
match &registration {
87+
Some(registration) if !platform::process_exists(registration.pid) => {}
88+
_ => return Ok(()),
89+
}
90+
}
9391

94-
platform::remove_stale_endpoint(&fs::canonicalize(directory)?)?;
92+
platform::remove_stale_endpoint(&fs::canonicalize(directory)?, registration.as_ref())?;
9593
// Remove the lock last so a racing daemon cannot claim a half-removed directory.
9694
files.sort_by_key(|file| file.ends_with(LOCK_FILE));
9795
for file in files {

‎packages/process-daemon/src/daemon/unix.rs‎

Lines changed: 130 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ use std::fs::{self, File, OpenOptions};
22
use std::io::Write;
33
use std::os::unix::ffi::OsStrExt;
44
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt};
5-
use std::path::{Path, PathBuf};
5+
use std::path::{Component, Path, PathBuf};
66
use std::thread::{self, JoinHandle};
77

8-
use anyhow::{Context, Result, anyhow};
8+
use anyhow::{Context, Result, anyhow, bail};
99
use fs2::FileExt;
1010
use sha2::{Digest, Sha256};
1111

@@ -135,9 +135,20 @@ pub(super) fn process_exists(pid: u32) -> bool {
135135
}
136136

137137
/// Removes the socket of an abandoned runtime found by the shared sweep.
138-
pub(super) fn remove_stale_endpoint(canonical_directory: &Path) -> Result<()> {
139-
let socket = socket_file(&socket_root(), canonical_directory);
140-
if fs::symlink_metadata(&socket).is_ok_and(|metadata| metadata.file_type().is_socket()) {
138+
pub(super) fn remove_stale_endpoint(
139+
canonical_directory: &Path,
140+
registration: Option<&Registration>,
141+
) -> Result<()> {
142+
// The abandoned daemon may have used a different OPENCODE_PTY_SOCKET_DIR, so
143+
// prefer the socket it registered over this daemon's socket root.
144+
let socket = match registration {
145+
Some(registration) => registration.socket.clone(),
146+
None => socket_file(&socket_root()?, canonical_directory),
147+
};
148+
let uid = nix::unistd::Uid::effective().as_raw();
149+
if fs::symlink_metadata(&socket)
150+
.is_ok_and(|metadata| metadata.file_type().is_socket() && metadata.uid() == uid)
151+
{
141152
fs::remove_file(&socket)?;
142153
}
143154
Ok(())
@@ -146,17 +157,37 @@ pub(super) fn remove_stale_endpoint(canonical_directory: &Path) -> Result<()> {
146157
fn socket_path(directory: &Path) -> Result<PathBuf> {
147158
let directory =
148159
fs::canonicalize(directory).context("failed to resolve PTY runtime directory")?;
149-
let root = socket_root();
160+
let root = socket_root()?;
150161
ensure_private_directory(&root)?;
151162
Ok(socket_file(&root, &directory))
152163
}
153164

154-
// Sockets stay in /tmp so their paths fit sun_path; temp cleaners skip sockets.
155-
fn socket_root() -> PathBuf {
156-
PathBuf::from("/tmp").join(format!(
157-
"opencode-pty-{}",
158-
nix::unistd::Uid::effective().as_raw()
159-
))
165+
// The default path stays short enough for sun_path; temp cleaners skip sockets.
166+
fn socket_root() -> Result<PathBuf> {
167+
resolve_socket_root(
168+
std::env::var_os("OPENCODE_PTY_SOCKET_DIR").map(PathBuf::from),
169+
nix::unistd::Uid::effective().as_raw(),
170+
)
171+
}
172+
173+
fn resolve_socket_root(directory: Option<PathBuf>, uid: u32) -> Result<PathBuf> {
174+
let base = match directory {
175+
None => PathBuf::from("/tmp"),
176+
Some(directory) if !directory.is_absolute() => {
177+
bail!("OPENCODE_PTY_SOCKET_DIR must be an absolute path")
178+
}
179+
Some(directory)
180+
if directory
181+
.components()
182+
.any(|part| part == Component::ParentDir) =>
183+
{
184+
bail!("OPENCODE_PTY_SOCKET_DIR must not contain '..'")
185+
}
186+
Some(directory) => directory,
187+
};
188+
// Sockets live in a dedicated child so the 0700 permission change never
189+
// applies to a caller-chosen directory.
190+
Ok(base.join(format!("opencode-pty-{uid}")))
160191
}
161192

162193
fn socket_file(root: &Path, canonical_directory: &Path) -> PathBuf {
@@ -222,6 +253,62 @@ mod tests {
222253

223254
use super::*;
224255

256+
#[test]
257+
fn socket_root_preserves_default_uid_directory() {
258+
assert_eq!(
259+
resolve_socket_root(None, 12345).unwrap(),
260+
PathBuf::from("/tmp/opencode-pty-12345")
261+
);
262+
}
263+
264+
#[test]
265+
fn socket_root_uses_dedicated_child_of_explicit_directory() {
266+
assert_eq!(
267+
resolve_socket_root(Some(PathBuf::from("/private/tmp/task-owned-pty")), 12345).unwrap(),
268+
PathBuf::from("/private/tmp/task-owned-pty/opencode-pty-12345")
269+
);
270+
}
271+
272+
#[test]
273+
fn socket_root_rejects_relative_and_parent_overrides() {
274+
for root in [
275+
"",
276+
".",
277+
"..",
278+
"relative",
279+
"../socket",
280+
"/tmp/..",
281+
"/tmp/a/../b",
282+
] {
283+
assert!(resolve_socket_root(Some(PathBuf::from(root)), 12345).is_err());
284+
}
285+
}
286+
287+
#[test]
288+
fn socket_root_leaves_explicit_directory_permissions_alone() {
289+
let base = std::env::temp_dir().join(format!("opencode-pty-root-test-{}", random_id()));
290+
fs::create_dir_all(&base).unwrap();
291+
fs::set_permissions(&base, fs::Permissions::from_mode(0o755)).unwrap();
292+
293+
let root = resolve_socket_root(Some(base.clone()), 12345).unwrap();
294+
ensure_private_directory(&root).unwrap();
295+
296+
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
297+
assert_eq!(mode(&base), 0o755);
298+
assert_eq!(mode(&root), 0o700);
299+
fs::remove_dir_all(base).unwrap();
300+
}
301+
302+
#[test]
303+
fn socket_root_rejects_symlinked_child() {
304+
let base = std::env::temp_dir().join(format!("opencode-pty-root-test-{}", random_id()));
305+
fs::create_dir_all(base.join("target")).unwrap();
306+
let root = resolve_socket_root(Some(base.clone()), 12345).unwrap();
307+
std::os::unix::fs::symlink(base.join("target"), &root).unwrap();
308+
assert!(ensure_private_directory(&root).is_err());
309+
fs::remove_dir_all(base).unwrap();
310+
}
311+
225312
#[test]
226313
fn socket_paths_are_short_and_runtime_specific() {
227314
let base = std::env::temp_dir().join(format!("opencode-pty-socket-test-{}", random_id()));
@@ -258,4 +345,35 @@ mod tests {
258345
assert!(!socket.exists());
259346
fs::remove_dir_all(root).unwrap();
260347
}
348+
349+
#[test]
350+
fn sweep_removes_socket_recorded_in_registration() {
351+
let root = std::env::temp_dir().join(format!("opencode-pty-sweep-test-{}", random_id()));
352+
let abandoned = root.join(random_id());
353+
fs::create_dir_all(&abandoned).unwrap();
354+
fs::write(abandoned.join(LOCK_FILE), b"").unwrap();
355+
// macOS temp directories are too long for sun_path.
356+
let socket = PathBuf::from(format!(
357+
"/tmp/opencode-pty-test-{}.sock",
358+
&random_id()[..16]
359+
));
360+
drop(UnixListener::bind(&socket).unwrap());
361+
let registration = Registration {
362+
instance_id: random_id(),
363+
pid: std::process::id(),
364+
protocol: PROTOCOL_VERSION,
365+
socket: socket.clone(),
366+
token: random_id(),
367+
};
368+
write_registration(&abandoned, &registration).unwrap();
369+
370+
let later = std::time::SystemTime::now()
371+
+ super::super::sweep::STALE_RUNTIME_AGE
372+
+ std::time::Duration::from_secs(1);
373+
super::super::sweep::runtime(&abandoned, later).unwrap();
374+
375+
assert!(!abandoned.exists());
376+
assert!(!socket.exists());
377+
fs::remove_dir_all(root).unwrap();
378+
}
261379
}

‎packages/process-daemon/src/daemon/windows.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,10 @@ pub(super) fn process_exists(pid: u32) -> bool {
185185
}
186186

187187
/// Named pipes vanish with their server process; there is no endpoint to remove.
188-
pub(super) fn remove_stale_endpoint(_canonical_directory: &Path) -> Result<()> {
188+
pub(super) fn remove_stale_endpoint(
189+
_canonical_directory: &Path,
190+
_registration: Option<&Registration>,
191+
) -> Result<()> {
189192
Ok(())
190193
}
191194

0 commit comments

Comments
 (0)