Workspaces and Terminals
This commit is contained in:
@@ -5,13 +5,14 @@ import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
import { officerdb, Users } from 'officerdb';
|
||||
|
||||
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string };
|
||||
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string };
|
||||
type ShellInfo = { command: string; args: string[]; name: string };
|
||||
type BridgeSession = {
|
||||
client: ServerWebSocket<WSData>;
|
||||
sidecar: WebSocket | null;
|
||||
dockerId: string;
|
||||
port: number;
|
||||
pendingMessages: string[];
|
||||
};
|
||||
|
||||
type ContainerInfo = {
|
||||
@@ -99,7 +100,7 @@ const ensureDockerImage = () => {
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number): { dockerId: string } => {
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number, email: string): { dockerId: string } => {
|
||||
ensureDockerImage();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const dockerId = `officer-terminal-${userId}`;
|
||||
@@ -110,12 +111,15 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do
|
||||
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
}
|
||||
|
||||
let userArgs: string[] = [];
|
||||
const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
|
||||
let uid = 1000;
|
||||
let gid = 1000;
|
||||
try {
|
||||
const stats = statSync(homeDir);
|
||||
userArgs = ['--user', `${stats.uid}:${stats.gid}`];
|
||||
uid = stats.uid;
|
||||
gid = stats.gid;
|
||||
} catch {
|
||||
userArgs = [];
|
||||
// fallback to defaults
|
||||
}
|
||||
|
||||
const run = Bun.spawnSync({
|
||||
@@ -127,13 +131,18 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number): { do
|
||||
dockerId,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
...userArgs,
|
||||
'-p',
|
||||
`127.0.0.1:${port}:${port}`,
|
||||
'-e',
|
||||
`TERMINAL_PTY_PORT=${port}`,
|
||||
'-e',
|
||||
'TERMINAL_PTY_HOST=0.0.0.0',
|
||||
'-e',
|
||||
`TERMINAL_USER=${username}`,
|
||||
'-e',
|
||||
`TERMINAL_UID=${uid}`,
|
||||
'-e',
|
||||
`TERMINAL_GID=${gid}`,
|
||||
'-v',
|
||||
`${homeDir}:/home/officer`,
|
||||
'-w',
|
||||
@@ -211,7 +220,7 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str
|
||||
}
|
||||
|
||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||
const docker = startDockerSidecar(port, homeDir, userId);
|
||||
const docker = startDockerSidecar(port, homeDir, userId, email);
|
||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||
map[email] = next;
|
||||
await saveContainerMap(map);
|
||||
@@ -268,6 +277,12 @@ export const initTerminalSidecars = async () => {
|
||||
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
||||
const containerHome = '/home/officer';
|
||||
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||
return home;
|
||||
};
|
||||
|
||||
export const terminalWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
const { email, role, sandboxed } = ws.data;
|
||||
@@ -278,16 +293,20 @@ export const terminalWebsocket = {
|
||||
}
|
||||
|
||||
if (!sandboxed) {
|
||||
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: HOST_SIDECAR_PORT, pendingMessages: [] };
|
||||
sessions.set(ws, session);
|
||||
|
||||
let sidecar: WebSocket | null = null;
|
||||
try {
|
||||
sidecar = await connectSidecar(HOST_SIDECAR_PORT);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect host sidecar';
|
||||
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
||||
sessions.delete(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.set(ws, { client: ws, sidecar, dockerId: '', port: HOST_SIDECAR_PORT });
|
||||
session.sidecar = sidecar;
|
||||
|
||||
sidecar.addEventListener('message', (ev) => {
|
||||
try {
|
||||
@@ -306,11 +325,14 @@ export const terminalWebsocket = {
|
||||
type: 'init',
|
||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: process.env.HOME,
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME,
|
||||
userLabel: email,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||
session.pendingMessages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,6 +341,9 @@ export const terminalWebsocket = {
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
|
||||
sessions.set(ws, session);
|
||||
|
||||
let sidecar: WebSocket | null = null;
|
||||
let info: ContainerInfo | undefined;
|
||||
try {
|
||||
@@ -335,10 +360,13 @@ export const terminalWebsocket = {
|
||||
}
|
||||
sendOutput(ws, '\r\n[Process exited]\r\n');
|
||||
if (info) stopDockerSidecar(info.dockerId);
|
||||
sessions.delete(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.set(ws, { client: ws, sidecar, dockerId: info.dockerId, port: info.port });
|
||||
session.sidecar = sidecar;
|
||||
session.dockerId = info.dockerId;
|
||||
session.port = info.port;
|
||||
|
||||
sidecar.addEventListener('message', (ev) => {
|
||||
try {
|
||||
@@ -357,19 +385,28 @@ export const terminalWebsocket = {
|
||||
type: 'init',
|
||||
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
||||
shell: containerShell,
|
||||
cwd: containerHome,
|
||||
cwd: resolveCwd(containerHome, ws.data.cwd),
|
||||
homeDir: containerHome,
|
||||
userLabel: email,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||
session.pendingMessages = [];
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
const session = sessions.get(ws);
|
||||
if (!session?.sidecar || session.sidecar.readyState !== WebSocket.OPEN) return;
|
||||
if (!session) return;
|
||||
|
||||
const payload = typeof raw === 'string' ? raw : raw.toString();
|
||||
|
||||
if (!session.sidecar || session.sidecar.readyState !== WebSocket.OPEN) {
|
||||
session.pendingMessages.push(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = typeof raw === 'string' ? raw : raw.toString();
|
||||
session.sidecar.send(payload);
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
Reference in New Issue
Block a user