feat(worker): box-user script builders + username sanitizer
This commit is contained in:
@@ -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,
|
||||||
|
];
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user