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
+12
View File
@@ -28,6 +28,18 @@ export const verifyTerminalToken = (
);
};
// Extract the username a box token authorizes without verifying its signature.
// The token itself authorizes the connection, so the upgrade handler reads the
// username from it (rather than a query param) and then verifies the whole
// token against that username. Returns null unless the format matches
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`.
export const parseBoxTokenUsername = (token: string): string | null => {
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return null;
const username = parts[2];
return username ? username : null;
};
// User-scoped variant authorizing a terminal connection to a user's box.
// Embeds a literal `box` segment so its four parts never collide with the
// three-part job token above. Format: