Compare commits

..

8 Commits

Author SHA1 Message Date
Gabriel Brown 8d2a089268 docs: server deploy changes (terminal, dotfiles, Fedora, Phase 2)
Build and Push Spoon Images / quality (push) Failing after 7s
Build and Push Spoon Images / build-images (push) Has been skipped
2026-06-24 10:32:45 -04:00
Gabriel Brown c6b27063a4 Phase 2: single per-user box container for every thread
Every thread (agent turns + terminal + project commands) now execs into one
persistent per-user container (spoon-box-{username}) instead of ephemeral
docker run --rm — so the agent and terminal share the exact same running
environment, filesystem, and in-session installs.

- docker.ts: ensureUserContainer (persistent box) + streamExecInContainer/
  runExecInContainer (docker exec, streaming) sharing a factored streamSubprocess
- user-container.ts: reference-counted box lifecycle (held while any thread
  workspace is active or a terminal is connected; idle-reaped after
  SPOON_AGENT_BOX_IDLE_MS, default 30m)
- worker.ts: runClaim acquires the box; codex turn + runProjectCommand exec into
  it; release on stop/PR/failure
- terminal.ts: execs into the shared box (dockerode TTY) instead of a per-job
  container; materializeUserHome runs the dotfiles setup in the box
- Verified: agent + terminal run in the same box, share fs, dotfiles + tmux load
2026-06-24 10:30:40 -04:00
Gabriel Brown c103430c7d Self-host VictorMono Nerd Font icons for the terminal + editor
- Add Symbols-Only Nerd Font Mono (woff2) under public/fonts; @font-face scoped
  by unicode-range to the Nerd Font glyph ranges, so the ~1.1MB file only loads
  when an icon actually renders (latin text stays on Victor Mono)
- Terminal + editor font stacks fall back to it for icons; terminal prewarms it
  and repaints so powerline/oh-my-posh/eza/nvim icons show
- No server/env changes; ships in the spoon-next image (public/)
2026-06-24 10:17:25 -04:00
Gabriel Brown c0ff6d8bed docs: personalized dev environment (dotfiles + persistent home) 2026-06-24 09:58:42 -04:00
Gabriel Brown 2cd03b6a83 Settings: Dotfiles file-browser workspace
- New Settings → Dotfiles section: a mini-workspace rooted at home/{firstName}
  reusing FileTree + the Monaco CodeEditor
- Drag-and-drop files/folders (FileSystem entries API) or upload a folder
  (webkitdirectory) / files; edit in-place; new file; delete
- Files stored relative to HOME via the encrypted userDotfiles API
- Repo & setup panel (public repo URL + ref + setup script path) writing
  userEnvironment; secrets nudge toward the Secrets feature
2026-06-24 09:57:11 -04:00
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
Gabriel Brown 683fc62129 Convex: per-user dotfiles + environment storage (encrypted)
- schema: userDotfiles (one encrypted row per file, HOME-relative path) and
  userEnvironment (home username + optional public dotfiles repo + setup command)
