@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.
Files changed (56) hide show
  1. package/README.md +1 -0
  2. package/lib/electron-browser/container-connection-contribution.d.ts +8 -0
  3. package/lib/electron-browser/container-connection-contribution.d.ts.map +1 -1
  4. package/lib/electron-browser/container-connection-contribution.js +158 -6
  5. package/lib/electron-browser/container-connection-contribution.js.map +1 -1
  6. package/lib/electron-browser/dev-container-frontend-module.d.ts.map +1 -1
  7. package/lib/electron-browser/dev-container-frontend-module.js +3 -0
  8. package/lib/electron-browser/dev-container-frontend-module.js.map +1 -1
  9. package/lib/electron-browser/dev-container-suggestion-contribution.d.ts +16 -0
  10. package/lib/electron-browser/dev-container-suggestion-contribution.d.ts.map +1 -0
  11. package/lib/electron-browser/dev-container-suggestion-contribution.js +96 -0
  12. package/lib/electron-browser/dev-container-suggestion-contribution.js.map +1 -0
  13. package/lib/electron-common/remote-container-connection-provider.d.ts +9 -0
  14. package/lib/electron-common/remote-container-connection-provider.d.ts.map +1 -1
  15. package/lib/electron-node/dev-container-file-service.d.ts.map +1 -1
  16. package/lib/electron-node/dev-container-file-service.js +4 -6
  17. package/lib/electron-node/dev-container-file-service.js.map +1 -1
  18. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.d.ts.map +1 -1
  19. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.js +7 -1
  20. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.js.map +1 -1
  21. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.d.ts +2 -0
  22. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.d.ts.map +1 -0
  23. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.js +421 -0
  24. package/lib/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.js.map +1 -0
  25. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.d.ts +28 -1
  26. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.d.ts.map +1 -1
  27. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.js +304 -4
  28. package/lib/electron-node/devcontainer-contributions/main-container-creation-contributions.js.map +1 -1
  29. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.d.ts.map +1 -1
  30. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.js +0 -1
  31. package/lib/electron-node/devcontainer-contributions/variable-resolver-contribution.js.map +1 -1
  32. package/lib/electron-node/devcontainer-file.d.ts +8 -1
  33. package/lib/electron-node/devcontainer-file.d.ts.map +1 -1
  34. package/lib/electron-node/devcontainer-file.js +14 -0
  35. package/lib/electron-node/devcontainer-file.js.map +1 -1
  36. package/lib/electron-node/remote-container-connection-provider.d.ts +6 -1
  37. package/lib/electron-node/remote-container-connection-provider.d.ts.map +1 -1
  38. package/lib/electron-node/remote-container-connection-provider.js +112 -4
  39. package/lib/electron-node/remote-container-connection-provider.js.map +1 -1
  40. package/lib/electron-node/remote-container-connection-provider.spec.d.ts +2 -0
  41. package/lib/electron-node/remote-container-connection-provider.spec.d.ts.map +1 -0
  42. package/lib/electron-node/remote-container-connection-provider.spec.js +131 -0
  43. package/lib/electron-node/remote-container-connection-provider.spec.js.map +1 -0
  44. package/package.json +7 -7
  45. package/src/electron-browser/container-connection-contribution.ts +173 -7
  46. package/src/electron-browser/dev-container-frontend-module.ts +4 -0
  47. package/src/electron-browser/dev-container-suggestion-contribution.ts +93 -0
  48. package/src/electron-common/remote-container-connection-provider.ts +10 -0
  49. package/src/electron-node/dev-container-file-service.ts +4 -6
  50. package/src/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.spec.ts +519 -0
  51. package/src/electron-node/devcontainer-contributions/cli-enhancing-creation-contributions.ts +7 -1
  52. package/src/electron-node/devcontainer-contributions/main-container-creation-contributions.ts +323 -5
  53. package/src/electron-node/devcontainer-contributions/variable-resolver-contribution.ts +0 -1
  54. package/src/electron-node/devcontainer-file.ts +13 -1
  55. package/src/electron-node/remote-container-connection-provider.spec.ts +152 -0
  56. package/src/electron-node/remote-container-connection-provider.ts +121 -5
@@ -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 { RemotePortForwardingProvider } from '@theia/remote/lib/electron-common/remote-port-forwarding-provider';
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(`could not build dockerfile "${dockerfile}" reason: ${error.message}`);
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
- this.portForwardingProvider.forwardPort(connection.localPort, { port, address });
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('could not execute postCreateCommand ' + JSON.stringify(command) + ' reason:' + error.message);
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 | null
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
+ });