Skip to content
Merged
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
14 changes: 10 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,6 @@ RUN echo "Installing microsandbox=${MICROSANDBOX_VERSION} (cachebust=${TOOLS_CAC
chmod -R a+rX /opt/microsandbox && \
msb --version

RUN echo "Installing Chromium runtime libraries for playwright=${PLAYWRIGHT_VERSION} (cachebust=${TOOLS_CACHEBUST})" && \
npx --yes "playwright@${PLAYWRIGHT_VERSION}" install-deps chromium && \
rm -rf /var/lib/apt/lists/* /root/.npm

ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=5003
Expand All @@ -125,6 +121,16 @@ ENV XDG_CACHE_HOME=/home/node/.cache
ENV OPENCODE_BUNDLED_VERSION=${OPENCODE_VERSION}
ENV MSB_PATH=/usr/local/bin/msb
ENV MSB_LIBKRUNFW_PATH=/opt/microsandbox/lib/libkrunfw.so
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
ENV NODE_PATH=/usr/local/lib/node_modules

RUN echo "Installing playwright=${PLAYWRIGHT_VERSION} chromium (cachebust=${TOOLS_CACHEBUST})" && \
npm install -g "playwright@${PLAYWRIGHT_VERSION}" && \
test "$(playwright --version | awk '{print $2}')" = "${PLAYWRIGHT_VERSION}" && \
playwright install --with-deps chromium && \
chown -R node:node "${PLAYWRIGHT_BROWSERS_PATH}" && \
chmod -R a+rX "${PLAYWRIGHT_BROWSERS_PATH}" && \
rm -rf /var/lib/apt/lists/* /root/.npm

COPY --from=deps --chown=node:node /app/node_modules ./node_modules
COPY --from=builder /app/shared ./shared
Expand Down
14 changes: 14 additions & 0 deletions backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ Prefer **pnpm** or **bun** over npm for installing dependencies to save disk spa
- uv is pre-installed in the container and provides faster package installation
- .venv directories created in repos will persist but can be removed safely

## Browser Automation

Playwright and Chromium are pre-installed, so browser automation runs without a project-local install:

- The \`playwright\` package resolves from any working directory via \`NODE_PATH\`
- Chromium is installed at \`PLAYWRIGHT_BROWSERS_PATH\`
- Run scripts with \`node\`, \`bun\`, or \`pnpm\`; no extra setup is needed

\`\`\`bash
node -e "const { chromium } = require('playwright'); (async () => { const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('https://example.com'); console.log(await page.title()); await browser.close(); })();"
\`\`\`

If Chromium fails to launch because the host kernel restricts user namespaces, pass \`--no-sandbox\`; the container is already the isolation boundary.

## General Guidelines

- This file is merged with any AGENTS.md files in individual repositories
Expand Down
15 changes: 11 additions & 4 deletions backend/test/scripts/docker-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,23 @@ describe('chromium runtime libraries for playwright', () => {
const sandboxDockerfile = read(join(repoRoot, 'Dockerfile.sandbox'))
const workflow = read(join(repoRoot, '.github/workflows/docker-build.yml'))
const installRun = dockerfile.slice(
dockerfile.indexOf('Installing Chromium runtime libraries'),
dockerfile.indexOf('ENV NODE_ENV=production'),
dockerfile.indexOf('Installing playwright='),
dockerfile.indexOf('COPY --from=deps --chown=node:node /app/node_modules'),
)

it('declares PLAYWRIGHT_VERSION next to the other tool args', () => {
expect(dockerfile).toMatch(/ARG PLAYWRIGHT_VERSION=1\.63\.0/)
})

it('resolves the system dependency list from the pinned playwright version', () => {
expect(installRun).toMatch(/npx --yes "playwright@\$\{PLAYWRIGHT_VERSION\}" install-deps chromium/)
it('installs the pinned playwright and verifies its version', () => {
expect(installRun).toContain('npm install -g "playwright@${PLAYWRIGHT_VERSION}"')
expect(installRun).toContain('test "$(playwright --version | awk \'{print $2}\')" = "${PLAYWRIGHT_VERSION}"')
})

it('preinstalls chromium with its system dependencies into a shared browsers path', () => {
expect(dockerfile).toContain('ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright')
expect(installRun).toContain('playwright install --with-deps chromium')
expect(installRun).toContain('chown -R node:node "${PLAYWRIGHT_BROWSERS_PATH}"')
})

it('does not hand-maintain a package list', () => {
Expand Down
1 change: 1 addition & 0 deletions docs/configuration/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ Instructions for AI agents working in the container:
- Reserved ports information
- Available dev server ports
- Docker-specific guidelines
- Pre-installed browser automation (Playwright and Chromium)

### Editing

Expand Down
2 changes: 1 addition & 1 deletion docs/features/sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ Pin a concrete tag or digest rather than a floating one. Attestation compares th

Override any tool pin at build time with its `ARG`, for example `--build-arg PLAYWRIGHT_VERSION=1.62.0` or `--build-arg RUST_VERSION=1.97.0`; the build asserts the installed version, so a typo fails early instead of shipping a stale tool. If your project drives Playwright itself, match this version to the one in your `package.json`; a mismatched browser revision makes Playwright refuse to launch. Rebuild and republish the guest image, then update the `SANDBOX.IMAGE` digest, whenever you change a pin.

The Manager image itself carries the same Chromium runtime libraries, resolved by `playwright install-deps chromium` for the same `PLAYWRIGHT_VERSION` at build time. That is what makes a Playwright e2e suite run in a container with sandboxing off, where the agent has no root or sudo to install them at runtime. Both images track one pin, so bumping `PLAYWRIGHT_VERSION` refreshes the sandbox browser and the Manager's system libraries together.
The Manager image itself carries the same Chromium browser and `playwright` package, installed by `playwright install --with-deps chromium` for the same `PLAYWRIGHT_VERSION` at build time and made world-readable. That is what makes browser automation run in a container with sandboxing off, where the agent has no root or sudo to install them at runtime; `NODE_PATH=/usr/local/lib/node_modules` lets agent code resolve `playwright` from any working directory. Both images track one pin, so bumping `PLAYWRIGHT_VERSION` refreshes the sandbox browser and the Manager's browser together.

## Caveats

Expand Down
37 changes: 25 additions & 12 deletions frontend/src/components/file-browser/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,21 +236,23 @@ useEffect(() => {
getCurrentPath: () => currentPath,
}), [currentPath, goToParentDirectory, canNavigateUp])

const handleFileSelect = useCallback(async (file: FileInfo) => {
if (file.isDirectory) {
setSelectedFile(null)
return
}

// Fetch the full file content when selecting a file
const openFilePath = useCallback(async (path: string) => {
setLoading(true)
try {
const response = await fetch(getFileApiUrl(file.path))
const response = await fetch(getFileApiUrl(path))
if (!response.ok) {
throw new Error(`Failed to load file: ${response.statusText}`)
}

const fullFileData = await response.json()
const fullFileData: FileInfo = await response.json()
if (fullFileData.isDirectory) {
setSelectedFile(null)
setIsPreviewModalOpen(false)
onPreviewStateChange?.(false)
await loadFiles(fullFileData.path)
return
}

setSelectedFile(fullFileData)
onFileSelect?.(fullFileData)

Expand All @@ -265,7 +267,16 @@ useEffect(() => {
} finally {
setLoading(false)
}
}, [onFileSelect, isMobile, onPreviewStateChange])
}, [onFileSelect, isMobile, onPreviewStateChange, loadFiles])

const handleFileSelect = useCallback(async (file: FileInfo) => {
if (file.isDirectory) {
setSelectedFile(null)
return
}

await openFilePath(file.path)
}, [openFilePath])

const handleCloseModal = useCallback(() => {
setIsPreviewModalOpen(false)
Expand Down Expand Up @@ -639,7 +650,7 @@ useEffect(() => {
{!isMobile && (
<div className="flex-1 overflow-y-auto min-h-0 h-full">
{selectedFile && !selectedFile.isDirectory ? (
<FilePreview key={selectedFile.path} file={selectedFile} />
<FilePreview key={selectedFile.path} file={selectedFile} onOpenFile={openFilePath} />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
Select a file to preview
Expand All @@ -655,6 +666,7 @@ useEffect(() => {
onClose={handleCloseModal}
file={selectedFile}
showFilePreviewHeader={true}
onOpenFile={openFilePath}
/>
</div>
)
Expand Down Expand Up @@ -740,7 +752,7 @@ useEffect(() => {
{!isMobile && (
<div className="flex-1 overflow-y-auto min-h-0 ">
{selectedFile && !selectedFile.isDirectory ? (
<FilePreview key={selectedFile.path} file={selectedFile} />
<FilePreview key={selectedFile.path} file={selectedFile} onOpenFile={openFilePath} />
) : (
<div className="flex items-center justify-center h-64 text-muted-foreground">
Select a file to preview
Expand All @@ -756,6 +768,7 @@ useEffect(() => {
isOpen={isMobile && isPreviewModalOpen}
onClose={handleCloseModal}
file={selectedFile}
onOpenFile={openFilePath}
/>

{uploadDialog}
Expand Down
52 changes: 51 additions & 1 deletion frontend/src/components/file-browser/FilePreview.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { FilePreview } from './FilePreview'
import type { FileInfo } from '@/types/files'
Expand Down Expand Up @@ -55,6 +55,56 @@ describe('FilePreview header buttons', () => {
expect(screen.getByText('# heading')).toBeInTheDocument()
})

it('enters fullscreen from the header toggle and exits with the small close button', () => {
render(<FilePreview file={textFile('Dockerfile', 'text/plain')} />)

expect(screen.queryByTitle('Exit fullscreen')).not.toBeInTheDocument()

fireEvent.click(screen.getByTitle('Enter fullscreen'))

expect(screen.queryByTitle('Enter fullscreen')).not.toBeInTheDocument()

fireEvent.click(screen.getByTitle('Exit fullscreen'))

expect(screen.getByTitle('Enter fullscreen')).toBeInTheDocument()
})

it('closes a mobile preview from the on-top close button without a fullscreen toggle', () => {
const onCloseModal = vi.fn()
render(<FilePreview file={textFile('Dockerfile', 'text/plain')} isMobileModal onCloseModal={onCloseModal} />)

expect(screen.queryByTitle('Enter fullscreen')).not.toBeInTheDocument()

fireEvent.click(screen.getByTitle('Close preview'))

expect(onCloseModal).toHaveBeenCalled()
})

it('shows an on-top close button for a headerless mobile preview', () => {
const onCloseModal = vi.fn()
render(<FilePreview file={textFile('Dockerfile', 'text/plain')} hideHeader onCloseModal={onCloseModal} />)

fireEvent.click(screen.getByTitle('Close preview'))

expect(onCloseModal).toHaveBeenCalled()
})

it('opens relative markdown links in the file browser and external links in a new tab', () => {
const onOpenFile = vi.fn()
const file = {
...textFile('index.md', 'text/markdown'),
content: btoa('- [ ] [report](sub/report.html)\n\n[site](https://example.com)'),
}
render(<FilePreview file={file} onOpenFile={onOpenFile} />)

fireEvent.click(screen.getByRole('link', { name: 'report' }))
expect(onOpenFile).toHaveBeenCalledWith('docker/sub/report.html')

const external = screen.getByRole('link', { name: 'site' })
expect(external).toHaveAttribute('target', '_blank')
expect(external).toHaveAttribute('rel', 'noopener noreferrer')
})

it('keeps the success and destructive tints on the edit actions in dark mode', () => {
render(<FilePreview file={textFile('Dockerfile', 'text/plain')} />)

Expand Down
38 changes: 30 additions & 8 deletions frontend/src/components/file-browser/FilePreview.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
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, ExternalLink } from 'lucide-react'
import { Download, X, Edit3, Save, X as XIcon, WrapText, Eye, Code, ExternalLink, Maximize2 } from 'lucide-react'
import type { FileInfo } from '@/types/files'
import { getFileApiUrl } from '@/api/files'
import { saveFileFromUrl } from '@/lib/download'
Expand All @@ -20,9 +20,10 @@ interface FilePreviewProps {
onCloseModal?: () => void
onFileSaved?: () => void
initialLineNumber?: number
onOpenFile?: (path: string) => void
}

export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber }: FilePreviewProps) {
export const FilePreview = memo(function FilePreview({ file, hideHeader = false, isMobileModal = false, onCloseModal, onFileSaved, initialLineNumber, onOpenFile }: FilePreviewProps) {
const isMarkdownFile = file.name.toLowerCase().endsWith('.md') || file.name.toLowerCase().endsWith('.mdx') || file.mimeType === 'text/markdown'
const isHtmlFile = /\.html?$/i.test(file.name) || file.mimeType === 'text/html'
const hasRenderedPreview = isMarkdownFile || isHtmlFile
Expand All @@ -41,6 +42,9 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
const [localMdContent, setLocalMdContent] = useState<string | null>(null)
const virtualizedRef = useRef<VirtualizedTextViewHandle>(null)
const contentRef = useRef<HTMLDivElement>(null)
const isMobilePreview = isMobileModal || Boolean(onCloseModal)
const [isFullscreen, setIsFullscreen] = useState(isMobilePreview)
const isDesktopFullscreen = isFullscreen && !isMobilePreview

const shouldVirtualize = file.size > VIRTUALIZATION_THRESHOLD_BYTES && !file.mimeType?.startsWith('image/')
const isMarkdownTooLarge = file.size > MARKDOWN_PREVIEW_SIZE_LIMIT
Expand Down Expand Up @@ -273,7 +277,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
<div className="w-6 h-6 border-2 border-muted-foreground border-t-transparent rounded-full animate-spin" />
</div>
) : fullContent ? (
<MarkdownRenderer content={fullContent} onContentChange={handleFullMarkdownContentChange} />
<MarkdownRenderer content={fullContent} onContentChange={handleFullMarkdownContentChange} filePath={file.path} onOpenFile={onOpenFile} />
) : null}
</>
)}
Expand Down Expand Up @@ -312,7 +316,7 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
}

if (isMarkdownFile && renderedPreview) {
return <MarkdownRenderer content={displayContent} onContentChange={handleLocalMarkdownContentChange} />
return <MarkdownRenderer content={displayContent} onContentChange={handleLocalMarkdownContentChange} filePath={file.path} onOpenFile={onOpenFile} />
}

const lines = displayContent.split('\n')
Expand Down Expand Up @@ -373,10 +377,10 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
const showCancelButton = viewMode === 'edit'

return (
<div className="h-full flex flex-col bg-background">
{!hideHeader && (
<div className={`${isDesktopFullscreen ? 'fixed inset-0 z-50' : 'h-full'} relative flex flex-col bg-background`}>
{!hideHeader && !isDesktopFullscreen && (
<>
<div className={`flex items-start gap-2 px-3 py-2 border-b border-border flex-shrink-0 overflow-hidden ${isMobileModal ? 'pt-3' : ''}`}>
<div className={`relative z-10 flex items-start gap-2 px-3 py-2 border-b border-border flex-shrink-0 overflow-hidden ${isMobileModal ? 'pt-3' : ''}`}>
<div className="flex-1 min-w-0 overflow-hidden">
<h3 className="text-foreground text-sm font-medium break-all leading-tight">
{file.name}
Expand Down Expand Up @@ -451,15 +455,33 @@ export const FilePreview = memo(function FilePreview({ file, hideHeader = false,
</Button>
)}

{!isMobilePreview && !isFullscreen && viewMode !== 'edit' && (
<Button variant="outline" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); setIsFullscreen(true) }} className="h-7 w-7 p-0" title="Enter fullscreen">
<Maximize2 className="w-3 h-3" />
</Button>
)}

{viewMode !== 'edit' && isMobileModal && onCloseModal && (
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); onCloseModal() }} className={DESTRUCTIVE_TINT_BUTTON_CLASS}>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); e.preventDefault(); onCloseModal() }} className={DESTRUCTIVE_TINT_BUTTON_CLASS} title="Close preview">
<X className="w-3 h-3" />
</Button>
)}
</div>
</div>
</>
)}

{isFullscreen && (isDesktopFullscreen || hideHeader) && (
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); e.preventDefault(); if (isMobilePreview) { onCloseModal?.() } else { setIsFullscreen(false) } }}
className="absolute top-2 right-2 z-[60] h-6 w-6 p-0 rounded-full bg-background/80 text-muted-foreground shadow-sm backdrop-blur-sm hover:bg-muted hover:text-foreground"
title={isMobilePreview ? 'Close preview' : 'Exit fullscreen'}
>
<X className="w-3.5 h-3.5" />
</Button>
)}

<div
ref={contentRef}
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/components/file-browser/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@ import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import rehypeRaw from 'rehype-raw'
import { markdownComponents } from './MarkdownComponents'
import { MarkdownLink } from '@/components/ui/markdown-link'
import { resolvePathFromFile } from '@/lib/markdownLinks'
import type { Components } from 'react-markdown'

interface MarkdownRendererProps {
content: string
className?: string
onContentChange?: (newContent: string) => void
filePath?: string
onOpenFile?: (path: string) => void
}

interface TaskItem {
Expand All @@ -29,7 +33,7 @@ function parseTaskItems(content: string): TaskItem[] {
return items
}

export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '', onContentChange }: MarkdownRendererProps) {
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className = '', onContentChange, filePath, onOpenFile }: MarkdownRendererProps) {
const taskItems = useMemo(() => parseTaskItems(content), [content])

const handleToggle = useCallback((taskItem: TaskItem) => {
Expand All @@ -54,6 +58,14 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN
}
}, [handleToggle, taskItems])

const handleOpenLocalPath = useMemo(() => {
if (!filePath || !onOpenFile) return undefined
return (linkPath: string) => {
const resolvedPath = resolvePathFromFile(filePath, linkPath)
if (resolvedPath) onOpenFile(resolvedPath)
}
}, [filePath, onOpenFile])

const components: Components = {
...markdownComponents,
input(props) {
Expand All @@ -74,6 +86,9 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, classN

return <input type={type} checked={checked} disabled={disabled} {...rest} />
},
a(props) {
return <MarkdownLink {...props} onOpenLocalPath={handleOpenLocalPath} />
},
}

return (
Expand Down
Loading