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
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user