Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Notable changes.

### [0.88.0]
- Add WSLc support (https://github.com/devcontainers/cli/pull/1249)
- Derive the `--userns=keep-id` mapping from the remote user's actual UID/GID when using Podman, so the container user is mapped to the host user even when their UIDs differ (e.g. high UIDs from AD/SSSD). (https://github.com/devcontainers/cli/issues/1284)

## May 2026

Expand Down
56 changes: 55 additions & 1 deletion src/spec-node/containerFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as path from 'path';

import { DevContainerConfig } from '../spec-configuration/configuration';
import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters, CLIVariant } from '../spec-shutdown/dockerUtils';
import { CLIHost } from '../spec-common/cliHost';
import { LogLevel, makeLog } from '../spec-utils/log';
import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration';
import { readLocalFile } from '../spec-utils/pfs';
Expand Down Expand Up @@ -422,7 +423,11 @@ export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParame
const { common } = params;
const { cliHost } = common;
const { updateRemoteUserUID } = mergedConfig;
if (params.updateRemoteUserUIDDefault === 'never' || !(typeof updateRemoteUserUID === 'boolean' ? updateRemoteUserUID : params.updateRemoteUserUIDDefault === 'on') || !(cliHost.platform === 'linux' || params.updateRemoteUserUIDOnMacOS && cliHost.platform === 'darwin')) {
// Under rootless podman with a non-bakeable host UID/GID, fall back to disabling the
// build-time bake and relying on the runtime --userns=keep-id mapping instead. This
// only applies when the user has not explicitly configured updateRemoteUserUID.
const effectiveUpdateRemoteUserUID = await resolveUpdateRemoteUserUID(params, mergedConfig, cliHost) ?? updateRemoteUserUID;
if (params.updateRemoteUserUIDDefault === 'never' || !(typeof effectiveUpdateRemoteUserUID === 'boolean' ? effectiveUpdateRemoteUserUID : params.updateRemoteUserUIDDefault === 'on') || !(cliHost.platform === 'linux' || params.updateRemoteUserUIDOnMacOS && cliHost.platform === 'darwin')) {
return null;
}
const details = await imageDetails();
Expand All @@ -442,6 +447,55 @@ export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParame
};
}

// The default subuid/subgid range size that rootless podman grants to a user. A host
// UID/GID at or below this value can be baked into the image at build time; anything
// above it cannot be owned by the container under rootless podman.
const DEFAULT_SUBID_RANGE = 65536;

// Returns true when the given host UID/GID can be baked into the image at build time
// under rootless podman. Rootless podman can only own files at UIDs within the user's
// subuid range (0..65536 by default). When the host UID is above that range, the
// build-time chown/usermod fails with EINVAL, so the CLI must fall back to the runtime
// --userns=keep-id mapping instead.
export function isBakeableUidGid(uid: number, gid: number): boolean {
return uid <= DEFAULT_SUBID_RANGE && gid <= DEFAULT_SUBID_RANGE;
}

// Determines whether the CLI should fall back to updateRemoteUserUID: false under
// rootless podman. This only applies when the user has NOT explicitly configured
// updateRemoteUserUID (i.e. it is at its default), and the host UID/GID is not bakeable.
// A user-supplied setting is always respected.
export async function shouldFallbackToKeepId(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, cliHost: CLIHost): Promise<boolean> {
if (params.cliVariant !== CLIVariant.Podman || cliHost.platform !== 'linux') {
return false;
}
// Never override a user-supplied setting.
if (typeof mergedConfig.updateRemoteUserUID === 'boolean') {
return false;
}
// Only fall back when the default would otherwise trigger the bake.
if (params.updateRemoteUserUIDDefault !== 'on') {
return false;
}
if (!cliHost.getuid || !cliHost.getgid) {
return false;
}
return !isBakeableUidGid(await cliHost.getuid(), await cliHost.getgid());
}

// Applies the rootless-podman fallback: when the host UID/GID is not bakeable and the
// user has not explicitly configured updateRemoteUserUID, disable the build-time bake
// and rely on the runtime --userns=keep-id mapping instead. Returns the effective
// updateRemoteUserUID value (a boolean when the fallback applies, otherwise undefined
// to keep the default behavior).
export async function resolveUpdateRemoteUserUID(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, cliHost: CLIHost): Promise<boolean | undefined> {
if (await shouldFallbackToKeepId(params, mergedConfig, cliHost)) {
params.common.output.write('Host UID/GID is outside the rootless podman subuid range; disabling updateRemoteUserUID and relying on --userns=keep-id.', LogLevel.Warning);
return false;
}
return undefined;
}

