Merge feat/box-user: non-root box user + settable password
Build and Push Spoon Images / quality (push) Successful in 1m57s
Build and Push Spoon Images / build-images (push) Successful in 9m52s

This commit is contained in:
Gabriel Brown
2026-07-13 11:38:29 -04:00
33 changed files with 1214 additions and 46 deletions
+97
View File
@@ -0,0 +1,97 @@
import { fetchBoxPassword } from './box-settings';
import {
buildBoxInitScript,
buildClearPasswordCommand,
buildSudoPolicyScript,
CHPASSWD_COMMAND,
linuxUsername,
} from './runtime/box-user';
import { runExecInContainer } from './runtime/docker';
const noRedact = (value: string) => value;
// Root exec with loud failure — a broken init would leave a root terminal, so
// surface the script output instead of continuing silently.
const execRoot = async (
containerName: string,
command: string[],
label: string,
input?: string,
redact: (value: string) => string = noRedact,
): Promise<void> => {
const result = await runExecInContainer({
containerName,
command,
environment: {},
containerCwd: '/',
redact,
timeoutMs: 120_000,
input,
// Explicit: under podman keep-id the container's DEFAULT user is the
// mapped uid (1000), not root, and useradd/chpasswd/sudoers need root.
user: 'root',
});
if (result.exitCode !== 0) {
throw new Error(`Box ${label} failed: ${result.output}`);
}
};
/**
* One-time (per container) box-user setup, run as root right after `run`:
* create the Linux user at the mounted home, apply the stored password (via
* chpasswd stdin — never argv), and set the sudo policy (NOPASSWD drop-in
* only while no password is stored). Used as ensureUserContainer's onCreated
* hook by every creation path.
*/
export const initializeBoxUser = async (
containerName: string,
spoonUsername: string,
containerHome: string,
): Promise<void> => {
const user = linuxUsername(spoonUsername);
await execRoot(
containerName,
['bash', '-c', buildBoxInitScript({ username: user, home: containerHome })],
'user init',
);
const password = await fetchBoxPassword(spoonUsername);
await applyBoxPassword(containerName, spoonUsername, password);
};
/**
* Apply (or clear, with null) the box password + matching sudo policy on a
* running container. Shared by init and the live /box/set-password route.
*/
export const applyBoxPassword = async (
containerName: string,
spoonUsername: string,
password: string | null,
): Promise<void> => {
const user = linuxUsername(spoonUsername);
if (password == null) {
await execRoot(
containerName,
buildClearPasswordCommand(user),
'password clear',
);
} else {
await execRoot(
containerName,
CHPASSWD_COMMAND,
'password apply',
`${user}:${password}\n`,
// chpasswd never echoes the password, but redact defensively in case a
// future runtime error message includes stdin.
(value) => value.replaceAll(password, '[redacted]'),
);
}
await execRoot(
containerName,
[
'bash',
'-c',
buildSudoPolicyScript({ username: user, passwordSet: password != null }),
],
'sudo policy',
);
};
+30
View File
@@ -0,0 +1,30 @@
import { ConvexHttpClient } from 'convex/browser';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
// Lazy: unit tests mock ./env with a partial object, and the constructor
// validates the URL eagerly.
let client: ConvexHttpClient | undefined;
const getClient = () => (client ??= new ConvexHttpClient(env.convexUrl));
/**
* The user's stored box password (decrypted), or null when none is set. Also
* null on ANY error: a box must still start when Convex is unreachable — the
* user just gets the passwordless-sudo default until the next recreation.
*/
export const fetchBoxPassword = async (
username: string,
): Promise<string | null> => {
try {
const result = await getClient().action(
api.boxSettingsNode.getBoxPasswordForWorker,
{ workerToken: env.workerToken, username },
);
return result.password;
} catch (error) {
console.error(`Failed to fetch box password for ${username}:`, error);
return null;
}
};
+31 -3
View File
@@ -1,9 +1,11 @@
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { applyBoxPassword, initializeBoxUser } from './box-init';
import { env } from './env'; import { env } from './env';
import { assertContainedRealPath } from './path-containment'; import { assertContainedRealPath } from './path-containment';
import { import {
chownInBox,
ensureUserContainer, ensureUserContainer,
inspectUserBoxStatus, inspectUserBoxStatus,
stopWorkspaceContainer, stopWorkspaceContainer,
@@ -41,7 +43,25 @@ export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
export const startBox = async (username: string): Promise<void> => { export const startBox = async (username: string): Promise<void> => {
const { homeDir, containerHome } = boxHomePaths(username); const { homeDir, containerHome } = boxHomePaths(username);
await ensureUserContainer({ username, workdir: homeDir, containerHome }); await ensureUserContainer({
username,
workdir: homeDir,
containerHome,
onCreated: (name) => initializeBoxUser(name, username, containerHome),
});
};
// Live password apply for the /box/set-password route: only touches the
// container when it's running — otherwise the stored password is picked up by
// the next creation's init.
export const setBoxUserPassword = async (
username: string,
password: string | null,
): Promise<{ applied: boolean }> => {
const status = await inspectUserBoxStatus(username);
if (!status.running) return { applied: false };
await applyBoxPassword(userContainerName(username), username, password);
return { applied: true };
}; };
export const stopBox = async (username: string): Promise<void> => { export const stopBox = async (username: string): Promise<void> => {
@@ -145,9 +165,17 @@ export const writeBoxFile = async (
relPath: string, relPath: string,
content: string, content: string,
): Promise<{ success: true }> => { ): Promise<{ success: true }> => {
const { homeDir } = boxHomePaths(username); const { homeDir, containerHome } = boxHomePaths(username);
const target = await safeHomePath(homeDir, relPath, { forWrite: true }); const target = await safeHomePath(homeDir, relPath, { forWrite: true });
await mkdir(path.dirname(target), { recursive: true }); await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content); await writeFile(target, content);
// Host-side write: repair ownership so the box user can edit the file in a
// shell too (no-op under dev keep-id; best-effort when the box is stopped —
// the next creation's marker chown won't rerun, but a later editor write or
// manual sudo chown recovers, and read access is unaffected).
await chownInBox({
containerName: userContainerName(username),
paths: [path.posix.join(containerHome, relPath)],
});
return { success: true }; return { success: true };
}; };
+4
View File
@@ -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',
+84
View File
@@ -0,0 +1,84 @@
// The per-box Linux user. Terminal sessions and agent turns exec as this user
// instead of root (Claude Code refuses some operations as root, and a real
// machine has a named user); sudo is passwordless until the user stores a
// password, after which wheel membership makes sudo prompt for it.
export const BOX_UID = 1000;
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
// Linux-safe username derived from the Spoon username. The home path keeps
// the Spoon username everywhere; only the passwd entry uses this.
export const linuxUsername = (spoonUsername: string): string => {
const cleaned = spoonUsername
.toLowerCase()
.replaceAll(/[^a-z0-9_-]/g, '-')
.slice(0, 32);
if (!cleaned) return 'spoon';
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
};
// Idempotent root init: ensure a SINGLE uid-1000 passwd entry with our name,
// grant wheel, and chown the home ONCE (marker-guarded — later host-side
// writes are repaired by targeted chowns, so a big home is never re-walked).
//
// The uid-1000 entry may already exist: podman --userns=keep-id injects one
// named after the HOST user. A second entry at the same uid would win the
// getpwuid lookup (whoami would report the host username), so an existing
// entry is RENAMED rather than duplicated. `-M` skips skeleton copy since the
// mounted home persists across containers.
export const buildBoxInitScript = (args: {
username: string;
home: string;
}): string => {
const user = shellQuote(args.username);
const home = shellQuote(args.home);
const marker = shellQuote(`${args.home}/.spoon/chown-v1`);
return [
'set -e',
`existing="$(getent passwd ${BOX_UID} | cut -d: -f1 || true)"`,
`if [ -n "$existing" ] && [ "$existing" != ${user} ]; then`,
` usermod -l ${user} -d ${home} -s /bin/bash "$existing"`,
`elif [ -z "$existing" ]; then`,
` useradd -u ${BOX_UID} -o -M -d ${home} -s /bin/bash ${user}`,
'fi',
`usermod -aG wheel ${user}`,
`if [ ! -f ${marker} ]; then`,
// Marker is created BEFORE the recursive chown so ~/.spoon itself ends up
// user-owned by the same sweep.
` mkdir -p "$(dirname ${marker})" && touch ${marker}`,
` chown -R ${BOX_UID}:${BOX_UID} ${home}`,
'fi',
].join('\n');
};
// Sudo policy: NOPASSWD drop-in only while no password is stored; with a
// password, wheel membership makes sudo prompt for it. The drop-in is staged
// to a temp file and checked with `visudo -c` before install so a bad write
// can never brick sudo.
export const buildSudoPolicyScript = (args: {
username: string;
passwordSet: boolean;
}): string => {
if (args.passwordSet) {
return 'rm -f /etc/sudoers.d/spoon-box';
}
const line = `${args.username} ALL=(ALL) NOPASSWD:ALL`;
return [
'set -e',
`printf '%s\\n' ${shellQuote(line)} > /etc/sudoers.d/spoon-box.tmp`,
'visudo -c -f /etc/sudoers.d/spoon-box.tmp',
'chmod 0440 /etc/sudoers.d/spoon-box.tmp',
'mv /etc/sudoers.d/spoon-box.tmp /etc/sudoers.d/spoon-box',
].join('\n');
};
// `chpasswd` reads `user:password` from stdin, so the password never appears
// in argv (visible in `ps`) or in a shell script body.
export const CHPASSWD_COMMAND = ['chpasswd'];
export const buildClearPasswordCommand = (username: string): string[] => [
'passwd',
'-d',
username,
];
@@ -1,17 +1,18 @@
import path from 'node:path';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { NormalizedAgentEvent } from '../agent-events'; import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeClaudeJsonLine } from '../agent-events'; import { normalizeClaudeJsonLine } from '../agent-events';
import { env } from '../env'; import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { writeJsonFile } from './auth'; import { writeJsonFile } from './auth';
import { linuxUsername } from './box-user';
import { import {
buildMarkedCommand, buildMarkedCommand,
killBoxProcessesByMarker, killBoxProcessesByMarker,
streamExecInContainer, streamExecInContainer,
} from './docker'; } from './docker';
import type { AdapterWorkspace } from './provider';
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider'; import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
export const createClaudeAdapter: AdapterFactory = (deps) => { export const createClaudeAdapter: AdapterFactory = (deps) => {
@@ -23,7 +24,9 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
if (!isClaudeOAuthProfile(workspace.claim)) return; if (!isClaudeOAuthProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.secret; const secret = workspace.claim.aiProviderProfile?.secret;
if (!secret) { if (!secret) {
throw new Error('Claude auth profile is missing credentials.json contents.'); throw new Error(
'Claude auth profile is missing credentials.json contents.',
);
} }
const credentialsPath = path.join( const credentialsPath = path.join(
workspace.homeDir, workspace.homeDir,
@@ -84,6 +87,7 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
try { try {
result = await execStream({ result = await execStream({
containerName: workspace.boxName, containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo, containerCwd: workspace.containerRepo,
command, command,
environment: { ...aiEnv, ...secretEnv }, environment: { ...aiEnv, ...secretEnv },
@@ -1,18 +1,19 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events'; import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeCodexJsonLine } from '../agent-events'; import { normalizeCodexJsonLine } from '../agent-events';
import { prepareCodexWorkspaceFiles } from '../codex-runtime'; import { prepareCodexWorkspaceFiles } from '../codex-runtime';
import { env } from '../env'; import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime'; import { linuxUsername } from './box-user';
import { import {
buildMarkedCommand, buildMarkedCommand,
killBoxProcessesByMarker, killBoxProcessesByMarker,
streamExecInContainer, streamExecInContainer,
} from './docker'; } from './docker';
import type { AdapterWorkspace } from './provider';
import { import {
codexModelArgs, codexModelArgs,
isCodexLoginProfile, isCodexLoginProfile,
@@ -142,6 +143,7 @@ export const createCodexAdapter: AdapterFactory = (deps) => {
try { try {
result = await execStream({ result = await execStream({
containerName: workspace.boxName, containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo, containerCwd: workspace.containerRepo,
command, command,
environment: { ...aiEnv, ...secretEnv }, environment: { ...aiEnv, ...secretEnv },
+96 -19
View File
@@ -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,54 @@ 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), // Explicit root: under podman keep-id the container default user is the
'-w', // mapped uid, which cannot chown files it does not own.
args.containerCwd, '-u',
'root',
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) => {
@@ -1,17 +1,18 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises'; import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events'; import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeOpenCodeRunLine } from '../agent-events'; import { normalizeOpenCodeRunLine } from '../agent-events';
import { env } from '../env'; import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime'; import { linuxUsername } from './box-user';
import { import {
buildMarkedCommand, buildMarkedCommand,
killBoxProcessesByMarker, killBoxProcessesByMarker,
streamExecInContainer, streamExecInContainer,
} from './docker'; } from './docker';
import type { AdapterWorkspace } from './provider';
import { opencodeModel, providerEnvironment } from './provider'; import { opencodeModel, providerEnvironment } from './provider';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only // Normalize + pretty-print an auth JSON blob before writing it with owner-only
@@ -97,6 +98,7 @@ export const createOpenCodeAdapter: AdapterFactory = (deps) => {
try { try {
result = await execStream({ result = await execStream({
containerName: workspace.boxName, containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo, containerCwd: workspace.containerRepo,
command, command,
environment: { ...aiEnv, ...secretEnv }, environment: { ...aiEnv, ...secretEnv },
+23 -1
View File
@@ -8,6 +8,7 @@ import {
listBoxTree, listBoxTree,
readBoxFile, readBoxFile,
restartBox, restartBox,
setBoxUserPassword,
startBox, startBox,
stopBox, stopBox,
writeBoxFile, writeBoxFile,
@@ -66,7 +67,9 @@ const jobRoute = (pathname: string) => {
}; };
export const boxRoute = (pathname: string) => { export const boxRoute = (pathname: string) => {
const match = /^\/box\/(status|lifecycle|tree|file)$/.exec(pathname); const match = /^\/box\/(status|lifecycle|tree|file|set-password)$/.exec(
pathname,
);
return match?.[1] ? { action: match[1] } : null; return match?.[1] ? { action: match[1] } : null;
}; };
@@ -136,6 +139,25 @@ export const startWorkerServer = () => {
); );
return; return;
} }
if (request.method === 'POST' && box.action === 'set-password') {
const body = await parseJson<{ password?: string | null }>(request);
// Convex owns validation/storage; this route only applies to the
// live container. Re-check bounds anyway so a bad caller can't
// push a malformed chpasswd line.
const password = body.password ?? null;
if (
password !== null &&
(password.length < 8 ||
password.length > 128 ||
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
/[\u0000-\u001f\u007f]/.test(password))
) {
sendJson(response, 400, { error: 'Invalid password' });
return;
}
sendJson(response, 200, await setBoxUserPassword(user, password));
return;
}
sendJson(response, 404, { error: 'Not found' }); sendJson(response, 404, { error: 'Not found' });
return; return;
} }
+46 -4
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import type { Duplex } from 'node:stream'; import type { Duplex } from 'node:stream';
import type { WebSocket } from 'ws'; import type { WebSocket } from 'ws';
@@ -6,7 +7,12 @@ import { WebSocketServer } from 'ws';
import type { BoxHandle } from './user-container'; import type { BoxHandle } from './user-container';
import { boxHomePaths } from './box'; import { boxHomePaths } from './box';
import { env } from './env'; import { env } from './env';
import { getDockerClient } from './runtime/docker'; import { linuxUsername } from './runtime/box-user';
import {
chownInBox,
getDockerClient,
killBoxProcessesByMarker,
} from './runtime/docker';
import { openExecStream } from './runtime/exec-stream'; import { openExecStream } from './runtime/exec-stream';
import { import {
parseBoxTokenUsername, parseBoxTokenUsername,
@@ -52,10 +58,19 @@ const closeWithReason = (ws: WebSocket, code: number, reason: string) => {
// The login-shell command shared by the job and box terminals: prefer a // The login-shell command shared by the job and box terminals: prefer a
// resumable tmux session, else fall back to a plain interactive login shell. // resumable tmux session, else fall back to a plain interactive login shell.
const SHELL_CMD = [ //
// The marker comment keeps a per-connection tag in the wrapper bash's argv so
// the bridge can kill THIS connection's process group when the WebSocket
// closes. Without it, every disconnect leaks an attached tmux client (the
// exec process survives the closed attach socket); the session then stays
// sized to the smallest zombie client, and once enough dead clients' ptys
// fill up, tmux stops rendering for live ones entirely. tmux/bash must NOT be
// exec'd — that would replace the marker-carrying argv.
export const buildTerminalShellCommand = (marker: string): string[] => [
'/bin/bash', '/bin/bash',
'-lc', '-lc',
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi', `# ${marker}
if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s spoon; else bash -il; fi`,
]; ];
type ShellBridgeOptions = { type ShellBridgeOptions = {
@@ -64,6 +79,11 @@ type ShellBridgeOptions = {
acquire: () => Promise<BoxHandle>; acquire: () => Promise<BoxHandle>;
// Host-side preparation before the shell starts (e.g. seed .bash_profile). // Host-side preparation before the shell starts (e.g. seed .bash_profile).
prepare?: () => Promise<void>; prepare?: () => Promise<void>;
// Runs after the box is acquired (so the container exists) — ownership
// repair for anything `prepare` wrote host-side.
postAcquire?: (boxName: string) => Promise<void>;
// Container user the shell runs as (the box user, not root).
user: string;
// Directory the login shell starts in (container path). // Directory the login shell starts in (container path).
cwd: string; cwd: string;
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms). // Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
@@ -101,6 +121,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
else pendingInput.push(data); else pendingInput.push(data);
}); });
const marker = `spoon-term-${randomUUID()}`;
const handleHolder: { current?: BoxHandle } = {}; const handleHolder: { current?: BoxHandle } = {};
let released = false; let released = false;
// Read through a function so TS doesn't narrow `released` to a constant — the // Read through a function so TS doesn't narrow `released` to a constant — the
@@ -111,6 +132,15 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
released = true; released = true;
execHolder.current?.stream.end(); execHolder.current?.stream.end();
execHolder.current?.stream.destroy(); execHolder.current?.stream.destroy();
// Closing the attach socket does NOT kill the exec'd shell — reap this
// connection's process group so dead tmux clients don't accumulate. The
// tmux server daemonized into its own group and survives.
const boxName = handleHolder.current?.boxName;
if (boxName && execHolder.current) {
void killBoxProcessesByMarker({ containerName: boxName, marker }).catch(
() => undefined,
);
}
handleHolder.current?.release(); handleHolder.current?.release();
}; };
ws.on('close', cleanup); ws.on('close', cleanup);
@@ -140,6 +170,8 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
return; return;
} }
if (opts.postAcquire) await opts.postAcquire(boxName);
// The exec create is a plain API call, but the attach is NOT dockerode's // The exec create is a plain API call, but the attach is NOT dockerode's
// `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http // `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http
// client `'upgrade'` event, which bun never emits, so under bun it hangs // client `'upgrade'` event, which bun never emits, so under bun it hangs
@@ -157,7 +189,8 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
AttachStdout: true, AttachStdout: true,
AttachStderr: true, AttachStderr: true,
Tty: true, Tty: true,
Cmd: SHELL_CMD, User: opts.user,
Cmd: buildTerminalShellCommand(marker),
Env: ['TERM=xterm-256color', ...opts.envFlags], Env: ['TERM=xterm-256color', ...opts.envFlags],
WorkingDir: opts.cwd, WorkingDir: opts.cwd,
}); });
@@ -214,6 +247,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
workdir: workspace.workdir, workdir: workspace.workdir,
containerHome: workspace.containerHome, containerHome: workspace.containerHome,
}), }),
user: linuxUsername(workspace.username),
cwd: workspace.containerRepo, cwd: workspace.containerRepo,
envFlags: [ envFlags: [
`HOME=${workspace.containerHome}`, `HOME=${workspace.containerHome}`,
@@ -228,8 +262,16 @@ const bridgeBox = async (ws: WebSocket, username: string) => {
const { homeDir, containerHome } = boxHomePaths(username); const { homeDir, containerHome } = boxHomePaths(username);
await runShellBridge(ws, { await runShellBridge(ws, {
prepare: () => ensureBashProfile(homeDir), prepare: () => ensureBashProfile(homeDir),
// prepare's host-side .bash_profile write may be root-owned under prod
// docker; repair once the container is known to exist.
postAcquire: (boxName) =>
chownInBox({
containerName: boxName,
paths: [`${containerHome}/.bash_profile`],
}),
acquire: () => acquire: () =>
acquireUserBox({ username, workdir: homeDir, containerHome }), acquireUserBox({ username, workdir: homeDir, containerHome }),
user: linuxUsername(username),
cwd: containerHome, cwd: containerHome,
envFlags: [`HOME=${containerHome}`], envFlags: [`HOME=${containerHome}`],
}); });
+6 -1
View File
@@ -1,3 +1,4 @@
import { initializeBoxUser } from './box-init';
import { env } from './env'; import { env } from './env';
import { import {
ensureUserContainer, ensureUserContainer,
@@ -92,7 +93,11 @@ export const acquireUserBox = (args: {
// cached "initialized" flag then hands out handles to a dead container // cached "initialized" flag then hands out handles to a dead container
// until restart. ensureUserContainer is idempotent — an inspect when the // until restart. ensureUserContainer is idempotent — an inspect when the
// box is running, a recreate when it isn't. // box is running, a recreate when it isn't.
box.name = await ensureUserContainer(args); box.name = await ensureUserContainer({
...args,
onCreated: (containerName) =>
initializeBoxUser(containerName, args.username, args.containerHome),
});
return handle; return handle;
} catch (error) { } catch (error) {
handle.release(); handle.release();
+10 -1
View File
@@ -8,7 +8,8 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env'; import { env } from './env';
import { assertContainedRealPath } from './path-containment'; import { assertContainedRealPath } from './path-containment';
import { runExecInContainer } from './runtime/docker'; import { linuxUsername } from './runtime/box-user';
import { chownInBox, runExecInContainer } from './runtime/docker';
const client = new ConvexHttpClient(env.convexUrl); const client = new ConvexHttpClient(env.convexUrl);
@@ -102,6 +103,9 @@ export const materializeUserHome = async (args: {
environment: { HOME: containerHome }, environment: { HOME: containerHome },
redact, redact,
timeoutMs: env.jobTimeoutMs, timeoutMs: env.jobTimeoutMs,
// The clone and setup command run as the box user so ~/.dotfiles and
// anything the setup writes stay editable from the terminal.
user: linuxUsername(userEnv.username),
}); });
await mkdir(path.dirname(markerPath), { recursive: true }); await mkdir(path.dirname(markerPath), { recursive: true });
await writeFile(markerPath, configHash); await writeFile(markerPath, configHash);
@@ -109,6 +113,7 @@ export const materializeUserHome = async (args: {
} }
// Editable overlay tree (wins over the repo/setup output). // Editable overlay tree (wins over the repo/setup output).
const writtenContainerPaths: string[] = [];
for (const file of userEnv.files) { for (const file of userEnv.files) {
const target = await assertContainedRealPath(homeDir, file.path, { const target = await assertContainedRealPath(homeDir, file.path, {
forWrite: true, forWrite: true,
@@ -116,5 +121,9 @@ export const materializeUserHome = async (args: {
await mkdir(path.dirname(target), { recursive: true }); await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content); await writeFile(target, file.content);
if (file.isExecutable) await chmod(target, 0o755); if (file.isExecutable) await chmod(target, 0o755);
writtenContainerPaths.push(path.posix.join(containerHome, file.path));
} }
// Overlay files are written host-side — repair ownership so the box user
// can edit them in a shell (no-op under dev keep-id).
await chownInBox({ containerName: boxName, paths: writtenContainerPaths });
}; };
+20 -2
View File
@@ -14,15 +14,17 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events'; import type { NormalizedAgentEvent } from './agent-events';
import type { AgentRuntimeName } from './runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { AdapterWorkspace, Claim } from './runtime/provider';
import type { BoxHandle } from './user-container'; import type { BoxHandle } from './user-container';
import type { AgentRuntimeName } from './runtime/agent-runtime';
import { getAdapter } from './runtime/agent-runtime'; import { getAdapter } from './runtime/agent-runtime';
import { import {
resolveTurnOutcome, resolveTurnOutcome,
shouldAppendCompletedContent, shouldAppendCompletedContent,
} from './runtime/turn-outcome'; } from './runtime/turn-outcome';
import './runtime/register'; import './runtime/register';
import { env } from './env'; import { env } from './env';
import { import {
cloneRepository, cloneRepository,
@@ -36,7 +38,9 @@ import { createHeartbeatController } from './heartbeat-controller';
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env'; import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
import { assertContainedRealPath } from './path-containment'; import { assertContainedRealPath } from './path-containment';
import { createRedactor, truncate } from './redact'; import { createRedactor, truncate } from './redact';
import { linuxUsername } from './runtime/box-user';
import { import {
chownInBox,
listWorkspaceContainerNames, listWorkspaceContainerNames,
runExecInContainer, runExecInContainer,
} from './runtime/docker'; } from './runtime/docker';
@@ -576,6 +580,7 @@ const runProjectCommand = async (args: {
phase: 'install' | 'check' | 'test'; phase: 'install' | 'check' | 'test';
claim: Claim; claim: Claim;
boxName: string; boxName: string;
username: string;
containerHome: string; containerHome: string;
containerCwd: string; containerCwd: string;
repoDir: string; repoDir: string;
@@ -594,6 +599,7 @@ const runProjectCommand = async (args: {
environment: { HOME: args.containerHome, ...secretEnv }, environment: { HOME: args.containerHome, ...secretEnv },
redact: args.redact, redact: args.redact,
timeoutMs: env.jobTimeoutMs, timeoutMs: env.jobTimeoutMs,
user: linuxUsername(args.username),
}) })
: await run('bash', ['-lc', args.command], { : await run('bash', ['-lc', args.command], {
cwd: args.repoDir, cwd: args.repoDir,
@@ -728,6 +734,12 @@ const materializeEnvFile = async (workspace: ActiveWorkspace) => {
claim.job.envFilePath, claim.job.envFilePath,
claim.secrets, claim.secrets,
); );
// Host-side 0600 write — the agent now runs as the box user, which must be
// able to read it. No-op under dev keep-id.
await chownInBox({
containerName: workspace.boxName,
paths: [path.posix.join(workspace.containerRepo, claim.job.envFilePath)],
});
await appendEvent( await appendEvent(
claim.job._id, claim.job._id,
'info', 'info',
@@ -923,6 +935,9 @@ const runClaim = async (claim: Claim) => {
redact, redact,
timeoutMs: env.jobTimeoutMs, timeoutMs: env.jobTimeoutMs,
}); });
// The clone happens host-side; hand it to the box user so the agent (and
// the user's shell) can write to it. No-op under dev keep-id.
await chownInBox({ containerName: boxName, paths: [containerRepo] });
const workspace: ActiveWorkspace = { const workspace: ActiveWorkspace = {
claim, claim,
workdir: homeDir, workdir: homeDir,
@@ -1116,6 +1131,7 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
phase: command.includes('test') ? 'test' : 'check', phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim, claim: workspace.claim,
boxName: workspace.boxName, boxName: workspace.boxName,
username: workspace.username,
containerHome: workspace.containerHome, containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo, containerCwd: workspace.containerRepo,
repoDir: workspace.repoDir, repoDir: workspace.repoDir,
@@ -1183,7 +1199,9 @@ export const replyToInteraction = async (
) => { ) => {
// Every runtime now runs its CLI non-interactively inside the box, so no // Every runtime now runs its CLI non-interactively inside the box, so no
// runtime emits an interaction request that expects a reply. // runtime emits an interaction request that expects a reply.
throw new Error('Interactive replies are not supported by exec-based runtimes.'); throw new Error(
'Interactive replies are not supported by exec-based runtimes.',
);
}; };
export const sendWorkspaceMessage = async ( export const sendWorkspaceMessage = async (
@@ -13,12 +13,13 @@ const load = async () => {
describe('boxRoute', () => { describe('boxRoute', () => {
afterEach(() => vi.resetModules()); afterEach(() => vi.resetModules());
test('matches the four box actions', async () => { test('matches the five box actions', async () => {
const { boxRoute } = await load(); const { boxRoute } = await load();
expect(boxRoute('/box/status')).toEqual({ action: 'status' }); expect(boxRoute('/box/status')).toEqual({ action: 'status' });
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' }); expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' }); expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
expect(boxRoute('/box/file')).toEqual({ action: 'file' }); expect(boxRoute('/box/file')).toEqual({ action: 'file' });
expect(boxRoute('/box/set-password')).toEqual({ action: 'set-password' });
}); });
test('rejects unknown, nested, and trailing paths', async () => { test('rejects unknown, nested, and trailing paths', async () => {
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
buildBoxInitScript,
buildClearPasswordCommand,
buildSudoPolicyScript,
linuxUsername,
} from '../../src/runtime/box-user';
describe('linuxUsername', () => {
it('lowercases and passes through simple names', () => {
expect(linuxUsername('gabriel')).toBe('gabriel');
expect(linuxUsername('Gabriel')).toBe('gabriel');
});
it('replaces invalid characters with dashes', () => {
expect(linuxUsername('g@b r!el')).toBe('g-b-r-el');
});
it('prefixes names that do not start with [a-z_]', () => {
expect(linuxUsername('1337')).toBe('u1337');
expect(linuxUsername('-dash')).toBe('u-dash');
});
it('truncates to 32 chars and falls back to spoon', () => {
expect(linuxUsername('a'.repeat(40))).toHaveLength(32);
expect(linuxUsername('')).toBe('spoon');
expect(linuxUsername('@@@')).toBe('u---');
});
});
describe('buildBoxInitScript', () => {
const script = buildBoxInitScript({
username: 'gabriel',
home: '/home/Gabriel',
});
it('renames an existing uid-1000 entry instead of duplicating it', () => {
// podman --userns=keep-id injects a passwd entry named after the HOST user
// at the mapped uid; a second uid-1000 entry would win the getpwuid lookup
// and make whoami report the host username.
expect(script).toContain('getent passwd 1000');
expect(script).toContain("usermod -l 'gabriel'");
});
it('creates the user with uid 1000 at the given home when none exists', () => {
expect(script).toContain('useradd');
expect(script).toContain('-u 1000');
expect(script).toContain("-d '/home/Gabriel'");
expect(script).toContain('-s /bin/bash');
});
it('adds wheel membership', () => {
expect(script).toContain("usermod -aG wheel 'gabriel'");
});
it('chowns the home once, guarded by the marker', () => {
expect(script).toContain('/home/Gabriel/.spoon/chown-v1');
expect(script).toContain("chown -R 1000:1000 '/home/Gabriel'");
});
});
describe('buildSudoPolicyScript', () => {
it('installs a visudo-validated NOPASSWD drop-in when no password is set', () => {
const script = buildSudoPolicyScript({
username: 'gabriel',
passwordSet: false,
});
expect(script).toContain('gabriel ALL=(ALL) NOPASSWD:ALL');
expect(script).toContain('visudo -c');
expect(script).toContain('/etc/sudoers.d/spoon-box');
expect(script).toContain('chmod 0440');
});
it('removes the drop-in when a password is set', () => {
const script = buildSudoPolicyScript({
username: 'gabriel',
passwordSet: true,
});
expect(script).toContain('rm -f /etc/sudoers.d/spoon-box');
expect(script).not.toContain('NOPASSWD');
});
});
describe('password commands', () => {
it('clears via passwd -d', () => {
expect(buildClearPasswordCommand('gabriel')).toEqual([
'passwd',
'-d',
'gabriel',
]);
});
});
@@ -1,7 +1,6 @@
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events'; import type { NormalizedAgentEvent } from '../../src/agent-events';
@@ -98,7 +97,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
tempDirs.push(homeDir); tempDirs.push(homeDir);
const adapter = createClaudeAdapter(); const adapter = createClaudeAdapter();
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json')); await adapter.prepareAuth(
makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'),
);
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
const contents = await readFile(credentialsPath, 'utf8'); const contents = await readFile(credentialsPath, 'utf8');
@@ -111,7 +112,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
tempDirs.push(homeDir); tempDirs.push(homeDir);
const adapter = createClaudeAdapter(); const adapter = createClaudeAdapter();
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json')); await adapter.prepareAuth(
makeOAuthWorkspace(homeDir, 'opencode_auth_json'),
);
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
await expect(stat(credentialsPath)).rejects.toThrow(); await expect(stat(credentialsPath)).rejects.toThrow();
@@ -174,4 +177,16 @@ describe('ClaudeCodeAdapter', () => {
expect(command[3]).toContain("'claude' '-p'"); expect(command[3]).toContain("'claude' '-p'");
expect(command[3]).toContain("'--output-format' 'stream-json'"); expect(command[3]).toContain("'--output-format' 'stream-json'");
}); });
test('execs the turn as the box user, not root', async () => {
const { createClaudeAdapter } = await loadAdapter();
let capturedUser: string | undefined;
const execStream = vi.fn((args: { user?: string }) => {
capturedUser = args.user;
return Promise.resolve({ exitCode: 0, output: '' });
}) as unknown as ExecStreamFn;
const adapter = createClaudeAdapter({ execStream });
await adapter.runTurn(makeWorkspace(), 'do it', () => Promise.resolve());
expect(capturedUser).toBe('tester');
});
}); });
@@ -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
@@ -44,3 +44,18 @@ describe('parseResizeMessage', () => {
expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 }); expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 });
}); });
}); });
describe('buildTerminalShellCommand', () => {
test('embeds the connection marker without exec-ing the shell away', async () => {
const { buildTerminalShellCommand } = await load();
const command = buildTerminalShellCommand('spoon-term-abc-123');
expect(command[0]).toBe('/bin/bash');
const script = command[2] ?? '';
// Marker must stay in the wrapper's argv for pgrep-based cleanup, so the
// inner shell/tmux must NOT be exec'd (exec would replace the argv).
expect(script).toContain('# spoon-term-abc-123');
expect(script).not.toContain('exec tmux');
expect(script).not.toContain('exec bash');
expect(script).toContain('tmux new-session -A -s spoon');
});
});
@@ -22,6 +22,20 @@ describe('box registry', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
test('passes an onCreated hook so a fresh box gets its user initialized', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
await module.acquireUserBox({
username: 'ada',
workdir: '/w',
containerHome: '/home/ada',
});
const call = vi.mocked(docker.ensureUserContainer).mock.calls[0]?.[0] as {
onCreated?: unknown;
};
expect(call.onCreated).toBeTypeOf('function');
});
test('re-ensures the container on every acquire so an externally removed box self-heals', async () => { test('re-ensures the container on every acquire so an externally removed box self-heals', async () => {
const module = await load(); const module = await load();
const docker = await import('../../src/runtime/docker'); const docker = await import('../../src/runtime/docker');
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
import { fetchAction } from 'convex/nextjs';
import { api } from '@spoon/backend/convex/_generated/api.js';
// Set (or clear, with null/'') the box user's password. Convex is the source
// of truth and is written FIRST (box creation re-applies from it); the worker
// call then applies it to the live container. A worker failure downgrades to
// applied:false — the password still takes effect on the next box restart.
export const POST = async (request: Request) =>
await withBox(async (username) => {
const body = (await request.json().catch(() => ({}))) as {
password?: string | null;
};
const password =
typeof body.password === 'string' && body.password.length > 0
? body.password
: null;
const token = await convexAuthNextjsToken();
const stored = await fetchAction(
api.boxSettingsNode.setBoxPassword,
{ password },
{ token },
);
let applied = false;
try {
const workerResponse = await proxyBox(username, 'set-password', {
method: 'POST',
body: JSON.stringify({ password }),
});
if (workerResponse.ok) {
applied = ((await workerResponse.json()) as { applied?: boolean })
.applied
? true
: false;
}
} catch {
// Stored but not live-applied; init picks it up at the next creation.
}
return NextResponse.json({ hasPassword: stored.hasPassword, applied });
});
@@ -0,0 +1,187 @@
'use client';
import { useState } from 'react';
import { useQuery } from 'convex/react';
import { KeyRound, Loader2, UserRound } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Input,
} from '@spoon/ui';
// Mirrors the worker's linuxUsername (runtime/box-user.ts) so the identity
// line shows the actual passwd entry.
const linuxUsername = (spoonUsername: string): string => {
const cleaned = spoonUsername
.toLowerCase()
.replaceAll(/[^a-z0-9_-]/g, '-')
.slice(0, 32);
if (!cleaned) return 'spoon';
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
};
/**
* The box's Linux account: identity (user@host) and the user-settable
* password. Password is write-only — the UI only ever knows whether one is
* set. Saving stores it (encrypted) and applies it to a running box live;
* clearing reverts sudo to passwordless.
*/
export const BoxUserCard = ({ username }: { username: string }) => {
const passwordState = useQuery(api.boxSettings.hasMine, {});
const hasPassword = passwordState?.hasPassword ?? false;
const user = linuxUsername(username || 'spoon');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [saving, setSaving] = useState(false);
const [pendingRestart, setPendingRestart] = useState(false);
const tooShort = password.length > 0 && password.length < 8;
const mismatch = confirm.length > 0 && confirm !== password;
const canSave =
!saving && password.length >= 8 && password.length <= 128 && !mismatch;
const submit = (next: string | null) => {
setSaving(true);
void (async () => {
try {
const response = await fetch('/api/box/set-password', {
method: 'POST',
body: JSON.stringify({ password: next }),
});
if (!response.ok) throw new Error(await response.text());
const data = (await response.json()) as {
hasPassword: boolean;
applied: boolean;
};
setPassword('');
setConfirm('');
setPendingRestart(!data.applied);
toast.success(
next === null ? 'Password removed.' : 'Password updated.',
);
} catch (error) {
console.error(error);
toast.error('Could not update the box password.');
} finally {
setSaving(false);
}
})();
};
return (
<Card>
<CardHeader>
<CardTitle className='flex items-center gap-2 text-base'>
<UserRound className='size-4' />
Box user
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
<div className='flex flex-wrap items-center gap-x-6 gap-y-2'>
<div>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Identity
</p>
<p className='font-mono text-sm'>
{user}@{user}-box
</p>
</div>
<div>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Password
</p>
<p className='text-sm'>{hasPassword ? 'Set' : 'Not set'}</p>
</div>
</div>
<p className='text-muted-foreground text-xs'>
Without a password, <code className='font-mono'>sudo</code> works
without prompting. Once you set one, sudo asks for it like a normal
machine.
</p>
<form
className='flex flex-wrap items-end gap-2'
onSubmit={(event) => {
event.preventDefault();
if (canSave) submit(password);
}}
>
<div className='space-y-1'>
<label
className='text-muted-foreground text-xs'
htmlFor='box-password'
>
New password
</label>
<Input
id='box-password'
type='password'
autoComplete='new-password'
value={password}
onChange={(event) => setPassword(event.target.value)}
className='w-56'
/>
</div>
<div className='space-y-1'>
<label
className='text-muted-foreground text-xs'
htmlFor='box-password-confirm'
>
Confirm
</label>
<Input
id='box-password-confirm'
type='password'
autoComplete='new-password'
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
className='w-56'
/>
</div>
<Button type='submit' size='sm' disabled={!canSave}>
{saving ? (
<Loader2 className='size-4 animate-spin' />
) : (
<KeyRound className='size-4' />
)}
{hasPassword ? 'Change password' : 'Set password'}
</Button>
{hasPassword ? (
<Button
type='button'
variant='outline'
size='sm'
disabled={saving}
onClick={() => submit(null)}
>
Remove password
</Button>
) : null}
</form>
{tooShort ? (
<p className='text-destructive text-xs'>
Password must be at least 8 characters.
</p>
) : null}
{mismatch ? (
<p className='text-destructive text-xs'>Passwords do not match.</p>
) : null}
{pendingRestart ? (
<p className='text-muted-foreground text-xs'>
Saved your machine is not running, so it takes effect the next
time it starts.
</p>
) : null}
</CardContent>
</Card>
);
};
@@ -18,6 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
import type { BoxStatus, LifecycleAction } from './box-status-card'; import type { BoxStatus, LifecycleAction } from './box-status-card';
import { BoxStatusCard } from './box-status-card'; import { BoxStatusCard } from './box-status-card';
import { BoxUserCard } from './box-user-card';
const STATUS_POLL_MS = 10_000; const STATUS_POLL_MS = 10_000;
@@ -261,6 +262,8 @@ export const MachineShell = () => {
onAction={runLifecycle} onAction={runLifecycle}
/> />
<BoxUserCard username={username} />
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')} onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
+1 -1
View File
@@ -181,7 +181,7 @@ export const resolveBoxUsername = async (): Promise<
// ever operates on the caller's own box. // ever operates on the caller's own box.
export const proxyBox = async ( export const proxyBox = async (
username: string, username: string,
action: 'status' | 'lifecycle' | 'tree' | 'file', action: 'status' | 'lifecycle' | 'tree' | 'file' | 'set-password',
init?: RequestInit, init?: RequestInit,
search?: URLSearchParams, search?: URLSearchParams,
) => { ) => {
@@ -0,0 +1,86 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BoxUserCard } from '../../src/components/machine/box-user-card';
const { mockUseQuery, fetchSpy } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
fetchSpy: vi.fn(),
}));
vi.mock('convex/react', () => ({
useQuery: mockUseQuery,
}));
vi.mock('sonner', () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
describe('BoxUserCard', () => {
beforeEach(() => {
vi.stubGlobal('fetch', fetchSpy);
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ hasPassword: true, applied: true })),
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it('shows the identity line and not-set state', () => {
mockUseQuery.mockReturnValue({ hasPassword: false });
render(<BoxUserCard username='Gabriel' />);
expect(screen.getByText('gabriel@gabriel-box')).toBeInTheDocument();
expect(screen.getByText('Not set')).toBeInTheDocument();
expect(screen.getByText('Set password')).toBeInTheDocument();
expect(screen.queryByText('Remove password')).not.toBeInTheDocument();
});
it('offers change/remove when a password is set', () => {
mockUseQuery.mockReturnValue({ hasPassword: true });
render(<BoxUserCard username='gabriel' />);
expect(screen.getByText('Set')).toBeInTheDocument();
expect(screen.getByText('Change password')).toBeInTheDocument();
expect(screen.getByText('Remove password')).toBeInTheDocument();
});
it('blocks submit on mismatched confirmation', () => {
mockUseQuery.mockReturnValue({ hasPassword: false });
render(<BoxUserCard username='gabriel' />);
fireEvent.change(screen.getByLabelText('New password'), {
target: { value: 'longenough' },
});
fireEvent.change(screen.getByLabelText('Confirm'), {
target: { value: 'different1' },
});
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
expect(screen.getByText('Set password').closest('button')!.disabled).toBe(
true,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('posts the password and shows the restart note when not applied live', async () => {
mockUseQuery.mockReturnValue({ hasPassword: false });
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ hasPassword: true, applied: false })),
);
render(<BoxUserCard username='gabriel' />);
fireEvent.change(screen.getByLabelText('New password'), {
target: { value: 'longenough' },
});
fireEvent.change(screen.getByLabelText('Confirm'), {
target: { value: 'longenough' },
});
fireEvent.click(screen.getByText('Set password'));
expect(
await screen.findByText(/takes effect the next time it starts/),
).toBeInTheDocument();
expect(fetchSpy).toHaveBeenCalledWith(
'/api/box/set-password',
expect.objectContaining({ method: 'POST' }),
);
});
});
+10
View File
@@ -50,6 +50,16 @@ RUN dnf install -y --setopt=install_weak_deps=False --nodocs \
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 @anthropic-ai/claude-code@2.1.207 \ RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 @anthropic-ai/claude-code@2.1.207 \
&& npm cache clean --force && npm cache clean --force
# nvm, system-wide: user dotfiles source /etc/profile.d/nvm.sh (workstation
# parity), and NVM_DIR must be writable by the box user (uid 1000) so
# `nvm install` works from the shell. PROFILE=/dev/null stops the installer
# from editing root's rc files.
ENV NVM_DIR=/usr/local/nvm
RUN mkdir -p "$NVM_DIR" \
&& wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | PROFILE=/dev/null bash \
&& printf 'export NVM_DIR=/usr/local/nvm\n[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"\n[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"\n' > /etc/profile.d/nvm.sh \
&& chown -R 1000:1000 "$NVM_DIR"
# oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme). # oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme).
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \ RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
&& oh-my-posh version && oh-my-posh version
+25
View File
@@ -108,6 +108,31 @@ Docker mode. Prod (Docker socket mounted) works as-is.
is intended for the single-user self-hosted deployment; do not expose the is intended for the single-user self-hosted deployment; do not expose the
worker domain without TLS, and keep the deployment single-tenant. worker domain without TLS, and keep the deployment single-tenant.
## The box user
Terminals and agent turns run as a named non-root user (uid 1000), not root —
Claude Code refuses some operations as root, and a real machine has a named
account. The Linux username mirrors your Spoon username (lowercased/sanitized;
the passwd entry may differ from the home folder name), the hostname is
`<user>-box`, and the account is in `wheel`.
**sudo & password:** with no password set, sudo works without prompting (a
NOPASSWD drop-in at `/etc/sudoers.d/spoon-box`). Set a password from the
**/machine → Box user** card: it is stored encrypted in Convex, applied live to
a running box (`chpasswd` over stdin via `POST /box/set-password`), and
re-applied at every box creation — at which point the NOPASSWD drop-in is
removed and wheel membership makes sudo prompt. Clearing the password reverts
to passwordless sudo. Passwords are 8-128 chars and write-only (the UI only
ever learns whether one is set).
**File ownership:** the worker writes into homes from the host (clones,
dotfiles, the file editor). Under dev rootless podman the box runs with
`--userns=keep-id:uid=1000,gid=1000` so the host user IS the box user and
ownership just works (`SPOON_AGENT_BOX_USERNS` overrides; empty disables).
Under prod rootful docker the worker repairs ownership with targeted
`chown -R 1000:1000` execs after each host-side write, plus a one-time
marker-guarded (`~/.spoon/chown-v1`) home chown at first box creation.
## Tools in the shell ## Tools in the shell
The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`, The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
+30
View File
@@ -0,0 +1,30 @@
// Pure validation for the box user's password, shared by the Node action and
// unit tests. Kept free of Convex imports so it tests without the harness.
export type BoxPasswordValidation =
| { ok: true; value: string | null }
| { ok: false; error: string };
// null/'' is an explicit clear (sudo reverts to NOPASSWD). The applied value
// travels to `chpasswd` as a `user:password` stdin line, so control characters
// (including newline/tab) are rejected; colons are fine — chpasswd splits on
// the first colon only.
export const normalizeBoxPassword = (
password: string | null | undefined,
): BoxPasswordValidation => {
if (password == null || password === '') return { ok: true, value: null };
if (password.length < 8) {
return { ok: false, error: 'Password must be at least 8 characters.' };
}
if (password.length > 128) {
return { ok: false, error: 'Password must be at most 128 characters.' };
}
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
if (/[\u0000-\u001f\u007f]/.test(password)) {
return {
ok: false,
error: 'Password must not contain control characters.',
};
}
return { ok: true, value: password };
};
+77
View File
@@ -0,0 +1,77 @@
import { v } from 'convex/values';
import { internalMutation, internalQuery, query } from './_generated/server';
import { deriveHomeUsername, getRequiredUserId } from './model';
// Per-user box account settings. The password itself is write-only from the
// client's perspective: browsers only ever learn whether one is set; the
// plaintext flows exclusively to the worker via boxSettingsNode.
export const hasMine = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const row = await ctx.db
.query('boxSettings')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.unique();
return { hasPassword: Boolean(row?.boxPasswordEncrypted) };
},
});
// Resolve the authed caller's owner id + home username (same derivation as
// userEnvironment.getMine) for the Node action, which cannot touch ctx.db.
export const resolveOwnerInternal = internalQuery({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const [user, settings] = await Promise.all([
ctx.db.get(ownerId),
ctx.db
.query('userEnvironment')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.unique(),
]);
return {
ownerId,
username: settings?.homeUsername ?? deriveHomeUsername(user?.name),
};
},
});
// Worker-facing lookup at box creation. `.first()` (not `.unique()`): derived
// usernames are not guaranteed globally unique, and a throw here would brick
// box creation for both colliding users.
export const getByUsernameInternal = internalQuery({
args: { username: v.string() },
handler: async (ctx, args) =>
await ctx.db
.query('boxSettings')
.withIndex('by_username', (q) => q.eq('username', args.username))
.first(),
});
export const upsertMineInternal = internalMutation({
args: {
ownerId: v.id('users'),
username: v.string(),
boxPasswordEncrypted: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('boxSettings')
.withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId))
.unique();
const patch = {
username: args.username,
// Explicit undefined clears the field on patch (password removed).
boxPasswordEncrypted: args.boxPasswordEncrypted,
updatedAt: Date.now(),
};
if (existing) {
await ctx.db.patch(existing._id, patch);
} else {
await ctx.db.insert('boxSettings', { ownerId: args.ownerId, ...patch });
}
},
});
@@ -0,0 +1,45 @@
'use node';
import { ConvexError, v } from 'convex/values';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { normalizeBoxPassword } from './boxPassword';
import { decryptSecret, encryptSecret } from './secretCrypto';
import { assertWorkerToken } from './workerAuth';
/** Authed: store (or clear, with null/'') the caller's box password. */
export const setBoxPassword = action({
args: { password: v.union(v.string(), v.null()) },
handler: async (ctx, args): Promise<{ hasPassword: boolean }> => {
const normalized = normalizeBoxPassword(args.password);
if (!normalized.ok) throw new ConvexError(normalized.error);
const { ownerId, username } = await ctx.runQuery(
internal.boxSettings.resolveOwnerInternal,
{},
);
await ctx.runMutation(internal.boxSettings.upsertMineInternal, {
ownerId,
username,
boxPasswordEncrypted:
normalized.value == null ? undefined : encryptSecret(normalized.value),
});
return { hasPassword: normalized.value != null };
},
});
/** Worker-facing: the stored password (decrypted) for a box username. */
export const getBoxPasswordForWorker = action({
args: { workerToken: v.string(), username: v.string() },
handler: async (ctx, args): Promise<{ password: string | null }> => {
assertWorkerToken(args.workerToken);
const row = await ctx.runQuery(internal.boxSettings.getByUsernameInternal, {
username: args.username,
});
return {
password: row?.boxPasswordEncrypted
? decryptSecret(row.boxPasswordEncrypted)
: null,
};
},
});
+12
View File
@@ -376,6 +376,18 @@ const applicationTables = {
setupCommand: v.optional(v.string()), setupCommand: v.optional(v.string()),
updatedAt: v.number(), updatedAt: v.number(),
}).index('by_owner', ['ownerId']), }).index('by_owner', ['ownerId']),
// Per-user box account settings (the Linux user inside spoon-box-<username>).
boxSettings: defineTable({
ownerId: v.id('users'),
// Resolved Spoon home username at the time the row was written — the
// worker looks the password up by this at box creation (usernames are not
// indexed anywhere else).
username: v.string(),
boxPasswordEncrypted: v.optional(v.string()),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_username', ['username']),
aiProviderProfiles: defineTable({ aiProviderProfiles: defineTable({
ownerId: v.id('users'), ownerId: v.id('users'),
name: v.string(), name: v.string(),
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { normalizeBoxPassword } from '../../convex/boxPassword';
describe('normalizeBoxPassword', () => {
it('treats null/empty as an explicit clear', () => {
expect(normalizeBoxPassword(null)).toEqual({ ok: true, value: null });
expect(normalizeBoxPassword(undefined)).toEqual({ ok: true, value: null });
expect(normalizeBoxPassword('')).toEqual({ ok: true, value: null });
});
it('enforces the 8..128 length bounds', () => {
expect(normalizeBoxPassword('short')).toMatchObject({ ok: false });
expect(normalizeBoxPassword('a'.repeat(7))).toMatchObject({ ok: false });
expect(normalizeBoxPassword('a'.repeat(8))).toEqual({
ok: true,
value: 'a'.repeat(8),
});
expect(normalizeBoxPassword('a'.repeat(128))).toMatchObject({ ok: true });
expect(normalizeBoxPassword('a'.repeat(129))).toMatchObject({ ok: false });
});
it('rejects control characters that would corrupt the chpasswd stdin line', () => {
expect(normalizeBoxPassword('has\nnewline1')).toMatchObject({ ok: false });
expect(normalizeBoxPassword('has\ttab-char1')).toMatchObject({ ok: false });
});
it('allows colons and spaces (chpasswd splits on the first colon only)', () => {
expect(normalizeBoxPassword('pa:ss wo:rd')).toEqual({
ok: true,
value: 'pa:ss wo:rd',
});
});
});
+1
View File
@@ -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",