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
77 changes: 77 additions & 0 deletions electron/ipc/pty.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ function buildSpawnArgs(
rows: 40,
dockerMode: true,
dockerImage: 'parallel-code-agent:test',
shareDockerAgentAuth: false,
onOutput: { __CHANNEL_ID__: 'channel-1' },
...overrides,
};
Expand Down Expand Up @@ -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', () => {
Expand Down
51 changes: 49 additions & 2 deletions electron/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export function spawnAgent(
isShell?: boolean;
dockerMode?: boolean;
dockerImage?: string;
shareDockerAgentAuth?: boolean;
onOutput: { __CHANNEL_ID__: string };
},
): void {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -616,7 +617,21 @@ function buildDockerEnvFlags(env: Record<string, string>): string[] {
return flags;
}

function buildDockerCredentialMounts(): string[] {
// Config directories each agent CLI uses for auth/settings, relative to HOME.
const AGENT_CONFIG_DIRS: Record<string, string[]> = {
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<string, string[]> = {
claude: ['.claude.json'],
};

function buildDockerCredentialMounts(agentCommand: string, shareAgentAuth: boolean): string[] {
const mounts: string[] = [];
const home = process.env.HOME;
if (!home) return mounts;
Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/components/NewTaskDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<Show when={store.shareDockerAgentAuth}>
{' '}
Agent credentials are shared across containers.
</Show>
</div>
<Show when={projectDockerfile()}>
<div
Expand Down
29 changes: 29 additions & 0 deletions src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
setInactiveColumnOpacity,
setEditorCommand,
setDockerImage,
setShareDockerAgentAuth,
setAskCodeProvider,
setMinimaxApiKey,
} from '../store/store';
Expand Down Expand Up @@ -541,6 +542,34 @@ export function SettingsDialog(props: SettingsDialogProps) {
will use a project-specific image instead.
</div>
</div>
<label
style={{
display: 'flex',
'align-items': 'center',
gap: '10px',
cursor: 'pointer',
padding: '8px 12px',
'border-radius': '8px',
background: theme.bgInput,
border: `1px solid ${theme.border}`,
}}
>
<input
type="checkbox"
checked={store.shareDockerAgentAuth}
onChange={(e) => setShareDockerAgentAuth(e.currentTarget.checked)}
style={{ 'accent-color': theme.accent, cursor: 'pointer' }}
/>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '2px' }}>
<span style={{ 'font-size': '14px', color: theme.fg }}>
Share agent auth across Linux containers
</span>
<span style={{ 'font-size': '12px', color: theme.fgSubtle }}>
Persist agent credentials in a user-owned host directory so you only need to sign in
once per agent type. Auth on first run is saved automatically for future containers.
</span>
</div>
</label>
</div>
</Show>

Expand Down
1 change: 1 addition & 0 deletions src/components/TerminalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
1 change: 1 addition & 0 deletions src/store/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
1 change: 1 addition & 0 deletions src/store/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const [store, setStore] = createStore<AppStore>({
editorCommand: '',
dockerImage: 'parallel-code-agent:latest',
dockerAvailable: false,
shareDockerAgentAuth: false,
askCodeProvider: 'claude',
newTaskDropUrl: null,
newTaskPrefillPrompt: null,
Expand Down
4 changes: 4 additions & 0 deletions src/store/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export async function saveState(): Promise<void> {
keybindingMigrationDismissed: store.keybindingMigrationDismissed || undefined,
focusMode: store.focusMode || undefined,
verboseLogging: store.verboseLogging || undefined,
shareDockerAgentAuth: store.shareDockerAgentAuth || undefined,
};

for (const taskId of store.taskOrder) {
Expand Down Expand Up @@ -257,6 +258,7 @@ interface LegacyPersistedState {
keybindingMigrationDismissed?: unknown;
focusMode?: unknown;
verboseLogging?: unknown;
shareDockerAgentAuth?: unknown;
}

export async function loadState(): Promise<void> {
Expand Down Expand Up @@ -396,6 +398,8 @@ export async function loadState(): Promise<void> {

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()
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ export {
setEditorCommand,
setDockerImage,
setDockerAvailable,
setShareDockerAgentAuth,
setAskCodeProvider,
setMinimaxApiKey,
setWindowState,
Expand Down
2 changes: 2 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export interface PersistedState {
inactiveColumnOpacity?: number;
editorCommand?: string;
dockerImage?: string;
shareDockerAgentAuth?: boolean;
askCodeProvider?: 'claude' | 'minimax';
customAgents?: AgentDef[];
keybindingMigrationDismissed?: boolean;
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions src/store/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading