From 71ba7371bcafa3883dd7f60e039d12cc9a5a3d80 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sun, 21 Jun 2026 09:25:48 -0400
Subject: [PATCH 1/5] fix: allow saving edited PAT credential when reusing
existing token
---
frontend/src/components/settings/GitCredentialDialog.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/src/components/settings/GitCredentialDialog.tsx b/frontend/src/components/settings/GitCredentialDialog.tsx
index 5705d37b..27808292 100644
--- a/frontend/src/components/settings/GitCredentialDialog.tsx
+++ b/frontend/src/components/settings/GitCredentialDialog.tsx
@@ -97,7 +97,7 @@ export function GitCredentialDialog({ open, onOpenChange, onSave, credential, re
}
if (formData.type === 'pat') {
- if (!formData.token?.trim()) {
+ if (!formData.token?.trim() && !(credential?.token && !tokenEdited)) {
showToast.error('Token is required for PAT type')
return
}
From 9afd5f04d54fa14b0892c1be544ac70ff540229a Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sun, 21 Jun 2026 14:37:53 -0400
Subject: [PATCH 2/5] feat: add dev-server preview system with proxy, file
serving, and HTML artifact preview
Adds a full dev-server preview system including:
- Backend proxy/upgrade handler for dev server traffic
- File-serving backend route with CSP headers
- DevServerPreviewButton and HtmlArtifactPanel components
- HTML artifact detection and preview in messages
- File preview improvements with mobile support
- Shared types and settings schema for dev server config
- Comprehensive test coverage for all new code
---
backend/eslint.config.js | 1 +
backend/src/constants.ts | 57 +--
backend/src/db/queries.ts | 14 +
backend/src/default-agents.md | 56 +++
backend/src/index.ts | 9 +-
backend/src/routes/dev-proxy.ts | 152 ++++++++
backend/src/routes/dev-server.ts | 64 ++++
backend/src/routes/files.ts | 127 +++++++
.../src/routes/internal/git-credentials.ts | 14 -
backend/src/routes/internal/index.ts | 4 +-
backend/src/routes/internal/shell-env.ts | 18 +
backend/src/services/dev-server/manager.ts | 22 ++
backend/src/services/dev-server/ports.ts | 19 +
.../src/services/dev-server/proxy-utils.ts | 182 ++++++++++
.../services/dev-server/upgrade-handler.ts | 125 +++++++
backend/src/services/files.ts | 28 ++
.../src/services/opencode-gh-env-plugin.ts | 2 +-
backend/src/text-modules.d.ts | 4 +
backend/test/routes/dev-server.test.ts | 265 ++++++++++++++
backend/test/routes/files.test.ts | 147 ++++++++
.../routes/internal/git-credentials.test.ts | 40 ---
.../test/routes/internal/shell-env.test.ts | 48 +++
.../services/dev-server/proxy-utils.test.ts | 337 ++++++++++++++++++
.../dev-server/upgrade-handler.test.ts | 259 ++++++++++++++
.../services/opencode-gh-env-plugin.test.ts | 2 +-
backend/vitest.config.ts | 12 +
frontend/src/api/devServer.ts | 11 +
frontend/src/api/files.test.ts | 40 +++
frontend/src/api/files.ts | 9 +
frontend/src/api/types/settings.ts | 1 +
.../DevServerPreviewButton.test.tsx | 55 +++
.../dev-server/DevServerPreviewButton.tsx | 44 +++
.../components/file-browser/FileBrowser.tsx | 61 +++-
.../file-browser/FileBrowserSheet.test.tsx | 65 +++-
.../file-browser/FilePreview.test.tsx | 104 ++++++
.../components/file-browser/FilePreview.tsx | 38 +-
.../file-browser/MobileFilePreviewModal.tsx | 19 +-
.../html-preview/HtmlArtifactPanel.test.tsx | 273 ++++++++++++++
.../html-preview/HtmlArtifactPanel.tsx | 111 ++++++
.../html-preview/HtmlPreviewFrame.tsx | 26 ++
.../html-preview/PreviewHtmlButton.tsx | 21 ++
.../components/message/CodePreview.test.tsx | 111 ++++++
.../src/components/message/CodePreview.tsx | 24 +-
.../src/components/message/FileToolRender.tsx | 14 +-
.../src/components/message/MessagePart.tsx | 9 +-
.../src/components/message/MessageThread.tsx | 8 +
.../src/components/message/TextPart.test.tsx | 100 ++++++
frontend/src/components/message/TextPart.tsx | 28 +-
.../src/components/message/ToolCallPart.tsx | 10 +-
.../components/settings/GeneralSettings.tsx | 35 ++
.../settings/GitCredentialDialog.tsx | 2 +-
frontend/src/lib/htmlArtifacts.test.ts | 194 ++++++++++
frontend/src/lib/htmlArtifacts.ts | 82 +++++
frontend/src/pages/SessionDetail.tsx | 124 +++++--
shared/src/schemas/settings.ts | 3 +
shared/src/types/dev-server.ts | 15 +
shared/src/types/index.ts | 3 +
57 files changed, 3464 insertions(+), 184 deletions(-)
create mode 100644 backend/src/default-agents.md
create mode 100644 backend/src/routes/dev-proxy.ts
create mode 100644 backend/src/routes/dev-server.ts
delete mode 100644 backend/src/routes/internal/git-credentials.ts
create mode 100644 backend/src/routes/internal/shell-env.ts
create mode 100644 backend/src/services/dev-server/manager.ts
create mode 100644 backend/src/services/dev-server/ports.ts
create mode 100644 backend/src/services/dev-server/proxy-utils.ts
create mode 100644 backend/src/services/dev-server/upgrade-handler.ts
create mode 100644 backend/src/text-modules.d.ts
create mode 100644 backend/test/routes/dev-server.test.ts
delete mode 100644 backend/test/routes/internal/git-credentials.test.ts
create mode 100644 backend/test/routes/internal/shell-env.test.ts
create mode 100644 backend/test/services/dev-server/proxy-utils.test.ts
create mode 100644 backend/test/services/dev-server/upgrade-handler.test.ts
create mode 100644 frontend/src/api/devServer.ts
create mode 100644 frontend/src/api/files.test.ts
create mode 100644 frontend/src/components/dev-server/DevServerPreviewButton.test.tsx
create mode 100644 frontend/src/components/dev-server/DevServerPreviewButton.tsx
create mode 100644 frontend/src/components/file-browser/FilePreview.test.tsx
create mode 100644 frontend/src/components/html-preview/HtmlArtifactPanel.test.tsx
create mode 100644 frontend/src/components/html-preview/HtmlArtifactPanel.tsx
create mode 100644 frontend/src/components/html-preview/HtmlPreviewFrame.tsx
create mode 100644 frontend/src/components/html-preview/PreviewHtmlButton.tsx
create mode 100644 frontend/src/components/message/CodePreview.test.tsx
create mode 100644 frontend/src/components/message/TextPart.test.tsx
create mode 100644 frontend/src/lib/htmlArtifacts.test.ts
create mode 100644 frontend/src/lib/htmlArtifacts.ts
create mode 100644 shared/src/types/dev-server.ts
diff --git a/backend/eslint.config.js b/backend/eslint.config.js
index f0084db6..a2972da6 100644
--- a/backend/eslint.config.js
+++ b/backend/eslint.config.js
@@ -26,6 +26,7 @@ export default defineConfig([
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'error',
'no-useless-escape': 'warn',
+ 'no-empty': ['error', { allowEmptyCatch: true }],
},
},
{
diff --git a/backend/src/constants.ts b/backend/src/constants.ts
index ca4d348d..e2da00f4 100644
--- a/backend/src/constants.ts
+++ b/backend/src/constants.ts
@@ -1,56 +1,3 @@
-export const DEFAULT_AGENTS_MD = `# OpenCode Manager - Global Agent Instructions
+import defaultAgentsMd from './default-agents.md' with { type: 'text' }
-## Critical System Constraints
-
-- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager
-- **DO NOT** kill or stop processes on ports 5003 or 5551
-- **DO NOT** modify files in the \`.config/opencode\` directory unless explicitly requested
-
-## Dev Server Ports
-
-When starting dev servers, use the pre-allocated ports 5100-5103:
-- Port 5100: Primary dev server (frontend)
-- Port 5101: Secondary dev server (API/backend)
-- Port 5102: Additional service
-- Port 5103: Additional service
-
-Always bind to \`0.0.0.0\` to allow external access from the Docker host.
-
-## Package Management
-
-### Node.js Packages
-Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space:
-- Use \`pnpm install\` instead of \`npm install\`
-- Use \`bun install\` as an alternative
-- Both are pre-installed in the container
-
- ### Python Packages
- Always create a virtual environment in the repository directory before installing packages:
-
- 1. Create virtual environment in repo:
- \`cd \`\`
- \`uv venv .venv\`
-
- 2. Activate the virtual environment:
- \`source .venv/bin/activate\` # or \`uv pip sync\` for project-based workflows
-
- 3. Install packages into activated environment:
- \`uv pip install \`\`
- \`uv pip install -r requirements.txt\`
-
- 4. Run Python commands:
- \`python script.py\` # Uses activated .venv
-
- Alternative: Use \`uv run python script.py\` to skip explicit activation
-
- **Important:**
- - Always create .venv in the repository directory (not workspace root)
- - Activate the environment before running pip operations
- - uv is pre-installed in the container and provides faster package installation
- - .venv directories created in repos will persist but can be removed safely
-
-## General Guidelines
-
-- This file is merged with any AGENTS.md files in individual repositories
-- Repository-specific instructions take precedence for their respective codebases
-`
+export const DEFAULT_AGENTS_MD = defaultAgentsMd
diff --git a/backend/src/db/queries.ts b/backend/src/db/queries.ts
index 2a1e85fd..623696b2 100644
--- a/backend/src/db/queries.ts
+++ b/backend/src/db/queries.ts
@@ -3,6 +3,7 @@ import type { Repo, CreateRepoInput } from '../types/repo'
import { getReposPath } from '@opencode-manager/shared/config/env'
import { ASSISTANT_REPO_ID, ASSISTANT_REPO_PATH } from '@opencode-manager/shared/utils'
import { getErrorMessage } from '../utils/error-utils'
+import type { DevServerConfig } from '@opencode-manager/shared/types'
import path from 'path'
interface RepoRow {
@@ -331,6 +332,19 @@ export function updateRepoBranch(db: Database, id: number, branch: string): void
}
}
+const DEV_SERVER_INJECT_BASE_KEY = 'devServerInjectBase'
+
+export function getDevServerConfig(db: Database, repoId: number): DevServerConfig {
+ const injectBase = getRepoSetting(db, repoId, DEV_SERVER_INJECT_BASE_KEY)
+ return {
+ injectBase: injectBase === '1',
+ }
+}
+
+export function setDevServerConfig(db: Database, repoId: number, config: DevServerConfig): void {
+ setRepoSetting(db, repoId, DEV_SERVER_INJECT_BASE_KEY, config.injectBase ? '1' : null)
+}
+
export function deleteRepo(db: Database, id: number): void {
if (id === ASSISTANT_REPO_ID) {
return
diff --git a/backend/src/default-agents.md b/backend/src/default-agents.md
new file mode 100644
index 00000000..1b85361f
--- /dev/null
+++ b/backend/src/default-agents.md
@@ -0,0 +1,56 @@
+# OpenCode Manager - Global Agent Instructions
+
+## Critical System Constraints
+
+- **DO NOT** use ports 5003 or 5551 - these are reserved for OpenCode Manager
+- **DO NOT** kill or stop processes on ports 5003 or 5551
+- **DO NOT** modify files in the `.config/opencode` directory unless explicitly requested
+
+## Dev Server Port
+
+The preview feature checks the port provided in the `OCM_DEV_SERVER_PORT` environment variable. Always run the dev server on that port so it can be previewed.
+
+- Start the dev server on the port in `$OCM_DEV_SERVER_PORT` (e.g. `PORT=$OCM_DEV_SERVER_PORT ` or pass `--port $OCM_DEV_SERVER_PORT`).
+- Bind to `0.0.0.0` to allow external access from the Docker host.
+
+## Package Management
+
+### Node.js Packages
+
+Prefer **pnpm** or **bun** over npm for installing dependencies to save disk space:
+
+- Use `pnpm install` instead of `npm install`
+- Use `bun install` as an alternative
+- Both are pre-installed in the container
+
+### Python Packages
+
+Always create a virtual environment in the repository directory before installing packages:
+
+1. Create virtual environment in repo:
+ `cd `
+ `uv venv .venv`
+
+2. Activate the virtual environment:
+ `source .venv/bin/activate` # or `uv pip sync` for project-based workflows
+
+3. Install packages into activated environment:
+ `uv pip install `
+ `uv pip install -r requirements.txt`
+
+4. Run Python commands:
+ `python script.py` # Uses activated .venv
+
+Alternative: Use `uv run python script.py` to skip explicit activation
+
+**Important:**
+
+- Always create .venv in the repository directory (not workspace root)
+- Activate the environment before running pip operations
+- uv is pre-installed in the container and provides faster package installation
+- .venv directories created in repos will persist but can be removed safely
+
+## General Guidelines
+
+- This file is merged with any AGENTS.md files in individual repositories
+- Repository-specific instructions take precedence for their respective codebases
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 21dd615b..1423d989 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -37,6 +37,9 @@ import { createPromptTemplateRoutes } from './routes/prompt-templates'
import { createInternalRoutes } from './routes/internal'
import { sweepStaleUploadSessions } from './routes/internal/repo-mirror-helpers'
import { createOpenCodeProxyRoutes } from './routes/opencode-proxy'
+import { createDevProxyRoutes } from './routes/dev-proxy'
+import { createDevServerRoutes } from './routes/dev-server'
+import { createDevProxyUpgradeHandler } from './services/dev-server/upgrade-handler'
import { sseAggregator } from './services/sse-aggregator'
import { ensureDirectoryExists, writeFileContent, fileExists, readFileContent } from './services/file-operations'
import { SettingsService } from './services/settings'
@@ -342,6 +345,8 @@ protectedApi.route('/ssh', createSSHRoutes(gitAuthService))
protectedApi.route('/notifications', createNotificationRoutes(notificationService))
protectedApi.route('/prompt-templates', createPromptTemplateRoutes(db))
protectedApi.route('/schedules', createScheduleRoutes(scheduleService))
+protectedApi.route('/dev-server', createDevServerRoutes(db))
+protectedApi.route('/dev-proxy', createDevProxyRoutes(db))
app.route('/api', protectedApi)
@@ -461,10 +466,12 @@ const shutdown = async (signal: string) => {
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
-serve({
+const server = serve({
fetch: app.fetch,
port: PORT,
hostname: HOST,
})
+server.on('upgrade', createDevProxyUpgradeHandler(auth, db))
+
logger.info(`🚀 OpenCode WebUI API running on http://${HOST}:${PORT}`)
diff --git a/backend/src/routes/dev-proxy.ts b/backend/src/routes/dev-proxy.ts
new file mode 100644
index 00000000..20647f68
--- /dev/null
+++ b/backend/src/routes/dev-proxy.ts
@@ -0,0 +1,152 @@
+import { Hono, type Context } from 'hono'
+import type { Database } from 'bun:sqlite'
+import { getDevServerPort } from '../services/dev-server/manager'
+import {
+ parseDevProxyPath,
+ buildUpstreamUrl,
+ filterProxyHeaders,
+ sanitizeUpstreamResponseHeaders,
+ prepareTransformedResponseHeaders,
+ isWebSocketUpgrade,
+ injectBaseTag,
+ rewriteDevProxyCssPaths,
+ rewriteDevProxyHtmlPaths,
+ rewriteDevProxyJavaScriptPaths,
+ rewriteViteClientHmrBase,
+ DEV_PROXY_PREFIX,
+} from '../services/dev-server/proxy-utils'
+import { getDevServerConfig } from '../db/queries'
+
+function getNotRunningHtml(port: number): string {
+ return `
+
+Dev Server Not Running
+
+Dev Server Not Running
+No development server responded on localhost:${port}.
+Go back
+
+`
+}
+
+export function createDevProxyRoutes(db: Database): Hono {
+ const app = new Hono()
+
+ app.all('/:repoId', async (c) => {
+ return handleProxyRequest(c, db)
+ })
+
+ app.all('/:repoId/*', async (c) => {
+ return handleProxyRequest(c, db)
+ })
+
+ return app
+}
+
+async function handleProxyRequest(c: Context, db: Database): Promise {
+ const repoId = parseInt(c.req.param('repoId'), 10)
+ if (isNaN(repoId)) {
+ return c.json({ error: 'Invalid repoId' }, 400)
+ }
+
+ const config = getDevServerConfig(db, repoId)
+ const port = getDevServerPort(db)
+
+ if (isWebSocketUpgrade((key: string) => c.req.header(key))) {
+ return c.json({ error: 'WebSocket handled by upgrade listener' }, 426)
+ }
+
+ const url = new URL(c.req.url)
+ const parsed = parseDevProxyPath(url.pathname) ?? parseMountedDevProxyPath(url.pathname, repoId)
+ if (!parsed) {
+ return c.json({ error: 'Invalid proxy path' }, 400)
+ }
+
+ const upstreamUrl = buildUpstreamUrl(port, parsed.rest, url.search)
+
+ const filteredHeaders = filterProxyHeaders(c.req.raw.headers)
+ delete filteredHeaders['if-none-match']
+ delete filteredHeaders['if-modified-since']
+
+ let requestBody: RequestInit['body'] = undefined
+ if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
+ requestBody = c.req.raw.body
+ }
+
+ let upstreamResponse: Response
+ try {
+ upstreamResponse = await fetch(upstreamUrl, {
+ method: c.req.method,
+ headers: filteredHeaders,
+ body: requestBody,
+ redirect: 'manual',
+ duplex: 'half',
+ })
+ } catch {
+ return c.html(getNotRunningHtml(port), 503)
+ }
+
+ const sanitizedHeaders = sanitizeUpstreamResponseHeaders(upstreamResponse.headers)
+
+ const contentType = upstreamResponse.headers.get('content-type') ?? ''
+
+ if (contentType.includes('text/html')) {
+ const text = await upstreamResponse.text()
+ const basePath = `${DEV_PROXY_PREFIX}/${repoId}/`
+ const modified = config.injectBase ? injectBaseTag(text, basePath) : rewriteDevProxyHtmlPaths(text, basePath)
+ return new Response(modified, {
+ status: upstreamResponse.status,
+ statusText: upstreamResponse.statusText,
+ headers: prepareTransformedResponseHeaders(sanitizedHeaders),
+ })
+ }
+
+ if (isJavaScriptContentType(contentType)) {
+ const text = await upstreamResponse.text()
+ const basePath = `${DEV_PROXY_PREFIX}/${repoId}/`
+ const rewrittenPaths = rewriteDevProxyJavaScriptPaths(text, basePath)
+ const modified = isViteClientRequest(parsed.rest)
+ ? rewriteViteClientHmrBase(rewrittenPaths, basePath)
+ : rewrittenPaths
+ return new Response(modified, {
+ status: upstreamResponse.status,
+ statusText: upstreamResponse.statusText,
+ headers: prepareTransformedResponseHeaders(sanitizedHeaders),
+ })
+ }
+
+ if (isCssContentType(contentType)) {
+ const text = await upstreamResponse.text()
+ const modified = rewriteDevProxyCssPaths(text, `${DEV_PROXY_PREFIX}/${repoId}/`)
+ return new Response(modified, {
+ status: upstreamResponse.status,
+ statusText: upstreamResponse.statusText,
+ headers: prepareTransformedResponseHeaders(sanitizedHeaders),
+ })
+ }
+
+ return new Response(upstreamResponse.body, {
+ status: upstreamResponse.status,
+ statusText: upstreamResponse.statusText,
+ headers: sanitizedHeaders,
+ })
+}
+
+function isViteClientRequest(rest: string): boolean {
+ return rest === '/@vite/client'
+}
+
+function isJavaScriptContentType(contentType: string): boolean {
+ return contentType.includes('javascript') || contentType.includes('ecmascript')
+}
+
+function isCssContentType(contentType: string): boolean {
+ return contentType.includes('text/css')
+}
+
+function parseMountedDevProxyPath(pathname: string, repoId: number): { repoId: number; rest: string } | null {
+ const prefix = `/${repoId}`
+ if (pathname !== prefix && !pathname.startsWith(`${prefix}/`)) return null
+ const rest = pathname.slice(prefix.length)
+ return { repoId, rest: rest || '/' }
+}
diff --git a/backend/src/routes/dev-server.ts b/backend/src/routes/dev-server.ts
new file mode 100644
index 00000000..fa2a0f11
--- /dev/null
+++ b/backend/src/routes/dev-server.ts
@@ -0,0 +1,64 @@
+import { Hono } from 'hono'
+import type { Database } from 'bun:sqlite'
+import { z } from 'zod'
+import { getRepoById, getDevServerConfig, setDevServerConfig } from '../db/queries'
+import { getDevServerState } from '../services/dev-server/manager'
+
+const UpdateConfigSchema = z.object({
+ injectBase: z.boolean(),
+})
+
+export function createDevServerRoutes(db: Database): Hono {
+ const app = new Hono()
+
+ app.get('/:repoId/status', async (c) => {
+ const repoId = parseInt(c.req.param('repoId'), 10)
+ if (isNaN(repoId)) {
+ return c.json({ error: 'Invalid repoId' }, 400)
+ }
+
+ const repo = getRepoById(db, repoId)
+ if (!repo) {
+ return c.json({ error: 'Repository not found' }, 404)
+ }
+
+ const state = await getDevServerState(db, repo.id)
+
+ return c.json(state)
+ })
+
+ app.get('/:repoId/config', async (c) => {
+ const repoId = parseInt(c.req.param('repoId'), 10)
+ if (isNaN(repoId)) {
+ return c.json({ error: 'Invalid repoId' }, 400)
+ }
+
+ const config = getDevServerConfig(db, repoId)
+ return c.json(config)
+ })
+
+ app.put('/:repoId/config', async (c) => {
+ const repoId = parseInt(c.req.param('repoId'), 10)
+ if (isNaN(repoId)) {
+ return c.json({ error: 'Invalid repoId' }, 400)
+ }
+
+ let body: unknown
+ try {
+ body = await c.req.json()
+ } catch {
+ return c.json({ error: 'Invalid JSON body' }, 400)
+ }
+
+ const parsed = UpdateConfigSchema.safeParse(body)
+ if (!parsed.success) {
+ return c.json({ error: parsed.error.flatten() }, 400)
+ }
+
+ setDevServerConfig(db, repoId, parsed.data)
+ const saved = getDevServerConfig(db, repoId)
+ return c.json(saved)
+ })
+
+ return app
+}
diff --git a/backend/src/routes/files.ts b/backend/src/routes/files.ts
index 4b43f6e4..eb162372 100644
--- a/backend/src/routes/files.ts
+++ b/backend/src/routes/files.ts
@@ -23,12 +23,139 @@ function getSpecialRoutePathFromRequest(c: Context, routeName: string): string |
return match?.[1] ? decodeFilePath(match[1]) : undefined
}
+function getPreviewPathFromRequest(c: Context): string | undefined {
+ const path = c.req.path
+ const prefix = '/api/files/preview'
+
+ const queryPath = c.req.query('path')
+ if (queryPath !== undefined) {
+ return queryPath
+ }
+
+ if (path.startsWith(prefix + '/')) {
+ const previewPath = path.slice(prefix.length + 1)
+ return decodeURIComponent(previewPath)
+ }
+
+ return undefined
+}
+
+const PREVIEWABLE_MIME_TYPES = new Set([
+ 'text/html',
+ 'text/css',
+ 'text/javascript',
+ 'application/javascript',
+ 'application/json',
+ 'application/xml',
+ 'image/png',
+ 'image/jpeg',
+ 'image/gif',
+ 'image/svg+xml',
+])
+
+function isPreviewableMimeType(mimeType?: string): boolean {
+ return mimeType !== undefined && PREVIEWABLE_MIME_TYPES.has(mimeType)
+}
+
+function buildPreviewValidators(stat: { size: number; lastModified: Date }): { etag: string; lastModified: string } {
+ return {
+ etag: `"${stat.size.toString(16)}-${stat.lastModified.getTime().toString(16)}"`,
+ lastModified: stat.lastModified.toUTCString(),
+ }
+}
+
+function isPreviewFresh(c: Context, validators: { etag: string; lastModified: string }): boolean {
+ const ifNoneMatch = c.req.header('if-none-match')
+ if (ifNoneMatch !== undefined) {
+ return ifNoneMatch.split(',').some((tag: string) => tag.trim() === validators.etag)
+ }
+
+ const ifModifiedSince = c.req.header('if-modified-since')
+ if (ifModifiedSince !== undefined) {
+ const since = Date.parse(ifModifiedSince)
+ return !Number.isNaN(since) && Date.parse(validators.lastModified) <= since
+ }
+
+ return false
+}
+
+function getPreviewHeaders(result: {
+ name: string
+ size: number
+ mimeType?: string
+ lastModified: Date
+}): Record {
+ const isHtmlSvg = result.mimeType === 'text/html' || result.mimeType === 'image/svg+xml'
+ const validators = buildPreviewValidators(result)
+ const headers: Record = {
+ 'Content-Type': result.mimeType || 'application/octet-stream',
+ 'Content-Length': result.size.toString(),
+ 'Content-Disposition': `inline; filename="${result.name}"`,
+ 'X-Content-Type-Options': 'nosniff',
+ 'Referrer-Policy': 'no-referrer',
+ 'Cache-Control': 'private, must-revalidate, max-age=0',
+ 'ETag': validators.etag,
+ 'Last-Modified': validators.lastModified,
+ }
+
+ if (isHtmlSvg) {
+ headers['Content-Security-Policy'] = [
+ 'sandbox allow-scripts allow-same-origin;',
+ "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval';",
+ "script-src 'self' http: https: 'unsafe-inline' 'unsafe-eval';",
+ "style-src 'self' http: https: 'unsafe-inline';",
+ "img-src 'self' http: https: data: blob:;",
+ "font-src 'self' http: https: data:;",
+ "connect-src 'self' http: https:;",
+ "media-src 'self' http: https: data: blob:;",
+ "object-src 'none';",
+ "base-uri 'none';",
+ "frame-ancestors 'self'",
+ ].join(' ')
+ }
+
+ return headers
+}
+
export function createFileRoutes() {
const app = new Hono()
app.get('*', async (c) => {
const path = c.req.path
+ if (path === '/api/files/preview' || path.startsWith('/api/files/preview/')) {
+ const userPath = getPreviewPathFromRequest(c)
+
+ if (!userPath) {
+ return c.json({ error: 'No path provided' }, 400)
+ }
+
+ try {
+ const stat = await fileService.getFilePreviewStat(userPath)
+
+ if (stat.isDirectory) {
+ return c.json({ error: 'Cannot preview directories' }, 400)
+ }
+
+ if (!isPreviewableMimeType(stat.mimeType)) {
+ return c.json({ error: 'File type cannot be previewed' }, 415)
+ }
+
+ const headers = getPreviewHeaders(stat)
+
+ if (isPreviewFresh(c, buildPreviewValidators(stat))) {
+ return new Response(null, { status: 304, headers })
+ }
+
+ const content = await fileService.getRawFileContent(userPath)
+
+ return new Response(content, { headers })
+ } catch (error: unknown) {
+ logger.error('Failed to preview file:', error)
+ return c.json({ error: getErrorMessage(error) || 'Failed to preview file' }, getStatusCode(error) as ContentfulStatusCode)
+ }
+ }
+
if (path.endsWith('/download-zip')) {
const userPath = getSpecialRoutePathFromRequest(c, 'download-zip')
diff --git a/backend/src/routes/internal/git-credentials.ts b/backend/src/routes/internal/git-credentials.ts
deleted file mode 100644
index 53a1c470..00000000
--- a/backend/src/routes/internal/git-credentials.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { Hono } from 'hono'
-import type { Database } from 'bun:sqlite'
-import { CredentialProvider } from '../../services/credential-provider'
-
-export function createInternalGitCredentialsRoutes(db: Database) {
- const app = new Hono()
-
- app.get('/gh-env', (c) => {
- const provider = new CredentialProvider(db)
- return c.json(provider.getGhCliEnv({ cwd: c.req.query('cwd') }))
- })
-
- return app
-}
diff --git a/backend/src/routes/internal/index.ts b/backend/src/routes/internal/index.ts
index 590ef337..a3200857 100644
--- a/backend/src/routes/internal/index.ts
+++ b/backend/src/routes/internal/index.ts
@@ -13,7 +13,7 @@ import { createInternalRepoSyncRoutes } from './repo-sync'
import { createInternalRepoMirrorRoutes as mirrorRoutes } from './repo-mirror'
import { createInternalOpenCodeWorkspacesRoutes } from './opencode-workspaces'
import { createInternalAssistantRoutes } from './assistant'
-import { createInternalGitCredentialsRoutes } from './git-credentials'
+import { createInternalShellEnvRoutes } from './shell-env'
export function createInternalRoutes(
db: Database,
@@ -35,6 +35,6 @@ export function createInternalRoutes(
app.route('/repos', repos)
app.route('/opencode-workspaces', createInternalOpenCodeWorkspacesRoutes(db))
app.route('/assistant', createInternalAssistantRoutes(openCodeClient))
- app.route('/git-credentials', createInternalGitCredentialsRoutes(db))
+ app.route('/shell-env', createInternalShellEnvRoutes(db))
return app
}
diff --git a/backend/src/routes/internal/shell-env.ts b/backend/src/routes/internal/shell-env.ts
new file mode 100644
index 00000000..19b4d887
--- /dev/null
+++ b/backend/src/routes/internal/shell-env.ts
@@ -0,0 +1,18 @@
+import { Hono } from 'hono'
+import type { Database } from 'bun:sqlite'
+import { CredentialProvider } from '../../services/credential-provider'
+import { getDevServerPort } from '../../services/dev-server/manager'
+
+export function createInternalShellEnvRoutes(db: Database) {
+ const app = new Hono()
+
+ app.get('/', (c) => {
+ const provider = new CredentialProvider(db)
+ return c.json({
+ ...provider.getGhCliEnv({ cwd: c.req.query('cwd') }),
+ OCM_DEV_SERVER_PORT: String(getDevServerPort(db)),
+ })
+ })
+
+ return app
+}
diff --git a/backend/src/services/dev-server/manager.ts b/backend/src/services/dev-server/manager.ts
new file mode 100644
index 00000000..2a0721a3
--- /dev/null
+++ b/backend/src/services/dev-server/manager.ts
@@ -0,0 +1,22 @@
+import type { Database } from 'bun:sqlite'
+import { DEFAULT_DEV_SERVER_PORT, type DevServerState } from '@opencode-manager/shared/types'
+import { SettingsService } from '../settings'
+import { isPortOpen } from './ports'
+
+export function getDevServerPort(db: Database): number {
+ const port = new SettingsService(db).getSettings('default').preferences.devServerPort
+ return port ?? DEFAULT_DEV_SERVER_PORT
+}
+
+export async function getDevServerState(db: Database, repoId: number): Promise {
+ const port = getDevServerPort(db)
+ const isRunning = await isPortOpen(port)
+
+ return {
+ repoId,
+ status: isRunning ? 'running' : 'stopped',
+ port,
+ error: isRunning ? null : `No dev server detected on localhost:${port}`,
+ previewPath: isRunning ? `/api/dev-proxy/${repoId}/` : null,
+ }
+}
diff --git a/backend/src/services/dev-server/ports.ts b/backend/src/services/dev-server/ports.ts
new file mode 100644
index 00000000..4944ed7d
--- /dev/null
+++ b/backend/src/services/dev-server/ports.ts
@@ -0,0 +1,19 @@
+import net from 'net'
+
+export async function isPortOpen(port: number, host = '127.0.0.1', timeoutMs = 1000): Promise {
+ return new Promise((resolve) => {
+ const socket = new net.Socket()
+ const onError = () => {
+ socket.destroy()
+ resolve(false)
+ }
+ socket.setTimeout(timeoutMs)
+ socket.once('connect', () => {
+ socket.destroy()
+ resolve(true)
+ })
+ socket.once('error', onError)
+ socket.once('timeout', onError)
+ socket.connect(port, host)
+ })
+}
diff --git a/backend/src/services/dev-server/proxy-utils.ts b/backend/src/services/dev-server/proxy-utils.ts
new file mode 100644
index 00000000..38b1a8d7
--- /dev/null
+++ b/backend/src/services/dev-server/proxy-utils.ts
@@ -0,0 +1,182 @@
+export const DEV_PROXY_PREFIX = '/api/dev-proxy'
+
+export const HOP_BY_HOP_HEADERS = new Set([
+ 'connection',
+ 'keep-alive',
+ 'proxy-authenticate',
+ 'proxy-authorization',
+ 'te',
+ 'trailers',
+ 'upgrade',
+ 'transfer-encoding',
+ 'content-length',
+ 'content-encoding',
+ 'host',
+])
+
+export function parseDevProxyPath(pathname: string): { repoId: number; rest: string } | null {
+ const match = pathname.match(/^\/api\/dev-proxy\/(\d+)(\/.*)?$/)
+ if (!match) return null
+ return { repoId: parseInt(match[1]!, 10), rest: match[2] ?? '/' }
+}
+
+export function buildUpstreamUrl(port: number, rest: string, search: string): string {
+ return `http://127.0.0.1:${port}${rest}${search}`
+}
+
+export function filterProxyHeaders(headers: Headers): Record {
+ const result: Record = {}
+ headers.forEach((value, key) => {
+ if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
+ result[key] = value
+ }
+ })
+ return result
+}
+
+export function sanitizeUpstreamResponseHeaders(headers: Headers): Record {
+ const blocked = new Set(['x-frame-options', 'content-security-policy'])
+ const result: Record = {}
+ headers.forEach((value, key) => {
+ const lower = key.toLowerCase()
+ if (!HOP_BY_HOP_HEADERS.has(lower) && !blocked.has(lower)) {
+ result[key] = value
+ }
+ })
+ result['referrer-policy'] = 'no-referrer'
+ return result
+}
+
+export function prepareTransformedResponseHeaders(headers: Record): Record {
+ const result = { ...headers }
+ delete result.etag
+ delete result.ETag
+ delete result['last-modified']
+ delete result['Last-Modified']
+ result['cache-control'] = 'no-store'
+ return result
+}
+
+export function isWebSocketUpgrade(headerGet: (k: string) => string | undefined): boolean {
+ const connection = headerGet('connection')?.toLowerCase() ?? ''
+ const upgrade = headerGet('upgrade')?.toLowerCase() ?? ''
+ return connection.includes('upgrade') && upgrade === 'websocket'
+}
+
+export function injectBaseTag(html: string, basePath: string): string {
+ const htmlWithRewrittenPaths = rewriteDevProxyHtmlPaths(html, basePath)
+ if (/]*>/i)
+ if (!match) return htmlWithRewrittenPaths
+
+ const idx = match.index! + match[0].length
+ return htmlWithRewrittenPaths.slice(0, idx) + `` + htmlWithRewrittenPaths.slice(idx)
+}
+
+export function rewriteDevProxyHtmlPaths(html: string, basePath: string): string {
+ const rewrittenAttributes = html.replace(
+ /\b(src|href|action|poster)=(['"])(\/[^/][^'"]*)\2/gi,
+ (match, attribute: string, quote: string, path: string, offset: number, fullHtml: string) => {
+ if (isBaseTagAttribute(fullHtml, offset)) return match
+ const proxiedPath = toDevProxyPath(path, basePath)
+ if (proxiedPath === path) return match
+ return `${attribute}=${quote}${proxiedPath}${quote}`
+ }
+ )
+
+ const rewrittenSrcSets = rewrittenAttributes.replace(
+ /\bsrcset=(['"])([^'"]*)\1/gi,
+ (_match, quote: string, value: string) => {
+ const rewrittenValue = value
+ .split(',')
+ .map((candidate) => rewriteSrcSetCandidate(candidate, basePath))
+ .join(',')
+ return `srcset=${quote}${rewrittenValue}${quote}`
+ }
+ )
+
+ return rewriteCssUrlReferences(rewrittenSrcSets, basePath)
+}
+
+export function rewriteViteClientHmrBase(js: string, basePath: string): string {
+ const normalizedBasePath = basePath.endsWith('/') ? basePath : `${basePath}/`
+
+ return js.replace(
+ /(\$\{\s*hmrPort\s*\|\|\s*importMetaUrl\.port\s*\}\$\{)(['"])([^'"]*)\2(\})/,
+ (match, prefix: string, quote: string, hmrBase: string, suffix: string) => {
+ if (hmrBase.startsWith(normalizedBasePath)) return match
+ const hmrBasePath = hmrBase.startsWith('/') ? hmrBase.slice(1) : hmrBase
+ const proxiedHmrBase = `${normalizedBasePath}${hmrBasePath}`
+ return `${prefix}${quote}${proxiedHmrBase}${quote}${suffix}`
+ }
+ )
+}
+
+export function rewriteDevProxyJavaScriptPaths(js: string, basePath: string): string {
+ const rewrittenStaticImports = js.replace(
+ /\b((?:import|export)\s+(?:[^'"]*?\s+from\s+)?)(['"])(\/[^/][^'"]*)\2/g,
+ (match, prefix: string, quote: string, path: string) => {
+ const proxiedPath = toDevProxyPath(path, basePath)
+ if (proxiedPath === path) return match
+ return `${prefix}${quote}${proxiedPath}${quote}`
+ }
+ )
+
+ return rewrittenStaticImports.replace(
+ /\b(import\(\s*)(['"])(\/[^/][^'"]*)\2(\s*\))/g,
+ (match, prefix: string, quote: string, path: string, suffix: string) => {
+ const proxiedPath = toDevProxyPath(path, basePath)
+ if (proxiedPath === path) return match
+ return `${prefix}${quote}${proxiedPath}${quote}${suffix}`
+ }
+ )
+}
+
+export function rewriteDevProxyCssPaths(css: string, basePath: string): string {
+ return rewriteCssUrlReferences(css, basePath)
+}
+
+function rewriteCssUrlReferences(content: string, basePath: string): string {
+ const rewrittenUrls = content.replace(
+ /\burl\(\s*(['"]?)(\/(?!\/)[^)'"]*)\1\s*\)/gi,
+ (match, quote: string, path: string) => {
+ const proxiedPath = toDevProxyPath(path, basePath)
+ if (proxiedPath === path) return match
+ return `url(${quote}${proxiedPath}${quote})`
+ }
+ )
+
+ return rewrittenUrls.replace(
+ /(@import\s+)(['"])(\/(?!\/)[^'"]*)\2/gi,
+ (match, prefix: string, quote: string, path: string) => {
+ const proxiedPath = toDevProxyPath(path, basePath)
+ if (proxiedPath === path) return match
+ return `${prefix}${quote}${proxiedPath}${quote}`
+ }
+ )
+}
+
+function rewriteSrcSetCandidate(candidate: string, basePath: string): string {
+ const match = candidate.match(/^(\s*)(\/\S+)(.*)$/)
+ if (!match) return candidate
+ const [, leading = '', path = '', descriptor = ''] = match
+ return `${leading}${toDevProxyPath(path, basePath)}${descriptor}`
+}
+
+function toDevProxyPath(path: string, basePath: string): string {
+ const normalizedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
+ const escapedBasePath = escapeRegExp(normalizedBasePath)
+ if (new RegExp(`^${escapedBasePath}(?:/|$)`).test(path)) return path
+ return `${normalizedBasePath}${path}`
+}
+
+function isBaseTagAttribute(html: string, attributeOffset: number): boolean {
+ const tagStart = html.lastIndexOf('<', attributeOffset)
+ if (tagStart === -1) return false
+ return /^<\s*base\b/i.test(html.slice(tagStart, attributeOffset))
+}
+
+function escapeRegExp(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+}
diff --git a/backend/src/services/dev-server/upgrade-handler.ts b/backend/src/services/dev-server/upgrade-handler.ts
new file mode 100644
index 00000000..68e8eb7f
--- /dev/null
+++ b/backend/src/services/dev-server/upgrade-handler.ts
@@ -0,0 +1,125 @@
+import type { IncomingMessage } from 'http'
+import type { Duplex } from 'stream'
+import type { Database } from 'bun:sqlite'
+import net from 'net'
+import type { AuthInstance } from '../../auth'
+import { getDevServerPort } from './manager'
+import { parseDevProxyPath } from './proxy-utils'
+import { logger } from '../../utils/logger'
+
+export function buildUpstreamUpgradeRequest(rawHead: string, rest: string, port: number): string {
+ const lines = rawHead.split('\r\n')
+ if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) return rawHead
+
+ const requestLine = lines[0]!
+ const parts = requestLine.split(' ')
+ const rewrittenLine = `${parts[0]} ${rest} ${parts.slice(2).join(' ')}`
+
+ const result: string[] = [rewrittenLine]
+
+ for (let i = 1; i < lines.length; i++) {
+ const line = lines[i]!
+ const colonIdx = line.indexOf(':')
+ if (colonIdx !== -1) {
+ const headerName = line.slice(0, colonIdx).trim().toLowerCase()
+ if (headerName === 'host') continue
+ }
+ result.push(line)
+ }
+
+ result.push(`Host: 127.0.0.1:${port}`)
+
+ return result.join('\r\n')
+}
+
+function nodeHeadersToWebHeaders(reqHeaders: IncomingMessage['headers']): Headers {
+ const headers = new Headers()
+ for (const [key, value] of Object.entries(reqHeaders)) {
+ if (value !== undefined) {
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ headers.append(key, v)
+ }
+ } else {
+ headers.set(key, value)
+ }
+ }
+ }
+ return headers
+}
+
+export function createDevProxyUpgradeHandler(auth: AuthInstance, db: Database) {
+ return async (req: IncomingMessage, socket: Duplex, head: Buffer): Promise => {
+ try {
+ const url = req.url ?? ''
+ const parsed = parseDevProxyPath(url)
+ if (!parsed) {
+ socket.destroy()
+ return
+ }
+
+ const headers = nodeHeadersToWebHeaders(req.headers)
+ let session
+ try {
+ session = await auth.api.getSession({ headers })
+ } catch {
+ socket.destroy()
+ return
+ }
+
+ if (!session) {
+ socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n')
+ socket.destroy()
+ return
+ }
+
+ const port = getDevServerPort(db)
+
+ const rawHeadLines: string[] = [`${req.method} ${req.url} HTTP/1.1`]
+ for (const [key, value] of Object.entries(req.headers)) {
+ if (value !== undefined) {
+ if (Array.isArray(value)) {
+ for (const v of value) {
+ rawHeadLines.push(`${key}: ${v}`)
+ }
+ } else {
+ rawHeadLines.push(`${key}: ${value}`)
+ }
+ }
+ }
+ const rawHead = rawHeadLines.join('\r\n')
+
+ const upstreamRequest = buildUpstreamUpgradeRequest(rawHead, parsed.rest, port)
+
+ const upstream = net.connect(port, '127.0.0.1')
+
+ upstream.on('connect', () => {
+ upstream.write(upstreamRequest + '\r\n\r\n')
+ if (head.length > 0) {
+ upstream.write(head)
+ }
+ socket.pipe(upstream)
+ upstream.pipe(socket)
+ })
+
+ upstream.on('error', () => {
+ socket.destroy()
+ })
+
+ socket.on('error', () => {
+ upstream.destroy()
+ })
+
+ socket.on('close', () => {
+ upstream.destroy()
+ })
+
+ upstream.on('close', () => {
+ socket.destroy()
+ })
+ } catch (error) {
+ logger.error('Dev proxy upgrade handler error:', error)
+ socket.destroy()
+ }
+ }
+}
diff --git a/backend/src/services/files.ts b/backend/src/services/files.ts
index 4dfc77f1..5c4b7a4f 100644
--- a/backend/src/services/files.ts
+++ b/backend/src/services/files.ts
@@ -61,6 +61,33 @@ export async function getRawFileContent(userPath: string): Promise {
}
}
+export interface FilePreviewStat {
+ isDirectory: boolean
+ name: string
+ size: number
+ mimeType: string
+ lastModified: Date
+}
+
+export async function getFilePreviewStat(userPath: string): Promise {
+ const validatedPath = validatePath(userPath)
+
+ const exists = await fileExists(validatedPath)
+ if (!exists) {
+ throw { message: 'File not found or cannot be read', statusCode: 404 }
+ }
+
+ const stats = await getFileStats(validatedPath)
+
+ return {
+ isDirectory: stats.isDirectory,
+ name: path.basename(validatedPath),
+ size: stats.size,
+ mimeType: getMimeType(validatedPath),
+ lastModified: stats.lastModified,
+ }
+}
+
export async function getFile(userPath: string): Promise {
const validatedPath = validatePath(userPath)
logger.info(`Getting file for path: ${userPath} -> ${validatedPath}`)
@@ -253,6 +280,7 @@ function getMimeType(filePath: string): AllowedMimeType {
const mimeTypes: Record = {
'.txt': 'text/plain',
'.html': 'text/html',
+ '.htm': 'text/html',
'.css': 'text/css',
'.js': 'text/javascript',
'.ts': 'text/typescript',
diff --git a/backend/src/services/opencode-gh-env-plugin.ts b/backend/src/services/opencode-gh-env-plugin.ts
index 81e908d0..7911156e 100644
--- a/backend/src/services/opencode-gh-env-plugin.ts
+++ b/backend/src/services/opencode-gh-env-plugin.ts
@@ -16,7 +16,7 @@ async function fetchGhEnv(cwd) {
const cached = cache.get(cacheKey)
if (cached && now < cached.expiry) return cached.env
try {
- const url = new URL(baseUrl + '/git-credentials/gh-env')
+ const url = new URL(baseUrl + '/shell-env')
if (cwd) url.searchParams.set('cwd', cwd)
const res = await fetch(url, {
headers: { Authorization: 'Bearer ' + token },
diff --git a/backend/src/text-modules.d.ts b/backend/src/text-modules.d.ts
new file mode 100644
index 00000000..43d00fea
--- /dev/null
+++ b/backend/src/text-modules.d.ts
@@ -0,0 +1,4 @@
+declare module '*.md' {
+ const content: string
+ export default content
+}
diff --git a/backend/test/routes/dev-server.test.ts b/backend/test/routes/dev-server.test.ts
new file mode 100644
index 00000000..fb30185e
--- /dev/null
+++ b/backend/test/routes/dev-server.test.ts
@@ -0,0 +1,265 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+
+vi.mock('../../src/services/dev-server/manager', () => ({
+ getDevServerState: vi.fn(),
+ getDevServerPort: vi.fn(() => 5100),
+}))
+
+vi.mock('../../src/db/queries', () => ({
+ getRepoById: vi.fn(),
+ getDevServerConfig: vi.fn(),
+ setDevServerConfig: vi.fn(),
+}))
+
+import { getDevServerState } from '../../src/services/dev-server/manager'
+import { getRepoById, getDevServerConfig, setDevServerConfig } from '../../src/db/queries'
+import { createDevServerRoutes } from '../../src/routes/dev-server'
+import { createDevProxyRoutes } from '../../src/routes/dev-proxy'
+import { injectBaseTag, rewriteDevProxyHtmlPaths, rewriteDevProxyJavaScriptPaths, rewriteViteClientHmrBase } from '../../src/services/dev-server/proxy-utils'
+
+describe('DevServer Management Routes', () => {
+ let devServerApp: ReturnType
+ let mockDb: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDb = {} as any
+ devServerApp = createDevServerRoutes(mockDb)
+ })
+
+ describe('GET /:repoId/status', () => {
+ it('returns 404 when repo is not found', async () => {
+ vi.mocked(getRepoById).mockReturnValue(null)
+
+ const res = await devServerApp.fetch(new Request('http://localhost/999/status'))
+ expect(res.status).toBe(404)
+ const body = await res.json() as Record
+ expect(body.error).toBe('Repository not found')
+ })
+
+ it('returns configured preview port status', async () => {
+ const mockRepo = { id: 1, fullPath: '/test/repo', repoUrl: null, localPath: '/test/repo' }
+ vi.mocked(getRepoById).mockReturnValue(mockRepo as any)
+ const mockState = {
+ repoId: 1,
+ status: 'running' as const,
+ port: 5100,
+ error: null,
+ previewPath: '/api/dev-proxy/1/',
+ }
+ vi.mocked(getDevServerState).mockResolvedValue(mockState)
+
+ const res = await devServerApp.fetch(new Request('http://localhost/1/status'))
+ expect(res.status).toBe(200)
+ const body = await res.json() as Record
+ expect(body.status).toBe('running')
+ expect(body.port).toBe(5100)
+ expect(body.previewPath).toBe('/api/dev-proxy/1/')
+ expect(getDevServerState).toHaveBeenCalledWith(mockDb, 1)
+ })
+ })
+
+ describe('GET /:repoId/config', () => {
+ it('returns dev server config for repo', async () => {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: true })
+
+ const res = await devServerApp.fetch(new Request('http://localhost/1/config'))
+ expect(res.status).toBe(200)
+ const body = await res.json() as Record
+ expect(body.injectBase).toBe(true)
+ })
+
+ it('returns 400 for invalid repoId', async () => {
+ const res = await devServerApp.fetch(new Request('http://localhost/abc/config'))
+ expect(res.status).toBe(400)
+ })
+ })
+
+ describe('PUT /:repoId/config', () => {
+ it('rejects body missing injectBase', async () => {
+ const res = await devServerApp.fetch(
+ new Request('http://localhost/1/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({}),
+ })
+ )
+ expect(res.status).toBe(400)
+ })
+
+ it('rejects non-boolean injectBase', async () => {
+ const res = await devServerApp.fetch(
+ new Request('http://localhost/1/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ injectBase: 'yes' }),
+ })
+ )
+ expect(res.status).toBe(400)
+ })
+
+ it('persists valid config and returns saved values', async () => {
+ vi.mocked(setDevServerConfig).mockImplementation(() => {})
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: true })
+
+ const res = await devServerApp.fetch(
+ new Request('http://localhost/1/config', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ injectBase: true }),
+ })
+ )
+ expect(res.status).toBe(200)
+ const body = await res.json() as Record
+ expect(body.injectBase).toBe(true)
+ expect(setDevServerConfig).toHaveBeenCalledWith(mockDb, 1, { injectBase: true })
+ })
+ })
+})
+
+describe('DevProxy Routes', () => {
+ let proxyApp: ReturnType
+ let mockDb: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockDb = {} as any
+ proxyApp = createDevProxyRoutes(mockDb)
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('GET /:repoId/ and /:repoId/*', () => {
+ it('returns 503 HTML when dev server is not running', async () => {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false })
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not running')))
+
+ const res = await proxyApp.fetch(new Request('http://localhost/1/'))
+ expect(res.status).toBe(503)
+ const text = await res.text()
+ expect(text).toContain('localhost:5100')
+ expect(res.headers.get('content-type')).toContain('text/html')
+ })
+
+ it('returns 503 HTML when dev server is not running (sub-path)', async () => {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false })
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('not running')))
+
+ const res = await proxyApp.fetch(new Request('http://localhost/1/some/page'))
+ expect(res.status).toBe(503)
+ const text = await res.text()
+ expect(text).toContain('Dev Server Not Running')
+ })
+
+ it('returns 426 for WebSocket upgrade requests', async () => {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false })
+
+ const res = await proxyApp.fetch(new Request('http://localhost/1/', {
+ headers: {
+ Connection: 'Upgrade',
+ Upgrade: 'websocket',
+ },
+ }))
+ expect(res.status).toBe(426)
+ const body = await res.json() as Record
+ expect(body.error).toContain('WebSocket')
+ })
+
+ it('rewrites Vite client HMR websocket base to stay under the dev proxy prefix', async () => {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false })
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
+ 'import "/node_modules/vite/dist/client/env.mjs"; const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${hmrPort || importMetaUrl.port}${"/"}`',
+ { headers: { 'content-type': 'application/javascript' } }
+ )))
+
+ const res = await proxyApp.fetch(new Request('http://localhost/1/@vite/client'))
+ const text = await res.text()
+
+ expect(text).toContain('${"/api/dev-proxy/1/"}')
+ expect(text).toContain('import "/api/dev-proxy/1/node_modules/vite/dist/client/env.mjs"')
+ })
+
+ it('rewrites root absolute HTML paths even when base injection is disabled', async () => {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false })
+ const fetchMock = vi.fn().mockResolvedValue(new Response(
+ '',
+ { headers: { 'content-type': 'text/html', etag: 'upstream-etag' } }
+ ))
+ vi.stubGlobal('fetch', fetchMock)
+
+ const res = await proxyApp.fetch(new Request('http://localhost/1/', {
+ headers: {
+ 'if-none-match': 'cached-etag',
+ 'if-modified-since': 'Sun, 21 Jun 2026 18:23:30 GMT',
+ },
+ }))
+ const text = await res.text()
+ const upstreamRequest = fetchMock.mock.calls[0]?.[1] as RequestInit
+ const upstreamHeaders = upstreamRequest.headers as Record
+
+ expect(text).toContain('href="/api/dev-proxy/1/src/style.css"')
+ expect(text).toContain('src="/api/dev-proxy/1/src/tetris.js"')
+ expect(text).not.toContain(' {
+ vi.mocked(getDevServerConfig).mockReturnValue({ injectBase: false })
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
+ '@import "/reset.css"; .hero{background:url(/assets/hero.png)}',
+ { headers: { 'content-type': 'text/css', etag: 'css-etag' } }
+ )))
+
+ const res = await proxyApp.fetch(new Request('http://localhost/1/assets/app.css'))
+ const text = await res.text()
+
+ expect(text).toContain('@import "/api/dev-proxy/1/reset.css"')
+ expect(text).toContain('url(/api/dev-proxy/1/assets/hero.png)')
+ expect(res.headers.get('etag')).toBeNull()
+ expect(res.headers.get('cache-control')).toBe('no-store')
+ })
+ })
+})
+
+describe('DevProxy HTML rewriting', () => {
+ it('keeps absolute Vite module paths under the dev proxy prefix', () => {
+ const html = ''
+
+ const rewritten = injectBaseTag(html, '/api/dev-proxy/1/')
+
+ expect(rewritten).toContain('src="/api/dev-proxy/1/@vite/client"')
+ expect(rewritten).toContain('src="/api/dev-proxy/1/src/main.tsx"')
+ })
+
+ it('rewrites absolute paths without injecting a base tag', () => {
+ const html = ''
+
+ const rewritten = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/1/')
+
+ expect(rewritten).toContain('src="/api/dev-proxy/1/src/tetris.js"')
+ expect(rewritten).not.toContain(' {
+ const js = 'const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${hmrPort || importMetaUrl.port}${"/"}`'
+
+ const rewritten = rewriteViteClientHmrBase(js, '/api/dev-proxy/1/')
+
+ expect(rewritten).toContain('${"/api/dev-proxy/1/"}')
+ })
+
+ it('keeps JavaScript module paths under the dev proxy prefix', () => {
+ const js = 'import "/node_modules/vite/dist/client/env.mjs"; export { game } from "/src/game.js"; import("/src/lazy.js")'
+
+ const rewritten = rewriteDevProxyJavaScriptPaths(js, '/api/dev-proxy/1/')
+
+ expect(rewritten).toContain('import "/api/dev-proxy/1/node_modules/vite/dist/client/env.mjs"')
+ expect(rewritten).toContain('from "/api/dev-proxy/1/src/game.js"')
+ expect(rewritten).toContain('import("/api/dev-proxy/1/src/lazy.js")')
+ })
+})
diff --git a/backend/test/routes/files.test.ts b/backend/test/routes/files.test.ts
index 23aaa5ad..ceddc700 100644
--- a/backend/test/routes/files.test.ts
+++ b/backend/test/routes/files.test.ts
@@ -30,6 +30,7 @@ vi.mock('@opencode-manager/shared/config/env', () => ({
vi.mock('../../src/services/files', () => ({
getFile: vi.fn(),
+ getFilePreviewStat: vi.fn(),
getRawFileContent: vi.fn(),
getFileRange: vi.fn(),
uploadFile: vi.fn(),
@@ -47,6 +48,8 @@ vi.mock('../../src/services/archive', () => ({
}))
const getFile = fileService.getFile as MockedFunction
+const getFilePreviewStat = fileService.getFilePreviewStat as MockedFunction
+const getRawFileContent = fileService.getRawFileContent as MockedFunction
const getFileRange = fileService.getFileRange as MockedFunction
const uploadFile = fileService.uploadFile as MockedFunction
const createFileOrFolder = fileService.createFileOrFolder as MockedFunction
@@ -69,6 +72,139 @@ describe('File Routes', () => {
app.route('/api/files', filesApp)
})
+ describe('GET /preview/* - HTML Preview Assets', () => {
+ const lastModified = new Date('2024-01-01T00:00:00.000Z')
+
+ const htmlStat = {
+ name: 'index.html',
+ isDirectory: false,
+ size: 31,
+ mimeType: 'text/html' as const,
+ lastModified,
+ }
+
+ it('should serve HTML files with sandbox CSP and caching headers', async () => {
+ getFilePreviewStat.mockResolvedValue(htmlStat)
+ getRawFileContent.mockResolvedValue(Buffer.from('Hello'))
+
+ const response = await app.request('/api/files/preview/test-repo/index.html')
+ const body = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(body).toBe('Hello')
+ expect(response.headers.get('Content-Type')).toContain('text/html')
+ expect(response.headers.get('Content-Disposition')).toContain('inline')
+ expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff')
+ expect(response.headers.get('Referrer-Policy')).toBe('no-referrer')
+ expect(response.headers.get('Content-Security-Policy')).toContain('sandbox allow-scripts allow-same-origin')
+ expect(response.headers.get('Content-Security-Policy')).toContain('https:')
+ expect(response.headers.get('Cache-Control')).toBe('private, must-revalidate, max-age=0')
+ expect(response.headers.get('ETag')).toBeTruthy()
+ expect(response.headers.get('Last-Modified')).toBe(lastModified.toUTCString())
+ expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html')
+ expect(getRawFileContent).toHaveBeenCalledWith('test-repo/index.html')
+ })
+
+ it('should return 304 when ETag matches If-None-Match without reading the file', async () => {
+ getFilePreviewStat.mockResolvedValue(htmlStat)
+ getRawFileContent.mockResolvedValue(Buffer.from('Hello'))
+
+ const first = await app.request('/api/files/preview/test-repo/index.html')
+ const etag = first.headers.get('ETag') as string
+ await first.text()
+ getRawFileContent.mockClear()
+
+ const response = await app.request('/api/files/preview/test-repo/index.html', {
+ headers: { 'If-None-Match': etag },
+ })
+
+ expect(response.status).toBe(304)
+ expect(getRawFileContent).not.toHaveBeenCalled()
+ })
+
+ it('should serve CSS files with text/css Content-Type', async () => {
+ getFilePreviewStat.mockResolvedValue({
+ name: 'styles.css',
+ isDirectory: false,
+ size: 50,
+ mimeType: 'text/css',
+ lastModified,
+ })
+ getRawFileContent.mockResolvedValue(Buffer.from('body { color: red }'))
+
+ const response = await app.request('/api/files/preview/test-repo/styles/app.css')
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('Content-Type')).toContain('text/css')
+ expect(response.headers.get('Content-Security-Policy')).toBeNull()
+ expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/styles/app.css')
+ })
+
+ it('should support query-based path parameter', async () => {
+ getFilePreviewStat.mockResolvedValue(htmlStat)
+ getRawFileContent.mockResolvedValue(Buffer.from('Hello'))
+
+ const response = await app.request('/api/files/preview?path=test-repo/index.html')
+ const body = await response.text()
+
+ expect(response.status).toBe(200)
+ expect(body).toBe('Hello')
+ expect(getFilePreviewStat).toHaveBeenCalledWith('test-repo/index.html')
+ })
+
+ it('should return 400 for directory results', async () => {
+ getFilePreviewStat.mockResolvedValue({
+ name: 'test-repo',
+ isDirectory: true,
+ size: 0,
+ mimeType: 'text/plain',
+ lastModified,
+ })
+
+ const response = await app.request('/api/files/preview/test-repo')
+
+ expect(response.status).toBe(400)
+ const body = await response.json() as { error: string }
+ expect(body.error).toContain('Cannot preview directories')
+ })
+
+ it('should return 415 for non-previewable MIME types', async () => {
+ getFilePreviewStat.mockResolvedValue({
+ name: 'doc.pdf',
+ isDirectory: false,
+ size: 100,
+ mimeType: 'application/pdf' as never,
+ lastModified,
+ })
+
+ const response = await app.request('/api/files/preview/test-repo/doc.pdf')
+
+ expect(response.status).toBe(415)
+ const body = await response.json() as { error: string }
+ expect(body.error).toContain('File type cannot be previewed')
+ expect(getRawFileContent).not.toHaveBeenCalled()
+ })
+
+ it('should return 400 when no path is provided', async () => {
+ const response = await app.request('/api/files/preview')
+
+ expect(response.status).toBe(400)
+ const body = await response.json() as { error: string }
+ expect(body.error).toContain('No path provided')
+ })
+
+ it('should return 403 for path traversal attempts', async () => {
+ const error = { message: 'Path traversal detected', statusCode: 403 }
+ getFilePreviewStat.mockRejectedValue(error)
+
+ const response = await app.request('/api/files/preview/test-repo/../outside')
+
+ expect(response.status).toBe(403)
+ const body = await response.json() as { error: string }
+ expect(body.error).toContain('Path traversal detected')
+ })
+ })
+
describe('GET /*/download-zip - Route Order Regression Test', () => {
it('should route to archive service for directory download, not file service', async () => {
const mockStream = {
@@ -175,6 +311,17 @@ describe('File Routes', () => {
expect(response.status).toBe(404)
expect(body).toHaveProperty('error')
})
+
+ it('should route paths starting with "preview" but not matching /preview/ to generic handler', async () => {
+ getFile.mockResolvedValue(mockFileInfo)
+
+ const response = await app.request('/api/files/previewer')
+ const body = await response.json() as FileInfo
+
+ expect(response.status).toBe(200)
+ expect(body).toHaveProperty('isDirectory', false)
+ expect(getFile).toHaveBeenCalledWith('previewer')
+ })
})
describe('GET /* with ?startLine&endLine - File Range', () => {
diff --git a/backend/test/routes/internal/git-credentials.test.ts b/backend/test/routes/internal/git-credentials.test.ts
deleted file mode 100644
index 4ae326be..00000000
--- a/backend/test/routes/internal/git-credentials.test.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { describe, it, expect, beforeEach } from 'vitest'
-import { Database } from 'bun:sqlite'
-import { migrate } from '../../../src/db/migration-runner'
-import { allMigrations } from '../../../src/db/migrations'
-import { SettingsService } from '../../../src/services/settings'
-import { createInternalGitCredentialsRoutes } from '../../../src/routes/internal/git-credentials'
-import type { GitCredential } from '@opencode-manager/shared'
-
-describe('internal git-credentials routes', () => {
- let db: Database
- let settingsService: SettingsService
- let app: ReturnType
-
- beforeEach(() => {
- db = new Database(':memory:')
- migrate(db, allMigrations)
- settingsService = new SettingsService(db)
- app = createInternalGitCredentialsRoutes(db)
- })
-
- it('GET /gh-env returns GH_TOKEN and GITHUB_TOKEN for a GitHub PAT', async () => {
- settingsService.updateSettings({
- gitCredentials: [
- { name: 'github', host: 'github.com', type: 'pat', token: 'ghp_test_token' } as GitCredential,
- ],
- })
-
- const res = await app.request('/gh-env')
-
- expect(res.status).toBe(200)
- expect(await res.json()).toEqual({ GH_TOKEN: 'ghp_test_token', GITHUB_TOKEN: 'ghp_test_token' })
- })
-
- it('GET /gh-env returns an empty object when no GitHub credential exists', async () => {
- const res = await app.request('/gh-env')
-
- expect(res.status).toBe(200)
- expect(await res.json()).toEqual({})
- })
-})
diff --git a/backend/test/routes/internal/shell-env.test.ts b/backend/test/routes/internal/shell-env.test.ts
new file mode 100644
index 00000000..961e0d3d
--- /dev/null
+++ b/backend/test/routes/internal/shell-env.test.ts
@@ -0,0 +1,48 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+
+const getGhCliEnvMock = vi.hoisted(() => vi.fn())
+const getDevServerPortMock = vi.hoisted(() => vi.fn())
+
+vi.mock('../../../src/services/credential-provider', () => ({
+ CredentialProvider: vi.fn().mockImplementation(() => ({ getGhCliEnv: getGhCliEnvMock })),
+}))
+
+vi.mock('../../../src/services/dev-server/manager', () => ({
+ getDevServerPort: getDevServerPortMock,
+}))
+
+import { createInternalShellEnvRoutes } from '../../../src/routes/internal/shell-env'
+
+describe('internal shell-env routes', () => {
+ const mockDb = {} as never
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ getGhCliEnvMock.mockReturnValue({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' })
+ getDevServerPortMock.mockReturnValue(4321)
+ })
+
+ it('GET / merges gh env with the dev server port and forwards cwd', async () => {
+ const app = createInternalShellEnvRoutes(mockDb)
+
+ const res = await app.request('/?cwd=%2Frepo')
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({
+ GH_TOKEN: 'ghp',
+ GITHUB_TOKEN: 'ghp',
+ OCM_DEV_SERVER_PORT: '4321',
+ })
+ expect(getGhCliEnvMock).toHaveBeenCalledWith({ cwd: '/repo' })
+ })
+
+ it('GET / returns only the dev server port when no GitHub credential exists', async () => {
+ getGhCliEnvMock.mockReturnValue({})
+ const app = createInternalShellEnvRoutes(mockDb)
+
+ const res = await app.request('/')
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({ OCM_DEV_SERVER_PORT: '4321' })
+ })
+})
diff --git a/backend/test/services/dev-server/proxy-utils.test.ts b/backend/test/services/dev-server/proxy-utils.test.ts
new file mode 100644
index 00000000..d84c74fb
--- /dev/null
+++ b/backend/test/services/dev-server/proxy-utils.test.ts
@@ -0,0 +1,337 @@
+import { describe, it, expect } from 'vitest'
+import {
+ DEV_PROXY_PREFIX,
+ parseDevProxyPath,
+ buildUpstreamUrl,
+ filterProxyHeaders,
+ sanitizeUpstreamResponseHeaders,
+ isWebSocketUpgrade,
+ injectBaseTag,
+ rewriteDevProxyCssPaths,
+ rewriteDevProxyHtmlPaths,
+ rewriteDevProxyJavaScriptPaths,
+} from '../../../src/services/dev-server/proxy-utils'
+
+describe('DEV_PROXY_PREFIX', () => {
+ it('is /api/dev-proxy', () => {
+ expect(DEV_PROXY_PREFIX).toBe('/api/dev-proxy')
+ })
+})
+
+describe('parseDevProxyPath', () => {
+ it('parses path with repo id and subpath', () => {
+ const result = parseDevProxyPath('/api/dev-proxy/12/assets/x.js')
+ expect(result).toEqual({ repoId: 12, rest: '/assets/x.js' })
+ })
+
+ it('parses path with only repo id', () => {
+ const result = parseDevProxyPath('/api/dev-proxy/12')
+ expect(result).toEqual({ repoId: 12, rest: '/' })
+ })
+
+ it('parses path with repo id and trailing slash', () => {
+ const result = parseDevProxyPath('/api/dev-proxy/12/')
+ expect(result).toEqual({ repoId: 12, rest: '/' })
+ })
+
+ it('returns null for path without dev-proxy prefix', () => {
+ expect(parseDevProxyPath('/api/files')).toBeNull()
+ })
+
+ it('returns null for path with non-numeric repo id', () => {
+ expect(parseDevProxyPath('/api/dev-proxy/abc')).toBeNull()
+ })
+
+ it('returns null when repo id segment is empty', () => {
+ expect(parseDevProxyPath('/api/dev-proxy/')).toBeNull()
+ })
+
+ it('returns null for unrelated path', () => {
+ expect(parseDevProxyPath('/api/other/12/file.js')).toBeNull()
+ })
+
+ it('parses multi-digit port numbers', () => {
+ const result = parseDevProxyPath('/api/dev-proxy/12345/assets')
+ expect(result).toEqual({ repoId: 12345, rest: '/assets' })
+ })
+
+ it('parses path with special characters in rest', () => {
+ const result = parseDevProxyPath('/api/dev-proxy/1/@fs/src/main.tsx')
+ expect(result).toEqual({ repoId: 1, rest: '/@fs/src/main.tsx' })
+ })
+})
+
+describe('buildUpstreamUrl', () => {
+ it('builds url with search params', () => {
+ const result = buildUpstreamUrl(5173, '/assets/x.js', '?v=1')
+ expect(result).toBe('http://127.0.0.1:5173/assets/x.js?v=1')
+ })
+
+ it('builds url without search params', () => {
+ const result = buildUpstreamUrl(5173, '/', '')
+ expect(result).toBe('http://127.0.0.1:5173/')
+ })
+
+ it('builds url with query string', () => {
+ const result = buildUpstreamUrl(3000, '/api/data', '?foo=bar&baz=1')
+ expect(result).toBe('http://127.0.0.1:3000/api/data?foo=bar&baz=1')
+ })
+
+ it('uses 127.0.0.1 as hostname', () => {
+ const result = buildUpstreamUrl(8080, '/', '')
+ expect(result).toMatch(/^http:\/\/127\.0\.0\.1:/)
+ })
+})
+
+describe('filterProxyHeaders', () => {
+ it('removes hop-by-hop headers', () => {
+ const headers = new Headers({
+ 'content-type': 'text/html',
+ 'connection': 'keep-alive',
+ 'upgrade': 'websocket',
+ 'cache-control': 'no-cache',
+ })
+ const result = filterProxyHeaders(headers)
+ expect(result).toHaveProperty('content-type')
+ expect(result).toHaveProperty('cache-control')
+ expect(result).not.toHaveProperty('connection')
+ expect(result).not.toHaveProperty('upgrade')
+ })
+
+ it('removes all hop-by-hop headers case-insensitively', () => {
+ const headers = new Headers({
+ 'Connection': 'close',
+ 'Transfer-Encoding': 'chunked',
+ 'Content-Length': '100',
+ 'Host': 'example.com',
+ })
+ const result = filterProxyHeaders(headers)
+ expect(Object.keys(result)).toHaveLength(0)
+ })
+
+ it('preserves non-hop-by-hop headers', () => {
+ const headers = new Headers({
+ 'accept': 'application/json',
+ 'user-agent': 'test',
+ 'referer': 'http://example.com',
+ })
+ const result = filterProxyHeaders(headers)
+ expect(result).toHaveProperty('accept')
+ expect(result).toHaveProperty('user-agent')
+ expect(result).toHaveProperty('referer')
+ })
+
+ it('returns empty object for empty headers', () => {
+ const result = filterProxyHeaders(new Headers())
+ expect(Object.keys(result)).toHaveLength(0)
+ })
+})
+
+describe('sanitizeUpstreamResponseHeaders', () => {
+ it('strips hop-by-hop headers', () => {
+ const headers = new Headers({
+ 'content-type': 'text/html',
+ 'connection': 'keep-alive',
+ 'x-frame-options': 'DENY',
+ })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result).toHaveProperty('content-type')
+ expect(result).not.toHaveProperty('connection')
+ })
+
+ it('removes x-frame-options', () => {
+ const headers = new Headers({
+ 'content-type': 'text/html',
+ 'x-frame-options': 'DENY',
+ })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result).not.toHaveProperty('x-frame-options')
+ })
+
+ it('removes content-security-policy', () => {
+ const headers = new Headers({
+ 'content-type': 'text/html',
+ 'content-security-policy': "default-src 'self'",
+ })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result).not.toHaveProperty('content-security-policy')
+ })
+
+ it('handles case-insensitive blocked header names', () => {
+ const headers = new Headers({
+ 'X-Frame-Options': 'SAMEORIGIN',
+ 'Content-Security-Policy': "default-src 'self'",
+ })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result).not.toHaveProperty('X-Frame-Options')
+ expect(result).not.toHaveProperty('Content-Security-Policy')
+ })
+
+ it('adds referrer-policy: no-referrer', () => {
+ const headers = new Headers({ 'content-type': 'text/plain' })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result['referrer-policy']).toBe('no-referrer')
+ })
+
+ it('overwrites upstream referrer-policy', () => {
+ const headers = new Headers({
+ 'referrer-policy': 'origin',
+ })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result['referrer-policy']).toBe('no-referrer')
+ })
+
+ it('preserves allowed headers', () => {
+ const headers = new Headers({
+ 'content-type': 'application/json',
+ 'cache-control': 'public',
+ 'etag': '"abc123"',
+ })
+ const result = sanitizeUpstreamResponseHeaders(headers)
+ expect(result).toHaveProperty('content-type')
+ expect(result).toHaveProperty('cache-control')
+ expect(result).toHaveProperty('etag')
+ })
+})
+
+describe('isWebSocketUpgrade', () => {
+ it('returns true for websocket upgrade', () => {
+ const headerGet = (k: string) =>
+ ({ connection: 'Upgrade', upgrade: 'websocket' })[k]
+ expect(isWebSocketUpgrade(headerGet)).toBe(true)
+ })
+
+ it('handles case-insensitive connection header', () => {
+ const headerGet = (k: string) =>
+ ({ connection: 'upgrade', upgrade: 'websocket' })[k]
+ expect(isWebSocketUpgrade(headerGet)).toBe(true)
+ })
+
+ it('returns false when connection does not include upgrade', () => {
+ const headerGet = (k: string) =>
+ ({ connection: 'keep-alive', upgrade: 'websocket' })[k]
+ expect(isWebSocketUpgrade(headerGet)).toBe(false)
+ })
+
+ it('returns false when upgrade is not websocket', () => {
+ const headerGet = (k: string) =>
+ ({ connection: 'Upgrade', upgrade: 'h2c' })[k]
+ expect(isWebSocketUpgrade(headerGet)).toBe(false)
+ })
+
+ it('returns false when both headers are missing', () => {
+ const headerGet = () => undefined
+ expect(isWebSocketUpgrade(headerGet)).toBe(false)
+ })
+
+ it('returns false when connection header is missing', () => {
+ const headerGet = (k: string) =>
+ ({ upgrade: 'websocket' })[k]
+ expect(isWebSocketUpgrade(headerGet)).toBe(false)
+ })
+
+ it('returns false when upgrade header is missing', () => {
+ const headerGet = (k: string) =>
+ ({ connection: 'Upgrade' })[k]
+ expect(isWebSocketUpgrade(headerGet)).toBe(false)
+ })
+})
+
+describe('injectBaseTag', () => {
+ it('injects base tag after ', () => {
+ const html = ''
+ const result = injectBaseTag(html, '/api/dev-proxy/3/')
+ expect(result).toBe('')
+ })
+
+ it('injects base tag after with attributes', () => {
+ const html = ''
+ const result = injectBaseTag(html, '/api/dev-proxy/3/')
+ expect(result).toBe('')
+ })
+
+ it('is no-op when already present', () => {
+ const html = ''
+ const result = injectBaseTag(html, '/api/dev-proxy/3/')
+ expect(result).toBe(html)
+ })
+
+ it('is no-op when no tag exists', () => {
+ const html = 'no head'
+ const result = injectBaseTag(html, '/api/dev-proxy/3/')
+ expect(result).toBe(html)
+ })
+
+ it('handles uppercase HEAD tag', () => {
+ const html = ''
+ const result = injectBaseTag(html, '/proxy/')
+ expect(result).toBe('')
+ })
+
+ it('injects after with newline after', () => {
+ const html = '\n Test\n'
+ const result = injectBaseTag(html, '/base/')
+ expect(result).toBe('\n Test\n')
+ })
+
+ it('does not inject when appears in non-head context (e.g. body)', () => {
+ const html = ''
+ const result = injectBaseTag(html, '/base/')
+ expect(result).toBe(html)
+ })
+
+ it('handles empty string', () => {
+ const result = injectBaseTag('', '/base/')
+ expect(result).toBe('')
+ })
+})
+
+describe('dev proxy path rewriting', () => {
+ it('rewrites common framework HTML asset paths', () => {
+ const html = '
'
+
+ const result = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/7/')
+
+ expect(result).toContain('src="/api/dev-proxy/7/_next/image.png"')
+ expect(result).toContain('srcset="/api/dev-proxy/7/assets/a.png 1x, /api/dev-proxy/7/static/b.png 2x"')
+ expect(result).toContain('action="/api/dev-proxy/7/api/action"')
+ })
+
+ it('rewrites CSS paths in HTML style content', () => {
+ const html = ''
+
+ const result = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/7/')
+
+ expect(result).toContain('url(/api/dev-proxy/7/assets/bg.png)')
+ expect(result).toContain('@import "/api/dev-proxy/7/theme.css"')
+ expect(result).toContain('url("/api/dev-proxy/7/img/bg.svg")')
+ })
+
+ it('rewrites CSS response paths', () => {
+ const css = '@import "/reset.css"; .hero{background:url(/assets/hero.png)} .icon{mask:url("/icons/x.svg")}'
+
+ const result = rewriteDevProxyCssPaths(css, '/api/dev-proxy/7/')
+
+ expect(result).toContain('@import "/api/dev-proxy/7/reset.css"')
+ expect(result).toContain('url(/api/dev-proxy/7/assets/hero.png)')
+ expect(result).toContain('url("/api/dev-proxy/7/icons/x.svg")')
+ })
+
+ it('rewrites JavaScript module paths', () => {
+ const js = 'import "/runtime.js"; export { App } from "/src/App.js"; const route = import("/routes/home.js")'
+
+ const result = rewriteDevProxyJavaScriptPaths(js, '/api/dev-proxy/7/')
+
+ expect(result).toContain('import "/api/dev-proxy/7/runtime.js"')
+ expect(result).toContain('from "/api/dev-proxy/7/src/App.js"')
+ expect(result).toContain('import("/api/dev-proxy/7/routes/home.js")')
+ })
+
+ it('does not rewrite protocol-relative or already proxied paths', () => {
+ const html = ''
+
+ const result = rewriteDevProxyHtmlPaths(html, '/api/dev-proxy/7/')
+
+ expect(result).toBe(html)
+ })
+})
diff --git a/backend/test/services/dev-server/upgrade-handler.test.ts b/backend/test/services/dev-server/upgrade-handler.test.ts
new file mode 100644
index 00000000..b9175fea
--- /dev/null
+++ b/backend/test/services/dev-server/upgrade-handler.test.ts
@@ -0,0 +1,259 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import {
+ buildUpstreamUpgradeRequest,
+ createDevProxyUpgradeHandler,
+} from '../../../src/services/dev-server/upgrade-handler'
+
+const netConnectMock = vi.hoisted(() => vi.fn())
+vi.mock('net', () => ({ connect: netConnectMock, default: { connect: netConnectMock } }))
+vi.mock('../../../src/services/dev-server/manager', () => ({
+ getDevServerPort: vi.fn(() => 5173),
+}))
+
+describe('buildUpstreamUpgradeRequest', () => {
+ it('rewrites request target to rest and sets Host header', () => {
+ const rawHead = [
+ 'GET /api/dev-proxy/1/ws-path HTTP/1.1',
+ 'Host: localhost:5003',
+ 'Upgrade: websocket',
+ 'Connection: Upgrade',
+ 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
+ 'Sec-WebSocket-Version: 13',
+ ].join('\r\n')
+
+ const result = buildUpstreamUpgradeRequest(rawHead, '/ws-path', 5173)
+
+ expect(result).toContain('GET /ws-path HTTP/1.1')
+ expect(result).toContain('Host: 127.0.0.1:5173')
+ expect(result).not.toContain('Host: localhost:5003')
+ })
+
+ it('preserves Upgrade, Connection, and Sec-WebSocket-* headers', () => {
+ const rawHead = [
+ 'GET /api/dev-proxy/2/socket HTTP/1.1',
+ 'Host: example.com',
+ 'Upgrade: websocket',
+ 'Connection: Upgrade',
+ 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
+ 'Sec-WebSocket-Version: 13',
+ 'Sec-WebSocket-Protocol: json',
+ ].join('\r\n')
+
+ const result = buildUpstreamUpgradeRequest(rawHead, '/socket', 3000)
+
+ expect(result).toContain('Upgrade: websocket')
+ expect(result).toContain('Connection: Upgrade')
+ expect(result).toContain('Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==')
+ expect(result).toContain('Sec-WebSocket-Version: 13')
+ expect(result).toContain('Sec-WebSocket-Protocol: json')
+ })
+
+ it('handles root path rest', () => {
+ const rawHead = [
+ 'GET /api/dev-proxy/1 HTTP/1.1',
+ 'Host: localhost:5003',
+ ].join('\r\n')
+
+ const result = buildUpstreamUpgradeRequest(rawHead, '/', 5173)
+
+ expect(result).toMatch(/^GET \/ HTTP\/1\.1\r\n/m)
+ })
+
+ it('preserves non-Host headers in order', () => {
+ const rawHead = [
+ 'GET /api/dev-proxy/1/test HTTP/1.1',
+ 'Host: localhost:5003',
+ 'User-Agent: test-agent',
+ 'Accept: */*',
+ ].join('\r\n')
+
+ const result = buildUpstreamUpgradeRequest(rawHead, '/test', 8080)
+
+ const userAgentIdx = result.indexOf('User-Agent: test-agent')
+ const acceptIdx = result.indexOf('Accept: */*')
+ expect(userAgentIdx).toBeGreaterThan(0)
+ expect(acceptIdx).toBeGreaterThan(userAgentIdx)
+ })
+
+ it('returns empty string when given empty string', () => {
+ expect(buildUpstreamUpgradeRequest('', '/', 5173)).toBe('')
+ })
+
+ it('handles single-line input without headers', () => {
+ const result = buildUpstreamUpgradeRequest('GET /api/dev-proxy/1/ HTTP/1.1', '/', 5173)
+ expect(result).toBe('GET / HTTP/1.1\r\nHost: 127.0.0.1:5173')
+ })
+
+ it('removes duplicate host headers, keeping only the new one', () => {
+ const rawHead = [
+ 'GET /api/dev-proxy/5/app HTTP/1.1',
+ 'Host: first.com',
+ 'Host: second.com',
+ 'Accept: application/json',
+ ].join('\r\n')
+
+ const result = buildUpstreamUpgradeRequest(rawHead, '/app', 9999)
+
+ const hostMatches = result.match(/^Host:/gm)
+ expect(hostMatches).toHaveLength(1)
+ expect(result).toContain('Host: 127.0.0.1:9999')
+ })
+})
+
+describe('createDevProxyUpgradeHandler', () => {
+ const mockGetSession = vi.fn()
+ let auth: { api: { getSession: typeof mockGetSession } }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ session: { userId: 'u1' }, user: { id: 'u1' } })
+ netConnectMock.mockReturnValue(createMockSocket())
+ auth = { api: { getSession: mockGetSession } }
+ })
+
+ function createMockSocket() {
+ const handlers = new Map void>>()
+ return {
+ on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
+ if (!handlers.has(event)) handlers.set(event, [])
+ handlers.get(event)!.push(handler)
+ }),
+ destroy: vi.fn(),
+ write: vi.fn(),
+ pipe: vi.fn(),
+ _handlers: handlers,
+ }
+ }
+
+ function triggerEvent(obj: { _handlers?: Map void>> }, event: string, ...args: unknown[]) {
+ const map = obj._handlers
+ if (!map) return
+ const handlers = map.get(event)
+ if (handlers) {
+ for (const h of handlers) h(...args)
+ }
+ }
+
+ describe('URL routing', () => {
+ it('destroys socket for non-proxy URL', async () => {
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/other', method: 'GET', headers: {} }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+
+ expect(socket.destroy).toHaveBeenCalledTimes(1)
+ expect(mockGetSession).not.toHaveBeenCalled()
+ })
+
+ it('destroys socket for URL without repo id', async () => {
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/', method: 'GET', headers: {} }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+
+ expect(socket.destroy).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('authentication', () => {
+ it('destroys socket when session lookup throws', async () => {
+ mockGetSession.mockRejectedValue(new Error('auth error'))
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+
+ expect(socket.destroy).toHaveBeenCalledTimes(1)
+ })
+
+ it('writes 401 and destroys socket when no session', async () => {
+ mockGetSession.mockResolvedValue(null)
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/hmr', method: 'GET', headers: { host: 'localhost' } }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+
+ expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('401 Unauthorized'))
+ expect(socket.destroy).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('upstream connection', () => {
+ it('calls net.connect with active port and 127.0.0.1', async () => {
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/hmr', method: 'GET', headers: { host: 'localhost' } }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+
+ expect(netConnectMock).toHaveBeenCalledWith(5173, '127.0.0.1')
+ })
+
+ it('writes upstream request on connect', async () => {
+ const upstreamSocket = createMockSocket()
+ netConnectMock.mockReturnValue(upstreamSocket)
+
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/hmr', method: 'GET', headers: { host: 'localhost', upgrade: 'websocket', connection: 'Upgrade' } }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+
+ triggerEvent(upstreamSocket, 'connect')
+
+ expect(upstreamSocket.write).toHaveBeenCalledWith(expect.stringContaining('GET /hmr HTTP/1.1'))
+ expect(upstreamSocket.write).toHaveBeenCalledWith(expect.stringContaining('Host: 127.0.0.1:5173'))
+ })
+
+ it('pipes head buffer after upgrade request', async () => {
+ const upstreamSocket = createMockSocket()
+ netConnectMock.mockReturnValue(upstreamSocket)
+
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } }
+ const head = Buffer.from('extra-data')
+
+ await handler(req as any, socket as any, head)
+
+ triggerEvent(upstreamSocket, 'connect')
+
+ const writeMock = upstreamSocket.write as ReturnType
+ const secondCallArg = writeMock.mock.calls[1]?.[0]
+ expect(secondCallArg).toBe(head)
+ })
+
+ it('pipes socket and upstream both ways on connect', async () => {
+ const upstreamSocket = createMockSocket()
+ netConnectMock.mockReturnValue(upstreamSocket)
+
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+ triggerEvent(upstreamSocket, 'connect')
+
+ expect(socket.pipe).toHaveBeenCalledWith(upstreamSocket)
+ expect(upstreamSocket.pipe).toHaveBeenCalledWith(socket)
+ })
+
+ it('destroys socket on upstream error', async () => {
+ const upstreamSocket = createMockSocket()
+ netConnectMock.mockReturnValue(upstreamSocket)
+
+ const handler = createDevProxyUpgradeHandler(auth as any, {} as any)
+ const socket = createMockSocket()
+ const req = { url: '/api/dev-proxy/1/ws', method: 'GET', headers: { host: 'localhost' } }
+
+ await handler(req as any, socket as any, Buffer.alloc(0))
+ triggerEvent(upstreamSocket, 'error', new Error('conn refused'))
+
+ expect(socket.destroy).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/backend/test/services/opencode-gh-env-plugin.test.ts b/backend/test/services/opencode-gh-env-plugin.test.ts
index 4ac101ee..c3b40fb8 100644
--- a/backend/test/services/opencode-gh-env-plugin.test.ts
+++ b/backend/test/services/opencode-gh-env-plugin.test.ts
@@ -53,7 +53,7 @@ describe('ocm-gh-env plugin', () => {
expect(output.env).toEqual({ GH_TOKEN: 'ghp', GITHUB_TOKEN: 'ghp' })
const [url, options] = fetchMock.mock.calls[0]!
- expect(url.toString()).toBe('http://localhost:5003/api/internal/git-credentials/gh-env?cwd=%2Frepo')
+ expect(url.toString()).toBe('http://localhost:5003/api/internal/shell-env?cwd=%2Frepo')
expect(options).toEqual({ headers: { Authorization: 'Bearer secret-token' } })
})
diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts
index da53ef82..cfdd9d47 100644
--- a/backend/vitest.config.ts
+++ b/backend/vitest.config.ts
@@ -2,6 +2,18 @@ import { defineConfig } from 'vitest/config'
import path from 'path'
export default defineConfig({
+ plugins: [
+ {
+ name: 'markdown-as-text',
+ enforce: 'pre',
+ transform(code: string, id: string) {
+ if (id.endsWith('.md')) {
+ return { code: `export default ${JSON.stringify(code)};`, map: null }
+ }
+ return null
+ },
+ },
+ ],
test: {
globals: true,
environment: 'node',
diff --git a/frontend/src/api/devServer.ts b/frontend/src/api/devServer.ts
new file mode 100644
index 00000000..3e666876
--- /dev/null
+++ b/frontend/src/api/devServer.ts
@@ -0,0 +1,11 @@
+import { fetchWrapper } from './fetchWrapper'
+import { API_BASE_URL } from '@/config'
+import type { DevServerState } from '@opencode-manager/shared/types'
+
+export async function getDevServerStatus(repoId: number): Promise {
+ return fetchWrapper(`${API_BASE_URL}/api/dev-server/${repoId}/status`)
+}
+
+export function getDevPreviewUrl(repoId: number): string {
+ return `${API_BASE_URL}/api/dev-proxy/${repoId}/`
+}
diff --git a/frontend/src/api/files.test.ts b/frontend/src/api/files.test.ts
new file mode 100644
index 00000000..13373d12
--- /dev/null
+++ b/frontend/src/api/files.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import { getFileApiUrl } from './files'
+import { encodePathForRoute, getFilePreviewUrl } from './files'
+
+describe('getFilePreviewUrl', () => {
+ it('constructs preview URL with encoded path segments', () => {
+ const url = getFilePreviewUrl('repo/dir/index.html')
+ expect(url).toBe('/api/files/preview/repo/dir/index.html')
+ })
+
+ it('encodes spaces per segment', () => {
+ const url = getFilePreviewUrl('my repo/file name.html')
+ expect(url).toBe('/api/files/preview/my%20repo/file%20name.html')
+ })
+
+ it('encodes hash characters per segment', () => {
+ const url = getFilePreviewUrl('repo/file#1.html')
+ expect(url).toBe('/api/files/preview/repo/file%231.html')
+ })
+})
+
+describe('encodePathForRoute', () => {
+ it('encodes each path segment separately', () => {
+ const result = encodePathForRoute('repo/dir/index.html')
+ expect(result).toBe('repo/dir/index.html')
+ })
+
+ it('encodes special characters per segment', () => {
+ const result = encodePathForRoute('my dir/file name.html')
+ expect(result).toBe('my%20dir/file%20name.html')
+ })
+})
+
+describe('getFileApiUrl - existing behavior unchanged', () => {
+ it('still returns a URL with path query param', () => {
+ const url = getFileApiUrl('repo/dir/index.html')
+ expect(url).toContain('/api/files')
+ expect(url).toContain('path=repo%2Fdir%2Findex.html')
+ })
+})
diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts
index 7337d9f3..df4a12cd 100644
--- a/frontend/src/api/files.ts
+++ b/frontend/src/api/files.ts
@@ -77,3 +77,12 @@ export async function applyFilePatches(path: string, patches: PatchOperation[]):
body: JSON.stringify({ patches }),
})
}
+
+export function encodePathForRoute(path: string): string {
+ return path.split('/').map(encodeURIComponent).join('/')
+}
+
+export function getFilePreviewUrl(path: string): string {
+ const encodedPath = encodePathForRoute(path)
+ return `${API_BASE_URL}/api/files/preview/${encodedPath}`
+}
diff --git a/frontend/src/api/types/settings.ts b/frontend/src/api/types/settings.ts
index cabc73b2..8439f09b 100644
--- a/frontend/src/api/types/settings.ts
+++ b/frontend/src/api/types/settings.ts
@@ -71,6 +71,7 @@ export interface UserPreferences {
repoSortMode?: 'recent' | 'manual' | 'name'
serverEnvVars?: Array<{ key: string; value: string }>
disabledDefaultServerEnvVars?: string[]
+ devServerPort?: number
}
export interface SettingsResponse {
diff --git a/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx b/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx
new file mode 100644
index 00000000..80cfb15a
--- /dev/null
+++ b/frontend/src/components/dev-server/DevServerPreviewButton.test.tsx
@@ -0,0 +1,55 @@
+import { render, screen, fireEvent } from '@testing-library/react'
+import { describe, it, expect, vi } from 'vitest'
+import { DevServerPreviewButton } from './DevServerPreviewButton'
+import { useMutation } from '@tanstack/react-query'
+
+vi.mock('@tanstack/react-query', () => ({
+ useMutation: vi.fn(),
+}))
+
+vi.mock('@/api/devServer', () => ({
+ getDevServerStatus: vi.fn(),
+ getDevPreviewUrl: (repoId: number) => `/api/dev-proxy/${repoId}/`,
+}))
+
+describe('DevServerPreviewButton', () => {
+ it('calls onOpen with devserver input when configured port is running', () => {
+ const onOpen = vi.fn()
+
+ let capturedOnSuccess: ((state: { status: 'running'; port: number }) => void) | undefined
+ vi.mocked(useMutation).mockImplementation(((options: { onSuccess?: (state: { status: 'running'; port: number }) => void }) => {
+ capturedOnSuccess = options.onSuccess
+ return {
+ mutate: () => capturedOnSuccess?.({ status: 'running', port: 5100 }),
+ isPending: false,
+ } as unknown as ReturnType
+ }) as typeof useMutation)
+
+ render()
+
+ const button = screen.getByLabelText('Open app preview')
+ expect(button).toBeInTheDocument()
+
+ fireEvent.click(button)
+
+ expect(onOpen).toHaveBeenCalledTimes(1)
+ expect(onOpen).toHaveBeenCalledWith({
+ source: 'devserver',
+ previewUrl: '/api/dev-proxy/3/',
+ title: 'App preview',
+ })
+ })
+
+ it('disables button while pending', () => {
+ const onOpen = vi.fn()
+ vi.mocked(useMutation).mockReturnValue({
+ mutate: vi.fn(),
+ isPending: true,
+ } as unknown as ReturnType)
+
+ render()
+
+ const button = screen.getByLabelText('Open app preview')
+ expect(button).toBeDisabled()
+ })
+})
diff --git a/frontend/src/components/dev-server/DevServerPreviewButton.tsx b/frontend/src/components/dev-server/DevServerPreviewButton.tsx
new file mode 100644
index 00000000..b8a32560
--- /dev/null
+++ b/frontend/src/components/dev-server/DevServerPreviewButton.tsx
@@ -0,0 +1,44 @@
+import { useMutation } from '@tanstack/react-query'
+import { getDevServerStatus, getDevPreviewUrl } from '@/api/devServer'
+import { Button } from '@/components/ui/button'
+import { showToast } from '@/lib/toast'
+import { Loader2, Play } from 'lucide-react'
+import type { OpenHtmlArtifactInput } from '@/lib/htmlArtifacts'
+
+interface DevServerPreviewButtonProps {
+ repoId: number
+ onOpen: (input: OpenHtmlArtifactInput) => void
+}
+
+export function DevServerPreviewButton({ repoId, onOpen }: DevServerPreviewButtonProps) {
+ const { mutate, isPending } = useMutation({
+ mutationFn: () => getDevServerStatus(repoId),
+ onSuccess: (state) => {
+ if (state.status !== 'running') {
+ showToast.error(`No app detected on localhost:${state.port}`)
+ return
+ }
+
+ onOpen({
+ source: 'devserver',
+ previewUrl: getDevPreviewUrl(repoId),
+ title: 'App preview',
+ })
+ },
+ onError: () => {
+ showToast.error('Failed to check preview port')
+ },
+ })
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx
index c6df1a35..b88c38f1 100644
--- a/frontend/src/components/file-browser/FileBrowser.tsx
+++ b/frontend/src/components/file-browser/FileBrowser.tsx
@@ -12,6 +12,9 @@ import { FolderOpen, Upload, RefreshCw, X } from 'lucide-react'
import type { FileInfo } from '@/types/files'
import { useMobile } from '@/hooks/useMobile'
import { getFileApiUrl, useFile } from '@/api/files'
+import { HtmlArtifactPanel } from '@/components/html-preview/HtmlArtifactPanel'
+import type { HtmlArtifact } from '@/lib/htmlArtifacts'
+import { createHtmlArtifact, isHtmlPath } from '@/lib/htmlArtifacts'
export interface FileBrowserHandle {
goBack: () => void
@@ -51,6 +54,10 @@ const encodeBase64 = (content: string) => {
return btoa(binary)
}
+const isHtmlFileInfo = (file: FileInfo): boolean => (
+ isHtmlPath(file.path) || isHtmlPath(file.name) || file.mimeType === 'text/html'
+)
+
async function readFileEntry(entry: FileSystemFileEntry): Promise {
return new Promise((resolve, reject) => {
entry.file(resolve, reject)
@@ -135,6 +142,7 @@ export const FileBrowser = forwardRef(funct
const [currentPath, setCurrentPath] = useState(basePath)
const [files, setFiles] = useState(null)
const [selectedFile, setSelectedFile] = useState(null)
+ const [htmlArtifact, setHtmlArtifact] = useState(null)
const [searchQuery, setSearchQuery] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
@@ -152,7 +160,17 @@ useEffect(() => {
if (initialFileData) {
setSelectedFile(initialFileData)
if (isMobile) {
- setIsPreviewModalOpen(true)
+ if (isHtmlFileInfo(initialFileData)) {
+ setHtmlArtifact(createHtmlArtifact({
+ source: 'file',
+ path: initialFileData.path,
+ title: initialFileData.name,
+ }))
+ setIsPreviewModalOpen(false)
+ } else {
+ setHtmlArtifact(null)
+ setIsPreviewModalOpen(true)
+ }
onPreviewStateChange?.(true)
}
}
@@ -262,7 +280,17 @@ useEffect(() => {
// On mobile, open preview in modal
if (isMobile) {
- setIsPreviewModalOpen(true)
+ if (isHtmlFileInfo(fullFileData)) {
+ setHtmlArtifact(createHtmlArtifact({
+ source: 'file',
+ path: fullFileData.path,
+ title: fullFileData.name,
+ }))
+ setIsPreviewModalOpen(false)
+ } else {
+ setHtmlArtifact(null)
+ setIsPreviewModalOpen(true)
+ }
onPreviewStateChange?.(true)
}
} catch (err) {
@@ -275,10 +303,19 @@ useEffect(() => {
const handleCloseModal = useCallback(() => {
setIsPreviewModalOpen(false)
+ setHtmlArtifact(null)
+ setSelectedFile(null)
+ onPreviewStateChange?.(false)
+ }, [onPreviewStateChange])
+
+ const handleCloseHtmlArtifact = useCallback(() => {
+ setHtmlArtifact(null)
setSelectedFile(null)
onPreviewStateChange?.(false)
}, [onPreviewStateChange])
+ const handleToggleHtmlArtifactFullscreen = useCallback(() => {}, [])
+
const handleDirectoryClick = (path: string) => {
loadFiles(path)
}
@@ -645,6 +682,16 @@ useEffect(() => {
file={selectedFile}
showFilePreviewHeader={true}
/>
+
+ {isMobile && htmlArtifact && (
+
+ )}
)
}
@@ -746,6 +793,16 @@ useEffect(() => {
onClose={handleCloseModal}
file={selectedFile}
/>
+
+ {isMobile && htmlArtifact && (
+
+ )}
{uploadDialog}
diff --git a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx
index af322b0a..421c97a6 100644
--- a/frontend/src/components/file-browser/FileBrowserSheet.test.tsx
+++ b/frontend/src/components/file-browser/FileBrowserSheet.test.tsx
@@ -7,6 +7,15 @@ import { FileBrowser } from './FileBrowser'
import type { FileBrowserHandle } from './FileBrowser'
import * as useMobile from '../../hooks/useMobile'
+vi.mock('@/components/html-preview/HtmlArtifactPanel', () => ({
+ HtmlArtifactPanel: ({ artifact, onClose }: { artifact: { path?: string } | null; onClose: () => void }) => (
+
+ {artifact?.path}
+
+
+ ),
+}))
+
function createQueryClient() {
return new QueryClient({
defaultOptions: {
@@ -87,6 +96,60 @@ describe('FileBrowserSheet', () => {
})
describe('FileBrowser navigation', () => {
+ it('opens mobile HTML files with the shared HTML artifact panel', async () => {
+ const mobileSpy = vi.spyOn(useMobile, 'useMobile').mockReturnValue(true)
+ const originalFetch = globalThis.fetch
+ const htmlFile = {
+ name: 'dashboard.html',
+ path: 'test-repo/dashboard.html',
+ isDirectory: false,
+ size: 24,
+ mimeType: 'text/html',
+ content: btoa('Dashboard
'),
+ lastModified: new Date(),
+ }
+
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
+ const url = new URL(String(input), 'http://localhost')
+ const path = url.searchParams.get('path')
+
+ if (path === htmlFile.path) {
+ return {
+ ok: true,
+ json: async () => htmlFile,
+ } as Response
+ }
+
+ return {
+ ok: true,
+ json: async () => ({
+ name: 'test-repo',
+ path: 'test-repo',
+ isDirectory: true,
+ children: [htmlFile],
+ }),
+ } as Response
+ })
+
+ try {
+ render(
+ ,
+ { wrapper: createWrapper() }
+ )
+
+ fireEvent.click(await screen.findByText('dashboard.html'))
+
+ expect(await screen.findByTestId('html-artifact-panel')).toHaveTextContent('test-repo/dashboard.html')
+ expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument()
+ } finally {
+ globalThis.fetch = originalFetch
+ mobileSpy.mockRestore()
+ }
+ })
+
it('exposes imperative handle with goBack, canGoBack, and getCurrentPath', () => {
const ref = { current: null as unknown as FileBrowserHandle }
@@ -1091,5 +1154,3 @@ describe('FileBrowserSheet swipe decision integration', () => {
mockUseSwipeBack.mockRestore()
})
})
-
-
diff --git a/frontend/src/components/file-browser/FilePreview.test.tsx b/frontend/src/components/file-browser/FilePreview.test.tsx
new file mode 100644
index 00000000..99918745
--- /dev/null
+++ b/frontend/src/components/file-browser/FilePreview.test.tsx
@@ -0,0 +1,104 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, fireEvent } from '@testing-library/react'
+import '@testing-library/jest-dom'
+import { FilePreview } from './FilePreview'
+import type { FileInfo } from '@/types/files'
+
+vi.mock('@/api/files', () => ({
+ getFileApiUrl: (path: string) => `/api/files?path=${encodeURIComponent(path)}`,
+ getFilePreviewUrl: (path: string) => {
+ const encodedPath = path.split('/').map(encodeURIComponent).join('/')
+ return `/api/files/preview/${encodedPath}`
+ },
+}))
+
+vi.mock('@/components/ui/virtualized-text-view', () => ({
+ VirtualizedTextView: vi.fn(() => ),
+}))
+
+function htmlFile(content: string, overrides?: Partial): FileInfo {
+ return {
+ name: 'dashboard.html',
+ path: 'test-repo/dashboard.html',
+ isDirectory: false,
+ size: content.length,
+ mimeType: 'text/html',
+ content: btoa(content),
+ lastModified: new Date(),
+ ...overrides,
+ }
+}
+
+function textFile(name: string, content: string, mimeType: string): FileInfo {
+ return {
+ name,
+ path: `test-repo/${name}`,
+ isDirectory: false,
+ size: content.length,
+ mimeType,
+ content: btoa(content),
+ lastModified: new Date(),
+ }
+}
+
+describe('FilePreview - HTML preview', () => {
+ it('renders HTML preview iframe by default for .html file', () => {
+ render(Dashboard')} />)
+
+ const iframe = screen.getByTitle('HTML preview: dashboard.html')
+ expect(iframe).toBeInTheDocument()
+ expect(iframe).toHaveAttribute('sandbox', 'allow-scripts allow-same-origin')
+ expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/dashboard.html')
+ })
+
+ it('toggles to source view when code button is clicked', () => {
+ render(Dashboard')} />)
+
+ const toggleButton = screen.getByTitle('Show HTML source')
+ expect(toggleButton).toBeInTheDocument()
+
+ fireEvent.click(toggleButton)
+
+ expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument()
+ expect(screen.getByText('Dashboard
')).toBeInTheDocument()
+ })
+
+ it('toggles back to preview when eye button is clicked from source view', () => {
+ render(Dashboard')} />)
+
+ const showSourceButton = screen.getByTitle('Show HTML source')
+ fireEvent.click(showSourceButton)
+ expect(screen.queryByTitle('HTML preview: dashboard.html')).not.toBeInTheDocument()
+
+ const showPreviewButton = screen.getByTitle('Preview rendered HTML')
+ expect(showPreviewButton).toBeInTheDocument()
+
+ fireEvent.click(showPreviewButton)
+
+ const iframe = screen.getByTitle('HTML preview: dashboard.html')
+ expect(iframe).toBeInTheDocument()
+ })
+
+ it('renders HTML preview for .htm file', () => {
+ render(HTM test
', { name: 'test.htm', path: 'test-repo/test.htm', mimeType: 'text/html' })} />)
+
+ const iframe = screen.getByTitle('HTML preview: test.htm')
+ expect(iframe).toBeInTheDocument()
+ expect(iframe.getAttribute('src')).toContain('/api/files/preview/test-repo/test.htm')
+ })
+
+ it('does not show HTML preview toggle for non-HTML text file', () => {
+ render()
+
+ expect(screen.queryByTitle('Show HTML source')).not.toBeInTheDocument()
+ expect(screen.queryByTitle('Preview rendered HTML')).not.toBeInTheDocument()
+ expect(screen.getByText('Hello world')).toBeInTheDocument()
+ })
+
+ it('preserves markdown preview behavior for .md files', () => {
+ render()
+
+ const toggleButton = screen.getByTitle('Show raw markdown')
+ expect(toggleButton).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/components/file-browser/FilePreview.tsx b/frontend/src/components/file-browser/FilePreview.tsx
index 9f8dfcb3..155da0d4 100644
--- a/frontend/src/components/file-browser/FilePreview.tsx
+++ b/frontend/src/components/file-browser/FilePreview.tsx
@@ -2,9 +2,11 @@ import { useState, useCallback, useRef, useEffect, memo } from 'react'
import { Button } from '@/components/ui/button'
import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code } from 'lucide-react'
import type { FileInfo } from '@/types/files'
-import { getFileApiUrl } from '@/api/files'
+import { getFileApiUrl, getFilePreviewUrl } from '@/api/files'
import { VirtualizedTextView, type VirtualizedTextViewHandle } from '@/components/ui/virtualized-text-view'
import { MarkdownRenderer } from './MarkdownRenderer'
+import { HtmlPreviewFrame } from '@/components/html-preview/HtmlPreviewFrame'
+import { isHtmlPath } from '@/lib/htmlArtifacts'
const VIRTUALIZATION_THRESHOLD_BYTES = 50_000
@@ -21,6 +23,7 @@ interface FilePreviewProps {
export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) {
const isMarkdownFile = file.name.toLowerCase().endsWith('.md') || file.name.toLowerCase().endsWith('.mdx') || file.mimeType === 'text/markdown'
+ const isHtmlFile = isHtmlPath(file.name) || file.mimeType === 'text/html'
const [viewMode, setViewMode] = useState<'preview' | 'edit'>('preview')
const [editContent, setEditContent] = useState('')
@@ -29,6 +32,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
const [highlightedLine, setHighlightedLine] = useState(initialLineNumber)
const [lineWrap, setLineWrap] = useState(true)
const [markdownPreview, setMarkdownPreview] = useState(isMarkdownFile)
+ const [htmlPreview, setHtmlPreview] = useState(isHtmlFile)
const [isLoadingAllContent, setIsLoadingAllContent] = useState(false)
const [fullContentLoaded, setFullContentLoaded] = useState(false)
const [fullContent, setFullContent] = useState(null)
@@ -42,9 +46,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
useEffect(() => {
setFullContentLoaded(false)
setMarkdownPreview(isMarkdownFile)
+ setHtmlPreview(isHtmlFile)
setFullContent(null)
setLocalMdContent(null)
- }, [file.path, isMarkdownFile])
+ }, [file.path, isMarkdownFile, isHtmlFile])
useEffect(() => {
if (shouldVirtualize && isMarkdownFile && markdownPreview && !isMarkdownTooLarge && !fullContentLoaded) {
@@ -214,6 +219,12 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
['application/json', 'application/xml', 'text/javascript', 'text/typescript'].includes(file.mimeType || '')
const renderContent = () => {
+ const htmlPreviewFrame = (
+
+
+
+ )
+
if (file.mimeType?.startsWith('image/')) {
return (
@@ -227,8 +238,13 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
}
if (shouldVirtualize && isTextFile) {
+ const showHtmlPreview = isHtmlFile && htmlPreview && viewMode !== 'edit'
const showMarkdownPreview = isMarkdownFile && markdownPreview && viewMode !== 'edit'
+ if (showHtmlPreview) {
+ return htmlPreviewFrame
+ }
+
return (
<>
@@ -285,6 +301,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
/>
)
}
+
+ if (isHtmlFile && htmlPreview) {
+ return htmlPreviewFrame
+ }
try {
const textContent = decodeBase64(file.content)
@@ -392,8 +412,20 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
{markdownPreview ?
:
}
)}
+
+ {isHtmlFile && viewMode !== 'edit' && (
+
+ )}
- {isTextFile && !markdownPreview && (
+ {isTextFile && !markdownPreview && !htmlPreview && (
-
-
- {repoLoading || sessionLoading || messagesLoading ? (
-
- ) : opcodeUrl && repoDirectory ? (
-
- ) : null}
-
- {opcodeUrl && repoDirectory && !isEditingMessage && (
-
+
+
+
+ {repoLoading || sessionLoading || messagesLoading ? (
+
+ ) : opcodeUrl && repoDirectory ? (
+
+ ) : null}
+
+ {opcodeUrl && repoDirectory && !isEditingMessage && (
+
{ttsEnabled && !hasPromptContent && !isSessionActive && latestPlayableAssistant && (
@@ -567,6 +593,16 @@ export function SessionDetail() {
)}
+
+ {htmlArtifact && !htmlArtifactFullscreen && !isMobile && (
+
+ )}
{/* Sessions Dialog */}
@@ -598,6 +634,16 @@ export function SessionDetail() {
initialSelectedFile={selectedFilePath}
/>
+ {htmlArtifact && (htmlArtifactFullscreen || isMobile) && (
+
+ )}
+
Date: Sun, 21 Jun 2026 17:17:33 -0400
Subject: [PATCH 3/5] chore: update Docker config, dev-proxy, and HTML preview
components
---
Dockerfile | 2 +-
backend/src/routes/dev-proxy.ts | 2 +-
.../src/services/dev-server/proxy-utils.ts | 8 +-
docker-compose.yml | 5 +-
docs/configuration/docker.md | 26 +++---
.../components/file-browser/FileBrowser.tsx | 88 +++++++------------
.../html-preview/HtmlArtifactPanel.tsx | 2 +-
.../html-preview/HtmlPreviewFrame.tsx | 3 +-
frontend/src/components/message/TextPart.tsx | 4 +-
9 files changed, 53 insertions(+), 87 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 7924a064..769a480f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -112,7 +112,7 @@ RUN chmod +x /docker-entrypoint.sh
RUN mkdir -p /workspace /app/data /home/node/.cache /home/node/.opencode && \
chown -R node:node /workspace /app/data /home/node
-EXPOSE 5003 5100 5101 5102 5103
+EXPOSE 5003 3055
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
CMD curl -f http://localhost:5003/api/health || exit 1
diff --git a/backend/src/routes/dev-proxy.ts b/backend/src/routes/dev-proxy.ts
index 20647f68..838b0ae5 100644
--- a/backend/src/routes/dev-proxy.ts
+++ b/backend/src/routes/dev-proxy.ts
@@ -49,7 +49,6 @@ async function handleProxyRequest(c: Context, db: Database): Promise {
return c.json({ error: 'Invalid repoId' }, 400)
}
- const config = getDevServerConfig(db, repoId)
const port = getDevServerPort(db)
if (isWebSocketUpgrade((key: string) => c.req.header(key))) {
@@ -91,6 +90,7 @@ async function handleProxyRequest(c: Context, db: Database): Promise {
const contentType = upstreamResponse.headers.get('content-type') ?? ''
if (contentType.includes('text/html')) {
+ const config = getDevServerConfig(db, repoId)
const text = await upstreamResponse.text()
const basePath = `${DEV_PROXY_PREFIX}/${repoId}/`
const modified = config.injectBase ? injectBaseTag(text, basePath) : rewriteDevProxyHtmlPaths(text, basePath)
diff --git a/backend/src/services/dev-server/proxy-utils.ts b/backend/src/services/dev-server/proxy-utils.ts
index 38b1a8d7..a7e4ac97 100644
--- a/backend/src/services/dev-server/proxy-utils.ts
+++ b/backend/src/services/dev-server/proxy-utils.ts
@@ -1,6 +1,6 @@
export const DEV_PROXY_PREFIX = '/api/dev-proxy'
-export const HOP_BY_HOP_HEADERS = new Set([
+const HOP_BY_HOP_HEADERS = new Set([
'connection',
'keep-alive',
'proxy-authenticate',
@@ -166,8 +166,7 @@ function rewriteSrcSetCandidate(candidate: string, basePath: string): string {
function toDevProxyPath(path: string, basePath: string): string {
const normalizedBasePath = basePath.endsWith('/') ? basePath.slice(0, -1) : basePath
- const escapedBasePath = escapeRegExp(normalizedBasePath)
- if (new RegExp(`^${escapedBasePath}(?:/|$)`).test(path)) return path
+ if (path === normalizedBasePath || path.startsWith(`${normalizedBasePath}/`)) return path
return `${normalizedBasePath}${path}`
}
@@ -177,6 +176,3 @@ function isBaseTagAttribute(html: string, attributeOffset: number): boolean {
return /^<\s*base\b/i.test(html.slice(tagStart, attributeOffset))
}
-function escapeRegExp(value: string): string {
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
-}
diff --git a/docker-compose.yml b/docker-compose.yml
index 00a02334..74340c0f 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -8,10 +8,7 @@ services:
container_name: opencode-manager
ports:
- "5003:5003"
- - "5100:5100"
- - "5101:5101"
- - "5102:5102"
- - "5103:5103"
+ - "3055:3055"
environment:
- NODE_ENV=${NODE_ENV:-production}
- HOST=0.0.0.0
diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md
index 3c8bcf3f..53a29422 100644
--- a/docs/configuration/docker.md
+++ b/docs/configuration/docker.md
@@ -38,10 +38,7 @@ services:
container_name: opencode-manager
ports:
- "5003:5003"
- - "5100:5100"
- - "5101:5101"
- - "5102:5102"
- - "5103:5103"
+ - "3055:3055"
environment:
- NODE_ENV=${NODE_ENV:-production}
- HOST=0.0.0.0
@@ -146,19 +143,18 @@ ports:
- "8080:5003" # Access at localhost:8080
```
-### Dev Server Ports
+### Dev Server Port
-Ports 5100-5103 are exposed for running dev servers inside repositories:
+The preview proxy reaches a repository's dev server through the main `5003` port at `/api/dev-proxy//`, so the dev server only needs to listen on a single port inside the container. That port defaults to `3055` and is configurable in **Settings** (`devServerPort`); agents receive it as the `$OCM_DEV_SERVER_PORT` environment variable.
+
+Exposing the port to the host is optional — the preview works entirely through `5003`. Map it only if you also want to hit the dev server directly:
```yaml
ports:
- - "5100:5100"
- - "5101:5101"
- - "5102:5102"
- - "5103:5103"
+ - "3055:3055"
```
-Configure your dev server to use one of these ports:
+Run your dev server on `$OCM_DEV_SERVER_PORT` and bind to `0.0.0.0`:
=== "Vite"
@@ -166,7 +162,7 @@ Configure your dev server to use one of these ports:
// vite.config.ts
export default {
server: {
- port: 5100,
+ port: Number(process.env.OCM_DEV_SERVER_PORT) || 3055,
host: '0.0.0.0'
}
}
@@ -175,13 +171,13 @@ Configure your dev server to use one of these ports:
=== "Next.js"
```bash
- next dev -p 5100 -H 0.0.0.0
+ next dev -p $OCM_DEV_SERVER_PORT -H 0.0.0.0
```
=== "Express"
```javascript
- app.listen(5100, '0.0.0.0')
+ app.listen(process.env.OCM_DEV_SERVER_PORT || 3055, '0.0.0.0')
```
## Volume Mounts
@@ -372,7 +368,7 @@ The container creates a default `AGENTS.md` file at `/workspace/.config/opencode
Instructions for AI agents working in the container:
- Reserved ports information
-- Available dev server ports
+- Dev server port (`$OCM_DEV_SERVER_PORT`, default 3055)
- Docker-specific guidelines
### Editing
diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx
index b88c38f1..59d1db7c 100644
--- a/frontend/src/components/file-browser/FileBrowser.tsx
+++ b/frontend/src/components/file-browser/FileBrowser.tsx
@@ -154,27 +154,27 @@ export const FileBrowser = forwardRef(funct
const uploadCancelledRef = useRef(false)
const isMobile = useMobile()
+ const openMobilePreview = useCallback((file: FileInfo) => {
+ if (isHtmlFileInfo(file)) {
+ setHtmlArtifact(createHtmlArtifact({ source: 'file', path: file.path, title: file.name }))
+ setIsPreviewModalOpen(false)
+ } else {
+ setHtmlArtifact(null)
+ setIsPreviewModalOpen(true)
+ }
+ onPreviewStateChange?.(true)
+ }, [onPreviewStateChange])
+
const { data: initialFileData, error: initialFileError } = useFile(initialSelectedFile)
useEffect(() => {
if (initialFileData) {
setSelectedFile(initialFileData)
if (isMobile) {
- if (isHtmlFileInfo(initialFileData)) {
- setHtmlArtifact(createHtmlArtifact({
- source: 'file',
- path: initialFileData.path,
- title: initialFileData.name,
- }))
- setIsPreviewModalOpen(false)
- } else {
- setHtmlArtifact(null)
- setIsPreviewModalOpen(true)
- }
- onPreviewStateChange?.(true)
+ openMobilePreview(initialFileData)
}
}
-}, [initialFileData, isMobile, onPreviewStateChange])
+}, [initialFileData, isMobile, openMobilePreview])
useEffect(() => {
if (initialFileError) {
@@ -280,18 +280,7 @@ useEffect(() => {
// On mobile, open preview in modal
if (isMobile) {
- if (isHtmlFileInfo(fullFileData)) {
- setHtmlArtifact(createHtmlArtifact({
- source: 'file',
- path: fullFileData.path,
- title: fullFileData.name,
- }))
- setIsPreviewModalOpen(false)
- } else {
- setHtmlArtifact(null)
- setIsPreviewModalOpen(true)
- }
- onPreviewStateChange?.(true)
+ openMobilePreview(fullFileData)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file')
@@ -299,23 +288,15 @@ useEffect(() => {
} finally {
setLoading(false)
}
- }, [onFileSelect, isMobile, onPreviewStateChange])
+ }, [onFileSelect, isMobile, openMobilePreview])
- const handleCloseModal = useCallback(() => {
+ const handleClosePreview = useCallback(() => {
setIsPreviewModalOpen(false)
setHtmlArtifact(null)
setSelectedFile(null)
onPreviewStateChange?.(false)
}, [onPreviewStateChange])
- const handleCloseHtmlArtifact = useCallback(() => {
- setHtmlArtifact(null)
- setSelectedFile(null)
- onPreviewStateChange?.(false)
- }, [onPreviewStateChange])
-
- const handleToggleHtmlArtifactFullscreen = useCallback(() => {}, [])
-
const handleDirectoryClick = (path: string) => {
loadFiles(path)
}
@@ -513,13 +494,13 @@ useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
- handleCloseModal()
+ handleClosePreview()
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
- }, [isPreviewModalOpen, handleCloseModal])
+ }, [isPreviewModalOpen, handleClosePreview])
const showNavigateUp = allowNavigateAboveBase && normalizePath(currentPath) !== '..'
? true
@@ -592,6 +573,15 @@ useEffect(() => {
)
+ const htmlArtifactPanel = isMobile && htmlArtifact && (
+
+ )
+
if (embedded) {
return (
{
{/* Mobile: File Preview Modal */}
- {isMobile && htmlArtifact && (
-
- )}
+ {htmlArtifactPanel}
)
}
@@ -790,19 +772,11 @@ useEffect(() => {
{/* Mobile: File Preview Modal */}
- {isMobile && htmlArtifact && (
-
- )}
+ {htmlArtifactPanel}
{uploadDialog}
diff --git a/frontend/src/components/html-preview/HtmlArtifactPanel.tsx b/frontend/src/components/html-preview/HtmlArtifactPanel.tsx
index bc559f5a..ad5dd105 100644
--- a/frontend/src/components/html-preview/HtmlArtifactPanel.tsx
+++ b/frontend/src/components/html-preview/HtmlArtifactPanel.tsx
@@ -14,7 +14,7 @@ interface HtmlArtifactPanelProps {
isFullscreen: boolean
isMobile: boolean
onClose: () => void
- onToggleFullscreen: () => void
+ onToggleFullscreen?: () => void
}
export function HtmlArtifactPanel({
diff --git a/frontend/src/components/html-preview/HtmlPreviewFrame.tsx b/frontend/src/components/html-preview/HtmlPreviewFrame.tsx
index 2f79f425..9b49279b 100644
--- a/frontend/src/components/html-preview/HtmlPreviewFrame.tsx
+++ b/frontend/src/components/html-preview/HtmlPreviewFrame.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react'
import { cn } from '@/lib/utils'
import { normalizeHtmlPreviewDocument } from '@/lib/htmlArtifacts'
@@ -11,7 +12,7 @@ interface HtmlPreviewFrameProps {
export function HtmlPreviewFrame({ title, src, srcDoc, className, sandbox }: HtmlPreviewFrameProps) {
const resolvedSandbox = sandbox ?? (src ? 'allow-scripts allow-same-origin' : 'allow-scripts')
- const normalizedSrcDoc = srcDoc ? normalizeHtmlPreviewDocument(srcDoc) : undefined
+ const normalizedSrcDoc = useMemo(() => (srcDoc ? normalizeHtmlPreviewDocument(srcDoc) : undefined), [srcDoc])
return (