Compare commits

...
13 Commits
Author SHA1 Message Date
Gabriel Brown 919d185a85 Merge feat/box-user: non-root box user + settable password
Build and Push Spoon Images / build-images (push) Successful in 9m52s
Build and Push Spoon Images / quality (push) Successful in 1m57s
2026-07-13 11:38:29 -04:00
Gabriel Brown 4505471149 fix(worker): keep-id compat, zombie tmux clients, nvm in box image
- Box init + chownInBox exec explicitly as root: under podman keep-id the
  container's default user is the mapped uid, which cannot useradd/chown.
- Init renames an existing uid-1000 passwd entry (podman keep-id injects one
  named after the HOST user) instead of adding a duplicate that would win the
  getpwuid lookup and make whoami report the wrong name.
- Terminal connections tag their shell with a per-connection marker and reap
  the process group on WebSocket close: closing the attach socket never
  killed the exec'd shell, so every disconnect leaked an attached tmux client
  (session stuck at the smallest zombie's size; enough dead clients starve
  tmux rendering for live ones).
- Job image: nvm 0.39.5 at /usr/local/nvm (user-writable) with
  /etc/profile.d/nvm.sh, matching what user dotfiles expect; docs for the box
  user + ownership model.
2026-07-13 10:36:44 -04:00
Gabriel Brown 028fbebcd6 feat(next): box user card on /machine 2026-07-13 10:11:11 -04:00
Gabriel Brown 134bab93a5 feat(next): box password API 2026-07-13 10:08:48 -04:00
Gabriel Brown 78aae034b5 feat(worker): live box password apply route 2026-07-13 10:07:06 -04:00
Gabriel Brown 58bd101793 feat(worker): run terminals and agent turns as the box user 2026-07-13 10:04:14 -04:00
Gabriel Brown a956d63e16 feat(backend): boxSettings table + box password actions 2026-07-13 09:56:08 -04:00
Gabriel Brown 37a82098c2 feat(worker): exec-as-user, stdin input, chown + userns plumbing 2026-07-13 09:52:45 -04:00
Gabriel Brown 78cb3d90c2 feat(worker): box-user script builders + username sanitizer 2026-07-13 09:49:59 -04:00
Gabriel Brown a91c8551ac docs: box user implementation plan 2026-07-13 09:48:25 -04:00
Gabriel Brown aea2aa2c5c docs: box user design spec (non-root box + user-set password) 2026-07-12 22:42:32 -04:00
Gabriel Brown b038a31520 fix(box): bump job image base to Fedora 44 for Neovim 0.12
fedora:41 ships Neovim 0.10.4, below LazyVim's >= 0.11 requirement, so
dotfiles-managed editors failed inside the box. F44 ships 0.12.4.
2026-07-12 22:26:45 -04:00
Gabriel Brown a640b5cb94 fix(terminal): bun-safe exec attach, box self-heal, surfaced errors
The workspace terminal died everywhere: bun's node:http client never
emits the client 'upgrade' event (it surfaces the 101 as a plain
response), so docker-modem's exec.start({hijack:true}) waited forever —
a connected WebSocket with no bytes flowing (prod), or an instant close
when the socket was unreachable (dev). The pre-Phase-1 bridge avoided
dockerode attach for exactly this reason; the real-TTY-resize rewrite
reintroduced it.

- runtime/exec-stream.ts: speak the exec-start HTTP upgrade over a raw
  net socket (docker and podman both answer 101 UPGRADED). Works under
  bun and node; honors the modem's socketPath/host:port. The stream is
  returned paused so the first screen paint can't race the caller's
  data handler. Exec create/resize stay on dockerode (plain POSTs).
- user-container.ts: acquireUserBox re-ensures the container on every
  acquire instead of trusting a cached "initialized" flag, so a box
  removed behind the worker's back (manual rm, another instance's
  reaper) is recreated instead of bricking terminals and agent turns
  until restart.
- terminal.ts: bridge failures are logged and close the WebSocket with
  a clamped human-readable reason instead of a silent catch;
  xterm-session.tsx displays that reason instead of a mute
  "Session ended". Also roomier terminal line spacing (1.2 -> 1.35).
