@theia/dev-container 1.71.0-next.72 → 1.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/lib/electron-browser/container-connection-contribution.d.ts +8 -0
- package/lib/electron-browser/container-connection-contribution.d.ts.map +1 -1
- package/lib/electron-browser/container-connection-contribution.js +158 -6
- package/lib/electron-browser/container-connection-contribution.js.map +1 -1
- package/lib/electron-browser/dev-container-frontend-module.d.ts.map +1 -1
- package/lib/electron-browser/dev-container-frontend-module.js +3 -0
- package/lib/electron-browser/dev-container-frontend-module.js.map +1 -1
- package/lib/electron-browser/dev-container-suggestion-contribution.d.ts +16 -0
- package/lib/electron-browser/dev-container-suggestion-contribution.d.ts.map +1 -0
- package/lib/electron-browser/dev-container-suggestion-contribution.js +96 -0
- package/lib/electron-browser/dev-container-suggestion-contribution.js.map +1 -0
- package/lib/electron-common/remote-container-connection-provider.d.ts +9 -0
- package/lib/electron-common/remote-container-connection-provider.d.ts.map +1 -1
- package/lib/electron-node/dev-container-file-service.d.ts.map +1 -1
- package/lib/electron-node/dev-container-file-service.js +4 -6
- package/lib/electron-node/dev-container-file-service.js.map +1 -1
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.d.ts.map +1 -1
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.js +7 -1
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.js.map +1 -1
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.d.ts +2 -0
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.d.ts.map +1 -0
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.js +421 -0
- package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.js.map +1 -0
- package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.d.ts +28 -1
- package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.d.ts.map +1 -1
- package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.js +304 -4
- package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.js.map +1 -1
- package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.d.ts.map +1 -1
- package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.js +0 -1
- package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.js.map +1 -1
- package/lib/electron-node/devcontainer-file.d.ts +8 -1
- package/lib/electron-node/devcontainer-file.d.ts.map +1 -1
- package/lib/electron-node/devcontainer-file.js +14 -0
- package/lib/electron-node/devcontainer-file.js.map +1 -1
- package/lib/electron-node/remote-container-connection-provider.d.ts +6 -1
- package/lib/electron-node/remote-container-connection-provider.d.ts.map +1 -1
- package/lib/electron-node/remote-container-connection-provider.js +112 -4
- package/lib/electron-node/remote-container-connection-provider.js.map +1 -1
- package/lib/electron-node/remote-container-connection-provider.spec.d.ts +2 -0
- package/lib/electron-node/remote-container-connection-provider.spec.d.ts.map +1 -0
- package/lib/electron-node/remote-container-connection-provider.spec.js +131 -0
- package/lib/electron-node/remote-container-connection-provider.spec.js.map +1 -0
- package/package.json +7 -7
- package/src/electron-browser/container-connection-contribution.ts +173 -7
- package/src/electron-browser/dev-container-frontend-module.ts +4 -0
- package/src/electron-browser/dev-container-suggestion-contribution.ts +93 -0
- package/src/electron-common/remote-container-connection-provider.ts +10 -0
- package/src/electron-node/dev-container-file-service.ts +4 -6
- package/src/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.ts +519 -0
- package/src/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.ts +7 -1
- package/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts +323 -5
- package/src/electron-node/devcontainer-contributions/variable-resolver-contribution.ts +0 -1
- package/src/electron-node/devcontainer-file.ts +13 -1
- package/src/electron-node/remote-container-connection-provider.spec.ts +152 -0
- package/src/electron-node/remote-container-connection-provider.ts +121 -5
package/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts
CHANGED
|
@@ -17,10 +17,13 @@ import * as Docker from 'dockerode';
|
|
|
17
17
|
import { inject, injectable, interfaces } from '@theia/core/shared/inversify';
|
|
18
18
|
import { ContainerCreationContribution } from '../docker-container-service';
|
|
19
19
|
import { DevContainerConfiguration, DockerfileContainer, ImageContainer, NonComposeContainerBase } from '../devcontainer-file';
|
|
20
|
-
import { Path } from '@theia/core';
|
|
20
|
+
import { ILogger, Path } from '@theia/core';
|
|
21
21
|
import { ContainerOutputProvider } from '../../electron-common/container-output-provider';
|
|
22
22
|
import * as fs from '@theia/core/shared/fs-extra';
|
|
23
|
-
import
|
|
23
|
+
import * as os from 'os';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import * as cp from 'child_process';
|
|
26
|
+
import { ForwardedPort, RemotePortForwardingProvider } from '@theia/remote/lib/electron-common/remote-port-forwarding-provider';
|
|
24
27
|
import { RemoteDockerContainerConnection } from '../remote-container-connection-provider';
|
|
25
28
|
import { WorkspaceCreationContribution } from './workspace-creation-contribution';
|
|
26
29
|
import { parseWorkspaceMount } from '../dockerode-utils';
|
|
@@ -34,6 +37,8 @@ export function registerContainerCreationContributions(bind: interfaces.Bind): v
|
|
|
34
37
|
bind(ContainerCreationContribution).to(PostCreateCommandContribution).inSingletonScope();
|
|
35
38
|
bind(ContainerCreationContribution).to(ContainerEnvContribution).inSingletonScope();
|
|
36
39
|
bind(ContainerCreationContribution).to(WorkspaceCreationContribution).inSingletonScope();
|
|
40
|
+
bind(ContainerCreationContribution).to(HostConfigSharingContribution).inSingletonScope();
|
|
41
|
+
bind(ContainerCreationContribution).to(DefaultShellContribution).inSingletonScope();
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
@injectable()
|
|
@@ -93,7 +98,7 @@ export class DockerFileContribution implements ContainerCreationContribution {
|
|
|
93
98
|
}, progress => outputprovider.onRemoteOutput(OutputHelper.parseProgress(progress))));
|
|
94
99
|
createOptions.Image = imageId;
|
|
95
100
|
} catch (error) {
|
|
96
|
-
outputprovider.onRemoteOutput(`
|
|
101
|
+
outputprovider.onRemoteOutput(`Could not build dockerfile "${dockerfile}": ${error.message}`);
|
|
97
102
|
throw error;
|
|
98
103
|
}
|
|
99
104
|
}
|
|
@@ -122,11 +127,40 @@ export class ForwardPortsContribution implements ContainerCreationContribution {
|
|
|
122
127
|
port = forward;
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
|
|
130
|
+
const forwardedPort: ForwardedPort = { port, address };
|
|
131
|
+
const attributes = this.getPortAttributes(containerConfig, port);
|
|
132
|
+
if (attributes) {
|
|
133
|
+
forwardedPort.label = attributes.label;
|
|
134
|
+
forwardedPort.protocol = attributes.protocol;
|
|
135
|
+
forwardedPort.onAutoForward = attributes.onAutoForward;
|
|
136
|
+
}
|
|
137
|
+
this.portForwardingProvider.forwardPort(connection.localPort, forwardedPort);
|
|
126
138
|
}
|
|
127
139
|
|
|
128
140
|
}
|
|
129
141
|
|
|
142
|
+
protected getPortAttributes(containerConfig: DevContainerConfiguration,
|
|
143
|
+
port: number): { label?: string; protocol?: 'http' | 'https'; onAutoForward?: ForwardedPort['onAutoForward'] } | undefined {
|
|
144
|
+
if (!containerConfig.portsAttributes) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
const portStr = String(port);
|
|
148
|
+
for (const [pattern, attributes] of Object.entries(containerConfig.portsAttributes)) {
|
|
149
|
+
if (pattern === portStr) {
|
|
150
|
+
return attributes;
|
|
151
|
+
}
|
|
152
|
+
const rangeMatch = pattern.match(/^(\d+)-(\d+)$/);
|
|
153
|
+
if (rangeMatch) {
|
|
154
|
+
const start = parseInt(rangeMatch[1]);
|
|
155
|
+
const end = parseInt(rangeMatch[2]);
|
|
156
|
+
if (port >= start && port <= end) {
|
|
157
|
+
return attributes;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
130
164
|
}
|
|
131
165
|
|
|
132
166
|
@injectable()
|
|
@@ -141,6 +175,7 @@ export class MountsContribution implements ContainerCreationContribution {
|
|
|
141
175
|
parseWorkspaceMount(mount) :
|
|
142
176
|
{ Source: mount.source, Target: mount.target, Type: mount.type ?? 'bind' }) ?? []);
|
|
143
177
|
}
|
|
178
|
+
|
|
144
179
|
}
|
|
145
180
|
|
|
146
181
|
@injectable()
|
|
@@ -170,7 +205,7 @@ export class PostCreateCommandContribution implements ContainerCreationContribut
|
|
|
170
205
|
const stream = await exec.start({ Tty: true });
|
|
171
206
|
stream.on('data', chunk => outputprovider.onRemoteOutput(chunk.toString()));
|
|
172
207
|
} catch (error) {
|
|
173
|
-
outputprovider.onRemoteOutput(
|
|
208
|
+
outputprovider.onRemoteOutput(`Could not execute postCreateCommand ${JSON.stringify(command)}: ${error.message}`);
|
|
174
209
|
}
|
|
175
210
|
}
|
|
176
211
|
}
|
|
@@ -191,6 +226,289 @@ export class ContainerEnvContribution implements ContainerCreationContribution {
|
|
|
191
226
|
}
|
|
192
227
|
}
|
|
193
228
|
|
|
229
|
+
@injectable()
|
|
230
|
+
export class HostConfigSharingContribution implements ContainerCreationContribution {
|
|
231
|
+
|
|
232
|
+
protected static readonly ISOLATED_SSH_DIR = path.join(os.homedir(), '.theia', 'dev-container', 'ssh');
|
|
233
|
+
|
|
234
|
+
@inject(ILogger)
|
|
235
|
+
protected readonly logger: ILogger;
|
|
236
|
+
|
|
237
|
+
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration): Promise<void> {
|
|
238
|
+
const mounts = createOptions.HostConfig?.Mounts ?? [];
|
|
239
|
+
const hasExistingMount = (targetSuffix: string): boolean =>
|
|
240
|
+
mounts.some(m => m.Target?.endsWith(targetSuffix));
|
|
241
|
+
|
|
242
|
+
// SSH: bind-mount an isolated SSH directory instead of the real ~/.ssh
|
|
243
|
+
const sshSigningInfo = await this.detectSshSigningKey();
|
|
244
|
+
if (!hasExistingMount('/.ssh')) {
|
|
245
|
+
const isolatedSshDir = await this.ensureIsolatedSshDir(sshSigningInfo);
|
|
246
|
+
if (isolatedSshDir) {
|
|
247
|
+
mounts.push({
|
|
248
|
+
Source: isolatedSshDir,
|
|
249
|
+
Target: this.getContainerHomePath(containerConfig, '.ssh'),
|
|
250
|
+
Type: 'bind',
|
|
251
|
+
ReadOnly: true
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Git config: bind-mount to a temp path; will be copied in post-create
|
|
257
|
+
const gitconfigPath = path.join(os.homedir(), '.gitconfig');
|
|
258
|
+
if (await fs.pathExists(gitconfigPath) && !hasExistingMount('/.gitconfig')) {
|
|
259
|
+
mounts.push({
|
|
260
|
+
Source: gitconfigPath,
|
|
261
|
+
Target: '/tmp/host_gitconfig',
|
|
262
|
+
Type: 'bind',
|
|
263
|
+
ReadOnly: true
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async handlePostCreate(containerConfig: DevContainerConfiguration, container: Docker.Container, _api: Docker, outputprovider: ContainerOutputProvider): Promise<void> {
|
|
269
|
+
const user = (containerConfig.remoteUser ?? containerConfig.containerUser ?? 'root') as string;
|
|
270
|
+
const containerHome = this.getContainerHomePath(containerConfig, '');
|
|
271
|
+
const containerSshDir = `${containerHome}.ssh`;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
// Fix SSH permissions (bind-mount may not preserve them)
|
|
275
|
+
await this.execInContainer(container, 'root',
|
|
276
|
+
`if [ -d "${containerSshDir}" ]; then ` +
|
|
277
|
+
`chmod 700 "${containerSshDir}" 2>/dev/null; ` +
|
|
278
|
+
`find "${containerSshDir}" -name "id_*" ! -name "*.pub" -exec chmod 600 {} \\; 2>/dev/null; ` +
|
|
279
|
+
`find "${containerSshDir}" -name "*.pub" -exec chmod 644 {} \\; 2>/dev/null; ` +
|
|
280
|
+
`chmod 644 "${containerSshDir}/known_hosts" "${containerSshDir}/config" 2>/dev/null; ` +
|
|
281
|
+
// Also fix signing key permissions if present
|
|
282
|
+
`find "${containerSshDir}" -name "git_signing_key*" ! -name "*.pub" -exec chmod 600 {} \\; 2>/dev/null; ` +
|
|
283
|
+
'true; fi'
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Copy host gitconfig into container user's home so the container user owns it
|
|
287
|
+
const sshSigningInfo = await this.detectSshSigningKey();
|
|
288
|
+
let gitConfigCmd =
|
|
289
|
+
'if [ -f /tmp/host_gitconfig ]; then ' +
|
|
290
|
+
`cp /tmp/host_gitconfig "${containerHome}.gitconfig" && ` +
|
|
291
|
+
`chown ${user}:$(id -gn ${user}) "${containerHome}.gitconfig" 2>/dev/null; ` +
|
|
292
|
+
'fi';
|
|
293
|
+
|
|
294
|
+
if (sshSigningInfo?.format === 'ssh' && sshSigningInfo.keyFile) {
|
|
295
|
+
// SSH signing: rewrite the signing key path to point to the container's SSH dir
|
|
296
|
+
const containerKeyPath = `${containerSshDir}/git_signing_key`;
|
|
297
|
+
gitConfigCmd += ` && git config --global user.signingkey "${containerKeyPath}"`;
|
|
298
|
+
} else {
|
|
299
|
+
// GPG signing or no format: disable (GPG keys aren't available in containers)
|
|
300
|
+
gitConfigCmd += ' && git config --global commit.gpgsign false 2>/dev/null' +
|
|
301
|
+
' && git config --global tag.gpgsign false 2>/dev/null';
|
|
302
|
+
}
|
|
303
|
+
gitConfigCmd += '; true';
|
|
304
|
+
|
|
305
|
+
await this.execInContainer(container, user, gitConfigCmd);
|
|
306
|
+
|
|
307
|
+
// Install an SSH agent startup script in /etc/profile.d/ so that
|
|
308
|
+
// login shells (bash -l, started by THEIA_SHELL_ARGS=-l) reuse a
|
|
309
|
+
// single agent. The agent socket lives in /tmp so it works even
|
|
310
|
+
// when ~/.ssh is mounted read-only.
|
|
311
|
+
// The agent starts empty — keys are added automatically on first use
|
|
312
|
+
// via AddKeysToAgent=yes in the SSH config. This avoids passphrase
|
|
313
|
+
// prompts during non-interactive shell initialization.
|
|
314
|
+
const agentScript = [
|
|
315
|
+
'#!/bin/sh',
|
|
316
|
+
'SSH_AGENT_SOCK="/tmp/theia-ssh-agent.sock"',
|
|
317
|
+
'if [ ! -S "$SSH_AGENT_SOCK" ]; then',
|
|
318
|
+
' eval $(ssh-agent -a "$SSH_AGENT_SOCK") > /dev/null 2>&1',
|
|
319
|
+
'fi',
|
|
320
|
+
'export SSH_AUTH_SOCK="$SSH_AGENT_SOCK"',
|
|
321
|
+
].join('\n');
|
|
322
|
+
|
|
323
|
+
await this.execInContainer(container, 'root',
|
|
324
|
+
`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`
|
|
325
|
+
);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
outputprovider?.onRemoteOutput(`Host config sharing: ${error.message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
protected async detectSshSigningKey(): Promise<{ format: string; keyFile?: string } | undefined> {
|
|
332
|
+
try {
|
|
333
|
+
const format = await this.gitConfigGet('gpg.format');
|
|
334
|
+
if (format !== 'ssh') {
|
|
335
|
+
return format ? { format } : undefined;
|
|
336
|
+
}
|
|
337
|
+
const signingKey = await this.gitConfigGet('user.signingkey');
|
|
338
|
+
return { format: 'ssh', keyFile: signingKey || undefined };
|
|
339
|
+
} catch (error) {
|
|
340
|
+
// Git config not available or command failed
|
|
341
|
+
return undefined;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
protected gitConfigGet(key: string): Promise<string> {
|
|
346
|
+
return new Promise<string>((resolve, reject) => {
|
|
347
|
+
cp.exec(`git config --global --get ${key}`, (err, stdout) => {
|
|
348
|
+
if (err) {
|
|
349
|
+
resolve('');
|
|
350
|
+
} else {
|
|
351
|
+
resolve(stdout.trim());
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
protected async ensureIsolatedSshDir(sshSigningInfo?: { format: string; keyFile?: string }): Promise<string | undefined> {
|
|
358
|
+
const isolatedDir = HostConfigSharingContribution.ISOLATED_SSH_DIR;
|
|
359
|
+
try {
|
|
360
|
+
await fs.mkdirs(isolatedDir);
|
|
361
|
+
|
|
362
|
+
// Copy known_hosts from real ~/.ssh so host verification works
|
|
363
|
+
const realKnownHosts = path.join(os.homedir(), '.ssh', 'known_hosts');
|
|
364
|
+
const isolatedKnownHosts = path.join(isolatedDir, 'known_hosts');
|
|
365
|
+
if (await fs.pathExists(realKnownHosts) && !await fs.pathExists(isolatedKnownHosts)) {
|
|
366
|
+
await fs.copy(realKnownHosts, isolatedKnownHosts);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Copy SSH config and all keys it references via IdentityFile
|
|
370
|
+
const realConfig = path.join(os.homedir(), '.ssh', 'config');
|
|
371
|
+
const isolatedConfig = path.join(isolatedDir, 'config');
|
|
372
|
+
if (await fs.pathExists(realConfig)) {
|
|
373
|
+
// Always refresh the config — rewrite IdentityFile paths so they
|
|
374
|
+
// resolve inside the container (where ~/.ssh is a different user's home)
|
|
375
|
+
const rawConfig = await fs.readFile(realConfig, 'utf-8');
|
|
376
|
+
// Parse IdentityFile entries from the ORIGINAL config and copy referenced keys
|
|
377
|
+
const identityFiles = rawConfig.match(/^\s*IdentityFile\s+(.+)$/gm);
|
|
378
|
+
// Rewrite absolute and ~/ paths to just the filename under ~/.ssh/
|
|
379
|
+
let rewrittenConfig = rawConfig.replace(
|
|
380
|
+
/^(\s*IdentityFile\s+)(.+)$/gm,
|
|
381
|
+
(_match, prefix, filePath) => `${prefix}~/.ssh/${path.basename(filePath.trim())}`
|
|
382
|
+
);
|
|
383
|
+
// Prepend AddKeysToAgent so that after the user enters a passphrase
|
|
384
|
+
// once, the key is automatically cached in the running ssh-agent
|
|
385
|
+
if (!/^\s*AddKeysToAgent\s/m.test(rewrittenConfig)) {
|
|
386
|
+
rewrittenConfig = 'Host *\n AddKeysToAgent yes\n\n' + rewrittenConfig;
|
|
387
|
+
}
|
|
388
|
+
await fs.writeFile(isolatedConfig, rewrittenConfig);
|
|
389
|
+
if (identityFiles) {
|
|
390
|
+
for (const line of identityFiles) {
|
|
391
|
+
const keyRef = line.replace(/^\s*IdentityFile\s+/, '').trim()
|
|
392
|
+
.replace(/^~\//, os.homedir() + '/');
|
|
393
|
+
const keyName = path.basename(keyRef);
|
|
394
|
+
const isolatedKey = path.join(isolatedDir, keyName);
|
|
395
|
+
if (await fs.pathExists(keyRef) && !await fs.pathExists(isolatedKey)) {
|
|
396
|
+
await fs.copy(keyRef, isolatedKey);
|
|
397
|
+
if (await fs.pathExists(keyRef + '.pub')) {
|
|
398
|
+
await fs.copy(keyRef + '.pub', isolatedKey + '.pub');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Generate a dedicated ed25519 keypair if none exists
|
|
406
|
+
const keyPath = path.join(isolatedDir, 'id_ed25519');
|
|
407
|
+
if (!await fs.pathExists(keyPath)) {
|
|
408
|
+
await new Promise<void>((resolve, reject) => {
|
|
409
|
+
cp.exec(`ssh-keygen -t ed25519 -f "${keyPath}" -N "" -C "theia-dev-container"`, err => {
|
|
410
|
+
if (err) {
|
|
411
|
+
reject(err);
|
|
412
|
+
} else {
|
|
413
|
+
resolve();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
const pubKey = await fs.readFile(keyPath + '.pub', 'utf-8');
|
|
418
|
+
this.logger.info('Dev Container SSH: generated new keypair. Register this public key with your Git provider:\n' + pubKey.trim());
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Copy the SSH signing key if git is configured for SSH signing
|
|
422
|
+
if (sshSigningInfo?.format === 'ssh' && sshSigningInfo.keyFile) {
|
|
423
|
+
const signingKeyPath = sshSigningInfo.keyFile.replace(/^~\//, os.homedir() + '/');
|
|
424
|
+
const isolatedSigningKey = path.join(isolatedDir, 'git_signing_key');
|
|
425
|
+
if (await fs.pathExists(signingKeyPath) && !await fs.pathExists(isolatedSigningKey)) {
|
|
426
|
+
await fs.copy(signingKeyPath, isolatedSigningKey);
|
|
427
|
+
// Also copy the .pub if it exists
|
|
428
|
+
if (await fs.pathExists(signingKeyPath + '.pub')) {
|
|
429
|
+
await fs.copy(signingKeyPath + '.pub', isolatedSigningKey + '.pub');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return isolatedDir;
|
|
435
|
+
} catch (error) {
|
|
436
|
+
this.logger.error('Failed to set up isolated SSH directory:', error);
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
protected async execInContainer(container: Docker.Container, user: string, cmd: string): Promise<void> {
|
|
442
|
+
const exec = await container.exec({
|
|
443
|
+
Cmd: ['sh', '-c', cmd],
|
|
444
|
+
User: user,
|
|
445
|
+
AttachStdout: true,
|
|
446
|
+
AttachStderr: true
|
|
447
|
+
});
|
|
448
|
+
await exec.start({});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
protected getContainerHomePath(containerConfig: DevContainerConfiguration, relativePath: string): string {
|
|
452
|
+
const user = containerConfig.remoteUser ?? containerConfig.containerUser;
|
|
453
|
+
if (user && user !== 'root') {
|
|
454
|
+
return `/home/${user}/${relativePath}`;
|
|
455
|
+
}
|
|
456
|
+
return `/root/${relativePath}`;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
@injectable()
|
|
461
|
+
export class DefaultShellContribution implements ContainerCreationContribution {
|
|
462
|
+
async handleContainerCreation(createOptions: Docker.ContainerCreateOptions, containerConfig: DevContainerConfiguration, api: Docker): Promise<void> {
|
|
463
|
+
// Set shell and terminal env vars at container creation time so they are
|
|
464
|
+
// inherited by ALL processes (including docker exec calls).
|
|
465
|
+
// The remote Theia server is launched via `sh -c` (non-login shell), so
|
|
466
|
+
// env vars must be baked into the container environment.
|
|
467
|
+
if (!createOptions.Env) {
|
|
468
|
+
createOptions.Env = [];
|
|
469
|
+
}
|
|
470
|
+
const setIfMissing = (key: string, value: string): void => {
|
|
471
|
+
if (!createOptions.Env!.some(e => e.startsWith(key + '='))) {
|
|
472
|
+
createOptions.Env!.push(`${key}=${value}`);
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
// Prefer bash; fall back to /bin/sh for minimal images (e.g. Alpine) that don't ship bash.
|
|
476
|
+
const shell = createOptions.Image && (await this.isBashAvailable(api, createOptions.Image)) === false
|
|
477
|
+
? '/bin/sh'
|
|
478
|
+
: '/bin/bash';
|
|
479
|
+
setIfMissing('THEIA_SHELL', shell);
|
|
480
|
+
// Start as login shell so /etc/profile.d/ and ~/.bashrc are sourced,
|
|
481
|
+
// giving the user a proper prompt and SSH agent env vars.
|
|
482
|
+
setIfMissing('THEIA_SHELL_ARGS', '-l');
|
|
483
|
+
setIfMissing('SHELL', shell);
|
|
484
|
+
setIfMissing('TERM', 'xterm-256color');
|
|
485
|
+
setIfMissing('COLORTERM', 'truecolor');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
protected async isBashAvailable(api: Docker, image: string): Promise<boolean | undefined> {
|
|
489
|
+
let probe: Docker.Container | undefined;
|
|
490
|
+
try {
|
|
491
|
+
probe = await api.createContainer({
|
|
492
|
+
Image: image,
|
|
493
|
+
Cmd: ['sh', '-c', 'command -v bash']
|
|
494
|
+
});
|
|
495
|
+
await probe.start();
|
|
496
|
+
const { StatusCode } = await probe.wait();
|
|
497
|
+
return StatusCode === 0;
|
|
498
|
+
} catch {
|
|
499
|
+
return undefined;
|
|
500
|
+
} finally {
|
|
501
|
+
if (probe) {
|
|
502
|
+
try {
|
|
503
|
+
await probe.remove();
|
|
504
|
+
} catch {
|
|
505
|
+
// ignore — container may already be gone
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
194
512
|
export namespace OutputHelper {
|
|
195
513
|
export interface Progress {
|
|
196
514
|
id?: string;
|
|
@@ -31,7 +31,6 @@ export function registerVariableResolverContributions(bind: interfaces.Bind): vo
|
|
|
31
31
|
@injectable()
|
|
32
32
|
export class LocalEnvVariableResolver implements VariableResolverContribution {
|
|
33
33
|
canResolve(type: string): boolean {
|
|
34
|
-
console.log(`Resolving localEnv variable: ${type}`);
|
|
35
34
|
return type === 'localEnv';
|
|
36
35
|
}
|
|
37
36
|
|
|
@@ -255,7 +255,7 @@ export interface DevContainerCommon {
|
|
|
255
255
|
* Remote environment variables to set for processes spawned in the container including lifecycle scripts and any remote editor/IDE server process.
|
|
256
256
|
*/
|
|
257
257
|
remoteEnv?: {
|
|
258
|
-
[k: string]: string |
|
|
258
|
+
[k: string]: string | undefined
|
|
259
259
|
}
|
|
260
260
|
/**
|
|
261
261
|
* The username to use for spawning processes in the container including lifecycle scripts and any remote editor/IDE server process.
|
|
@@ -413,3 +413,15 @@ export interface MountConfig {
|
|
|
413
413
|
target: string,
|
|
414
414
|
type: 'volume' | 'bind',
|
|
415
415
|
}
|
|
416
|
+
|
|
417
|
+
export namespace DevContainerConfiguration {
|
|
418
|
+
/**
|
|
419
|
+
* Creates an empty DevContainerConfiguration with minimal valid properties.
|
|
420
|
+
* Used when attaching to existing containers where no devcontainer.json is available.
|
|
421
|
+
*/
|
|
422
|
+
export function empty(): DevContainerConfiguration {
|
|
423
|
+
return {
|
|
424
|
+
image: 'unknown'
|
|
425
|
+
} as DevContainerConfiguration;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// *****************************************************************************
|
|
2
|
+
// Copyright (C) 2026 EclipseSource and others.
|
|
3
|
+
//
|
|
4
|
+
// This program and the accompanying materials are made available under the
|
|
5
|
+
// terms of the Eclipse Public License v. 2.0 which is available at
|
|
6
|
+
// http://www.eclipse.org/legal/epl-2.0.
|
|
7
|
+
//
|
|
8
|
+
// This Source Code may also be made available under the following Secondary
|
|
9
|
+
// Licenses when the conditions for such availability set forth in the Eclipse
|
|
10
|
+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
|
|
11
|
+
// with the GNU Classpath Exception which is available at
|
|
12
|
+
// https://www.gnu.org/software/classpath/license.html.
|
|
13
|
+
//
|
|
14
|
+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
|
|
15
|
+
// *****************************************************************************
|
|
16
|
+
|
|
17
|
+
import { expect } from 'chai';
|
|
18
|
+
import { RemoteDockerContainerConnection } from './remote-container-connection-provider';
|
|
19
|
+
import { DevContainerConfiguration } from './devcontainer-file';
|
|
20
|
+
import { ILogger } from '@theia/core';
|
|
21
|
+
import * as Docker from 'dockerode';
|
|
22
|
+
|
|
23
|
+
class TestableDockerContainerConnection extends RemoteDockerContainerConnection {
|
|
24
|
+
public testGetRemoteEnv(): string[] | undefined {
|
|
25
|
+
return this.getRemoteEnv();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createConnection(config: DevContainerConfiguration): TestableDockerContainerConnection {
|
|
30
|
+
const mockDocker = {
|
|
31
|
+
getEvents: () => Promise.resolve({ on: () => { } })
|
|
32
|
+
} as unknown as Docker;
|
|
33
|
+
const mockContainer = {} as unknown as Docker.Container;
|
|
34
|
+
const mockLogger = {} as ILogger;
|
|
35
|
+
return new TestableDockerContainerConnection({
|
|
36
|
+
id: 'test-id',
|
|
37
|
+
name: 'test',
|
|
38
|
+
type: 'Dev Container',
|
|
39
|
+
docker: mockDocker,
|
|
40
|
+
container: mockContainer,
|
|
41
|
+
config,
|
|
42
|
+
logger: mockLogger
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('RemoteDockerContainerConnection', () => {
|
|
47
|
+
|
|
48
|
+
describe('getRemoteEnv', () => {
|
|
49
|
+
|
|
50
|
+
it('should return undefined when remoteEnv is not set', () => {
|
|
51
|
+
const connection = createConnection({
|
|
52
|
+
image: 'test'
|
|
53
|
+
} as DevContainerConfiguration);
|
|
54
|
+
|
|
55
|
+
expect(connection.testGetRemoteEnv()).to.be.undefined;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should return undefined when remoteEnv is empty', () => {
|
|
59
|
+
const connection = createConnection({
|
|
60
|
+
image: 'test',
|
|
61
|
+
remoteEnv: {}
|
|
62
|
+
} as DevContainerConfiguration);
|
|
63
|
+
|
|
64
|
+
expect(connection.testGetRemoteEnv()).to.be.undefined;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should convert remoteEnv entries to KEY=value format', () => {
|
|
68
|
+
const connection = createConnection({
|
|
69
|
+
image: 'test',
|
|
70
|
+
remoteEnv: {
|
|
71
|
+
'MY_VAR': 'my_value',
|
|
72
|
+
'ANOTHER_VAR': 'another_value'
|
|
73
|
+
}
|
|
74
|
+
} as DevContainerConfiguration);
|
|
75
|
+
|
|
76
|
+
const env = connection.testGetRemoteEnv();
|
|
77
|
+
expect(env).to.have.lengthOf(2);
|
|
78
|
+
expect(env).to.include('MY_VAR=my_value');
|
|
79
|
+
expect(env).to.include('ANOTHER_VAR=another_value');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should filter out entries with undefined values', () => {
|
|
83
|
+
const connection = createConnection({
|
|
84
|
+
image: 'test',
|
|
85
|
+
remoteEnv: {
|
|
86
|
+
'KEEP_VAR': 'value',
|
|
87
|
+
'REMOVE_VAR': undefined
|
|
88
|
+
}
|
|
89
|
+
} as DevContainerConfiguration);
|
|
90
|
+
|
|
91
|
+
const env = connection.testGetRemoteEnv();
|
|
92
|
+
expect(env).to.have.lengthOf(1);
|
|
93
|
+
expect(env).to.include('KEEP_VAR=value');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should return undefined when all entries have undefined values', () => {
|
|
97
|
+
const connection = createConnection({
|
|
98
|
+
image: 'test',
|
|
99
|
+
remoteEnv: {
|
|
100
|
+
'VAR1': undefined,
|
|
101
|
+
'VAR2': undefined
|
|
102
|
+
}
|
|
103
|
+
} as DevContainerConfiguration);
|
|
104
|
+
|
|
105
|
+
const env = connection.testGetRemoteEnv();
|
|
106
|
+
expect(env).to.have.lengthOf(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should handle values containing equals signs', () => {
|
|
110
|
+
const connection = createConnection({
|
|
111
|
+
image: 'test',
|
|
112
|
+
remoteEnv: {
|
|
113
|
+
'CONNECTION_STRING': 'host=localhost;port=5432'
|
|
114
|
+
}
|
|
115
|
+
} as DevContainerConfiguration);
|
|
116
|
+
|
|
117
|
+
const env = connection.testGetRemoteEnv();
|
|
118
|
+
expect(env).to.have.lengthOf(1);
|
|
119
|
+
expect(env).to.include('CONNECTION_STRING=host=localhost;port=5432');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should handle empty string values', () => {
|
|
123
|
+
const connection = createConnection({
|
|
124
|
+
image: 'test',
|
|
125
|
+
remoteEnv: {
|
|
126
|
+
'EMPTY_VAR': ''
|
|
127
|
+
}
|
|
128
|
+
} as DevContainerConfiguration);
|
|
129
|
+
|
|
130
|
+
const env = connection.testGetRemoteEnv();
|
|
131
|
+
expect(env).to.have.lengthOf(1);
|
|
132
|
+
expect(env).to.include('EMPTY_VAR=');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should handle values with special characters', () => {
|
|
136
|
+
const connection = createConnection({
|
|
137
|
+
image: 'test',
|
|
138
|
+
remoteEnv: {
|
|
139
|
+
'PATH_EXTRA': '/usr/local/bin:/custom/path',
|
|
140
|
+
'QUOTED': 'hello "world"',
|
|
141
|
+
'SPACED': 'hello world'
|
|
142
|
+
}
|
|
143
|
+
} as DevContainerConfiguration);
|
|
144
|
+
|
|
145
|
+
const env = connection.testGetRemoteEnv();
|
|
146
|
+
expect(env).to.have.lengthOf(3);
|
|
147
|
+
expect(env).to.include('PATH_EXTRA=/usr/local/bin:/custom/path');
|
|
148
|
+
expect(env).to.include('QUOTED=hello "world"');
|
|
149
|
+
expect(env).to.include('SPACED=hello world');
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|