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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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}`),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user