76 lines
2.8 KiB
TypeScript
76 lines
2.8 KiB
TypeScript
// 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,
|
|
];
|