merge conflict resolved

This commit is contained in:
2026-02-19 18:09:02 +00:00
14 changed files with 402 additions and 57 deletions
@@ -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"]
+34
View File
@@ -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
+5
View File
@@ -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@'
+50 -13
View File
@@ -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