Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
41 changes: 26 additions & 15 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,7 @@
// workspace requests to the actual VM running the agent on the configured port.
// vm-{id} DNS records are orange-clouded; CF edge terminates TLS and re-encrypts
// to the VM agent's Origin CA cert. This handles both HTTP and WebSocket requests.
app.use('*', async (c, next) => {

Check failure on line 494 in apps/api/src/index.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ1Ydn18r-X0o8pqHZ2J&open=AZ1Ydn18r-X0o8pqHZ2J&pullRequest=607
const url = new URL(c.req.url);
const hostname = url.hostname;
const baseDomain = c.env?.BASE_DOMAIN || '';
Expand Down Expand Up @@ -562,28 +562,14 @@
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.
const headers = new Headers(c.req.raw.headers);
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);

Expand All @@ -594,6 +580,31 @@
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,
Expand Down
57 changes: 22 additions & 35 deletions apps/web/src/components/BrowserSidecar.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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)
Expand All @@ -32,7 +32,6 @@ export const BrowserSidecar: FC<BrowserSidecarProps> = (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 = {
Expand All @@ -41,26 +40,27 @@ export const BrowserSidecar: FC<BrowserSidecarProps> = (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]);

const sidecarStatus = status?.status ?? 'off';
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 (
<div data-testid="browser-sidecar">
{/* Control buttons */}
Expand All @@ -74,7 +74,7 @@ export const BrowserSidecar: FC<BrowserSidecarProps> = (props) => {
aria-label="Start remote browser"
>
<Globe size={14} aria-hidden="true" />
Remote Browser
Start Remote Browser
</Button>
)}

Expand All @@ -83,12 +83,12 @@ export const BrowserSidecar: FC<BrowserSidecarProps> = (props) => {
<Button
variant={isRunning ? 'primary' : 'secondary'}
size="sm"
onClick={() => setShowViewer(!showViewer)}
disabled={isLoading}
aria-label={showViewer ? 'Hide remote browser' : 'Show remote browser'}
onClick={handleOpen}
disabled={isLoading || !status?.url}
aria-label="Open remote browser in new tab"
>
<Monitor size={14} aria-hidden="true" />
{showViewer ? 'Hide' : 'Show'} Browser
<ExternalLink size={14} aria-hidden="true" />
Open Browser
{isStarting && <Loader2 size={12} className="animate-spin" aria-hidden="true" />}
{isStarting && <span className="sr-only">Starting browser...</span>}
</Button>
Expand Down Expand Up @@ -128,19 +128,6 @@ export const BrowserSidecar: FC<BrowserSidecarProps> = (props) => {
</Alert>
)}

{/* Neko viewer iframe */}
{showViewer && isRunning && status?.url && (
<div className="border border-border-default rounded-lg overflow-hidden relative w-full" style={{ aspectRatio: '16/9' }}>
<iframe
src={status.url}
title="Remote Browser"
className="w-full h-full border-none"
allow="autoplay; clipboard-write; clipboard-read"
sandbox="allow-scripts allow-forms allow-popups"
/>
</div>
)}

{/* Port forwarders info */}
{isRunning && status?.ports && status.ports.length > 0 && (
<div className="text-xs text-fg-muted mt-1">
Expand Down
49 changes: 48 additions & 1 deletion apps/web/src/components/project-message-view/SessionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { DetectedPort, NodeResponse, VMSize, WorkspaceResponse } from '@simple-agent-manager/shared';
import { VM_SIZE_LABELS } from '@simple-agent-manager/shared';
import { Button, Dialog, Spinner } from '@simple-agent-manager/ui';
import { Box, CheckCircle2, ChevronDown, ChevronUp, Cloud, Cpu, ExternalLink, FolderOpen, GitBranch, GitCompare, Globe, MapPin, Server } from 'lucide-react';
import { Box, CheckCircle2, ChevronDown, ChevronUp, Cloud, Cpu, ExternalLink, FolderOpen, GitBranch, GitCompare, Globe, Loader2, MapPin, Monitor, Server } from 'lucide-react';
import { useCallback, useState } from 'react';

import { useBrowserSidecar } from '../../hooks/useBrowserSidecar';
import type { ChatSessionResponse } from '../../lib/api';
import { deleteWorkspace, updateProjectTaskStatus } from '../../lib/api';
import { stripMarkdown } from '../../lib/text-utils';
Expand Down Expand Up @@ -60,6 +61,34 @@
const [confirmOpen, setConfirmOpen] = useState(false);
const [completeError, setCompleteError] = useState<string | null>(null);

// Remote browser sidecar (session-mode)
const browserSidecar = useBrowserSidecar(
session.workspaceId && sessionState === 'active'
? { projectId, sessionId: session.id }
: { projectId: '', sessionId: '' } // disabled placeholder
);
const browserEnabled = !!(session.workspaceId && sessionState === 'active');
const browserStatus = browserSidecar.status?.status ?? 'off';
const browserIsRunning = browserStatus === 'running';
const browserIsStarting = browserStatus === 'starting';

const handleOpenBrowser = useCallback(async () => {
if (browserIsRunning && browserSidecar.status?.url) {
window.open(browserSidecar.status.url, '_blank', 'noopener,noreferrer');
return;
}
// Start the browser and open once URL is available
const result = await browserSidecar.start({
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio || 1,
isTouchDevice: 'ontouchstart' in window || navigator.maxTouchPoints > 0,

Check warning on line 85 in apps/web/src/components/project-message-view/SessionHeader.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ1Ydn40r-X0o8pqHZ2K&open=AZ1Ydn40r-X0o8pqHZ2K&pullRequest=607
});
if (result?.url) {
window.open(result.url, '_blank', 'noopener,noreferrer');
}
}, [browserIsRunning, browserSidecar]);

const hasDetails = !!(
taskEmbed?.outputBranch ||
taskEmbed?.outputPrUrl ||
Expand Down Expand Up @@ -245,6 +274,24 @@
</>
)}

{/* Remote Browser — start or open in new tab */}
{browserEnabled && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenBrowser}
disabled={browserSidecar.isLoading}
>
{browserIsStarting ? (
<Loader2 size={14} className="mr-1 animate-spin" />
) : (
<Monitor size={14} className="mr-1" />

Check failure on line 288 in apps/web/src/components/project-message-view/SessionHeader.tsx

View workflow job for this annotation

GitHub Actions / Test

tests/unit/components/session-header.test.tsx > SessionHeader > hides Mark Complete button when task is completed

Error: [vitest] No "Monitor" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ SessionHeader src/components/project-message-view/SessionHeader.tsx:288:22 ❯ Object.react-stack-bottom-frame ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:23863:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:5529:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:8897:19 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:10522:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:15140:22 ❯ workLoopSync ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:14956:41

Check failure on line 288 in apps/web/src/components/project-message-view/SessionHeader.tsx

View workflow job for this annotation

GitHub Actions / Test

tests/unit/components/session-header.test.tsx > SessionHeader > shows Mark Complete button when task is eligible

Error: [vitest] No "Monitor" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ SessionHeader src/components/project-message-view/SessionHeader.tsx:288:22 ❯ Object.react-stack-bottom-frame ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:23863:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:5529:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:8897:19 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:10522:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:15140:22 ❯ workLoopSync ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:14956:41

Check failure on line 288 in apps/web/src/components/project-message-view/SessionHeader.tsx

View workflow job for this annotation

GitHub Actions / Test

tests/unit/components/session-header.test.tsx > SessionHeader > shows Open Workspace button for active sessions with workspace

Error: [vitest] No "Monitor" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ SessionHeader src/components/project-message-view/SessionHeader.tsx:288:22 ❯ Object.react-stack-bottom-frame ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:23863:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:5529:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:8897:19 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:10522:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:15140:22 ❯ workLoopSync ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:14956:41

Check failure on line 288 in apps/web/src/components/project-message-view/SessionHeader.tsx

View workflow job for this annotation

GitHub Actions / Test

tests/unit/components/session-header.test.tsx > SessionHeader > expands to show details when toggle is clicked

Error: [vitest] No "Monitor" export is defined on the "lucide-react" mock. Did you forget to return it from "vi.mock"? If you need to partially mock a module, you can use "importOriginal" helper inside: vi.mock(import("lucide-react"), async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } }) ❯ SessionHeader src/components/project-message-view/SessionHeader.tsx:288:22 ❯ Object.react-stack-bottom-frame ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:23863:20 ❯ renderWithHooks ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:5529:22 ❯ updateFunctionComponent ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:8897:19 ❯ beginWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:10522:18 ❯ runWithFiberInDEV ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:1522:13 ❯ performUnitOfWork ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:15140:22 ❯ workLoopSync ../../node_modules/.pnpm/react-dom@19.1.0_react@19.1.0/node_modules/react-dom/cjs/react-dom-client.development.js:14956:41
)}
{browserIsRunning ? 'Open Browser' : browserIsStarting ? 'Starting...' : 'Remote Browser'}

