Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions backend/internal/cli/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ func newPreviewCommand(ctx *commandContext) *cobra.Command {
Use: "preview [url]",
Short: "Open a URL (or the workspace's index.html) in the desktop browser panel for the current session",
Long: "Open a URL in the desktop browser panel for the current session.\n\n" +
"With no argument it re-opens whatever this session last previewed,\n" +
"falling back to the workspace's index.html. A workspace-relative path\n" +
"With no argument it opens the workspace's index.html. A workspace-relative path\n" +
"(e.g. ./dist/index.html) is served as a local file. Use `ao preview\n" +
"clear` to empty the panel.",
Args: cobra.MaximumNArgs(1),
Expand Down
4 changes: 4 additions & 0 deletions backend/internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/notify"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
"github.com/aoagents/agent-orchestrator/backend/internal/preview"
"github.com/aoagents/agent-orchestrator/backend/internal/runfile"
notificationsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/notification"
projectsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/project"
Expand Down Expand Up @@ -127,6 +128,7 @@ func Run() error {
}
return fmt.Errorf("wire session service: %w", err)
}
previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)

srv, err := httpd.NewWithDeps(cfg, log, termMgr, httpd.APIDeps{
Projects: projectsvc.NewWithDeps(projectsvc.Deps{Store: store, Sessions: sessionSvc, Telemetry: telemetrySink}),
Expand All @@ -141,6 +143,7 @@ func Run() error {
})
if err != nil {
stop()
<-previewDone
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
Expand All @@ -155,6 +158,7 @@ func Run() error {
// via defer) avoids the LIFO trap where a Stop() that blocks on ctx-cancel
// runs before the cancel — which would hang any non-signal exit path.
stop()
<-previewDone
lcStack.Stop()
if err := cdcPipe.Stop(); err != nil {
log.Error("cdc pipeline shutdown", "err", err)
Expand Down
123 changes: 68 additions & 55 deletions backend/internal/httpd/controllers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apispec"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/envelope"
"github.com/aoagents/agent-orchestrator/backend/internal/ports"
previewutil "github.com/aoagents/agent-orchestrator/backend/internal/preview"
sessionsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/session"
)

Expand All @@ -25,6 +26,8 @@ const (
maxMessageLen = 4096
)

var errPreviewFileNotFound = errors.New("preview file not found")

// SessionService is the controller-facing session service contract.
type SessionService interface {
List(ctx context.Context, filter sessionsvc.ListFilter) ([]domain.Session, error)
Expand Down Expand Up @@ -182,10 +185,9 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
// session and fans out a session_updated CDC event so the dashboard's browser
// panel reacts live. The target is resolved as follows:
//
// - An empty url reuses the session's existing preview target (so a bare
// `ao preview` re-opens whatever this agent/context last previewed),
// falling back to autodetecting a static entry point (index.html and
// friends) only when nothing has been previewed yet.
// - An empty url opens the workspace's static entry point (index.html and
// friends), falling back to the session's existing preview target only
// when no entry point exists.
// - An explicit workspace-local path (e.g. `index.html`, `./dist/index.html`)
// is served through the preview/files route so local files load.
// - Anything else (http(s)/file URLs, host:port dev servers) is kept verbatim.
Expand All @@ -212,16 +214,26 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
// ponytail: no URL sanitization on preview target; agent-trusted for now
previewURL := strings.TrimSpace(in.URL)
if previewURL == "" {
if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
previewURL = existing
} else if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
previewURL = previewFileURL(r, sessionID(r), entry)
} else if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
var resolveErr error
previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, existing)
if resolveErr != nil {
writePreviewResolveError(w, r, resolveErr)
return
}
} else {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
return
}
} else if resolved, ok := resolveLocalPreview(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL); ok {
previewURL = resolved
} else {
var resolveErr error
previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, previewURL)
if resolveErr != nil {
writePreviewResolveError(w, r, resolveErr)
return
}
}
updated, err := c.Svc.SetPreview(r.Context(), sessionID(r), previewURL)
if err != nil {
Expand Down Expand Up @@ -544,20 +556,8 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) {
}

func discoverPreviewEntry(workspacePath string) (string, bool) {
if strings.TrimSpace(workspacePath) == "" {
return "", false
}
for _, candidate := range []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"} {
file, ok := confinedPreviewPath(workspacePath, candidate)
if !ok {
continue
}
info, err := os.Stat(file)
if err == nil && !info.IsDir() {
return candidate, true
}
}
return "", false
entry, ok := previewutil.DiscoverEntry(workspacePath)
return entry.Path, ok
}

// resolveLocalPreview maps a workspace-local path (e.g. "index.html" or
Expand All @@ -583,6 +583,49 @@ func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, ra
return previewFileURL(r, id, entry), true
}

func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, error) {
raw = strings.TrimSpace(raw)
if isAbsolutePreviewPath(raw) {
return absolutePreviewFileURL(raw)
}
if resolved, ok := resolveLocalPreview(r, id, workspacePath, raw); ok {
return resolved, nil
}
return raw, nil
}

func isAbsolutePreviewPath(raw string) bool {
return filepath.IsAbs(raw) || isWindowsAbsolutePath(raw)
}

func isWindowsAbsolutePath(raw string) bool {
return len(raw) >= 3 && ((raw[0] >= 'a' && raw[0] <= 'z') || (raw[0] >= 'A' && raw[0] <= 'Z')) && raw[1] == ':' && (raw[2] == '\\' || raw[2] == '/')
}

