import { createHash } from 'node:crypto'; import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { ConvexHttpClient } from 'convex/browser'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { env } from './env'; import { runInJobContainer } from './runtime/docker'; const client = new ConvexHttpClient(env.convexUrl); export type UserEnvironment = { username: string; enabled: boolean; dotfilesRepoUrl?: string; dotfilesRepoRef?: string; setupCommand?: string; files: { path: string; content: string; isExecutable: boolean }[]; }; /** The job owner's resolved environment (username + dotfiles, decrypted). */ export const fetchUserEnvironment = async ( jobId: Id<'agentJobs'>, ): Promise => await client.action(api.userDotfilesNode.getEnvironmentForJob, { workerToken: env.workerToken, jobId, }); const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; // Keep a written path inside the home directory. const safeHomeJoin = (homeDir: string, relPath: string) => { const target = path.resolve(homeDir, relPath); const root = path.resolve(homeDir); if (target !== root && !target.startsWith(`${root}${path.sep}`)) { throw new Error(`Refusing to write dotfile outside home: ${relPath}`); } return target; }; /** * Materializes the persistent per-user home: a `.bash_profile` so login shells * load `~/.bashrc`; (when configured and changed) a clone of the public dotfiles * repo + the setup command, run inside the job image so the user's tools/paths * apply; then the editable overlay files (which win over the repo). Idempotent * via a hash marker so the repo/setup only re-runs when the config changes. */ export const materializeUserHome = async (args: { homeDir: string; containerHome: string; userEnv: UserEnvironment; redact: (value: string) => string; }): Promise => { const { homeDir, containerHome, userEnv, redact } = args; await mkdir(homeDir, { recursive: true }); // A mounted home has no /etc/skel, so ensure login shells source ~/.bashrc. const bashProfile = path.join(homeDir, '.bash_profile'); await readFile(bashProfile, 'utf8').catch(async () => { await writeFile( bashProfile, '# Spoon: load ~/.bashrc for login shells.\n[ -f ~/.bashrc ] && . ~/.bashrc\n', ); }); if (!userEnv.enabled) return; // Public dotfiles repo + setup command, only re-run when the config changes. if (userEnv.dotfilesRepoUrl) { const configHash = createHash('sha256') .update( JSON.stringify({ repo: userEnv.dotfilesRepoUrl, ref: userEnv.dotfilesRepoRef ?? '', setup: userEnv.setupCommand ?? '', }), ) .digest('hex'); const markerPath = path.join(homeDir, '.spoon', 'env-hash'); const previous = await readFile(markerPath, 'utf8').catch(() => ''); if (previous.trim() !== configHash) { const branch = userEnv.dotfilesRepoRef ? `--branch ${shellQuote(userEnv.dotfilesRepoRef)} ` : ''; const script = [ 'set -e', 'rm -rf ~/.dotfiles', `git clone --depth 1 ${branch}${shellQuote(userEnv.dotfilesRepoUrl)} ~/.dotfiles`, userEnv.setupCommand ? `cd ~/.dotfiles && bash ${shellQuote(userEnv.setupCommand)}` : '', ] .filter(Boolean) .join('\n'); await runInJobContainer({ workdir: homeDir, containerHome, containerCwd: containerHome, command: ['bash', '-lc', script], environment: { HOME: containerHome }, redact, timeoutMs: env.jobTimeoutMs, }); await mkdir(path.dirname(markerPath), { recursive: true }); await writeFile(markerPath, configHash); } } // Editable overlay tree (wins over the repo/setup output). for (const file of userEnv.files) { const target = safeHomeJoin(homeDir, file.path); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, file.content); if (file.isExecutable) await chmod(target, 0o755); } };