@@ -2,10 +2,10 @@ use std::fs::{self, File, OpenOptions};
22use std:: io:: Write ;
33use std:: os:: unix:: ffi:: OsStrExt ;
44use std:: os:: unix:: fs:: { FileTypeExt , MetadataExt , OpenOptionsExt , PermissionsExt } ;
5- use std:: path:: { Path , PathBuf } ;
5+ use std:: path:: { Component , Path , PathBuf } ;
66use std:: thread:: { self , JoinHandle } ;
77
8- use anyhow:: { Context , Result , anyhow} ;
8+ use anyhow:: { Context , Result , anyhow, bail } ;
99use fs2:: FileExt ;
1010use 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<()> {
146157fn 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
162193fn 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}
0 commit comments