Check warning on line 290 in apps/web/src/components/project-message-view/SessionHeader.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=raphaeltm_simple-agent-manager&issues=AZ1Ydn40r-X0o8pqHZ2L&open=AZ1Ydn40r-X0o8pqHZ2L&pullRequest=607
{browserIsRunning && <ExternalLink size={10} className="ml-0.5" />}
</Button>
)}

{canMarkComplete && (
<Button
variant="ghost"
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/hooks/useBrowserSidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ interface UseBrowserSidecarResult {
devicePixelRatio?: number;
isTouchDevice?: boolean;
enableAudio?: boolean;
}) => Promise<void>;
}) => Promise<BrowserSidecarStatusResponse | null>;
stop: () => Promise<void>;
refresh: () => Promise<void>;
}
Expand Down Expand Up @@ -88,16 +88,18 @@ export function useBrowserSidecar(
devicePixelRatio?: number;
isTouchDevice?: boolean;
enableAudio?: boolean;
}) => {
}): Promise<BrowserSidecarStatusResponse | null> => {
setIsLoading(true);
setError(null);
try {
const result = isWorkspaceMode
? await startWorkspaceBrowserSidecar(workspaceId!, opts)
: await startBrowserSidecar(projectId!, sessionId!, opts);
setStatus(result);
return result;
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start browser');
return null;
} finally {
setIsLoading(false);
}
Expand Down
34 changes: 34 additions & 0 deletions packages/vm-agent/internal/browser/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,40 @@ func (m *Manager) GetPorts(workspaceID string) []PortForwarder {
return result
}

// GetNekoTarget returns the Neko container's bridge IP and port for a workspace,
// or empty string if no sidecar is running. This is used by the port proxy to route
// requests to the Neko container instead of the DevContainer when the requested
// port matches the Neko sidecar port.
func (m *Manager) GetNekoTarget(ctx context.Context, workspaceID string, requestedPort int) (string, bool) {
m.mu.RLock()
state, ok := m.sidecars[workspaceID]
if !ok || state.Status != StatusRunning || state.NekoPort != requestedPort {
m.mu.RUnlock()
return "", false
}
containerName := state.ContainerName
m.mu.RUnlock()

// Resolve bridge IP via docker inspect
out, err := m.docker.Run(ctx, "inspect", "-f",
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", containerName)
if err != nil {
slog.Warn("Failed to resolve Neko container bridge IP",
"workspace", workspaceID,
"container", containerName,
"error", err)
return "", false
}
ip := strings.TrimSpace(string(out))
if ip == "" {
slog.Warn("Neko container has no bridge IP",
"workspace", workspaceID,
"container", containerName)
return "", false
}
return ip, true
}

// DockerExec returns the underlying Docker executor (used by handlers for network discovery).
func (m *Manager) DockerExec() DockerExecutor {
return m.docker
Expand Down
Loading
Loading