fix(pi): add snap node compatibility diagnostics and documentation
- Added detailed error logging to detect snap node compatibility issues - When Pi process exits with code 1, log helpful diagnostic info including node path - Add hint to check for snap node and reinstall via apt/nvm - Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide - Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes - Provide clear installation instructions for NodeSource and nvm alternatives
This commit is contained in:
@@ -1,79 +0,0 @@
|
||||
FROM imbios/bun-node:22-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y \
|
||||
python3 python3-pip python3-venv make gcc g++ zsh git curl wget ca-certificates \
|
||||
sudo gosu locales \
|
||||
zip unzip tree btop net-tools tmux \
|
||||
procps psmisc lsof less file man-db \
|
||||
ripgrep fd-find jq htop sqlite3 \
|
||||
&& sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen \
|
||||
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean
|
||||
|
||||
ENV LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
|
||||
|
||||
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 \
|
||||
&& npm install ws@8.18.1 node-pty@1.1.0
|
||||
|
||||
RUN curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
||||
|
||||
RUN git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git /opt/oh-my-zsh
|
||||
|
||||
ENV EZA_VERSION=0.18.15
|
||||
RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_x86_64-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz \
|
||||
&& tar -xzf /tmp/eza.tar.gz -C /tmp \
|
||||
&& mv /tmp/eza /usr/local/bin/eza \
|
||||
&& chmod +x /usr/local/bin/eza \
|
||||
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
||||
|
||||
ENV LAZYGIT_VERSION=0.44.1
|
||||
RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_x86_64.tar.gz" -o /tmp/lazygit.tar.gz \
|
||||
&& tar -xzf /tmp/lazygit.tar.gz -C /tmp \
|
||||
&& mv /tmp/lazygit /usr/local/bin/lazygit \
|
||||
&& chmod +x /usr/local/bin/lazygit \
|
||||
&& rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md
|
||||
|
||||
|
||||
ENV GOLANG_VERSION=1.23.6
|
||||
RUN curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz" -o /tmp/go.tar.gz \
|
||||
&& tar -C /usr/local -xzf /tmp/go.tar.gz \
|
||||
&& rm /tmp/go.tar.gz
|
||||
|
||||
ENV PATH="/usr/local/go/bin:${PATH}"
|
||||
|
||||
ENV RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal \
|
||||
&& chmod -R a+rw $CARGO_HOME
|
||||
|
||||
ENV PATH="/usr/local/cargo/bin:${PATH}"
|
||||
|
||||
RUN npm install -g @mariozechner/pi-coding-agent @anthropic-ai/claude-code
|
||||
|
||||
# Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined
|
||||
RUN sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' \
|
||||
/usr/local/lib/node_modules/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js
|
||||
|
||||
WORKDIR /tmp
|
||||
|
||||
ENV TERMINAL_PTY_PORT=5337
|
||||
|
||||
|
||||
EXPOSE 5337
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/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
|
||||
mkdir -p /home/$USERNAME
|
||||
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/$USERNAME "$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/$USERNAME/.config/nvim ]; then
|
||||
mkdir -p /home/$USERNAME/.config
|
||||
cp -r /opt/lazyvim-starter /home/$USERNAME/.config/nvim
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
|
||||
fi
|
||||
|
||||
# Seed shell config files from templates if not present
|
||||
if [ ! -f /home/$USERNAME/.zshrc ]; then
|
||||
cp /opt/terminal-templates/.zshrc /home/$USERNAME/.zshrc
|
||||
chown "$USER_UID:$USER_GID" /home/$USERNAME/.zshrc
|
||||
fi
|
||||
|
||||
if [ ! -f /home/$USERNAME/.tmux.conf ]; then
|
||||
cp /opt/terminal-templates/.tmux.conf /home/$USERNAME/.tmux.conf
|
||||
chown "$USER_UID:$USER_GID" /home/$USERNAME/.tmux.conf
|
||||
fi
|
||||
|
||||
if [ ! -f /home/$USERNAME/.config/starship-officer.toml ]; then
|
||||
mkdir -p /home/$USERNAME/.config
|
||||
cp /opt/terminal-templates/starship-officer.toml /home/$USERNAME/.config/starship-officer.toml
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
|
||||
fi
|
||||
|
||||
if [ ! -d /home/$USERNAME/.oh-my-zsh ]; then
|
||||
cp -r /opt/oh-my-zsh /home/$USERNAME/.oh-my-zsh
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.oh-my-zsh
|
||||
fi
|
||||
|
||||
mkdir -p /home/$USERNAME/.local/bin
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.local
|
||||
|
||||
# Ensure Pi agent sessions directory exists and is writable
|
||||
mkdir -p /home/$USERNAME/.pi/agent/sessions
|
||||
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.pi
|
||||
|
||||
# Init git repo in home dir so Claude Code skips the workspace trust prompt
|
||||
if [ ! -d /home/$USERNAME/.git ]; then
|
||||
gosu "$USER_UID:$USER_GID" git init /home/$USERNAME >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Run sidecar as the user
|
||||
exec gosu "$USER_UID:$USER_GID" node /app/pty-sidecar.mjs
|
||||
@@ -1,3 +1,6 @@
|
||||
// Ignore SIGINT — sudo/pty child processes may propagate it
|
||||
process.on('SIGINT', () => {});
|
||||
|
||||
import http from 'node:http';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
@@ -14,10 +17,8 @@ const run = (cmd, args, opts = {}) =>
|
||||
});
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const isDocker = existsSync('/opt/terminal-templates/.zshrc');
|
||||
|
||||
const templateDir = isDocker ? '/opt/terminal-templates' : join(__dirname, 'templates');
|
||||
const ohMyZshSource = isDocker ? '/opt/oh-my-zsh' : null;
|
||||
const templateDir = join(__dirname, 'templates');
|
||||
|
||||
const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
|
||||
const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1';
|
||||
@@ -64,18 +65,7 @@ const ensureUserFiles = async (homeDir) => {
|
||||
|
||||
const ohMyZshPath = join(homeDir, '.oh-my-zsh');
|
||||
if (!existsSync(ohMyZshPath)) {
|
||||
if (ohMyZshSource && existsSync(ohMyZshSource)) {
|
||||
await cp(ohMyZshSource, ohMyZshPath, { recursive: true });
|
||||
} else {
|
||||
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isDocker) {
|
||||
const starshipBin = join(homeDir, '.local', 'bin', 'starship');
|
||||
if (!existsSync(starshipBin)) {
|
||||
await run('sh', ['-c', 'curl -sS https://starship.rs/install.sh | sh -s -- -y -b "$HOME/.local/bin"'], { env: { ...process.env, HOME: homeDir } });
|
||||
}
|
||||
await run('git', ['clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshPath]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -145,16 +135,31 @@ wss.on('connection', (ws) => {
|
||||
const cwd = msg.cwd ?? process.cwd();
|
||||
const homeDir = msg.homeDir ?? process.cwd();
|
||||
const userLabel = msg.userLabel ?? 'officer';
|
||||
const username = msg.username ?? null;
|
||||
const cols = msg.cols ?? 80;
|
||||
const rows = msg.rows ?? 24;
|
||||
const isHost = !!msg.host;
|
||||
|
||||
let spawnCommand;
|
||||
let spawnArgs;
|
||||
let ptyEnv;
|
||||
|
||||
if (isHost) {
|
||||
// Host session — spawn shell directly as current user
|
||||
spawnCommand = shell.command;
|
||||
spawnArgs = shell.args ?? [];
|
||||
ptyEnv = { ...process.env, TERM: 'xterm-256color', ...(msg.env ?? {}) };
|
||||
} else if (username) {
|
||||
// User session — spawn via sudo -u as the target Linux user
|
||||
spawnCommand = 'sudo';
|
||||
spawnArgs = ['-u', username, '-i', '/bin/zsh'];
|
||||
ptyEnv = {
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
} else {
|
||||
const prompt = `${userLabel} in %~ %# `;
|
||||
const bashPrompt = `${userLabel} \\w \\$ `;
|
||||
// Fallback — direct spawn with custom env (legacy)
|
||||
spawnCommand = shell.command;
|
||||
spawnArgs = shell.args ?? [];
|
||||
|
||||
try {
|
||||
await ensureUserFiles(homeDir);
|
||||
@@ -171,19 +176,21 @@ wss.on('connection', (ws) => {
|
||||
USER: userLabel,
|
||||
LOGNAME: userLabel,
|
||||
OFFICER_TERMINAL_USER: userLabel,
|
||||
PROMPT: prompt,
|
||||
PS1: bashPrompt,
|
||||
TERM: 'xterm-256color',
|
||||
};
|
||||
}
|
||||
|
||||
// For username sessions, don't set cwd — sudo -u -i will cd to the user's home.
|
||||
// node-pty does chdir before exec, so it would fail if the service user can't access the dir.
|
||||
const ptyCwd = username ? undefined : cwd;
|
||||
|
||||
let term;
|
||||
try {
|
||||
term = pty.spawn(shell.command, shell.args ?? [], {
|
||||
term = pty.spawn(spawnCommand, spawnArgs, {
|
||||
name: 'xterm-256color',
|
||||
cols,
|
||||
rows,
|
||||
cwd,
|
||||
cwd: ptyCwd,
|
||||
env: ptyEnv,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
skip_global_compinit=1
|
||||
@@ -1,6 +1,9 @@
|
||||
# If you come from bash you might have to change your $PATH.
|
||||
export PATH=$HOME/.local/bin:$PATH
|
||||
|
||||
# Skip insecure directory check (system zsh dirs may be group-writable)
|
||||
ZSH_DISABLE_COMPFIX=true
|
||||
|
||||
# Path to your Oh My Zsh installation.
|
||||
export ZSH="$HOME/.oh-my-zsh"
|
||||
|
||||
@@ -22,7 +25,7 @@ source $ZSH/oh-my-zsh.sh
|
||||
# ============================================================================
|
||||
# STARSHIP PROMPT
|
||||
# ============================================================================
|
||||
if [[ -n "$OFFICER_TERMINAL_USER" ]]; then
|
||||
if [[ -f "$HOME/.config/starship-officer.toml" ]]; then
|
||||
export STARSHIP_CONFIG="$HOME/.config/starship-officer.toml"
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
format = "$env_var:$hostname $directory $character"
|
||||
format = "$username:$hostname $directory $character"
|
||||
|
||||
[env_var]
|
||||
variable = "OFFICER_TERMINAL_USER"
|
||||
format = "[$env_value]($style)"
|
||||
style = "bold #0891B2"
|
||||
[username]
|
||||
show_always = true
|
||||
format = "[$user]($style)"
|
||||
style_user = "bold #0891B2"
|
||||
style_root = "bold red"
|
||||
|
||||
[hostname]
|
||||
ssh_only = false
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir, getGlobalSkillsDir, getGlobalToolsDir, getGlobalExtensionsDir, getUserSkillsDir, getUserToolsDir, DATA_PATH, PI_CONFIG_DIR, toShellUsername } from '@@/data-path';
|
||||
import { getUsers } from 'officerdb';
|
||||
import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
|
||||
const ensureDir = (dir: string) => { if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); return dir; };
|
||||
|
||||
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
|
||||
type ShellInfo = { command: string; args: string[]; name: string };
|
||||
type WSData = {
|
||||
userId: number;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
sandboxed: boolean;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
};
|
||||
type BridgeSession = {
|
||||
client: ServerWebSocket<WSData>;
|
||||
sidecar: WebSocket | null;
|
||||
dockerId: string;
|
||||
port: number;
|
||||
pendingMessages: string[];
|
||||
};
|
||||
|
||||
type ContainerInfo = {
|
||||
userId: number;
|
||||
email: string;
|
||||
dockerId: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const HOST_SIDECAR_PORT = 5338;
|
||||
|
||||
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
|
||||
const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json');
|
||||
|
||||
let dockerImageReady = false;
|
||||
let containersCache: Record<string, ContainerInfo> | null = null;
|
||||
let hostSidecarProcess: ReturnType<typeof import('bun').spawn> | null = null;
|
||||
|
||||
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
||||
@@ -43,14 +35,14 @@ const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const connectSidecar = async (port: number): Promise<WebSocket> => {
|
||||
const connectSidecar = async (): Promise<WebSocket> => {
|
||||
const delays = [200, 300, 500, 800, 1200, 1600, 2000];
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const delay of delays) {
|
||||
try {
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${HOST_SIDECAR_PORT}`);
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
socket.close();
|
||||
@@ -80,224 +72,9 @@ const connectSidecar = async (port: number): Promise<WebSocket> => {
|
||||
throw lastError ?? new Error('Terminal sidecar connection failed');
|
||||
};
|
||||
|
||||
const ensureDockerImage = () => {
|
||||
if (dockerImageReady) return;
|
||||
const dockerPath = Bun.which('docker');
|
||||
if (!dockerPath) throw new Error('Docker not found');
|
||||
|
||||
const tag = 'officer-terminal-sidecar:v1';
|
||||
const inspect = Bun.spawnSync({ cmd: [dockerPath, 'image', 'inspect', tag], stdout: 'ignore', stderr: 'ignore' });
|
||||
if (inspect.exitCode === 0) {
|
||||
dockerImageReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const dockerfilePath = fileURLToPath(new URL('./Dockerfile.terminal-sidecar', import.meta.url));
|
||||
const build = Bun.spawnSync({
|
||||
cmd: [dockerPath, 'build', '-f', dockerfilePath, '-t', tag, '.'],
|
||||
cwd: fileURLToPath(new URL('./', import.meta.url)),
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
if (build.exitCode !== 0) throw new Error('Failed to build terminal sandbox image');
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
// Check whether a container has all expected volume mounts.
|
||||
// Tests for multiple mount sources — if any is missing, the container should be recreated.
|
||||
const containerHasExpectedMounts = (dockerId: string): boolean => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({
|
||||
cmd: [dockerPath, 'inspect', '--format', '{{range .Mounts}}{{.Source}}\n{{end}}', dockerId],
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
});
|
||||
if (result.exitCode !== 0) return false;
|
||||
const mounts = result.stdout.toString();
|
||||
return mounts.includes(getGlobalSkillsDir()) && mounts.includes('/officer/data') && mounts.includes('.claude');
|
||||
};
|
||||
|
||||
const startDockerSidecar = async (port: number, homeDir: string, userId: number, username: string, email: string, contextFile?: string, settingsFile?: string): Promise<{ dockerId: string }> => {
|
||||
ensureDockerImage();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const dockerId = `officer-terminal-${userId}`;
|
||||
const tag = 'officer-terminal-sidecar:v1';
|
||||
|
||||
// Remove stale container with same name if it exists
|
||||
if (dockerContainerExists(dockerId)) {
|
||||
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
}
|
||||
|
||||
let uid = 1000;
|
||||
let gid = 1000;
|
||||
const sidecarAlive = async (): Promise<boolean> => {
|
||||
try {
|
||||
const stats = statSync(homeDir);
|
||||
uid = stats.uid;
|
||||
gid = stats.gid;
|
||||
} catch {
|
||||
// fallback to defaults
|
||||
}
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
|
||||
const run = Bun.spawnSync({
|
||||
cmd: [
|
||||
dockerPath,
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
dockerId,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
'--network', 'host',
|
||||
'-e',
|
||||
`TERMINAL_PTY_PORT=${port}`,
|
||||
'-e',
|
||||
'TERMINAL_PTY_HOST=127.0.0.1',
|
||||
'-e',
|
||||
`TERMINAL_USER=${username}`,
|
||||
'-e',
|
||||
`TERMINAL_UID=${uid}`,
|
||||
'-e',
|
||||
`TERMINAL_GID=${gid}`,
|
||||
'-e',
|
||||
`OFFICER_EMAIL=${email}`,
|
||||
'-v', `${homeDir}:${containerHome}`,
|
||||
'-v', `${getGlobalSkillsDir()}:/officer/skills:ro`,
|
||||
'-v', `${getGlobalToolsDir()}:/officer/tools:ro`,
|
||||
'-v', `${getGlobalExtensionsDir()}:/officer/extensions:ro`,
|
||||
'-v', `${getUserSkillsDir(email)}:/officer/user/skills:ro`,
|
||||
'-v', `${getUserToolsDir(email)}:/officer/user/tools:ro`,
|
||||
'-v', `${PI_CONFIG_DIR}:/officer/pi-config:ro`,
|
||||
'-v', `${PI_CONFIG_DIR}:${containerHome}/.pi/agent`,
|
||||
'-v', `${ensureDir(join(getHomeDir(email), '.pi', 'agent', 'sessions'))}:${containerHome}/.pi/agent/sessions`,
|
||||
'-v', `${join(DATA_PATH, '.generated')}:/officer/generated:ro`,
|
||||
'-v', `${join(DATA_PATH, email)}:/officer/data`,
|
||||
...(existsSync(join(process.env.HOME ?? '', '.claude')) ? ['-v', `${join(process.env.HOME!, '.claude')}:${containerHome}/.claude`] : []),
|
||||
...(contextFile && existsSync(contextFile) ? ['-v', `${contextFile}:${containerHome}/.claude/CLAUDE.md:ro`] : []),
|
||||
...(settingsFile && existsSync(settingsFile) ? ['-v', `${settingsFile}:${containerHome}/.claude/settings.json:ro`] : []),
|
||||
'-w', containerHome,
|
||||
tag,
|
||||
],
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
|
||||
|
||||
// Wait for entrypoint to finish (user creation, sidecar start)
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (await sidecarAlive(port)) return { dockerId };
|
||||
}
|
||||
throw new Error('Terminal sidecar did not start in time');
|
||||
};
|
||||
|
||||
const stopDockerSidecar = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
};
|
||||
|
||||
const readDockerLogs = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const logs = Bun.spawnSync({ cmd: [dockerPath, 'logs', '--tail', '200', dockerId], stdout: 'pipe', stderr: 'pipe' });
|
||||
if (logs.exitCode !== 0) return '';
|
||||
return logs.stdout.toString().trim();
|
||||
};
|
||||
|
||||
const loadContainerMap = async (): Promise<Record<string, ContainerInfo>> => {
|
||||
if (containersCache) return containersCache;
|
||||
const data = await Bun.file(containerMapPath)
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
containersCache = data as Record<string, ContainerInfo>;
|
||||
return containersCache;
|
||||
};
|
||||
|
||||
const saveContainerMap = async (map: Record<string, ContainerInfo>) => {
|
||||
containersCache = map;
|
||||
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
|
||||
};
|
||||
|
||||
const getAvailablePort = (map: Record<string, ContainerInfo>, userId: number) => {
|
||||
const base = 54000;
|
||||
const used = new Set(Object.values(map).map((item) => item.port));
|
||||
let port = base + (userId % 1000);
|
||||
while (used.has(port)) port += 1;
|
||||
return port;
|
||||
};
|
||||
|
||||
const dockerContainerExists = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-a', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
|
||||
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
||||
};
|
||||
|
||||
const dockerContainerRunning = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
|
||||
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
||||
};
|
||||
|
||||
const dockerStart = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({ cmd: [dockerPath, 'start', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
export const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string, contextFile?: string, settingsFile?: string) => {
|
||||
// Check if mount sources are stale (e.g. data dir was deleted and Docker recreated them as root)
|
||||
// Must check BEFORE mkdirSync overwrites them
|
||||
const skillsDir = getUserSkillsDir(email);
|
||||
let stale = false;
|
||||
try {
|
||||
const s = statSync(skillsDir);
|
||||
if (s.uid === 0) stale = true;
|
||||
} catch {
|
||||
// doesn't exist yet — not stale, will be created below
|
||||
}
|
||||
|
||||
// Ensure user-specific resource dirs exist before mounting (Docker creates them as root if missing)
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
mkdirSync(getUserToolsDir(email), { recursive: true });
|
||||
mkdirSync(join(DATA_PATH, email, 'integrations'), { recursive: true });
|
||||
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) {
|
||||
// Recreate if resource mounts are missing or data dir was recreated (stale mounts)
|
||||
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
|
||||
console.log(`[terminal] recreating container for ${email} — mounts stale or missing`);
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else {
|
||||
console.log(`[terminal] reusing running container ${existing.dockerId} for ${email} on port ${existing.port}`);
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing && dockerContainerExists(existing.dockerId)) {
|
||||
if (!containerHasExpectedMounts(existing.dockerId) || stale) {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
} else if (dockerStart(existing.dockerId)) {
|
||||
return existing;
|
||||
} else {
|
||||
stopDockerSidecar(existing.dockerId);
|
||||
}
|
||||
}
|
||||
|
||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||
const docker = await startDockerSidecar(port, homeDir, userId, username, email, contextFile, settingsFile);
|
||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||
map[email] = next;
|
||||
await saveContainerMap(map);
|
||||
return next;
|
||||
};
|
||||
|
||||
const sidecarAlive = async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}`, { signal: AbortSignal.timeout(500) });
|
||||
const res = await fetch(`http://127.0.0.1:${HOST_SIDECAR_PORT}`, { signal: AbortSignal.timeout(500) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -334,135 +111,61 @@ const startHostSidecar = async () => {
|
||||
};
|
||||
|
||||
export const ensureHostSidecar = async () => {
|
||||
const alive = await sidecarAlive(HOST_SIDECAR_PORT);
|
||||
const alive = await sidecarAlive();
|
||||
if (!alive) {
|
||||
await startHostSidecar();
|
||||
// Wait for it to come up
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
if (await sidecarAlive(HOST_SIDECAR_PORT)) return;
|
||||
if (await sidecarAlive()) return;
|
||||
}
|
||||
throw new Error('Host sidecar failed to start');
|
||||
}
|
||||
};
|
||||
|
||||
export const initTerminalSidecars = async () => {
|
||||
await startHostSidecar();
|
||||
ensureDockerImage();
|
||||
const users = await getUsers();
|
||||
for (const user of users) {
|
||||
const homeDir = getHomeDir(user.email);
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
const shellUsername = toShellUsername(user.username ?? '', user.email);
|
||||
const contextFile = generateContainerContext(user.email);
|
||||
const settingsFile = generateClaudeSettings(user.email, shellUsername);
|
||||
try {
|
||||
await ensureDockerContainer(user.email, user.id, homeDir, shellUsername, contextFile, settingsFile);
|
||||
console.log(`[terminal] sidecar ready for ${user.email}`);
|
||||
} catch (err) {
|
||||
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
|
||||
// Sidecar is managed by pm2 — wait for it to be available
|
||||
for (let i = 0; i < 15; i++) {
|
||||
if (await sidecarAlive()) {
|
||||
console.log(`[terminal] host sidecar already running on port ${HOST_SIDECAR_PORT}`);
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
console.warn(`[terminal] host sidecar not detected on port ${HOST_SIDECAR_PORT} — terminals will retry on connect`);
|
||||
};
|
||||
|
||||
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
||||
|
||||
const resolveCwd = (home: string, cwd?: string) => {
|
||||
if (!cwd || cwd === '~') return home;
|
||||
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
|
||||
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
|
||||
if (cwd.startsWith('/')) return cwd;
|
||||
return home;
|
||||
};
|
||||
|
||||
export const terminalWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
const { email, username, role, sandboxed } = ws.data;
|
||||
const isHost = !sandboxed && role === 'Super Admin';
|
||||
|
||||
if (!sandboxed && role !== 'Super Admin') {
|
||||
sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n');
|
||||
return;
|
||||
}
|
||||
console.log(
|
||||
`[terminal] open: email=${email} username=${username} role=${role} sandboxed=${sandboxed} isHost=${isHost}`,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
session.sidecar = sidecar;
|
||||
|
||||
sidecar.addEventListener('message', (ev) => {
|
||||
try {
|
||||
if (typeof ev.data === 'string') {
|
||||
ws.send(ev.data);
|
||||
} else {
|
||||
ws.send(new TextDecoder().decode(ev.data));
|
||||
}
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
});
|
||||
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
host: true,
|
||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||
session.pendingMessages = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const cwd = getHomeDir(email);
|
||||
const userRoot = dirname(cwd);
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const session: BridgeSession = { client: ws, sidecar: null, dockerId: '', port: 0, pendingMessages: [] };
|
||||
const session: BridgeSession = { client: ws, sidecar: null, pendingMessages: [] };
|
||||
sessions.set(ws, session);
|
||||
|
||||
let sidecar: WebSocket | null = null;
|
||||
let info: ContainerInfo | undefined;
|
||||
try {
|
||||
info = await ensureDockerContainer(email, ws.data.userId, cwd, username, generateContainerContext(email), generateClaudeSettings(email, username));
|
||||
sidecar = await connectSidecar(info.port);
|
||||
sidecar = await connectSidecar();
|
||||
console.log('[terminal] sidecar connected');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
||||
console.error(`[terminal] sidecar connection failed for ${email}:`, message);
|
||||
console.error('[terminal] sidecar connection failed:', message);
|
||||
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
||||
if (info) {
|
||||
const logs = readDockerLogs(info.dockerId);
|
||||
if (logs) {
|
||||
sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`);
|
||||
}
|
||||
}
|
||||
sendOutput(ws, '\r\n[Process exited]\r\n');
|
||||
if (info) stopDockerSidecar(info.dockerId);
|
||||
sessions.delete(ws);
|
||||
return;
|
||||
}
|
||||
|
||||
session.sidecar = sidecar;
|
||||
session.dockerId = info.dockerId;
|
||||
session.port = info.port;
|
||||
|
||||
sidecar.addEventListener('message', (ev) => {
|
||||
try {
|
||||
@@ -476,19 +179,40 @@ export const terminalWebsocket = {
|
||||
}
|
||||
});
|
||||
|
||||
const containerHome = `/home/${username}`;
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
||||
shell: containerShell,
|
||||
cwd: resolveCwd(containerHome, ws.data.cwd),
|
||||
homeDir: containerHome,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
}),
|
||||
);
|
||||
if (isHost) {
|
||||
// Super Admin host terminal — spawn as the service user directly
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
host: true,
|
||||
sessionId: ws.data.sessionId ?? `host-${ws.data.userId}`,
|
||||
shell: { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] },
|
||||
cwd: resolveCwd(process.env.HOME!, ws.data.cwd),
|
||||
homeDir: process.env.HOME,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// User terminal — spawn as the target Linux user via sudo -u
|
||||
const homeDir = getHomeDir(email);
|
||||
mkdirSync(dirname(homeDir), { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
username,
|
||||
sessionId: ws.data.sessionId ?? `default-${ws.data.userId}`,
|
||||
cwd: resolveCwd(homeDir, ws.data.cwd),
|
||||
homeDir,
|
||||
userLabel: email,
|
||||
cols: ws.data.cols,
|
||||
rows: ws.data.rows,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
for (const msg of session.pendingMessages) sidecar.send(msg);
|
||||
session.pendingMessages = [];
|
||||
@@ -531,32 +255,20 @@ export const broadcastPanelRefresh = (email: string) => {
|
||||
const msg = JSON.stringify({ type: 'panel-refresh' });
|
||||
for (const [ws, session] of sessions) {
|
||||
if (ws.data.email === email && session.sidecar) {
|
||||
try { ws.send(msg); } catch { /* ignore */ }
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const stopAllContainers = async () => {
|
||||
// Stop host sidecar
|
||||
export const stopAllSidecars = async () => {
|
||||
if (hostSidecarProcess) {
|
||||
hostSidecarProcess.kill();
|
||||
await hostSidecarProcess.exited.catch(() => {});
|
||||
hostSidecarProcess = null;
|
||||
console.log('[terminal] host sidecar stopped');
|
||||
}
|
||||
|
||||
// Stop all Docker containers
|
||||
const map = await loadContainerMap();
|
||||
const entries = Object.entries(map);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
for (const [email, info] of entries) {
|
||||
try {
|
||||
Bun.spawnSync({ cmd: [dockerPath, 'stop', '-t', '2', info.dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
console.log(`[terminal] stopped container ${info.dockerId} (${email})`);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user