@@ -17,6 +17,7 @@ import (
1717 "github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
1818 "github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
1919 "github.com/aoagents/agent-orchestrator/backend/internal/ports"
20+ previewutil "github.com/aoagents/agent-orchestrator/backend/internal/preview"
2021 sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
2122)
2223
@@ -25,6 +26,8 @@ const (
2526 maxMessageLen = 4096
2627)
2728
29+ var errPreviewFileNotFound = errors .New ("preview file not found" )
30+
2831// SessionService is the controller-facing session service contract.
2932type SessionService interface {
3033 List (ctx context.Context , filter sessionsvc.ListFilter ) ([]domain.Session , error )
@@ -182,10 +185,9 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
182185// session and fans out a session_updated CDC event so the dashboard's browser
183186// panel reacts live. The target is resolved as follows:
184187//
185- // - An empty url reuses the session's existing preview target (so a bare
186- // `ao preview` re-opens whatever this agent/context last previewed),
187- // falling back to autodetecting a static entry point (index.html and
188- // friends) only when nothing has been previewed yet.
188+ // - An empty url opens the workspace's static entry point (index.html and
189+ // friends), falling back to the session's existing preview target only
190+ // when no entry point exists.
189191// - An explicit workspace-local path (e.g. `index.html`, `./dist/index.html`)
190192// is served through the preview/files route so local files load.
191193// - Anything else (http(s)/file URLs, host:port dev servers) is kept verbatim.
@@ -212,16 +214,26 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
212214 // ponytail: no URL sanitization on preview target; agent-trusted for now
213215 previewURL := strings .TrimSpace (in .URL )
214216 if previewURL == "" {
215- if existing := strings .TrimSpace (sess .Metadata .PreviewURL ); existing != "" {
216- previewURL = existing
217- } else if entry , ok := discoverPreviewEntry (sess .Metadata .WorkspacePath ); ok {
217+ if entry , ok := discoverPreviewEntry (sess .Metadata .WorkspacePath ); ok {
218218 previewURL = previewFileURL (r , sessionID (r ), entry )
219+ } else if existing := strings .TrimSpace (sess .Metadata .PreviewURL ); existing != "" {
220+ var resolveErr error
221+ previewURL , resolveErr = resolvePreviewTarget (r , sessionID (r ), sess .Metadata .WorkspacePath , existing )
222+ if resolveErr != nil {
223+ writePreviewResolveError (w , r , resolveErr )
224+ return
225+ }
219226 } else {
220227 envelope .WriteAPIError (w , r , http .StatusNotFound , "not_found" , "NO_PREVIEW_ENTRY" , "No preview entry point found in session workspace" , nil )
221228 return
222229 }
223- } else if resolved , ok := resolveLocalPreview (r , sessionID (r ), sess .Metadata .WorkspacePath , previewURL ); ok {
224- previewURL = resolved
230+ } else {
231+ var resolveErr error
232+ previewURL , resolveErr = resolvePreviewTarget (r , sessionID (r ), sess .Metadata .WorkspacePath , previewURL )
233+ if resolveErr != nil {
234+ writePreviewResolveError (w , r , resolveErr )
235+ return
236+ }
225237 }
226238 updated , err := c .Svc .SetPreview (r .Context (), sessionID (r ), previewURL )
227239 if err != nil {
@@ -544,20 +556,8 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) {
544556}
545557
546558func discoverPreviewEntry (workspacePath string ) (string , bool ) {
547- if strings .TrimSpace (workspacePath ) == "" {
548- return "" , false
549- }
550- for _ , candidate := range []string {"index.html" , "public/index.html" , "dist/index.html" , "build/index.html" } {
551- file , ok := confinedPreviewPath (workspacePath , candidate )
552- if ! ok {
553- continue
554- }
555- info , err := os .Stat (file )
556- if err == nil && ! info .IsDir () {
557- return candidate , true
558- }
559- }
560- return "" , false
559+ entry , ok := previewutil .DiscoverEntry (workspacePath )
560+ return entry .Path , ok
561561}
562562
563563// resolveLocalPreview maps a workspace-local path (e.g. "index.html" or
@@ -583,6 +583,49 @@ func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, ra
583583 return previewFileURL (r , id , entry ), true
584584}
585585
586+ func resolvePreviewTarget (r * http.Request , id domain.SessionID , workspacePath , raw string ) (string , error ) {
587+ raw = strings .TrimSpace (raw )
588+ if isAbsolutePreviewPath (raw ) {
589+ return absolutePreviewFileURL (raw )
590+ }
591+ if resolved , ok := resolveLocalPreview (r , id , workspacePath , raw ); ok {
592+ return resolved , nil
593+ }
594+ return raw , nil
595+ }
596+
597+ func isAbsolutePreviewPath (raw string ) bool {
598+ return filepath .IsAbs (raw ) || isWindowsAbsolutePath (raw )
599+ }
600+
601+ func isWindowsAbsolutePath (raw string ) bool {
602+ return len (raw ) >= 3 && ((raw [0 ] >= 'a' && raw [0 ] <= 'z' ) || (raw [0 ] >= 'A' && raw [0 ] <= 'Z' )) && raw [1 ] == ':' && (raw [2 ] == '\\' || raw [2 ] == '/' )
603+ }
604+
605+ func absolutePreviewFileURL (raw string ) (string , error ) {
606+ file , err := filepath .Abs (raw )
607+ if err != nil {
608+ return "" , errPreviewFileNotFound
609+ }
610+ info , err := os .Stat (file )
611+ if err != nil || info .IsDir () {
612+ return "" , errPreviewFileNotFound
613+ }
614+ filePath := filepath .ToSlash (file )
615+ if filepath .VolumeName (file ) != "" || isWindowsAbsolutePath (filePath ) {
616+ filePath = "/" + filePath
617+ }
618+ return (& url.URL {Scheme : "file" , Path : filePath }).String (), nil
619+ }
620+
621+ func writePreviewResolveError (w http.ResponseWriter , r * http.Request , err error ) {
622+ if errors .Is (err , errPreviewFileNotFound ) {
623+ envelope .WriteAPIError (w , r , http .StatusNotFound , "not_found" , "PREVIEW_FILE_NOT_FOUND" , "Preview file not found" , nil )
624+ return
625+ }
626+ envelope .WriteError (w , r , err )
627+ }
628+
586629// hasURLScheme reports whether raw begins with an RFC-3986 "scheme:" prefix
587630// (http:, https:, file:, or a host:port like localhost:5173). It mirrors the
588631// renderer's withDefaultScheme heuristic so the daemon and browser panel agree
@@ -602,41 +645,11 @@ func hasURLScheme(raw string) bool {
602645}
603646
604647func confinedPreviewPath (workspacePath , assetPath string ) (string , bool ) {
605- root , err := filepath .Abs (workspacePath )
606- if err != nil || root == "" {
607- return "" , false
608- }
609- clean := strings .TrimPrefix (path .Clean ("/" + strings .TrimSpace (assetPath )), "/" )
610- if clean == "" || clean == "." {
611- clean = "index.html"
612- }
613- file := filepath .Join (root , filepath .FromSlash (clean ))
614- absFile , err := filepath .Abs (file )
615- if err != nil {
616- return "" , false
617- }
618- rel , err := filepath .Rel (root , absFile )
619- if err != nil || rel == "." || strings .HasPrefix (rel , ".." + string (filepath .Separator )) || rel == ".." {
620- return "" , false
621- }
622- return absFile , true
648+ return previewutil .ConfinedPath (workspacePath , assetPath )
623649}
624650
625651func previewFileURL (r * http.Request , id domain.SessionID , entry string ) string {
626- u := url.URL {
627- Scheme : "http" ,
628- Host : r .Host ,
629- Path : "/api/v1/sessions/" + url .PathEscape (string (id )) + "/preview/files/" + escapePath (entry ),
630- }
631- return u .String ()
632- }
633-
634- func escapePath (raw string ) string {
635- parts := strings .Split (raw , "/" )
636- for i , part := range parts {
637- parts [i ] = url .PathEscape (part )
638- }
639- return strings .Join (parts , "/" )
652+ return previewutil .FileURL ("http://" + r .Host , id , entry )
640653}
641654
642655func sessionView (s domain.Session ) SessionView {
0 commit comments