- userDotfiles.ts: list/remove/removeDirectory/rename + internal upsert/getRaw
- userDotfilesNode.ts ('use node'): putFile/importFiles/getFileContent (encrypt
  /decrypt via secretCrypto) + getEnvironmentForJob (worker-token, returns the
  owner's decrypted dotfiles + repo/setup config)
- userEnvironment.ts: getMine/updateMine + getRawEnvironmentForJobInternal
- model.ts: deriveHomeUsername + normalizeDotfilePath helpers
2026-06-24 09:38:43 -04:00
Gabriel Brown 32a71f00ca Switch agent job image to Fedora with QoL CLI tooling + neutral shell defaults
- Base fedora:41; reinstall toolchain (node 22, gcc/c++, neovim, tmux, git, etc.)
- Add QoL CLI from the user's Panama setup, all in default Fedora repos: zoxide,
  eza, bat, fzf, fd, gh, gum, ripgrep, bash-completion; oh-my-posh via installer;
  pnpm/yarn/bun via npm; keep codex@0.142.0 + opencode@1.17.9 pinned
- Ship neutral system-wide defaults that work even with an empty/mounted HOME:
  /etc/profile.d/spoon.sh (zoxide/eza/fzf/oh-my-posh init + aliases),
  /etc/tmux.conf (login-shell panes), /etc/spoon/omp.json (default prompt theme)
- .dockerignore: re-include docker/agent-job-rootfs into the build context
- Verified: codex runs a real turn on Fedora (exit 0); all tools present
2026-06-24 09:33:39 -04:00
26 changed files with 1697 additions and 178 deletions
+2 -1
View File
@@ -45,7 +45,8 @@ packages/backend/.convex
Thumbs.db
# Docker
docker
docker/*
!docker/agent-job-rootfs
Dockerfile
.dockerignore
+2
View File
@@ -46,6 +46,8 @@ export const env = {
process.env.SPOON_WORKER_TOKEN?.trim() ??
'',
terminalIdleMs: intEnv('SPOON_AGENT_TERMINAL_IDLE_MS', 1_800_000),
// How long a per-user box container survives with no active jobs/terminals.
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
workdir: process.env.SPOON_AGENT_WORKDIR?.trim() ?? '.local/agent-work',
hostWorkdir: process.env.SPOON_AGENT_HOST_WORKDIR?.trim(),
network: process.env.SPOON_AGENT_NETWORK?.trim(),
+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,
+182 -59
View File
@@ -1,4 +1,5 @@
import path from 'node:path';
import type { Readable } from 'node:stream';
import { execa } from 'execa';
import { env } from '../env';
@@ -80,18 +81,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 +116,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 +134,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 +162,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']),
],
@@ -218,8 +226,71 @@ export const execInWorkspaceContainer = async (args: {
};
};
// Shared line-streaming + result normalization for a started subprocess
// (used by both `docker run` and `docker exec` paths).
type StreamingSubprocess = {
stdout: Readable | null;
stderr: Readable | null;
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;
const streamSubprocess = async (
subprocess: StreamingSubprocess,
redact: (value: string) => string,
onStdoutLine?: (line: string) => Promise<void>,
onStderrLine?: (line: string) => Promise<void>,
): Promise<CommandResult> => {
let stdoutBuffer = '';
let stderrBuffer = '';
const output: string[] = [];
let lineHandlers = Promise.resolve();
const consume = async (
chunk: Buffer,
source: 'stdout' | 'stderr',
handler?: (line: string) => Promise<void>,
) => {
output.push(chunk.toString('utf8'));
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
const lines = next.split(/\r?\n/);
const remainder = lines.pop() ?? '';
if (source === 'stdout') stdoutBuffer = remainder;
else stderrBuffer = remainder;
for (const line of lines) {
if (handler) await handler(redact(line));
}
};
subprocess.stdout?.on('data', (chunk: Buffer) => {
lineHandlers = lineHandlers.then(() =>
consume(chunk, 'stdout', onStdoutLine),
);
});
subprocess.stderr?.on('data', (chunk: Buffer) => {
lineHandlers = lineHandlers.then(() =>
consume(chunk, 'stderr', onStderrLine),
);
});
let result: Awaited<StreamingSubprocess>;
try {
result = await subprocess;
} catch (error) {
await lineHandlers;
const outputText = output.join('');
const message =
error instanceof Error ? error.message : 'Container command failed.';
return {
exitCode: 1,
output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
};
}
await lineHandlers;
if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
return normalizeRunResult(result, output.join(''), redact);
};
export const streamInJobContainer = async (args: {
workdir: string;
containerHome?: string;
containerCwd?: string;
command: string[];
environment: Record<string, string>;
redact: (value: string) => string;
@@ -240,9 +311,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,
],
@@ -253,58 +324,110 @@ export const streamInJobContainer = async (args: {
timeout: args.timeoutMs,
},
);
let stdoutBuffer = '';
let stderrBuffer = '';
const output: string[] = [];
let lineHandlers = Promise.resolve();
const consume = async (
chunk: Buffer,
source: 'stdout' | 'stderr',
handler?: (line: string) => Promise<void>,
) => {
output.push(chunk.toString('utf8'));
const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
const lines = next.split(/\r?\n/);
const remainder = lines.pop() ?? '';
if (source === 'stdout') stdoutBuffer = remainder;
else stderrBuffer = remainder;
for (const line of lines) {
if (handler) {
await handler(args.redact(line));
}
}
};
subprocess.stdout.on('data', (chunk: Buffer) => {
lineHandlers = lineHandlers.then(() =>
consume(chunk, 'stdout', args.onStdoutLine),
return streamSubprocess(
subprocess,
args.redact,
args.onStdoutLine,
args.onStderrLine,
);
});
subprocess.stderr.on('data', (chunk: Buffer) => {
lineHandlers = lineHandlers.then(() =>
consume(chunk, 'stderr', args.onStderrLine),
);
});
let result: Awaited<typeof subprocess>;
try {
result = await subprocess;
} catch (error) {
await lineHandlers;
const outputText = output.join('');
const message =
error instanceof Error ? error.message : 'Container command failed.';
return {
exitCode: 1,
output: args.redact(`${outputText}${outputText ? '\n' : ''}${message}`),
};
}
await lineHandlers;
if (stdoutBuffer && args.onStdoutLine) {
await args.onStdoutLine(args.redact(stdoutBuffer));
}
if (stderrBuffer && args.onStderrLine) {
await args.onStderrLine(args.redact(stderrBuffer));
}
return normalizeRunResult(result, output.join(''), args.redact);
// Per-user persistent "box" container that all of a user's threads exec into
// (Phase 2). Started once, reused; the home volume persists state across stops.
export const userContainerName = (username: string) =>
`spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
export const ensureUserContainer = async (args: {
username: string;
workdir: string;
containerHome: string;
}): Promise<string> => {
await ensureJobImagePulled();
const name = userContainerName(args.username);
const inspect = await execa(
containerRuntime(),
['inspect', '-f', '{{.State.Running}}', name],
{ reject: false, stdin: 'ignore' },
);
if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
// Not running: remove any stale container, then start fresh.
await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
await execa(
containerRuntime(),
[
'run',
'-d',
'--name',
name,
'--memory',
'4g',
'--cpus',
'2',
...networkArgs(),
'-v',
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
'-w',
args.containerHome,
env.jobImage,
'sleep',
'infinity',
],
{ stdin: 'ignore' },
);
return name;
};
export const streamExecInContainer = async (args: {
containerName: string;
command: string[];
environment: Record<string, string>;
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
onStdoutLine?: (line: string) => Promise<void>;
onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
const subprocess = execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
args.containerName,
...args.command,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
);
return streamSubprocess(
subprocess,
args.redact,
args.onStdoutLine,
args.onStderrLine,
);
};
export const runExecInContainer = async (args: {
containerName: string;
command: string[];
environment: Record<string, string>;
containerCwd: string;
redact: (value: string) => string;
timeoutMs: number;
}): Promise<CommandResult> => {
const result = await execa(
containerRuntime(),
[
'exec',
...environmentArgs(args.environment),
'-w',
args.containerCwd,
args.containerName,
...args.command,
],
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
);
return normalizeRunResult(result, result.all, args.redact);
};
export const stopWorkspaceContainer = async (containerName: string) => {
+25 -70
View File
@@ -5,66 +5,12 @@ import Docker from 'dockerode';
import { WebSocketServer } from 'ws';
import { env } from './env';
import { containerVolumeSuffix, hostWorkspacePath } from './runtime/docker';
import { verifyTerminalToken } from './terminal-token';
import { acquireUserBox, releaseUserBox } from './user-container';
import { getTerminalWorkspace } from './worker';
const TERMINAL_IMAGE = env.terminalImage;
const IDLE_STOP_MS = env.terminalIdleMs;
const CONTAINER_WORKDIR = '/workspace/repo';
const docker = new Docker();
const containerName = (jobId: string) =>
`spoon-agent-term-${jobId.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;
type Session = { connections: number; idleTimer?: NodeJS.Timeout };
const sessions = new Map<string, Session>();
const ensureTerminalContainer = async (jobId: string, workdir: string) => {
const name = containerName(jobId);
const container = docker.getContainer(name);
const info = await container.inspect().catch(() => null);
if (info?.State.Running) return container;
if (info && !info.State.Running) {
await container.remove({ force: true }).catch(() => undefined);
}
const suffix = containerVolumeSuffix();
const source = hostWorkspacePath(workdir);
const created = await docker.createContainer({
name,
Image: TERMINAL_IMAGE,
Cmd: ['sleep', 'infinity'],
WorkingDir: CONTAINER_WORKDIR,
Tty: false,
Labels: { 'spoon.agent.terminal': jobId },
HostConfig: {
Binds: [`${source}:/workspace${suffix ? `:${suffix}` : ''}`],
NetworkMode: env.network,
Memory: 4 * 1024 * 1024 * 1024,
AutoRemove: false,
},
});
await created.start();
return created;
};
const stopTerminalContainer = async (jobId: string) => {
await docker
.getContainer(containerName(jobId))
.remove({ force: true })
.catch(() => undefined);
sessions.delete(jobId);
};
const scheduleIdleStop = (jobId: string) => {
const session = sessions.get(jobId);
if (!session || session.connections > 0) return;
session.idleTimer = setTimeout(() => {
void stopTerminalContainer(jobId);
}, IDLE_STOP_MS);
};
const bridge = async (ws: WebSocket, jobId: string) => {
const workspace = getTerminalWorkspace(jobId);
if (!workspace) {
@@ -72,17 +18,27 @@ const bridge = async (ws: WebSocket, jobId: string) => {
return;
}
const session = sessions.get(jobId) ?? { connections: 0 };
if (session.idleTimer) clearTimeout(session.idleTimer);
session.idleTimer = undefined;
session.connections += 1;
sessions.set(jobId, session);
// Hold the per-user box open while this terminal is connected; the agent and
// the terminal share the exact same container (Phase 2).
let boxName: string;
try {
boxName = await acquireUserBox({
username: workspace.username,
workdir: workspace.workdir,
containerHome: workspace.containerHome,
});
} catch (error) {
ws.close(
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
return;
}
let stream: Duplex | undefined;
let exec: Docker.Exec | undefined;
try {
const container = await ensureTerminalContainer(jobId, workspace.workdir);
exec = await container.exec({
exec = await docker.getContainer(boxName).exec({
// Reattach a persistent tmux session across reconnects when tmux is
// available; otherwise fall back to a plain login shell.
Cmd: [
@@ -94,9 +50,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}`),
],
});
@@ -106,8 +63,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
1011,
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
);
session.connections -= 1;
scheduleIdleStop(jobId);
releaseUserBox(workspace.username);
return;
}
@@ -143,13 +99,12 @@ const bridge = async (ws: WebSocket, jobId: string) => {
activeStream.write(data);
});
let released = false;
const cleanup = () => {
if (released) return;
released = true;
activeStream.end();
const current = sessions.get(jobId);
if (current) {
current.connections = Math.max(0, current.connections - 1);
scheduleIdleStop(jobId);
}
releaseUserBox(workspace.username);
};
ws.on('close', cleanup);
ws.on('error', cleanup);
+40
View File
@@ -0,0 +1,40 @@
import { env } from './env';
import {
ensureUserContainer,
stopWorkspaceContainer,
userContainerName,
} from './runtime/docker';
// Phase 2: one persistent "box" container per user that all of their threads
// (agent turns + terminal + commands) exec into. Reference-counted so it stays
// up while any thread workspace is active or a terminal is connected, and is
// reaped after an idle period once nothing holds it.
type Box = { refs: number; idleTimer?: NodeJS.Timeout };
const boxes = new Map<string, Box>();
export const acquireUserBox = async (args: {
username: string;
workdir: string;
containerHome: string;
}): Promise<string> => {
const name = await ensureUserContainer(args);
const box = boxes.get(args.username) ?? { refs: 0 };
if (box.idleTimer) {
clearTimeout(box.idleTimer);
box.idleTimer = undefined;
}
box.refs += 1;
boxes.set(args.username, box);
return name;
};
export const releaseUserBox = (username: string) => {
const box = boxes.get(username);
if (!box) return;
box.refs = Math.max(0, box.refs - 1);
if (box.refs > 0) return;
box.idleTimer = setTimeout(() => {
void stopWorkspaceContainer(userContainerName(username));
boxes.delete(username);
}, env.boxIdleMs);
};
+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 { runExecInContainer } 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;
boxName: string;
userEnv: UserEnvironment;
redact: (value: string) => string;
}): Promise<void> => {
const { homeDir, containerHome, boxName, 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 runExecInContainer({
containerName: boxName,
command: ['bash', '-lc', script],
containerCwd: containerHome,
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);
}
};
+107 -30
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,
@@ -40,11 +36,13 @@ import {
import { createRedactor, truncate } from './redact';
import {
listWorkspaceContainerNames,
runInJobContainer,
runExecInContainer,
startWorkspaceContainer,
stopWorkspaceContainer,
streamInJobContainer,
streamExecInContainer,
} from './runtime/docker';
import { acquireUserBox, releaseUserBox } from './user-container';
import { fetchUserEnvironment, materializeUserHome } from './user-environment';
type Claim = {
job: {
@@ -98,8 +96,17 @@ 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;
// Phase 2: the per-user box container this thread execs into.
boxName: string;
githubToken: string;
redact: (value: string) => string;
runtimeMode?: 'opencode_server' | 'codex_exec' | 'legacy_cli';
@@ -620,6 +627,8 @@ const ensureOpenCodeSession = async (workspace: ActiveWorkspace) => {
);
const container = await startWorkspaceContainer({
workdir: workspace.workdir,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
containerName,
environment: {
...aiEnv,
@@ -649,7 +658,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 +730,7 @@ const runCodexTurn = async (args: {
outputFileName,
);
const outputFileContainerPath = path.posix.join(
codexContainerWorkspace,
workspace.containerHome,
'.codex',
outputFileName,
);
@@ -747,15 +756,16 @@ 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,
const result = await streamExecInContainer({
containerName: workspace.boxName,
containerCwd: workspace.containerRepo,
command,
environment: {
...aiEnv,
@@ -866,7 +876,7 @@ const runOpenCodeTurn = async (args: {
session,
prompt,
model: opencodeModel(workspace.claim),
directory: '/workspace/repo',
directory: workspace.containerRepo,
});
await turnDone;
};
@@ -998,27 +1008,29 @@ const runProjectCommand = async (args: {
command: string;
phase: 'install' | 'check' | 'test';
claim: Claim;
workdir: string;
boxName: string;
containerHome: string;
containerCwd: string;
repoDir: string;
redact: (value: string) => string;
}) => {
await appendEvent(args.claim.job._id, 'info', args.phase, args.command);
const secretEnv = Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
);
const result =
env.runtime === 'docker'
? await runInJobContainer({
workdir: args.workdir,
? await runExecInContainer({
containerName: args.boxName,
command: commandToShell(args.command),
environment: Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
),
containerCwd: args.containerCwd,
environment: { HOME: args.containerHome, ...secretEnv },
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
})
: await run('bash', ['-lc', args.command], {
cwd: args.repoDir,
env: Object.fromEntries(
args.claim.secrets.map((secret) => [secret.name, secret.value]),
),
env: secretEnv,
redact: args.redact,
timeoutMs: env.jobTimeoutMs,
});
@@ -1268,9 +1280,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 ?? '',
@@ -1278,6 +1296,7 @@ const runClaim = async (claim: Claim) => {
...claim.secrets.map((secret) => secret.value),
].filter(Boolean);
const redact = createRedactor(secretValues);
let acquiredBoxUser: string | undefined;
try {
if ((claim.job.runtime ?? 'opencode') !== 'opencode') {
throw new Error('Legacy OpenAI direct jobs are no longer supported.');
@@ -1288,8 +1307,36 @@ 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,
);
// Start (or reuse) the persistent per-user box that this thread — and the
// terminal — exec into. It mounts the home, so the clone below is visible.
const boxName = await acquireUserBox({
username,
workdir: homeDir,
containerHome,
});
acquiredBoxUser = username;
const repoDir = await cloneRepository({
workdir,
workdir: checkoutParent,
dirName: branchSlug,
token: githubToken,
owner: claim.job.forkOwner,
repo: claim.job.forkRepo,
@@ -1300,11 +1347,31 @@ const runClaim = async (claim: Claim) => {
});
const workspace: ActiveWorkspace = {
claim,
workdir,
workdir: homeDir,
homeDir,
username,
containerHome,
containerRepo,
repoDir,
boxName,
githubToken,
redact,
};
if (userEnv) {
await appendEvent(
jobId,
'info',
'clone',
'Applying your dotfiles and environment.',
);
await materializeUserHome({
homeDir,
containerHome,
boxName,
userEnv,
redact,
});
}
if (isCodexLoginProfile(claim)) {
await prepareCodexAuth(workspace);
}
@@ -1366,6 +1433,7 @@ const runClaim = async (claim: Claim) => {
).catch((stopError: unknown) => {
console.error(stopError);
});
if (acquiredBoxUser) releaseUserBox(acquiredBoxUser);
}
};
@@ -1451,7 +1519,9 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
command,
phase: command.includes('test') ? 'test' : 'check',
claim: workspace.claim,
workdir: workspace.workdir,
boxName: workspace.boxName,
containerHome: workspace.containerHome,
containerCwd: workspace.containerRepo,
repoDir: workspace.repoDir,
redact: workspace.redact,
});
@@ -1471,6 +1541,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 +1605,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 +1854,9 @@ 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;
// release the box (reaped once no other thread/terminal holds it).
releaseUserBox(workspace.username);
return {
pullRequestUrl: pullRequest.html_url,
pullRequestNumber: pullRequest.number,
@@ -1796,7 +1871,9 @@ 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;
// release the box (reaped once no other thread/terminal holds it).
releaseUserBox(workspace.username);
return { success: true };
};
Binary file not shown.
@@ -0,0 +1,22 @@
'use server';
import { DotfilesManager } from '@/components/settings/dotfiles/dotfiles-manager';
const SettingsDotfilesPage = () => {
return (
<section className='space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Dotfiles</h2>
<p className='text-muted-foreground mt-1 text-sm'>
Your personal shell, editor, and tool config applied to the
workspace terminal in every thread. Files are placed relative to your
home directory (e.g. <code>.bashrc</code>,{' '}
<code>.config/nvim/init.lua</code>).
</p>
</div>
<DotfilesManager />
</section>
);
};
export default SettingsDotfilesPage;
+2 -1
View File
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Brain, Github, ServerCog, Shield, User } from 'lucide-react';
import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react';
import { cn } from '@spoon/ui';
@@ -11,6 +11,7 @@ const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain },
{ href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog },
{ href: '/settings/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield },
];
+16
View File
@@ -2,6 +2,22 @@
@import 'tw-animate-css';
@import '@spoon/tailwind-config/theme';
/*
* Nerd Font icons for the workspace terminal + editor. Scoped to the Nerd Font
* glyph ranges via unicode-range, so the ~1MB file is only fetched when an icon
* actually renders (latin text stays on Victor Mono). Used as a fallback in the
* terminal/editor font stacks.
*/
@font-face {
font-family: 'Symbols Nerd Font Mono';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/fonts/SymbolsNerdFontMono.woff2') format('woff2');
unicode-range:
U+23fb-23fe, U+2665, U+26a1, U+2b58, U+e000-f8ff, U+f0000-fffff;
}
@source '../../../../packages/ui/src/**/*.{ts,tsx}';
@custom-variant dark (&:where(.dark, .dark *));
@@ -20,7 +20,7 @@ const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), {
});
const EDITOR_FONT_FAMILY =
"var(--font-victor-mono), 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace";
type MonacoEditorInstance = {
getModel?: () => unknown;
@@ -9,7 +9,7 @@ import { Button } from '@spoon/ui';
import '@xterm/xterm/css/xterm.css';
const TERMINAL_FONT =
"var(--font-victor-mono), 'Geist Mono', ui-monospace, monospace";
"var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, monospace";
type Status = 'connecting' | 'connected' | 'closed' | 'error' | 'unconfigured';
@@ -146,6 +146,15 @@ export const WorkspaceTerminal = ({
fit.fit();
termRef.current = term;
// Pull in the Nerd Font icon glyphs (loaded lazily by unicode-range) and
// repaint once ready so powerline/oh-my-posh/eza icons render.
void document.fonts
.load("16px 'Symbols Nerd Font Mono'", '\ue0b0')
.then(() => {
if (!isAborted()) term.refresh(0, term.rows - 1);
})
.catch(() => undefined);
const sendResize = () => {
if (ws?.readyState !== WebSocket.OPEN) return;
ws.send(
@@ -0,0 +1,453 @@
'use client';
import type { FileTreeNode } from '@/components/agent-workspace/types';
import { useEffect, useMemo, useRef, useState } from 'react';
import { CodeEditor } from '@/components/agent-workspace/code-editor';
import { FileTree } from '@/components/agent-workspace/file-tree';
import { useAction, useMutation, useQuery } from 'convex/react';
import { FilePlus, FolderUp, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, Input, Label } from '@spoon/ui';
type DotfileMeta = {
_id: string;
path: string;
size: number;
isExecutable: boolean;
updatedAt: number;
};
type UploadFile = { path: string; content: string; isExecutable?: boolean };
// Minimal typed surface of the drag-and-drop FileSystem entry API.
type FsEntry = {
isFile: boolean;
isDirectory: boolean;
name: string;
file?: (cb: (f: File) => void, err: (e: unknown) => void) => void;
createReader?: () => {
readEntries: (
cb: (e: FsEntry[]) => void,
err: (e: unknown) => void,
) => void;
};
};
const buildTree = (files: DotfileMeta[], rootLabel: string): FileTreeNode => {
const root: FileTreeNode = {
name: rootLabel,
path: '',
type: 'directory',
children: [],
};
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
const segments = file.path.split('/');
let node = root;
segments.forEach((segment, index) => {
const isLeaf = index === segments.length - 1;
const childPath = segments.slice(0, index + 1).join('/');
node.children ??= [];
let child = node.children.find((c) => c.path === childPath);
if (!child) {
child = {
name: segment,
path: childPath,
type: isLeaf ? 'file' : 'directory',
children: isLeaf ? undefined : [],
};
node.children.push(child);
}
node = child;
});
}
return root;
};
const readAllEntries = (reader: {
readEntries: (cb: (e: FsEntry[]) => void, err: (e: unknown) => void) => void;
}) =>
new Promise<FsEntry[]>((resolve, reject) => {
const all: FsEntry[] = [];
const next = () =>
reader.readEntries((batch) => {
if (batch.length === 0) resolve(all);
else {
all.push(...batch);
next();
}
}, reject);
next();
});
const collectEntry = async (
entry: FsEntry,
prefix: string,
out: UploadFile[],
) => {
if (entry.isFile && entry.file) {
const file = await new Promise<File>((res, rej) => entry.file?.(res, rej));
out.push({ path: `${prefix}${entry.name}`, content: await file.text() });
} else if (entry.isDirectory && entry.createReader) {
const entries = await readAllEntries(entry.createReader());
for (const child of entries) {
await collectEntry(child, `${prefix}${entry.name}/`, out);
}
}
};
export const DotfilesManager = () => {
const settings = useQuery(api.userEnvironment.getMine);
const filesQuery = useQuery(api.userDotfiles.listMine);
const files = useMemo(
() => (filesQuery ?? []) as DotfileMeta[],
[filesQuery],
);
const getFileContent = useAction(api.userDotfilesNode.getFileContent);
const putFile = useAction(api.userDotfilesNode.putFile);
const importFiles = useAction(api.userDotfilesNode.importFiles);
const removeFile = useMutation(api.userDotfiles.remove);
const updateEnv = useMutation(api.userEnvironment.updateMine);
const [selected, setSelected] = useState<DotfileMeta>();
const [content, setContent] = useState('');
const [savedContent, setSavedContent] = useState('');
const [expandedOverride, setExpandedOverride] = useState<string[] | null>(
null,
);
const [dragOver, setDragOver] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const filesInputRef = useRef<HTMLInputElement>(null);
const firstName = settings?.firstName ?? 'you';
const tree = useMemo(
() => buildTree(files, `home/${firstName}`),
[files, firstName],
);
// Directories default to expanded; once the user toggles, their choice wins.
const allDirs = useMemo(
() =>
files
.flatMap((f) => {
const segs = f.path.split('/');
return segs
.slice(0, -1)
.map((_, i) => segs.slice(0, i + 1).join('/'));
})
.filter((v, i, a) => a.indexOf(v) === i),
[files],
);
const expanded = expandedOverride ?? allDirs;
const openFile = async (path: string) => {
const file = files.find((f) => f.path === path);
if (!file) return; // directory
setSelected(file);
setContent('');
setSavedContent('');
try {
const { content: text } = await getFileContent({
fileId: file._id as never,
});
setContent(text);
setSavedContent(text);
} catch {
toast.error('Could not open file.');
}
};
const saveSelected = async (next: string) => {
if (!selected) return;
await putFile({
path: selected.path,
content: next,
isExecutable: selected.isExecutable,
});
setSavedContent(next);
toast.success('Saved.');
};
const importAll = async (incoming: UploadFile[]) => {
const valid = incoming.filter((f) => f.path.trim());
if (valid.length === 0) return;
try {
await importFiles({ files: valid });
toast.success(`Imported ${valid.length} file(s).`);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Import failed.');
}
};
const onDrop = async (event: React.DragEvent) => {
event.preventDefault();
setDragOver(false);
const out: UploadFile[] = [];
const entries: FsEntry[] = [];
for (const item of Array.from(event.dataTransfer.items)) {
const entry = item.webkitGetAsEntry() as FsEntry | null;
if (entry) entries.push(entry);
}
if (entries.length > 0) {
for (const entry of entries) await collectEntry(entry, '', out);
} else {
for (const file of Array.from(event.dataTransfer.files)) {
out.push({ path: file.name, content: await file.text() });
}
}
await importAll(out);
};
const onPickFiles = async (
event: React.ChangeEvent<HTMLInputElement>,
stripFirstSegment: boolean,
) => {
const picked = Array.from(event.target.files ?? []);
const out: UploadFile[] = [];
for (const file of picked) {
const relative =
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
file.name;
const path = stripFirstSegment
? relative.split('/').slice(1).join('/')
: relative;
out.push({ path, content: await file.text() });
}
event.target.value = '';
await importAll(out);
};
const newFile = async () => {
const path = window.prompt('New file path (relative to home):', '.bashrc');
if (!path?.trim()) return;
try {
await putFile({ path: path.trim(), content: '' });
toast.success('Created.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Could not create.');
}
};
const deleteSelected = async () => {
if (!selected) return;
await removeFile({ fileId: selected._id as never });
setSelected(undefined);
setContent('');
setSavedContent('');
toast.success('Deleted.');
};
return (
<div className='space-y-4'>
<Card className='gap-0 overflow-hidden p-0 shadow-none'>
<div className='border-border flex flex-wrap items-center gap-2 border-b p-2'>
<Button type='button' variant='outline' size='sm' onClick={newFile}>
<FilePlus className='size-4' /> New file
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => folderInputRef.current?.click()}
>
<FolderUp className='size-4' /> Upload folder
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => filesInputRef.current?.click()}
>
<Upload className='size-4' /> Upload files
</Button>
{selected ? (
<Button
type='button'
variant='outline'
size='sm'
className='text-destructive ml-auto'
onClick={() => void deleteSelected()}
>
<Trash2 className='size-4' /> Delete
</Button>
) : null}
<input
ref={folderInputRef}
type='file'
// @ts-expect-error non-standard but widely supported folder picker
webkitdirectory=''
multiple
hidden
onChange={(e) => void onPickFiles(e, true)}
/>
<input
ref={filesInputRef}
type='file'
multiple
hidden
onChange={(e) => void onPickFiles(e, false)}
/>
</div>
<div className='grid min-h-[28rem] grid-cols-1 md:grid-cols-[16rem_1fr]'>
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => void onDrop(e)}
className={`border-border min-h-0 overflow-auto border-b md:border-r md:border-b-0 ${
dragOver ? 'bg-primary/10' : ''
}`}
>
<FileTree
tree={tree}
selectedPath={selected?.path}
expandedPaths={expanded}
onSelect={(path) => void openFile(path)}
onToggleDirectory={(path) =>
setExpandedOverride(
expanded.includes(path)
? expanded.filter((p) => p !== path)
: [...expanded, path],
)
}
/>
{files.length === 0 ? (
<p className='text-muted-foreground p-4 text-center text-xs'>
Drag files or folders here, or use the buttons above. They land
relative to your home directory.
</p>
) : null}
</div>
<div className='min-h-0'>
{selected ? (
<CodeEditor
path={selected.path}
content={content}
savedContent={savedContent}
readOnly={false}
vimEnabled={false}
onSave={saveSelected}
onChange={setContent}
onVimEnabledChange={() => undefined}
/>
) : (
<div className='text-muted-foreground flex h-full items-center justify-center p-6 text-sm'>
Select a file to edit, or add files to get started.
</div>
)}
</div>
</div>
</Card>
<RepoPanel
settings={settings}
onSave={async (values) => {
await updateEnv(values);
toast.success('Saved.');
}}
/>
<p className='text-muted-foreground text-xs'>
Dotfiles are encrypted at rest. For real API keys or tokens, use the
Secrets feature on a Spoon instead those are injected as environment
variables.
</p>
</div>
);
};
const RepoPanel = ({
settings,
onSave,
}: {
settings:
| {
dotfilesRepoUrl?: string;
dotfilesRepoRef?: string;
setupCommand?: string;
}
| undefined;
onSave: (values: {
dotfilesRepoUrl?: string;
dotfilesRepoRef?: string;
setupCommand?: string;
}) => Promise<void>;
}) => {
const [repoUrl, setRepoUrl] = useState('');
const [repoRef, setRepoRef] = useState('');
const [setupCommand, setSetupCommand] = useState('');
const [saving, setSaving] = useState(false);
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
if (!settings || hydrated) return;
const timeout = window.setTimeout(() => {
setRepoUrl(settings.dotfilesRepoUrl ?? '');
setRepoRef(settings.dotfilesRepoRef ?? '');
setSetupCommand(settings.setupCommand ?? '');
setHydrated(true);
}, 0);
return () => window.clearTimeout(timeout);
}, [settings, hydrated]);
return (
<Card className='space-y-3 p-4 shadow-none'>
<div>
<h3 className='font-medium'>Dotfiles repo (optional)</h3>
<p className='text-muted-foreground text-xs'>
A public git repo cloned to <code>~/.dotfiles</code> on start. The
setup command runs in the container afterwards (e.g.{' '}
<code>install</code> to symlink, like a dotfiles bootstrap). Your
edited files above are applied on top.
</p>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<Label htmlFor='repoUrl'>Public repo URL</Label>
<Input
id='repoUrl'
placeholder='https://github.com/you/dotfiles'
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
/>
</div>
<div className='space-y-1'>
<Label htmlFor='repoRef'>Branch / ref (optional)</Label>
<Input
id='repoRef'
placeholder='main'
value={repoRef}
onChange={(e) => setRepoRef(e.target.value)}
/>
</div>
</div>
<div className='space-y-1'>
<Label htmlFor='setupCommand'>Setup script path (optional)</Label>
<Input
id='setupCommand'
placeholder='install.sh'
value={setupCommand}
onChange={(e) => setSetupCommand(e.target.value)}
/>
</div>
<Button
type='button'
size='sm'
disabled={saving}
onClick={() => {
setSaving(true);
void onSave({
dotfilesRepoUrl: repoUrl,
dotfilesRepoRef: repoRef,
setupCommand,
}).finally(() => setSaving(false));
}}
>
{saving ? 'Saving…' : 'Save repo settings'}
</Button>
</Card>
);
};
@@ -0,0 +1,40 @@
# Spoon container — neutral interactive shell defaults (system-wide).
# Tools here benefit everyone; a user's ~/.bashrc (loaded via ~/.bash_profile,
# which the worker ensures) layers on top and can override any of this.
# Interactive shells only.
case $- in
*i*) ;;
*) return ;;
esac
export EDITOR="${EDITOR:-nvim}"
export PAGER="${PAGER:-less}"
# User-local + bun install locations.
export PATH="$HOME/.local/bin:$HOME/.bun/bin:$PATH"
if command -v zoxide >/dev/null 2>&1; then
eval "$(zoxide init bash)"
fi
if command -v eza >/dev/null 2>&1; then
alias ls='eza --group-directories-first --icons'
alias ll='eza -lh --group-directories-first --icons --git'
alias la='eza -lha --group-directories-first --icons --git'
alias lt='eza --tree --level=2 --icons --git'
fi
command -v bat >/dev/null 2>&1 && alias cat='bat --paging=never --style=plain'
alias n='nvim'
alias g='git'
alias cl='clear'
# fzf keybindings + completion when present.
for f in /usr/share/fzf/shell/key-bindings.bash \
/usr/share/bash-completion/completions/fzf; do
[ -f "$f" ] && . "$f"
done
if command -v oh-my-posh >/dev/null 2>&1 && [ -f /etc/spoon/omp.json ]; then
eval "$(oh-my-posh init bash --config /etc/spoon/omp.json)"
fi
@@ -0,0 +1,44 @@
{
"$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json",
"version": 3,
"final_space": true,
"blocks": [
{
"type": "prompt",
"alignment": "left",
"segments": [
{
"type": "path",
"style": "plain",
"foreground": "#5fd0e0",
"template": " {{ .Path }} ",
"properties": { "style": "agnoster_short", "max_depth": 3 }
},
{
"type": "git",
"style": "plain",
"foreground": "#8fd6b4",
"template": "{{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} ",
"properties": {
"fetch_status": true,
"branch_icon": " "
}
}
]
},
{
"type": "prompt",
"alignment": "left",
"newline": true,
"segments": [
{
"type": "text",
"style": "plain",
"foreground": "#1fb895",
"foreground_templates": ["{{ if gt .Code 0 }}#f3625d{{ end }}"],
"template": " "
}
]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
# Spoon container — system tmux defaults. A user's ~/.config/tmux/tmux.conf (or
# ~/.tmux.conf) is read after this and overrides it.
# Login shells so /etc/profile.d/spoon.sh (tools) and ~/.bash_profile load.
set -g default-command "exec bash -l"
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
set -g mouse on
set -g history-limit 50000
set -g escape-time 10
set -g focus-events on
setw -g mode-keys vi
+42 -11
View File
@@ -1,30 +1,61 @@
FROM docker.io/library/node:22-bookworm
FROM registry.fedoraproject.org/fedora:41
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
ENV LANG=en_US.UTF-8
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
# Core toolchain + interactive/QoL CLI tooling. Everything below is in the
# default Fedora repos (no COPR needed). The QoL set mirrors the user's Panama
# workstation setup so the terminal feels like a real dev box for everyone.
RUN dnf install -y --setopt=install_weak_deps=False --nodocs \
bash \
bash-completion \
bat \
bubblewrap \
build-essential \
ca-certificates \
curl \
eza \
fd-find \
findutils \
fzf \
gcc \
gcc-c++ \
gh \
git \
glibc-langpack-en \
gum \
gzip \
jq \
less \
locales \
make \
ncurses \
neovim \
openssh-client \
nodejs \
nodejs-npm \
openssh-clients \
procps-ng \
python3 \
python3-pip \
ripgrep \
tar \
tmux \
unzip \
wget \
&& corepack enable \
&& corepack prepare pnpm@latest --activate \
&& corepack prepare yarn@stable --activate \
&& npm install -g bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
&& rm -rf /var/lib/apt/lists/*
which \
zoxide \
&& dnf clean all \
&& rm -rf /var/cache/dnf
# Package managers + pinned agent CLIs (kept identical to the prior image).
# Fedora's nodejs-npm doesn't ship corepack, so install pnpm/yarn via npm.
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 \
&& npm cache clean --force
# oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme).
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
&& oh-my-posh version
# Neutral system-wide defaults: /etc/profile.d/spoon.sh, /etc/tmux.conf, theme.
COPY docker/agent-job-rootfs/ /
WORKDIR /workspace
+78
View File
@@ -0,0 +1,78 @@
# Personalized dev environment (dotfiles + persistent home)
Makes the workspace terminal feel like the user's own machine: a Fedora image
preloaded with QoL CLI tooling, a persistent per-user home, and user dotfiles.
## The model
- **Persistent per-user home.** Each user gets a home directory on the worker
host at `${SPOON_AGENT_WORKDIR}/homes/{username}`, bind-mounted into every
job/terminal container at `/home/{username}` (`HOME`). It survives across
sessions, so dotfiles, installed tools, nvim plugins, and shell history persist.
`username` is derived from the user's profile first name (sanitized).
- **Threads as folders.** Each thread's checkout lives at
`~/Code/{spoon}/{branch}` inside that home, so every thread shows up as a
folder in one home. The agent (`codex --cd …`) and the terminal both open there.
- **Neutral defaults (everyone).** The Fedora job image
(`docker/agent-job.Dockerfile`) ships zoxide, eza, bat, fzf, fd, ripgrep, gh,
gum, neovim, tmux, oh-my-posh, etc., plus system-wide defaults that work even
with an empty home: `/etc/profile.d/spoon.sh` (tool init + aliases),
`/etc/tmux.conf` (login-shell panes), `/etc/spoon/omp.json` (prompt theme).
- **User dotfiles (per-user).** Configured in **Settings → Dotfiles**, applied on
top of the neutral defaults.
## Settings → Dotfiles
A mini file-browser workspace rooted at `home/{firstName}`:
- **Editable overlay tree** — drag in files/folders (or use Upload folder/files),
edit them in the Monaco editor, add/delete. Files are placed **relative to
`$HOME`** (`.bashrc``~/.bashrc`, `.config/nvim/…``~/.config/nvim/…`).
Stored encrypted at rest (`userDotfiles`, AES-256-GCM via `secretCrypto`).
- **Dotfiles repo (optional)** — a **public** git repo URL + optional ref + a
setup script path. On start the container clones it to `~/.dotfiles` and runs
`bash ~/.dotfiles/<setup>` (e.g. a bootstrap that symlinks configs, like the
user's Panama `install`).
- **Precedence (hybrid):** repo clone + setup runs first; then the editable
overlay is written on top — **overlay wins**.
Secrets: dotfiles are encrypted, but real API keys/tokens belong in a Spoon's
**Secrets** feature (injected as env vars), not in dotfiles. The UI nudges this.
## Materialization (worker)
`apps/agent-worker/src/user-environment.ts`:
1. `fetchUserEnvironment(jobId)` — a worker-token Convex action
(`userDotfilesNode.getEnvironmentForJob`) returns the owner's decrypted
dotfiles + repo/setup config.
2. `materializeUserHome` — ensures `~/.bash_profile` (so login shells source
`~/.bashrc` in a mounted home with no `/etc/skel`); clones the repo + runs the
setup command **inside the job image** (so the user's tools/paths apply), only
when the config hash changes (`~/.spoon/env-hash`); writes the overlay files.
## Configuration
| Variable | Default | Notes |
| ------------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `SPOON_AGENT_WORKDIR` | `.local/agent-work` (dev) / `/var/lib/spoon-agent/work` (prod) | Per-user homes live under `homes/{username}`; reuses the existing host-path translation. |
| `SPOON_ENCRYPTION_KEY` / `INSTANCE_SECRET` | — | Already required; encrypts dotfiles like other secrets. |
No new required env. The home is a host directory under the existing workdir, so
the prod bind-mount + `SPOON_AGENT_HOST_WORKDIR` translation already covers it.
## Notes / limits (Phase 1)
- **Repo auth:** public repos only. Private/self-hosted (e.g. Gitea) dotfiles
repos are a follow-up (store a token/deploy key).
- **Binary files:** the overlay is text-first.
- **Cleanup:** `~/Code/{spoon}/{branch}` checkouts persist (threads as folders);
a per-thread "delete checkout" action is a follow-up.
- **Concurrency:** jobs share one home; fine at the default
`SPOON_AGENT_MAX_CONCURRENT_JOBS=1`.
## Phase 2 north star
A single long-running per-user container that every thread `exec`s into (agent
via `docker exec`, not `docker run --rm`). The per-user home + `~/Code/{spoon}/
{branch}` layout built here is its foundation.
+90
View File
@@ -0,0 +1,90 @@
# Server deploy changes (terminal + dotfiles + Fedora + Phase 2)
Everything the production host / compose / `.env` needs for the workspace
terminal, personalized dev environment, Nerd Font, and the per-user container.
Most items have safe defaults; the **Required** ones are the only must-dos.
## Required
1. **Build-time env for the Next image** — add to the build env file (the one CI /
`scripts/build-next-app` passes as build args; e.g. `DOTENV_PROD`):
```
NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL=wss://worker.spoon.gbrown.org
```
This is a `NEXT_PUBLIC` (build-time) var — it must be present **when the
`spoon-next` image is built**, not just at runtime. Already wired into
`docker/Dockerfile` + `docker/compose.yml` build args. Without it, the
workspace **Terminal** tab shows "not configured".
2. **nginx: expose the worker for the terminal WebSocket.** Add a TLS server
block proxying the worker domain to the worker on the shared network, with WS
upgrade:
```nginx
server {
listen 443 ssl;
server_name worker.spoon.gbrown.org; # + your ssl_certificate lines
location / {
proxy_pass http://spoon-agent-worker:3921;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
}
```
3. **Rebuild + redeploy all three images** (CI does this on push to `main`):
`spoon-next`, `spoon-agent-worker`, and `spoon-agent-job` (now **Fedora**).
The worker auto-`docker pull`s the job image once per process, so a worker
restart picks up the new Fedora job image. Make sure the prod registry has the
new `spoon-agent-job:latest`.
4. **Deploy Convex functions** (new tables `userDotfiles`, `userEnvironment`).
`SPOON_ENCRYPTION_KEY` (or `INSTANCE_SECRET`) is already required and is what
encrypts dotfiles at rest — no change, just confirm it's set.
5. **Confirm `SPOON_AGENT_HOST_WORKDIR`** on the `spoon-agent-worker` service is
the absolute host path backing `SPOON_AGENT_WORKDIR` (the fix from the terminal
work). The per-user homes live under `${SPOON_AGENT_WORKDIR}/homes/{username}`
and are bind-mounted into the box via the host daemon — this only resolves if
the host-workdir translation is correct. (No new var; just verify.)
## Optional (safe defaults — only set to override)
On the `spoon-agent-worker` service:
| Var | Default | Purpose |
| ------------------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `SPOON_AGENT_TERMINAL_SECRET` | falls back to `SPOON_AGENT_WORKER_INTERNAL_TOKEN` | HMAC secret for terminal tokens (must match the Next app's, which also falls back). Leave unset to use the shared token. |
| `SPOON_AGENT_BOX_IDLE_MS` | `1800000` (30m) | How long a per-user box survives idle before being reaped. |
| `SPOON_AGENT_TERMINAL_IDLE_MS` | `1800000` | (Legacy; box idle now governs cleanup.) |
No new env is needed for dotfiles, the per-user home, or the Nerd Font.
## Notes / one-time cleanup
- **Layout change:** thread checkouts moved from `${WORKDIR}/{jobId}/repo` to
`${WORKDIR}/homes/{username}/Code/{spoon}/{branch}` (persistent). Old per-job
dirs are orphaned and safe to delete.
- **Containers:** per-thread agent containers (`docker run --rm`) and per-job
terminal containers (`spoon-agent-term-*`) are gone; everything runs in one
`spoon-box-{username}` per user. Any lingering `spoon-agent-term-*` containers
can be removed.
- **Resources:** each active user holds one box (4 GB mem cap, `sleep infinity`)
until 30m idle. Single-user = one box.
- Compose already mounts `/var/run/docker.sock` into the worker (unchanged) — the
box is created/exec'd through it.
## Quick post-deploy checks
```bash
docker exec spoon-agent-worker docker --version # CLI present (29.x)
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest codex --version # 0.142
docker run --rm git.gbrown.org/gib/spoon-agent-job:latest bash -lc 'eza --version; zoxide --version; oh-my-posh --version'
# then: open a thread → Terminal tab; Settings → Dotfiles add a .bashrc alias.
```
+25
View File
@@ -35,3 +35,28 @@ export const optionalText = (value: string | undefined) => {
if (!trimmed) return undefined;
return trimmed;
};
// Linux username for the per-user container home (/home/<username>). Derived
// from the first token of the profile name, sanitized; falls back to "user".
export const deriveHomeUsername = (name?: string): string => {
const first = (name ?? '').trim().split(/\s+/)[0] ?? '';
const sanitized = first.toLowerCase().replace(/[^a-z0-9_-]/g, '');
return sanitized || 'user';
};
// Normalizes a dotfile path to a safe HOME-relative path (no leading slash, no
// "..", no empty segments). Throws on anything that would escape HOME.
export const normalizeDotfilePath = (rawPath: string): string => {
const cleaned = rawPath
.trim()
.replace(/^\.\/+/, '')
.replace(/^\/+/, '');
const segments = cleaned.split('/').filter((s) => s.length > 0);
if (segments.length === 0) {
throw new ConvexError('A dotfile path is required.');
}
if (segments.some((s) => s === '..' || s === '.')) {
throw new ConvexError(`Invalid dotfile path: ${rawPath}`);
}
return segments.join('/');
};
+24
View File
@@ -348,6 +348,30 @@ const applicationTables = {
})
.index('by_user', ['userId'])
.index('by_user_provider', ['userId', 'provider']),
// Per-user dotfiles: one row per file, materialized into the workspace
// container's HOME. Content is encrypted at rest (reuses secretCrypto).
// `path` is relative to HOME, e.g. ".bashrc" or ".config/nvim/init.lua".
userDotfiles: defineTable({
ownerId: v.id('users'),
path: v.string(),
encryptedContent: v.string(),
size: v.number(),
isExecutable: v.optional(v.boolean()),
updatedAt: v.number(),
})
.index('by_owner', ['ownerId'])
.index('by_owner_path', ['ownerId', 'path']),
// Per-user environment config: the persistent home username + an optional
// public dotfiles repo and setup command run in the container.
userEnvironment: defineTable({
ownerId: v.id('users'),
enabled: v.boolean(),
homeUsername: v.optional(v.string()),
dotfilesRepoUrl: v.optional(v.string()),
dotfilesRepoRef: v.optional(v.string()),
setupCommand: v.optional(v.string()),
updatedAt: v.number(),
}).index('by_owner', ['ownerId']),
aiProviderProfiles: defineTable({
ownerId: v.id('users'),
name: v.string(),
+121
View File
@@ -0,0 +1,121 @@
import { ConvexError, v } from 'convex/values';
import type { Doc } from './_generated/dataModel';
import {
internalMutation,
internalQuery,
mutation,
query,
} from './_generated/server';
import { getRequiredUserId, normalizeDotfilePath } from './model';
const fileMeta = (file: Doc<'userDotfiles'>) => ({
_id: file._id,
path: file.path,
size: file.size,
isExecutable: file.isExecutable ?? false,
updatedAt: file.updatedAt,
});
/** Lists the user's dotfile tree (metadata only; content is fetched per-file). */
export const listMine = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const files = await ctx.db
.query('userDotfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
return files.map(fileMeta).sort((a, b) => a.path.localeCompare(b.path));
},
});
export const remove = mutation({
args: { fileId: v.id('userDotfiles') },
handler: async (ctx, { fileId }) => {
const ownerId = await getRequiredUserId(ctx);
const file = await ctx.db.get(fileId);
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
await ctx.db.delete(fileId);
return { success: true };
},
});
/** Removes every file under a directory prefix (e.g. deleting ".config/nvim"). */
export const removeDirectory = mutation({
args: { prefix: v.string() },
handler: async (ctx, { prefix }) => {
const ownerId = await getRequiredUserId(ctx);
const normalized = normalizeDotfilePath(prefix);
const files = await ctx.db
.query('userDotfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect();
const matches = files.filter(
(f) => f.path === normalized || f.path.startsWith(`${normalized}/`),
);
await Promise.all(matches.map((f) => ctx.db.delete(f._id)));
return { removed: matches.length };
},
});
export const rename = mutation({
args: { fileId: v.id('userDotfiles'), path: v.string() },
handler: async (ctx, { fileId, path }) => {
const ownerId = await getRequiredUserId(ctx);
const file = await ctx.db.get(fileId);
if (file?.ownerId !== ownerId) throw new ConvexError('Dotfile not found.');
const normalized = normalizeDotfilePath(path);
const clash = await ctx.db
.query('userDotfiles')
.withIndex('by_owner_path', (q) =>
q.eq('ownerId', ownerId).eq('path', normalized),
)
.unique();
if (clash && clash._id !== fileId) {
throw new ConvexError(`A dotfile already exists at ${normalized}.`);
}
await ctx.db.patch(fileId, { path: normalized, updatedAt: Date.now() });
return { success: true };
},
});
// Read by the decrypting Node action (userDotfilesNode.getFileContent).
export const getRawFileInternal = internalQuery({
args: { fileId: v.id('userDotfiles') },
handler: async (ctx, { fileId }) => await ctx.db.get(fileId),
});
// Called by the encrypting Node action (userDotfilesNode). Upserts one file by
// (owner, path).
export const upsertFileInternal = internalMutation({
args: {
ownerId: v.id('users'),
path: v.string(),
encryptedContent: v.string(),
size: v.number(),
isExecutable: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query('userDotfiles')
.withIndex('by_owner_path', (q) =>
q.eq('ownerId', args.ownerId).eq('path', args.path),
)
.unique();
const now = Date.now();
if (existing) {
await ctx.db.patch(existing._id, {
encryptedContent: args.encryptedContent,
size: args.size,
isExecutable: args.isExecutable,
updatedAt: now,
});
return existing._id;
}
return await ctx.db.insert('userDotfiles', {
...args,
updatedAt: now,
});
},
});
+136
View File
@@ -0,0 +1,136 @@
'use node';
import { getAuthUserId } from '@convex-dev/auth/server';
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { ActionCtx } from './_generated/server';
import { internal } from './_generated/api';
import { action } from './_generated/server';
import { normalizeDotfilePath } from './model';
import { decryptSecret, encryptSecret } from './secretCrypto';
const MAX_FILE_BYTES = 512 * 1024; // 512 KB per dotfile
const getRequiredUserId = async (ctx: ActionCtx): Promise<Id<'users'>> => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
return userId;
};
const requireWorkerToken = (workerToken: string) => {
const expected = process.env.SPOON_WORKER_TOKEN;
if (!expected) throw new ConvexError('Worker token is not configured.');
if (workerToken !== expected) throw new ConvexError('Invalid worker token.');
};
const putOne = async (
ctx: ActionCtx,
ownerId: Id<'users'>,
rawPath: string,
content: string,
isExecutable?: boolean,
) => {
const path = normalizeDotfilePath(rawPath);
const size = Buffer.byteLength(content, 'utf8');
if (size > MAX_FILE_BYTES) {
throw new ConvexError(`${path} is too large (max 512 KB).`);
}
await ctx.runMutation(internal.userDotfiles.upsertFileInternal, {
ownerId,
path,
encryptedContent: encryptSecret(content),
size,
isExecutable,
});
return path;
};
/** Create/update a single dotfile (used by the in-app editor and "new file"). */
export const putFile = action({
args: {
path: v.string(),
content: v.string(),
isExecutable: v.optional(v.boolean()),
},
handler: async (ctx, args): Promise<{ path: string }> => {
const ownerId = await getRequiredUserId(ctx);
const path = await putOne(
ctx,
ownerId,
args.path,
args.content,
args.isExecutable,
);
return { path };
},
});
/** Bulk import (drag-and-drop folder/files). */
export const importFiles = action({
args: {
files: v.array(
v.object({
path: v.string(),
content: v.string(),
isExecutable: v.optional(v.boolean()),
}),
),
},
handler: async (ctx, args): Promise<{ imported: number }> => {
const ownerId = await getRequiredUserId(ctx);
for (const file of args.files) {
await putOne(ctx, ownerId, file.path, file.content, file.isExecutable);
}
return { imported: args.files.length };
},
});
/** Decrypts one file's content for the editor (owner only). */
export const getFileContent = action({
args: { fileId: v.id('userDotfiles') },
handler: async (ctx, { fileId }): Promise<{ content: string }> => {
const ownerId = await getRequiredUserId(ctx);
const file = await ctx.runQuery(internal.userDotfiles.getRawFileInternal, {
fileId,
});
if (file?.ownerId !== ownerId) {
throw new ConvexError('Dotfile not found.');
}
return { content: decryptSecret(file.encryptedContent) };
},
});
type WorkerEnvironment = {
username: string;
enabled: boolean;
dotfilesRepoUrl?: string;
dotfilesRepoRef?: string;
setupCommand?: string;
files: { path: string; content: string; isExecutable: boolean }[];
};
/** Worker-facing: the job owner's full environment with dotfiles decrypted. */
export const getEnvironmentForJob = action({
args: { workerToken: v.string(), jobId: v.id('agentJobs') },
handler: async (ctx, args): Promise<WorkerEnvironment | null> => {
requireWorkerToken(args.workerToken);
const raw = await ctx.runQuery(
internal.userEnvironment.getRawEnvironmentForJobInternal,
{ jobId: args.jobId },
);
if (!raw) return null;
return {
username: raw.username,
enabled: raw.enabled,
dotfilesRepoUrl: raw.dotfilesRepoUrl,
dotfilesRepoRef: raw.dotfilesRepoRef,
setupCommand: raw.setupCommand,
files: raw.files.map((f) => ({
path: f.path,
content: decryptSecret(f.encryptedContent),
isExecutable: f.isExecutable,
})),
};
},
});
@@ -0,0 +1,96 @@
import { ConvexError, v } from 'convex/values';
import type { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
import { internalQuery, mutation, query } from './_generated/server';
import { deriveHomeUsername, getRequiredUserId, optionalText } from './model';
const loadSettings = async (ctx: QueryCtx, ownerId: Id<'users'>) =>
await ctx.db
.query('userEnvironment')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.unique();
/** Current user's environment settings + the resolved home username/first name. */
export const getMine = query({
args: {},
handler: async (ctx) => {
const ownerId = await getRequiredUserId(ctx);
const [user, settings] = await Promise.all([
ctx.db.get(ownerId),
loadSettings(ctx, ownerId),
]);
const firstName = (user?.name ?? '').trim().split(/\s+/)[0] || 'you';
const username = settings?.homeUsername ?? deriveHomeUsername(user?.name);
return {
enabled: settings?.enabled ?? true,
username,
firstName,
dotfilesRepoUrl: settings?.dotfilesRepoUrl,
dotfilesRepoRef: settings?.dotfilesRepoRef,
setupCommand: settings?.setupCommand,
};
},
});
export const updateMine = mutation({
args: {
enabled: v.optional(v.boolean()),
dotfilesRepoUrl: v.optional(v.string()),
dotfilesRepoRef: v.optional(v.string()),
setupCommand: v.optional(v.string()),
},
handler: async (ctx, args) => {
const ownerId = await getRequiredUserId(ctx);
const repoUrl = optionalText(args.dotfilesRepoUrl);
if (repoUrl && !/^https?:\/\//.test(repoUrl)) {
throw new ConvexError('Dotfiles repo must be a public http(s) URL.');
}
const existing = await loadSettings(ctx, ownerId);
const patch = {
enabled: args.enabled ?? existing?.enabled ?? true,
dotfilesRepoUrl: repoUrl,
dotfilesRepoRef: optionalText(args.dotfilesRepoRef),
setupCommand: optionalText(args.setupCommand),
updatedAt: Date.now(),
};
if (existing) {
await ctx.db.patch(existing._id, patch);
return { success: true };
}
await ctx.db.insert('userEnvironment', { ownerId, ...patch });
return { success: true };
},
});
// Worker-facing: everything needed to materialize a job's owner's environment.
// Content stays encrypted here; the Node action decrypts it. Resolves the owner
// from the job.
export const getRawEnvironmentForJobInternal = internalQuery({
args: { jobId: v.id('agentJobs') },
handler: async (ctx, { jobId }) => {
const job = await ctx.db.get(jobId);
if (!job) return null;
const ownerId = job.ownerId;
const [user, settings, files] = await Promise.all([
ctx.db.get(ownerId),
loadSettings(ctx, ownerId),
ctx.db
.query('userDotfiles')
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
.collect(),
]);
return {
username: settings?.homeUsername ?? deriveHomeUsername(user?.name),
enabled: settings?.enabled ?? true,
dotfilesRepoUrl: settings?.dotfilesRepoUrl,
dotfilesRepoRef: settings?.dotfilesRepoRef,
setupCommand: settings?.setupCommand,
files: files.map((f) => ({
path: f.path,
encryptedContent: f.encryptedContent,
isExecutable: f.isExecutable ?? false,
})),
};
},
});