func absolutePreviewFileURL(raw string) (string, error) {
file, err := filepath.Abs(raw)
if err != nil {
return "", errPreviewFileNotFound
}
info, err := os.Stat(file)
if err != nil || info.IsDir() {
return "", errPreviewFileNotFound
}
filePath := filepath.ToSlash(file)
if filepath.VolumeName(file) != "" || isWindowsAbsolutePath(filePath) {
filePath = "/" + filePath
}
return (&url.URL{Scheme: "file", Path: filePath}).String(), nil
}

func writePreviewResolveError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, errPreviewFileNotFound) {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
envelope.WriteError(w, r, err)
}

// hasURLScheme reports whether raw begins with an RFC-3986 "scheme:" prefix
// (http:, https:, file:, or a host:port like localhost:5173). It mirrors the
// renderer's withDefaultScheme heuristic so the daemon and browser panel agree
Expand All @@ -602,41 +645,11 @@ func hasURLScheme(raw string) bool {
}

func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
root, err := filepath.Abs(workspacePath)
if err != nil || root == "" {
return "", false
}
clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/")
if clean == "" || clean == "." {
clean = "index.html"
}
file := filepath.Join(root, filepath.FromSlash(clean))
absFile, err := filepath.Abs(file)
if err != nil {
return "", false
}
rel, err := filepath.Rel(root, absFile)
if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
return "", false
}
return absFile, true
return previewutil.ConfinedPath(workspacePath, assetPath)
}

func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
u := url.URL{
Scheme: "http",
Host: r.Host,
Path: "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry),
}
return u.String()
}

func escapePath(raw string) string {
parts := strings.Split(raw, "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
return previewutil.FileURL("http://"+r.Host, id, entry)
}

func sessionView(s domain.Session) SessionView {
Expand Down
102 changes: 99 additions & 3 deletions backend/internal/httpd/controllers/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -430,11 +431,11 @@ func TestSessionsAPI_SetPreviewEmptyURLAutodetectsIndex(t *testing.T) {
}
}

func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) {
func TestSessionsAPI_SetPreviewEmptyURLPrefersWorkspaceEntryOverExistingTarget(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
// An index.html exists, but the session already has a preview target — the
// bare `ao preview` must reuse that target rather than autodetecting index.
// An index.html exists, so bare `ao preview` returns to the workspace entry
// instead of sticking to the last explicit target.
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<html></html>`), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
Expand All @@ -443,6 +444,57 @@ func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTarget(t *testing.T) {
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)

body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
}
var resp struct {
Session struct {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") {
t.Fatalf("response previewUrl = %q, want workspace index files URL", resp.Session.PreviewURL)
}
}

func TestSessionsAPI_SetPreviewEmptyURLNormalizesExistingRelativeTarget(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(`<html></html>`), 0o644); err != nil {
t.Fatalf("write index: %v", err)
}
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{WorkspacePath: workspace, PreviewURL: "index.html"}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)

body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
}
var resp struct {
Session struct {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/index.html") {
t.Fatalf("response previewUrl = %q, want index.html files URL", resp.Session.PreviewURL)
}
if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != resp.Session.PreviewURL {
t.Fatalf("persisted previewUrl = %q, want normalized response URL %q", got, resp.Session.PreviewURL)
}
}

func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTargetWhenNoEntryExists(t *testing.T) {
svc := newFakeSessionService()
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{WorkspacePath: t.TempDir(), PreviewURL: "http://localhost:4321/docs"}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)

body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
Expand Down Expand Up @@ -499,6 +551,50 @@ func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T)
}
}

func TestSessionsAPI_SetPreviewAbsoluteFilePathPersistsFileURL(t *testing.T) {
svc := newFakeSessionService()
file := filepath.Join(t.TempDir(), "implementation_plan.html")
if err := os.WriteFile(file, []byte(`<html></html>`), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
srv := newSessionTestServer(t, svc)

body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(file)+`}`)
if status != http.StatusOK {
t.Fatalf("set preview = %d, want 200; body=%s", status, body)
}
var resp struct {
Session struct {
PreviewURL string `json:"previewUrl"`
} `json:"session"`
}
mustJSON(t, body, &resp)
parsed, err := url.Parse(resp.Session.PreviewURL)
if err != nil {
t.Fatalf("parse preview url: %v", err)
}
if parsed.Scheme != "file" {
t.Fatalf("previewUrl = %q, want file URL", resp.Session.PreviewURL)
}
}

func TestSessionsAPI_SetPreviewMissingAbsoluteFilePathFailsWithoutOverwriting(t *testing.T) {
svc := newFakeSessionService()
missing := filepath.Join(t.TempDir(), "implmentation_plan.html")
s := svc.sessions["ao-1"]
s.Metadata = domain.SessionMetadata{PreviewURL: "http://localhost:4321/docs"}
svc.sessions["ao-1"] = s
srv := newSessionTestServer(t, svc)

body, status, _ := doRequest(t, srv, "POST", "/api/v1/sessions/ao-1/preview", `{"url":`+strconv.Quote(missing)+`}`)
if status != http.StatusNotFound {
t.Fatalf("set missing absolute preview = %d, want 404; body=%s", status, body)
}
if got := svc.sessions["ao-1"].Metadata.PreviewURL; got != "http://localhost:4321/docs" {
t.Fatalf("persisted previewUrl = %q, want existing target preserved", got)
}
}

func TestSessionsAPI_SetPreviewBumpsRevisionOnSameURL(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
Expand Down
Loading
Loading