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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user