Files
spoon/apps/agent-worker/src/user-environment.ts
T
Gabriel Brown 4c0de2cbf3 Worker: persistent per-user home + dotfiles materialization
- Each job/terminal now mounts a persistent per-user home (${workdir}/homes/
  {username}) at /home/{username}; the thread checkout lives at
  ~/Code/{spoon}/{branch} so every thread shows up as a folder in one home and
  dotfiles/tools/nvim plugins persist across sessions
- docker.ts helpers + git.ts cloneRepository take container home/cwd + dir name
  (backward-compatible defaults); codex/opencode/terminal use the per-user paths
- new user-environment.ts: fetchUserEnvironment (worker-token Convex action) +
  materializeUserHome — ensures ~/.bash_profile, applies the editable overlay
  files, and (hashed/idempotent) clones the public dotfiles repo + runs the
  setup command inside the job image
- stopWorkspace no longer deletes the home; only the container stops
- Verified: codex runs a real turn under the new /home/{user} + ~/Code layout;
  overlay .bashrc loads in the interactive shell
2026-06-24 09:51:39 -04:00

120 lines
4.1 KiB
TypeScript

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<UserEnvironment | null> =>
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<void> => {
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);
}
};