diff --git a/.prettierignore b/.prettierignore index 97d7bbb8..be93a23d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,6 +5,8 @@ release/ node_modules/ .worktrees/ .claude/ +.enzyme/ +CLAUDE.local.md .omc/ .planning/ .letta/ diff --git a/electron/ipc/channels.ts b/electron/ipc/channels.ts index 0bbfcdf3..0b35da0d 100644 --- a/electron/ipc/channels.ts +++ b/electron/ipc/channels.ts @@ -99,6 +99,7 @@ export enum IPC { CheckDockerAvailable = 'check_docker_available', CheckDockerImageExists = 'check_docker_image_exists', BuildDockerImage = 'build_docker_image', + ResolveProjectDockerfile = 'resolve_project_dockerfile', // System GetSystemFonts = 'get_system_fonts', diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 5d883535..7b6185be 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -1,9 +1,249 @@ -import { describe, it, expect } from 'vitest'; -import { validateCommand } from './pty.js'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import type { BrowserWindow } from 'electron'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn } = vi.hoisted(() => { + const mockExecFileSync = vi.fn((command: string, args?: string[]) => { + if (command === 'which' && args?.[0] === 'nonexistent-binary-xyz') { + throw new Error('not found'); + } + return ''; + }); + + const mockExecFile = vi.fn(); + const mockChildProcessSpawn = vi.fn(() => ({ + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + })); + + const mockPtySpawn = vi.fn( + (_command: string, _args: string[], options: { cols: number; rows: number }) => { + let onDataHandler: ((data: string) => void) | undefined; + let onExitHandler: + | ((event: { exitCode: number; signal: number | undefined }) => void) + | undefined; + + const proc = { + cols: options.cols, + rows: options.rows, + write: vi.fn(), + resize: vi.fn((cols: number, rows: number) => { + proc.cols = cols; + proc.rows = rows; + }), + pause: vi.fn(), + resume: vi.fn(), + kill: vi.fn(() => { + onExitHandler?.({ exitCode: 0, signal: 15 }); + }), + onData: vi.fn((handler: (data: string) => void) => { + onDataHandler = handler; + }), + onExit: vi.fn( + (handler: (event: { exitCode: number; signal: number | undefined }) => void) => { + onExitHandler = handler; + }, + ), + emitData(data: string) { + onDataHandler?.(data); + }, + emitExit(event: { exitCode: number; signal: number | undefined }) { + onExitHandler?.(event); + }, + }; + + return proc; + }, + ); + + return { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn }; +}); + +vi.mock('child_process', async () => { + const actual = await vi.importActual('child_process'); + return { + ...actual, + execFileSync: mockExecFileSync, + execFile: mockExecFile, + spawn: mockChildProcessSpawn, + }; +}); + +vi.mock('node-pty', () => ({ + spawn: mockPtySpawn, +})); + +import { + buildDockerImage, + DOCKER_CONTAINER_HOME, + dockerImageExists, + hashDockerfile, + killAllAgents, + projectImageTag, + resolveProjectDockerfile, + spawnAgent, + validateCommand, +} from './pty.js'; + +let tempPaths: string[] = []; +let agentCounter = 0; + +function createMockWindow(): BrowserWindow { + return { + isDestroyed: vi.fn(() => false), + webContents: { + send: vi.fn(), + }, + } as unknown as BrowserWindow; +} + +function nextAgentId(): string { + agentCounter += 1; + return `agent-${agentCounter}`; +} + +function buildSpawnArgs( + overrides: Partial[1]> = {}, +): Parameters[1] { + return { + taskId: 'task-1', + agentId: nextAgentId(), + command: 'claude', + args: ['--print', 'hello'], + cwd: '/workspace/project', + env: {}, + cols: 120, + rows: 40, + dockerMode: true, + dockerImage: 'parallel-code-agent:test', + onOutput: { __CHANNEL_ID__: 'channel-1' }, + ...overrides, + }; +} + +function getLastSpawnCall(): { + command: string; + args: string[]; + options: { + cols: number; + rows: number; + cwd?: string; + env: Record; + name: string; + }; +} { + const lastCall = mockPtySpawn.mock.lastCall; + expect(lastCall).toBeTruthy(); + const [command, args, options] = lastCall as [ + string, + string[], + { cols: number; rows: number; cwd?: string; env: Record; name: string }, + ]; + return { command, args, options }; +} + +function getFlagValues(args: string[], flag: string): string[] { + const values: string[] = []; + for (let i = 0; i < args.length - 1; i += 1) { + if (args[i] === flag) { + values.push(args[i + 1]); + } + } + return values; +} + +function makeTempHome(entries: string[]): string { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-docker-home-')); + tempPaths.push(home); + + for (const entry of entries) { + const target = path.join(home, entry); + if (entry.endsWith('/')) { + fs.mkdirSync(target, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, 'test'); + } + } + + return home; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + tempPaths = []; +}); + +afterEach(() => { + killAllAgents(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + for (const tempPath of tempPaths) { + fs.rmSync(tempPath, { recursive: true, force: true }); + } + tempPaths = []; +}); + +describe('DOCKER_CONTAINER_HOME', () => { + it('uses a home directory writable by arbitrary host-mapped docker users', () => { + expect(DOCKER_CONTAINER_HOME).toBe('/tmp'); + }); +}); + +describe('spawnAgent docker mode', () => { + it('injects HOME=/tmp into docker run args', () => { + vi.stubEnv('HOME', '/Users/tester'); + + spawnAgent(createMockWindow(), buildSpawnArgs()); + + const { command, args } = getLastSpawnCall(); + expect(command).toBe('docker'); + expect(getFlagValues(args, '-e')).toContain(`HOME=${DOCKER_CONTAINER_HOME}`); + }); + + it('does not forward host or renderer HOME as a generic docker env flag', () => { + const hostHome = '/Users/host-home'; + const rendererHome = '/Users/renderer-home'; + vi.stubEnv('HOME', hostHome); + + spawnAgent( + createMockWindow(), + buildSpawnArgs({ + env: { + API_KEY: 'secret', + HOME: rendererHome, + }, + }), + ); + + const envFlags = getFlagValues(getLastSpawnCall().args, '-e'); + expect(envFlags).toContain('API_KEY=secret'); + expect(envFlags.filter((value) => value.startsWith('HOME='))).toEqual([ + `HOME=${DOCKER_CONTAINER_HOME}`, + ]); + expect(envFlags).not.toContain(`HOME=${hostHome}`); + expect(envFlags).not.toContain(`HOME=${rendererHome}`); + }); + + it('redirects credential mounts under /tmp inside the container', () => { + const home = makeTempHome(['.ssh/', '.gitconfig', '.config/gh/']); + vi.stubEnv('HOME', home); + + spawnAgent(createMockWindow(), buildSpawnArgs()); + + const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); + expect(volumeFlags).toContain(`${home}/.ssh:${DOCKER_CONTAINER_HOME}/.ssh:ro`); + expect(volumeFlags).toContain(`${home}/.gitconfig:${DOCKER_CONTAINER_HOME}/.gitconfig:ro`); + expect(volumeFlags).toContain(`${home}/.config/gh:${DOCKER_CONTAINER_HOME}/.config/gh:ro`); + }); +}); describe('validateCommand', () => { it('does not throw for a command found in PATH', () => { - // /bin/sh always exists on macOS/Linux expect(() => validateCommand('/bin/sh')).not.toThrow(); }); @@ -33,3 +273,105 @@ describe('validateCommand', () => { expect(() => validateCommand(' ')).toThrow(/must not be empty/); }); }); + +describe('resolveProjectDockerfile', () => { + it('returns absolute path when .parallel-code/Dockerfile exists in project root', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-resolve-')); + tempPaths.push(projectRoot); + const dockerDir = path.join(projectRoot, '.parallel-code'); + fs.mkdirSync(dockerDir, { recursive: true }); + fs.writeFileSync(path.join(dockerDir, 'Dockerfile'), 'FROM node:20\n'); + + const result = resolveProjectDockerfile(projectRoot); + expect(result).toBe(path.join(projectRoot, '.parallel-code', 'Dockerfile')); + }); + + it('returns null when .parallel-code/Dockerfile does not exist', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-resolve-')); + tempPaths.push(projectRoot); + + const result = resolveProjectDockerfile(projectRoot); + expect(result).toBeNull(); + }); + + it('returns null when project root does not exist', () => { + const result = resolveProjectDockerfile('/nonexistent/path/to/project'); + expect(result).toBeNull(); + }); +}); + +describe('projectImageTag', () => { + it('returns a tag in the format parallel-code-project:<12-char-hash>', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-tag-')); + tempPaths.push(tmpDir); + const dockerfilePath = path.join(tmpDir, 'Dockerfile'); + fs.writeFileSync(dockerfilePath, 'FROM node:20\nRUN echo hello\n'); + + const tag = projectImageTag(dockerfilePath); + expect(tag).toMatch(/^parallel-code-project:[a-f0-9]{12}$/); + }); + + it('returns parallel-code-project:unknown for non-existent Dockerfile path', () => { + const tag = projectImageTag('/nonexistent/Dockerfile'); + expect(tag).toBe('parallel-code-project:unknown'); + }); +}); + +describe('hashDockerfile', () => { + it('returns a SHA-256 hex string for a real file', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-hash-')); + tempPaths.push(tmpDir); + const dockerfilePath = path.join(tmpDir, 'Dockerfile'); + fs.writeFileSync(dockerfilePath, 'FROM ubuntu:22.04\n'); + + const hash = hashDockerfile(dockerfilePath); + expect(hash).not.toBeNull(); + expect(hash).toMatch(/^[a-f0-9]{64}$/); + }); + + it('returns null for a non-existent file', () => { + const hash = hashDockerfile('/nonexistent/Dockerfile'); + expect(hash).toBeNull(); + }); +}); + +describe('dockerImageExists', () => { + it('fails closed when a custom dockerfile path is unreadable', async () => { + mockExecFile.mockImplementationOnce( + ( + _command: string, + _args: string[], + _options: { encoding: string; timeout: number }, + callback: (err: Error | null, stdout: string) => void, + ) => callback(null, 'stored-hash'), + ); + + await expect( + dockerImageExists('parallel-code-project:test', { + dockerfilePath: '/nonexistent/Dockerfile', + }), + ).resolves.toBe(false); + }); +}); + +describe('buildDockerImage', () => { + it('uses the provided build context for a project dockerfile', () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pty-build-context-')); + tempPaths.push(projectRoot); + const dockerDir = path.join(projectRoot, '.parallel-code'); + fs.mkdirSync(dockerDir, { recursive: true }); + const dockerfilePath = path.join(dockerDir, 'Dockerfile'); + fs.writeFileSync(dockerfilePath, 'FROM node:20\n'); + + buildDockerImage(createMockWindow(), 'channel:build-test', { + dockerfilePath, + imageTag: 'parallel-code-project:test', + buildContext: projectRoot, + } as unknown as Parameters[2]); + + const lastCall = mockChildProcessSpawn.mock.lastCall; + expect(lastCall).toBeTruthy(); + const args = ((lastCall as unknown as [string, string[]])?.[1] ?? []) as string[]; + expect(args[args.length - 1]).toBe(projectRoot); + }); +}); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index f39ce2c9..87bbce93 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -463,23 +463,32 @@ export function getAgentCols(agentId: string): number { // --- Docker mode helpers --- +/** + * Writable HOME inside the Docker container. + * + * Docker tasks run as the host user's uid/gid so files created in the mounted + * project worktree stay owned by the host user. On macOS that is often 501:20, + * which cannot write to the image-owned /home/agent directory. Using /tmp keeps + * HOME writable for arbitrary host-mapped users and avoids agents hanging + * during startup while trying to initialize config under an unwritable home. + */ +export const DOCKER_CONTAINER_HOME = '/tmp'; + /** * Env vars that are desktop/host-specific and must NOT be forwarded into the * container. Everything else is forwarded so agents can use arbitrary vars * (custom API keys, feature flags, tool config, etc.) without needing an * ever-growing allowlist. */ -/** Home directory inside the Docker container (writable by uid 1000). */ -const DOCKER_CONTAINER_HOME = '/home/agent'; const DOCKER_ENV_BLOCK_LIST = new Set([ // Host PATH must not override the container's PATH — agent CLIs like // `claude` are installed at /usr/local/bin inside the image and won't be // found if the host PATH (pointing at host-only dirs) is forwarded. 'PATH', - // Host HOME points to a non-writable directory inside the container - // (created by Docker for read-only credential mounts). Agents need a - // writable HOME for config files — we set HOME=/home/agent explicitly. + // Host HOME points to a non-writable directory inside the container when we + // run as the host user's uid/gid. Agents need a writable HOME for config + // files, so Docker mode sets HOME to DOCKER_CONTAINER_HOME explicitly. 'HOME', // Display / desktop session 'DISPLAY', @@ -547,8 +556,6 @@ function buildDockerCredentialMounts(): string[] { const home = process.env.HOME; if (!home) return mounts; - const containerHome = DOCKER_CONTAINER_HOME; - /** Mount a host path read-only into the container home. Skips if absent. */ const mountIfExists = (hostPath: string, containerPath: string): void => { try { @@ -560,19 +567,19 @@ function buildDockerCredentialMounts(): string[] { }; // SSH keys for git push/pull - mountIfExists(`${home}/.ssh`, `${containerHome}/.ssh`); + mountIfExists(`${home}/.ssh`, `${DOCKER_CONTAINER_HOME}/.ssh`); // Git identity / config - mountIfExists(`${home}/.gitconfig`, `${containerHome}/.gitconfig`); + mountIfExists(`${home}/.gitconfig`, `${DOCKER_CONTAINER_HOME}/.gitconfig`); // GitHub CLI auth tokens (~/.config/gh/) - mountIfExists(`${home}/.config/gh`, `${containerHome}/.config/gh`); + mountIfExists(`${home}/.config/gh`, `${DOCKER_CONTAINER_HOME}/.config/gh`); // npm auth token - mountIfExists(`${home}/.npmrc`, `${containerHome}/.npmrc`); + mountIfExists(`${home}/.npmrc`, `${DOCKER_CONTAINER_HOME}/.npmrc`); // General HTTP/git HTTPS credentials (used by git credential helper) - mountIfExists(`${home}/.netrc`, `${containerHome}/.netrc`); + mountIfExists(`${home}/.netrc`, `${DOCKER_CONTAINER_HOME}/.netrc`); // Google Application Credentials file (for Vertex AI / gcloud) — mounted // at its original path since the env var points there. @@ -625,20 +632,69 @@ function resolveDockerfilePath(): string | null { return fs.existsSync(p) ? p : null; } -/** SHA-256 hex digest of the current Dockerfile, or null if not found. */ +/** SHA-256 hex digest of an arbitrary Dockerfile, or null if unreadable. */ +export function hashDockerfile(dockerfilePath: string): string | null { + try { + return crypto.createHash('sha256').update(fs.readFileSync(dockerfilePath)).digest('hex'); + } catch { + return null; + } +} + +/** SHA-256 hex digest of the bundled Dockerfile, or null if not found. */ function getDockerfileHash(): string | null { const p = resolveDockerfilePath(); if (!p) return null; - return crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex'); + return hashDockerfile(p); +} + +/** + * Check if a project has a local Dockerfile at .parallel-code/Dockerfile. + * Returns the absolute path if found, null otherwise. + */ +export function resolveProjectDockerfile(projectRoot: string): string | null { + const p = path.join(projectRoot, '.parallel-code', 'Dockerfile'); + try { + if (!fs.statSync(p).isFile()) return null; + } catch { + return null; + } + return p; +} + +/** + * Derive a deterministic image tag for a project Dockerfile. + * Tag format: parallel-code-project: + */ +export function projectImageTag(dockerfilePath: string): string { + const hash = hashDockerfile(dockerfilePath); + return `parallel-code-project:${(hash ?? 'unknown').slice(0, 12)}`; } /** * Check if a Docker image exists locally **and** matches the current Dockerfile. * Returns false when the image is missing or was built from a different Dockerfile, * so the UI will prompt the user to (re)build. + * + * When `opts.dockerfilePath` is provided, hash that file for the staleness check. + * When the image is not the default and no `dockerfilePath` is given, skip the hash + * check entirely (just verify the image exists). */ -export async function dockerImageExists(image: string): Promise { - const expectedHash = getDockerfileHash(); +export async function dockerImageExists( + image: string, + opts?: { dockerfilePath?: string }, +): Promise { + const customPath = opts?.dockerfilePath; + const expectedHash = customPath + ? hashDockerfile(customPath) + : image === DOCKER_DEFAULT_IMAGE + ? getDockerfileHash() + : null; + + if (customPath && !expectedHash) { + return false; + } + return new Promise((resolve) => { execFile( 'docker', @@ -655,7 +711,6 @@ export async function dockerImageExists(image: string): Promise { resolve(false); return; } - // If we can't compute the expected hash (Dockerfile missing), accept any existing image if (!expectedHash) { resolve(true); return; @@ -670,32 +725,42 @@ export async function dockerImageExists(image: string): Promise { let activeBuild: Promise<{ ok: boolean; error?: string }> | null = null; /** - * Build the bundled Dockerfile into parallel-code-agent:latest. + * Build a Dockerfile into a Docker image. * Streams build output to the renderer via an IPC channel so the user can see progress. * Returns a promise that resolves on success, rejects on failure. - * Concurrent calls return the same in-flight promise. + * + * When no `opts` are given, builds the bundled Dockerfile into the default image + * (backward compatible). Concurrent calls for the default image share the same + * in-flight promise; custom builds are never deduplicated. */ export function buildDockerImage( win: BrowserWindow, onOutputChannel: string, + opts?: { dockerfilePath?: string; buildContext?: string; imageTag?: string }, ): Promise<{ ok: boolean; error?: string }> { - if (activeBuild !== null) { + const isDefaultBuild = !opts?.dockerfilePath && !opts?.buildContext && !opts?.imageTag; + + // Only dedup when building the default image + if (isDefaultBuild && activeBuild !== null) { return activeBuild; } - activeBuild = new Promise((resolve) => { + const buildPromise = new Promise<{ ok: boolean; error?: string }>((resolve) => { const finish = (result: { ok: boolean; error?: string }) => { - activeBuild = null; + if (isDefaultBuild) { + activeBuild = null; + } resolve(result); }; - const dockerfilePath = resolveDockerfilePath(); - if (!dockerfilePath) { + const resolvedDockerfilePath = opts?.dockerfilePath ?? resolveDockerfilePath(); + if (!resolvedDockerfilePath) { finish({ ok: false, error: 'Dockerfile not found' }); return; } - const dockerDir = path.dirname(dockerfilePath); - const hash = getDockerfileHash() ?? 'unknown'; + const buildContext = opts?.buildContext ?? path.dirname(resolvedDockerfilePath); + const hash = hashDockerfile(resolvedDockerfilePath) ?? 'unknown'; + const imageTag = opts?.imageTag ?? DOCKER_DEFAULT_IMAGE; const send = (text: string) => { if (!win.isDestroyed()) { @@ -708,12 +773,12 @@ export function buildDockerImage( [ 'build', '-t', - DOCKER_DEFAULT_IMAGE, + imageTag, '--label', `${DOCKERFILE_HASH_LABEL}=${hash}`, '-f', - dockerfilePath, - dockerDir, + resolvedDockerfilePath, + buildContext, ], { stdio: ['ignore', 'pipe', 'pipe'], @@ -736,5 +801,9 @@ export function buildDockerImage( }); }); - return activeBuild; + if (isDefaultBuild) { + activeBuild = buildPromise; + } + + return buildPromise; } diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index e4842e00..52b133e7 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -16,6 +16,8 @@ import { isDockerAvailable, dockerImageExists, buildDockerImage, + resolveProjectDockerfile, + projectImageTag, } from './pty.js'; import { ensurePlansDirectory, @@ -85,6 +87,25 @@ function validateBranchName(name: unknown, label: string): void { if (name.startsWith('-')) throw new Error(`${label} must not start with "-"`); } +function getOptionalDockerfilePath(value: unknown): string | undefined { + assertOptionalString(value, 'dockerfilePath'); + if (value !== undefined) validatePath(value, 'dockerfilePath'); + return value; +} + +function getOptionalBuildContext(value: unknown): string | undefined { + assertOptionalString(value, 'buildContext'); + if (value !== undefined) validatePath(value, 'buildContext'); + return value; +} + +function getOptionalImageTag(value: unknown): string | undefined { + assertOptionalString(value, 'imageTag'); + const imageTag = value?.trim(); + if (imageTag === '') throw new Error('imageTag must be a non-empty string'); + return imageTag; +} + /** * Create a leading+trailing throttled event forwarder. * Fires immediately, suppresses for `intervalMs`, then fires once more @@ -187,11 +208,31 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.CheckDockerAvailable, () => isDockerAvailable()); ipcMain.handle(IPC.CheckDockerImageExists, (_e, args) => { assertString(args.image, 'image'); - return dockerImageExists(args.image); + const dockerfilePath = getOptionalDockerfilePath(args.dockerfilePath); + return dockerImageExists(args.image, dockerfilePath ? { dockerfilePath } : undefined); }); ipcMain.handle(IPC.BuildDockerImage, (_e, args) => { assertString(args.onOutputChannel, 'onOutputChannel'); - return buildDockerImage(win, args.onOutputChannel); + const dockerfilePath = getOptionalDockerfilePath(args.dockerfilePath); + const buildContext = getOptionalBuildContext(args.buildContext); + const imageTag = getOptionalImageTag(args.imageTag); + return buildDockerImage( + win, + args.onOutputChannel, + dockerfilePath || buildContext || imageTag + ? { dockerfilePath, buildContext, imageTag } + : undefined, + ); + }); + ipcMain.handle(IPC.ResolveProjectDockerfile, (_e, args) => { + validatePath(args.projectRoot, 'projectRoot'); + const dockerfilePath = resolveProjectDockerfile(args.projectRoot); + if (!dockerfilePath) return null; + return { + dockerfilePath, + imageTag: projectImageTag(dockerfilePath), + buildContext: args.projectRoot, + }; }); // --- Task commands --- diff --git a/electron/preload.cjs b/electron/preload.cjs index 95427d1c..fa95fa63 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -89,6 +89,7 @@ const ALLOWED_CHANNELS = new Set([ 'check_docker_available', 'check_docker_image_exists', 'build_docker_image', + 'resolve_project_dockerfile', // Ask about code 'ask_about_code', 'cancel_ask_about_code', diff --git a/electron/tsconfig.json b/electron/tsconfig.json index f5d34412..3d56adc4 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -12,5 +12,10 @@ "types": ["node"] }, "include": ["./**/*.ts"], - "exclude": ["./preload.ts", "./shims/**", "./vite.config.electron.ts"] + "exclude": [ + "./preload.ts", + "./shims/**", + "./vite.config.electron.ts", + "./vite.config.electron.test.ts" + ] } diff --git a/electron/vite.config.electron.test.ts b/electron/vite.config.electron.test.ts new file mode 100644 index 00000000..85e9cc26 --- /dev/null +++ b/electron/vite.config.electron.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import config from './vite.config.electron'; + +describe('electron vite config', () => { + it('ignores nested worktree directories in dev watch mode', () => { + const ignored = config.server?.watch?.ignored; + + expect(ignored).toBeDefined(); + + const patterns = Array.isArray(ignored) ? ignored : [ignored]; + expect(patterns).toContain('**/.worktrees/**'); + }); +}); diff --git a/electron/vite.config.electron.ts b/electron/vite.config.electron.ts index 02b08a95..12e835b5 100644 --- a/electron/vite.config.electron.ts +++ b/electron/vite.config.electron.ts @@ -8,5 +8,11 @@ export default defineConfig({ server: { port: 1421, strictPort: true, + watch: { + // Creating git worktrees inside this repo would otherwise look like a giant + // source-tree change to Vite in dev mode, causing the renderer to reload + // right when Parallel Code creates a task for itself. + ignored: ['**/.worktrees/**'], + }, }, }); diff --git a/eslint.config.js b/eslint.config.js index 20301404..0ea23af7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,8 +15,9 @@ export default [ 'node_modules/**', '.worktrees/**', '.claude/**', - // Build config excluded from electron tsconfig; not worth linting separately + // Build config is excluded from electron tsconfig; ignore the config and its test. 'electron/vite.config.electron.ts', + 'electron/vite.config.electron.test.ts', ], }, diff --git a/src/components/NewTaskDialog.tsx b/src/components/NewTaskDialog.tsx index 52212168..8eda0374 100644 --- a/src/components/NewTaskDialog.tsx +++ b/src/components/NewTaskDialog.tsx @@ -29,6 +29,7 @@ import { BranchPrefixField } from './BranchPrefixField'; import { ProjectSelect } from './ProjectSelect'; import { SymlinkDirPicker } from './SymlinkDirPicker'; import type { AgentDef } from '../ipc/types'; +import { DEFAULT_DOCKER_IMAGE, PROJECT_DOCKERFILE_RELATIVE_PATH } from '../lib/docker'; interface NewTaskDialogProps { open: boolean; @@ -55,6 +56,11 @@ export function NewTaskDialog(props: NewTaskDialogProps) { const [dockerBuilding, setDockerBuilding] = createSignal(false); const [dockerBuildOutput, setDockerBuildOutput] = createSignal(''); const [dockerBuildError, setDockerBuildError] = createSignal(''); + const [projectDockerfile, setProjectDockerfile] = createSignal<{ + dockerfilePath: string; + imageTag: string; + buildContext: string; + } | null>(null); const [branchPrefix, setBranchPrefix] = createSignal(''); let promptRef!: HTMLTextAreaElement; let formRef!: HTMLFormElement; @@ -124,6 +130,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { setDockerBuilding(false); setDockerBuildOutput(''); setDockerBuildError(''); + setProjectDockerfile(null); void (async () => { // Check Docker availability in background @@ -284,21 +291,73 @@ export function NewTaskDialog(props: NewTaskDialogProps) { setGitIsolation(proj?.defaultGitIsolation ?? 'worktree'); }); + // Resolve project Dockerfile when project changes + createEffect(() => { + if (!dockerMode() || !store.dockerAvailable) { + setProjectDockerfile(null); + return; + } + const pid = selectedProjectId(); + if (!pid) { + setProjectDockerfile(null); + return; + } + + const projectRoot = getProjectPath(pid); + if (!projectRoot) { + setProjectDockerfile(null); + return; + } + + let cancelled = false; + invoke<{ dockerfilePath: string; imageTag: string; buildContext: string } | null>( + IPC.ResolveProjectDockerfile, + { projectRoot }, + ).then( + (result) => { + if (!cancelled) setProjectDockerfile(result); + }, + () => { + if (!cancelled) setProjectDockerfile(null); + }, + ); + + onCleanup(() => { + cancelled = true; + }); + }); + // Check if the default Docker image exists when Docker mode is enabled (debounced) let checkTimer: ReturnType; createEffect(() => { - if (dockerMode() && store.dockerAvailable) { - const image = store.dockerImage || 'parallel-code-agent:latest'; + if (!dockerMode() || !store.dockerAvailable) { clearTimeout(checkTimer); - checkTimer = setTimeout(() => { - invoke(IPC.CheckDockerImageExists, { image }).then( - (exists) => setDockerImageReady(exists), - () => setDockerImageReady(false), - ); - }, 300); - } else { setDockerImageReady(null); + return; } + + const projDocker = projectDockerfile(); + const image = projDocker ? projDocker.imageTag : store.dockerImage || DEFAULT_DOCKER_IMAGE; + const checkArgs: Record = { image }; + if (projDocker) checkArgs.dockerfilePath = projDocker.dockerfilePath; + + let cancelled = false; + clearTimeout(checkTimer); + checkTimer = setTimeout(() => { + invoke(IPC.CheckDockerImageExists, checkArgs).then( + (exists) => { + if (!cancelled) setDockerImageReady(exists); + }, + () => { + if (!cancelled) setDockerImageReady(false); + }, + ); + }, 300); + + onCleanup(() => { + cancelled = true; + clearTimeout(checkTimer); + }); }); // Auto-scroll build output to bottom @@ -322,9 +381,14 @@ export function NewTaskDialog(props: NewTaskDialogProps) { }); try { - const result = await invoke<{ ok: boolean; error?: string }>(IPC.BuildDockerImage, { - onOutputChannel: `channel:${channelId}`, - }); + const projDocker = projectDockerfile(); + const buildArgs: Record = { onOutputChannel: `channel:${channelId}` }; + if (projDocker) { + buildArgs.dockerfilePath = projDocker.dockerfilePath; + buildArgs.imageTag = projDocker.imageTag; + buildArgs.buildContext = projDocker.buildContext; + } + const result = await invoke<{ ok: boolean; error?: string }>(IPC.BuildDockerImage, buildArgs); if (result.ok) { setDockerImageReady(true); setDockerBuildOutput((prev) => prev + '\nImage built successfully!'); @@ -428,6 +492,7 @@ export function NewTaskDialog(props: NewTaskDialogProps) { } } + const projDocker = projectDockerfile(); const taskId = await createTask({ name: n, agentDef: agent, @@ -441,7 +506,16 @@ export function NewTaskDialog(props: NewTaskDialogProps) { stepsEnabled: stepsEnabled() || undefined, skipPermissions: agentSupportsSkipPermissions() && skipPermissions(), dockerMode: dockerMode() || undefined, - dockerImage: dockerMode() ? store.dockerImage : undefined, + dockerSource: dockerMode() + ? projDocker + ? 'project' + : store.dockerImage && store.dockerImage !== DEFAULT_DOCKER_IMAGE + ? 'custom' + : 'default' + : undefined, + dockerImage: dockerMode() + ? (projDocker?.imageTag ?? (store.dockerImage || DEFAULT_DOCKER_IMAGE)) + : undefined, }); // Drop flow: prefill prompt without auto-sending if (isFromDrop && p) { @@ -794,30 +868,49 @@ export function NewTaskDialog(props: NewTaskDialogProps) { The agent will run inside a Docker container. Only the project directory is mounted — files outside the project are protected from accidental deletion. -
- - setDockerImage(e.currentTarget.value)} - placeholder="parallel-code-agent:latest" + +
-
+ > + + Using project Dockerfile:{' '} + + {PROJECT_DOCKERFILE_RELATIVE_PATH} + +
+ + +
+ + setDockerImage(e.currentTarget.value)} + placeholder={DEFAULT_DOCKER_IMAGE} + style={{ + flex: '1', + background: theme.bgInput, + border: `1px solid ${theme.border}`, + 'border-radius': '6px', + padding: '5px 10px', + color: theme.fg, + 'font-size': '12px', + 'font-family': "'JetBrains Mono', monospace", + outline: 'none', + }} + /> +
+
Image not found locally.
diff --git a/src/components/TaskAITerminal.tsx b/src/components/TaskAITerminal.tsx index cfdb5d47..c9392754 100644 --- a/src/components/TaskAITerminal.tsx +++ b/src/components/TaskAITerminal.tsx @@ -19,6 +19,7 @@ import { Dialog } from './Dialog'; import { theme } from '../lib/theme'; import { sf } from '../lib/fontScale'; import { invoke } from '../lib/ipc'; +import { getTaskDockerOverlayLabel } from '../lib/docker'; import { IPC } from '../../electron/ipc/channels'; import { createHighlightedMarkdown } from '../lib/marked-shiki'; import type { Task } from '../store/types'; @@ -33,21 +34,43 @@ interface TaskAITerminalProps { export function TaskAITerminal(props: TaskAITerminalProps) { onCleanup(() => unregisterFocusFn(`${props.task.id}:ai-terminal`)); - // --- Markdown file viewer --- + const dockerOverlayLabel = () => getTaskDockerOverlayLabel(props.task.dockerSource); const [mdViewerContent, setMdViewerContent] = createSignal(''); const [mdViewerFileName, setMdViewerFileName] = createSignal(''); const [mdViewerOpen, setMdViewerOpen] = createSignal(false); + const fileNameFromPath = (filePath: string) => filePath.split(/[\\/]/).pop() ?? filePath; + + const infoBarStatus = () => { + if (firstAgent()?.status === 'exited' && props.task.initialPrompt) { + return { + title: 'Agent exited before prompt was sent', + text: 'Agent exited before prompt was sent', + }; + } + + if (props.task.dockerMode && props.task.initialPrompt) { + return { + title: 'Starting Docker container…', + text: 'Starting Docker container…', + }; + } + + return props.task.initialPrompt + ? { title: 'Waiting to send prompt…', text: 'Waiting to send prompt…' } + : { title: 'No prompts sent yet', text: 'No prompts sent' }; + }; + function handleFileLink(filePath: string) { invoke(IPC.ReadFileText, { filePath }) .then((content) => { setMdViewerContent(content); - setMdViewerFileName(filePath.split('/').pop() ?? filePath); + setMdViewerFileName(fileNameFromPath(filePath)); setMdViewerOpen(true); }) .catch((err) => { setMdViewerContent(`**Error loading file:** ${String(err)}`); - setMdViewerFileName(filePath.split('/').pop() ?? filePath); + setMdViewerFileName(fileNameFromPath(filePath)); setMdViewerOpen(true); }); } @@ -75,36 +98,20 @@ export function TaskAITerminal(props: TaskAITerminalProps) { onClick={() => setTaskFocusedPanel(props.task.id, 'ai-terminal')} > { if (props.task.lastPrompt && props.promptHandle && !props.promptHandle.getText()) props.promptHandle.setText(props.task.lastPrompt); }} > - {props.task.lastPrompt - ? `> ${props.task.lastPrompt}` - : firstAgent()?.status === 'exited' && props.task.initialPrompt - ? 'Agent exited before prompt was sent' - : props.task.dockerMode && props.task.initialPrompt - ? 'Starting Docker container…' - : props.task.initialPrompt - ? 'Waiting to send prompt…' - : 'No prompts sent'} + {props.task.lastPrompt ? `> ${props.task.lastPrompt}` : infoBarStatus().text}
- docker + {dockerOverlayLabel()}
@@ -299,7 +306,6 @@ function MarkdownViewerDialog(props: { ); } -/** Restart/switch-agent dropdown menu shown on the exit badge. */ function AgentRestartMenu(props: { agentId: string; agentDefId: string }) { const [showAgentMenu, setShowAgentMenu] = createSignal(false); let menuRef: HTMLSpanElement | undefined; diff --git a/src/components/TaskTitleBar.tsx b/src/components/TaskTitleBar.tsx index d6c7cf7f..c73a18cf 100644 --- a/src/components/TaskTitleBar.tsx +++ b/src/components/TaskTitleBar.tsx @@ -13,6 +13,7 @@ import { StatusDot } from './StatusDot'; import { theme } from '../lib/theme'; import { badgeStyle } from '../lib/badgeStyle'; import { handleDragReorder } from '../lib/dragReorder'; +import { getTaskDockerBadgeLabel } from '../lib/docker'; import type { Task } from '../store/types'; interface TaskTitleBarProps { @@ -27,6 +28,8 @@ interface TaskTitleBarProps { } export function TaskTitleBar(props: TaskTitleBarProps) { + const dockerBadgeLabel = () => getTaskDockerBadgeLabel(props.task.dockerSource); + function handleTitleMouseDown(e: MouseEvent) { handleDragReorder(e, { itemId: props.task.id, @@ -67,7 +70,9 @@ export function TaskTitleBar(props: TaskTitleBarProps) { {props.task.branchName} - Docker + + {dockerBadgeLabel()} + { + it('renders project labels from explicit docker source metadata', () => { + expect(getTaskDockerBadgeLabel('project')).toBe('Docker (project)'); + expect(getTaskDockerOverlayLabel('project')).toBe('project dockerfile'); + }); + + it('infers docker source from image tag', () => { + expect(inferDockerSource(DEFAULT_DOCKER_IMAGE)).toBe('default'); + expect(inferDockerSource(undefined)).toBe('default'); + expect(inferDockerSource(`${PROJECT_DOCKER_IMAGE_PREFIX}abc123`)).toBe('project'); + expect(inferDockerSource('my-custom-image:v2')).toBe('custom'); + }); + + it('keeps generic labels for default and custom sources', () => { + expect(getTaskDockerBadgeLabel('default')).toBe('Docker'); + expect(getTaskDockerBadgeLabel('custom')).toBe('Docker'); + expect(getTaskDockerOverlayLabel('default')).toBe('docker'); + expect(getTaskDockerOverlayLabel('custom')).toBe('docker'); + }); +}); diff --git a/src/lib/docker.ts b/src/lib/docker.ts new file mode 100644 index 00000000..d02e22b5 --- /dev/null +++ b/src/lib/docker.ts @@ -0,0 +1,19 @@ +export type DockerSource = 'default' | 'project' | 'custom'; + +export const DEFAULT_DOCKER_IMAGE = 'parallel-code-agent:latest'; +export const PROJECT_DOCKER_IMAGE_PREFIX = 'parallel-code-project:'; +export const PROJECT_DOCKERFILE_RELATIVE_PATH = '.parallel-code/Dockerfile'; + +export function inferDockerSource(image?: string): DockerSource { + if (image?.startsWith(PROJECT_DOCKER_IMAGE_PREFIX)) return 'project'; + if (image && image !== DEFAULT_DOCKER_IMAGE) return 'custom'; + return 'default'; +} + +export function getTaskDockerBadgeLabel(source?: DockerSource): string { + return source === 'project' ? 'Docker (project)' : 'Docker'; +} + +export function getTaskDockerOverlayLabel(source?: DockerSource): string { + return source === 'project' ? 'project dockerfile' : 'docker'; +} diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 3644fa59..63bd4ddb 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -14,6 +14,7 @@ import type { Project, } from './types'; import type { AgentDef } from '../ipc/types'; +import { inferDockerSource } from '../lib/docker'; import { DEFAULT_TERMINAL_FONT } from '../lib/fonts'; import { isLookPreset } from '../lib/look'; import { syncTerminalCounter } from './terminals'; @@ -81,6 +82,7 @@ export async function saveState(): Promise { baseBranch: task.baseBranch, skipPermissions: task.skipPermissions, dockerMode: task.dockerMode, + dockerSource: task.dockerSource, dockerImage: task.dockerImage, githubUrl: task.githubUrl, savedInitialPrompt: task.savedInitialPrompt, @@ -109,6 +111,7 @@ export async function saveState(): Promise { baseBranch: task.baseBranch, skipPermissions: task.skipPermissions, dockerMode: task.dockerMode, + dockerSource: task.dockerSource, dockerImage: task.dockerImage, githubUrl: task.githubUrl, savedInitialPrompt: task.savedInitialPrompt, @@ -381,6 +384,11 @@ export async function loadState(): Promise { baseBranch: legacy.baseBranch || undefined, skipPermissions: pt.skipPermissions === true, dockerMode: pt.dockerMode === true ? true : undefined, + dockerSource: + pt.dockerMode === true + ? (pt.dockerSource ?? + inferDockerSource(typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined)) + : undefined, dockerImage: typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined, githubUrl: pt.githubUrl, savedInitialPrompt: pt.savedInitialPrompt, @@ -444,6 +452,11 @@ export async function loadState(): Promise { baseBranch: legacyCollapsed.baseBranch || undefined, skipPermissions: pt.skipPermissions === true, dockerMode: pt.dockerMode === true ? true : undefined, + dockerSource: + pt.dockerMode === true + ? (pt.dockerSource ?? + inferDockerSource(typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined)) + : undefined, dockerImage: typeof pt.dockerImage === 'string' ? pt.dockerImage : undefined, githubUrl: pt.githubUrl, savedInitialPrompt: pt.savedInitialPrompt, diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 78b029ba..dfaaa3fa 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -16,6 +16,7 @@ import { recordMergedLines, recordTaskCompleted } from './completion'; import type { AgentDef, CreateTaskResult, MergeResult, StepEntry } from '../ipc/types'; import { parseGitHubUrl, taskNameFromGitHubUrl } from '../lib/github-url'; import type { Agent, Task, GitIsolationMode } from './types'; +import type { DockerSource } from '../lib/docker'; function initTaskInStore( taskId: string, @@ -82,6 +83,7 @@ export interface CreateTaskOptions { githubUrl?: string; skipPermissions?: boolean; dockerMode?: boolean; + dockerSource?: DockerSource; dockerImage?: string; stepsEnabled?: boolean; } @@ -98,6 +100,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { githubUrl, skipPermissions, dockerMode, + dockerSource, dockerImage, } = opts; const projectRoot = getProjectPath(projectId); @@ -164,6 +167,7 @@ export async function createTask(opts: CreateTaskOptions): Promise { stepsEnabled: stepsEnabled || undefined, skipPermissions: skipPermissions ?? undefined, dockerMode: dockerMode ?? undefined, + dockerSource: dockerSource ?? undefined, dockerImage: dockerImage ?? undefined, githubUrl, }; diff --git a/src/store/types.ts b/src/store/types.ts index 2624599c..b8d1f7d8 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -1,4 +1,5 @@ import type { AgentDef, StepEntry, WorktreeStatus } from '../ipc/types'; +import type { DockerSource } from '../lib/docker'; import type { LookPreset } from '../lib/look'; export type GitIsolationMode = 'worktree' | 'direct'; @@ -51,6 +52,7 @@ export interface Task { baseBranch?: string; skipPermissions?: boolean; dockerMode?: boolean; + dockerSource?: DockerSource; dockerImage?: string; githubUrl?: string; collapsed?: boolean; @@ -83,6 +85,7 @@ export interface PersistedTask { baseBranch?: string; skipPermissions?: boolean; dockerMode?: boolean; + dockerSource?: DockerSource; dockerImage?: string; githubUrl?: string; savedInitialPrompt?: string;