Workspaces and Terminals
This commit is contained in:
@@ -1,12 +1,25 @@
|
||||
FROM imbios/bun-node:22-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
&& apt-get install -y \
|
||||
python3 make g++ zsh git curl wget ca-certificates \
|
||||
fortune-mod cowsay sudo gosu \
|
||||
zip unzip tree btop net-tools tmux \
|
||||
&& apt-get clean
|
||||
|
||||
RUN curl -LO https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
|
||||
&& tar -C /opt -xzf nvim-linux-x86_64.tar.gz \
|
||||
&& rm nvim-linux-x86_64.tar.gz
|
||||
|
||||
ENV PATH="/opt/nvim-linux-x86_64/bin:${PATH}"
|
||||
|
||||
RUN git clone --depth 1 https://github.com/LazyVim/starter /opt/lazyvim-starter \
|
||||
&& rm -rf /opt/lazyvim-starter/.git
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pty-sidecar.mjs /app/pty-sidecar.mjs
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
COPY templates /opt/terminal-templates
|
||||
|
||||
RUN npm init -y \
|
||||
@@ -23,6 +36,7 @@ RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VE
|
||||
&& chmod +x /usr/local/bin/eza \
|
||||
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
||||
|
||||
RUN mkdir -p /home/officer
|
||||
|
||||
WORKDIR /home/officer
|
||||
|
||||
@@ -32,4 +46,4 @@ ENV PATH="/usr/games:${PATH}"
|
||||
|
||||
EXPOSE 5337
|
||||
|
||||
CMD ["node", "/app/pty-sidecar.mjs"]
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
USERNAME="${TERMINAL_USER:-officer}"
|
||||
USER_UID="${TERMINAL_UID:-1000}"
|
||||
USER_GID="${TERMINAL_GID:-1000}"
|
||||
|
||||
# Remove any existing user/group with the target UID/GID
|
||||
EXISTING_USER=$(getent passwd "$USER_UID" | cut -d: -f1)
|
||||
if [ -n "$EXISTING_USER" ] && [ "$EXISTING_USER" != "$USERNAME" ]; then
|
||||
userdel "$EXISTING_USER" 2>/dev/null || true
|
||||
fi
|
||||
EXISTING_GROUP=$(getent group "$USER_GID" | cut -d: -f1)
|
||||
if [ -n "$EXISTING_GROUP" ] && [ "$EXISTING_GROUP" != "$USERNAME" ]; then
|
||||
groupdel "$EXISTING_GROUP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Create group and user
|
||||
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
|
||||
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/officer "$USERNAME" 2>/dev/null || true
|
||||
|
||||
# Passwordless sudo
|
||||
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
|
||||
chmod 0440 /etc/sudoers.d/terminal-user
|
||||
|
||||
# Seed LazyVim config if not present
|
||||
if [ ! -d /home/officer/.config/nvim ]; then
|
||||
mkdir -p /home/officer/.config
|
||||
cp -r /opt/lazyvim-starter /home/officer/.config/nvim
|
||||
chown -R "$USER_UID:$USER_GID" /home/officer/.config
|
||||
fi
|
||||
|
||||
# Run sidecar as the user
|
||||
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
|
||||
@@ -45,6 +45,11 @@ const ensureUserFiles = async (homeDir) => {
|
||||
await cp(join(templateDir, '.zshrc'), zshrcPath);
|
||||
}
|
||||
|
||||
const tmuxconfPath = join(homeDir, '.tmux.conf');
|
||||
if (!existsSync(tmuxconfPath)) {
|
||||
await cp(join(templateDir, '.tmux.conf'), tmuxconfPath);
|
||||
}
|
||||
|
||||
const starshipPath = join(homeDir, '.config', 'starship-officer.toml');
|
||||
if (!existsSync(starshipPath)) {
|
||||
await cp(join(templateDir, 'starship-officer.toml'), starshipPath);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
########## TPM AUTO-INSTALL + SESSION PERSISTENCE ##########
|
||||
|
||||
# Auto-install TPM if missing
|
||||
if-shell '[ ! -d ~/.tmux/plugins/tpm ]' \
|
||||
'run-shell "git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm"'
|
||||
|
||||
# Plugin list
|
||||
set -g @plugin 'tmux-plugins/tpm'
|
||||
|
||||
# remap prefix from 'C-b' to 'C-a'
|
||||
unbind C-b
|
||||
set-option -g prefix C-a
|
||||
bind-key C-a send-prefix
|
||||
|
||||
set -g base-index 1
|
||||
|
||||
# split panes using | and -
|
||||
unbind '"'
|
||||
unbind %
|
||||
bind | split-window -h
|
||||
bind - split-window -v
|
||||
|
||||
# reload config file (change file location to your the tmux.conf you want to use)
|
||||
unbind r
|
||||
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!" \; refresh-client -S
|
||||
|
||||
# switch panes using Alt-arrow without prefix
|
||||
bind -n M-Left select-pane -L
|
||||
bind -n M-Right select-pane -R
|
||||
bind -n M-Up select-pane -U
|
||||
bind -n M-Down select-pane -D
|
||||
# switch panes using Alt-HJKL without prefix
|
||||
bind -n M-h select-pane -L
|
||||
bind -n M-l select-pane -R
|
||||
bind -n M-k select-pane -U
|
||||
bind -n M-j select-pane -D
|
||||
|
||||
# Enable mouse control (clickable windows, panes, resizable panes)
|
||||
|
||||
# don't rename windows automatically
|
||||
set-option -g allow-rename off
|
||||
|
||||
######################
|
||||
### DESIGN CHANGES ###
|
||||
######################
|
||||
|
||||
# loud or quiet?
|
||||
set -g visual-activity off
|
||||
set -g visual-bell off
|
||||
set -g visual-silence off
|
||||
setw -g monitor-activity off
|
||||
set -g bell-action none
|
||||
|
||||
# modes
|
||||
setw -g clock-mode-colour colour12
|
||||
setw -g mode-style 'fg=colour1 bg=colour18 bold'
|
||||
|
||||
# panes
|
||||
set -g pane-border-style 'fg=colour19 bg=colour0'
|
||||
set -g pane-active-border-style 'bg=colour0 fg=colour9'
|
||||
|
||||
# statusbar
|
||||
set -g status-position bottom
|
||||
set -g status-justify left
|
||||
set -g status-style 'bg=colour2 fg=colour23'
|
||||
# set -g status-left '#[fg=white,bg=black,bold] pastilhas #[default]'
|
||||
set -g status-left '#[fg=#ffffff,bg=#000000,bold] #{USER}@#H #[default]'
|
||||
# set -g status-left-length 20
|
||||
set -g status-right '#[fg=#ffffff,bg=colour1] %d/%m #[fg=#ffffff,bg=colour8] %H:%M:%S '
|
||||
set -g status-right-length 50
|
||||
set -g status-left-length 20
|
||||
|
||||
setw -g window-status-current-style 'fg=colour1 bg=colour19 bold'
|
||||
setw -g window-status-current-format ' #I#[fg=colour249]:#[fg=colour255]#W#[fg=colour249]#F '
|
||||
|
||||
setw -g window-status-style 'fg=colour9 bg=colour18'
|
||||
setw -g window-status-format ' #I#[fg=colour237]:#[fg=colour250]#W#[fg=colour244]#F '
|
||||
|
||||
setw -g window-status-bell-style 'fg=colour255 bg=colour1 bold'
|
||||
# ...existing code...
|
||||
# messages
|
||||
set -g message-style 'fg=#ffffff bg=red bold'
|
||||
# Change the font color for the exit pane confirmation message
|
||||
set -g message-command-style 'fg=#ffffff bg=red bold'
|
||||
|
||||
# ...existing code...
|
||||
|
||||
# messages
|
||||
# set -g message-style 'fg=colour232 bg=colour16 bold'
|
||||
|
||||
##########################
|
||||
### END DESIGN CHANGES ###
|
||||
##########################
|
||||
|
||||
##########################
|
||||
### EASY MOUSE SCROLL ###
|
||||
##########################
|
||||
|
||||
set -g mouse on
|
||||
set -ga terminal-overrides ',*256color*:smcup@:rmcup@'
|
||||
@@ -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