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.
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
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
|
// 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.
|
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||||
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Docker from 'dockerode';
|
|||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
import { BOX_UID, linuxUsername } from './box-user';
|
||||||
|
|
||||||
type CommandResult = {
|
type CommandResult = {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
@@ -26,6 +27,21 @@ const environmentArgs = (environment: Record<string, string>) =>
|
|||||||
|
|
||||||
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
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;
|
const containerRuntime = () => env.containerRuntime;
|
||||||
|
|
||||||
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
||||||
@@ -173,6 +189,10 @@ export const ensureUserContainer = async (args: {
|
|||||||
username: string;
|
username: string;
|
||||||
workdir: string;
|
workdir: string;
|
||||||
containerHome: 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> => {
|
}): Promise<string> => {
|
||||||
await ensureJobImagePulled();
|
await ensureJobImagePulled();
|
||||||
const name = userContainerName(args.username);
|
const name = userContainerName(args.username);
|
||||||
@@ -196,10 +216,13 @@ export const ensureUserContainer = async (args: {
|
|||||||
'--init',
|
'--init',
|
||||||
'--name',
|
'--name',
|
||||||
name,
|
name,
|
||||||
|
'--hostname',
|
||||||
|
`${linuxUsername(args.username)}-box`,
|
||||||
'--memory',
|
'--memory',
|
||||||
'4g',
|
'4g',
|
||||||
'--cpus',
|
'--cpus',
|
||||||
'2',
|
'2',
|
||||||
|
...boxUsernsArgs(),
|
||||||
...networkArgs(),
|
...networkArgs(),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
@@ -211,6 +234,7 @@ export const ensureUserContainer = async (args: {
|
|||||||
],
|
],
|
||||||
{ stdin: 'ignore' },
|
{ stdin: 'ignore' },
|
||||||
);
|
);
|
||||||
|
if (args.onCreated) await args.onCreated(name);
|
||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,7 +260,12 @@ export const inspectUserBoxStatus = async (
|
|||||||
{ reject: false, stdin: 'ignore' },
|
{ reject: false, stdin: 'ignore' },
|
||||||
);
|
);
|
||||||
if (result.exitCode !== 0) {
|
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
|
const [runningField, imageField, startedAtField, memoryField] = result.stdout
|
||||||
.trim()
|
.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: {
|
export const streamExecInContainer = async (args: {
|
||||||
containerName: string;
|
containerName: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
@@ -342,21 +390,16 @@ export const streamExecInContainer = async (args: {
|
|||||||
containerCwd: string;
|
containerCwd: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
user?: string;
|
||||||
onStdoutLine?: (line: string) => Promise<void>;
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
onStderrLine?: (line: string) => Promise<void>;
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
}): Promise<CommandResult> => {
|
}): Promise<CommandResult> => {
|
||||||
const subprocess = execa(
|
const subprocess = execa(containerRuntime(), execArgv(args), {
|
||||||
containerRuntime(),
|
all: true,
|
||||||
[
|
reject: false,
|
||||||
'exec',
|
stdin: 'ignore',
|
||||||
...environmentArgs(args.environment),
|
timeout: args.timeoutMs,
|
||||||
'-w',
|
});
|
||||||
args.containerCwd,
|
|
||||||
args.containerName,
|
|
||||||
...args.command,
|
|
||||||
],
|
|
||||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
|
||||||
);
|
|
||||||
return streamSubprocess(
|
return streamSubprocess(
|
||||||
subprocess,
|
subprocess,
|
||||||
args.redact,
|
args.redact,
|
||||||
@@ -372,20 +415,50 @@ export const runExecInContainer = async (args: {
|
|||||||
containerCwd: string;
|
containerCwd: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
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> => {
|
}): 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(
|
const result = await execa(
|
||||||
containerRuntime(),
|
containerRuntime(),
|
||||||
[
|
[
|
||||||
'exec',
|
'exec',
|
||||||
...environmentArgs(args.environment),
|
|
||||||
'-w',
|
|
||||||
args.containerCwd,
|
|
||||||
args.containerName,
|
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) => {
|
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 () => {
|
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||||
const { normalizeRunResult } = await loadVolumeSpec();
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
// This is what execa returns with `reject: false` when the runtime binary is
|
// This is what execa returns with `reject: false` when the runtime binary is
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"SPOON_AGENT_TERMINAL_SECRET",
|
"SPOON_AGENT_TERMINAL_SECRET",
|
||||||
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
||||||
"SPOON_AGENT_BOX_IDLE_MS",
|
"SPOON_AGENT_BOX_IDLE_MS",
|
||||||
|
"SPOON_AGENT_BOX_USERNS",
|
||||||
"SPOON_AGENT_DEV_WATCHDOG",
|
"SPOON_AGENT_DEV_WATCHDOG",
|
||||||
"SPOON_AGENT_RUNTIME",
|
"SPOON_AGENT_RUNTIME",
|
||||||
"SPOON_AGENT_CONTAINER_RUNTIME",
|
"SPOON_AGENT_CONTAINER_RUNTIME",
|
||||||
|
|||||||
Reference in New Issue
Block a user