diff --git a/apps/agent-worker/src/git.ts b/apps/agent-worker/src/git.ts index 7b1392f..b40efae 100644 --- a/apps/agent-worker/src/git.ts +++ b/apps/agent-worker/src/git.ts @@ -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, diff --git a/apps/agent-worker/src/runtime/docker.ts b/apps/agent-worker/src/runtime/docker.ts index b747575..ac5a98f 100644 --- a/apps/agent-worker/src/runtime/docker.ts +++ b/apps/agent-worker/src/runtime/docker.ts @@ -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; 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; 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; 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, ], diff --git a/apps/agent-worker/src/terminal.ts b/apps/agent-worker/src/terminal.ts index 50febed..598a4a7 100644 --- a/apps/agent-worker/src/terminal.ts +++ b/apps/agent-worker/src/terminal.ts @@ -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(); -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}`), ], }); diff --git a/apps/agent-worker/src/user-environment.ts b/apps/agent-worker/src/user-environment.ts new file mode 100644 index 0000000..40c63d6 --- /dev/null +++ b/apps/agent-worker/src/user-environment.ts @@ -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 => + 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 => { + 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); + } +}; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 6268646..a9a6b53 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -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 }; };