mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 || [];
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user