From c5b622b3566df247ee383b0b1bbf21350f3d0cb9 Mon Sep 17 00:00:00 2001
From: aprv10 <1apoorvindia@gmail.com>
Date: Sun, 19 Jul 2026 08:37:55 +0530
Subject: [PATCH 1/4] fix(preview): resolve root-relative static assets
---
.../internal/httpd/controllers/sessions.go | 81 +++++++++
.../httpd/controllers/sessions_test.go | 170 ++++++++++++++++--
backend/internal/httpd/cors.go | 8 +-
backend/internal/httpd/cors_test.go | 14 ++
backend/internal/httpd/router.go | 15 +-
backend/internal/preview/entry.go | 60 +++++--
backend/internal/preview/entry_test.go | 66 +++++++
backend/internal/preview/poller.go | 3 +
backend/internal/preview/poller_test.go | 25 ++-
9 files changed, 408 insertions(+), 34 deletions(-)
diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go
index 82cc9ed753..49769393ae 100644
--- a/backend/internal/httpd/controllers/sessions.go
+++ b/backend/internal/httpd/controllers/sessions.go
@@ -200,6 +200,87 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
http.ServeFile(w, r, file)
}
+// PreviewOrigin serves a workspace preview from its isolated *.localhost
+// origin. It returns false when the request host is not a preview origin so the
+// daemon router can continue handling its normal API and control surfaces.
+//
+// The selected entry's directory is mounted at the origin root. For example,
+// dist/index.html is reachable at both /dist/ (the persisted URL) and /, while
+// /assets/app.css maps to dist/assets/app.css. This mirrors a production static
+// server and fixes root-relative URLs without rewriting user-generated files.
+func (c *SessionsController) PreviewOrigin(w http.ResponseWriter, r *http.Request) bool {
+ id, ok := previewutil.SessionIDFromHost(r.Host)
+ if !ok {
+ return false
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ envelope.WriteAPIError(w, r, http.StatusMethodNotAllowed, "method_not_allowed", "METHOD_NOT_ALLOWED",
+ r.Method+" not allowed on preview origin", nil)
+ return true
+ }
+ if c.Svc == nil {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_NOT_FOUND", "Preview not found", nil)
+ return true
+ }
+ sess, err := c.Svc.Get(r.Context(), id)
+ if err != nil {
+ envelope.WriteError(w, r, err)
+ return true
+ }
+ entry, ok := previewOriginEntry(sess)
+ if !ok {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "NO_PREVIEW_ENTRY", "No preview entry point found in session workspace", nil)
+ return true
+ }
+ asset := previewOriginAssetPath(entry, r.URL.Path)
+ file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, asset)
+ if !ok {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
+ return true
+ }
+ if info, statErr := os.Stat(file); statErr != nil || info.IsDir() {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
+ return true
+ }
+ if previewutil.IsMarkdownPath(file) {
+ c.servePreviewMarkdown(w, r, file)
+ return true
+ }
+ http.ServeFile(w, r, file)
+ return true
+}
+
+func previewOriginEntry(sess domain.Session) (string, bool) {
+ if parsed, err := url.Parse(strings.TrimSpace(sess.Metadata.PreviewURL)); err == nil {
+ if id, ok := previewutil.SessionIDFromHost(parsed.Host); ok && id == sess.ID {
+ entry := strings.TrimPrefix(path.Clean("/"+parsed.Path), "/")
+ if file, confined := confinedPreviewPath(sess.Metadata.WorkspacePath, entry); confined {
+ if info, statErr := os.Stat(file); statErr == nil && !info.IsDir() {
+ return entry, true
+ }
+ }
+ }
+ }
+ return discoverPreviewEntry(sess.Metadata.WorkspacePath)
+}
+
+func previewOriginAssetPath(entry, requestPath string) string {
+ requested := strings.TrimPrefix(path.Clean("/"+requestPath), "/")
+ if requested == "" || requested == "." {
+ return entry
+ }
+ root := path.Dir(entry)
+ if root == "." {
+ return requested
+ }
+ if requested == root {
+ return entry
+ }
+ requested = strings.TrimPrefix(requested, root+"/")
+ return path.Join(root, requested)
+}
+
// servePreviewMarkdown renders a workspace Markdown file to a self-contained
// HTML document so the browser panel displays formatted content instead of raw
// source.
diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go
index aec4a7ca9f..cb42edbd67 100644
--- a/backend/internal/httpd/controllers/sessions_test.go
+++ b/backend/internal/httpd/controllers/sessions_test.go
@@ -253,6 +253,30 @@ func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Serve
return srv
}
+func doPreviewOriginRequest(t *testing.T, srv *httptest.Server, previewURL, requestPath string) ([]byte, int, http.Header) {
+ t.Helper()
+ preview, err := url.Parse(previewURL)
+ if err != nil {
+ t.Fatalf("parse preview URL: %v", err)
+ }
+ req, err := http.NewRequest(http.MethodGet, srv.URL+requestPath, nil)
+ if err != nil {
+ t.Fatalf("new preview request: %v", err)
+ }
+ req.Host = preview.Host
+ req.Header.Set("Origin", preview.Scheme+"://"+preview.Host)
+ resp, err := srv.Client().Do(req)
+ if err != nil {
+ t.Fatalf("do preview request: %v", err)
+ }
+ defer resp.Body.Close()
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ t.Fatalf("read preview response: %v", err)
+ }
+ return body, resp.StatusCode, resp.Header
+}
+
func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) {
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv := httptest.NewServer(httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{}, httpd.ControlDeps{}))
@@ -394,14 +418,14 @@ func TestSessionsAPI_PreviewDiscoversAndServesStaticIndex(t *testing.T) {
if strings.Contains(preview.PreviewURL, workspace) {
t.Fatalf("preview leaked workspace path: %s", preview.PreviewURL)
}
- if !strings.Contains(preview.PreviewURL, "/index.html") {
- t.Fatalf("preview URL = %q, want index.html asset path", preview.PreviewURL)
- }
parsed, err := url.Parse(preview.PreviewURL)
if err != nil {
t.Fatalf("parse preview URL: %v", err)
}
- body, status, headers := doRequest(t, srv, "GET", parsed.RequestURI(), "")
+ if !strings.HasSuffix(parsed.Hostname(), ".localhost") || parsed.Path != "/index.html" {
+ t.Fatalf("preview URL = %q, want isolated localhost origin ending in /index.html", preview.PreviewURL)
+ }
+ body, status, headers := doPreviewOriginRequest(t, srv, preview.PreviewURL, "/")
if status != http.StatusOK {
t.Fatalf("preview file = %d, want 200; body=%s", status, body)
}
@@ -487,8 +511,8 @@ func TestSessionsAPI_SetPreviewEmptyURLPrefersWorkspaceEntryOverExistingTarget(t
} `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)
+ if !strings.HasSuffix(resp.Session.PreviewURL, "/index.html") {
+ t.Fatalf("response previewUrl = %q, want workspace index preview URL", resp.Session.PreviewURL)
}
}
@@ -513,8 +537,8 @@ func TestSessionsAPI_SetPreviewEmptyURLNormalizesExistingRelativeTarget(t *testi
} `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 !strings.HasSuffix(resp.Session.PreviewURL, "/index.html") {
+ t.Fatalf("response previewUrl = %q, want index.html preview 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)
@@ -543,7 +567,7 @@ func TestSessionsAPI_SetPreviewEmptyURLReusesExistingTargetWhenNoEntryExists(t *
}
}
-func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T) {
+func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToPreviewOrigin(t *testing.T) {
svc := newFakeSessionService()
workspace := t.TempDir()
if err := os.MkdirAll(filepath.Join(workspace, "dist"), 0o755); err != nil {
@@ -567,23 +591,135 @@ func TestSessionsAPI_SetPreviewLocalRelativePathResolvesToFilesURL(t *testing.T)
} `json:"session"`
}
mustJSON(t, body, &resp)
- if !strings.HasSuffix(resp.Session.PreviewURL, "/preview/files/dist/index.html") {
- t.Fatalf("response previewUrl = %q, want dist/index.html files URL", resp.Session.PreviewURL)
+ if !strings.HasSuffix(resp.Session.PreviewURL, "/dist/index.html") {
+ t.Fatalf("response previewUrl = %q, want dist/index.html preview URL", resp.Session.PreviewURL)
}
if strings.Contains(resp.Session.PreviewURL, workspace) {
t.Fatalf("preview leaked workspace path: %s", resp.Session.PreviewURL)
}
- // The resolved files URL actually serves the local file.
- parsed, err := url.Parse(resp.Session.PreviewURL)
- if err != nil {
- t.Fatalf("parse preview URL: %v", err)
- }
- fileBody, fileStatus, _ := doRequest(t, srv, "GET", parsed.RequestURI(), "")
+ // The resolved preview origin actually serves the local file.
+ fileBody, fileStatus, _ := doPreviewOriginRequest(t, srv, resp.Session.PreviewURL, "/")
if fileStatus != http.StatusOK {
t.Fatalf("serve local file = %d, want 200; body=%s", fileStatus, fileBody)
}
}
+func TestSessionsAPI_PreviewOriginResolvesRootRelativeAssetsFromEntryDirectory(t *testing.T) {
+ svc := newFakeSessionService()
+ workspace := t.TempDir()
+ for _, dir := range []string{
+ filepath.Join(workspace, "dist", "assets"),
+ filepath.Join(workspace, "dist", "fonts"),
+ filepath.Join(workspace, "assets"),
+ } {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatalf("mkdir %s: %v", dir, err)
+ }
+ }
+ files := map[string]string{
+ filepath.Join(workspace, "dist", "index.html"): ``,
+ filepath.Join(workspace, "dist", "assets", "app.css"): `@font-face { src: url("/fonts/app.woff2") }`,
+ filepath.Join(workspace, "dist", "assets", "app.js"): `document.body.dataset.loaded = "yes"`,
+ filepath.Join(workspace, "dist", "fonts", "app.woff2"): "preview-font",
+ // A conflicting workspace-root asset proves /assets is mounted relative
+ // to the selected deployment directory, not the workspace root.
+ filepath.Join(workspace, "assets", "app.css"): "wrong-root",
+ }
+ for file, contents := range files {
+ if err := os.WriteFile(file, []byte(contents), 0o644); err != nil {
+ t.Fatalf("write %s: %v", file, err)
+ }
+ }
+ s := svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
+ svc.sessions["ao-1"] = s
+ srv := newSessionTestServer(t, svc)
+
+ body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/sessions/ao-1/preview", `{"url":"./dist/index.html"}`)
+ 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)
+
+ for requestPath, want := range map[string]string{
+ "/": `/assets/app.css`,
+ "/assets/app.css": `/fonts/app.woff2`,
+ "/dist/assets/app.js": `dataset.loaded`,
+ "/fonts/app.woff2": `preview-font`,
+ } {
+ assetBody, assetStatus, _ := doPreviewOriginRequest(t, srv, resp.Session.PreviewURL, requestPath)
+ if assetStatus != http.StatusOK {
+ t.Errorf("GET %s = %d, want 200; body=%s", requestPath, assetStatus, assetBody)
+ continue
+ }
+ if !strings.Contains(string(assetBody), want) {
+ t.Errorf("GET %s body = %q, want content containing %q", requestPath, assetBody, want)
+ }
+ }
+
+ // Retain the old API route as a compatibility surface for stored URLs and
+ // external callers while new previews use the isolated origin.
+ legacyBody, legacyStatus, _ := doRequest(t, srv, http.MethodGet, "/api/v1/sessions/ao-1/preview/files/dist/assets/app.css", "")
+ if legacyStatus != http.StatusOK || !strings.Contains(string(legacyBody), "/fonts/app.woff2") {
+ t.Fatalf("legacy preview route = %d, body=%q; want existing file response", legacyStatus, legacyBody)
+ }
+}
+
+func TestSessionsAPI_PreviewOriginsIsolateConcurrentSessionsAndSurviveRouterRestart(t *testing.T) {
+ svc := newFakeSessionService()
+ previewURLs := make(map[domain.SessionID]string)
+ for _, tc := range []struct {
+ id domain.SessionID
+ content string
+ }{{id: "ao-1", content: "session-one"}, {id: "ao-2", content: "session-two"}} {
+ workspace := t.TempDir()
+ if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte(``), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(workspace, "theme.css"), []byte(tc.content), 0o644); err != nil {
+ t.Fatalf("write theme: %v", err)
+ }
+ s := domain.Session{SessionRecord: domain.SessionRecord{ID: tc.id, Kind: domain.KindWorker}}
+ s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
+ svc.sessions[tc.id] = s
+ }
+
+ srv := newSessionTestServer(t, svc)
+ for id := range svc.sessions {
+ body, status, _ := doRequest(t, srv, http.MethodPost, "/api/v1/sessions/"+string(id)+"/preview", `{}`)
+ if status != http.StatusOK {
+ t.Fatalf("set preview %s = %d, want 200; body=%s", id, status, body)
+ }
+ var resp struct {
+ Session struct {
+ PreviewURL string `json:"previewUrl"`
+ } `json:"session"`
+ }
+ mustJSON(t, body, &resp)
+ previewURLs[id] = resp.Session.PreviewURL
+ }
+
+ for id, want := range map[domain.SessionID]string{"ao-1": "session-one", "ao-2": "session-two"} {
+ body, status, _ := doPreviewOriginRequest(t, srv, previewURLs[id], "/theme.css")
+ if status != http.StatusOK || string(body) != want {
+ t.Fatalf("session %s asset = %d, %q; want 200, %q", id, status, body, want)
+ }
+ }
+
+ // A new router has no in-memory preview registry. The persisted URL and
+ // session workspace are sufficient to reconstruct the same virtual root.
+ restarted := newSessionTestServer(t, svc)
+ body, status, _ := doPreviewOriginRequest(t, restarted, previewURLs["ao-1"], "/theme.css")
+ if status != http.StatusOK || string(body) != "session-one" {
+ t.Fatalf("asset after router restart = %d, %q; want 200, session-one", status, body)
+ }
+}
+
func TestSessionsAPI_SetPreviewAbsoluteFilePathPersistsFileURL(t *testing.T) {
svc := newFakeSessionService()
file := filepath.Join(t.TempDir(), "implementation_plan.html")
diff --git a/backend/internal/httpd/cors.go b/backend/internal/httpd/cors.go
index 454bb6c68f..b5155bd9dc 100644
--- a/backend/internal/httpd/cors.go
+++ b/backend/internal/httpd/cors.go
@@ -83,8 +83,12 @@ func isLoopbackOrigin(origin string) bool {
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return false
}
- host := u.Hostname()
- if host == "localhost" {
+ host := strings.ToLower(strings.TrimSuffix(u.Hostname(), "."))
+ // RFC 6761 reserves localhost and every name below it for loopback.
+ // Workspace previews use a per-session subdomain so browser-enforced CORS
+ // requests (ES modules and fetch) remain isolated without being rejected by
+ // the daemon's origin boundary.
+ if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
diff --git a/backend/internal/httpd/cors_test.go b/backend/internal/httpd/cors_test.go
index 286778f410..14adc270dc 100644
--- a/backend/internal/httpd/cors_test.go
+++ b/backend/internal/httpd/cors_test.go
@@ -48,6 +48,13 @@ func TestCORS(t *testing.T) {
wantStatus: http.StatusOK,
wantACAO: "http://127.0.0.1:8080",
},
+ {
+ name: "isolated localhost preview origin allowed",
+ method: http.MethodGet,
+ headers: map[string]string{"Origin": "http://ao-preview.mfxs2mi.localhost:5181"},
+ wantStatus: http.StatusOK,
+ wantACAO: "http://ao-preview.mfxs2mi.localhost:5181",
+ },
{
// localhost in the host position of a non-loopback origin must not
// fool the predicate.
@@ -67,6 +74,13 @@ func TestCORS(t *testing.T) {
wantStatus: http.StatusForbidden,
wantACAO: "",
},
+ {
+ name: "localhost suffix lookalike rejected",
+ method: http.MethodGet,
+ headers: map[string]string{"Origin": "http://ao-preview.localhost.evil.example"},
+ wantStatus: http.StatusForbidden,
+ wantACAO: "",
+ },
{
name: "null origin is rejected",
method: http.MethodGet,
diff --git a/backend/internal/httpd/router.go b/backend/internal/httpd/router.go
index 2380a240a2..60fd1a5c51 100644
--- a/backend/internal/httpd/router.go
+++ b/backend/internal/httpd/router.go
@@ -46,12 +46,14 @@ type ControlDeps struct {
func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager, deps APIDeps, control ControlDeps) chi.Router {
log = loggerOrDefault(log)
r := chi.NewRouter()
+ api := NewAPI(cfg, deps)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(requestLogger(log, deps.Telemetry))
r.Use(recoverTelemetry(log, deps.Telemetry))
r.Use(corsMiddleware(cfg.AllowedOrigins))
+ r.Use(previewOriginMiddleware(api.sessions))
// JSON envelopes for unmatched routes / methods — chi's defaults are
// text/plain, which would break consumers that parse every response as
@@ -64,11 +66,22 @@ func NewRouterWithControl(cfg config.Config, log *slog.Logger, termMgr *terminal
mountControl(r, control)
mountTelemetry(r, deps.Telemetry)
mountMobile(r, deps.Mobile)
- NewAPI(cfg, deps).Register(r)
+ api.Register(r)
return r
}
+func previewOriginMiddleware(sessions *controllers.SessionsController) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if sessions != nil && sessions.PreviewOrigin(w, r) {
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
// mountHealth registers the liveness and readiness probes the Electron
// supervisor polls before letting the renderer connect.
func mountHealth(r chi.Router) {
diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go
index 0b2a2bbf5f..c26b0f0bd7 100644
--- a/backend/internal/preview/entry.go
+++ b/backend/internal/preview/entry.go
@@ -1,17 +1,22 @@
package preview
import (
+ "encoding/base32"
"io/fs"
+ "net"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"time"
+ "unicode/utf8"
"github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
+const previewHostLabel = "ao-preview"
+
var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"}
// previewableExts are the file extensions the browser panel can render: HTML
@@ -155,15 +160,58 @@ func ConfinedPath(workspacePath, assetPath string) (string, bool) {
return absFile, true
}
-// FileURL builds the daemon preview/files URL for a workspace-local entry.
+// FileURL builds an isolated localhost origin for a workspace-local entry.
+// Mounting the entry directory at the origin root makes both relative and
+// root-relative browser requests resolve inside the preview instead of falling
+// through to the daemon's API origin.
func FileURL(baseURL string, id domain.SessionID, entry string) string {
u := normalizedBaseURL(baseURL)
- u.Path = "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/" + escapePath(entry)
+ u.Host = previewHost(u, id)
+ // URL.String escapes Path exactly once. Supplying an already-escaped path
+ // here would turn spaces into %2520 and make otherwise valid workspace files
+ // impossible to resolve.
+ u.Path = path.Clean("/" + strings.TrimSpace(entry))
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
+// SessionIDFromHost decodes the session identity carried by a FileURL host.
+// The labels use unpadded base32 so arbitrary session IDs remain DNS-safe.
+func SessionIDFromHost(rawHost string) (domain.SessionID, bool) {
+ host := rawHost
+ if parsedHost, _, err := net.SplitHostPort(rawHost); err == nil {
+ host = parsedHost
+ }
+ labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".")
+ if len(labels) < 3 || labels[0] != previewHostLabel || labels[len(labels)-1] != "localhost" {
+ return "", false
+ }
+ encoded := strings.Join(labels[1:len(labels)-1], "")
+ decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
+ if err != nil || len(decoded) == 0 || !utf8.Valid(decoded) {
+ return "", false
+ }
+ return domain.SessionID(decoded), true
+}
+
+func previewHost(u url.URL, id domain.SessionID) string {
+ encoded := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id)))
+ labels := []string{previewHostLabel}
+ const maxChunk = 50
+ for len(encoded) > 0 {
+ n := min(len(encoded), maxChunk)
+ labels = append(labels, encoded[:n])
+ encoded = encoded[n:]
+ }
+ labels = append(labels, "localhost")
+ host := strings.Join(labels, ".")
+ if port := u.Port(); port != "" {
+ return host + ":" + port
+ }
+ return host
+}
+
func normalizedBaseURL(raw string) url.URL {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
@@ -178,11 +226,3 @@ func normalizedBaseURL(raw string) url.URL {
}
return *u
}
-
-func escapePath(raw string) string {
- parts := strings.Split(raw, "/")
- for i, part := range parts {
- parts[i] = url.PathEscape(part)
- }
- return strings.Join(parts, "/")
-}
diff --git a/backend/internal/preview/entry_test.go b/backend/internal/preview/entry_test.go
index 705f6f1697..96eb02f27f 100644
--- a/backend/internal/preview/entry_test.go
+++ b/backend/internal/preview/entry_test.go
@@ -1,10 +1,14 @@
package preview
import (
+ "net/url"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
)
func writeEntryFile(t *testing.T, path, contents string, mod time.Time) {
@@ -120,3 +124,65 @@ func TestIsMarkdownPath(t *testing.T) {
}
}
}
+
+func TestFileURLUsesIsolatedLocalhostOrigin(t *testing.T) {
+ id := domain.SessionID("ao-1")
+ raw := FileURL("http://127.0.0.1:3001", id, "dist/index.html")
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse FileURL: %v", err)
+ }
+ if parsed.Scheme != "http" || parsed.Port() != "3001" {
+ t.Fatalf("FileURL = %q, want http on daemon port", raw)
+ }
+ if !strings.HasSuffix(parsed.Hostname(), ".localhost") {
+ t.Fatalf("FileURL host = %q, want isolated .localhost origin", parsed.Hostname())
+ }
+ if parsed.Path != "/dist/index.html" {
+ t.Fatalf("FileURL path = %q, want /dist/index.html", parsed.Path)
+ }
+ decoded, ok := SessionIDFromHost(parsed.Host)
+ if !ok || decoded != id {
+ t.Fatalf("SessionIDFromHost(%q) = %q, %v; want %q, true", parsed.Host, decoded, ok, id)
+ }
+}
+
+func TestSessionIDFromHostSupportsLongUnicodeIDs(t *testing.T) {
+ id := domain.SessionID(strings.Repeat("worker-", 12) + "雪")
+ raw := FileURL("http://localhost:4321", id, "index.html")
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse FileURL: %v", err)
+ }
+ for _, label := range strings.Split(parsed.Hostname(), ".") {
+ if len(label) > 63 {
+ t.Fatalf("hostname label length = %d, want <= 63", len(label))
+ }
+ }
+ decoded, ok := SessionIDFromHost(parsed.Host)
+ if !ok || decoded != id {
+ t.Fatalf("round trip = %q, %v; want %q, true", decoded, ok, id)
+ }
+}
+
+func TestFileURLPreservesSpecialCharactersInEntryPath(t *testing.T) {
+ raw := FileURL("http://127.0.0.1:3001", "ao-1", "dist/my report #1.html")
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse FileURL: %v", err)
+ }
+ if parsed.Path != "/dist/my report #1.html" {
+ t.Fatalf("FileURL path = %q, want decoded workspace path", parsed.Path)
+ }
+ if strings.Contains(raw, "%2520") || strings.Contains(raw, "%2523") {
+ t.Fatalf("FileURL = %q, path was double-escaped", raw)
+ }
+}
+
+func TestSessionIDFromHostRejectsOrdinaryHosts(t *testing.T) {
+ for _, host := range []string{"127.0.0.1:3001", "localhost:3001", "ao-preview.invalid.localhost:3001", "example.com"} {
+ if id, ok := SessionIDFromHost(host); ok {
+ t.Errorf("SessionIDFromHost(%q) = %q, true; want false", host, id)
+ }
+ }
+}
diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go
index b06c82a099..8b8b13848f 100644
--- a/backend/internal/preview/poller.go
+++ b/backend/internal/preview/poller.go
@@ -172,6 +172,9 @@ func isWorkspacePreviewURL(raw string, id domain.SessionID) bool {
if err != nil {
return false
}
+ if originID, ok := SessionIDFromHost(parsed.Host); ok {
+ return originID == id
+ }
previewPath := parsed.Path
if previewPath == "" {
previewPath = raw
diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go
index 3090383b65..d5226a2d85 100644
--- a/backend/internal/preview/poller_test.go
+++ b/backend/internal/preview/poller_test.go
@@ -50,7 +50,7 @@ func TestPollerSetsPreviewWhenActiveWorkerEntryAppears(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html",
+ url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"),
})
}
@@ -66,7 +66,7 @@ func TestPollerUsesFirstExistingEntrypoint(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/dist/index.html",
+ url: FileURL("http://127.0.0.1:3001", "ao-1", "dist/index.html"),
})
}
@@ -83,7 +83,7 @@ func TestPollerPreservesEntrypointPriority(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/public/index.html",
+ url: FileURL("http://127.0.0.1:3001", "ao-1", "public/index.html"),
})
}
@@ -129,7 +129,7 @@ func TestPollerRediscoverEntryAfterDeleteAndRecreate(t *testing.T) {
if err := poller.Poll(context.Background()); err != nil {
t.Fatalf("first Poll: %v", err)
}
- wantURL := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html"
+ wantURL := FileURL("http://127.0.0.1:3001", "ao-1", "index.html")
assertSets(t, svc.sets, previewSet{id: "ao-1", url: wantURL})
// Delete the entry — poller must clear the preview and mark the session cleared.
@@ -191,6 +191,23 @@ func TestPollerDoesNotOverrideExplicitPreviewTarget(t *testing.T) {
}
}
+func TestPollerMigratesLegacyWorkspacePreviewURL(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "hello")
+ legacy := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html"
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, legacy)}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ assertSets(t, svc.sets, previewSet{
+ id: "ao-1",
+ url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"),
+ })
+}
+
func TestPollerSkipsNonWorkerSessions(t *testing.T) {
workspace := t.TempDir()
writeFile(t, filepath.Join(workspace, "index.html"), "hello")
From 597dd75fa4ae5422d9b8ab4c60bb228909a78638 Mon Sep 17 00:00:00 2001
From: aprv10 <1apoorvindia@gmail.com>
Date: Sun, 19 Jul 2026 08:47:40 +0530
Subject: [PATCH 2/4] fix(preview): fix lint errors
---
backend/internal/preview/entry.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go
index c26b0f0bd7..c0f3d9281a 100644
--- a/backend/internal/preview/entry.go
+++ b/backend/internal/preview/entry.go
@@ -199,7 +199,7 @@ func previewHost(u url.URL, id domain.SessionID) string {
encoded := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id)))
labels := []string{previewHostLabel}
const maxChunk = 50
- for len(encoded) > 0 {
+ for encoded != "" {
n := min(len(encoded), maxChunk)
labels = append(labels, encoded[:n])
encoded = encoded[n:]
From e7efd7a1aec66c4b0a0d3e4398c4125872f361e7 Mon Sep 17 00:00:00 2001
From: aprv10 <1apoorvindia@gmail.com>
Date: Mon, 20 Jul 2026 14:35:54 +0530
Subject: [PATCH 3/4] fix(preview): harden isolated asset serving
---
backend/internal/daemon/daemon.go | 3 +-
backend/internal/httpd/addr_in_use_unix.go | 12 ++
backend/internal/httpd/addr_in_use_windows.go | 13 ++
.../internal/httpd/controllers/sessions.go | 109 +++++++--------
.../httpd/controllers/sessions_test.go | 125 +++++++++++++++++-
backend/internal/httpd/server.go | 3 +-
backend/internal/preview/entry.go | 75 +++++++----
backend/internal/preview/entry_test.go | 81 +++++++++++-
backend/internal/preview/poller.go | 58 +++-----
backend/internal/preview/poller_test.go | 48 ++++++-
backend/internal/preview/stored_entry.go | 58 ++++++++
backend/internal/preview/workspace_file.go | 70 ++++++++++
12 files changed, 516 insertions(+), 139 deletions(-)
create mode 100644 backend/internal/httpd/addr_in_use_unix.go
create mode 100644 backend/internal/httpd/addr_in_use_windows.go
create mode 100644 backend/internal/preview/stored_entry.go
create mode 100644 backend/internal/preview/workspace_file.go
diff --git a/backend/internal/daemon/daemon.go b/backend/internal/daemon/daemon.go
index 4facb17dd3..d2c25c6188 100644
--- a/backend/internal/daemon/daemon.go
+++ b/backend/internal/daemon/daemon.go
@@ -133,7 +133,6 @@ func Run() error {
return fmt.Errorf("wire session service: %w", err)
}
lcStack.trackerDone = startTrackerIntake(ctx, store, sessionSvc, log)
- previewDone := preview.NewPoller(store, sessionSvc, "http://"+cfg.Addr(), preview.PollerConfig{Logger: log}).Start(ctx)
agentSvc := agentsvc.New()
go func() {
if _, err := agentSvc.Refresh(ctx); err != nil {
@@ -169,13 +168,13 @@ func Run() error {
})
if err != nil {
stop()
- <-previewDone
lcStack.Stop()
if cdcErr := cdcPipe.Stop(); cdcErr != nil {
log.Error("cdc pipeline shutdown", "err", cdcErr)
}
return err
}
+ previewDone := preview.NewPoller(store, sessionSvc, "http://"+srv.Addr().String(), preview.PollerConfig{Logger: log}).Start(ctx)
// Late-bind: the LAN listener shares the exact loopback router instance so
// the LAN surface and loopback surface never drift apart.
diff --git a/backend/internal/httpd/addr_in_use_unix.go b/backend/internal/httpd/addr_in_use_unix.go
new file mode 100644
index 0000000000..c45bd4b89c
--- /dev/null
+++ b/backend/internal/httpd/addr_in_use_unix.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package httpd
+
+import (
+ "errors"
+ "syscall"
+)
+
+func isAddrInUse(err error) bool {
+ return errors.Is(err, syscall.EADDRINUSE)
+}
diff --git a/backend/internal/httpd/addr_in_use_windows.go b/backend/internal/httpd/addr_in_use_windows.go
new file mode 100644
index 0000000000..61250239d0
--- /dev/null
+++ b/backend/internal/httpd/addr_in_use_windows.go
@@ -0,0 +1,13 @@
+//go:build windows
+
+package httpd
+
+import (
+ "errors"
+
+ "golang.org/x/sys/windows"
+)
+
+func isAddrInUse(err error) bool {
+ return errors.Is(err, windows.WSAEADDRINUSE)
+}
diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go
index 49769393ae..792b6efa52 100644
--- a/backend/internal/httpd/controllers/sessions.go
+++ b/backend/internal/httpd/controllers/sessions.go
@@ -3,6 +3,7 @@ package controllers
import (
"context"
"errors"
+ "io"
"net/http"
"net/url"
"os"
@@ -173,7 +174,11 @@ func (c *SessionsController) preview(w http.ResponseWriter, r *http.Request) {
res := SessionPreviewResponse{SessionID: sessionID(r)}
if ok {
res.Entry = entry
- res.PreviewURL = previewFileURL(r, sessionID(r), entry)
+ res.PreviewURL, err = previewFileURL(r, sessionID(r), entry)
+ if err != nil {
+ writePreviewResolveError(w, r, err)
+ return
+ }
}
envelope.WriteJSON(w, http.StatusOK, res)
}
@@ -188,16 +193,7 @@ func (c *SessionsController) previewFile(w http.ResponseWriter, r *http.Request)
envelope.WriteError(w, r, err)
return
}
- file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, chi.URLParam(r, "*"))
- if !ok {
- envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
- return
- }
- if previewutil.IsMarkdownPath(file) {
- c.servePreviewMarkdown(w, r, file)
- return
- }
- http.ServeFile(w, r, file)
+ c.serveWorkspacePreviewFile(w, r, sess.Metadata.WorkspacePath, chi.URLParam(r, "*"))
}
// PreviewOrigin serves a workspace preview from its isolated *.localhost
@@ -234,32 +230,14 @@ func (c *SessionsController) PreviewOrigin(w http.ResponseWriter, r *http.Reques
return true
}
asset := previewOriginAssetPath(entry, r.URL.Path)
- file, ok := confinedPreviewPath(sess.Metadata.WorkspacePath, asset)
- if !ok {
- envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
- return true
- }
- if info, statErr := os.Stat(file); statErr != nil || info.IsDir() {
- envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
- return true
- }
- if previewutil.IsMarkdownPath(file) {
- c.servePreviewMarkdown(w, r, file)
- return true
- }
- http.ServeFile(w, r, file)
+ c.serveWorkspacePreviewFile(w, r, sess.Metadata.WorkspacePath, asset)
return true
}
func previewOriginEntry(sess domain.Session) (string, bool) {
- if parsed, err := url.Parse(strings.TrimSpace(sess.Metadata.PreviewURL)); err == nil {
- if id, ok := previewutil.SessionIDFromHost(parsed.Host); ok && id == sess.ID {
- entry := strings.TrimPrefix(path.Clean("/"+parsed.Path), "/")
- if file, confined := confinedPreviewPath(sess.Metadata.WorkspacePath, entry); confined {
- if info, statErr := os.Stat(file); statErr == nil && !info.IsDir() {
- return entry, true
- }
- }
+ if entry, ok := previewutil.StoredWorkspaceEntry(sess.Metadata.PreviewURL, sess.ID); ok {
+ if stored, exists := previewutil.EntryAtPath(sess.Metadata.WorkspacePath, entry); exists {
+ return stored.Path, true
}
}
return discoverPreviewEntry(sess.Metadata.WorkspacePath)
@@ -281,23 +259,35 @@ func previewOriginAssetPath(entry, requestPath string) string {
return path.Join(root, requested)
}
-// servePreviewMarkdown renders a workspace Markdown file to a self-contained
-// HTML document so the browser panel displays formatted content instead of raw
-// source.
-func (c *SessionsController) servePreviewMarkdown(w http.ResponseWriter, r *http.Request, file string) {
- source, err := os.ReadFile(file)
+// serveWorkspacePreviewFile is the single serving path for both the legacy API
+// route and isolated preview origins. OpenRoot keeps symlink traversal and the
+// subsequent read on the same workspace-confined file handle.
+func (c *SessionsController) serveWorkspacePreviewFile(w http.ResponseWriter, r *http.Request, workspacePath, assetPath string) {
+ file, info, clean, err := previewutil.OpenWorkspaceFile(workspacePath, assetPath)
if err != nil {
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
- rendered, err := previewutil.RenderMarkdown(source, filepath.Base(file))
+ defer func() { _ = file.Close() }()
+ if !previewutil.IsMarkdownPath(clean) {
+ http.ServeContent(w, r, info.Name(), info.ModTime(), file)
+ return
+ }
+
+ source, err := io.ReadAll(file)
+ if err != nil {
+ envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
+ return
+ }
+ rendered, err := previewutil.RenderMarkdown(source, filepath.Base(clean))
if err != nil {
envelope.WriteError(w, r, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
-
- _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted
+ if r.Method != http.MethodHead {
+ _, _ = w.Write(rendered) //nolint:gosec // G705: preview content is workspace-local and agent-trusted
+ }
}
func (c *SessionsController) listWorkspaceFiles(w http.ResponseWriter, r *http.Request) {
@@ -365,7 +355,11 @@ func (c *SessionsController) setPreview(w http.ResponseWriter, r *http.Request)
previewURL := strings.TrimSpace(in.URL)
if previewURL == "" {
if entry, ok := discoverPreviewEntry(sess.Metadata.WorkspacePath); ok {
- previewURL = previewFileURL(r, sessionID(r), entry)
+ previewURL, err = previewFileURL(r, sessionID(r), entry)
+ if err != nil {
+ writePreviewResolveError(w, r, err)
+ return
+ }
} else if existing := strings.TrimSpace(sess.Metadata.PreviewURL); existing != "" {
var resolveErr error
previewURL, resolveErr = resolvePreviewTarget(r, sessionID(r), sess.Metadata.WorkspacePath, existing)
@@ -746,21 +740,16 @@ func discoverPreviewEntry(workspacePath string) (string, bool) {
// that already looks like a URL (an http(s)/file scheme, or a host:port dev
// server) and for paths that escape the workspace or do not point at a file, so
// the caller keeps those targets verbatim.
-func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool) {
- raw = strings.TrimSpace(raw)
+func resolveLocalPreview(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, bool, error) {
if raw == "" || hasURLScheme(raw) {
- return "", false
+ return "", false, nil
}
- file, ok := confinedPreviewPath(workspacePath, raw)
+ entry, ok := previewutil.EntryAtPath(workspacePath, raw)
if !ok {
- return "", false
- }
- info, err := os.Stat(file)
- if err != nil || info.IsDir() {
- return "", false
+ return "", false, nil
}
- entry := strings.TrimPrefix(path.Clean("/"+raw), "/")
- return previewFileURL(r, id, entry), true
+ resolved, err := previewFileURL(r, id, entry.Path)
+ return resolved, true, err
}
func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, raw string) (string, error) {
@@ -768,8 +757,8 @@ func resolvePreviewTarget(r *http.Request, id domain.SessionID, workspacePath, r
if isAbsolutePreviewPath(raw) {
return absolutePreviewFileURL(raw)
}
- if resolved, ok := resolveLocalPreview(r, id, workspacePath, raw); ok {
- return resolved, nil
+ if resolved, ok, err := resolveLocalPreview(r, id, workspacePath, raw); ok || err != nil {
+ return resolved, err
}
return raw, nil
}
@@ -803,6 +792,10 @@ func writePreviewResolveError(w http.ResponseWriter, r *http.Request, err error)
envelope.WriteAPIError(w, r, http.StatusNotFound, "not_found", "PREVIEW_FILE_NOT_FOUND", "Preview file not found", nil)
return
}
+ if errors.Is(err, previewutil.ErrPreviewHostUnsupported) {
+ envelope.WriteAPIError(w, r, http.StatusUnprocessableEntity, "unprocessable", "PREVIEW_SESSION_ID_UNSUPPORTED", "Session ID is too long for an isolated preview hostname", nil)
+ return
+ }
envelope.WriteError(w, r, err)
}
@@ -824,11 +817,7 @@ func hasURLScheme(raw string) bool {
return false
}
-func confinedPreviewPath(workspacePath, assetPath string) (string, bool) {
- return previewutil.ConfinedPath(workspacePath, assetPath)
-}
-
-func previewFileURL(r *http.Request, id domain.SessionID, entry string) string {
+func previewFileURL(r *http.Request, id domain.SessionID, entry string) (string, error) {
return previewutil.FileURL("http://"+r.Host, id, entry)
}
diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go
index cb42edbd67..69ce830ec0 100644
--- a/backend/internal/httpd/controllers/sessions_test.go
+++ b/backend/internal/httpd/controllers/sessions_test.go
@@ -19,6 +19,7 @@ import (
"github.com/aoagents/agent-orchestrator/backend/internal/httpd"
"github.com/aoagents/agent-orchestrator/backend/internal/httpd/apierr"
"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"
)
@@ -254,12 +255,16 @@ func newSessionTestServer(t *testing.T, svc *fakeSessionService) *httptest.Serve
}
func doPreviewOriginRequest(t *testing.T, srv *httptest.Server, previewURL, requestPath string) ([]byte, int, http.Header) {
+ return doPreviewOriginMethod(t, srv, http.MethodGet, previewURL, requestPath)
+}
+
+func doPreviewOriginMethod(t *testing.T, srv *httptest.Server, method, previewURL, requestPath string) ([]byte, int, http.Header) {
t.Helper()
preview, err := url.Parse(previewURL)
if err != nil {
t.Fatalf("parse preview URL: %v", err)
}
- req, err := http.NewRequest(http.MethodGet, srv.URL+requestPath, nil)
+ req, err := http.NewRequest(method, srv.URL+requestPath, nil)
if err != nil {
t.Fatalf("new preview request: %v", err)
}
@@ -437,6 +442,20 @@ func TestSessionsAPI_PreviewDiscoversAndServesStaticIndex(t *testing.T) {
}
}
+func TestSessionsAPI_PreviewRejectsSessionIDTooLongForHostname(t *testing.T) {
+ svc := newFakeSessionService()
+ id := domain.SessionID(strings.Repeat("x", 143))
+ workspace := t.TempDir()
+ if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte("preview"), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+ svc.sessions[id] = domain.Session{SessionRecord: domain.SessionRecord{ID: id, Kind: domain.KindWorker, Metadata: domain.SessionMetadata{WorkspacePath: workspace}}}
+ srv := newSessionTestServer(t, svc)
+
+ body, status, _ := doRequest(t, srv, http.MethodGet, "/api/v1/sessions/"+string(id)+"/preview", "")
+ assertErrorCode(t, body, status, http.StatusUnprocessableEntity, "PREVIEW_SESSION_ID_UNSUPPORTED")
+}
+
func TestSessionsAPI_SetPreviewExplicitURLPersists(t *testing.T) {
svc := newFakeSessionService()
srv := newSessionTestServer(t, svc)
@@ -670,6 +689,110 @@ func TestSessionsAPI_PreviewOriginResolvesRootRelativeAssetsFromEntryDirectory(t
}
}
+func TestSessionsAPI_PreviewOriginErrorContract(t *testing.T) {
+ svc := newFakeSessionService()
+ workspace := t.TempDir()
+ if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte("preview"), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+ s := svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
+ svc.sessions["ao-1"] = s
+ srv := newSessionTestServer(t, svc)
+ validURL := mustPreviewFileURL(t, srv, "ao-1", "index.html")
+
+ tests := []struct {
+ name string
+ method string
+ previewURL string
+ path string
+ wantStatus int
+ wantCode string
+ wantAllow string
+ }{
+ {name: "method", method: http.MethodPost, previewURL: validURL, path: "/", wantStatus: http.StatusMethodNotAllowed, wantCode: "METHOD_NOT_ALLOWED", wantAllow: "GET, HEAD"},
+ {name: "unknown session", method: http.MethodGet, previewURL: mustPreviewFileURL(t, srv, "ao-missing", "index.html"), path: "/", wantStatus: http.StatusNotFound, wantCode: "SESSION_NOT_FOUND"},
+ {name: "missing asset", method: http.MethodGet, previewURL: validURL, path: "/missing.css", wantStatus: http.StatusNotFound, wantCode: "PREVIEW_FILE_NOT_FOUND"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ body, status, headers := doPreviewOriginMethod(t, srv, tc.method, tc.previewURL, tc.path)
+ if status != tc.wantStatus {
+ t.Fatalf("status = %d, want %d; body=%s", status, tc.wantStatus, body)
+ }
+ if got := headers.Get("Allow"); got != tc.wantAllow {
+ t.Fatalf("Allow = %q, want %q", got, tc.wantAllow)
+ }
+ var got struct {
+ Code string `json:"code"`
+ RequestID string `json:"requestId"`
+ }
+ mustJSON(t, body, &got)
+ if got.Code != tc.wantCode || got.RequestID == "" {
+ t.Fatalf("error = %#v, want code %q and requestId", got, tc.wantCode)
+ }
+ })
+ }
+
+ empty := t.TempDir()
+ s = svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{WorkspacePath: empty}
+ svc.sessions["ao-1"] = s
+ body, status, _ := doPreviewOriginRequest(t, srv, validURL, "/")
+ assertErrorCode(t, body, status, http.StatusNotFound, "NO_PREVIEW_ENTRY")
+}
+
+func TestSessionsAPI_PreviewRoutesRejectSymlinkOutsideWorkspace(t *testing.T) {
+ workspace := t.TempDir()
+ outside := filepath.Join(t.TempDir(), "secret.css")
+ if err := os.WriteFile(filepath.Join(workspace, "index.html"), []byte("preview"), 0o644); err != nil {
+ t.Fatalf("write index: %v", err)
+ }
+ if err := os.WriteFile(outside, []byte("must-not-leak"), 0o644); err != nil {
+ t.Fatalf("write outside file: %v", err)
+ }
+ link := filepath.Join(workspace, "escape.css")
+ if err := os.Symlink(outside, link); err != nil {
+ t.Skipf("symlinks unavailable on this platform: %v", err)
+ }
+
+ svc := newFakeSessionService()
+ s := svc.sessions["ao-1"]
+ s.Metadata = domain.SessionMetadata{WorkspacePath: workspace}
+ svc.sessions["ao-1"] = s
+ srv := newSessionTestServer(t, svc)
+ previewURL := mustPreviewFileURL(t, srv, "ao-1", "index.html")
+
+ for _, tc := range []struct {
+ name string
+ do func() ([]byte, int, http.Header)
+ }{
+ {name: "isolated origin", do: func() ([]byte, int, http.Header) {
+ return doPreviewOriginRequest(t, srv, previewURL, "/escape.css")
+ }},
+ {name: "legacy route", do: func() ([]byte, int, http.Header) {
+ return doRequest(t, srv, http.MethodGet, "/api/v1/sessions/ao-1/preview/files/escape.css", "")
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ body, status, _ := tc.do()
+ assertErrorCode(t, body, status, http.StatusNotFound, "PREVIEW_FILE_NOT_FOUND")
+ if strings.Contains(string(body), "must-not-leak") {
+ t.Fatalf("response leaked file outside workspace: %s", body)
+ }
+ })
+ }
+}
+
+func mustPreviewFileURL(t *testing.T, srv *httptest.Server, id domain.SessionID, entry string) string {
+ t.Helper()
+ raw, err := previewutil.FileURL(srv.URL, id, entry)
+ if err != nil {
+ t.Fatalf("FileURL: %v", err)
+ }
+ return raw
+}
+
func TestSessionsAPI_PreviewOriginsIsolateConcurrentSessionsAndSurviveRouterRestart(t *testing.T) {
svc := newFakeSessionService()
previewURLs := make(map[domain.SessionID]string)
diff --git a/backend/internal/httpd/server.go b/backend/internal/httpd/server.go
index 4dbbe93b1e..0a6f8d0a1a 100644
--- a/backend/internal/httpd/server.go
+++ b/backend/internal/httpd/server.go
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"sync"
- "syscall"
"time"
"github.com/aoagents/agent-orchestrator/backend/internal/config"
@@ -46,7 +45,7 @@ func NewWithDeps(cfg config.Config, log *slog.Logger, termMgr *terminal.Manager,
log = loggerOrDefault(log)
ln, err := net.Listen("tcp", cfg.Addr())
if err != nil {
- if !errors.Is(err, syscall.EADDRINUSE) {
+ if !isAddrInUse(err) {
return nil, fmt.Errorf("bind %s: %w", cfg.Addr(), err)
}
// Configured port is taken by a non-AO process: retry on an ephemeral port.
diff --git a/backend/internal/preview/entry.go b/backend/internal/preview/entry.go
index c0f3d9281a..05e62534d5 100644
--- a/backend/internal/preview/entry.go
+++ b/backend/internal/preview/entry.go
@@ -2,10 +2,11 @@ package preview
import (
"encoding/base32"
+ "errors"
+ "fmt"
"io/fs"
"net"
"net/url"
- "os"
"path"
"path/filepath"
"strings"
@@ -17,6 +18,10 @@ import (
const previewHostLabel = "ao-preview"
+// ErrPreviewHostUnsupported indicates that a session ID cannot be represented
+// by a standards-compliant localhost hostname.
+var ErrPreviewHostUnsupported = errors.New("session ID is too long for a preview hostname")
+
var entryCandidates = []string{"index.html", "public/index.html", "dist/index.html", "build/index.html"}
// previewableExts are the file extensions the browser panel can render: HTML
@@ -50,13 +55,8 @@ func DiscoverEntry(workspacePath string) (Entry, bool) {
return Entry{}, false
}
for _, candidate := range entryCandidates {
- file, ok := ConfinedPath(workspacePath, candidate)
- if !ok {
- continue
- }
- info, err := os.Stat(file)
- if err == nil && !info.IsDir() {
- return Entry{Path: candidate, AbsPath: file, ModTime: info.ModTime(), Size: info.Size()}, true
+ if entry, ok := EntryAtPath(workspacePath, candidate); ok {
+ return entry, true
}
}
return mostRecentPreviewable(workspacePath)
@@ -92,19 +92,18 @@ func mostRecentPreviewable(workspacePath string) (Entry, bool) {
if seen > maxPreviewWalkFiles {
return filepath.SkipAll
}
- info, err := d.Info()
- if err != nil {
- //nolint:nilerr // skip this file, keep scanning the rest of the workspace
- return nil
- }
rel, err := filepath.Rel(root, p)
if err != nil {
//nolint:nilerr // skip this file, keep scanning the rest of the workspace
return nil
}
relSlash := filepath.ToSlash(rel)
- if !found || newerPreviewable(info, relSlash, best) {
- best = Entry{Path: relSlash, AbsPath: p, ModTime: info.ModTime(), Size: info.Size()}
+ entry, ok := EntryAtPath(root, relSlash)
+ if !ok {
+ return nil
+ }
+ if !found || newerPreviewable(entry, relSlash, best) {
+ best = entry
found = true
}
return nil
@@ -112,8 +111,8 @@ func mostRecentPreviewable(workspacePath string) (Entry, bool) {
return best, found
}
-func newerPreviewable(info fs.FileInfo, relSlash string, best Entry) bool {
- mod := info.ModTime()
+func newerPreviewable(entry Entry, relSlash string, best Entry) bool {
+ mod := entry.ModTime
if mod.After(best.ModTime) {
return true
}
@@ -144,8 +143,11 @@ func ConfinedPath(workspacePath, assetPath string) (string, bool) {
if err != nil || root == "" {
return "", false
}
- clean := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(assetPath)), "/")
- if clean == "" || clean == "." {
+ clean, ok := CleanWorkspacePath(assetPath)
+ if !ok {
+ if path.Clean("/"+filepath.ToSlash(assetPath)) != "/" {
+ return "", false
+ }
clean = "index.html"
}
file := filepath.Join(root, filepath.FromSlash(clean))
@@ -164,16 +166,20 @@ func ConfinedPath(workspacePath, assetPath string) (string, bool) {
// Mounting the entry directory at the origin root makes both relative and
// root-relative browser requests resolve inside the preview instead of falling
// through to the daemon's API origin.
-func FileURL(baseURL string, id domain.SessionID, entry string) string {
+func FileURL(baseURL string, id domain.SessionID, entry string) (string, error) {
u := normalizedBaseURL(baseURL)
- u.Host = previewHost(u, id)
+ host, err := previewHost(u, id)
+ if err != nil {
+ return "", err
+ }
+ u.Host = host
// URL.String escapes Path exactly once. Supplying an already-escaped path
// here would turn spaces into %2520 and make otherwise valid workspace files
// impossible to resolve.
- u.Path = path.Clean("/" + strings.TrimSpace(entry))
+ u.Path = path.Clean("/" + entry)
u.RawQuery = ""
u.Fragment = ""
- return u.String()
+ return u.String(), nil
}
// SessionIDFromHost decodes the session identity carried by a FileURL host.
@@ -183,10 +189,19 @@ func SessionIDFromHost(rawHost string) (domain.SessionID, bool) {
if parsedHost, _, err := net.SplitHostPort(rawHost); err == nil {
host = parsedHost
}
- labels := strings.Split(strings.TrimSuffix(strings.ToLower(host), "."), ".")
+ host = strings.TrimSuffix(strings.ToLower(host), ".")
+ if host == "" || len(host) > 253 {
+ return "", false
+ }
+ labels := strings.Split(host, ".")
if len(labels) < 3 || labels[0] != previewHostLabel || labels[len(labels)-1] != "localhost" {
return "", false
}
+ for _, label := range labels {
+ if label == "" || len(label) > 63 {
+ return "", false
+ }
+ }
encoded := strings.Join(labels[1:len(labels)-1], "")
decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(encoded))
if err != nil || len(decoded) == 0 || !utf8.Valid(decoded) {
@@ -195,7 +210,10 @@ func SessionIDFromHost(rawHost string) (domain.SessionID, bool) {
return domain.SessionID(decoded), true
}
-func previewHost(u url.URL, id domain.SessionID) string {
+func previewHost(u url.URL, id domain.SessionID) (string, error) {
+ if id == "" {
+ return "", fmt.Errorf("%w: empty session ID", ErrPreviewHostUnsupported)
+ }
encoded := strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(id)))
labels := []string{previewHostLabel}
const maxChunk = 50
@@ -206,10 +224,13 @@ func previewHost(u url.URL, id domain.SessionID) string {
}
labels = append(labels, "localhost")
host := strings.Join(labels, ".")
+ if len(host) > 253 {
+ return "", fmt.Errorf("%w: encoded hostname is %d characters", ErrPreviewHostUnsupported, len(host))
+ }
if port := u.Port(); port != "" {
- return host + ":" + port
+ return host + ":" + port, nil
}
- return host
+ return host, nil
}
func normalizedBaseURL(raw string) url.URL {
diff --git a/backend/internal/preview/entry_test.go b/backend/internal/preview/entry_test.go
index 96eb02f27f..3e6400d731 100644
--- a/backend/internal/preview/entry_test.go
+++ b/backend/internal/preview/entry_test.go
@@ -1,6 +1,7 @@
package preview
import (
+ "errors"
"net/url"
"os"
"path/filepath"
@@ -109,6 +110,14 @@ func TestDiscoverEntryEmptyWorkspace(t *testing.T) {
}
}
+func TestCleanWorkspacePathRejectsTraversal(t *testing.T) {
+ for _, raw := range []string{"../secret.html", "assets/../../secret.html", `assets\..\..\secret.html`} {
+ if got, ok := CleanWorkspacePath(raw); ok {
+ t.Errorf("CleanWorkspacePath(%q) = %q, true; want false", raw, got)
+ }
+ }
+}
+
func TestIsMarkdownPath(t *testing.T) {
cases := map[string]bool{
"a.md": true,
@@ -127,7 +136,7 @@ func TestIsMarkdownPath(t *testing.T) {
func TestFileURLUsesIsolatedLocalhostOrigin(t *testing.T) {
id := domain.SessionID("ao-1")
- raw := FileURL("http://127.0.0.1:3001", id, "dist/index.html")
+ raw := mustFileURL(t, "http://127.0.0.1:3001", id, "dist/index.html")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse FileURL: %v", err)
@@ -149,7 +158,7 @@ func TestFileURLUsesIsolatedLocalhostOrigin(t *testing.T) {
func TestSessionIDFromHostSupportsLongUnicodeIDs(t *testing.T) {
id := domain.SessionID(strings.Repeat("worker-", 12) + "雪")
- raw := FileURL("http://localhost:4321", id, "index.html")
+ raw := mustFileURL(t, "http://localhost:4321", id, "index.html")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse FileURL: %v", err)
@@ -166,7 +175,7 @@ func TestSessionIDFromHostSupportsLongUnicodeIDs(t *testing.T) {
}
func TestFileURLPreservesSpecialCharactersInEntryPath(t *testing.T) {
- raw := FileURL("http://127.0.0.1:3001", "ao-1", "dist/my report #1.html")
+ raw := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "dist/my report #1.html")
parsed, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse FileURL: %v", err)
@@ -179,6 +188,72 @@ func TestFileURLPreservesSpecialCharactersInEntryPath(t *testing.T) {
}
}
+func TestFileURLPreservesLeadingAndTrailingSpacesInEntryPath(t *testing.T) {
+ raw := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "dist/ report.html ")
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse FileURL: %v", err)
+ }
+ if parsed.Path != "/dist/ report.html " {
+ t.Fatalf("FileURL path = %q, want exact filename spaces preserved", parsed.Path)
+ }
+}
+
+func TestFileURLRejectsHostnameOverDNSLimit(t *testing.T) {
+ accepted := domain.SessionID(strings.Repeat("x", 142))
+ raw := mustFileURL(t, "http://127.0.0.1:3001", accepted, "index.html")
+ parsed, err := url.Parse(raw)
+ if err != nil {
+ t.Fatalf("parse boundary FileURL: %v", err)
+ }
+ if len(parsed.Hostname()) != 253 {
+ t.Fatalf("boundary hostname length = %d, want 253", len(parsed.Hostname()))
+ }
+ if decoded, ok := SessionIDFromHost(parsed.Host); !ok || decoded != accepted {
+ t.Fatalf("boundary hostname round trip = %q, %v; want %q, true", decoded, ok, accepted)
+ }
+
+ _, err = FileURL("http://127.0.0.1:3001", domain.SessionID(strings.Repeat("x", 143)), "index.html")
+ if !errors.Is(err, ErrPreviewHostUnsupported) {
+ t.Fatalf("FileURL error = %v, want ErrPreviewHostUnsupported", err)
+ }
+}
+
+func TestSessionIDFromHostRejectsHostnameOverDNSLimit(t *testing.T) {
+ host := "ao-preview." + strings.Repeat("a.", 120) + "localhost:3001"
+ if id, ok := SessionIDFromHost(host); ok {
+ t.Fatalf("SessionIDFromHost(overlong) = %q, true; want false", id)
+ }
+}
+
+func TestStoredWorkspaceEntryPreservesLegacyAndRelativeTargets(t *testing.T) {
+ for _, tc := range []struct {
+ raw string
+ want string
+ }{
+ {raw: "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/docs/report.html", want: "docs/report.html"},
+ {raw: "docs/report.html", want: "docs/report.html"},
+ {raw: " docs/report.html ", want: " docs/report.html "},
+ } {
+ got, ok := StoredWorkspaceEntry(tc.raw, "ao-1")
+ if !ok || got != tc.want {
+ t.Errorf("StoredWorkspaceEntry(%q) = %q, %v; want %q, true", tc.raw, got, ok, tc.want)
+ }
+ }
+ if got, ok := StoredWorkspaceEntry("https://example.com/api/v1/sessions/ao-1/preview/files/docs/report.html", "ao-1"); ok {
+ t.Fatalf("external lookalike URL = %q, true; want false", got)
+ }
+}
+
+func mustFileURL(t *testing.T, baseURL string, id domain.SessionID, entry string) string {
+ t.Helper()
+ raw, err := FileURL(baseURL, id, entry)
+ if err != nil {
+ t.Fatalf("FileURL: %v", err)
+ }
+ return raw
+}
+
func TestSessionIDFromHostRejectsOrdinaryHosts(t *testing.T) {
for _, host := range []string{"127.0.0.1:3001", "localhost:3001", "ao-preview.invalid.localhost:3001", "example.com"} {
if id, ok := SessionIDFromHost(host); ok {
diff --git a/backend/internal/preview/poller.go b/backend/internal/preview/poller.go
index 8b8b13848f..782620dafe 100644
--- a/backend/internal/preview/poller.go
+++ b/backend/internal/preview/poller.go
@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"log/slog"
- "net/url"
- "path/filepath"
"strings"
"time"
@@ -114,9 +112,16 @@ func (p *Poller) Poll(ctx context.Context) error {
if sess.Kind != domain.KindWorker {
continue
}
- entry, ok := DiscoverEntry(sess.Metadata.WorkspacePath)
+ storedEntry, workspaceOwned := StoredWorkspaceEntry(sess.Metadata.PreviewURL, sess.ID)
+ entry, ok := Entry{}, false
+ if workspaceOwned {
+ entry, ok = EntryAtPath(sess.Metadata.WorkspacePath, storedEntry)
+ }
+ if !ok {
+ entry, ok = DiscoverEntry(sess.Metadata.WorkspacePath)
+ }
if !ok {
- if isWorkspacePreviewURL(sess.Metadata.PreviewURL, sess.ID) {
+ if workspaceOwned {
if _, err := p.setter.SetPreview(ctx, sess.ID, ""); err != nil {
p.logger.Error("preview poller: failed to clear stale preview",
"session", sess.ID, "err", err)
@@ -130,8 +135,13 @@ func (p *Poller) Poll(ctx context.Context) error {
if seenBefore && previous == state {
continue
}
- target := FileURL(p.baseURL, sess.ID, entry.Path)
- if !p.shouldRefresh(sess, target, seenBefore) {
+ target, err := FileURL(p.baseURL, sess.ID, entry.Path)
+ if err != nil {
+ p.logger.Error("preview poller: cannot build isolated preview URL", "session", sess.ID, "err", err)
+ p.seen[sess.ID] = state
+ continue
+ }
+ if !p.shouldRefresh(sess, target, seenBefore, workspaceOwned) {
p.seen[sess.ID] = state
continue
}
@@ -148,7 +158,7 @@ func (p *Poller) Poll(ctx context.Context) error {
return nil
}
-func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore bool) bool {
+func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBefore, workspaceOwned bool) bool {
current := strings.TrimSpace(sess.Metadata.PreviewURL)
if current == "" {
if !seenBefore {
@@ -157,40 +167,12 @@ func (p *Poller) shouldRefresh(sess domain.SessionRecord, target string, seenBef
previous := p.seen[sess.ID]
return previous.cleared
}
- if current == target || isWorkspacePreviewURL(current, sess.ID) {
- return true
+ if current == target {
+ return seenBefore
}
- return isStaleWorkspacePath(current)
+ return workspaceOwned
}
func stateFor(entry Entry) entryState {
return entryState{path: entry.Path, modUnix: entry.ModTime.UnixNano(), size: entry.Size}
}
-
-func isWorkspacePreviewURL(raw string, id domain.SessionID) bool {
- parsed, err := url.Parse(strings.TrimSpace(raw))
- if err != nil {
- return false
- }
- if originID, ok := SessionIDFromHost(parsed.Host); ok {
- return originID == id
- }
- previewPath := parsed.Path
- if previewPath == "" {
- previewPath = raw
- }
- prefix := "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/"
- return strings.HasPrefix(previewPath, prefix)
-}
-
-func isStaleWorkspacePath(raw string) bool {
- raw = strings.TrimSpace(raw)
- if raw == "" || strings.Contains(raw, "://") || filepath.IsAbs(raw) || isWindowsAbs(raw) {
- return false
- }
- return !strings.Contains(raw, ":")
-}
-
-func isWindowsAbs(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] == '/')
-}
diff --git a/backend/internal/preview/poller_test.go b/backend/internal/preview/poller_test.go
index d5226a2d85..68455ecf9c 100644
--- a/backend/internal/preview/poller_test.go
+++ b/backend/internal/preview/poller_test.go
@@ -50,7 +50,7 @@ func TestPollerSetsPreviewWhenActiveWorkerEntryAppears(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"),
+ url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "index.html"),
})
}
@@ -66,7 +66,7 @@ func TestPollerUsesFirstExistingEntrypoint(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: FileURL("http://127.0.0.1:3001", "ao-1", "dist/index.html"),
+ url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "dist/index.html"),
})
}
@@ -83,7 +83,7 @@ func TestPollerPreservesEntrypointPriority(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: FileURL("http://127.0.0.1:3001", "ao-1", "public/index.html"),
+ url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "public/index.html"),
})
}
@@ -129,7 +129,7 @@ func TestPollerRediscoverEntryAfterDeleteAndRecreate(t *testing.T) {
if err := poller.Poll(context.Background()); err != nil {
t.Fatalf("first Poll: %v", err)
}
- wantURL := FileURL("http://127.0.0.1:3001", "ao-1", "index.html")
+ wantURL := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "index.html")
assertSets(t, svc.sets, previewSet{id: "ao-1", url: wantURL})
// Delete the entry — poller must clear the preview and mark the session cleared.
@@ -194,7 +194,8 @@ func TestPollerDoesNotOverrideExplicitPreviewTarget(t *testing.T) {
func TestPollerMigratesLegacyWorkspacePreviewURL(t *testing.T) {
workspace := t.TempDir()
writeFile(t, filepath.Join(workspace, "index.html"), "hello")
- legacy := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/index.html"
+ writeFile(t, filepath.Join(workspace, "docs", "report.html"), "chosen report")
+ legacy := "http://127.0.0.1:3001/api/v1/sessions/ao-1/preview/files/docs/report.html"
svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, legacy)}}
poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
@@ -204,7 +205,42 @@ func TestPollerMigratesLegacyWorkspacePreviewURL(t *testing.T) {
assertSets(t, svc.sets, previewSet{
id: "ao-1",
- url: FileURL("http://127.0.0.1:3001", "ao-1", "index.html"),
+ url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "docs/report.html"),
+ })
+}
+
+func TestPollerPreservesStoredRelativeEntry(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "default")
+ writeFile(t, filepath.Join(workspace, "docs", "report.html"), "chosen report")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, "docs/report.html")}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:3001", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ assertSets(t, svc.sets, previewSet{
+ id: "ao-1",
+ url: mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "docs/report.html"),
+ })
+}
+
+func TestPollerRewritesStoredOriginToActualDaemonPortWithoutChangingEntry(t *testing.T) {
+ workspace := t.TempDir()
+ writeFile(t, filepath.Join(workspace, "index.html"), "default")
+ writeFile(t, filepath.Join(workspace, "docs", "report.html"), "chosen report")
+ old := mustFileURL(t, "http://127.0.0.1:3001", "ao-1", "docs/report.html")
+ svc := &fakePreviewSessions{sessions: []domain.SessionRecord{workerSession("ao-1", workspace, old)}}
+ poller := NewPoller(svc, svc, "http://127.0.0.1:49152", PollerConfig{Logger: discardLogger()})
+
+ if err := poller.Poll(context.Background()); err != nil {
+ t.Fatalf("Poll: %v", err)
+ }
+
+ assertSets(t, svc.sets, previewSet{
+ id: "ao-1",
+ url: mustFileURL(t, "http://127.0.0.1:49152", "ao-1", "docs/report.html"),
})
}
diff --git a/backend/internal/preview/stored_entry.go b/backend/internal/preview/stored_entry.go
new file mode 100644
index 0000000000..e3ead44455
--- /dev/null
+++ b/backend/internal/preview/stored_entry.go
@@ -0,0 +1,58 @@
+package preview
+
+import (
+ "net"
+ "net/url"
+ "path/filepath"
+ "strings"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
+)
+
+// StoredWorkspaceEntry extracts a workspace-relative entry from every preview
+// format persisted by released versions: isolated-origin URLs, legacy API
+// URLs, and plain relative paths.
+func StoredWorkspaceEntry(raw string, id domain.SessionID) (string, bool) {
+ if strings.TrimSpace(raw) == "" {
+ return "", false
+ }
+ parsed, err := url.Parse(raw)
+ if err == nil {
+ if originID, ok := SessionIDFromHost(parsed.Host); ok {
+ if originID != id {
+ return "", false
+ }
+ entry, err := url.PathUnescape(strings.TrimPrefix(parsed.EscapedPath(), "/"))
+ if err != nil {
+ return "", false
+ }
+ return CleanWorkspacePath(entry)
+ }
+
+ prefix := "/api/v1/sessions/" + url.PathEscape(string(id)) + "/preview/files/"
+ if isLegacyPreviewHost(parsed.Hostname()) && strings.HasPrefix(parsed.EscapedPath(), prefix) {
+ entry, err := url.PathUnescape(strings.TrimPrefix(parsed.EscapedPath(), prefix))
+ if err != nil {
+ return "", false
+ }
+ return CleanWorkspacePath(entry)
+ }
+ }
+
+ if strings.Contains(raw, "://") || filepath.IsAbs(raw) || isWindowsAbsolute(raw) || strings.Contains(raw, ":") {
+ return "", false
+ }
+ return CleanWorkspacePath(raw)
+}
+
+func isLegacyPreviewHost(host string) bool {
+ if host == "" || strings.EqualFold(host, "localhost") {
+ return true
+ }
+ ip := net.ParseIP(host)
+ return ip != nil && ip.IsLoopback()
+}
+
+func isWindowsAbsolute(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] == '/')
+}
diff --git a/backend/internal/preview/workspace_file.go b/backend/internal/preview/workspace_file.go
new file mode 100644
index 0000000000..3f3707739d
--- /dev/null
+++ b/backend/internal/preview/workspace_file.go
@@ -0,0 +1,70 @@
+package preview
+
+import (
+ "io/fs"
+ "os"
+ "path"
+ "path/filepath"
+ "strings"
+)
+
+// CleanWorkspacePath normalizes a browser/workspace path without discarding
+// meaningful whitespace from valid Unix filenames. The leading slash used by
+// browser requests is treated as the preview root, not as a host filesystem
+// root.
+func CleanWorkspacePath(raw string) (string, bool) {
+ raw = filepath.ToSlash(raw)
+ for _, segment := range strings.Split(raw, "/") {
+ if segment == ".." {
+ return "", false
+ }
+ }
+ clean := strings.TrimPrefix(path.Clean("/"+raw), "/")
+ if clean == "" || clean == "." {
+ return "", false
+ }
+ return clean, true
+}
+
+// OpenWorkspaceFile opens a regular file beneath workspacePath using os.Root.
+// os.Root follows symlinks that remain inside the workspace and rejects links
+// that escape it, so callers can safely serve the returned handle without a
+// second path lookup.
+func OpenWorkspaceFile(workspacePath, assetPath string) (*os.File, fs.FileInfo, string, error) {
+ clean, ok := CleanWorkspacePath(assetPath)
+ if !ok {
+ return nil, nil, "", fs.ErrNotExist
+ }
+ root, err := os.OpenRoot(workspacePath)
+ if err != nil {
+ return nil, nil, "", err
+ }
+ defer func() { _ = root.Close() }()
+
+ file, err := root.Open(filepath.FromSlash(clean))
+ if err != nil {
+ return nil, nil, "", err
+ }
+ info, err := file.Stat()
+ if err != nil {
+ _ = file.Close()
+ return nil, nil, "", err
+ }
+ if !info.Mode().IsRegular() {
+ _ = file.Close()
+ return nil, nil, "", fs.ErrNotExist
+ }
+ return file, info, clean, nil
+}
+
+// EntryAtPath resolves an existing regular workspace file with the same rooted
+// confinement used by the HTTP serving path.
+func EntryAtPath(workspacePath, assetPath string) (Entry, bool) {
+ file, info, clean, err := OpenWorkspaceFile(workspacePath, assetPath)
+ if err != nil {
+ return Entry{}, false
+ }
+ _ = file.Close()
+ absPath, _ := ConfinedPath(workspacePath, clean)
+ return Entry{Path: clean, AbsPath: absPath, ModTime: info.ModTime(), Size: info.Size()}, true
+}
From d59ff209293e61c6d2819f43b0077c4ee8e9e155 Mon Sep 17 00:00:00 2001
From: aprv10 <1apoorvindia@gmail.com>
Date: Mon, 20 Jul 2026 14:57:51 +0530
Subject: [PATCH 4/4] fix build errors
---
backend/internal/preview/workspace_file.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/internal/preview/workspace_file.go b/backend/internal/preview/workspace_file.go
index 3f3707739d..261263373d 100644
--- a/backend/internal/preview/workspace_file.go
+++ b/backend/internal/preview/workspace_file.go
@@ -13,7 +13,7 @@ import (
// browser requests is treated as the preview root, not as a host filesystem
// root.
func CleanWorkspacePath(raw string) (string, bool) {
- raw = filepath.ToSlash(raw)
+ raw = strings.ReplaceAll(raw, `\`, "/")
for _, segment := range strings.Split(raw, "/") {
if segment == ".." {
return "", false