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
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']
);
});
});