Skip to content

Commit d394ff6

Browse files
brookscclaude
andcommitted
fix: isolate Docker sub-task HOME dirs to prevent config collisions (TODO johannesjo#19)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 79d2b3c commit d394ff6

2 files changed

Lines changed: 47 additions & 23 deletions

File tree

electron/ipc/pty.test.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -239,24 +239,27 @@ describe('spawnAgent docker mode', () => {
239239
expect(volumeFlags).toContain(`${cwd}:${cwd}`);
240240
});
241241

242-
it('injects HOME=/tmp into docker run args', () => {
242+
it('injects a per-agent HOME under /tmp into docker run args', () => {
243243
vi.stubEnv('HOME', '/Users/tester');
244244

245-
spawnAgent(createMockWindow(), buildSpawnArgs());
245+
const agentId = nextAgentId();
246+
spawnAgent(createMockWindow(), buildSpawnArgs({ agentId }));
246247

247248
const { command, args } = getLastSpawnCall();
248249
expect(command).toBe('docker');
249-
expect(getFlagValues(args, '-e')).toContain(`HOME=${DOCKER_CONTAINER_HOME}`);
250+
expect(getFlagValues(args, '-e')).toContain(`HOME=${DOCKER_CONTAINER_HOME}/agent-${agentId}`);
250251
});
251252

252253
it('does not forward host or renderer HOME as a generic docker env flag', () => {
253254
const hostHome = '/Users/host-home';
254255
const rendererHome = '/Users/renderer-home';
255256
vi.stubEnv('HOME', hostHome);
256257

258+
const agentId = nextAgentId();
257259
spawnAgent(
258260
createMockWindow(),
259261
buildSpawnArgs({
262+
agentId,
260263
env: {
261264
API_KEY: 'secret',
262265
HOME: rendererHome,
@@ -267,7 +270,7 @@ describe('spawnAgent docker mode', () => {
267270
const envFlags = getFlagValues(getLastSpawnCall().args, '-e');
268271
expect(envFlags).toContain('API_KEY=secret');
269272
expect(envFlags.filter((value) => value.startsWith('HOME='))).toEqual([
270-
`HOME=${DOCKER_CONTAINER_HOME}`,
273+
`HOME=${DOCKER_CONTAINER_HOME}/agent-${agentId}`,
271274
]);
272275
expect(envFlags).not.toContain(`HOME=${hostHome}`);
273276
expect(envFlags).not.toContain(`HOME=${rendererHome}`);
@@ -328,16 +331,18 @@ describe('spawnAgent docker mode', () => {
328331
expect(ctx.args).toEqual(['-c', '<redacted>']);
329332
});
330333

331-
it('redirects credential mounts under /tmp inside the container', () => {
334+
it('redirects credential mounts under per-agent /tmp/agent-<id> inside the container', () => {
332335
const home = makeTempHome(['.ssh/', '.gitconfig', '.config/gh/']);
333336
vi.stubEnv('HOME', home);
334337

335-
spawnAgent(createMockWindow(), buildSpawnArgs());
338+
const agentId = nextAgentId();
339+
spawnAgent(createMockWindow(), buildSpawnArgs({ agentId }));
336340

341+
const containerHome = `${DOCKER_CONTAINER_HOME}/agent-${agentId}`;
337342
const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v');
338-
expect(volumeFlags).toContain(`${home}/.ssh:${DOCKER_CONTAINER_HOME}/.ssh:ro`);
339-
expect(volumeFlags).toContain(`${home}/.gitconfig:${DOCKER_CONTAINER_HOME}/.gitconfig:ro`);
340-
expect(volumeFlags).toContain(`${home}/.config/gh:${DOCKER_CONTAINER_HOME}/.config/gh:ro`);
343+
expect(volumeFlags).toContain(`${home}/.ssh:${containerHome}/.ssh:ro`);
344+
expect(volumeFlags).toContain(`${home}/.gitconfig:${containerHome}/.gitconfig:ro`);
345+
expect(volumeFlags).toContain(`${home}/.config/gh:${containerHome}/.config/gh:ro`);
341346
});
342347

343348
describe('agent config dir mounts (shareDockerAgentAuth)', () => {
@@ -353,11 +358,16 @@ describe('spawnAgent docker mode', () => {
353358
const home = makeTempHome([]);
354359
vi.stubEnv('HOME', home);
355360

356-
spawnAgent(createMockWindow(), buildSpawnArgs({ command, shareDockerAgentAuth: true }));
361+
const agentId = nextAgentId();
362+
spawnAgent(
363+
createMockWindow(),
364+
buildSpawnArgs({ agentId, command, shareDockerAgentAuth: true }),
365+
);
357366

367+
const containerHome = `${DOCKER_CONTAINER_HOME}/agent-${agentId}`;
358368
const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v');
359369
const expectedHostDir = `${home}/.parallel-code/agent-auth/${command}/${relDir}`;
360-
expect(volumeFlags).toContain(`${expectedHostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`);
370+
expect(volumeFlags).toContain(`${expectedHostDir}:${containerHome}/${relDir}`);
361371
},
362372
);
363373

@@ -378,14 +388,16 @@ describe('spawnAgent docker mode', () => {
378388
const home = makeTempHome([]);
379389
vi.stubEnv('HOME', home);
380390

391+
const agentId = nextAgentId();
381392
spawnAgent(
382393
createMockWindow(),
383-
buildSpawnArgs({ command: 'claude', shareDockerAgentAuth: true }),
394+
buildSpawnArgs({ agentId, command: 'claude', shareDockerAgentAuth: true }),
384395
);
385396

397+
const containerHome = `${DOCKER_CONTAINER_HOME}/agent-${agentId}`;
386398
const volumeFlags = getFlagValues(getLastSpawnCall().args, '-v');
387399
const expectedHostFile = `${home}/.parallel-code/agent-auth/claude/.claude.json`;
388-
expect(volumeFlags).toContain(`${expectedHostFile}:${DOCKER_CONTAINER_HOME}/.claude.json`);
400+
expect(volumeFlags).toContain(`${expectedHostFile}:${containerHome}/.claude.json`);
389401
expect(JSON.parse(fs.readFileSync(expectedHostFile, 'utf8'))).toMatchObject({
390402
projects: {
391403
'/workspace/project': {

electron/ipc/pty.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -323,12 +323,23 @@ export function spawnAgent(
323323
cwd,
324324
// Forward env vars the agent needs (API keys, git config, etc.)
325325
...buildDockerEnvFlags(spawnEnv),
326-
// Writable HOME for agent config files (host HOME is blocked above)
326+
// Per-agent writable HOME so concurrent sub-tasks don't collide on config files.
327327
'-e',
328-
`HOME=${DOCKER_CONTAINER_HOME}`,
328+
`HOME=${DOCKER_CONTAINER_HOME}/agent-${args.agentId}`,
329329
// Mount SSH and git config read-only for git operations
330-
...buildDockerCredentialMounts(args.command, args.shareDockerAgentAuth === true, cwd),
330+
...buildDockerCredentialMounts(
331+
args.command,
332+
args.shareDockerAgentAuth === true,
333+
cwd,
334+
`${DOCKER_CONTAINER_HOME}/agent-${args.agentId}`,
335+
),
331336
image,
337+
// Pre-create the per-agent HOME directory then exec the real command.
338+
// $HOME is already set by the -e flag above; using it here avoids repeating the path.
339+
'sh',
340+
'-c',
341+
'mkdir -p "$HOME" && exec "$@"',
342+
'--',
332343
command,
333344
...args.args,
334345
];
@@ -733,6 +744,7 @@ function buildDockerCredentialMounts(
733744
agentCommand: string,
734745
shareAgentAuth: boolean,
735746
worktreePath: string,
747+
containerHome: string,
736748
): string[] {
737749
const mounts: string[] = [];
738750
const home = process.env.HOME;
@@ -749,19 +761,19 @@ function buildDockerCredentialMounts(
749761
};
750762

751763
// SSH keys for git push/pull
752-
mountIfExists(`${home}/.ssh`, `${DOCKER_CONTAINER_HOME}/.ssh`);
764+
mountIfExists(`${home}/.ssh`, `${containerHome}/.ssh`);
753765

754766
// Git identity / config
755-
mountIfExists(`${home}/.gitconfig`, `${DOCKER_CONTAINER_HOME}/.gitconfig`);
767+
mountIfExists(`${home}/.gitconfig`, `${containerHome}/.gitconfig`);
756768

757769
// GitHub CLI auth tokens (~/.config/gh/)
758-
mountIfExists(`${home}/.config/gh`, `${DOCKER_CONTAINER_HOME}/.config/gh`);
770+
mountIfExists(`${home}/.config/gh`, `${containerHome}/.config/gh`);
759771

760772
// npm auth token
761-
mountIfExists(`${home}/.npmrc`, `${DOCKER_CONTAINER_HOME}/.npmrc`);
773+
mountIfExists(`${home}/.npmrc`, `${containerHome}/.npmrc`);
762774

763775
// General HTTP/git HTTPS credentials (used by git credential helper)
764-
mountIfExists(`${home}/.netrc`, `${DOCKER_CONTAINER_HOME}/.netrc`);
776+
mountIfExists(`${home}/.netrc`, `${containerHome}/.netrc`);
765777

766778
// Google Application Credentials file (for Vertex AI / gcloud) — mounted
767779
// at its original path since the env var points there.
@@ -782,7 +794,7 @@ function buildDockerCredentialMounts(
782794
const hostDir = path.join(home, '.parallel-code', 'agent-auth', baseCommand, relDir);
783795
try {
784796
fs.mkdirSync(hostDir, { recursive: true, mode: 0o700 });
785-
mounts.push('-v', `${hostDir}:${DOCKER_CONTAINER_HOME}/${relDir}`);
797+
mounts.push('-v', `${hostDir}:${containerHome}/${relDir}`);
786798
} catch {
787799
console.warn(`[docker-auth] Could not create host auth dir ${hostDir}, skipping mount`);
788800
}
@@ -798,7 +810,7 @@ function buildDockerCredentialMounts(
798810
if (baseCommand === 'claude' && relFile === '.claude.json') {
799811
seedClaudeProjectTrust(hostFile, worktreePath);
800812
}
801-
mounts.push('-v', `${hostFile}:${DOCKER_CONTAINER_HOME}/${relFile}`);
813+
mounts.push('-v', `${hostFile}:${containerHome}/${relFile}`);
802814
} catch {
803815
console.warn(`[docker-auth] Could not create host auth file ${hostFile}, skipping mount`);
804816
}

0 commit comments

Comments
 (0)