// 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: 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). `-o` allows // the uid even if the image ever gains a uid-1000 user; `-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', `if ! id -u ${user} >/dev/null 2>&1; then`, ` useradd -u ${BOX_UID} -o -M -d ${home} -s /bin/bash ${user}`, 'fi', `usermod -aG wheel ${user}`, `if [ ! -f ${marker} ]; then`, ` chown -R ${BOX_UID}:${BOX_UID} ${home}`, ` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown ${BOX_UID}:${BOX_UID} ${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, ];