From f79a791c41aabf9b64e1e08ff583ccf4c45d699e Mon Sep 17 00:00:00 2001 From: Brooks Cutter Date: Sat, 2 May 2026 13:18:27 -0700 Subject: [PATCH 1/4] share docker agent auth --- electron/ipc/pty.test.ts | 62 +++++++++++++++++++++++++++++++ electron/ipc/pty.ts | 28 +++++++++++++- electron/ipc/register.ts | 1 + src/components/SettingsDialog.tsx | 29 +++++++++++++++ src/components/TerminalView.tsx | 1 + src/store/core.ts | 1 + src/store/persistence.ts | 4 ++ src/store/store.ts | 1 + src/store/types.ts | 2 + src/store/ui.ts | 4 ++ 10 files changed, 131 insertions(+), 2 deletions(-) diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 2a4863b2..6973c66b 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -126,6 +126,7 @@ function buildSpawnArgs( rows: 40, dockerMode: true, dockerImage: 'parallel-code-agent:test', + shareDockerAgentAuth: false, onOutput: { __CHANNEL_ID__: 'channel-1' }, ...overrides, }; @@ -310,6 +311,67 @@ describe('spawnAgent docker mode', () => { expect(volumeFlags).toContain(`${home}/.gitconfig:${DOCKER_CONTAINER_HOME}/.gitconfig:ro`); expect(volumeFlags).toContain(`${home}/.config/gh:${DOCKER_CONTAINER_HOME}/.config/gh:ro`); }); + + describe('agent config dir mounts (shareDockerAgentAuth)', () => { + it.each([ + ['claude', '.claude'], + ['codex', '.codex'], + ['gemini', '.gemini'], + ['opencode', '.config/opencode'], + ['copilot', '.config/github-copilot'], + ])( + '%s bind-mounts a user-owned host directory when shareDockerAgentAuth is enabled', + (command, relDir) => { + const home = makeTempHome([]); + vi.stubEnv('HOME', home); + + spawnAgent(createMockWindow(), buildSpawnArgs({ command, shareDockerAgentAuth: true })); + + const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); + const expectedHostDir = `${home}/.parallel-code/agent-auth/${command}`; + expect(volumeFlags).toContain(`${expectedHostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`); + }, + ); + + it('creates the host auth directory so it is user-owned before mounting', () => { + const home = makeTempHome([]); + vi.stubEnv('HOME', home); + + spawnAgent( + createMockWindow(), + buildSpawnArgs({ command: 'claude', shareDockerAgentAuth: true }), + ); + + const hostDir = `${home}/.parallel-code/agent-auth/claude`; + expect(fs.existsSync(hostDir)).toBe(true); + }); + + it('does not mount agent auth directory when shareDockerAgentAuth is disabled', () => { + const home = makeTempHome([]); + vi.stubEnv('HOME', home); + + spawnAgent( + createMockWindow(), + buildSpawnArgs({ command: 'claude', shareDockerAgentAuth: false }), + ); + + const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); + expect(volumeFlags.some((v) => v.includes('.parallel-code/agent-auth'))).toBe(false); + }); + + it('does not mount agent auth directory for an unknown agent command', () => { + const home = makeTempHome([]); + vi.stubEnv('HOME', home); + + spawnAgent( + createMockWindow(), + buildSpawnArgs({ command: 'unknown-agent', shareDockerAgentAuth: true }), + ); + + const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); + expect(volumeFlags.some((v) => v.includes('.parallel-code/agent-auth'))).toBe(false); + }); + }); }); describe('validateCommand', () => { diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 4069a700..cd4a83d2 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -148,6 +148,7 @@ export function spawnAgent( isShell?: boolean; dockerMode?: boolean; dockerImage?: string; + shareDockerAgentAuth?: boolean; onOutput: { __CHANNEL_ID__: string }; }, ): void { @@ -264,7 +265,7 @@ export function spawnAgent( '-e', `HOME=${DOCKER_CONTAINER_HOME}`, // Mount SSH and git config read-only for git operations - ...buildDockerCredentialMounts(), + ...buildDockerCredentialMounts(args.command, args.shareDockerAgentAuth === true), image, command, ...args.args, @@ -616,7 +617,16 @@ function buildDockerEnvFlags(env: Record): string[] { return flags; } -function buildDockerCredentialMounts(): string[] { +// Config directories each agent CLI uses for auth/settings, relative to HOME. +const AGENT_CONFIG_DIRS: Record = { + claude: ['.claude'], + codex: ['.codex'], + gemini: ['.gemini'], + opencode: ['.config/opencode'], + copilot: ['.config/github-copilot'], +}; + +function buildDockerCredentialMounts(agentCommand: string, shareAgentAuth: boolean): string[] { const mounts: string[] = []; const home = process.env.HOME; if (!home) return mounts; @@ -653,6 +663,20 @@ function buildDockerCredentialMounts(): string[] { mountIfExists(googleCredsFile, googleCredsFile); } + // When "Share agent auth across Linux containers" is enabled, bind-mount a + // host directory (created here, owned by the current user) into the agent's + // config location inside the container. Using a host directory avoids the + // root-ownership problem of Docker named volumes: the directory is created + // by this process (running as the user), so the containerised agent can + // write credentials on first login and read them on subsequent runs. + if (shareAgentAuth) { + for (const relDir of AGENT_CONFIG_DIRS[agentCommand] ?? []) { + const hostDir = path.join(home, '.parallel-code', 'agent-auth', agentCommand); + fs.mkdirSync(hostDir, { recursive: true }); + mounts.push('-v', `${hostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`); + } + } + return mounts; } diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 1d3d40a1..ddb17147 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -237,6 +237,7 @@ export function registerAllHandlers(win: BrowserWindow): void { assertInt(args.rows, 'rows'); assertOptionalBoolean(args.dockerMode, 'dockerMode'); assertOptionalString(args.dockerImage, 'dockerImage'); + assertOptionalBoolean(args.shareDockerAgentAuth, 'shareDockerAgentAuth'); assertOptionalBoolean(args.stepsEnabled, 'stepsEnabled'); if (args.cwd) validatePath(args.cwd, 'cwd'); if (!args.isShell && args.cwd) { diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 1befe40b..d6eb9892 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -23,6 +23,7 @@ import { setInactiveColumnOpacity, setEditorCommand, setDockerImage, + setShareDockerAgentAuth, setAskCodeProvider, setMinimaxApiKey, } from '../store/store'; @@ -541,6 +542,34 @@ export function SettingsDialog(props: SettingsDialogProps) { will use a project-specific image instead. + diff --git a/src/components/TerminalView.tsx b/src/components/TerminalView.tsx index efaa93ab..c7d05f33 100644 --- a/src/components/TerminalView.tsx +++ b/src/components/TerminalView.tsx @@ -612,6 +612,7 @@ export function TerminalView(props: TerminalViewProps) { stepsEnabled: props.stepsEnabled, dockerMode: props.dockerMode, dockerImage: props.dockerImage, + shareDockerAgentAuth: store.shareDockerAgentAuth, onOutput, // eslint-disable-next-line solid/reactivity -- promise catch handler reads current prop values intentionally }).catch((err) => { diff --git a/src/store/core.ts b/src/store/core.ts index e5c4d0b6..e6c3069e 100644 --- a/src/store/core.ts +++ b/src/store/core.ts @@ -51,6 +51,7 @@ export const [store, setStore] = createStore({ editorCommand: '', dockerImage: 'parallel-code-agent:latest', dockerAvailable: false, + shareDockerAgentAuth: false, askCodeProvider: 'claude', newTaskDropUrl: null, newTaskPrefillPrompt: null, diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 0d5b744b..66091fef 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -69,6 +69,7 @@ export async function saveState(): Promise { keybindingMigrationDismissed: store.keybindingMigrationDismissed || undefined, focusMode: store.focusMode || undefined, verboseLogging: store.verboseLogging || undefined, + shareDockerAgentAuth: store.shareDockerAgentAuth || undefined, }; for (const taskId of store.taskOrder) { @@ -257,6 +258,7 @@ interface LegacyPersistedState { keybindingMigrationDismissed?: unknown; focusMode?: unknown; verboseLogging?: unknown; + shareDockerAgentAuth?: unknown; } export async function loadState(): Promise { @@ -396,6 +398,8 @@ export async function loadState(): Promise { s.verboseLogging = typeof raw.verboseLogging === 'boolean' ? raw.verboseLogging : false; + s.shareDockerAgentAuth = raw.shareDockerAgentAuth === true; + const rawDockerImage = raw.dockerImage; s.dockerImage = typeof rawDockerImage === 'string' && rawDockerImage.trim() diff --git a/src/store/store.ts b/src/store/store.ts index 397297d8..0e8b330d 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -116,6 +116,7 @@ export { setEditorCommand, setDockerImage, setDockerAvailable, + setShareDockerAgentAuth, setAskCodeProvider, setMinimaxApiKey, setWindowState, diff --git a/src/store/types.ts b/src/store/types.ts index b1c9f987..67560992 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -153,6 +153,7 @@ export interface PersistedState { inactiveColumnOpacity?: number; editorCommand?: string; dockerImage?: string; + shareDockerAgentAuth?: boolean; askCodeProvider?: 'claude' | 'minimax'; customAgents?: AgentDef[]; keybindingMigrationDismissed?: boolean; @@ -230,6 +231,7 @@ export interface AppStore { editorCommand: string; dockerImage: string; dockerAvailable: boolean; + shareDockerAgentAuth: boolean; askCodeProvider: 'claude' | 'minimax'; newTaskDropUrl: string | null; newTaskPrefillPrompt: { prompt: string; projectId: string | null } | null; diff --git a/src/store/ui.ts b/src/store/ui.ts index 18278a29..08e52fcd 100644 --- a/src/store/ui.ts +++ b/src/store/ui.ts @@ -135,6 +135,10 @@ export function setDockerAvailable(available: boolean): void { setStore('dockerAvailable', available); } +export function setShareDockerAgentAuth(enabled: boolean): void { + setStore('shareDockerAgentAuth', enabled); +} + export function toggleArena(show?: boolean): void { setStore('showArena', show ?? !store.showArena); } From 4d12dd3db70357c50c631605355cb4562170b47c Mon Sep 17 00:00:00 2001 From: brooksc Date: Mon, 4 May 2026 18:21:19 -0700 Subject: [PATCH 2/4] fix(docker-auth): address PR review feedback - Use path.basename() for agent command lookup so full paths work - Incorporate relDir into hostDir to avoid cross-contamination if an agent has multiple config dirs in future - Create host auth dirs with mode 0o700 to restrict access to credentials - Degrade gracefully if mkdirSync fails rather than aborting Docker spawn - Add shareDockerAgentAuth to autosave snapshot so toggling persists immediately Co-Authored-By: Claude Sonnet 4.6 --- electron/ipc/pty.test.ts | 4 ++-- electron/ipc/pty.ts | 13 +++++++++---- src/store/autosave.ts | 1 + 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 6973c66b..94f716e1 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -328,7 +328,7 @@ describe('spawnAgent docker mode', () => { spawnAgent(createMockWindow(), buildSpawnArgs({ command, shareDockerAgentAuth: true })); const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); - const expectedHostDir = `${home}/.parallel-code/agent-auth/${command}`; + const expectedHostDir = `${home}/.parallel-code/agent-auth/${command}/${relDir}`; expect(volumeFlags).toContain(`${expectedHostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`); }, ); @@ -342,7 +342,7 @@ describe('spawnAgent docker mode', () => { buildSpawnArgs({ command: 'claude', shareDockerAgentAuth: true }), ); - const hostDir = `${home}/.parallel-code/agent-auth/claude`; + const hostDir = `${home}/.parallel-code/agent-auth/claude/.claude`; expect(fs.existsSync(hostDir)).toBe(true); }); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index cd4a83d2..0a7a3500 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -670,10 +670,15 @@ function buildDockerCredentialMounts(agentCommand: string, shareAgentAuth: boole // by this process (running as the user), so the containerised agent can // write credentials on first login and read them on subsequent runs. if (shareAgentAuth) { - for (const relDir of AGENT_CONFIG_DIRS[agentCommand] ?? []) { - const hostDir = path.join(home, '.parallel-code', 'agent-auth', agentCommand); - fs.mkdirSync(hostDir, { recursive: true }); - mounts.push('-v', `${hostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`); + const baseCommand = path.basename(agentCommand); + for (const relDir of AGENT_CONFIG_DIRS[baseCommand] ?? []) { + const hostDir = path.join(home, '.parallel-code', 'agent-auth', baseCommand, relDir); + try { + fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); + mounts.push('-v', `${hostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`); + } catch { + console.warn(`[docker-auth] Could not create host auth dir ${hostDir}, skipping mount`); + } } } diff --git a/src/store/autosave.ts b/src/store/autosave.ts index 415f2f8a..7d8565d6 100644 --- a/src/store/autosave.ts +++ b/src/store/autosave.ts @@ -33,6 +33,7 @@ function persistedSnapshot(): string { editorCommand: store.editorCommand, customAgents: store.customAgents, focusMode: store.focusMode, + shareDockerAgentAuth: store.shareDockerAgentAuth, tasks: Object.fromEntries( [...store.taskOrder, ...store.collapsedTaskOrder] .filter((id) => store.tasks[id]) From 6c0d49c97b10efc795a1a60170e53ef198a9c6ef Mon Sep 17 00:00:00 2001 From: brooksc Date: Mon, 4 May 2026 19:53:30 -0700 Subject: [PATCH 3/4] fix(docker-auth): persist claude.json and update Docker info message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude stores its main auth config in ~/.claude.json (at HOME level) in addition to ~/.claude/ — without mounting this file, credentials written in the first container are lost when the container exits and the next container prompts for login again. Also add a note to the new-task Docker info message when auth sharing is enabled so users know credentials will carry over. Co-Authored-By: Claude Sonnet 4.6 --- electron/ipc/pty.test.ts | 15 +++++++++++++++ electron/ipc/pty.ts | 18 ++++++++++++++++++ src/components/NewTaskDialog.tsx | 4 ++++ 3 files changed, 37 insertions(+) diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 94f716e1..f4b330ff 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -346,6 +346,21 @@ describe('spawnAgent docker mode', () => { expect(fs.existsSync(hostDir)).toBe(true); }); + it('bind-mounts .claude.json file for claude so auth persists across containers', () => { + const home = makeTempHome([]); + vi.stubEnv('HOME', home); + + spawnAgent( + createMockWindow(), + buildSpawnArgs({ command: 'claude', shareDockerAgentAuth: true }), + ); + + const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); + const expectedHostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; + expect(volumeFlags).toContain(`${expectedHostFile}:${DOCKER_CONTAINER_HOME}/.claude.json`); + expect(fs.existsSync(expectedHostFile)).toBe(true); + }); + it('does not mount agent auth directory when shareDockerAgentAuth is disabled', () => { const home = makeTempHome([]); vi.stubEnv('HOME', home); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 0a7a3500..ca136e01 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -626,6 +626,11 @@ const AGENT_CONFIG_DIRS: Record = { copilot: ['.config/github-copilot'], }; +// Config files (not directories) each agent CLI uses for auth, relative to HOME. +const AGENT_CONFIG_FILES: Record = { + claude: ['.claude.json'], +}; + function buildDockerCredentialMounts(agentCommand: string, shareAgentAuth: boolean): string[] { const mounts: string[] = []; const home = process.env.HOME; @@ -680,6 +685,19 @@ function buildDockerCredentialMounts(agentCommand: string, shareAgentAuth: boole console.warn(`[docker-auth] Could not create host auth dir ${hostDir}, skipping mount`); } } + for (const relFile of AGENT_CONFIG_FILES[baseCommand] ?? []) { + const hostFile = path.join(home, '.parallel-code', 'agent-auth', baseCommand, relFile); + try { + const hostDir = path.dirname(hostFile); + fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); + if (!fs.existsSync(hostFile)) { + fs.writeFileSync(hostFile, '', { mode: 0o600 }); + } + mounts.push('-v', `${hostFile}:${DOCKER_CONTAINER_HOME}/${relFile}`); + } catch { + console.warn(`[docker-auth] Could not create host auth file ${hostFile}, skipping mount`); + } + } } return mounts; diff --git a/src/components/NewTaskDialog.tsx b/src/components/NewTaskDialog.tsx index 878e970f..ff31a8e9 100644 --- a/src/components/NewTaskDialog.tsx +++ b/src/components/NewTaskDialog.tsx @@ -902,6 +902,10 @@ 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. + + {' '} + Agent credentials are shared across containers. +
Date: Mon, 4 May 2026 20:15:06 -0700 Subject: [PATCH 4/4] fix(docker-auth): seed .claude.json with {} if missing or empty An empty file causes Claude to reject it as invalid JSON on startup. Seed it with a valid empty object so the container starts cleanly. Co-Authored-By: Claude Sonnet 4.6 --- electron/ipc/pty.test.ts | 2 +- electron/ipc/pty.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index f4b330ff..86238e23 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -358,7 +358,7 @@ describe('spawnAgent docker mode', () => { const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v'); const expectedHostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`; expect(volumeFlags).toContain(`${expectedHostFile}:${DOCKER_CONTAINER_HOME}/.claude.json`); - expect(fs.existsSync(expectedHostFile)).toBe(true); + expect(fs.readFileSync(expectedHostFile, 'utf8')).toBe('{}'); }); it('does not mount agent auth directory when shareDockerAgentAuth is disabled', () => { diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index ca136e01..56bca6a9 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -690,8 +690,8 @@ function buildDockerCredentialMounts(agentCommand: string, shareAgentAuth: boole try { const hostDir = path.dirname(hostFile); fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 }); - if (!fs.existsSync(hostFile)) { - fs.writeFileSync(hostFile, '', { mode: 0o600 }); + if (!fs.existsSync(hostFile) || fs.statSync(hostFile).size === 0) { + fs.writeFileSync(hostFile, '{}', { mode: 0o600 }); } mounts.push('-v', `${hostFile}:${DOCKER_CONTAINER_HOME}/${relFile}`); } catch {