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
17 changes: 8 additions & 9 deletions packages/app/src/components/collab/PreviewLauncher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,15 +205,14 @@ function RunningBanner(props: {
onRestart: () => void
busy: boolean
}) {
// Use the portless `/preview/` URL form — the proxy's parsePreviewPath
// routes it to the active preview's port automatically (commit b313df846
// on opencode-collab). Frontend repos that ship `.opencode-preview.json`
// with a matching `<base href="/preview/">` (e.g. Angular CLI with the
// `preview` build configuration) rely on this URL form so their internal
// routing + asset URLs resolve correctly. Falling back to the explicit
// `/preview/<port>/` form would still work for legacy clients but is
// less consistent with what the SPA's index.html expects.
const url = () => `/preview/`
// Server-authoritative preview URL. The server computes it
// (preview-host.ts → previewUrl()): an absolute
// `https://preview.collab…/` when a dedicated preview host is configured
// (root serve, base href "/"), else the legacy portless `/preview/`. We
// link to it verbatim so the SPA never hard-codes the host. Fallback to
// `/preview/` only for older servers whose snapshot predates the `url`
// field.
const url = () => props.state.url ?? `/preview/`
return (
<div class="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 space-y-1.5">
<div class="flex items-center gap-2 text-xs text-emerald-200">
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/context/collab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ export interface PreviewStateSnapshot {
lastTraffic: number
recentLog: ReadonlyArray<{ stream: "stdout" | "stderr"; line: string; ts: number }>
errorMessage?: string
/** Absolute URL to open the preview (server-computed): the dedicated
* preview-host root when configured, else the legacy `/preview/`.
* Optional for resilience against older servers. */
url?: string
}

const CollabContext = createContext<CollabContextValue>()
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/pages/collab/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -756,10 +756,10 @@ function CollabSessionInner(props: { me: Me }) {
<For each={collab.previewPorts()}>
{(port) => (
<a
href={`/preview/`}
href={collab.previewState()?.url ?? `/preview/`}
target="_blank"
rel="noreferrer"
title={`Open live preview (proxied to port ${port} via /preview/, the portless URL form)`}
title={`Open live preview (served at ${collab.previewState()?.url ?? "/preview/"})`}
class="flex items-center gap-1 text-[10px] text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 px-1.5 py-0.5 rounded-full border border-emerald-500/30 transition-colors"
>
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Expand Down
4 changes: 4 additions & 0 deletions packages/collab/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
11 changes: 11 additions & 0 deletions packages/opencode/src/collab/cookie-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down
42 changes: 42 additions & 0 deletions packages/opencode/src/collab/preview-host.ts
Original file line number Diff line number Diff line change
@@ -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/"
}
12 changes: 12 additions & 0 deletions packages/opencode/src/collab/preview-launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -342,6 +348,7 @@ export function getPreviewState(): PreviewStateSnapshot | null {
lastTraffic: active.lastTraffic,
recentLog: active._log.slice(-LOG_LINES_RETAINED),
errorMessage: active.errorMessage,
url: previewUrl(),
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -687,6 +698,7 @@ export function restartPreview(): LaunchResult {
startedAt: Date.now(),
lastTraffic: Date.now(),
recentLog: [],
url: previewUrl(),
}

stopPreview("restart")
Expand Down
18 changes: 17 additions & 1 deletion packages/opencode/src/collab/preview-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/"

Expand Down Expand Up @@ -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/<port>/…` 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.
Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/collab/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}
}

Expand Down
112 changes: 91 additions & 21 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 =
`<!doctype html><meta charset="utf-8"><title>No preview running</title>` +
`<body style="font:14px system-ui;max-width:40rem;margin:4rem auto;padding:0 1rem;color:#27272a">` +
`<h1 style="font-size:1.1rem">No preview is currently running</h1>` +
`<p>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.</p></body>`

/**
* 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<Uint8Array>,
() => 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.
Expand All @@ -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
Expand All @@ -53,9 +122,26 @@ const collabMiddleware: HttpMiddleware.HttpMiddleware = (app) =>
return yield* Effect.sync(() => HttpServerResponse.fromWeb(serveCollabLanding(webRequest)))
}

// /preview/<port>/<rest> — 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 `<port>/` 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/<port>/<rest>` 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)
Expand All @@ -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<Uint8Array>,
() => 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
Expand Down
Loading