microsoft/vscode · #336049

Agent Host: Forward Git identity to Dev Containers

chrmarti · merged Sep 13, 20262 files · 131 + / 1
src/vs/platform/agentHost/node/devContainerAgentHostService.ts55 + / 1
@@ -63,6 +63,11 @@ interface IDevContainerMount { 	readonly Destination: string; } +interface IGitIdentity {+	readonly name: string | undefined;+	readonly email: string | undefined;+}+ const devContainerUpResultValidator = vObj({ 	outcome: vLiteral('success'), 	containerId: vString(),@@ -166,6 +171,15 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev 				safeDirectoryStopWatch.stop(); 				this._logService.warn(`${LOG_PREFIX} Failed to configure Git safe.directory after ${safeDirectoryStopWatch.elapsed()}ms`, error); 			}+			const gitIdentityStopWatch = StopWatch.create(false);+			try {+				await this._configureGitIdentity(exec, tokenSource.token);+				gitIdentityStopWatch.stop();+				this._logService.info(`${LOG_PREFIX} Git identity configuration completed in ${gitIdentityStopWatch.elapsed()}ms`);+			} catch (error) {+				gitIdentityStopWatch.stop();+				this._logService.warn(`${LOG_PREFIX} Failed to configure Git identity after ${gitIdentityStopWatch.elapsed()}ms`, error);+			} 			const [{ stdout: unameS }, { stdout: unameM }, { stdout: libc }] = await Promise.all([ 				exec('uname -s'), 				exec('uname -m'),@@ -275,6 +289,46 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev 		this._logService.info(`${LOG_PREFIX} Added Git root '${rootFolder}' to the Dev Container user's safe.directory list`); 	} +	private async _configureGitIdentity(exec: ISshExec, token: CancellationToken): Promise<void> {+		const identity = await this._getHostGitIdentity(token);+		if (!identity.name && !identity.email) {+			return;+		}++		const gitAvailable = await exec('command -v git >/dev/null 2>&1', { ignoreExitCode: true });+		if (gitAvailable.code !== 0) {+			return;+		}++		const values: readonly { readonly key: string; readonly value: string | undefined }[] = [+			{ key: 'user.name', value: identity.name },+			{ key: 'user.email', value: identity.email },+		];+		for (const { key, value } of values) {+			if (!value) {+				continue;+			}+			const configured = await exec(`git config --get ${key}`, { ignoreExitCode: true });+			if (configured.code === 0 && configured.stdout.trim()) {+				continue;+			}+			await exec(`git config --global --replace-all ${key} ${shellEscape(value)}`);+			this._logService.info(`${LOG_PREFIX} Configured Git ${key} from the host for the Dev Container user`);+		}+	}++	protected async _getHostGitIdentity(token: CancellationToken): Promise<IGitIdentity> {+		const environment = await this._resolveShellEnvironment();+		const read = async (key: string): Promise<string | undefined> => {+			const result = await this._runLocalCommand('git', ['config', '--global', '--get', key], environment, token);+			return result.code === 0 ? result.stdout.trim() || undefined : undefined;+		};+		return {+			name: await read('user.name'),+			email: await read('user.email'),+		};+	}+ 	protected async _getContainerMounts(connectionId: string, containerId: string, token: CancellationToken): Promise<readonly IDevContainerMount[]> { 		const environment = await this._resolveShellEnvironment(); 		const result = await this._runLocalCommand(@@ -330,7 +384,7 @@ export class DevContainerAgentHostMainService extends Disposable implements IDev 		}; 	} -	private _runLocalCommand(command: string, args: readonly string[], environment: NodeJS.ProcessEnv, token: CancellationToken, cwd?: string): Promise<{ stdout: string; stderr: string; code: number }> {+	protected _runLocalCommand(command: string, args: readonly string[], environment: NodeJS.ProcessEnv, token: CancellationToken, cwd?: string): Promise<{ stdout: string; stderr: string; code: number }> { 		return new Promise((resolve, reject) => { 			if (token.isCancellationRequested) { 				reject(new CancellationError());
src/vs/platform/agentHost/test/node/devContainerAgentHostService.test.ts76 + / 0
@@ -55,6 +55,7 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ 	readonly relay = new TestRelay(); 	readonly execCommands: string[] = []; 	readonly devContainerArgs: string[][] = [];+	readonly localCommands: { readonly command: string; readonly args: readonly string[] }[] = []; 	relayCommand: string | undefined; 	endpointPollsBeforeAvailable = 0; 	endpointPolls = 0;@@ -71,6 +72,8 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ 	containerMountsError: Error | undefined; 	hostDirectoryOwnedByCurrentUser = true; 	readonly checkedHostDirectories: string[] = [];+	readonly hostGitConfig = new Map<string, string>();+	readonly containerGitConfig = new Map<string, string>(); 	private _renameCalls = 0; 	private readonly _firstRenameStarted = new DeferredPromise<void>(); 	private readonly _secondRenameFinished = new DeferredPromise<void>();@@ -192,13 +195,29 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ 		return Promise.resolve(this.hostDirectoryOwnedByCurrentUser); 	} +	protected override _runLocalCommand(command: string, args: readonly string[]): Promise<{ stdout: string; stderr: string; code: number }> {+		this.localCommands.push({ command, args });+		if (command === 'git' && args[0] === 'config' && args[1] === '--global' && args[2] === '--get') {+			const value = this.hostGitConfig.get(args[3]);+			return Promise.resolve({+				stdout: value === undefined ? '' : `${value}\n`,+				stderr: '',+				code: value === undefined ? 1 : 0,+			});+		}+		throw new Error(`Unexpected local command: ${command} ${args.join(' ')}`);+	}+ 	createDevContainerExec(connectionId: string, workspaceFolder: string, token: CancellationToken): ISshExec { 		return super._createExec(connectionId, workspaceFolder, token); 	}  	protected override _createExec(): ISshExec { 		return async command => { 			this.execCommands.push(command);+			if (command === 'command -v git >/dev/null 2>&1') {+				return { stdout: '', stderr: '', code: 0 };+			} 			if (command.startsWith('command -v git ')) { 				return { 					stdout: this.gitRootReportedAsDubiousOwnership ? '' : this.gitRootFolder ?? '',@@ -209,6 +228,11 @@ class TestDevContainerAgentHostMainService extends DevContainerAgentHostMainServ 			if (command === 'git config --global --get-all safe.directory') { 				return { stdout: this.safeDirectories.join('\n'), stderr: '', code: this.safeDirectories.length ? 0 : 1 }; 			}+			const gitIdentityMatch = /^git config --get (?<key>user\.(?:name|email))$/.exec(command);+			if (gitIdentityMatch?.groups) {+				const value = this.containerGitConfig.get(gitIdentityMatch.groups.key);+				return { stdout: value === undefined ? '' : `${value}\n`, stderr: '', code: value === undefined ? 1 : 0 };+			} 			if (command === 'uname -s') { 				return { stdout: 'Linux\n', stderr: '', code: 0 }; 			}@@ -486,6 +510,58 @@ suite('Dev Container Agent Host Main Service', () => { 		}); 	}); +	test('forwards missing host Git identity without overwriting container identity', async () => {+		const forwarded = store.add(new TestDevContainerAgentHostMainService());+		forwarded.hostGitConfig.set('user.name', 'Host User');+		forwarded.hostGitConfig.set('user.email', 'host@example.com');+		await forwarded.connect({+			connectionId: 'forwarded',+			workspaceFolder: '/workspace',+			name: 'Project Dev Container',+		});++		const preserved = store.add(new TestDevContainerAgentHostMainService());+		preserved.hostGitConfig.set('user.name', 'Host User');+		preserved.hostGitConfig.set('user.email', 'host@example.com');+		preserved.containerGitConfig.set('user.name', 'Container User');+		await preserved.connect({+			connectionId: 'preserved',+			workspaceFolder: '/workspace',+			name: 'Project Dev Container',+		});++		const absent = store.add(new TestDevContainerAgentHostMainService());+		await absent.connect({+			connectionId: 'absent',+			workspaceFolder: '/workspace',+			name: 'Project Dev Container',+		});++		assert.deepStrictEqual({+			hostCommands: forwarded.localCommands,+			forwardedCommands: forwarded.execCommands.filter(command => command.includes('user.')),+			preservedCommands: preserved.execCommands.filter(command => command.includes('user.')),+			absentCommands: absent.execCommands.filter(command => command.includes('user.')),+		}, {+			hostCommands: [+				{ command: 'git', args: ['config', '--global', '--get', 'user.name'] },+				{ command: 'git', args: ['config', '--global', '--get', 'user.email'] },+			],+			forwardedCommands: [+				'git config --get user.name',+				'git config --global --replace-all user.name \'Host User\'',+				'git config --get user.email',+				'git config --global --replace-all user.email \'host@example.com\'',+			],+			preservedCommands: [+				'git config --get user.name',+				'git config --get user.email',+				'git config --global --replace-all user.email \'host@example.com\'',+			],+			absentCommands: [],+		});+	});+ 	test('adds the exact bind-mounted repository root to Git safe.directory only when host-owned', async () => { 		const configured = store.add(new TestDevContainerAgentHostMainService()); 		configured.remoteWorkspaceFolder = '/workspaces/project/folder';