diff --git a/packages/collab/src/types.ts b/packages/collab/src/types.ts
index b3830cb16c57..85557b166734 100644
--- a/packages/collab/src/types.ts
+++ b/packages/collab/src/types.ts
@@ -215,6 +215,10 @@ export type CollabEvent =
lastTraffic: number
recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }>
errorMessage?: string
+ /** Absolute URL to open the preview — dedicated preview-host root
+ * (`https://preview.…/`) when configured, else legacy `/preview/`.
+ * Computed server-side; the SPA links to it verbatim. */
+ url: string
}
}
| { type: "collab:preview_stopped"; collabSessionId: string }
diff --git a/packages/opencode/src/collab/cookie-auth.ts b/packages/opencode/src/collab/cookie-auth.ts
index 109a36d69974..d317d27bbfa8 100644
--- a/packages/opencode/src/collab/cookie-auth.ts
+++ b/packages/opencode/src/collab/cookie-auth.ts
@@ -28,6 +28,7 @@ import {
CollabRepoTable,
} from "./schema.sql"
import { decryptToken, isEncrypted } from "./crypto"
+import { previewHost } from "./preview-host"
// NOTE: the auth gate only needs the cookie holder's identity
// (github_id, github_login) for the participation check. The encrypted
@@ -319,6 +320,16 @@ export function cookieAuthorizesRequest(req: Request): CookieAuthDecision {
const url = new URL(req.url, "http://localhost")
+ // Rule (a0): the dedicated preview host. When the request arrived on
+ // `preview.collab…`, the ENTIRE host is the single active preview served
+ // at root — every path is a preview asset. Same shell-trust model as the
+ // legacy `/preview/*` path (ADR-0001): a valid cookie alone is enough, no
+ // scope check. Host comes off the Host header (url above is normalised to
+ // localhost and can't carry it).
+ const reqHost = (req.headers.get("host") ?? "").toLowerCase().split(":")[0]
+ const ph = previewHost()
+ if (ph && reqHost === ph) return "allow"
+
// Rule (a): cookie-only paths (no scope check needed).
if (cookieAllowedWithoutScope(url.pathname)) return "allow"
diff --git a/packages/opencode/src/collab/preview-host.ts b/packages/opencode/src/collab/preview-host.ts
new file mode 100644
index 000000000000..58e239c9e257
--- /dev/null
+++ b/packages/opencode/src/collab/preview-host.ts
@@ -0,0 +1,42 @@
+/**
+ * Resolution of the dedicated host the single active preview is served at.
+ *
+ * The collab control-plane SPA owns `/` on the main collab host, so the
+ * frontend preview can't take that root — it gets its own host
+ * (e.g. `preview.collab.utils.unleashlive.com`) and is served there at root
+ * (base href "/"), byte-for-byte like a develop-style serve, with no
+ * `/preview/` prefix rewriting.
+ *
+ * Leaf module — imports nothing else in collab/, so server.ts, cookie-auth.ts,
+ * preview-router.ts and preview-launcher.ts can all import it without the
+ * import cycles that would arise from hanging this off preview-router (which
+ * imports from cookie-auth) or preview-launcher.
+ */
+
+/**
+ * The preview host, lower-cased + port-stripped for direct comparison against
+ * an incoming request's `Host` header. Resolution order:
+ *
+ * 1. `COLLAB_PREVIEW_HOST` env — explicit override.
+ * 2. `preview.${COLLAB_DOMAIN}` — derived from the main collab domain.
+ * 3. `null` — no preview host configured (local dev): callers fall back to
+ * the legacy path-based `/preview/` serving.
+ */
+export function previewHost(): string | null {
+ const explicit = process.env["COLLAB_PREVIEW_HOST"]
+ if (explicit && explicit.trim()) return explicit.trim().toLowerCase().split(":")[0]!
+ const domain = process.env["COLLAB_DOMAIN"]
+ if (domain && domain.trim()) return `preview.${domain.trim().toLowerCase().split(":")[0]!}`
+ return null
+}
+
+/**
+ * Absolute URL the SPA links to for opening the preview. Root of the
+ * dedicated preview host when one is configured, else the legacy portless
+ * `/preview/` path (relative — resolved against the current origin by the
+ * browser).
+ */
+export function previewUrl(): string {
+ const host = previewHost()
+ return host ? `https://${host}/` : "/preview/"
+}
diff --git a/packages/opencode/src/collab/preview-launcher.ts b/packages/opencode/src/collab/preview-launcher.ts
index f9b348e41aa8..6332c30cb734 100644
--- a/packages/opencode/src/collab/preview-launcher.ts
+++ b/packages/opencode/src/collab/preview-launcher.ts
@@ -37,6 +37,7 @@ import { spawn, type ChildProcess } from "child_process"
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { repoWorkspacePath } from "./workspace"
+import { previewUrl } from "./preview-host"
import type { CollabEvent } from "@opencode-ai/collab"
// ── Configuration ──────────────────────────────────────────────────────────
@@ -160,6 +161,11 @@ export interface PreviewStateSnapshot {
/** Last N lines of combined stdout+stderr — for the install/run UI. */
readonly recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }>
readonly errorMessage?: string
+ /** Absolute URL the SPA should link to for opening this preview.
+ * `https://${previewHost()}/` when a dedicated preview host is configured
+ * (root serve), else the legacy portless `/preview/` path. Computed
+ * server-side so the SPA never hard-codes the host. */
+ readonly url: string
}
interface ActiveState extends PreviewStateSnapshot {
@@ -342,6 +348,7 @@ export function getPreviewState(): PreviewStateSnapshot | null {
lastTraffic: active.lastTraffic,
recentLog: active._log.slice(-LOG_LINES_RETAINED),
errorMessage: active.errorMessage,
+ url: previewUrl(),
}
}
@@ -553,6 +560,10 @@ export function launchPreview(
_log: [],
recentLog: [],
errorMessage: undefined,
+ // Snapshot URL (server-computed). Required on PreviewStateSnapshot, so
+ // ActiveState (which extends it) must carry it too; getPreviewState()
+ // re-derives the same stable, env-based value when it builds a snapshot.
+ url: previewUrl(),
child,
config,
_stopRequested: false,
@@ -687,6 +698,7 @@ export function restartPreview(): LaunchResult {
startedAt: Date.now(),
lastTraffic: Date.now(),
recentLog: [],
+ url: previewUrl(),
}
stopPreview("restart")
diff --git a/packages/opencode/src/collab/preview-router.ts b/packages/opencode/src/collab/preview-router.ts
index 62d2ca9dcec3..2e23d278ef9d 100644
--- a/packages/opencode/src/collab/preview-router.ts
+++ b/packages/opencode/src/collab/preview-router.ts
@@ -27,6 +27,7 @@ import type { IncomingMessage } from "node:http"
import type { Socket } from "node:net"
import { lookupCookieIdentityFromHeaders } from "./cookie-auth"
import { getActiveUpstreamScheme, getActivePreviewPort, getActiveServePath } from "./preview-launcher"
+import { previewHost } from "./preview-host"
const PREVIEW_PREFIX = "/preview/"
@@ -295,7 +296,22 @@ export function attachPreviewUpgrade(server: {
server.on("upgrade", (req, clientSocket, head) => {
const url = req.url ?? "/"
const pathname = url.split("?", 1)[0]!
- const parsed = parsePreviewPath(pathname)
+
+ // Two ways an upgrade is "ours":
+ // 1. Host-based — the request arrived on the dedicated preview host
+ // (preview.collab…). The WHOLE path is the preview; route the full
+ // pathname to the active preview's port at root.
+ // 2. Path-based — legacy `/preview//…` on the main host (local
+ // dev / fallback when no preview host is configured).
+ const reqHost = ((req.headers["host"] as string | undefined) ?? "").toLowerCase().split(":")[0]
+ const ph = previewHost()
+ let parsed: { port: number; rest: string } | null
+ if (ph && reqHost === ph) {
+ const activePort = getActivePreviewPort()
+ parsed = activePort === null ? null : { port: activePort, rest: pathname || "/" }
+ } else {
+ parsed = parsePreviewPath(pathname)
+ }
if (!parsed) {
// Not ours — leave the socket alone so other upgrade listeners (e.g.
// opencode's own WebSocket routes) can claim it.
diff --git a/packages/opencode/src/collab/router.ts b/packages/opencode/src/collab/router.ts
index 0cc8bea4a8e2..6676079f819c 100644
--- a/packages/opencode/src/collab/router.ts
+++ b/packages/opencode/src/collab/router.ts
@@ -604,9 +604,21 @@ function setSession(session: CookieSession): { token: string; header: string } {
expires_at: expiresAt,
}).run()
})
+ // Scope the cookie to the collab subdomain tree so the dedicated preview
+ // host (preview.${COLLAB_DOMAIN}) receives it and the preview's root serve
+ // is authenticated by the same session — WITHOUT widening to the whole
+ // utils.unleashlive.com (which would leak the session token to unrelated
+ // sibling services). `Domain=.collab.utils.unleashlive.com` covers the
+ // apex collab host AND every *.collab.utils.unleashlive.com. Omit the
+ // attribute entirely when COLLAB_DOMAIN is unset (local dev) → host-only,
+ // unchanged behaviour. SameSite=Lax is fine: the preview opens as a
+ // top-level new-tab navigation and subdomains of unleashlive.com are
+ // same-site regardless.
+ const collabDomain = process.env["COLLAB_DOMAIN"]?.trim().toLowerCase().split(":")[0]
+ const domainAttr = collabDomain ? `Domain=.${collabDomain}; ` : ""
return {
token,
- header: `collab_sid=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_TTL_SECONDS}`,
+ header: `collab_sid=${token}; ${domainAttr}Path=/; HttpOnly; SameSite=Lax; Max-Age=${COOKIE_TTL_SECONDS}`,
}
}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 169182ea222c..87d2fab11aff 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -3,7 +3,8 @@ import "./init-projectors"
import { handleCollabRequest } from "@/collab/router"
import { parsePreviewPath, handlePreviewHttp, attachPreviewUpgrade } from "@/collab/preview-router"
import { cookieAuthorizesRequest, lookupCookieIdentity } from "@/collab/cookie-auth"
-import { markPreviewTraffic } from "@/collab/preview-launcher"
+import { markPreviewTraffic, getActivePreviewPort } from "@/collab/preview-launcher"
+import { previewHost } from "@/collab/preview-host"
import { Database } from "@/storage/db"
import { NodeHttpServer } from "@effect/platform-node"
import * as Log from "@opencode-ai/core/util/log"
@@ -28,10 +29,45 @@ const serverStartedAt = Date.now()
// route can serve index.html for them. Bridges the standard Web Request/Response
// API used by the collab router into Effect's HttpServerRequest/HttpServerResponse.
+// Friendly page shown at the preview host when nothing is running yet.
+const NO_PREVIEW_HTML =
+ `No preview running` +
+ `` +
+ `No preview is currently running
` +
+ `A collab Driver needs to launch the dev-server preview from the session sidebar. ` +
+ `Once it's up, this host serves it at the root.
`
+
+/**
+ * Convert the proxied upstream `Response` from handlePreviewHttp into an
+ * Effect HttpServerResponse, streaming the body without buffering (video,
+ * large downloads, SSE all work). Shared by the dedicated-preview-host
+ * branch and the legacy `/preview/` path branch.
+ */
+function previewServerResponse(webResponse: Response) {
+ const previewHeaders = new Headers(webResponse.headers)
+ if (webResponse.body) {
+ const previewStream = Stream.fromAsyncIterable(
+ webResponse.body as AsyncIterable,
+ () => new Error("Preview stream error"),
+ )
+ return HttpServerResponse.stream(previewStream, {
+ status: webResponse.status,
+ headers: previewHeaders,
+ })
+ }
+ return HttpServerResponse.raw(new Uint8Array(), {
+ status: webResponse.status,
+ headers: previewHeaders,
+ })
+}
+
const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) =>
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
const pathname = new URL(req.url, "http://localhost").pathname
+ // Lower-cased, port-stripped Host for the dedicated-preview-host routing
+ // below. Effect's HttpServerRequest exposes the raw header map.
+ const host = (req.headers["host"] ?? "").toLowerCase().split(":")[0]
// /healthz — ALB / ECS health probe. Sits ahead of every other route so
// a degraded collab/preview path can't fail the liveness check on its own.
@@ -41,6 +77,39 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) =>
return yield* serveHealthz()
}
+ // Dedicated preview host — root serve. When the request arrived on
+ // `preview.collab…` the ENTIRE host is the single active preview served
+ // at root (base href "/"), byte-for-byte like a develop serve. This
+ // MUST come before the `/`→/collab/new landing below, since on the
+ // preview host `/` is the preview's index.html, not the collab landing.
+ // (/healthz is handled above this, so ALB health checks are unaffected
+ // regardless of Host.)
+ const ph = previewHost()
+ if (ph && host === ph) {
+ const webRequest = yield* HttpServerRequest.toWeb(req)
+ // Same shell-trust gate as the legacy path (ADR-0001): a valid collab
+ // cookie is enough. cookieAuthorizesRequest now allows when the Host
+ // is the preview host (see cookie-auth.ts rule a0).
+ if (cookieAuthorizesRequest(webRequest) !== "allow") {
+ return HttpServerResponse.raw(new TextEncoder().encode("Forbidden"), {
+ status: 403,
+ headers: new Headers({ "content-type": "text/plain" }),
+ })
+ }
+ markPreviewTraffic()
+ const port = getActivePreviewPort()
+ if (port === null) {
+ return HttpServerResponse.raw(new TextEncoder().encode(NO_PREVIEW_HTML), {
+ status: 200,
+ headers: new Headers({ "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }),
+ })
+ }
+ // Root serve: forward the WHOLE pathname (servePath is null now that the
+ // frontend builds at base href "/"), so /assets/x.js → /assets/x.js.
+ const webResponse = yield* Effect.promise(() => handlePreviewHttp(webRequest, port, pathname || "/"))
+ return previewServerResponse(webResponse)
+ }
+
// GET / and GET /collab — collab landing. Authenticated users are
// bounced to /collab/new (which lists their existing sessions in the
// sidebar + shows the create form). Unauthenticated users get a small
@@ -53,9 +122,26 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) =>
return yield* Effect.sync(() => HttpServerResponse.fromWeb(serveCollabLanding(webRequest)))
}
- // /preview// — HTTP reverse proxy to a dev server running
- // inside this container. WebSocket upgrades for the same path are
- // handled separately by attachPreviewUpgrade on the http.Server.
+ // Legacy `/preview/...` on the MAIN host, when a dedicated preview host
+ // IS configured → 301 to the subdomain root so old links + bookmarks keep
+ // working and there's one canonical preview origin. Done by prefix (not
+ // parsePreviewPath) so the redirect fires even when no preview is active
+ // (parsePreviewPath's portless form returns null then). Strip an optional
+ // leading `/` segment so the redirect preserves the deep path.
+ if (ph && pathname.startsWith("/preview/")) {
+ const afterPrefix = pathname.slice("/preview/".length)
+ const segs = afterPrefix.split("/")
+ const rest = /^\d+$/.test(segs[0] ?? "") ? "/" + segs.slice(1).join("/") : "/" + afterPrefix
+ const location = `https://${ph}${rest}${new URL(req.url, "http://localhost").search}`
+ return HttpServerResponse.raw(new Uint8Array(), {
+ status: 301,
+ headers: new Headers({ location, "cache-control": "no-store" }),
+ })
+ }
+
+ // Legacy `/preview//` path-based proxy — only the local-dev
+ // fallback now (no preview host configured). WebSocket upgrades are
+ // handled separately by attachPreviewUpgrade.
const previewParsed = parsePreviewPath(pathname)
if (previewParsed) {
const webRequest = yield* HttpServerRequest.toWeb(req)
@@ -77,23 +163,7 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) =>
const webResponse = yield* Effect.promise(() =>
handlePreviewHttp(webRequest, previewParsed.port, previewParsed.rest),
)
- const previewHeaders = new Headers(webResponse.headers)
- // The upstream body is a stream — let Effect pipe it through without
- // buffering, so video / large downloads / SSE all work.
- if (webResponse.body) {
- const previewStream = Stream.fromAsyncIterable(
- webResponse.body as AsyncIterable,
- () => new Error("Preview stream error"),
- )
- return HttpServerResponse.stream(previewStream, {
- status: webResponse.status,
- headers: previewHeaders,
- })
- }
- return HttpServerResponse.raw(new Uint8Array(), {
- status: webResponse.status,
- headers: previewHeaders,
- })
+ return previewServerResponse(webResponse)
}
// Only intercept collab API/auth/invite paths — let UI routes fall through to index.html