Skip to content

Commit 7f41f3c

Browse files
CodeCasterXcodexclaude
authored
fix(sandbox): handle WSL2 UID conflicts (#600)
- Remove a matching preinstalled ubuntu account before creating devuser. - Preserve non-conflicting and root UID mappings. - Cover cleanup success and failure paths with shell-level tests. Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 65bac2e commit 7f41f3c

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

lib/sandbox/runtimes/base.dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ RUN if [ "${HOST_UID}" = "0" ]; then \
1212
(groupadd -o -g ${HOST_GID} devuser || true) && \
1313
useradd -o -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash devuser; \
1414
else \
15+
if ubuntu_uid="$(id -u ubuntu 2>/dev/null)" && [ "${ubuntu_uid}" = "${HOST_UID}" ]; then \
16+
userdel -r ubuntu || exit 1; \
17+
fi; \
1518
(groupadd -g ${HOST_GID} devuser || true) && \
1619
useradd -u ${HOST_UID} -g ${HOST_GID} -m -s /bin/bash devuser; \
1720
fi

tests/integration/cli/sandbox-dockerfile.test.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,138 @@ test("base.dockerfile guards root host uid with useradd -o", () => {
578578
assert.match(content, /useradd -o -u \$\{HOST_UID\}/);
579579
});
580580

581+
test("base.dockerfile handles the preinstalled ubuntu UID conflict", onPlatforms("linux", "darwin"), async (t) => {
582+
const content = fs.readFileSync(filePath("lib/sandbox/runtimes/base.dockerfile"), "utf8");
583+
const runBlock = content
584+
.split(/^(?=FROM |USER |ENV |ARG |RUN |WORKDIR |CMD |COPY |ADD )/m)
585+
.find((block) => block.startsWith("RUN ") && block.includes("useradd"));
586+
assert.ok(runBlock, "expected a RUN block creating devuser");
587+
588+
const shellBody = runBlock.replace(/^RUN\s+/, "").replace(/\\\n\s*/g, " ").trim();
589+
const stubDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-infra-sandbox-user-stubs-"));
590+
const logFile = path.join(stubDir, "invocations.log");
591+
const stubs = {
592+
id: [
593+
"#!/bin/sh",
594+
"printf 'id %s\\n' \"$*\" >> \"$INVOCATIONS_LOG\"",
595+
"[ \"$*\" = \"-u ubuntu\" ] && [ -n \"$STUB_UBUNTU_UID\" ] || exit 1",
596+
"printf '%s\\n' \"$STUB_UBUNTU_UID\""
597+
],
598+
userdel: [
599+
"#!/bin/sh",
600+
"printf 'userdel %s\\n' \"$*\" >> \"$INVOCATIONS_LOG\"",
601+
'exit "${STUB_USERDEL_STATUS:-0}"'
602+
],
603+
groupadd: [
604+
"#!/bin/sh",
605+
"printf 'groupadd %s\\n' \"$*\" >> \"$INVOCATIONS_LOG\""
606+
],
607+
useradd: [
608+
"#!/bin/sh",
609+
"printf 'useradd %s\\n' \"$*\" >> \"$INVOCATIONS_LOG\""
610+
]
611+
};
612+
613+
try {
614+
for (const [name, lines] of Object.entries(stubs)) {
615+
fs.writeFileSync(path.join(stubDir, name), `${lines.join("\n")}\n`, { mode: 0o755 });
616+
}
617+
618+
const cases = [
619+
{
620+
name: "removes a matching ubuntu account before creating devuser",
621+
uid: "1000",
622+
gid: "1000",
623+
ubuntuUid: "1000",
624+
userdelStatus: "0",
625+
status: 0,
626+
invocations: [
627+
"id -u ubuntu",
628+
"userdel -r ubuntu",
629+
"groupadd -g 1000 devuser",
630+
"useradd -u 1000 -g 1000 -m -s /bin/bash devuser"
631+
]
632+
},
633+
{
634+
name: "preserves the macOS 501 mapping when ubuntu has UID 1000",
635+
uid: "501",
636+
gid: "20",
637+
ubuntuUid: "1000",
638+
userdelStatus: "0",
639+
status: 0,
640+
invocations: [
641+
"id -u ubuntu",
642+
"groupadd -g 20 devuser",
643+
"useradd -u 501 -g 20 -m -s /bin/bash devuser"
644+
]
645+
},
646+
{
647+
name: "creates devuser when the ubuntu account is absent",
648+
uid: "1000",
649+
gid: "1000",
650+
ubuntuUid: "",
651+
userdelStatus: "0",
652+
status: 0,
653+
invocations: [
654+
"id -u ubuntu",
655+
"groupadd -g 1000 devuser",
656+
"useradd -u 1000 -g 1000 -m -s /bin/bash devuser"
657+
]
658+
},
659+
{
660+
name: "keeps the root mapping branch unchanged",
661+
uid: "0",
662+
gid: "0",
663+
ubuntuUid: "1000",
664+
userdelStatus: "0",
665+
status: 0,
666+
invocations: [
667+
"groupadd -o -g 0 devuser",
668+
"useradd -o -u 0 -g 0 -m -s /bin/bash devuser"
669+
]
670+
},
671+
{
672+
name: "stops before group creation when ubuntu cleanup fails",
673+
uid: "1000",
674+
gid: "1000",
675+
ubuntuUid: "1000",
676+
userdelStatus: "1",
677+
status: 1,
678+
invocations: [
679+
"id -u ubuntu",
680+
"userdel -r ubuntu"
681+
]
682+
}
683+
];
684+
685+
for (const scenario of cases) {
686+
await t.test(scenario.name, () => {
687+
fs.rmSync(logFile, { force: true });
688+
const result = spawnSync("/bin/sh", ["-c", shellBody], {
689+
env: {
690+
...process.env,
691+
PATH: `${stubDir}:${process.env.PATH}`,
692+
HOST_UID: scenario.uid,
693+
HOST_GID: scenario.gid,
694+
INVOCATIONS_LOG: logFile,
695+
STUB_UBUNTU_UID: scenario.ubuntuUid,
696+
STUB_USERDEL_STATUS: scenario.userdelStatus
697+
},
698+
encoding: "utf8"
699+
});
700+
const invocations = fs.existsSync(logFile)
701+
? fs.readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean)
702+
: [];
703+
704+
assert.equal(result.status, scenario.status, result.stderr);
705+
assert.deepEqual(invocations, scenario.invocations);
706+
});
707+
}
708+
} finally {
709+
fs.rmSync(stubDir, { recursive: true, force: true });
710+
}
711+
});
712+
581713
test("composeDockerfile rejects unknown runtimes", async () => {
582714
const sandboxDockerfile = await loadFreshEsm<typeof import("../../../lib/sandbox/dockerfile.ts")>("lib/sandbox/dockerfile.js");
583715

0 commit comments

Comments
 (0)