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:
Gabriel Brown
2026-06-24 09:51:39 -04:00
parent 683fc62129
commit 4c0de2cbf3
5 changed files with 224 additions and 33 deletions
+6 -2
View File
@@ -36,12 +36,16 @@ export const cloneRepository = async (args: {
workBranch: string;
redact: (value: string) => string;
timeoutMs: number;
// Directory name to clone into under `workdir` (default "repo"). Used to lay
// out checkouts as ~/Code/{spoon}/{branch}.
dirName?: string;
}) => {
await mkdir(args.workdir, { recursive: true });
const dirName = args.dirName ?? 'repo';
const repoUrl = `https://x-access-token:${args.token}@github.com/${args.owner}/${args.repo}.git`;
const clone = await run(
'git',
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, 'repo'],
['clone', '--branch', args.baseBranch, '--single-branch', repoUrl, dirName],
{
cwd: args.workdir,
redact: args.redact,
@@ -51,7 +55,7 @@ export const cloneRepository = async (args: {
if (clone.exitCode !== 0) {
throw new Error(`git clone failed:\n${clone.output}`);
}
const repoDir = path.join(args.workdir, 'repo');
const repoDir = path.join(args.workdir, dirName);
const checkout = await run('git', ['checkout', '-b', args.workBranch], {
cwd: repoDir,
redact: args.redact,
+18 -9
View File
@@ -80,18 +80,23 @@ export const containerVolumeSuffix = () =>
export { hostWorkspacePath };
export const jobWorkspaceVolumeSpec = (workdir: string) => {
export const jobWorkspaceVolumeSpec = (
workdir: string,
containerHome = '/workspace',
) => {
const volumeOptions =
env.containerVolumeOptions ??
(containerRuntime().endsWith('podman') ? 'Z' : undefined);
const source = hostWorkspacePath(workdir);
return volumeOptions
? `${source}:/workspace:${volumeOptions}`
: `${source}:/workspace`;
? `${source}:${containerHome}:${volumeOptions}`
: `${source}:${containerHome}`;
};
export const runInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
@@ -110,9 +115,9 @@ export const runInJobContainer = async (args: {
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir),
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
'/workspace/repo',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
@@ -128,6 +133,8 @@ export const runInJobContainer = async (args: {
export const startWorkspaceContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
containerName: string;
environment: Record<string, string>;
command?: string[];
@@ -154,9 +161,9 @@ export const startWorkspaceContainer = async (args: {
: []),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir),
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
'/workspace/repo',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...(args.command ?? ['sleep', 'infinity']),
],
@@ -220,6 +227,8 @@ export const execInWorkspaceContainer = async (args: {
export const streamInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
@@ -240,9 +249,9 @@ export const streamInJobContainer = async (args: {
...networkArgs(),
...environmentArgs(args.environment),
'-v',
jobWorkspaceVolumeSpec(args.workdir),
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
'/workspace/repo',
args.containerCwd ?? '/workspace/repo',
env.jobImage,
...args.command,
],
+14 -6
View File
@@ -11,7 +11,6 @@ import { getTerminalWorkspace } from './worker';
const TERMINAL_IMAGE = env.terminalImage;
const IDLE_STOP_MS = env.terminalIdleMs;
const CONTAINER_WORKDIR = '/workspace/repo';
const docker = new Docker();
@@ -21,7 +20,11 @@ const containerName = (jobId: string) =>
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
const sessions = new Map<string, Session>();
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
const ensureTerminalContainer = async (
jobId: string,
workdir: string,
containerHome: string,
) => {
const name = containerName(jobId);
const container = docker.getContainer(name);
const info = await container.inspect().catch(() => null);
@@ -35,11 +38,11 @@ const ensureTerminalContainer = async (jobId: string, workdir: string) => {
name,
Image: TERMINAL_IMAGE,
Cmd: ['sleep', 'infinity'],
WorkingDir: CONTAINER_WORKDIR,
WorkingDir: containerHome,
Tty: false,
Labels: { 'spoon.agent.terminal': jobId },
HostConfig: {
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
Binds: [`${source}:${containerHome}${suffix ? `:${suffix}` : ''}`],
NetworkMode: env.network,
Memory: 4 * 1024 * 1024 * 1024,
AutoRemove: false,
@@ -81,7 +84,11 @@ const bridge = async (ws: WebSocket, jobId: string) => {
let stream: Duplex | undefined;
let exec: Docker.Exec | undefined;
try {
const container = await ensureTerminalContainer(jobId, workspace.workdir);
const container = await ensureTerminalContainer(
jobId,
workspace.workdir,
workspace.containerHome,
);
exec = await container.exec({
// Reattach a persistent tmux session across reconnects when tmux is
// available; otherwise fall back to a plain login shell.
@@ -94,9 +101,10 @@ const bridge = async (ws: WebSocket, jobId: string) => {
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: CONTAINER_WORKDIR,
WorkingDir: workspace.containerRepo,
Env: [
'TERM=xterm-256color',
`HOME=${workspace.containerHome}`,
...workspace.secrets.map((s) => `${s.name}=${s.value}`),
],
});
+119
View File
@@ -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);
}
};
+67 -16
View File
@@ -17,11 +17,7 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
import type { NormalizedAgentEvent } from './agent-events';
import type { OpenCodeSession } from './opencode-session';
import { normalizeCodexJsonLine } from './agent-events';
import {
codexContainerRepo,
codexContainerWorkspace,
prepareCodexWorkspaceFiles,
} from './codex-runtime';
import { prepareCodexWorkspaceFiles } from './codex-runtime';
import { env } from './env';
import {
cloneRepository,
@@ -45,6 +41,7 @@ import {
stopWorkspaceContainer,
streamInJobContainer,
} from './runtime/docker';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
type Claim = {
job: {
@@ -98,7 +95,14 @@ type Claim = {
type ActiveWorkspace = {
claim: Claim;
// Host path of the persistent per-user home (mounted at `containerHome`).
// Equal to `homeDir`; kept as `workdir` for the container mount source.
workdir: string;
homeDir: string;
username: string;
// In-container paths: HOME and the thread's checkout (~/Code/{spoon}/{branch}).
containerHome: string;
containerRepo: string;
repoDir: string;
githubToken: string;
redact: (value: string) => string;
@@ -620,6 +624,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
);
const container = await startWorkspaceContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
containerName,
environment: {
...aiEnv,
@@ -649,7 +655,7 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
const session = await createOpenCodeSession({
baseUrl,
password,
directory: '/workspace/repo',
directory: workspace.containerRepo,
title: workspace.claim.job.prompt.slice(0, 80) || 'Spoon workspace',
onEvent: async (event) => {
const messageId = workspaceCurrentMessage.get(
@@ -721,7 +727,7 @@ const runCodexTurn = async (args: {
outputFileName,
);
const outputFileContainerPath = path.posix.join(
codexContainerWorkspace,
workspace.containerHome,
'.codex',
outputFileName,
);
@@ -747,15 +753,17 @@ const runCodexTurn = async (args: {
'--output-last-message',
outputFileContainerPath,
'--cd',
codexContainerRepo,
workspace.containerRepo,
prompt,
];
const aiEnv = providerEnvironment(workspace.claim, codexContainerWorkspace);
const aiEnv = providerEnvironment(workspace.claim, workspace.containerHome);
const secretEnv = Object.fromEntries(
workspace.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result = await streamInJobContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
command,
environment: {
...aiEnv,
@@ -866,7 +874,7 @@ const runOpenCodeTurn = async (args: {
session,
prompt,
model: opencodeModel(workspace.claim),
directory: '/workspace/repo',
directory: workspace.containerRepo,
});
await turnDone;
};
@@ -1268,9 +1276,15 @@ const ensureNoEnvFilesStaged = async (workspace: ActiveWorkspace) => {
}
};
const slugify = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-') || 'x';
const runClaim = async (claim: Claim) => {
const jobId = claim.job._id;
const workdir = path.resolve(env.workdir, jobId);
const secretValues = [
claim.openai.apiKey ?? '',
claim.aiProviderProfile?.secret ?? '',
@@ -1288,8 +1302,27 @@ const runClaim = async (claim: Claim) => {
throw new Error('GitHub installation ID is missing.');
}
const githubToken = await getInstallationToken(claim.github.installationId);
// Resolve the persistent per-user home and lay the checkout out as
// ~/Code/{spoon}/{branch} inside it, so dotfiles/tools persist and every
// thread shows up as a folder in one home.
const userEnv = await fetchUserEnvironment(jobId);
const username = userEnv?.username ?? 'user';
const homeDir = path.resolve(env.workdir, 'homes', username);
const containerHome = path.posix.join('/home', username);
const spoonSlug = slugify(claim.spoon.name);
const branchSlug = slugify(claim.job.workBranch);
const checkoutParent = path.join(homeDir, 'Code', spoonSlug);
const containerRepo = path.posix.join(
containerHome,
'Code',
spoonSlug,
branchSlug,
);
const repoDir = await cloneRepository({
workdir,
workdir: checkoutParent,
dirName: branchSlug,
token: githubToken,
owner: claim.job.forkOwner,
repo: claim.job.forkRepo,
@@ -1300,11 +1333,24 @@ const runClaim = async (claim: Claim) => {
});
const workspace: ActiveWorkspace = {
claim,
workdir,
workdir: homeDir,
homeDir,
username,
containerHome,
containerRepo,
repoDir,
githubToken,
redact,
};
if (userEnv) {
await appendEvent(
jobId,
'info',
'clone',
'Applying your dotfiles and environment.',
);
await materializeUserHome({ homeDir, containerHome, userEnv, redact });
}
if (isCodexLoginProfile(claim)) {
await prepareCodexAuth(workspace);
}
@@ -1471,6 +1517,9 @@ export const getTerminalWorkspace = (jobId: string) => {
if (!workspace) return null;
return {
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerRepo: workspace.containerRepo,
username: workspace.username,
secrets: workspace.claim.secrets,
};
};
@@ -1532,7 +1581,7 @@ export const replyToInteraction = async (
session: workspace.opencodeSession,
permissionId: args.externalRequestId,
response: mapped,
directory: '/workspace/repo',
directory: workspace.containerRepo,
});
await patchInteractionRequest({
interactionId: args.interactionId,
@@ -1781,7 +1830,8 @@ export const openWorkspacePullRequest = async (jobId: string) => {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
// The persistent per-user home + ~/Code checkouts survive across sessions;
// only the container is stopped.
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
@@ -1796,7 +1846,8 @@ export const stopWorkspace = async (jobId: string) => {
await stopWorkspaceContainer(workspace.containerName);
}
activeWorkspaces.delete(jobId);
await rm(workspace.workdir, { recursive: true, force: true });
// The persistent per-user home + ~/Code checkouts survive across sessions;
// only the container is stopped.
return { success: true };
};