Add a USER-scoped `/box/terminal` WebSocket that acquires the user's box and opens a login shell rooted at `~`. Extract the shared real-TTY PTY bridging (buffered input, dockerode exec/start, resize, forward, cleanup) out of the job `bridge()` into `runShellBridge`, parameterized by how the box is acquired, the working directory, and the env; `bridge()` and the new `bridgeBox()` both call it so the job path is unchanged. The `/box/terminal` upgrade reads the username FROM the token (`parseBoxTokenUsername`, part[2] of the 4-part token), then verifies the whole token with `verifyBoxTerminalToken`; on success `bridgeBox` derives `boxHomePaths(username)`, seeds a minimal `.bash_profile` via a shared `ensureBashProfile` (extracted from `materializeUserHome`), acquires the box, and opens the shell at `containerHome` with `TERM`+`HOME` only (no per-job secrets). Cleanup releases the box handle. Manual verification (dockerode PTY I/O is daemon-dependent): - `bun run test:unit` 105 passed (incl. new `parseBoxTokenUsername` tests), `bun run typecheck` clean. - Raw upgrade against a live server: valid box token -> `101 Switching Protocols`; bad token -> refused (no upgrade). Job `/jobs/:id/terminal` branch unchanged. - `bridgeBox` path exercised: `acquireUserBox` created `spoon-box-<user>` and `ensureBashProfile` seeded `~/.bash_profile`. Interactive typing/ resize could not be observed on this host because dockerode cannot reach the podman API socket (same mechanism the job terminal uses, so no regression) — full browser flow is covered by later tasks. Claude-Session: https://claude.ai/code/session_01ScyZ1NhfN84LAnHpwhfEQk
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
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 { assertContainedRealPath } from './path-containment';
|
|
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("'", "'\\''")}'`;
|
|
|
|
/**
|
|
* A mounted home has no /etc/skel, so login shells wouldn't source ~/.bashrc.
|
|
* Write a minimal `.bash_profile` (only if absent) so an interactive login
|
|
* shell — the agent's, or the user's box terminal — loads their environment.
|
|
* Shared by `materializeUserHome` and the box terminal bridge.
|
|
*/
|
|
export const ensureBashProfile = async (homeDir: string): Promise<void> => {
|
|
await mkdir(homeDir, { recursive: true });
|
|
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',
|
|
);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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 ensureBashProfile(homeDir);
|
|
|
|
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 = await assertContainedRealPath(homeDir, file.path, {
|
|
forWrite: true,
|
|
});
|
|
await mkdir(path.dirname(target), { recursive: true });
|
|
await writeFile(target, file.content);
|
|
if (file.isExecutable) await chmod(target, 0o755);
|
|
}
|
|
};
|