// ***************************************************************************** // Copyright (C) 2024 Typefox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import * as Docker from 'dockerode'; import { inject, injectable, interfaces } from '@theia/core/shared/inversify'; import { ContainerCreationContribution } from '../docker-container-service'; import { DevContainerConfiguration, DockerfileContainer, ImageContainer, NonComposeContainerBase } from '../devcontainer-file'; import { ILogger, Path } from '@theia/core'; import { ContainerOutputProvider } from '../../electron-common/container-output-provider'; import * as fs from '@theia/core/shared/fs-extra'; import * as os from 'os'; import * as path from 'path'; import * as cp from 'child_process'; import { ForwardedPort, RemotePortForwardingProvider } from '@theia/remote/lib/electron-common/remote-port-forwarding-provider'; import { RemoteDockerContainerConnection } from '../remote-docker-container-connection'; import { WorkspaceCreationContribution } from './workspace-creation-contribution'; import { parseWorkspaceMount } from '../dockerode-utils'; export function registerContainerCreationContributions(bind: interfaces.Bind): void { bind(ContainerCreationContribution).to(ImageFileContribution).inSingletonScope(); bind(ContainerCreationContribution).to(DockerFileContribution).inSingletonScope(); bind(ContainerCreationContribution).to(ForwardPortsContribution).inSingletonScope(); bind(ContainerCreationContribution).to(MountsContribution).inSingletonScope(); bind(ContainerCreationContribution).to(RemoteUserContribution).inSingletonScope(); bind(ContainerCreationContribution).to(PostCreateCommandContribution).inSingletonScope(); bind(ContainerCreationContribution).to(ContainerEnvContribution).inSingletonScope(); bind(ContainerCreationContribution).to(WorkspaceCreationContribution).inSingletonScope(); bind(ContainerCreationContribution).to(HostConfigSharingContribution).inSingletonScope(); bind(ContainerCreationContribution).to(DefaultShellContribution).inSingletonScope(); } @injectable() export class ImageFileContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: ImageContainer, api: Docker, outputprovider: ContainerOutputProvider): Promise { if (containerConfig.image) { const platform = process.platform; const arch = process.arch; const options = platform === 'darwin' && arch === 'arm64' ? { platform: 'amd64' } : {}; const dedup = OutputHelper.createDedupContext(); await new Promise((res, rej) => api.pull(containerConfig.image, options, (err, stream) => { if (err) { rej(err); } else if (stream === undefined) { rej('Stream is undefined'); } else { api.modem.followProgress(stream!, (error, output) => error ? rej(error) : res(), progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress, dedup))); } })); createOptions.Image = containerConfig.image; } } } @injectable() export class DockerFileContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DockerfileContainer, api: Docker, outputprovider: ContainerOutputProvider): Promise { // check if dockerfile container if (containerConfig.dockerFile || containerConfig.build?.dockerfile) { const dockerfile = (containerConfig.dockerFile ?? containerConfig.build?.dockerfile) as string; const context = containerConfig.context ?? new Path(containerConfig.location as string).dir.fsPath(); try { // ensure dockerfile exists await fs.lstat(new Path(context as string).join(dockerfile).fsPath()); const buildStream = await api.buildImage({ context, src: [dockerfile], } as Docker.ImageBuildContext, { buildargs: containerConfig.build?.args }); // TODO probably have some console windows showing the output of the build const dedup = OutputHelper.createDedupContext(); const imageId = await new Promise((res, rej) => api.modem.followProgress(buildStream!, (err, outputs) => { if (err) { rej(err); } else { for (let i = outputs.length - 1; i >= 0; i--) { if (outputs[i].aux?.ID) { res(outputs[i].aux.ID); return; } } } }, progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress, dedup)))); createOptions.Image = imageId; } catch (error) { outputprovider.onRemoteOutput(`Could not build dockerfile "${dockerfile}": ${error.message}`); throw error; } } } } @injectable() export class ForwardPortsContribution implements ContainerCreationContribution { @inject(RemotePortForwardingProvider) protected readonly portForwardingProvider: RemotePortForwardingProvider; async handlePostConnect(containerConfig: DevContainerConfiguration, connection: RemoteDockerContainerConnection): Promise { if (!containerConfig.forwardPorts) { return; } for (const forward of containerConfig.forwardPorts) { let port: number; let address: string | undefined; if (typeof forward === 'string') { const parts = forward.split(':'); address = parts[0]; port = parseInt(parts[1]); } else { port = forward; } const forwardedPort: ForwardedPort = { port, address }; const attributes = this.getPortAttributes(containerConfig, port); if (attributes) { forwardedPort.label = attributes.label; forwardedPort.protocol = attributes.protocol; forwardedPort.onAutoForward = attributes.onAutoForward; } this.portForwardingProvider.forwardPort(connection.localPort, forwardedPort); } } protected getPortAttributes(containerConfig: DevContainerConfiguration, port: number): { label?: string; protocol?: 'http' | 'https'; onAutoForward?: ForwardedPort['onAutoForward'] } | undefined { if (!containerConfig.portsAttributes) { return undefined; } const portStr = String(port); for (const [pattern, attributes] of Object.entries(containerConfig.portsAttributes)) { if (pattern === portStr) { return attributes; } const rangeMatch = pattern.match(/^(\d+)-(\d+)$/); if (rangeMatch) { const start = parseInt(rangeMatch[1]); const end = parseInt(rangeMatch[2]); if (port >= start && port <= end) { return attributes; } } } return undefined; } } @injectable() export class MountsContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise { if (!containerConfig.mounts) { return; } createOptions.HostConfig!.Mounts!.push(...(containerConfig as NonComposeContainerBase)?.mounts ?.map(mount => typeof mount === 'string' ? parseWorkspaceMount(mount) : { Source: mount.source, Target: mount.target, Type: mount.type ?? 'bind' }) ?? []); } } @injectable() export class RemoteUserContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise { if (containerConfig.remoteUser) { createOptions.User = containerConfig.remoteUser; } } } @injectable() export class PostCreateCommandContribution implements ContainerCreationContribution { async handlePostCreate?(containerConfig: DevContainerConfiguration, container: Docker.Container, api: Docker, outputprovider: ContainerOutputProvider): Promise { if (containerConfig.postCreateCommand) { const commands = typeof containerConfig.postCreateCommand === 'object' && !(containerConfig.postCreateCommand instanceof Array) ? Object.values(containerConfig.postCreateCommand) : [containerConfig.postCreateCommand]; for (const command of commands) { try { let exec; if (command instanceof Array) { exec = await container.exec({ Cmd: command, AttachStderr: true, AttachStdout: true }); } else { exec = await container.exec({ Cmd: ['sh', '-c', command], AttachStderr: true, AttachStdout: true }); } const stream = await exec.start({ Tty: true }); stream.on('data', chunk => outputprovider.onRemoteOutput(chunk.toString())); } catch (error) { outputprovider.onRemoteOutput(`Could not execute postCreateCommand ${JSON.stringify(command)}: ${error.message}`); } } } } } @injectable() export class ContainerEnvContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration): Promise { if (containerConfig.containerEnv) { if (createOptions.Env === undefined) { createOptions.Env = []; } for (const [key, value] of Object.entries(containerConfig.containerEnv)) { createOptions.Env.push(`${key}=${value}`); } } } } @injectable() export class HostConfigSharingContribution implements ContainerCreationContribution { protected static readonly ISOLATED_SSH_DIR = path.join(os.homedir(), '.theia', 'dev-container', 'ssh'); @inject(ILogger) protected readonly logger: ILogger; async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration): Promise { const mounts = createOptions.HostConfig?.Mounts ?? []; const hasExistingMount = (targetSuffix: string): boolean => mounts.some(m => m.Target?.endsWith(targetSuffix)); // SSH: bind-mount an isolated SSH directory instead of the real ~/.ssh const sshSigningInfo = await this.detectSshSigningKey(); if (!hasExistingMount('/.ssh')) { const isolatedSshDir = await this.ensureIsolatedSshDir(sshSigningInfo); if (isolatedSshDir) { mounts.push({ Source: isolatedSshDir, Target: this.getContainerHomePath(containerConfig, '.ssh'), Type: 'bind', ReadOnly: true }); } } // Git config: bind-mount to a temp path; will be copied in post-create const gitconfigPath = path.join(os.homedir(), '.gitconfig'); if (await fs.pathExists(gitconfigPath) && !hasExistingMount('/.gitconfig')) { mounts.push({ Source: gitconfigPath, Target: '/tmp/host_gitconfig', Type: 'bind', ReadOnly: true }); } } async handlePostCreate(containerConfig: DevContainerConfiguration, container: Docker.Container, _api: Docker, outputprovider: ContainerOutputProvider): Promise { const user = (containerConfig.remoteUser ?? containerConfig.containerUser ?? 'root') as string; const containerHome = this.getContainerHomePath(containerConfig, ''); const containerSshDir = `${containerHome}.ssh`; try { // Fix SSH permissions (bind-mount may not preserve them) await this.execInContainer(container, 'root', `if [ -d "${containerSshDir}" ]; then ` + `chmod 700 "${containerSshDir}" 2>/dev/null; ` + `find "${containerSshDir}" -name "id_*" ! -name "*.pub" -exec chmod 600 {} \\; 2>/dev/null; ` + `find "${containerSshDir}" -name "*.pub" -exec chmod 644 {} \\; 2>/dev/null; ` + `chmod 644 "${containerSshDir}/known_hosts" "${containerSshDir}/config" 2>/dev/null; ` + // Also fix signing key permissions if present `find "${containerSshDir}" -name "git_signing_key*" ! -name "*.pub" -exec chmod 600 {} \\; 2>/dev/null; ` + 'true; fi' ); // Copy host gitconfig into container user's home so the container user owns it const sshSigningInfo = await this.detectSshSigningKey(); let gitConfigCmd = 'if [ -f /tmp/host_gitconfig ]; then ' + `cp /tmp/host_gitconfig "${containerHome}.gitconfig" && ` + `chown ${user}:$(id -gn ${user}) "${containerHome}.gitconfig" 2>/dev/null; ` + 'fi'; if (sshSigningInfo?.format === 'ssh' && sshSigningInfo.keyFile) { // SSH signing: rewrite the signing key path to point to the container's SSH dir const containerKeyPath = `${containerSshDir}/git_signing_key`; gitConfigCmd += ` && git config --global user.signingkey "${containerKeyPath}"`; } else { // GPG signing or no format: disable (GPG keys aren't available in containers) gitConfigCmd += ' && git config --global commit.gpgsign false 2>/dev/null' + ' && git config --global tag.gpgsign false 2>/dev/null'; } gitConfigCmd += '; true'; await this.execInContainer(container, user, gitConfigCmd); // Install an SSH agent startup script in /etc/profile.d/ so that // login shells (bash -l, started by THEIA_SHELL_ARGS=-l) reuse a // single agent. The agent socket lives in /tmp so it works even // when ~/.ssh is mounted read-only. // The agent starts empty — keys are added automatically on first use // via AddKeysToAgent=yes in the SSH config. This avoids passphrase // prompts during non-interactive shell initialization. const agentScript = [ '#!/bin/sh', 'SSH_AGENT_SOCK="/tmp/theia-ssh-agent.sock"', 'if [ ! -S "$SSH_AGENT_SOCK" ]; then', ' eval $(ssh-agent -a "$SSH_AGENT_SOCK") > /dev/null 2>&1', 'fi', 'export SSH_AUTH_SOCK="$SSH_AGENT_SOCK"', ].join('\n'); await this.execInContainer(container, 'root', `mkdir -p /etc/profile.d && printf '%s\\n' '${agentScript.replace(/'/g, "'\\''")}' > /etc/profile.d/ssh-agent.sh && chmod 644 /etc/profile.d/ssh-agent.sh` ); } catch (error) { outputprovider?.onRemoteOutput(`Host config sharing: ${error.message}`); } } protected async detectSshSigningKey(): Promise<{ format: string; keyFile?: string } | undefined> { try { const format = await this.gitConfigGet('gpg.format'); if (format !== 'ssh') { return format ? { format } : undefined; } const signingKey = await this.gitConfigGet('user.signingkey'); return { format: 'ssh', keyFile: signingKey || undefined }; } catch (error) { // Git config not available or command failed return undefined; } } protected gitConfigGet(key: string): Promise { return new Promise((resolve, reject) => { cp.exec(`git config --global --get ${key}`, (err, stdout) => { if (err) { resolve(''); } else { resolve(stdout.trim()); } }); }); } protected async ensureIsolatedSshDir(sshSigningInfo?: { format: string; keyFile?: string }): Promise { const isolatedDir = HostConfigSharingContribution.ISOLATED_SSH_DIR; try { await fs.mkdirs(isolatedDir); // Copy known_hosts from real ~/.ssh so host verification works const realKnownHosts = path.join(os.homedir(), '.ssh', 'known_hosts'); const isolatedKnownHosts = path.join(isolatedDir, 'known_hosts'); if (await fs.pathExists(realKnownHosts) && !await fs.pathExists(isolatedKnownHosts)) { await fs.copy(realKnownHosts, isolatedKnownHosts); } // Copy SSH config and all keys it references via IdentityFile const realConfig = path.join(os.homedir(), '.ssh', 'config'); const isolatedConfig = path.join(isolatedDir, 'config'); if (await fs.pathExists(realConfig)) { // Always refresh the config — rewrite IdentityFile paths so they // resolve inside the container (where ~/.ssh is a different user's home) const rawConfig = await fs.readFile(realConfig, 'utf-8'); // Parse IdentityFile entries from the ORIGINAL config and copy referenced keys const identityFiles = rawConfig.match(/^\s*IdentityFile\s+(.+)$/gm); // Rewrite absolute and ~/ paths to just the filename under ~/.ssh/ let rewrittenConfig = rawConfig.replace( /^(\s*IdentityFile\s+)(.+)$/gm, (_match, prefix, filePath) => `${prefix}~/.ssh/${path.basename(filePath.trim())}` ); // Prepend AddKeysToAgent so that after the user enters a passphrase // once, the key is automatically cached in the running ssh-agent if (!/^\s*AddKeysToAgent\s/m.test(rewrittenConfig)) { rewrittenConfig = 'Host *\n AddKeysToAgent yes\n\n' + rewrittenConfig; } await fs.writeFile(isolatedConfig, rewrittenConfig); if (identityFiles) { for (const line of identityFiles) { const keyRef = line.replace(/^\s*IdentityFile\s+/, '').trim() .replace(/^~\//, os.homedir() + '/'); const keyName = path.basename(keyRef); const isolatedKey = path.join(isolatedDir, keyName); if (await fs.pathExists(keyRef) && !await fs.pathExists(isolatedKey)) { await fs.copy(keyRef, isolatedKey); if (await fs.pathExists(keyRef + '.pub')) { await fs.copy(keyRef + '.pub', isolatedKey + '.pub'); } } } } } // Generate a dedicated ed25519 keypair if none exists const keyPath = path.join(isolatedDir, 'id_ed25519'); if (!await fs.pathExists(keyPath)) { await new Promise((resolve, reject) => { cp.exec(`ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "theia-dev-container"`, err => { if (err) { reject(err); } else { resolve(); } }); }); const pubKey = await fs.readFile(keyPath + '.pub', 'utf-8'); this.logger.info('Dev Container SSH: generated new keypair. Register this public key with your Git provider:\n' + pubKey.trim()); } // Copy the SSH signing key if git is configured for SSH signing if (sshSigningInfo?.format === 'ssh' && sshSigningInfo.keyFile) { const signingKeyPath = sshSigningInfo.keyFile.replace(/^~\//, os.homedir() + '/'); const isolatedSigningKey = path.join(isolatedDir, 'git_signing_key'); if (await fs.pathExists(signingKeyPath) && !await fs.pathExists(isolatedSigningKey)) { await fs.copy(signingKeyPath, isolatedSigningKey); // Also copy the .pub if it exists if (await fs.pathExists(signingKeyPath + '.pub')) { await fs.copy(signingKeyPath + '.pub', isolatedSigningKey + '.pub'); } } } return isolatedDir; } catch (error) { this.logger.error('Failed to set up isolated SSH directory:', error); return undefined; } } protected async execInContainer(container: Docker.Container, user: string, cmd: string): Promise { const exec = await container.exec({ Cmd: ['sh', '-c', cmd], User: user, AttachStdout: true, AttachStderr: true }); await exec.start({}); } protected getContainerHomePath(containerConfig: DevContainerConfiguration, relativePath: string): string { const user = containerConfig.remoteUser ?? containerConfig.containerUser; if (user && user !== 'root') { return `/home/${user}/${relativePath}`; } return `/root/${relativePath}`; } } @injectable() export class DefaultShellContribution implements ContainerCreationContribution { async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise { // Set shell and terminal env vars at container creation time so they are // inherited by ALL processes (including docker exec calls). // The remote Theia server is launched via `sh -c` (non-login shell), so // env vars must be baked into the container environment. if (!createOptions.Env) { createOptions.Env = []; } const setIfMissing = (key: string, value: string): void => { if (!createOptions.Env!.some(e => e.startsWith(key + '='))) { createOptions.Env!.push(`${key}=${value}`); } }; // Prefer bash; fall back to /bin/sh for minimal images (e.g. Alpine) that don't ship bash. const shell = createOptions.Image && (await this.isBashAvailable(api, createOptions.Image)) === false ? '/bin/sh' : '/bin/bash'; setIfMissing('THEIA_SHELL', shell); // Start as login shell so /etc/profile.d/ and ~/.bashrc are sourced, // giving the user a proper prompt and SSH agent env vars. setIfMissing('THEIA_SHELL_ARGS', '-l'); setIfMissing('SHELL', shell); setIfMissing('TERM', 'xterm-256color'); setIfMissing('COLORTERM', 'truecolor'); } protected async isBashAvailable(api: Docker, image: string): Promise { let probe: Docker.Container | undefined; try { probe = await api.createContainer({ Image: image, Cmd: ['sh', '-c', 'command -v bash'] }); await probe.start(); const { StatusCode } = await probe.wait(); return StatusCode === 0; } catch { return undefined; } finally { if (probe) { try { await probe.remove(); } catch { // ignore — container may already be gone } } } } } export namespace OutputHelper { export interface Progress { id?: string; stream: string; status?: string; progress?: string; } export interface DedupContext { lastMessage: string; } export function createDedupContext(): DedupContext { return { lastMessage: '' }; } export function parseProgress(progress: Progress, dedup?: DedupContext): string { if (progress.stream) { if (dedup) { dedup.lastMessage = ''; } return progress.stream; } const message = progress.progress ?? progress.status ?? ''; const formatted = progress.id ? `${progress.id}: ${message}` : message; if (dedup && formatted === dedup.lastMessage) { return ''; } if (dedup) { dedup.lastMessage = formatted; } return formatted; } }