diff --git a/CLAUDE.md b/CLAUDE.md index 1f8457a5..ce2653e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -220,7 +220,7 @@ Domains chain together: competitive research feeds marketing and business strate ## Recent Changes - strengthen-eslint-configuration: Strengthened ESLint with `eslint-plugin-jsx-a11y` (accessibility warnings for `.tsx`), `eslint-plugin-simple-import-sort` (import ordering as errors — CI-breaking), `@typescript-eslint/consistent-type-imports` (enforces `import type` syntax as errors — auto-fixable), `@typescript-eslint/no-non-null-assertion` (warning); 645 files auto-fixed for import ordering and type imports; a11y rules start as warnings for incremental adoption; `no-console` enforcement for API code (from PR #581) remains in place -- neko-browser-streaming-sidecar: Neko remote browser sidecar for workspaces — VM agent `internal/browser/` package manages Neko container lifecycle (start/stop/status) per workspace, Docker network attachment, and socat port forwarders syncing DevContainer ports via `/proc/net/tcp` polling; 4 HTTP endpoints (`POST/GET/DELETE /workspaces/{id}/browser`, `GET /workspaces/{id}/browser/ports`); API Worker proxy routes at both project-session level (`/projects/:id/sessions/:sessionId/browser`) and workspace level (`/workspaces/:id/browser`); cloud-init optional Neko image pre-pull (`nekoImage`, `nekoPrePull` variables); `BrowserSidecar` React component with workspace/session dual-mode, mobile viewport detection, Neko iframe embed; `useBrowserSidecar` hook with auto-polling; integrated into `WorkspaceSidebar` collapsible section; configurable via NEKO_IMAGE (default: ghcr.io/m1k1o/neko/google-chrome:latest), NEKO_SCREEN_RESOLUTION (default: 1920x1080), NEKO_MAX_FPS (default: 30), NEKO_WEBRTC_PORT (default: 8080), NEKO_SOCAT_POLL_INTERVAL (default: 5s), NEKO_MIN_RAM_MB (default: 2048), NEKO_ENABLE_AUDIO (default: true), NEKO_TCP_FALLBACK (default: true), BROWSER_PROXY_TIMEOUT_MS (default: 30000) +- neko-browser-streaming-sidecar: Neko remote browser sidecar for workspaces — VM agent `internal/browser/` package manages Neko container lifecycle (start/stop/status) per workspace, Docker network attachment, and socat port forwarders syncing DevContainer ports via `/proc/net/tcp` polling; 4 HTTP endpoints (`POST/GET/DELETE /workspaces/{id}/browser`, `GET /workspaces/{id}/browser/ports`); API Worker proxy routes at both project-session level (`/projects/:id/sessions/:sessionId/browser`) and workspace level (`/workspaces/:id/browser`); cloud-init optional Neko image pre-pull (`nekoImage`, `nekoPrePull` variables); `BrowserSidecar` React component with workspace/session dual-mode, mobile viewport detection, Neko iframe embed; `useBrowserSidecar` hook with auto-polling; integrated into `WorkspaceSidebar` collapsible section; configurable via NEKO_IMAGE (default: ghcr.io/m1k1o/neko/google-chrome:latest), NEKO_SCREEN_RESOLUTION (default: 1920x1080), NEKO_MAX_FPS (default: 30), NEKO_WEBRTC_PORT (default: 6080), NEKO_SOCAT_POLL_INTERVAL (default: 5s), NEKO_MIN_RAM_MB (default: 2048), NEKO_ENABLE_AUDIO (default: true), NEKO_TCP_FALLBACK (default: true), BROWSER_PROXY_TIMEOUT_MS (default: 30000) - per-project-scaling-provider-locations: Per-project scaling parameters and provider-aware location validation — `PROVIDER_LOCATIONS` registry in shared constants maps each provider (hetzner, scaleway, gcp) to valid locations; `isValidLocationForProvider()`, `getLocationsForProvider()`, `getDefaultLocationForProvider()` validation functions; 9 new nullable columns on projects table (defaultLocation + 8 scaling params: taskExecutionTimeoutMs, maxConcurrentTasks, maxDispatchDepth, maxSubTasksPerTask, warmNodeTimeoutMs, maxWorkspacesPerNode, nodeCpuThresholdPercent, nodeMemoryThresholdPercent); `resolveProjectScalingConfig()` helper for project→env→default fallback chain; `SCALING_PARAMS` registry with ScalingParamMeta for UI generation; API validation on PATCH projects, POST nodes, POST tasks (submit + run); TaskRunner DO uses projectScaling for node capacity thresholds and warm timeout; MCP dispatch tools use project overrides for depth/concurrency/sub-task limits; NodeLifecycle DO accepts warmTimeoutOverrideMs; ScalingSettings UI component with provider/location dropdowns, task limit fields, node scheduling fields, platform defaults as placeholders, per-field reset; location resolution: explicit override → project defaultLocation → provider default → platform default - task-submission-file-attachments: Task submission file attachments via R2 presigned uploads — `POST /api/projects/:id/tasks/request-upload` generates presigned PUT URL for direct browser→R2 upload; `uploadAttachmentToR2()` client function with XHR progress events; `TaskSubmitForm` and `ProjectChat` attachment UI (paperclip button, progress chips, file validation); `validateAttachments()` R2 HEAD checks at submit time; `attachment_transfer` execution step in TaskRunner DO (between `workspace_ready` and `agent_session`) downloads from R2 and uploads to workspace `.private/` via VM agent; augmented initial prompt lists attached files; `cleanupAttachments()` eager R2 delete after transfer; shared types `TaskAttachment`, `RequestAttachmentUploadRequest/Response`, `ATTACHMENT_DEFAULTS`, `SAFE_FILENAME_REGEX`; configurable via R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, CF_ACCOUNT_ID, ATTACHMENT_UPLOAD_MAX_BYTES (default: 50MB), ATTACHMENT_UPLOAD_BATCH_MAX_BYTES (default: 200MB), ATTACHMENT_MAX_FILES (default: 20), ATTACHMENT_PRESIGN_EXPIRY_SECONDS (default: 900) - workspace-file-upload-download: File upload/download for workspace sessions — VM agent `POST /workspaces/{id}/files/upload` (multipart, `docker exec tee`, no shell interpolation) with configurable per-file max (FILE_UPLOAD_MAX_BYTES, default: 50MB) and batch max (FILE_UPLOAD_BATCH_MAX_BYTES, default: 250MB); `GET /workspaces/{id}/files/download?path=...` with `docker exec cat` and CRLF-stripped Content-Disposition; API proxy routes `POST/GET /api/projects/:id/sessions/:sessionId/files/upload|download` with size-limited streaming; `uploadSessionFiles`/`downloadSessionFile` client functions; Paperclip attach button in `FollowUpInput`; download button in `ChatFilePanel` view mode; `.private` upload destination created during bootstrap (`ensureVolumeWritable`); safe filename regex rejects shell metacharacters; configurable via FILE_UPLOAD_MAX_BYTES, FILE_UPLOAD_BATCH_MAX_BYTES, FILE_UPLOAD_TIMEOUT (VM agent), FILE_UPLOAD_TIMEOUT_MS, FILE_DOWNLOAD_TIMEOUT_MS, FILE_DOWNLOAD_MAX_BYTES (Worker) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 29e7716c..172283cb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -562,21 +562,6 @@ app.use('*', async (c, next) => { if (targetPort !== null) { const subPath = url.pathname === '/' ? '' : url.pathname; vmUrl.pathname = `/workspaces/${workspaceId}/ports/${targetPort}${subPath}`; - - // Inject a workspace-scoped JWT so the VM agent can authenticate this request. - // Port-forwarded URLs are accessed directly by browsers which have no pre-existing - // workspace session cookie or token. The Worker is a trusted intermediary that has - // already validated the workspace exists and is running. - try { - const { token } = await signTerminalToken('port-proxy', workspaceId, c.env); - vmUrl.searchParams.set('token', token); - } catch (err) { - log.error('port_proxy_token_error', { - workspaceId, - ...serializeError(err), - }); - return c.json({ error: 'TOKEN_ERROR', message: 'Failed to generate port proxy token' }, 500); - } } // Strip client-supplied routing headers and inject trusted routing context. @@ -584,6 +569,7 @@ app.use('*', async (c, next) => { headers.delete('x-sam-node-id'); headers.delete('x-sam-workspace-id'); headers.delete('x-forwarded-host'); + headers.delete('authorization'); headers.set('X-SAM-Node-Id', (workspace.nodeId || workspaceId)); headers.set('X-SAM-Workspace-Id', workspaceId); @@ -594,6 +580,31 @@ app.use('*', async (c, next) => { headers.set('X-Forwarded-Host', hostname); headers.set('X-Forwarded-Proto', 'https'); + // For port-proxied requests, inject a workspace-scoped JWT so the VM agent can + // authenticate. The Worker is a trusted intermediary that has already validated the + // workspace exists and is running. + // + // CRITICAL: Cloudflare's edge proxy returns 502 for same-zone subrequests that + // contain long JWT tokens in ANY header value (Authorization, custom headers, query + // params). This appears to be a WAF/bot-protection rule that cannot be disabled. + // Workaround: split the JWT across two headers so neither individual value triggers + // the detection. The VM agent reassembles them. Verified 2026-04-04. + if (targetPort !== null) { + try { + const { token } = await signTerminalToken('port-proxy', workspaceId, c.env); + // Split JWT into two halves across separate headers to avoid CF edge 502. + const mid = Math.ceil(token.length / 2); + headers.set('X-SAM-Port-Token-A', token.substring(0, mid)); + headers.set('X-SAM-Port-Token-B', token.substring(mid)); + } catch (err) { + log.error('port_proxy_token_error', { + workspaceId, + ...serializeError(err), + }); + return c.json({ error: 'TOKEN_ERROR', message: 'Failed to generate port proxy token' }, 500); + } + } + return fetch(vmUrl.toString(), { method: c.req.raw.method, headers, diff --git a/apps/web/src/components/BrowserSidecar.tsx b/apps/web/src/components/BrowserSidecar.tsx index cf3b2b59..76b15475 100644 --- a/apps/web/src/components/BrowserSidecar.tsx +++ b/apps/web/src/components/BrowserSidecar.tsx @@ -1,6 +1,6 @@ -import { Alert,Button } from '@simple-agent-manager/ui'; -import { Globe, Loader2, Monitor,X } from 'lucide-react'; -import { type FC, useCallback, useEffect,useState } from 'react'; +import { Alert, Button } from '@simple-agent-manager/ui'; +import { ExternalLink, Globe, Loader2, X } from 'lucide-react'; +import { type FC, useCallback } from 'react'; import { useBrowserSidecar } from '../hooks/useBrowserSidecar'; @@ -19,8 +19,8 @@ interface BrowserSidecarWorkspaceProps { type BrowserSidecarProps = BrowserSidecarSessionProps | BrowserSidecarWorkspaceProps; /** - * BrowserSidecar provides a button to start/stop a Neko remote browser sidecar - * and an iframe to view the browser stream when running. + * BrowserSidecar provides controls to start/stop a Neko remote browser sidecar + * and opens it in a new browser tab when running. * * Supports two modes: * - Session mode: requires projectId + sessionId (used in project chat) @@ -32,7 +32,6 @@ export const BrowserSidecar: FC = (props) => { : { projectId: props.projectId!, sessionId: props.sessionId! }; const { status, isLoading, error, start, stop } = useBrowserSidecar(hookOptions); - const [showViewer, setShowViewer] = useState(false); const handleStart = useCallback(async () => { const opts = { @@ -41,12 +40,20 @@ export const BrowserSidecar: FC = (props) => { devicePixelRatio: window.devicePixelRatio || 1, isTouchDevice: 'ontouchstart' in window || navigator.maxTouchPoints > 0, }; - await start(opts); - setShowViewer(true); + const result = await start(opts); + // Open in new tab once URL is available + if (result?.url) { + window.open(result.url, '_blank', 'noopener,noreferrer'); + } }, [start]); + const handleOpen = useCallback(() => { + if (status?.url) { + window.open(status.url, '_blank', 'noopener,noreferrer'); + } + }, [status?.url]); + const handleStop = useCallback(async () => { - setShowViewer(false); await stop(); }, [stop]); @@ -54,13 +61,6 @@ export const BrowserSidecar: FC = (props) => { const isRunning = sidecarStatus === 'running'; const isStarting = sidecarStatus === 'starting'; - // Reset viewer when sidecar transitions to off or error - useEffect(() => { - if (sidecarStatus === 'off' || sidecarStatus === 'error') { - setShowViewer(false); - } - }, [sidecarStatus]); - return (
{/* Control buttons */} @@ -74,7 +74,7 @@ export const BrowserSidecar: FC = (props) => { aria-label="Start remote browser" >