From 78cb3d90c2899a6738d5f88287f59d4b90fdacc9 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 09:49:59 -0400 Subject: [PATCH] feat(worker): box-user script builders + username sanitizer --- apps/agent-worker/src/runtime/box-user.ts | 75 ++++++++++++++++++ apps/agent-worker/tests/unit/box-user.test.ts | 79 +++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 apps/agent-worker/src/runtime/box-user.ts create mode 100644 apps/agent-worker/tests/unit/box-user.test.ts diff --git a/apps/agent-worker/src/runtime/box-user.ts b/apps/agent-worker/src/runtime/box-user.ts new file mode 100644 index 0000000..16edf98 --- /dev/null +++ b/apps/agent-worker/src/runtime/box-user.ts @@ -0,0 +1,75 @@ +// 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, +]; diff --git a/apps/agent-worker/tests/unit/box-user.test.ts b/apps/agent-worker/tests/unit/box-user.test.ts new file mode 100644 index 0000000..cd6e0c8 --- /dev/null +++ b/apps/agent-worker/tests/unit/box-user.test.ts @@ -0,0 +1,79 @@ +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', + ]); + }); +});