Fix Docker Compose CORS issues with nginx API proxying (#793)

* Changes from fix/docker-compose-cors-error

* Update apps/server/src/index.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Fix: Delete Worktree Crash + PR Comments + Dev Server UX Improvements (#792)

* Changes from fix/delete-worktree-hotifx

* fix: Improve bot detection and prevent UI overflow issues

- Include GitHub app-initiated comments in bot detection
- Wrap handleQuickCreateSession with useCallback to fix dependency issues
- Truncate long branch names in agent header to prevent layout overflow

* feat: Support GitHub App comments in PR review and fix session filtering

* feat: Return invalidation result from delete session handler

* fix: Improve CORS origin validation to handle wildcard correctly

* fix: Correct IPv6 localhost parsing and improve responsive UI layouts

* Changes from fix/pwa-cache-fix (#794)

* fix: Add type checking to prevent crashes from malformed cache entries

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-21 13:56:48 -08:00
committed by GitHub
parent f785f1204b
commit 28becb177b
11 changed files with 100 additions and 53 deletions

View File

@@ -209,9 +209,10 @@ COPY libs ./libs
COPY apps/ui ./apps/ui COPY apps/ui ./apps/ui
# Build packages in dependency order, then build UI # Build packages in dependency order, then build UI
# VITE_SERVER_URL tells the UI where to find the API server # When VITE_SERVER_URL is empty, the UI uses relative URLs (e.g., /api/...) which nginx proxies
# Use ARG to allow overriding at build time: --build-arg VITE_SERVER_URL=http://api.example.com # to the server container. This avoids CORS issues entirely in Docker Compose setups.
ARG VITE_SERVER_URL=http://localhost:3008 # Override at build time if needed: --build-arg VITE_SERVER_URL=http://api.example.com
ARG VITE_SERVER_URL=
ENV VITE_SKIP_ELECTRON=true ENV VITE_SKIP_ELECTRON=true
ENV VITE_SERVER_URL=${VITE_SERVER_URL} ENV VITE_SERVER_URL=${VITE_SERVER_URL}
RUN npm run build:packages && npm run build --workspace=apps/ui RUN npm run build:packages && npm run build --workspace=apps/ui

View File

@@ -267,6 +267,26 @@ app.use(
// CORS configuration // CORS configuration
// When using credentials (cookies), origin cannot be '*' // When using credentials (cookies), origin cannot be '*'
// We dynamically allow the requesting origin for local development // We dynamically allow the requesting origin for local development
// Check if origin is a local/private network address
function isLocalOrigin(origin: string): boolean {
try {
const url = new URL(origin);
const hostname = url.hostname;
return (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '[::1]' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(hostname)
);
} catch {
return false;
}
}
app.use( app.use(
cors({ cors({
origin: (origin, callback) => { origin: (origin, callback) => {
@@ -277,35 +297,25 @@ app.use(
} }
// If CORS_ORIGIN is set, use it (can be comma-separated list) // If CORS_ORIGIN is set, use it (can be comma-separated list)
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((o) => o.trim()); const allowedOrigins = process.env.CORS_ORIGIN?.split(',')
if (allowedOrigins && allowedOrigins.length > 0 && allowedOrigins[0] !== '*') { .map((o) => o.trim())
if (allowedOrigins.includes(origin)) { .filter(Boolean);
callback(null, origin); if (allowedOrigins && allowedOrigins.length > 0) {
} else { if (allowedOrigins.includes('*')) {
callback(new Error('Not allowed by CORS')); callback(null, true);
return;
} }
return; if (allowedOrigins.includes(origin)) {
}
// For local development, allow all localhost/loopback origins (any port)
try {
const url = new URL(origin);
const hostname = url.hostname;
if (
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname === '::1' ||
hostname === '0.0.0.0' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.')
) {
callback(null, origin); callback(null, origin);
return; return;
} }
} catch { // Fall through to local network check below
// Ignore URL parsing errors }
// Allow all localhost/loopback/private network origins (any port)
if (isLocalOrigin(origin)) {
callback(null, origin);
return;
} }
// Reject other origins by default for security // Reject other origins by default for security

View File

@@ -1,9 +1,28 @@
# Map for conditional WebSocket upgrade header
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server { server {
listen 80; listen 80;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Proxy API and WebSocket requests to the backend server container
# Handles both HTTP API calls and WebSocket upgrades (/api/events, /api/terminal/ws)
location /api/ {
proxy_pass http://server:3008;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }

View File

@@ -914,7 +914,7 @@ export function PRCommentResolutionDialog({
{!loading && !error && allComments.length > 0 && ( {!loading && !error && allComments.length > 0 && (
<> <>
{/* Controls Bar */} {/* Controls Bar */}
<div className="flex items-center justify-between gap-4 px-1"> <div className="flex flex-wrap items-center justify-between gap-2 px-1">
{/* Select All - only interactive when there are visible comments */} {/* Select All - only interactive when there are visible comments */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@@ -935,7 +935,7 @@ export function PRCommentResolutionDialog({
</Label> </Label>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
{/* Show/Hide Resolved Filter Toggle - always visible */} {/* Show/Hide Resolved Filter Toggle - always visible */}
<Button <Button
variant="ghost" variant="ghost"
@@ -990,7 +990,7 @@ export function PRCommentResolutionDialog({
</Button> </Button>
{/* Mode Toggle */} {/* Mode Toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<Label <Label
className={cn( className={cn(
'text-xs cursor-pointer', 'text-xs cursor-pointer',

View File

@@ -301,8 +301,13 @@ export function SessionManager({
const refetchResult = await invalidateSessions(); const refetchResult = await invalidateSessions();
if (currentSessionId === sessionId) { if (currentSessionId === sessionId) {
// Switch to another session using fresh data, excluding the deleted session // Switch to another session using fresh data, excluding the deleted session
// Filter to sessions within the same worktree to avoid jumping to a different worktree
const freshSessions = refetchResult?.data ?? []; const freshSessions = refetchResult?.data ?? [];
const activeSessionsList = freshSessions.filter((s) => !s.isArchived && s.id !== sessionId); const activeSessionsList = freshSessions.filter((s) => {
if (s.isArchived || s.id === sessionId) return false;
const sessionDir = s.workingDirectory || s.projectPath;
return pathsEqual(sessionDir, effectiveWorkingDirectory);
});
if (activeSessionsList.length > 0) { if (activeSessionsList.length > 0) {
onSelectSession(activeSessionsList[0].id); onSelectSession(activeSessionsList[0].id);
} }

View File

@@ -480,6 +480,7 @@ export function BoardView() {
// Find the worktree that matches the current selection, or use main worktree // Find the worktree that matches the current selection, or use main worktree
const selectedWorktree = useMemo((): WorktreeInfo | undefined => { const selectedWorktree = useMemo((): WorktreeInfo | undefined => {
let found; let found;
let usedFallback = false;
if (currentWorktreePath === null) { if (currentWorktreePath === null) {
// Primary worktree selected - find the main worktree // Primary worktree selected - find the main worktree
found = worktrees.find((w) => w.isMain); found = worktrees.find((w) => w.isMain);
@@ -487,9 +488,11 @@ export function BoardView() {
// Specific worktree selected - find it by path // Specific worktree selected - find it by path
found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath)); found = worktrees.find((w) => !w.isMain && pathsEqual(w.path, currentWorktreePath));
// If the selected worktree no longer exists (e.g. just deleted), // If the selected worktree no longer exists (e.g. just deleted),
// fall back to main to prevent rendering with undefined worktree // fall back to main to prevent rendering with undefined worktree.
// onDeleted will call setCurrentWorktree(…, null) to reset properly.
if (!found) { if (!found) {
found = worktrees.find((w) => w.isMain); found = worktrees.find((w) => w.isMain);
usedFallback = true;
} }
} }
if (!found) return undefined; if (!found) return undefined;
@@ -498,7 +501,11 @@ export function BoardView() {
...found, ...found,
isCurrent: isCurrent:
found.isCurrent ?? found.isCurrent ??
(currentWorktreePath !== null ? pathsEqual(found.path, currentWorktreePath) : found.isMain), (usedFallback
? found.isMain // treat main as current during the transient fallback render
: currentWorktreePath !== null
? pathsEqual(found.path, currentWorktreePath)
: found.isMain),
hasWorktree: found.hasWorktree ?? true, hasWorktree: found.hasWorktree ?? true,
}; };
}, [worktrees, currentWorktreePath]); }, [worktrees, currentWorktreePath]);

View File

@@ -220,6 +220,7 @@ export function useBoardActions({
const { const {
initialStatus: requestedStatus, initialStatus: requestedStatus,
workMode: _workMode, workMode: _workMode,
childDependencies,
...restFeatureData ...restFeatureData
} = featureData; } = featureData;
const initialStatus = requestedStatus || 'backlog'; const initialStatus = requestedStatus || 'backlog';
@@ -244,8 +245,8 @@ export function useBoardActions({
saveCategory(featureData.category); saveCategory(featureData.category);
// Handle child dependencies - update other features to depend on this new feature // Handle child dependencies - update other features to depend on this new feature
if (featureData.childDependencies && featureData.childDependencies.length > 0) { if (childDependencies && childDependencies.length > 0) {
for (const childId of featureData.childDependencies) { for (const childId of childDependencies) {
const childFeature = features.find((f) => f.id === childId); const childFeature = features.find((f) => f.id === childId);
if (childFeature) { if (childFeature) {
const childDeps = childFeature.dependencies || []; const childDeps = childFeature.dependencies || [];

View File

@@ -267,6 +267,13 @@ export function WorktreeActionsDropdown({
}; };
}, [showPRInfo, worktree.pr]); }, [showPRInfo, worktree.pr]);
const viewDevServerLogsItem = (
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Dev Server Logs
</DropdownMenuItem>
);
return ( return (
<DropdownMenu onOpenChange={onOpenChange}> <DropdownMenu onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -411,12 +418,7 @@ export function WorktreeActionsDropdown({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" /> <DropdownMenuSubTrigger className="text-xs px-1 rounded-l-none border-l border-border/30 h-8" />
</div> </div>
<DropdownMenuSubContent> <DropdownMenuSubContent>{viewDevServerLogsItem}</DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Dev Server Logs
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>
@@ -443,12 +445,7 @@ export function WorktreeActionsDropdown({
disabled={isStartingDevServer} disabled={isStartingDevServer}
/> />
</div> </div>
<DropdownMenuSubContent> <DropdownMenuSubContent>{viewDevServerLogsItem}</DropdownMenuSubContent>
<DropdownMenuItem onClick={() => onViewDevServerLogs(worktree)} className="text-xs">
<ScrollText className="w-3.5 h-3.5 mr-2" />
View Dev Server Logs
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</> </>

View File

@@ -467,8 +467,7 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
} catch (error) { } catch (error) {
logger.error('Start dev server failed:', error); logger.error('Start dev server failed:', error);
toast.error('Failed to start dev server', { toast.error('Failed to start dev server', {
description: description: error instanceof Error ? error.message : undefined,
error instanceof Error ? error.message : 'Check the dev server logs panel for details.',
}); });
} finally { } finally {
setIsStartingDevServer(false); setIsStartingDevServer(false);

View File

@@ -174,13 +174,19 @@ export function restoreFromUICache(
) { ) {
const sanitized: Record<string, { path: string | null; branch: string }> = {}; const sanitized: Record<string, { path: string | null; branch: string }> = {};
for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) { for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) {
if (worktree.path === null) { if (
typeof worktree === 'object' &&
worktree !== null &&
'path' in worktree &&
worktree.path === null
) {
// Main branch selection — always safe to restore // Main branch selection — always safe to restore
sanitized[projectPath] = worktree; sanitized[projectPath] = worktree;
} }
// Non-null paths are dropped; the app will re-discover actual worktrees // Non-null paths are dropped; the app will re-discover actual worktrees
// from the server and the validation effect in use-worktrees will handle // from the server and the validation effect in use-worktrees will handle
// resetting to main if the cached worktree no longer exists. // resetting to main if the cached worktree no longer exists.
// Null/malformed entries are also dropped to prevent crashes.
} }
if (Object.keys(sanitized).length > 0) { if (Object.keys(sanitized).length > 0) {
stateUpdate.currentWorktreeByProject = sanitized; stateUpdate.currentWorktreeByProject = sanitized;

View File

@@ -64,8 +64,10 @@ services:
# Optional - data directory for sessions, settings, etc. (container-only) # Optional - data directory for sessions, settings, etc. (container-only)
- DATA_DIR=/data - DATA_DIR=/data
# Optional - CORS origin (default allows all) # Optional - CORS origin (default: auto-detect local network origins)
- CORS_ORIGIN=${CORS_ORIGIN:-http://localhost:3007} # With nginx proxying API requests, CORS is not needed for same-origin access.
# Set explicitly only if accessing the API from a different domain.
- CORS_ORIGIN=${CORS_ORIGIN:-}
# Internal - indicates the API is running in a containerized sandbox environment # Internal - indicates the API is running in a containerized sandbox environment
# This is used by the UI to determine if sandbox risk warnings should be shown # This is used by the UI to determine if sandbox risk warnings should be shown