feat(worker): exec-as-user, stdin input, chown + userns plumbing
This commit is contained in:
@@ -37,6 +37,10 @@ export const env = {
|
||||
'',
|
||||
// How long a per-user box container survives with no active jobs/terminals.
|
||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||
// Override for the box container's --userns flag. Unset → keep-id mapping
|
||||
// under podman (host user becomes the box user), nothing under docker.
|
||||
// Empty string disables entirely; any other value passes through verbatim.
|
||||
boxUserns: process.env.SPOON_AGENT_BOX_USERNS,
|
||||
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
||||
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -44,6 +44,65 @@ describe('Docker runtime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('boxUsernsArgs maps the host user onto the box uid under podman', async () => {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||
const { boxUsernsArgs } = await loadVolumeSpec();
|
||||
expect(boxUsernsArgs()).toEqual(['--userns=keep-id:uid=1000,gid=1000']);
|
||||
});
|
||||
|
||||
test('boxUsernsArgs adds nothing under docker', async () => {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
|
||||
const { boxUsernsArgs } = await loadVolumeSpec();
|
||||
expect(boxUsernsArgs()).toEqual([]);
|
||||
});
|
||||
|
||||
test('SPOON_AGENT_BOX_USERNS overrides: value passes through, empty disables', async () => {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||
process.env.SPOON_AGENT_BOX_USERNS = 'host';
|
||||
let { boxUsernsArgs } = await loadVolumeSpec();
|
||||
expect(boxUsernsArgs()).toEqual(['--userns=host']);
|
||||
|
||||
process.env.SPOON_AGENT_BOX_USERNS = '';
|
||||
({ boxUsernsArgs } = await loadVolumeSpec());
|
||||
expect(boxUsernsArgs()).toEqual([]);
|
||||
delete process.env.SPOON_AGENT_BOX_USERNS;
|
||||
});
|
||||
|
||||
test('execArgv places -u after the workdir and before the container name', async () => {
|
||||
const { execArgv } = await loadVolumeSpec();
|
||||
expect(
|
||||
execArgv({
|
||||
containerName: 'spoon-box-gabriel',
|
||||
command: ['ls', '-la'],
|
||||
environment: { HOME: '/home/gabriel' },
|
||||
containerCwd: '/home/gabriel',
|
||||
user: 'gabriel',
|
||||
}),
|
||||
).toEqual([
|
||||
'exec',
|
||||
'-e',
|
||||
'HOME=/home/gabriel',
|
||||
'-w',
|
||||
'/home/gabriel',
|
||||
'-u',
|
||||
'gabriel',
|
||||
'spoon-box-gabriel',
|
||||
'ls',
|
||||
'-la',
|
||||
]);
|
||||
});
|
||||
|
||||
test('execArgv omits -u when no user is given', async () => {
|
||||
const { execArgv } = await loadVolumeSpec();
|
||||
const argv = execArgv({
|
||||
containerName: 'c',
|
||||
command: ['true'],
|
||||
environment: {},
|
||||
containerCwd: '/',
|
||||
});
|
||||
expect(argv).not.toContain('-u');
|
||||
});
|
||||
|
||||
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||
const { normalizeRunResult } = await loadVolumeSpec();
|
||||
// This is what execa returns with `reject: false` when the runtime binary is
|
||||
|
||||
Reference in New Issue
Block a user