2026-07-12 22:14:37 -04:00
38 changed files with 2041 additions and 81 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 { applyBoxPassword, initializeBoxUser } from './box-init';
import { env } from './env';
import { assertContainedRealPath } from './path-containment';
import {
chownInBox,
ensureUserContainer,
inspectUserBoxStatus,
stopWorkspaceContainer,
@@ -41,7 +43,25 @@ export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
export const startBox = async (username: string): Promise<void> => {
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> => {
@@ -145,9 +165,17 @@ export const writeBoxFile = async (
relPath: string,
content: string,
): Promise<{ success: true }> => {
const { homeDir } = boxHomePaths(username);
const { homeDir, containerHome } = boxHomePaths(username);
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
await mkdir(path.dirname(target), { recursive: true });
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 };
};
+4
View File
@@ -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',
+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 path from 'node:path';
import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeClaudeJsonLine } from '../agent-events';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { writeJsonFile } from './auth';
import { linuxUsername } from './box-user';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
export const createClaudeAdapter: AdapterFactory = (deps) => {
@@ -23,7 +24,9 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
if (!isClaudeOAuthProfile(workspace.claim)) return;
const secret = workspace.claim.aiProviderProfile?.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(
workspace.homeDir,
@@ -84,6 +87,7 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
try {
result = await execStream({
containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
@@ -1,18 +1,19 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeCodexJsonLine } from '../agent-events';
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { linuxUsername } from './box-user';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import {
codexModelArgs,
isCodexLoginProfile,
@@ -142,6 +143,7 @@ export const createCodexAdapter: AdapterFactory = (deps) => {
try {
result = await execStream({
containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
+96 -19
View File
@@ -5,6 +5,7 @@ import Docker from 'dockerode';
import { execa } from 'execa';
import { env } from '../env';
import { BOX_UID, linuxUsername } from './box-user';
type CommandResult = {
exitCode: number;
@@ -26,6 +27,21 @@ const environmentArgs = (environment: Record<string, string>) =>
const networkArgs = () => (env.network ? ['--network', env.network] : []);
// Box user namespace mapping. Under rootless podman, keep-id maps the host
// user onto the box user's uid so host-side writes (clones, dotfiles, file
// editor) are already user-owned inside the box. Rootful docker has no
// equivalent — ownership is repaired with targeted chowns instead (see
// chownInBox). SPOON_AGENT_BOX_USERNS overrides: empty disables, any other
// value passes through.
export const boxUsernsArgs = (): string[] => {
if (env.boxUserns !== undefined) {
return env.boxUserns ? [`--userns=${env.boxUserns}`] : [];
}
return containerRuntime().endsWith('podman')
? [`--userns=keep-id:uid=${BOX_UID},gid=${BOX_UID}`]
: [];
};
const containerRuntime = () => env.containerRuntime;
// `docker run` reuses a stale local `:latest` forever, so without an explicit
@@ -173,6 +189,10 @@ export const ensureUserContainer = async (args: {
username: string;
workdir: string;
containerHome: string;
// Runs once per fresh container, after `run` succeeds — box-user init
// (useradd, sudo policy, password) hooks in here so every creation path
// (acquire, explicit start) sets the user up without duplicating the logic.
onCreated?: (containerName: string) => Promise<void>;
}): Promise<string> => {
await ensureJobImagePulled();
const name = userContainerName(args.username);
@@ -196,10 +216,13 @@ export const ensureUserContainer = async (args: {
'--init',
'--name',
name,
'--hostname',
`${linuxUsername(args.username)}-box`,
'--memory',
'4g',
'--cpus',
'2',
...boxUsernsArgs(),
...networkArgs(),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
@@ -211,6 +234,7 @@ export const ensureUserContainer = async (args: {
],
{ stdin: 'ignore' },
);
if (args.onCreated) await args.onCreated(name);
return name;
};
@@ -236,7 +260,12 @@ export const inspectUserBoxStatus = async (
{ reject: false, stdin: 'ignore' },
);
if (result.exitCode !== 0) {
return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
return {
running: false,
image: null,
startedAt: null,
memoryLimitBytes: null,
};
}
const [runningField, imageField, startedAtField, memoryField] = result.stdout
.trim()
@@ -335,6 +364,25 @@ export const killBoxProcessesByMarker = async (args: {
);
};
// Shared `<runtime> exec` argv. `user` runs the command as that container
// user instead of root — terminals and agent turns pass the box user; init,
// chown and kill paths stay root.
export const execArgv = (args: {
containerName: string;
command: string[];
environment: Record<string, string>;
containerCwd: string;
user?: string;
}): string[] => [
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
...(args.user ? ['-u', args.user] : []),
args.containerName,
...args.command,
];
export const streamExecInContainer = async (args: {
containerName: string;
command: string[];
@@ -342,21 +390,16 @@ export const streamExecInContainer = async (args: {
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
user?: string;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
const subprocess = execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
args.containerName,
...args.command,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
);
const subprocess = execa(containerRuntime(), execArgv(args), {
all: true,
reject: false,
stdin: 'ignore',
timeout: args.timeoutMs,
});
return streamSubprocess(
subprocess,
args.redact,
@@ -372,20 +415,54 @@ 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,
// Explicit root: under podman keep-id the container default user is the
// mapped uid, which cannot chown files it does not own.
'-u',
'root',
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) => {
@@ -0,0 +1,131 @@
import net from 'node:net';
import type { Duplex } from 'node:stream';
import { getDockerClient } from './docker';
// Hand-rolled replacement for docker-modem's hijacked `exec.start`. Bun's
// node:http client never emits the `'upgrade'` event (it surfaces the 101 as a
// plain response), so dockerode's `exec.start({ hijack: true })` waits forever
// under bun — the terminal connects but no bytes ever flow. Docker and podman
// both answer the exec-start upgrade with `101 UPGRADED` followed by the raw
// TTY byte stream on the same connection, so we speak that one exchange
// directly over a net socket and hand the socket to the caller.
export type UpgradeParse =
| { complete: false }
| { complete: true; statusCode: number; remainder: Buffer };
const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
// Parse an accumulating exec-start response buffer. Incomplete until the CRLF
// CRLF header terminator arrives; after it, `remainder` is the start of the raw
// stream (or the error body) and must be preserved byte-exact.
export const parseUpgradeResponse = (data: Buffer): UpgradeParse => {
const headerEnd = data.indexOf(HEADER_TERMINATOR);
if (headerEnd === -1) return { complete: false };
const statusLine =
data.subarray(0, headerEnd).toString('latin1').split('\r\n', 1)[0] ?? '';
const match = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
return {
complete: true,
statusCode: match?.[1] ? Number.parseInt(match[1], 10) : 0,
remainder: data.subarray(headerEnd + HEADER_TERMINATOR.length),
};
};
type ModemTarget = {
socketPath?: string;
host?: string;
port?: number | string;
protocol?: string;
};
/**
* Start a created exec and return the raw bidirectional TTY stream. Writes are
* stdin; reads are the terminal output (Tty:true, so no stdcopy framing). The
* socket is returned PAUSED: output that arrives while the caller is still
* wiring handlers stays buffered instead of being dropped (the first tmux
* screen paint often lands within the same tick as the upgrade). Callers must
* `stream.resume()` once their `data` handler is attached.
*/
export const openExecStream = (
execId: string,
{ timeoutMs = 15_000 }: { timeoutMs?: number } = {},
): Promise<Duplex> =>
new Promise((resolve, reject) => {
const modem = getDockerClient().modem as ModemTarget;
if (modem.protocol && modem.protocol !== 'http' && !modem.socketPath) {
reject(
new Error(
`Exec streaming supports unix sockets and plain tcp docker hosts; got protocol "${modem.protocol}".`,
),
);
return;
}
const socket = modem.socketPath
? net.connect({ path: modem.socketPath })
: net.connect({
host: modem.host ?? 'localhost',
port: Number(modem.port ?? 2375),
});
let buffered = Buffer.alloc(0);
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.destroy();
reject(error);
};
const timer = setTimeout(
() => fail(new Error(`Exec start timed out after ${timeoutMs}ms.`)),
timeoutMs,
);
socket.on('error', (error: Error) =>
fail(new Error(`Exec stream connection failed: ${error.message}`)),
);
socket.on('close', () =>
fail(
new Error(
`Exec stream closed before the upgrade completed${buffered.length ? `: ${buffered.toString('utf8').slice(0, 300)}` : '.'}`,
),
),
);
socket.on('connect', () => {
const body = JSON.stringify({ Detach: false, Tty: true });
socket.write(
`POST /exec/${encodeURIComponent(execId)}/start HTTP/1.1\r\n` +
'Host: docker\r\n' +
'Content-Type: application/json\r\n' +
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
'Connection: Upgrade\r\n' +
'Upgrade: tcp\r\n' +
'\r\n' +
body,
);
});
const onData = (chunk: Buffer) => {
buffered = Buffer.concat([buffered, chunk]);
const parsed = parseUpgradeResponse(buffered);
if (!parsed.complete) return;
if (parsed.statusCode !== 101 && parsed.statusCode !== 200) {
fail(
new Error(
`Exec start failed: HTTP ${parsed.statusCode || 'unparseable'} ${parsed.remainder.toString('utf8').slice(0, 300)}`.trim(),
),
);
return;
}
settled = true;
clearTimeout(timer);
socket.off('data', onData);
socket.pause();
if (parsed.remainder.length > 0) socket.unshift(parsed.remainder);
resolve(socket);
};
socket.on('data', onData);
});
@@ -1,17 +1,18 @@
import { randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { NormalizedAgentEvent } from '../agent-events';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import type { AdapterWorkspace } from './provider';
import { normalizeOpenCodeRunLine } from '../agent-events';
import { env } from '../env';
import type { AdapterFactory, TurnResult } from './agent-runtime';
import { linuxUsername } from './box-user';
import {
buildMarkedCommand,
killBoxProcessesByMarker,
streamExecInContainer,
} from './docker';
import type { AdapterWorkspace } from './provider';
import { opencodeModel, providerEnvironment } from './provider';
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
@@ -97,6 +98,7 @@ export const createOpenCodeAdapter: AdapterFactory = (deps) => {
try {
result = await execStream({
containerName: workspace.boxName,
user: linuxUsername(workspace.username),
containerCwd: workspace.containerRepo,
command,
environment: { ...aiEnv, ...secretEnv },
+23 -1
View File
@@ -8,6 +8,7 @@ import {
listBoxTree,
readBoxFile,
restartBox,
setBoxUserPassword,
startBox,
stopBox,
writeBoxFile,
@@ -66,7 +67,9 @@ const jobRoute = (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;
};
@@ -136,6 +139,25 @@ export const startWorkerServer = () => {
);
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' });
return;
}
+102 -15
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import type { Duplex } from 'node:stream';
import type { WebSocket } from 'ws';
@@ -6,7 +7,13 @@ import { WebSocketServer } from 'ws';
import type { BoxHandle } from './user-container';
import { boxHomePaths } from './box';
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 {
parseBoxTokenUsername,
verifyBoxTerminalToken,
@@ -43,12 +50,27 @@ export const parseResizeMessage = (
}
};
// Close with a reason the browser can display. The WebSocket close-frame
// payload caps the reason at 123 bytes — `ws` throws past that — so clamp.
const closeWithReason = (ws: WebSocket, code: number, reason: string) => {
ws.close(code, Buffer.from(reason, 'utf8').subarray(0, 120).toString('utf8'));
};
// 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.
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',
'-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 = {
@@ -57,6 +79,11 @@ type ShellBridgeOptions = {
acquire: () => Promise<BoxHandle>;
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
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).
cwd: string;
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
@@ -94,6 +121,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
else pendingInput.push(data);
});
const marker = `spoon-term-${randomUUID()}`;
const handleHolder: { current?: BoxHandle } = {};
let released = false;
// Read through a function so TS doesn't narrow `released` to a constant — the
@@ -104,6 +132,15 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
released = true;
execHolder.current?.stream.end();
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();
};
ws.on('close', cleanup);
@@ -118,7 +155,9 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
handleHolder.current = handle;
boxName = handle.boxName;
} catch (error) {
ws.close(
console.error('Terminal box acquire failed:', error);
closeWithReason(
ws,
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
@@ -131,18 +170,41 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
return;
}
if (opts.postAcquire) await opts.postAcquire(boxName);
// 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
// client `'upgrade'` event, which bun never emits, so under bun it hangs
// forever with a connected-but-dead terminal. `openExecStream` speaks the
// upgrade exchange over a raw socket instead (works under bun and node,
// against docker and podman). `exec.resize` is a plain POST and stays on
// dockerode.
let exec: import('dockerode').Exec;
let stream: Duplex;
try {
const docker = getDockerClient();
const container = docker.getContainer(boxName);
const exec = await container.exec({
exec = await container.exec({
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: SHELL_CMD,
User: opts.user,
Cmd: buildTerminalShellCommand(marker),
Env: ['TERM=xterm-256color', ...opts.envFlags],
WorkingDir: opts.cwd,
});
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
stream = await openExecStream(exec.id);
} catch (error) {
console.error('Terminal exec attach failed:', error);
closeWithReason(
ws,
1011,
`Failed to attach terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
cleanup();
return;
}
execHolder.current = { exec, stream };
if (isReleased()) {
@@ -153,10 +215,6 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
await exec.resize({ h: rows, w: cols }).catch(() => undefined);
// Replay any keystrokes the client sent before the process was ready.
for (const buffered of pendingInput) stream.write(buffered);
pendingInput.length = 0;
stream.on('data', (chunk: Buffer) => {
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
});
@@ -166,6 +224,12 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
stream.on('error', () => {
if (ws.readyState === ws.OPEN) ws.close();
});
// Replay any keystrokes the client sent before the process was ready, then
// start the (paused) stream now that the output handler is attached.
for (const buffered of pendingInput) stream.write(buffered);
pendingInput.length = 0;
stream.resume();
};
// Job terminal: opens the shell at the repo checkout, with the job's selected
@@ -183,6 +247,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
workdir: workspace.workdir,
containerHome: workspace.containerHome,
}),
user: linuxUsername(workspace.username),
cwd: workspace.containerRepo,
envFlags: [
`HOME=${workspace.containerHome}`,
@@ -197,8 +262,16 @@ const bridgeBox = async (ws: WebSocket, username: string) => {
const { homeDir, containerHome } = boxHomePaths(username);
await runShellBridge(ws, {
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: () =>
acquireUserBox({ username, workdir: homeDir, containerHome }),
user: linuxUsername(username),
cwd: containerHome,
envFlags: [`HOME=${containerHome}`],
});
@@ -231,8 +304,15 @@ export const attachTerminalServer = (server: Server) => {
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridgeBox(ws, username).catch(() => {
if (ws.readyState === ws.OPEN) ws.close();
void bridgeBox(ws, username).catch((error: unknown) => {
console.error(`Box terminal bridge failed (${username}):`, error);
if (ws.readyState === ws.OPEN) {
closeWithReason(
ws,
1011,
`Terminal bridge failed: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
});
});
return;
@@ -250,8 +330,15 @@ export const attachTerminalServer = (server: Server) => {
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
void bridge(ws, jobId).catch(() => {
if (ws.readyState === ws.OPEN) ws.close();
void bridge(ws, jobId).catch((error: unknown) => {
console.error(`Job terminal bridge failed (${jobId}):`, error);
if (ws.readyState === ws.OPEN) {
closeWithReason(
ws,
1011,
`Terminal bridge failed: ${error instanceof Error ? error.message : 'unknown error'}`,
);
}
});
});
});
+13 -7
View File
@@ -1,3 +1,4 @@
import { initializeBoxUser } from './box-init';
import { env } from './env';
import {
ensureUserContainer,
@@ -13,7 +14,6 @@ export type BoxHandle = { boxName: string; release: () => void };
type Box = {
name: string;
initialized: boolean;
refs: Set<symbol>;
idleTimer?: ReturnType<typeof setTimeout>;
};
@@ -79,7 +79,6 @@ export const acquireUserBox = (args: {
if (!box) {
box = {
name: userContainerName(args.username),
initialized: false,
refs: new Set(),
};
boxes.set(args.username, box);
@@ -88,10 +87,17 @@ export const acquireUserBox = (args: {
// release it without leaking registry state.
const handle = makeHandle(args.username, box);
try {
if (!box.initialized) {
box.name = await ensureUserContainer(args);
box.initialized = true;
}
// Re-verify with the container runtime on EVERY acquire, not just the
// first: the registry goes stale when the box is removed behind the
// worker's back (manual `docker rm`, another worker's idle reaper), and a
// cached "initialized" flag then hands out handles to a dead container
// until restart. ensureUserContainer is idempotent — an inspect when the
// box is running, a recreate when it isn't.
box.name = await ensureUserContainer({
...args,
onCreated: (containerName) =>
initializeBoxUser(containerName, args.username, args.containerHome),
});
return handle;
} catch (error) {
handle.release();
@@ -109,7 +115,7 @@ export const reconcileExistingBoxes = async (): Promise<void> => {
for (const name of names) {
const username = name.replace(/^spoon-box-/, '');
if (boxes.has(username)) continue;
const box: Box = { name, initialized: false, refs: new Set() };
const box: Box = { name, refs: new Set() };
boxes.set(username, box);
scheduleReapIfIdle(username);
}
+10 -1
View File
@@ -8,7 +8,8 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import { env } from './env';
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);
@@ -102,6 +103,9 @@ export const materializeUserHome = async (args: {
environment: { HOME: containerHome },
redact,
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 writeFile(markerPath, configHash);
@@ -109,6 +113,7 @@ export const materializeUserHome = async (args: {
}
// Editable overlay tree (wins over the repo/setup output).
const writtenContainerPaths: string[] = [];
for (const file of userEnv.files) {
const target = await assertContainedRealPath(homeDir, file.path, {
forWrite: true,
@@ -116,5 +121,9 @@ export const materializeUserHome = async (args: {
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, file.content);
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 type { NormalizedAgentEvent } from './agent-events';
import type { AgentRuntimeName } from './runtime/agent-runtime';
import type { AdapterWorkspace, Claim } from './runtime/provider';
import type { BoxHandle } from './user-container';
import type { AgentRuntimeName } from './runtime/agent-runtime';
import { getAdapter } from './runtime/agent-runtime';
import {
resolveTurnOutcome,
shouldAppendCompletedContent,
} from './runtime/turn-outcome';
import './runtime/register';
import { env } from './env';
import {
cloneRepository,
@@ -36,7 +38,9 @@ import { createHeartbeatController } from './heartbeat-controller';
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
import { assertContainedRealPath } from './path-containment';
import { createRedactor, truncate } from './redact';
import { linuxUsername } from './runtime/box-user';
import {
chownInBox,
listWorkspaceContainerNames,
runExecInContainer,
} from './runtime/docker';
@@ -576,6 +580,7 @@ const runProjectCommand = async (args: {
phase: 'install' | 'check' | 'test';
claim: Claim;
boxName: string;
username: string;
containerHome: string;
containerCwd: string;
repoDir: string;
@@ -594,6 +599,7 @@ const runProjectCommand = async (args: {
environment: { HOME: args.containerHome, ...secretEnv },
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
user: linuxUsername(args.username),
})
: await run('bash', ['-lc', args.command], {
cwd: args.repoDir,
@@ -728,6 +734,12 @@ const materializeEnvFile = async (workspace: ActiveWorkspace) => {
claim.job.envFilePath,
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(
claim.job._id,
'info',
@@ -923,6 +935,9 @@ const runClaim = async (claim: Claim) => {
redact,
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 = {
claim,
workdir: homeDir,
@@ -1116,6 +1131,7 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim,
boxName: workspace.boxName,
username: workspace.username,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
repoDir: workspace.repoDir,
@@ -1183,7 +1199,9 @@ export const replyToInteraction = async (
) => {
// Every runtime now runs its CLI non-interactively inside the box, so no
// 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 (
@@ -13,12 +13,13 @@ const load = async () => {
describe('boxRoute', () => {
afterEach(() => vi.resetModules());
test('matches the four box actions', async () => {
test('matches the five box actions', async () => {
const { boxRoute } = await load();
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
expect(boxRoute('/box/set-password')).toEqual({ action: 'set-password' });
});
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 { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NormalizedAgentEvent } from '../../src/agent-events';
@@ -98,7 +97,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
tempDirs.push(homeDir);
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 contents = await readFile(credentialsPath, 'utf8');
@@ -111,7 +112,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
tempDirs.push(homeDir);
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');
await expect(stat(credentialsPath)).rejects.toThrow();
@@ -174,4 +177,16 @@ describe('ClaudeCodeAdapter', () => {
expect(command[3]).toContain("'claude' '-p'");
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 () => {
const { normalizeRunResult } = await loadVolumeSpec();
// This is what execa returns with `reject: false` when the runtime binary is
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
// exec-stream imports the docker runtime, whose env module requires these at
// import time — set them before the (hoist-free) dynamic import.
process.env.SPOON_WORKER_TOKEN ??= 'test-worker-token';
process.env.GITHUB_APP_ID ??= '123';
process.env.GITHUB_APP_PRIVATE_KEY ??= 'test-key';
const { parseUpgradeResponse } = await import('../../src/runtime/exec-stream');
describe('parseUpgradeResponse', () => {
it('reports incomplete until the header terminator arrives', () => {
expect(parseUpgradeResponse(Buffer.from('')).complete).toBe(false);
expect(
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\r\n')).complete,
).toBe(false);
expect(
parseUpgradeResponse(
Buffer.from('HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\n'),
).complete,
).toBe(false);
});
it('parses a 101 upgrade and preserves stream bytes after the headers', () => {
const payload = Buffer.concat([
Buffer.from(
'HTTP/1.1 101 UPGRADED\r\n' +
'Content-Type: application/vnd.docker.raw-stream\r\n' +
'Connection: Upgrade\r\n' +
'Upgrade: tcp\r\n' +
'\r\n',
),
Buffer.from([0x1b, 0x5b, 0x48, 0x00, 0xff]),
]);
const parsed = parseUpgradeResponse(payload);
expect(parsed).toMatchObject({ complete: true, statusCode: 101 });
if (!parsed.complete) throw new Error('unreachable');
expect([...parsed.remainder]).toEqual([0x1b, 0x5b, 0x48, 0x00, 0xff]);
});
it('parses a 200 stream response (docker without upgrade) as success-shaped', () => {
const parsed = parseUpgradeResponse(
Buffer.from('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi'),
);
expect(parsed).toMatchObject({ complete: true, statusCode: 200 });
if (!parsed.complete) throw new Error('unreachable');
expect(parsed.remainder.toString('utf8')).toBe('hi');
});
it('parses error statuses with their body so callers can report them', () => {
const parsed = parseUpgradeResponse(
Buffer.from(
'HTTP/1.1 404 Not Found\r\nContent-Length: 26\r\n\r\n{"message":"no such exec"}',
),
);
expect(parsed).toMatchObject({ complete: true, statusCode: 404 });
if (!parsed.complete) throw new Error('unreachable');
expect(parsed.remainder.toString('utf8')).toBe(
'{"message":"no such exec"}',
);
});
it('returns statusCode 0 for an unparseable status line', () => {
const parsed = parseUpgradeResponse(Buffer.from('garbage\r\n\r\n'));
expect(parsed).toMatchObject({ complete: true, statusCode: 0 });
});
it('does not treat a bare LF-LF as a header terminator', () => {
expect(
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\n\n')).complete,
).toBe(false);
});
});
@@ -44,3 +44,18 @@ describe('parseResizeMessage', () => {
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,9 +22,53 @@ describe('box registry', () => {
vi.clearAllMocks();
});
test('serializes concurrent acquire into a single docker run', async () => {
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 () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
const first = await module.acquireUserBox({
username: 'zoe',
workdir: '/w',
containerHome: '/home/zoe',
});
first.release();
// Simulate `docker rm` behind the worker's back: the registry entry still
// exists, but acquire must go back to the runtime rather than trust it.
const second = await module.acquireUserBox({
username: 'zoe',
workdir: '/w',
containerHome: '/home/zoe',
});
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(2);
expect(second.boxName).toBe('spoon-box-zoe');
});
test('serializes concurrent acquires so ensure calls never overlap', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
let inFlight = 0;
vi.mocked(docker.ensureUserContainer).mockImplementation(
async (args: { username: string }) => {
inFlight += 1;
expect(inFlight).toBe(1);
await Promise.resolve();
inFlight -= 1;
return `spoon-box-${args.username}`;
},
);
const [a, b] = await Promise.all([
module.acquireUserBox({
username: 'alice',
@@ -37,7 +81,6 @@ describe('box registry', () => {
containerHome: '/home/alice',
}),
]);
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
expect(a.boxName).toBe('spoon-box-alice');
a.release();
await vi.advanceTimersByTimeAsync(2000);
@@ -83,7 +126,7 @@ describe('box registry', () => {
);
});
test('ensures an adopted box once before returning acquire handles', async () => {
test('acquiring an adopted box ensures it and returns valid handles', async () => {
const module = await load();
const docker = await import('../../src/runtime/docker');
vi.mocked(docker.listWorkspaceContainerNames).mockResolvedValueOnce([
@@ -102,7 +145,7 @@ describe('box registry', () => {
containerHome: '/home/casey',
});
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
expect(docker.ensureUserContainer).toHaveBeenCalled();
expect(first.boxName).toBe('spoon-box-casey');
expect(second.boxName).toBe('spoon-box-casey');
});
@@ -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 });
});
@@ -189,7 +189,7 @@ export const XtermSession = ({
const term = new Terminal({
fontFamily: TERMINAL_FONT,
fontSize: 13,
lineHeight: 1.2,
lineHeight: 1.35,
cursorBlink: true,
theme: themeIsLight ? lightTheme : darkTheme,
allowProposedApi: true,
@@ -257,8 +257,13 @@ export const XtermSession = ({
if (typeof event.data === 'string') term.write(event.data);
else term.write(new Uint8Array(event.data));
};
ws.onclose = () => {
if (!isAborted()) setStatus('closed');
ws.onclose = (event: CloseEvent) => {
if (isAborted()) return;
setStatus('closed');
// The worker attaches a human-readable reason when the bridge fails
// (e.g. container runtime unreachable) — show it instead of a mute
// "Session ended".
if (event.reason) setErrorText(event.reason);
};
ws.onerror = () => {
if (!isAborted()) setStatus('error');
@@ -323,7 +328,9 @@ export const XtermSession = ({
{status === 'connected'
? `Connected · ${label}`
: status === 'idle'
? (waitingLabel ? 'Waiting…' : 'Idle')
? waitingLabel
? 'Waiting…'
: 'Idle'
: status === 'connecting'
? 'Connecting…'
: status === 'closed'
@@ -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 { BoxStatusCard } from './box-status-card';
import { BoxUserCard } from './box-user-card';
const STATUS_POLL_MS = 10_000;
@@ -261,6 +262,8 @@ export const MachineShell = () => {
onAction={runLifecycle}
/>
<BoxUserCard username={username} />
<Tabs
value={activeTab}
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.
export const proxyBox = async (
username: string,
action: 'status' | 'lifecycle' | 'tree' | 'file',
action: 'status' | 'lifecycle' | 'tree' | 'file' | 'set-password',
init?: RequestInit,
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' }),
);
});
});
+11 -1
View File
@@ -1,4 +1,4 @@
FROM registry.fedoraproject.org/fedora:41
FROM registry.fedoraproject.org/fedora:44
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV LANG=en_US.UTF-8
@@ -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 \
&& 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).
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
&& 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
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
The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
@@ -0,0 +1,341 @@
# Box User Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Terminal sessions and agent turns inside `spoon-box-<username>` run as a named non-root user (uid 1000) with wheel/sudo and a user-settable password stored encrypted in Convex.
**Architecture:** The worker creates the Linux user at box init via an idempotent root exec script; dev podman maps the host user onto uid 1000 with `--userns=keep-id`, prod docker repairs host-written file ownership with targeted in-container chowns. Password lives in a new `boxSettings` Convex table (AES-256-GCM via existing `secretCrypto`), applied at init and live via a new `/box/set-password` worker route; a "Box user" card on `/machine` drives it.
**Tech Stack:** bun + TypeScript worker (execa/dockerode), Convex backend, Next 15 App Router, vitest.
**Spec:** `docs/superpowers/specs/2026-07-12-box-user-design.md`
## Global Constraints
- Box user uid/gid: `1000:1000`, fixed.
- sudo: NOPASSWD drop-in `/etc/sudoers.d/spoon-box` present only while no password is stored; wheel membership always.
- Password bounds: min 8, max 128 chars; `null`/empty clears.
- Password plaintext never in argv (stdin only), never returned to a browser.
- Home path stays `/home/<spoon-username>`; only the passwd entry name is sanitized.
- podman-only run flag: `--userns=keep-id:uid=1000,gid=1000`, override env `SPOON_AGENT_BOX_USERNS` (empty disables, other values pass through).
- All commits run the repo pre-commit hooks; finish with full `bun run ci:check`.
---
### Task 1: `box-user.ts` — sanitizer + script builders (worker, pure)
**Files:**
- Create: `apps/agent-worker/src/runtime/box-user.ts`
- Test: `apps/agent-worker/tests/unit/box-user.test.ts`
**Interfaces (Produces):**
- `linuxUsername(spoonUsername: string): string`
- `BOX_UID = 1000`
- `buildBoxInitScript(args: { username: string; home: string }): string` — idempotent root script: useradd (uid 1000, `-d <home>`, bash), wheel, marker-guarded `chown -R` of home. Does NOT touch sudoers/password (those depend on stored state and are applied by `applySudoPolicy`).
- `buildSudoPolicyScript(args: { username: string; passwordSet: boolean }): string` — writes or removes `/etc/sudoers.d/spoon-box` (mode 0440, validated with `visudo -c` before install).
- `CHPASSWD_COMMAND: string[]` (`['chpasswd']`) and `buildClearPasswordCommand(username: string): string[]` (`['passwd', '-d', username]`).
- [ ] **Step 1: Write failing tests** covering: sanitizer (lowercase, invalid → `-`, digit-start prefixed `u`, >32 truncated, empty → `spoon`); init script contains `useradd` guarded by `id -u`, `-u 1000`, `-d /home/Gabriel` untouched-case home, wheel usermod, marker path `/home/Gabriel/.spoon/chown-v1` guard around `chown -R 1000:1000`; sudo script with `passwordSet:false` installs NOPASSWD via visudo-validated temp file, with `passwordSet:true` removes the drop-in.
```ts
// apps/agent-worker/tests/unit/box-user.test.ts
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('creates the user idempotently with uid 1000 at the given home', () => {
expect(script).toContain("id -u 'gabriel'");
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',
]);
});
});
```
- [ ] **Step 2: Run to verify failure**`cd apps/agent-worker && bun run test:unit -- box-user` → module not found.
- [ ] **Step 3: Implement**
```ts
// apps/agent-worker/src/runtime/box-user.ts
// The per-box Linux user (Phase: box-user). Terminal sessions and agent turns
// exec as this user instead of root; sudo is passwordless until the user
// stores a password (then wheel membership enforces password-required sudo).
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; 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: create the user at the (already mounted) home, 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).
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',
`if ! id -u ${user} >/dev/null 2>&1; then`,
` useradd -u 1000 -o -M -d ${home} -s /bin/bash ${user}`,
'fi',
`usermod -aG wheel ${user}`,
`if [ ! -f ${marker} ]; then`,
` chown -R 1000:1000 ${home}`,
` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown 1000:1000 ${marker}`,
'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,
];
```
- [ ] **Step 4: Run to verify pass**`bun run test:unit -- box-user` → all pass.
- [ ] **Step 5: Commit**`feat(worker): box-user script builders + username sanitizer`
### Task 2: docker.ts plumbing — exec-as-user, stdin input, chown helper, box run flags
**Files:**
- Modify: `apps/agent-worker/src/runtime/docker.ts`
- Test: `apps/agent-worker/tests/unit/docker-runtime.test.ts` (extend)
**Interfaces:**
- Consumes: `linuxUsername`, `BOX_UID`, `buildBoxInitScript`, `buildSudoPolicyScript`, `CHPASSWD_COMMAND`, `buildClearPasswordCommand` from Task 1.
- Produces:
- `streamExecInContainer` / `runExecInContainer` gain optional `user?: string` (adds `-u <user>` to the exec argv) and `runExecInContainer` gains optional `input?: string` (execa `input`, replacing `stdin: 'ignore'` when present).
- `chownInBox(args: { containerName: string; paths: string[]; redact?: (v: string) => string }): Promise<void>` — best-effort `chown -R 1000:1000 <paths>` exec'd as root; logs (does not throw) on failure. No-op for empty `paths`.
- `boxUsernsArgs(): string[]``['--userns=keep-id:uid=1000,gid=1000']` when runtime ends with `podman`; `SPOON_AGENT_BOX_USERNS` overrides (empty string → `[]`, other value → `['--userns=<value>']`). Exported for tests.
- `ensureUserContainer` gains optional `onCreated?: (containerName: string) => Promise<void>` — invoked only when a fresh container was created (not when already running), AFTER `run` succeeds. Also adds `--hostname <linuxUsername(username)>-box` and `...boxUsernsArgs()` to the `run` argv.
- New env in `apps/agent-worker/src/env.ts`: `boxUserns: process.env.SPOON_AGENT_BOX_USERNS` (raw, may be empty string — distinguish unset).
- [ ] **Step 1: Write failing tests** in `docker-runtime.test.ts` for `boxUsernsArgs` (podman default, docker default `[]`, override set, override empty) and for the exec arg builders if extracted pure (extract `execArgv(args)` helper returning the argv array so `-u` placement is unit-testable: `['exec', ...envFlags, '-w', cwd, ...(user ? ['-u', user] : []), name, ...command]`).
- [ ] **Step 2: Run to verify failure.**
- [ ] **Step 3: Implement** (extract `execArgv` used by both exec fns; add `input`/`user` params; `chownInBox` via `runExecInContainer` with `command: ['chown','-R','1000:1000',...paths]`, catches and `console.error`s; `boxUsernsArgs` reading `env.boxUserns`; wire `--hostname`/userns/`onCreated` into `ensureUserContainer`).
- [ ] **Step 4: Run tests** — extended docker-runtime tests + whole unit suite pass.
- [ ] **Step 5: Commit**`feat(worker): exec-as-user, stdin input, chown + userns plumbing`
### Task 3: Convex — `boxSettings` table + node actions
**Files:**
- Modify: `packages/backend/convex/schema.ts`
- Create: `packages/backend/convex/boxSettings.ts` (query + internal query/mutation)
- Create: `packages/backend/convex/boxSettingsNode.ts` ('use node' actions)
- Test: follow the existing backend test layout (`packages/backend/tests/…`, convex-test) mirroring the closest existing coverage of `aiSettingsNode`/`userDotfiles`.
**Interfaces (Produces):**
- Schema:
```ts
boxSettings: defineTable({
ownerId: v.id('users'),
// Resolved Spoon home username at the time the password was set — the
// worker looks the row up by this (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']),
```
- `boxSettings.hasMine` — owner query → `{ hasPassword: boolean }`.
- `boxSettings.getByUsernameInternal` / `boxSettings.upsertMineInternal` — internal plumbing for the node actions.
- `boxSettingsNode.setBoxPassword` — authed action `{ password: string | null }`; validates 8128 (null/'' clears), resolves the caller's username exactly like `userEnvironment.getMine` (reuse `deriveHomeUsername` from `model.ts`), stores `encryptSecret(password)` or clears the field.
- `boxSettingsNode.getBoxPasswordForWorker` — action `{ workerToken: string; username: string }``{ password: string | null }`, gated by the same worker-token check used in `userDotfilesNode.getEnvironmentForJob`; decrypts with `decryptSecret`.
- [ ] **Step 1: Write failing backend tests** (validation bounds, clear semantics, worker-token gate rejects bad token, round-trip returns the plaintext).
- [ ] **Step 2: Run to verify failure** (`cd packages/backend && bun run test` — use the package's existing test script name).
- [ ] **Step 3: Implement schema + functions; run `bash scripts/convex-codegen`.**
- [ ] **Step 4: Run tests + typecheck.**
- [ ] **Step 5: Commit**`feat(backend): boxSettings table + box password actions`
### Task 4: Worker — box init wiring (user creation, password at creation, exec-as-user everywhere)
**Files:**
- Create: `apps/agent-worker/src/box-settings.ts` (Convex fetch)
- Modify: `apps/agent-worker/src/user-container.ts`, `apps/agent-worker/src/box.ts`, `apps/agent-worker/src/terminal.ts`, `apps/agent-worker/src/worker.ts`, `apps/agent-worker/src/user-environment.ts`, adapters (`runtime/claude-adapter.ts`, `runtime/codex-adapter.ts`, `runtime/opencode-adapter.ts`) and `runtime/agent-runtime.ts` arg types.
- Test: extend `apps/agent-worker/tests/unit/user-container.test.ts`, adapter tests (assert `user` is forwarded to `execStream`), `terminal-resize.test.ts` untouched.
**Interfaces:**
- Consumes: everything from Tasks 13.
- Produces:
- `box-settings.ts`: `fetchBoxPassword(username: string): Promise<string | null>` — ConvexHttpClient action call to `api.boxSettingsNode.getBoxPasswordForWorker` with `env.workerToken`; returns null on any error (box must still start when Convex is down; log the error).
- `initializeBoxUser(containerName: string, spoonUsername: string, home: string): Promise<void>` (in `box.ts` or a small `box-init.ts`): runs `buildBoxInitScript` as root, fetches password, applies `chpasswd` via stdin when set, then `buildSudoPolicyScript`. This is the `onCreated` hook passed to `ensureUserContainer` from BOTH call paths (`acquireUserBox` in `user-container.ts` and `startBox` in `box.ts`).
- Terminal bridges pass `User: linuxUsername(username)` in the dockerode exec create (both `bridge` and `bridgeBox`).
- `worker.ts` / adapters: exec arg objects gain `user: linuxUsername(claim/workspace username)`; `runProjectCommand`, agent turn `execStream` calls, and dotfiles `runExecInContainer` in `materializeUserHome` all pass it. `killBoxProcessesByMarker` stays root.
- Chown call sites (all best-effort `chownInBox`): after `cloneRepository` (repo dir), after secrets env-file write in `worker.ts` (the `claim.job.envFilePath` target), after overlay-file writes in `materializeUserHome`, after `writeBoxFile` in `box.ts`, after `ensureBashProfile` when invoked with a running box (bridgeBox: chown `~/.bash_profile` right after acquire).
- [ ] **Step 1: Write failing tests** — user-container test asserting `onCreated` fires once per creation (mock `ensureUserContainer` invoking its hook); adapter test asserting `user` reaches `execStream` args.
- [ ] **Step 2: Run to verify failure.**
- [ ] **Step 3: Implement the wiring.**
- [ ] **Step 4: Full worker unit suite + typecheck + lint pass.**
- [ ] **Step 5: Commit**`feat(worker): run terminals and agent turns as the box user`
### Task 5: Worker — `/box/set-password` route
**Files:**
- Modify: `apps/agent-worker/src/server.ts` (route regex + handler), `apps/agent-worker/src/box.ts` (`setBoxUserPassword`)
- Test: extend `apps/agent-worker/tests/unit/box-route.test.ts` (regex accepts `set-password`).
**Interfaces:**
- Produces: `setBoxUserPassword(username: string, password: string | null): Promise<{ applied: boolean }>` — if box not running → `{ applied: false }`; else chpasswd/clear + sudo policy toggle → `{ applied: true }`. Route: `POST /box/set-password` body `{ password: string | null }`, 400 on invalid length, 200 `{ applied }`.
- [ ] Steps: failing route-regex test → implement → suite green → commit `feat(worker): live box password apply route`.
### Task 6: Next — proxy + API route
**Files:**
- Modify: `apps/next/src/lib/agent-worker-proxy.ts` (`proxyBox` action union + `'set-password'`)
- Create: `apps/next/src/app/api/box/set-password/route.ts`
**Interfaces:**
- Produces: `POST /api/box/set-password` `{ password: string | null }``withBox` → validate 8128/null → `fetchAction(api.boxSettingsNode.setBoxPassword, { password }, { token })` (Convex is source of truth, written first) → `proxyBox(username, 'set-password', { method: 'POST', body: JSON.stringify({ password }) })` → respond `{ hasPassword, applied }`; worker failure downgrades to `{ applied: false }` with 200 (spec: card shows "takes effect on restart").
- [ ] Steps: implement (route has no unit test — component test in Task 7 covers the card; the route mirrors existing `/api/box/*` one-screen handlers) → typecheck/lint → commit `feat(next): box password API`.
### Task 7: Next — Box user card on /machine
**Files:**
- Create: `apps/next/src/components/machine/box-user-card.tsx`
- Modify: `apps/next/src/components/machine/machine-shell.tsx` (render the card)
- Test: `apps/next/tests/component/box-user-card.test.tsx`
**Interfaces:**
- Consumes: `api.boxSettings.hasMine` (convex react query), `api.userEnvironment.getMine` (username for the `user@host` line), `POST /api/box/set-password`.
- Behavior: shows `<username>@<username>-box`; "Password: set / not set"; form (password + confirm, min 8) with Save and — when set — a "Remove password" button; on save success where `applied === false`, shows the "takes effect next restart" note; explains the sudo policy in one sentence of muted copy.
- [ ] Steps: failing component test (renders not-set state; mismatch confirm blocks submit; renders applied-false warning) → implement card + wire into `machine-shell.tsx` → component suite green → commit `feat(next): box user card on /machine`.
### Task 8: Docs + end-to-end verification
**Files:**
- Modify: `docs/agent-terminal.md` (+ the box docs section touched in the docs rewrite) — box user, sudo policy, password flow, ownership model (keep-id vs chown), `SPOON_AGENT_BOX_USERNS`.
- [ ] **Step 1: Docs.**
- [ ] **Step 2: Live verification (dev)** — recreate the box, then via the WS harness: `whoami` → username; `id -u` → 1000; `sudo -n true` → ok; `touch ~/x && ls -l` → user-owned; box file editor write → file user-editable in the terminal. Set a password via the UI (or curl the API route), then: `sudo -n true` fails, `chpasswd` took effect (`su - <user>` with the password succeeds); clear password → NOPASSWD sudo returns. Run one agent turn and confirm it executes as the user (`whoami` artifact or a file it creates is uid 1000).
- [ ] **Step 3: `bun run ci:check` at repo root — all green.**
- [ ] **Step 4: Commit docs; final review of the whole diff.**
@@ -0,0 +1,166 @@
# Box user: non-root terminal & agent execution with a user-set password
**Date:** 2026-07-12
**Status:** Approved design, pending implementation
## Problem
Everything inside a user's box container (`spoon-box-<username>`) runs as root:
the interactive terminal, agent turns (Claude Code / Codex / OpenCode), and
dotfiles setup. This is wrong for three reasons:
1. Claude Code refuses to run certain operations as root, so agent turns hit
artificial failures.
2. Root-owned homes make every file mutation a foot-gun (a stray `rm` has no
guardrails, tools written for normal users misbehave).
3. The box is pitched as "your dev machine in Spoon" — a real machine has a
named user, a home they own, sudo, and a password.
## Decisions (made with the user)
- **sudo policy:** passwordless until the user sets a password; once a
password is stored, sudo requires it.
- **Password UX:** a "Box user" card on the `/machine` page. Saving stores the
password encrypted in Convex and, when the box is running, applies it live
via `chpasswd` — no restart needed.
- **Agent turns run as the user too**, not just the terminal (this was the
original motivation).
- The Linux username mirrors the Spoon username (sanitized, see below); the
prompt reads `gabriel@gabriel-box`.
## Architecture
### The ownership problem (the one hard part)
The worker writes into box homes **from the host side**: dotfiles overlay
files, `.bash_profile`, the box file editor (`writeBoxFile`), and job repo
clones. Dev and prod differ in who that host writer is:
| | Dev (rootless podman) | Prod (rootful docker) |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| Host writer | `gib` (host uid) | worker container, uid 0 |
| Appears in box as | uid 0 (default mapping) | uid 0 |
| Fix | `--userns=keep-id:uid=1000,gid=1000` on the box: the host user _is_ box uid 1000, so host writes are already user-owned | worker chowns to `1000:1000` after each host-side write |
The uniform mechanism: after every host-side write into a home, the worker
runs `chown -R 1000:1000 <paths>` **inside the container as root**. Under
dev's keep-id mapping this is a no-op (files are already uid 1000); under prod
it repairs uid 0 ownership. One code path, no runtime branching at call sites.
The keep-id flag is added to the box `run` args only when the container
runtime is podman, overridable via `SPOON_AGENT_BOX_USERNS` (empty string
disables; any other value is passed through as `--userns=<value>`).
### Box init (worker, `ensureUserContainer`)
After the `run`, the worker execs an idempotent root init script:
1. `useradd` the sanitized username with uid/gid 1000, shell `/bin/bash`,
home `-d /home/<spoon-username>` (no `-M` skeleton copy — the mounted home
persists; `ensureBashProfile` already seeds login-shell wiring).
2. `usermod -aG wheel <user>` (normal Fedora admin group; wheel requires a
password for sudo, which is exactly the post-password behavior we want).
3. Sudoers drop-in `/etc/sudoers.d/spoon-box`: present with
`<user> ALL=(ALL) NOPASSWD:ALL` **only when no password is stored**;
absent otherwise (wheel membership then enforces password-required sudo).
Written via `visudo -c` validation; `chmod 0440`.
4. If a password is stored: apply via `chpasswd` **through stdin** (never
argv — argv is visible in `ps`).
5. One-time home ownership migration: if `~/.spoon/chown-v1` marker is
absent, `chown -R 1000:1000 /home/<username>` and write the marker. Guards
against re-walking a large home on every box recreation; later host-side
writes are covered by the per-write chown above.
6. `--hostname <linux-username>-box` on the `run` for a normal-machine prompt.
Username sanitization (`linuxUsername()` helper, unit-tested): lowercase,
invalid chars → `-`, must start `[a-z_]` (prefix `u` otherwise), truncated to
32 chars, fallback `spoon`. The **home path stays keyed by the Spoon
username** (everything already depends on it); only the passwd entry uses the
sanitized name, with `-d` pointing at the existing home.
Init failure fails the acquire; the terminal bridge already logs it and closes
the WebSocket with a readable reason, and agent turns already surface acquire
errors.
### Execution as the user
- Terminal bridge (`terminal.ts`): both job and box execs get
`User: <linux-username>`.
- Agent turns: `streamExecInContainer` / `runExecInContainer` /
`buildMarkedCommand` call sites pass `-u <linux-username>`.
- Root remains only where needed: box init, post-write chowns, and
`killBoxProcessesByMarker` (root can signal user processes).
- Job secrets files (0600, host-written) are included in post-write chown so
agents can still read them.
### Password storage (backend, Convex)
- New user-keyed `boxSettings` table (one row per user; do NOT piggyback on
the dotfiles/user-environment record — the password must exist independently
of whether dotfiles are configured) with
`boxPasswordEncrypted?: string`, encrypted with the existing
`secretCrypto.ts` AES-256-GCM helpers.
- Owner-authed mutation `setBoxPassword` (min 8 / max 128 chars; empty clears
the password and reverts sudo to NOPASSWD on next apply).
- Worker-token-authed Node action returns the decrypted password for box
init, mirroring `getEnvironmentForJob`.
- A `hasBoxPassword` owner query for the UI. The password itself is
write-only: no query ever returns the plaintext to a browser.
### Live apply (worker route + Next proxy)
- New worker route `POST /box/set-password` (same HMAC internal-token auth as
the other `/box/*` routes), body `{ password: string | null }`. If the box
is running: `chpasswd` via stdin (or `passwd -d` + NOPASSWD drop-in
restore when clearing), and toggle the sudoers drop-in to match. If not
running: no-op success (init applies it at next creation).
- Next API route (mirrors the existing `/box/*` proxy pattern): verifies the
session, writes Convex first, then calls the worker route so a running box
updates live.
### UI (`/machine` page)
A "Box user" card: shows `user@host` identity, whether a password is set, and
a set/change/clear password form (two fields: new password + confirm;
write-only, never displays the current value). Save → Next API → Convex +
live apply → toast. Copy explains the sudo behavior ("no password: sudo works
without one; with a password: sudo prompts for it").
## Error handling
- Init script errors → acquire failure → visible terminal close reason /
agent turn error (infrastructure added earlier today).
- `chpasswd`/sudoers failure on live apply → worker route 500 with stderr
text → surfaced in the card.
- Convex write succeeds but live apply fails → card shows a warning that the
password takes effect on next box restart (state is still consistent:
init re-applies from Convex).
## Testing
- **Unit (worker):** `linuxUsername()` sanitizer; init-script builder
(user/wheel/sudoers/chpasswd-presence permutations); sudoers content for
password vs no-password; exec call sites pass `-u`/`User`.
- **Unit (backend):** setBoxPassword validation, encryption round-trip,
worker-token gate on the decrypt action.
- **Component (next):** Box user card renders states (no password / password
set / apply-failed warning).
- **End-to-end (manual, WS harness from today):** `whoami` → username;
`id -u` → 1000; `sudo -n true` succeeds before password; after setting a
password `sudo -n true` fails and `sudo true` prompts; agent turn creates a
file owned by the user; dotfiles + box file editor writes remain
user-editable in both dev and prod runtimes.
## Migration
Existing boxes pick everything up on their next recreation (idle reap or
`/machine` Restart) — no data migration. The one-time home chown handles
files root already created. Docs (`docs/agent-terminal.md`, box docs) get a
section on the box user + password.
## Out of scope
- SSH access to the box.
- Multiple users / teams per box.
- Password complexity policies beyond length bounds.
- Changing the home path layout.
+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()),
updatedAt: v.number(),
}).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({
ownerId: v.id('users'),
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_IDLE_MS",
"SPOON_AGENT_BOX_IDLE_MS",
"SPOON_AGENT_BOX_USERNS",
"SPOON_AGENT_DEV_WATCHDOG",
"SPOON_AGENT_RUNTIME",
"SPOON_AGENT_CONTAINER_RUNTIME",