diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 2a4863b2..86238e23 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,82 @@ 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}/${relDir}`; + 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/.claude`; + 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.readFileSync(expectedHostFile, 'utf8')).toBe('{}'); + }); + + 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..56bca6a9 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,21 @@ 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'], +}; + +// 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; if (!home) return mounts; @@ -653,6 +668,38 @@ 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) { + 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`); + } + } + 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.statSync(hostFile).size === 0) { + 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/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/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. +
+
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/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]) 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); }