microsoft/vscode · #336049
Agent Host: Forward Git identity to Dev Containers
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';