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
77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
import { createHmac } from 'node:crypto';
|
|
import { describe, expect, test } from 'vitest';
|
|
|
|
import {
|
|
parseBoxTokenUsername,
|
|
verifyBoxTerminalToken,
|
|
} from '../../src/terminal-token';
|
|
|
|
const mintBox = (username: string, expiresAt: number, secret: string) => {
|
|
const payload = `${expiresAt}.box.${username}`;
|
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
|
return `${payload}.${sig}`;
|
|
};
|
|
|
|
describe('verifyBoxTerminalToken', () => {
|
|
const secret = 'test-secret';
|
|
|
|
test('accepts a valid, unexpired, username-matched token', () => {
|
|
const token = mintBox('alice', Date.now() + 60_000, secret);
|
|
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(true);
|
|
});
|
|
|
|
test('rejects an expired token', () => {
|
|
const token = mintBox('alice', Date.now() - 1, secret);
|
|
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
|
|
});
|
|
|
|
test('rejects a token minted for another username', () => {
|
|
const token = mintBox('alice', Date.now() + 60_000, secret);
|
|
expect(verifyBoxTerminalToken(token, 'bob', secret)).toBe(false);
|
|
});
|
|
|
|
test('rejects a token signed with a different secret', () => {
|
|
const token = mintBox('alice', Date.now() + 60_000, 'other-secret');
|
|
expect(verifyBoxTerminalToken(token, 'alice', secret)).toBe(false);
|
|
});
|
|
|
|
test('rejects malformed input and an empty secret', () => {
|
|
expect(verifyBoxTerminalToken('garbage', 'alice', secret)).toBe(false);
|
|
expect(verifyBoxTerminalToken('', 'alice', secret)).toBe(false);
|
|
expect(
|
|
verifyBoxTerminalToken(mintBox('alice', Date.now() + 1000, ''), 'alice', ''),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('rejects a 3-part job token (cross-scheme confusion guard)', () => {
|
|
const payload = `${Date.now() + 60_000}.alice`;
|
|
const sig = createHmac('sha256', secret).update(payload).digest('hex');
|
|
const jobToken = `${payload}.${sig}`;
|
|
expect(verifyBoxTerminalToken(jobToken, 'alice', secret)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('parseBoxTokenUsername', () => {
|
|
test('extracts the username from a well-formed box token', () => {
|
|
const token = mintBox('alice', Date.now() + 60_000, 'test-secret');
|
|
expect(parseBoxTokenUsername(token)).toBe('alice');
|
|
});
|
|
|
|
test('returns null for a 3-part job token', () => {
|
|
expect(parseBoxTokenUsername('123.alice.sig')).toBeNull();
|
|
});
|
|
|
|
test('returns null when the literal box segment is missing', () => {
|
|
expect(parseBoxTokenUsername('123.notbox.alice.sig')).toBeNull();
|
|
});
|
|
|
|
test('returns null for an empty username segment', () => {
|
|
expect(parseBoxTokenUsername('123.box..sig')).toBeNull();
|
|
});
|
|
|
|
test('returns null for garbage input', () => {
|
|
expect(parseBoxTokenUsername('garbage')).toBeNull();
|
|
expect(parseBoxTokenUsername('')).toBeNull();
|
|
});
|
|
});
|