@@ -75,68 +75,19 @@ async fn session(server_url: &str, room_id: &str) -> Result<()> {
7575 if let Ok ( WsMessage :: File { path, content } ) =
7676 serde_json:: from_str :: < WsMessage > ( & msg)
7777 {
78- let base = std:: path:: Path :: new ( "/tmp/coding-human" ) ;
79- let dest = base. join ( & path) ;
80- // Reject paths that escape the base directory
81- if !dest. starts_with ( base)
82- || std:: path:: Path :: new ( & path)
83- . components ( )
84- . any ( |c| c == std:: path:: Component :: ParentDir )
85- {
86- eprintln ! ( "Rejected unsafe file path: {}" , path) ;
87- continue ;
88- }
89- if let Some ( parent) = dest. parent ( ) {
90- tokio:: fs:: create_dir_all ( parent) . await ?;
91- }
92- tokio:: fs:: write ( & dest, & content) . await ?;
93- println ! ( "Received file: {} -> {}" , path, dest. display( ) ) ;
94-
95- // Open the file in $EDITOR so the coder can edit it
96- let editor = std:: env:: var ( "EDITOR" ) . unwrap_or_else ( |_| "nvim" . to_string ( ) ) ;
97- match tokio:: process:: Command :: new ( & editor)
98- . arg ( & dest)
99- . status ( )
100- . await
101- {
102- Err ( e) => eprintln ! ( "Failed to open editor '{}': {}" , editor, e) ,
103- Ok ( _) => {
104- // Generate a unified diff between original and edited content
105- let orig_tmp =
106- base. join ( format ! ( ".orig.{}" , path. replace( '/' , "_" ) ) ) ;
107- if tokio:: fs:: write ( & orig_tmp, & content) . await . is_ok ( ) {
108- if let ( Some ( orig_str) , Some ( dest_str) ) =
109- ( orig_tmp. to_str ( ) , dest. to_str ( ) )
110- {
111- let diff_out = tokio:: process:: Command :: new ( "diff" )
112- . args ( [ "-u" , orig_str, dest_str] )
113- . output ( )
114- . await ;
115- let _ = tokio:: fs:: remove_file ( & orig_tmp) . await ;
116- if let Ok ( out) = diff_out {
117- let diff_text =
118- String :: from_utf8_lossy ( & out. stdout ) . to_string ( ) ;
119- if !diff_text. is_empty ( ) {
120- match serde_json:: to_string ( & WsMessage :: Diff {
121- path : path. clone ( ) ,
122- diff : diff_text,
123- } ) {
124- Ok ( diff_msg) => {
125- if let Err ( e) = write
126- . send ( Message :: text ( diff_msg) )
127- . await
128- {
129- eprintln ! ( "Failed to send diff: {}" , e) ;
130- }
131- }
132- Err ( e) => {
133- eprintln ! ( "Failed to serialize diff: {}" , e)
134- }
135- }
136- }
137- }
138- }
78+ match safe_tmp_path ( & path) {
79+ None => eprintln ! ( "Rejected unsafe file path: {}" , path) ,
80+ Some ( dest) => {
81+ if let Some ( parent) = dest. parent ( ) {
82+ tokio:: fs:: create_dir_all ( parent) . await ?;
83+ }
84+ tokio:: fs:: write ( & dest, & content) . await ?;
85+ // Save an original snapshot for later diff generation
86+ let orig_snap = orig_snap_path ( & path) ;
87+ if let Err ( e) = tokio:: fs:: write ( & orig_snap, & content) . await {
88+ eprintln ! ( "Warning: could not save original snapshot: {}" , e) ;
13989 }
90+ println ! ( "Received file: {} -> {}" , path, dest. display( ) ) ;
14091 }
14192 }
14293 continue ;
@@ -171,21 +122,102 @@ async fn session(server_url: &str, room_id: &str) -> Result<()> {
171122 break ;
172123 }
173124 let trimmed = line. trim_end_matches( '\n' ) ;
174- if let Some ( cmd) = trimmed. strip_prefix( '$' ) {
175- let command = cmd. trim( ) . to_string( ) ;
176- let msg = serde_json:: to_string( & WsMessage :: Cmd { command } ) ?;
177- write. send( Message :: text( msg) ) . await ?;
125+ if let Some ( file_path) = trimmed. strip_prefix( '@' ) {
126+ // Open the file from /tmp/coding-human/ in $EDITOR
127+ let file_path = file_path. trim( ) ;
128+ match safe_tmp_path( file_path) {
129+ None => eprintln!( "Rejected unsafe file path: {}" , file_path) ,
130+ Some ( dest) => {
131+ let editor =
132+ std:: env:: var( "EDITOR" ) . unwrap_or_else( |_| "nvim" . to_string( ) ) ;
133+ if let Err ( e) = tokio:: process:: Command :: new( & editor)
134+ . arg( & dest)
135+ . status( )
136+ . await
137+ {
138+ eprintln!( "Failed to open editor '{}': {}" , editor, e) ;
139+ }
140+ }
141+ }
142+ } else if let Some ( rest) = trimmed. strip_prefix( '$' ) {
143+ let rest = rest. trim( ) ;
144+ if let Some ( diff_path) = rest. strip_prefix( "diff " ) {
145+ // Generate a unified diff and send it to the client
146+ let diff_path = diff_path. trim( ) ;
147+ match safe_tmp_path( diff_path) {
148+ None => eprintln!( "Rejected unsafe file path: {}" , diff_path) ,
149+ Some ( dest) => {
150+ let orig_snap = orig_snap_path( diff_path) ;
151+ if let ( Some ( orig_str) , Some ( dest_str) ) =
152+ ( orig_snap. to_str( ) , dest. to_str( ) )
153+ {
154+ let diff_out = tokio:: process:: Command :: new( "diff" )
155+ . args( [ "-u" , orig_str, dest_str] )
156+ . output( )
157+ . await ;
158+ match diff_out {
159+ Ok ( out) => {
160+ let diff_text =
161+ String :: from_utf8_lossy( & out. stdout)
162+ . to_string( ) ;
163+ if diff_text. is_empty( ) {
164+ println!( "No changes detected." ) ;
165+ } else {
166+ match serde_json:: to_string( & WsMessage :: Diff {
167+ path: diff_path. to_string( ) ,
168+ diff: diff_text,
169+ } ) {
170+ Ok ( diff_msg) => {
171+ if let Err ( e) = write
172+ . send( Message :: text( diff_msg) )
173+ . await
174+ {
175+ eprintln!(
176+ "Failed to send diff: {}" ,
177+ e
178+ ) ;
179+ } else {
180+ println!( "Diff sent, waiting for client response..." ) ;
181+ }
182+ }
183+ Err ( e) => eprintln!(
184+ "Failed to serialize diff: {}" ,
185+ e
186+ ) ,
187+ }
188+ }
189+ }
190+ Err ( e) => eprintln!( "Failed to run diff: {}" , e) ,
191+ }
192+ }
193+ }
194+ }
195+ } else {
196+ let command = rest. to_string( ) ;
197+ let msg = serde_json:: to_string( & WsMessage :: Cmd { command } ) ?;
198+ write. send( Message :: text( msg) ) . await ?;
199+ }
178200 } else {
179201 write. send( Message :: text( trimmed) ) . await ?;
180202 }
181203 }
182204 ws_msg = read. next( ) => {
183205 match ws_msg {
184206 Some ( Ok ( Message :: Text ( msg) ) ) => {
185- if let Ok ( WsMessage :: CmdResult { command, output } ) =
186- serde_json:: from_str:: <WsMessage >( & msg)
187- {
188- println!( "\n [cmd result] $ {}\n {}" , command, output) ;
207+ if let Ok ( ws_msg) = serde_json:: from_str:: <WsMessage >( & msg) {
208+ match ws_msg {
209+ WsMessage :: CmdResult { command, output } => {
210+ println!( "\n [cmd result] $ {}\n {}" , command, output) ;
211+ }
212+ WsMessage :: DiffResponse { accepted } => {
213+ if accepted {
214+ println!( "\n [diff] Client accepted and applied the changes." ) ;
215+ } else {
216+ println!( "\n [diff] Client rejected the changes." ) ;
217+ }
218+ }
219+ _ => { }
220+ }
189221 }
190222 }
191223 Some ( Ok ( Message :: Close ( _) ) ) => {
@@ -214,3 +246,25 @@ fn ws_url(server_url: &str, path: &str) -> String {
214246 . unwrap_or ( server_url) ;
215247 format ! ( "{}://{}{}" , scheme, host, path)
216248}
249+
250+ /// Returns the validated destination path inside `/tmp/coding-human/`, or
251+ /// `None` if the path would escape the base directory.
252+ fn safe_tmp_path ( relative_path : & str ) -> Option < std:: path:: PathBuf > {
253+ let base = std:: path:: Path :: new ( "/tmp/coding-human" ) ;
254+ let dest = base. join ( relative_path) ;
255+ if dest. starts_with ( base)
256+ && !std:: path:: Path :: new ( relative_path)
257+ . components ( )
258+ . any ( |c| c == std:: path:: Component :: ParentDir )
259+ {
260+ Some ( dest)
261+ } else {
262+ None
263+ }
264+ }
265+
266+ /// Returns the path of the `.orig.` snapshot for the given relative file path.
267+ fn orig_snap_path ( relative_path : & str ) -> std:: path:: PathBuf {
268+ std:: path:: Path :: new ( "/tmp/coding-human" )
269+ . join ( format ! ( ".orig.{}" , relative_path. replace( '/' , "_" ) ) )
270+ }
0 commit comments