feat(worker): user-scoped /box/terminal websocket bridge

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
This commit is contained in:
Gabriel Brown
2026-07-11 12:25:32 -04:00
parent 192f547be5
commit 2c6a605646
4 changed files with 161 additions and 41 deletions
+18 -10
View File
@@ -32,6 +32,23 @@ export const fetchUserEnvironment = async (
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
@@ -47,16 +64,7 @@ export const materializeUserHome = async (args: {
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',
);
});
await ensureBashProfile(homeDir);
if (!userEnv.enabled) return;