export async function updateRemoteUserUID(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise<ImageDetails>, runArgsUser: string | undefined) {
const { common } = params;
const { cliHost } = common;
Expand Down
50 changes: 47 additions & 3 deletions src/spec-node/singleContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t
...getLabels(labels),
...containerEnv,
...containerUserArgs,
...await getPodmanArgs(params, config, mergedConfig, imageDetails),
...await getPodmanArgs(params, config, mergedConfig, imageName, imageDetails),
...(config.runArgs || []),
...(await extraRunArgs(common, params, config) || []),
...featureArgs,
Expand All @@ -435,21 +435,65 @@ while sleep 1 & wait $!; do :; done`, '-']; // `wait $!` allows for the `trap` t
common.output.stop(text, start);
}

async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageDetails: () => Promise<ImageDetails>): Promise<string[]> {
async function getPodmanArgs(params: DockerResolverParameters, config: DevContainerFromDockerfileConfig | DevContainerFromImageConfig, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise<ImageDetails>): Promise<string[]> {
if (params.cliVariant === CLIVariant.Podman && params.common.cliHost.platform === 'linux') {
const args = ['--security-opt', 'label=disable'];
const hasIdMapping = (config.runArgs || []).some(arg => /--[ug]idmap(=|$)/.test(arg));
if (!hasIdMapping) {
const remoteUser = mergedConfig.remoteUser || findUserArg(config.runArgs) || (await imageDetails()).Config.User || 'root';
if (remoteUser !== 'root' && remoteUser !== '0') {
args.push('--userns=keep-id');
// Prefer parsing a numeric user spec directly from config; only fall back to
// running a throwaway container when the user is a name that must be resolved
// from the image's /etc/passwd and /etc/group.
const uidGid = parseNumericUidGid(remoteUser) ?? await resolveRemoteUserUidGid(params, imageName, remoteUser);
args.push(...getKeepIdArgs(uidGid));
}
}
return args;
}
return [];
}

// Parses a user spec (e.g. "1000", "1000:1000", "vscode", "vscode:1000") and returns
// numeric uid/gid only when both parts are numeric. When no group is given, the gid
// defaults to the uid. Returns undefined when the user is a name, in which case the
// caller must resolve the mapping from the image (e.g. via a throwaway container).
export function parseNumericUidGid(remoteUser: string): { uid: string; gid: string } | undefined {
const [user, group] = remoteUser.split(':');
if (!user || !/^\d+$/.test(user)) {
return undefined;
}
const gid = group ?? user;
if (!/^\d+$/.test(gid)) {
return undefined;
}
return { uid: user, gid };
}

// Resolves the remote user's UID and GID inside the image by running a throwaway container.
// Returns undefined if the resolution fails, in which case the caller falls back to plain --userns=keep-id.
export async function resolveRemoteUserUidGid(params: DockerResolverParameters, imageName: string, remoteUser: string): Promise<{ uid: string; gid: string } | undefined> {
try {
const infoParams = { ...toExecParameters(params), output: makeLog(params.common.output, LogLevel.Info) };
const result = await dockerCLI(infoParams, 'run', '--rm', '--entrypoint', '/bin/sh', imageName, '-c', `id -u ${remoteUser}; id -g ${remoteUser}`);
const [uid, gid] = result.stdout.toString().trim().split(/\r?\n/);
if (uid && gid && /^\d+$/.test(uid) && /^\d+$/.test(gid)) {
return { uid, gid };
}
} catch {
// Fall through to plain --userns=keep-id.
}
return undefined;
}

// Builds the --userns=keep-id argument, using the explicit uid/gid mapping when available.
export function getKeepIdArgs(uidGid: { uid: string; gid: string } | undefined): string[] {
if (uidGid) {
return [`--userns=keep-id:uid=${uidGid.uid},gid=${uidGid.gid}`];
}
return ['--userns=keep-id'];
}

// Convert a --mount string (e.g., "type=bind,source=/a,target=/b,consistency=cached") to -v syntax for wslc.
function convertMountToVolume(mountStr: string): string[] {
const parts = new Map(mountStr.split(',').map(p => {
Expand Down
41 changes: 41 additions & 0 deletions src/test/cli.podman.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,46 @@ describe('Dev Containers CLI using Podman', function () {
assert.ok(containerId, 'Container id not found.');
await shellExec(`podman rm -f ${containerId}`);
});

it('should map the remote user uid/gid with an explicit --userns=keep-id mapping', async () => {
const testFolder = `${__dirname}/configs/podman-keep-id`;
const res = await shellExec(`${cli} up --docker-path podman --workspace-folder ${testFolder}`);
const response = JSON.parse(res.stdout);
assert.equal(response.outcome, 'success');
const containerId: string = response.containerId;
assert.ok(containerId, 'Container id not found.');

// The container user 'foo' is baked to uid 1234 / gid 4321. With an explicit
// keep-id mapping, files the remote user creates in the bind-mounted workspace
// must be owned by the host user (not by host uid 1234).
const marker = `keepidtest_${Date.now()}`;
await shellExec(`podman exec ${containerId} sh -c "touch /workspaces/cli/${marker}"`);
const hostStat = await shellExec(`stat -c '%u:%g' ${path.join(__dirname, '..', '..', marker)}`);
assert.strictEqual(hostStat.stdout.trim(), `${process.getuid!()}:${process.getgid!()}`);
await shellExec(`rm -f ${path.join(__dirname, '..', '..', marker)}`);

await shellExec(`podman rm -f ${containerId}`);
});

it('should map a numeric remote user uid/gid without resolving from the image', async () => {
const testFolder = `${__dirname}/configs/podman-keep-id-numeric`;
const res = await shellExec(`${cli} up --docker-path podman --workspace-folder ${testFolder}`);
const response = JSON.parse(res.stdout);
assert.equal(response.outcome, 'success');
const containerId: string = response.containerId;
assert.ok(containerId, 'Container id not found.');

// The remote user is specified numerically (1234), so the CLI must derive the
// keep-id mapping directly from config rather than running a throwaway container.
// Files the remote user creates in the bind-mounted workspace must be owned by
// the host user (not by host uid 1234).
const marker = `keepidtest_${Date.now()}`;
await shellExec(`podman exec ${containerId} sh -c "touch /workspaces/cli/${marker}"`);
const hostStat = await shellExec(`stat -c '%u:%g' ${path.join(__dirname, '..', '..', marker)}`);
assert.strictEqual(hostStat.stdout.trim(), `${process.getuid!()}:${process.getgid!()}`);
await shellExec(`rm -f ${path.join(__dirname, '..', '..', marker)}`);

await shellExec(`podman rm -f ${containerId}`);
});
});
});
7 changes: 7 additions & 0 deletions src/test/configs/podman-keep-id-numeric/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"build": {
"dockerfile": "Dockerfile"
},
"remoteUser": "1234",
"updateRemoteUserUID": false
}
4 changes: 4 additions & 0 deletions src/test/configs/podman-keep-id-numeric/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FROM debian:latest

RUN groupadd -g 4321 foo
RUN useradd -m -u 1234 -g 4321 foo
7 changes: 7 additions & 0 deletions src/test/configs/podman-keep-id/.devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"build": {
"dockerfile": "Dockerfile"
},
"remoteUser": "foo",
"updateRemoteUserUID": false
}
4 changes: 4 additions & 0 deletions src/test/configs/podman-keep-id/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FROM debian:latest

RUN groupadd -g 4321 foo
RUN useradd -m -u 1234 -g 4321 foo
54 changes: 54 additions & 0 deletions src/test/keepIdArgs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as assert from 'assert';
import { getKeepIdArgs, parseNumericUidGid } from '../spec-node/singleContainer';

describe('parseNumericUidGid', function () {
it('should parse a plain numeric uid, defaulting gid to the uid', () => {
assert.deepStrictEqual(parseNumericUidGid('1000'), { uid: '1000', gid: '1000' });
});

it('should parse a numeric uid:gid pair', () => {
assert.deepStrictEqual(parseNumericUidGid('1000:1001'), { uid: '1000', gid: '1001' });
});

it('should return undefined for a named user', () => {
assert.strictEqual(parseNumericUidGid('vscode'), undefined);
});

it('should return undefined for a named user with numeric group', () => {
assert.strictEqual(parseNumericUidGid('vscode:1000'), undefined);
});

it('should return undefined for a numeric user with named group', () => {
assert.strictEqual(parseNumericUidGid('1000:vscode'), undefined);
});

it('should return undefined for an empty or malformed spec', () => {
assert.strictEqual(parseNumericUidGid(''), undefined);
assert.strictEqual(parseNumericUidGid(':1000'), undefined);
});
});

describe('getKeepIdArgs', function () {
it('should return plain --userns=keep-id when uid/gid are not resolved', () => {
assert.deepStrictEqual(getKeepIdArgs(undefined), ['--userns=keep-id']);
});

it('should return explicit uid/gid mapping when resolved', () => {
assert.deepStrictEqual(
getKeepIdArgs({ uid: '1000', gid: '1000' }),
['--userns=keep-id:uid=1000,gid=1000']
);
});

it('should return explicit mapping for a high (non-bakeable) uid', () => {
assert.deepStrictEqual(
getKeepIdArgs({ uid: '1400601103', gid: '1400600513' }),
['--userns=keep-id:uid=1400601103,gid=1400600513']
);
});
});
Loading