@@ -2,6 +2,7 @@ package main
22
33import (
44 "bytes"
5+ "encoding/base64"
56 "encoding/json"
67 "fmt"
78 "io"
@@ -25,6 +26,8 @@ const maxOutputBytes = 128 * 1024
2526const (
2627 reconnectBaseDelay = 1 * time .Second
2728 reconnectMaxDelay = 30 * time .Second
29+ pongWait = 60 * time .Second
30+ maxFileSize = 50 * 1024 * 1024
2831)
2932
3033var sessionCodePattern = regexp .MustCompile (`^[0-9a-f]{12}$` )
@@ -42,6 +45,32 @@ func printLine(parts ...string) {
4245 _ , _ = os .Stdout .WriteString (strings .Join (parts , "" ) + "\n " )
4346}
4447
48+ // Global reference for signal handler to gracefully close the connection
49+ var (
50+ currentBridge * wsConn
51+ currentBridgeMu sync.Mutex
52+ )
53+
54+ func setCurrentBridge (w * wsConn ) {
55+ currentBridgeMu .Lock ()
56+ currentBridge = w
57+ currentBridgeMu .Unlock ()
58+ }
59+
60+ func clearCurrentBridge () {
61+ currentBridgeMu .Lock ()
62+ currentBridge = nil
63+ currentBridgeMu .Unlock ()
64+ }
65+
66+ func closeCurrentBridge () {
67+ currentBridgeMu .Lock ()
68+ if currentBridge != nil {
69+ currentBridge .close ()
70+ }
71+ currentBridgeMu .Unlock ()
72+ }
73+
4574func main () {
4675 wsURL := os .Getenv ("BRIDGE_WS_URL" )
4776 if wsURL == "" {
@@ -60,6 +89,9 @@ func main() {
6089 signal .Notify (sig , syscall .SIGINT , syscall .SIGTERM )
6190 go func () {
6291 <- sig
92+ // Gracefully close the bridge WebSocket connection so the server
93+ // gets a clean close frame. This also unblocks ReadMessage().
94+ closeCurrentBridge ()
6395 close (quit )
6496 }()
6597
@@ -217,6 +249,16 @@ func (w *wsConn) close() {
217249 _ = w .conn .Close ()
218250}
219251
252+ func (w * wsConn ) closeUnderlying () {
253+ w .mu .Lock ()
254+ defer w .mu .Unlock ()
255+ if w .closed {
256+ return
257+ }
258+ w .closed = true
259+ _ = w .conn .Close ()
260+ }
261+
220262func readCommands (wsc * wsConn , dot string ) bool {
221263 defer func () {
222264 printLine ("\n " , dot , " Connection closed." , ansiReset )
@@ -269,13 +311,133 @@ func readCommands(wsc *wsConn, dot string) bool {
269311 "exit_code" : code ,
270312 "truncated" : truncated ,
271313 })
314+ case "file_read" :
315+ var msg struct {
316+ ID string `json:"id"`
317+ Path string `json:"path"`
318+ Encoding string `json:"encoding"`
319+ }
320+ _ = json .Unmarshal (data , & msg )
321+ if msg .ID == "" {
322+ continue
323+ }
324+ handleFileRead (wsc , msg .ID , msg .Path )
325+ case "file_write" :
326+ var msg struct {
327+ ID string `json:"id"`
328+ Path string `json:"path"`
329+ Data string `json:"data"`
330+ Encoding string `json:"encoding"`
331+ }
332+ _ = json .Unmarshal (data , & msg )
333+ if msg .ID == "" {
334+ continue
335+ }
336+ handleFileWrite (wsc , msg .ID , msg .Path , msg .Data , msg .Encoding )
272337 case "bye" :
273338 wsc .close ()
274339 return false
275340 }
276341 }
277342}
278343
344+ func handleFileRead (wsc * wsConn , id , path string ) {
345+ printLine (ansiCyan + "📖" + ansiReset , " " , ansiBold , "File read:" , ansiReset , " " , path )
346+ data , size , err := readFile (path )
347+ if err != nil {
348+ wsc .sendJSON (map [string ]any {
349+ "type" : "file_read_result" ,
350+ "id" : id ,
351+ "path" : path ,
352+ "error" : err .Error (),
353+ "size" : 0 ,
354+ "data" : "" ,
355+ "encoding" : "base64" ,
356+ })
357+ return
358+ }
359+ wsc .sendJSON (map [string ]any {
360+ "type" : "file_read_result" ,
361+ "id" : id ,
362+ "path" : path ,
363+ "data" : data ,
364+ "size" : size ,
365+ "encoding" : "base64" ,
366+ })
367+ }
368+
369+ func handleFileWrite (wsc * wsConn , id , path , data , encoding string ) {
370+ printLine (ansiCyan + "📝" + ansiReset , " " , ansiBold , "File write:" , ansiReset , " " , path )
371+ bytesWritten , err := writeFile (path , data , encoding )
372+ if err != nil {
373+ wsc .sendJSON (map [string ]any {
374+ "type" : "file_write_result" ,
375+ "id" : id ,
376+ "path" : path ,
377+ "error" : err .Error (),
378+ "bytes_written" : 0 ,
379+ })
380+ return
381+ }
382+ wsc .sendJSON (map [string ]any {
383+ "type" : "file_write_result" ,
384+ "id" : id ,
385+ "path" : path ,
386+ "bytes_written" : bytesWritten ,
387+ })
388+ }
389+
390+ func readFile (path string ) (string , int64 , error ) {
391+ fi , err := os .Stat (path )
392+ if err != nil {
393+ return "" , 0 , fmt .Errorf ("cannot access %s: %w" , path , err )
394+ }
395+ if fi .Size () > maxFileSize {
396+ return "" , 0 , fmt .Errorf ("file too large: %d bytes (max %d)" , fi .Size (), maxFileSize )
397+ }
398+
399+ f , err := os .Open (path )
400+ if err != nil {
401+ return "" , 0 , fmt .Errorf ("cannot open %s: %w" , path , err )
402+ }
403+ defer f .Close ()
404+
405+ data , err := io .ReadAll (f )
406+ if err != nil {
407+ return "" , 0 , fmt .Errorf ("cannot read %s: %w" , path , err )
408+ }
409+
410+ encoded := base64 .StdEncoding .EncodeToString (data )
411+ return encoded , fi .Size (), nil
412+ }
413+
414+ func writeFile (path , data , encoding string ) (int , error ) {
415+ var raw []byte
416+ switch encoding {
417+ case "base64" , "" :
418+ var err error
419+ raw , err = base64 .StdEncoding .DecodeString (data )
420+ if err != nil {
421+ // Try base64url
422+ raw , err = base64 .URLEncoding .DecodeString (data )
423+ if err != nil {
424+ return 0 , fmt .Errorf ("invalid base64 data: %w" , err )
425+ }
426+ }
427+ default :
428+ return 0 , fmt .Errorf ("unsupported encoding: %s" , encoding )
429+ }
430+
431+ if len (raw ) > maxFileSize {
432+ return 0 , fmt .Errorf ("data too large: %d bytes (max %d)" , len (raw ), maxFileSize )
433+ }
434+
435+ if err := os .WriteFile (path , raw , 0644 ); err != nil {
436+ return 0 , fmt .Errorf ("cannot write %s: %w" , path , err )
437+ }
438+ return len (raw ), nil
439+ }
440+
279441func dialAndRun (wsURL , code string , quit <- chan struct {}) (string , bool ) {
280442 dialer := websocket.Dialer {HandshakeTimeout : 15 * time .Second }
281443 conn , resp , err := dialer .Dial (wsURL , http.Header {})
@@ -294,9 +456,18 @@ func dialAndRun(wsURL, code string, quit <-chan struct{}) (string, bool) {
294456 }
295457
296458 bridge := & wsConn {conn : conn }
459+ setCurrentBridge (bridge )
460+
297461 dot := ansiCyan + "●" + ansiReset
298462 printLine (dot , " " , ansiBold , code , ansiReset , " — Ctrl+C to disconnect" )
299463
464+ // Set up heartbeat: expect pong within pongWait
465+ conn .SetReadDeadline (time .Now ().Add (pongWait ))
466+ conn .SetPongHandler (func (string ) error {
467+ conn .SetReadDeadline (time .Now ().Add (pongWait ))
468+ return nil
469+ })
470+
300471 if ! bridge .sendJSON (map [string ]any {
301472 "type" : "join" ,
302473 "session" : code ,
@@ -313,15 +484,18 @@ func dialAndRun(wsURL, code string, quit <-chan struct{}) (string, bool) {
313484 }) {
314485 select {
315486 case <- quit :
487+ clearCurrentBridge ()
316488 return "" , false
317489 default :
318490 }
319491 printLine (dot , " " , ansiRed , "Join failed, reconnecting..." , ansiReset )
320492 bridge .close ()
493+ clearCurrentBridge ()
321494 return "" , true
322495 }
323496
324497 reconnect := readCommands (bridge , dot )
498+ clearCurrentBridge ()
325499 return dot , reconnect
326500}
327501
0 commit comments