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 { assertContainedRealPath } from './path-containment'; import { runExecInContainer } 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("'", "'\\''")}'`; /** * A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc. * Write a minimal `.bash_profile` (only if absent) so an interactive login * shell — the agent's, or the user's box terminal — loads their environment. * Shared by `materializeUserHome` and the box terminal bridge. */ export const ensureBashProfile = async (homeDir: string): Promise => { await mkdir(homeDir, { recursive: true }); 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', ); }); }; /** * 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; boxName: string; userEnv: UserEnvironment; redact: (value: string) => string; }): Promise => { const { homeDir, containerHome, boxName, userEnv, redact } = args; await ensureBashProfile(homeDir); 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 runExecInContainer({ containerName: boxName, command: ['bash', '-lc', script], containerCwd: containerHome, 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 = await assertContainedRealPath(homeDir, file.path, { forWrite: true, }); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, file.content); if (file.isExecutable) await chmod(target, 0o755); } };