diff --git a/Dockerfile b/Dockerfile index 0042db620..23f1a202e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -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 diff --git a/backend/src/constants.ts b/backend/src/constants.ts index ca4d348d3..0712696ed 100644 --- a/backend/src/constants.ts +++ b/backend/src/constants.ts @@ -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 diff --git a/backend/test/scripts/docker-config.test.ts b/backend/test/scripts/docker-config.test.ts index fab11a94a..45b511c1f 100644 --- a/backend/test/scripts/docker-config.test.ts +++ b/backend/test/scripts/docker-config.test.ts @@ -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', () => { diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 95a97c93d..12654f93e 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -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 diff --git a/docs/features/sandboxing.md b/docs/features/sandboxing.md index 435b7d411..e58a0d1a6 100644 --- a/docs/features/sandboxing.md +++ b/docs/features/sandboxing.md @@ -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 diff --git a/frontend/src/components/file-browser/FileBrowser.tsx b/frontend/src/components/file-browser/FileBrowser.tsx index a27ec8a1d..c851bf620 100644 --- a/frontend/src/components/file-browser/FileBrowser.tsx +++ b/frontend/src/components/file-browser/FileBrowser.tsx @@ -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) @@ -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) @@ -639,7 +650,7 @@ useEffect(() => { {!isMobile && (