feat(worker): exec-as-user, stdin input, chown + userns plumbing

This commit is contained in:
Gabriel Brown
2026-07-13 09:52:45 -04:00
parent 78cb3d90c2
commit 37a82098c2
4 changed files with 156 additions and 19 deletions
+92 -19
View File
@@ -5,6 +5,7 @@ import Docker from 'dockerode';
import { execa } from 'execa';
import { env } from '../env';
import { BOX_UID, linuxUsername } from './box-user';
type CommandResult = {
exitCode: number;
@@ -26,6 +27,21 @@ const environmentArgs = (environment: Record<string, string>) =>
const networkArgs = () => (env.network ? ['--network', env.network] : []);
// Box user namespace mapping. Under rootless podman, keep-id maps the host
// user onto the box user's uid so host-side writes (clones, dotfiles, file
// editor) are already user-owned inside the box. Rootful docker has no
// equivalent — ownership is repaired with targeted chowns instead (see
// chownInBox). SPOON_AGENT_BOX_USERNS overrides: empty disables, any other
// value passes through.
export const boxUsernsArgs = (): string[] => {
if (env.boxUserns !== undefined) {
return env.boxUserns ? [`--userns=${env.boxUserns}`] : [];
}
return containerRuntime().endsWith('podman')
? [`--userns=keep-id:uid=${BOX_UID},gid=${BOX_UID}`]
: [];
};
const containerRuntime = () => env.containerRuntime;
// `docker run` reuses a stale local `:latest` forever, so without an explicit
@@ -173,6 +189,10 @@ export const ensureUserContainer = async (args: {
username: string;
workdir: string;
containerHome: string;
// Runs once per fresh container, after `run` succeeds — box-user init
// (useradd, sudo policy, password) hooks in here so every creation path
// (acquire, explicit start) sets the user up without duplicating the logic.
onCreated?: (containerName: string) => Promise<void>;
}): Promise<string> => {
await ensureJobImagePulled();
const name = userContainerName(args.username);
@@ -196,10 +216,13 @@ export const ensureUserContainer = async (args: {
'--init',
'--name',
name,
'--hostname',
`${linuxUsername(args.username)}-box`,
'--memory',
'4g',
'--cpus',
'2',
...boxUsernsArgs(),
...networkArgs(),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
@@ -211,6 +234,7 @@ export const ensureUserContainer = async (args: {
],
{ stdin: 'ignore' },
);
if (args.onCreated) await args.onCreated(name);
return name;
};
@@ -236,7 +260,12 @@ export const inspectUserBoxStatus = async (
{ reject: false, stdin: 'ignore' },
);
if (result.exitCode !== 0) {
return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
return {
running: false,
image: null,
startedAt: null,
memoryLimitBytes: null,
};
}
const [runningField, imageField, startedAtField, memoryField] = result.stdout
.trim()
@@ -335,6 +364,25 @@ export const killBoxProcessesByMarker = async (args: {
);
};
// Shared `<runtime> exec` argv. `user` runs the command as that container
// user instead of root — terminals and agent turns pass the box user; init,
// chown and kill paths stay root.
export const execArgv = (args: {
containerName: string;
command: string[];
environment: Record<string, string>;
containerCwd: string;
user?: string;
}): string[] => [
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
...(args.user ? ['-u', args.user] : []),
args.containerName,
...args.command,
];
export const streamExecInContainer = async (args: {
containerName: string;
command: string[];
@@ -342,21 +390,16 @@ export const streamExecInContainer = async (args: {
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
user?: string;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
const subprocess = execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
args.containerName,
...args.command,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
);
const subprocess = execa(containerRuntime(), execArgv(args), {
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
});
return streamSubprocess(
subprocess,
args.redact,
@@ -372,20 +415,50 @@ export const runExecInContainer = async (args: {
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
user?: string;
// Piped to the command's stdin (e.g. `chpasswd` credentials, which must
// never appear in argv). Without it stdin is closed.
input?: string;
}): Promise<CommandResult> => {
const result = await execa(containerRuntime(), execArgv(args), {
all: true,
reject: false,
timeout: args.timeoutMs,
...(args.input == null
? { stdin: 'ignore' as const }
: { input: args.input }),
});
return normalizeRunResult(result, result.all, args.redact);
};
// Best-effort ownership repair after host-side writes into a box home. Under
// dev podman (keep-id) this is a no-op — files are already the box uid; under
// prod docker it fixes uid-0 ownership left by the worker container. Failures
// are logged, not thrown: a stopped box just means the next creation's
// marker-guarded chown (or the next call) picks it up.
export const chownInBox = async (args: {
containerName: string;
paths: string[];
}): Promise<void> => {
if (args.paths.length === 0) return;
const result = await execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
args.containerName,
...args.command,
'chown',
'-R',
`${BOX_UID}:${BOX_UID}`,
...args.paths,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
{ all: true, reject: false, stdin: 'ignore' },
);
return normalizeRunResult(result, result.all, args.redact);
const normalized = normalizeRunResult(result, result.all, (value) => value);
if (normalized.exitCode !== 0) {
console.error(
`chown in ${args.containerName} failed (${String(normalized.exitCode)}): ${normalized.output}`,
);
}
};
export const stopWorkspaceContainer = async (containerName: string) => {