From ef13f96d366a981f9862e541c8698e850cbf6ad8 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Wed, 4 Mar 2026 01:45:36 +0000 Subject: [PATCH] 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 --- ecosystem.config.cjs | 10 + package.json | 6 +- scripts/provision-existing-users.sh | 181 ++++++ scripts/setup-desktop.sh | 9 +- scripts/setup-pty-sidecar.sh | 57 ++ scripts/setup.sh | 425 ++++++++++++- .../Screens/Dashboard/Automation/CLAUDE.md | 276 +++++++++ .../ServerSettings/AIHarnessesSection.tsx | 585 +++++++++--------- .../Dashboard/Settings/SystemSettings.tsx | 84 ++- src/server.tsx | 37 +- src/servers/api/auth/verify.ts | 6 + src/servers/api/pi/SNAP_NODE_COMPATIBILITY.md | 133 ++++ src/servers/api/pi/list-models.ts | 27 +- src/servers/api/pi/pi-bridge.ts | 371 ++++++----- src/servers/api/pi/rest.ts | 35 +- src/servers/api/pi/websocket.ts | 154 +++-- src/servers/api/server-settings/pi-mono.ts | 275 ++++---- .../api/terminal/Dockerfile.terminal-sidecar | 79 --- src/servers/api/terminal/entrypoint.sh | 69 --- src/servers/api/terminal/pty-sidecar.mjs | 49 +- src/servers/api/terminal/templates/.zshenv | 1 + src/servers/api/terminal/templates/.zshrc | 5 +- .../terminal/templates/starship-officer.toml | 11 +- src/servers/api/terminal/websocket.ts | 438 +++---------- src/servers/api/users/provision.ts | 147 +++++ src/servers/api/users/users-router.ts | 11 +- src/servers/bootstrap.ts | 31 +- src/servers/channels/send-and-await.ts | 18 +- src/servers/channels/send-claude-code.ts | 222 ++++--- src/servers/generate-container-context.ts | 42 +- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 16 +- .../officerdev/src/apps/Terminal/Headers.tsx | 20 +- .../officerdev/src/hooks/usePiChat.ts | 19 +- src/workspaces/state/src/useModels.ts | 11 +- 34 files changed, 2394 insertions(+), 1466 deletions(-) create mode 100644 ecosystem.config.cjs create mode 100755 scripts/provision-existing-users.sh create mode 100755 scripts/setup-pty-sidecar.sh create mode 100644 src/apps/officer-web/Screens/Dashboard/Automation/CLAUDE.md create mode 100644 src/servers/api/pi/SNAP_NODE_COMPATIBILITY.md delete mode 100644 src/servers/api/terminal/Dockerfile.terminal-sidecar delete mode 100755 src/servers/api/terminal/entrypoint.sh create mode 100644 src/servers/api/terminal/templates/.zshenv create mode 100644 src/servers/api/users/provision.ts diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs new file mode 100644 index 00000000..898746e0 --- /dev/null +++ b/ecosystem.config.cjs @@ -0,0 +1,10 @@ +module.exports = { + apps: [ + { + name: 'officer', + script: 'bun', + args: 'start', + watch: false, + }, + ], +}; diff --git a/package.json b/package.json index 705e43b5..50a1f19d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,11 @@ "format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write", "format:all": "prettier --write \"src/**/*.{ts,tsx}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", - "setup": "bash scripts/setup.sh" + "setup": "bash scripts/setup.sh", + "start:sidecar": "sudo systemctl start officer-pty-sidecar", + "stop:sidecar": "sudo systemctl stop officer-pty-sidecar", + "restart:sidecar": "sudo systemctl restart officer-pty-sidecar", + "logs:sidecar": "journalctl -u officer-pty-sidecar -f" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.41", diff --git a/scripts/provision-existing-users.sh b/scripts/provision-existing-users.sh new file mode 100755 index 00000000..627fed44 --- /dev/null +++ b/scripts/provision-existing-users.sh @@ -0,0 +1,181 @@ +#!/bin/bash +# One-time migration: provision Linux users for existing database members. +# Reads users from PostgreSQL, skips Super Admins (they use the service account), +# and creates Linux users + seeds shell configs for everyone else. +# +# Usage: bash scripts/provision-existing-users.sh +# Requires: sudoers entry from setup.sh, POSTGRES_URL in .env + +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +ok() { echo -e " ${GREEN}✓${NC} $1"; } +warn() { echo -e " ${YELLOW}!${NC} $1"; } +fail() { echo -e " ${RED}✗${NC} $1"; } +skip() { echo -e " - $1 (skipped)"; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_DIR/.env" +TEMPLATE_DIR="$PROJECT_DIR/src/servers/api/terminal/templates" + +# Load .env +if [ ! -f "$ENV_FILE" ]; then + fail ".env not found at $ENV_FILE" + exit 1 +fi +source <(grep -E '^[A-Z_]+=.*' "$ENV_FILE" | sed 's/^/export /') + +# Resolve DATA_PATH +DATA_PATH="${DATA_PATH:-$PROJECT_DIR/data}" + +# Parse POSTGRES_URL for psql +if [ -z "${POSTGRES_URL:-}" ]; then + fail "POSTGRES_URL not set in .env" + exit 1 +fi + +# Extract components from postgresql://user:pass@host:port/dbname +PG_USER=$(echo "$POSTGRES_URL" | sed -n 's|.*://\([^:]*\):.*|\1|p') +PG_PASS=$(echo "$POSTGRES_URL" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p') +PG_HOST=$(echo "$POSTGRES_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p') +PG_PORT=$(echo "$POSTGRES_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p') +PG_DB=$(echo "$POSTGRES_URL" | sed -n 's|.*/\([^?]*\).*|\1|p') + +echo "" +echo "═══════════════════════════════════════════" +echo " Provision existing users" +echo "═══════════════════════════════════════════" +echo "" +echo "DATA_PATH: $DATA_PATH" +echo "Database: $PG_DB @ $PG_HOST:$PG_PORT" +echo "" + +# Query non-Super Admin active users +USERS=$(PGPASSWORD="$PG_PASS" psql -h "$PG_HOST" -p "$PG_PORT" -U "$PG_USER" -d "$PG_DB" -t -A -F '|' \ + -c "SELECT email, COALESCE(username, '') FROM users WHERE role != 'Super Admin' AND status = 'Active';" 2>&1) + +if [ $? -ne 0 ]; then + fail "Failed to query database: $USERS" + exit 1 +fi + +if [ -z "$USERS" ]; then + echo "No non-admin active users found. Nothing to do." + exit 0 +fi + +echo "── Found users ──" +echo "$USERS" | while IFS='|' read -r email username; do + echo " $email (username: ${username:-})" +done +echo "" + +echo "── Provisioning ──" +while IFS='|' read -r email username; do + # Derive shell username (same logic as toShellUsername in data-path.ts) + if [ -n "$username" ]; then + shell_user=$(echo "$username" | sed 's/@.*$//' | sed 's/[^a-zA-Z0-9._-]/_/g' | tr '[:upper:]' '[:lower:]' | cut -c1-32) + else + shell_user=$(echo "$email" | sed 's/@.*$//' | sed 's/[^a-zA-Z0-9._-]/_/g' | tr '[:upper:]' '[:lower:]' | cut -c1-32) + fi + + USER_ROOT="$DATA_PATH/$email" + HOME_DIR="$USER_ROOT/home" + + echo "" + echo " [$email → $shell_user]" + + # Ensure data dirs exist (sudo in case dir is owned by a previous provisioning run) + sudo mkdir -p "$USER_ROOT" "$HOME_DIR" + + # Create Linux user if needed + if id "$shell_user" &>/dev/null; then + skip "Linux user $shell_user already exists" + else + if sudo useradd -d "$HOME_DIR" -s /bin/zsh -M "$shell_user"; then + ok "Created Linux user $shell_user" + else + fail "Failed to create Linux user $shell_user" + continue + fi + fi + + # Seed shell configs (only if not already present) + if [ ! -f "$HOME_DIR/.zshenv" ] && [ -f "$TEMPLATE_DIR/.zshenv" ]; then + sudo cp "$TEMPLATE_DIR/.zshenv" "$HOME_DIR/.zshenv" + ok "Seeded .zshenv" + else + skip ".zshenv" + fi + + if [ ! -f "$HOME_DIR/.zshrc" ] && [ -f "$TEMPLATE_DIR/.zshrc" ]; then + sudo cp "$TEMPLATE_DIR/.zshrc" "$HOME_DIR/.zshrc" + ok "Seeded .zshrc" + else + skip ".zshrc" + fi + + if [ ! -f "$HOME_DIR/.tmux.conf" ] && [ -f "$TEMPLATE_DIR/.tmux.conf" ]; then + sudo cp "$TEMPLATE_DIR/.tmux.conf" "$HOME_DIR/.tmux.conf" + ok "Seeded .tmux.conf" + else + skip ".tmux.conf" + fi + + sudo mkdir -p "$HOME_DIR/.config" + if [ ! -f "$HOME_DIR/.config/starship-officer.toml" ] && [ -f "$TEMPLATE_DIR/starship-officer.toml" ]; then + sudo cp "$TEMPLATE_DIR/starship-officer.toml" "$HOME_DIR/.config/starship-officer.toml" + ok "Seeded starship config" + else + skip "starship config" + fi + + # Oh My Zsh + if [ ! -d "$HOME_DIR/.oh-my-zsh" ]; then + if [ -d "$HOME/.oh-my-zsh" ]; then + sudo cp -r "$HOME/.oh-my-zsh" "$HOME_DIR/.oh-my-zsh" + ok "Copied oh-my-zsh from host" + fi + else + skip "oh-my-zsh" + fi + + # LazyVim + sudo mkdir -p "$HOME_DIR/.config" + if [ ! -d "$HOME_DIR/.config/nvim" ]; then + if [ -d "$HOME/.config/nvim" ]; then + sudo cp -r "$HOME/.config/nvim" "$HOME_DIR/.config/nvim" + ok "Copied nvim config from host" + fi + else + skip "nvim config" + fi + + # Ensure dirs + sudo mkdir -p "$HOME_DIR/.local/bin" + sudo mkdir -p "$HOME_DIR/.pi/agent/sessions" + + # Set ownership and permissions last + sudo chown -R "$shell_user:$shell_user" "$USER_ROOT" + sudo chmod 770 "$USER_ROOT" + + # Add service user to this user's group so server jobs can access user data + SERVICE_USER="${SUDO_USER:-$(whoami)}" + if [ "$SERVICE_USER" != "$shell_user" ]; then + sudo usermod -aG "$shell_user" "$SERVICE_USER" + ok "Added $SERVICE_USER to group $shell_user" + fi + + ok "Provisioning complete" +done <<< "$USERS" + +echo "" +echo "═══════════════════════════════════════════" +echo " Done!" +echo "═══════════════════════════════════════════" +echo "" diff --git a/scripts/setup-desktop.sh b/scripts/setup-desktop.sh index 1fc0d705..fc8d24db 100755 --- a/scripts/setup-desktop.sh +++ b/scripts/setup-desktop.sh @@ -5,14 +5,7 @@ set -euo pipefail # Run as the user who will own the VNC session (not root). # Usage: ./scripts/setup-desktop.sh [resolution] -if [ $# -lt 1 ]; then - echo "Usage: $0 [resolution]" - echo " vnc-password: 8 characters max (VNC protocol limitation)" - echo " resolution: optional, default 1920x1080" - exit 1 -fi - -VNC_PASS="$1" +VNC_PASS="${1:-$(head -c 6 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 8)}" RESOLUTION="${2:-1920x1080}" USER_NAME="$(whoami)" ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env" diff --git a/scripts/setup-pty-sidecar.sh b/scripts/setup-pty-sidecar.sh new file mode 100755 index 00000000..b0340f1c --- /dev/null +++ b/scripts/setup-pty-sidecar.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Officer PTY Sidecar — systemd service setup +# Creates and enables a systemd service for the terminal PTY sidecar. +# Usage: bash scripts/setup-pty-sidecar.sh + +set -euo pipefail + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +ok() { echo -e " ${GREEN}✓${NC} $1"; } +warn() { echo -e " ${YELLOW}!${NC} $1"; } +skip() { echo -e " - $1 (already set up)"; } + +SERVICE_NAME="officer-pty-sidecar" +SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service" +SERVICE_USER="$(whoami)" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +NODE_BIN="$(which node)" + +echo "" +echo "── PTY Sidecar (systemd service) ──" + +if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + skip "$SERVICE_NAME service already running" + echo " Use 'bun run restart:sidecar' to restart" + exit 0 +fi + +sudo tee "$SERVICE_FILE" > /dev/null << EOF +[Unit] +Description=Officer PTY Sidecar +After=network.target + +[Service] +Type=simple +User=$SERVICE_USER +WorkingDirectory=$PROJECT_DIR +Environment=TERMINAL_PTY_PORT=5338 +ExecStart=$NODE_BIN $PROJECT_DIR/src/servers/api/terminal/pty-sidecar.mjs +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +EOF + +sudo systemctl daemon-reload +sudo systemctl enable --now "$SERVICE_NAME" + +if systemctl is-active --quiet "$SERVICE_NAME"; then + ok "$SERVICE_NAME service installed and running" +else + warn "$SERVICE_NAME service failed to start — check 'journalctl -u $SERVICE_NAME'" +fi diff --git a/scripts/setup.sh b/scripts/setup.sh index 89461f13..d40e6795 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -55,8 +55,12 @@ if has git; then skip "git"; else CORE_PKGS+=(git); fi if has zip; then skip "zip"; else CORE_PKGS+=(zip); fi if has unzip; then skip "unzip"; else CORE_PKGS+=(unzip); fi -# curl / wget (usually present but just in case) +# curl / wget if has curl; then skip "curl"; else CORE_PKGS+=(curl); fi +if has wget; then skip "wget"; else CORE_PKGS+=(wget); fi + +# zsh +if has zsh; then skip "zsh"; else CORE_PKGS+=(zsh); fi # psmisc (fuser) and procps (pgrep) if has fuser; then skip "fuser (psmisc)"; else @@ -82,11 +86,116 @@ if has script; then skip "script (bsdutils)"; else esac fi +# build tools (make, gcc, g++) — needed for native npm modules like node-pty +if has make && has gcc; then skip "build tools (make, gcc, g++)"; else + case $PM in + apt) CORE_PKGS+=(build-essential) ;; + pacman) CORE_PKGS+=(base-devel) ;; + brew) warn "Install Xcode command line tools: xcode-select --install" ;; + esac +fi + +# python3 + pip + venv +if has python3; then skip "python3"; else + case $PM in + apt) CORE_PKGS+=(python3 python3-pip python3-venv) ;; + pacman) CORE_PKGS+=(python python-pip) ;; + brew) CORE_PKGS+=(python3) ;; + esac +fi +# ensure pip/venv even if python3 already exists (apt splits them) +if has python3 && [ "$PM" = "apt" ]; then + if ! dpkg -s python3-pip &>/dev/null 2>&1; then CORE_PKGS+=(python3-pip); fi + if ! dpkg -s python3-venv &>/dev/null 2>&1; then CORE_PKGS+=(python3-venv); fi +fi + +# shell utilities +for tool in tree btop tmux jq htop lsof duf; do + if has "$tool"; then skip "$tool"; else CORE_PKGS+=("$tool"); fi +done + +# sqlite3 +if has sqlite3; then skip "sqlite3"; else + case $PM in + apt) CORE_PKGS+=(sqlite3) ;; + pacman) CORE_PKGS+=(sqlite) ;; + brew) CORE_PKGS+=(sqlite) ;; + esac +fi + +# ripgrep +if has rg; then skip "ripgrep"; else + case $PM in + apt) CORE_PKGS+=(ripgrep) ;; + pacman) CORE_PKGS+=(ripgrep) ;; + brew) CORE_PKGS+=(ripgrep) ;; + esac +fi + +# fd-find +if has fd || has fdfind; then skip "fd-find"; else + case $PM in + apt) CORE_PKGS+=(fd-find) ;; + pacman) CORE_PKGS+=(fd) ;; + brew) CORE_PKGS+=(fd) ;; + esac +fi + +# net-tools, less, file, man-db +case $PM in + apt) + for pkg in net-tools less file man-db; do + if dpkg -s "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi + done + ;; + pacman) + for pkg in net-tools less file man-db; do + if pacman -Qi "$pkg" &>/dev/null 2>&1; then skip "$pkg"; else CORE_PKGS+=("$pkg"); fi + done + ;; + brew) + skip "net-tools, less, file, man (built-in on macOS)" + ;; +esac + +# locales +case $PM in + apt) + if dpkg -s locales &>/dev/null 2>&1; then skip "locales"; else CORE_PKGS+=(locales); fi + ;; +esac + +# ca-certificates +case $PM in + apt) + if dpkg -s ca-certificates &>/dev/null 2>&1; then skip "ca-certificates"; else CORE_PKGS+=(ca-certificates); fi + ;; +esac + if [ ${#CORE_PKGS[@]} -gt 0 ]; then install_pkg "${CORE_PKGS[@]}" ok "Installed: ${CORE_PKGS[*]}" fi +# locale generation (ensure en_US.UTF-8) +case $PM in + apt) + if ! locale -a 2>/dev/null | grep -q "en_US.utf8"; then + sudo sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen + sudo locale-gen + ok "Generated en_US.UTF-8 locale" + else + skip "en_US.UTF-8 locale" + fi + ;; +esac + +# symlink fdfind → fd (apt installs as fdfind) +if has fdfind && ! has fd; then + sudo ln -sf "$(which fdfind)" /usr/local/bin/fd + ok "Symlinked fdfind → fd" +fi + # ─── 2. archive extras (optional but useful) ────────────────────────────────── echo "" echo "── Archive utilities (optional) ──" @@ -127,30 +236,26 @@ else ok "ffmpeg installed" fi -# ─── 4. docker ───────────────────────────────────────────────────────────────── +# ─── 4. sudoers for officer service user ────────────────────────────────────── echo "" -echo "── Docker ──" +echo "── Sudoers (Linux user isolation) ──" -if has docker; then - skip "docker ($(docker --version 2>/dev/null | awk '{print $3}' | tr -d ','))" +SERVICE_USER="$(whoami)" +SUDOERS_FILE="/etc/sudoers.d/officer-service" + +if [ -f "$SUDOERS_FILE" ] && grep -q "$SERVICE_USER" "$SUDOERS_FILE" 2>/dev/null; then + skip "sudoers entry for $SERVICE_USER" else case $PM in - apt) - warn "Installing docker.io from apt" - sudo apt install -y docker.io - sudo usermod -aG docker "$USER" - warn "You may need to log out/in for docker group to take effect" - ;; - pacman) - sudo pacman -S --noconfirm docker - sudo systemctl enable --now docker - sudo usermod -aG docker "$USER" + apt|pacman) + echo "$SERVICE_USER ALL=(ALL) NOPASSWD: ALL" | sudo tee "$SUDOERS_FILE" > /dev/null + sudo chmod 0440 "$SUDOERS_FILE" + ok "Created sudoers entry for $SERVICE_USER at $SUDOERS_FILE" ;; brew) - warn "Install Docker Desktop from https://www.docker.com/products/docker-desktop" + warn "Sudoers setup is Linux-only — skipping on macOS" ;; esac - if has docker; then ok "docker installed"; else warn "docker not found — install manually"; fi fi # ─── 5. Node.js 22 ──────────────────────────────────────────────────────────── @@ -172,6 +277,45 @@ else warn " nvm install 22" fi +# ─── npm global prefix ───────────────────────────────────────────────────────── +# Set npm global prefix to a user-writable directory so npm install -g +# never requires sudo. This also lets the server install packages at runtime. +echo "" +echo "── npm global prefix ──" + +if has npm; then + NPM_PREFIX=$(npm config get prefix 2>/dev/null) + NPM_GLOBAL="$HOME/.npm-global" + + if [ "$NPM_PREFIX" = "$NPM_GLOBAL" ]; then + skip "npm prefix already set to $NPM_GLOBAL" + else + mkdir -p "$NPM_GLOBAL" + npm config set prefix "$NPM_GLOBAL" + export PATH="$NPM_GLOBAL/bin:$PATH" + ok "Set npm global prefix to $NPM_GLOBAL" + + # Migrate existing global packages from system prefix if any + if [ -d "$NPM_PREFIX/lib/node_modules/@mariozechner" ] || [ -d "$NPM_PREFIX/lib/node_modules/@anthropic-ai" ]; then + warn "Removing stale system-level npm packages from $NPM_PREFIX (will reinstall to $NPM_GLOBAL)" + sudo rm -rf "$NPM_PREFIX/lib/node_modules/@mariozechner" "$NPM_PREFIX/lib/node_modules/@anthropic-ai" 2>/dev/null + sudo rm -f "$NPM_PREFIX/bin/pi" "$NPM_PREFIX/bin/claude" 2>/dev/null + fi + fi + + # Ensure ~/.npm-global/bin is in shell profiles + for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do + if [ -f "$profile" ] && ! grep -q '.npm-global/bin' "$profile"; then + echo '' >> "$profile" + echo '# npm global packages' >> "$profile" + echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> "$profile" + ok "Added ~/.npm-global/bin to $(basename "$profile")" + fi + done +else + warn "npm not found — skipping prefix setup" +fi + # ─── 6. Bun ─────────────────────────────────────────────────────────────────── echo "" echo "── Bun ──" @@ -189,14 +333,45 @@ fi echo "" echo "── Go ──" +GOLANG_VERSION=1.23.6 + if has go; then skip "go ($(go version 2>/dev/null | awk '{print $3}'))" else - install_pkg golang-go 2>/dev/null || install_pkg go 2>/dev/null || install_pkg golang 2>/dev/null + case $PM in + apt|pacman) + echo " Installing Go ${GOLANG_VERSION} from official tarball..." + ARCH=$(uname -m) + case $ARCH in + x86_64) GO_ARCH=amd64 ;; + aarch64) GO_ARCH=arm64 ;; + *) GO_ARCH=amd64 ;; + esac + curl -fsSL "https://go.dev/dl/go${GOLANG_VERSION}.linux-${GO_ARCH}.tar.gz" -o /tmp/go.tar.gz + sudo tar -C /usr/local -xzf /tmp/go.tar.gz + rm /tmp/go.tar.gz + export PATH="/usr/local/go/bin:$PATH" + ;; + brew) + brew install go + ;; + esac if has go; then ok "go installed"; else warn "go not found — install manually from https://go.dev/dl/"; fi fi -# ─── 8. PulseAudio (headless audio for cliamp) ──────────────────────────────── +# ─── 8. Rust ────────────────────────────────────────────────────────────────── +echo "" +echo "── Rust ──" + +if has rustc && has cargo; then + skip "rust ($(rustc --version 2>/dev/null | awk '{print $2}'))" +else + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + export PATH="$HOME/.cargo/bin:$PATH" + if has rustc; then ok "rust installed"; else warn "rust install failed"; fi +fi + +# ─── 9. PulseAudio (headless audio for cliamp) ──────────────────────────────── echo "" echo "── PulseAudio (headless audio) ──" @@ -235,7 +410,7 @@ if [ ${#PULSE_PKGS[@]} -gt 0 ]; then ok "Installed: ${PULSE_PKGS[*]}" fi -# ─── 9. cliamp (music player) ───────────────────────────────────────────────── +# ─── 10. cliamp (music player) ──────────────────────────────────────────────── echo "" echo "── cliamp ──" @@ -261,7 +436,114 @@ else fi fi -# ─── 10. yt-dlp (optional — video/audio download) ───────────────────────────── +# ─── 11. Neovim ────────────────────────────────────────────────────────────── +echo "" +echo "── Neovim ──" + +if has nvim; then + skip "neovim ($(nvim --version 2>/dev/null | head -1))" +else + case $PM in + apt) + echo " Installing Neovim from GitHub releases..." + ARCH=$(uname -m) + case $ARCH in + x86_64) NVIM_ARCH=x86_64 ;; + aarch64) NVIM_ARCH=aarch64 ;; + *) NVIM_ARCH=x86_64 ;; + esac + curl -fsSL "https://github.com/neovim/neovim/releases/latest/download/nvim-linux-${NVIM_ARCH}.tar.gz" -o /tmp/nvim.tar.gz + sudo tar -C /opt -xzf /tmp/nvim.tar.gz + sudo ln -sf "/opt/nvim-linux-${NVIM_ARCH}/bin/nvim" /usr/local/bin/nvim + rm /tmp/nvim.tar.gz + ;; + pacman) install_pkg neovim ;; + brew) install_pkg neovim ;; + esac + if has nvim; then ok "neovim installed"; else warn "neovim install failed"; fi +fi + +# LazyVim starter config +if [ -d "$HOME/.config/nvim" ]; then + skip "nvim config (already exists at ~/.config/nvim)" +else + echo " Installing LazyVim starter config..." + git clone --depth 1 https://github.com/LazyVim/starter "$HOME/.config/nvim" + rm -rf "$HOME/.config/nvim/.git" + ok "LazyVim starter installed at ~/.config/nvim" +fi + +# ─── 12. Terminal tools ────────────────────────────────────────────────────── +echo "" +echo "── Terminal tools (starship, oh-my-zsh, eza, lazygit) ──" + +# Starship prompt +if has starship; then + skip "starship" +else + curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin + if has starship; then ok "starship installed"; else warn "starship install failed"; fi +fi + +# Oh-My-Zsh +if [ -d "$HOME/.oh-my-zsh" ]; then + skip "oh-my-zsh (already at ~/.oh-my-zsh)" +else + git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh" + ok "oh-my-zsh installed at ~/.oh-my-zsh" +fi + +# eza +EZA_VERSION=0.18.15 +if has eza; then + skip "eza" +else + case $PM in + apt) + ARCH=$(uname -m) + case $ARCH in + x86_64) EZA_ARCH=x86_64 ;; + aarch64) EZA_ARCH=aarch64 ;; + *) EZA_ARCH=x86_64 ;; + esac + curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_${EZA_ARCH}-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz + tar -xzf /tmp/eza.tar.gz -C /tmp + sudo mv /tmp/eza /usr/local/bin/eza + sudo chmod +x /usr/local/bin/eza + rm -rf /tmp/eza.tar.gz + ;; + pacman) install_pkg eza ;; + brew) install_pkg eza ;; + esac + if has eza; then ok "eza installed"; else warn "eza install failed"; fi +fi + +# lazygit +LAZYGIT_VERSION=0.44.1 +if has lazygit; then + skip "lazygit" +else + case $PM in + apt) + ARCH=$(uname -m) + case $ARCH in + x86_64) LG_ARCH=x86_64 ;; + aarch64) LG_ARCH=arm64 ;; + *) LG_ARCH=x86_64 ;; + esac + curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LAZYGIT_VERSION}/lazygit_${LAZYGIT_VERSION}_Linux_${LG_ARCH}.tar.gz" -o /tmp/lazygit.tar.gz + tar -xzf /tmp/lazygit.tar.gz -C /tmp + sudo mv /tmp/lazygit /usr/local/bin/lazygit + sudo chmod +x /usr/local/bin/lazygit + rm -rf /tmp/lazygit.tar.gz /tmp/LICENSE /tmp/README.md + ;; + pacman) install_pkg lazygit ;; + brew) install_pkg lazygit ;; + esac + if has lazygit; then ok "lazygit installed"; else warn "lazygit install failed"; fi +fi + +# ─── 13. yt-dlp (optional — video/audio download) ──────────────────────────── echo "" echo "── yt-dlp (optional) ──" @@ -275,22 +557,58 @@ else esac fi -# ─── 11. npm dependencies ───────────────────────────────────────────────────── +# ─── 14. npm global packages ───────────────────────────────────────────────── echo "" echo "── npm global packages ──" if has npm; then + # Pi (coding agent) if has pi; then skip "pi (@mariozechner/pi-coding-agent)" else npm install -g @mariozechner/pi-coding-agent if has pi; then ok "pi installed"; else warn "pi install failed"; fi fi + + # Fix ~/.pi ownership (previous installs via sudo may have created root-owned dirs) + if [ -d "$HOME/.pi" ]; then + if find "$HOME/.pi" -not -user "$USER" -print -quit 2>/dev/null | grep -q .; then + sudo chown -R "$USER:$(id -gn)" "$HOME/.pi" + ok "Fixed ~/.pi ownership" + fi + fi + + # Patch Pi compaction bug: calculateContextTokens crashes when usage is undefined + PI_COMPACTION="$(npm root -g 2>/dev/null)/@mariozechner/pi-coding-agent/dist/core/compaction/compaction.js" + if [ -f "$PI_COMPACTION" ]; then + if grep -q "if (!usage) return 0;" "$PI_COMPACTION" 2>/dev/null; then + skip "pi compaction patch (already applied)" + else + sed -i '/^export function calculateContextTokens(usage) {$/a\ if (!usage) return 0;' "$PI_COMPACTION" + ok "Applied pi compaction bug patch" + fi + fi + + # Claude Code + if has claude; then + skip "claude (@anthropic-ai/claude-code)" + else + npm install -g @anthropic-ai/claude-code + if has claude; then ok "claude-code installed"; else warn "claude-code install failed"; fi + fi + + # pm2 (process manager) + if has pm2; then + skip "pm2" + else + npm install -g pm2 + if has pm2; then ok "pm2 installed"; else warn "pm2 install failed"; fi + fi else warn "npm not found — skipping global package installs" fi -# ─── 12. bun install (project dependencies) ─────────────────────────────────── +# ─── 15. bun install (project dependencies) ────────────────────────────────── echo "" echo "── Project dependencies ──" @@ -305,6 +623,26 @@ else warn "Skipping bun install (bun not found or not in project dir)" fi +# ─── 16. remote desktop (XFCE + VNC) ──────────────────────────────────────── +echo "" +echo "── Remote Desktop (XFCE + VNC) ──" + +if systemctl is-active --quiet officer-vnc 2>/dev/null; then + skip "remote desktop (officer-vnc service already running)" +else + case $PM in + apt) + bash "$SCRIPT_DIR/setup-desktop.sh" + ;; + *) + warn "Remote desktop setup is Ubuntu/Debian only — skipping" + ;; + esac +fi + +# ─── 17. PTY sidecar (systemd service) ────────────────────────────────────── +bash "$SCRIPT_DIR/setup-pty-sidecar.sh" + # ─── verification ───────────────────────────────────────────────────────────── echo "" echo "═══════════════════════════════════════════" @@ -321,19 +659,45 @@ check git check node check bun check npm -check docker check ffmpeg check zip check script +check python3 +check make +check gcc + +echo "" +echo "Dev tools:" +check go +check rustc +check cargo +check nvim +check zsh +check starship +check lazygit +check eza +check rg +check fd +check jq +check htop +check tmux +check tree +check btop +check sqlite3 echo "" echo "Audio (cliamp):" -check go check pulseaudio check parec check pactl check cliamp +echo "" +echo "AI agents:" +check pi +check claude +check pm2 + echo "" echo "Optional:" check unzip @@ -342,13 +706,6 @@ check unrar check pgrep check fuser check yt-dlp -check pi - -echo "" -echo "Installable from Settings UI:" -for tool in claude opencode; do - if has "$tool"; then ok "$tool"; else echo " - $tool (install from Settings > Applications)"; fi -done echo "" echo "═══════════════════════════════════════════" @@ -358,6 +715,6 @@ echo "" echo "Notes:" echo " • PulseAudio null sink starts automatically with the server" echo " • Make sure ~/go/bin is in your PATH for cliamp" -echo " • Claude, opencode, sharp, whisper-cpp, mlx-audio" -echo " can be installed from the Settings > Applications page" +echo " • Make sure ~/.cargo/bin is in your PATH for Rust tools" +echo " • sharp, whisper-cpp, mlx-audio can be installed from Settings > Applications" echo "" diff --git a/src/apps/officer-web/Screens/Dashboard/Automation/CLAUDE.md b/src/apps/officer-web/Screens/Dashboard/Automation/CLAUDE.md new file mode 100644 index 00000000..83f052df --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Automation/CLAUDE.md @@ -0,0 +1,276 @@ +# Automation + +## Overview + +The `/automation` route is the hub for managing AI agent capabilities: skills, tools, tasks, and processes. Each capability is a markdown file (with YAML frontmatter) stored on the filesystem. There is no relational database for automation data. + +The page uses a resizable `WorkspaceView` layout with three panels: a sidebar to pick categories, a right panel for browsing/viewing, and an optional bottom-right chat panel for AI-assisted editing. + +``` ++------------------+------------------------------------------+ +| Sidebar (20%) | Right Panel (80%) | +| | - CapabilityList (browse) | +| 8 categories | - CapabilityDetailView (selected item) | +| | - New* form (creating) | +| +------------------------------------------+ +| | AutomationEditChat (when editing, 50%) | ++------------------+------------------------------------------+ +``` + +## Route & Entry Point + +Defined in `App.tsx`: +```tsx +} /> +} /> // stub, not implemented +``` + +Entry component: `index.tsx` (`Automation`). Manages the `WorkspaceView` layout and dynamically adds/removes the chat panel based on `selection.editing`. + +## Capability Categories + +| Category | Kind | Backend Route | Status | +|----------|------|---------------|--------| +| Skills | `Skill` | `GET/POST/DELETE /api/skills` | Working | +| Tools | `Tool` | `GET/POST/DELETE /api/tools` | Working | +| Tasks | `Task` | `GET/POST/DELETE /api/tasks` | Working | +| Processes | `Process` | `GET/POST/DELETE /api/processes` | Working | +| Pipelines | `Pipeline` | None | Placeholder (404) | +| Workflows | `Workflow` | None | Placeholder (404) | +| Crons | `Cron` | None | Placeholder (404) | +| Services | `Service` | None | Placeholder (404) | + +Defined as `capabilityItems` in `AutomationSidebar.tsx`. + +## State Management + +All cross-panel communication uses a single shared channel: + +```ts +usePanelChannel('automation:selected-capability', null) +``` + +`AutomationSelection` (defined in `AutomationRightPanel.tsx`): +```ts +type AutomationSelection = { + kind: string; // 'Skill' | 'Task' | 'Tool' | 'Process' | etc. + endpoint: string; // '/skills' | '/tasks' | etc. + queryKey: string; // React Query cache key + dirName: string; // '' = list view, non-empty = detail view + isNew?: boolean; // just created, AI chat opens in creation mode + editing?: boolean; // chat panel is open + creating?: boolean; // New* form is shown + description?: string; // passed to AI as initial context +} | null; +``` + +Layout state persisted via `useDashboardState('screens/automation')`. + +## Components + +### `AutomationSidebar.tsx` +Left nav with the 8 category buttons. Clicking sets selection to `{ kind, endpoint, queryKey, dirName: '' }` (list view). + +### `AutomationRightPanel.tsx` +Routes to one of three views based on selection state: +- `creating === true` -> `New*` form (mapped via `newComponentMap[kind]`) +- `dirName === ''` -> `CapabilityList` (browse items) +- `dirName !== ''` -> `CapabilityDetailView` (detail for specific item) + +### `CapabilityList.tsx` +Fetches items from `GET {endpoint}`, renders filterable list. Has `+` button to set `creating: true`. + +### `CapabilityDetailView.tsx` +Shows a selected capability's detail (frontmatter + markdown body). Header actions: +- **Back arrow** - returns to list view +- **Run** (tasks only) - parses `inputs:` from frontmatter YAML, opens `TaskRunnerModal` +- **Edit** - toggles `selection.editing` to open/close chat panel +- **Delete** - confirmation dialog, then `DELETE {endpoint}/{dirName}` + +Task input parsing (`parseInputs`) supports: `string`, `number`, `boolean`, `select` types. + +### `AutomationEditChat.tsx` +Bottom chat panel for AI-assisted editing. Uses `CapabilityChat` from `CapabilityPage.tsx`. Features: +- Delete chat history button (`DELETE {endpoint}/{dirName}/chat`) +- Close button (sets `editing: false`) +- On AI response end, invalidates both individual and list query caches + +### `New*.tsx` (8 components) +`NewTask`, `NewSkill`, `NewTool`, `NewProcess`, `NewPipeline`, `NewCron`, `NewService`, `NewWorkflow`. All structurally identical: +1. Name + description form +2. `POST {endpoint}` with `{ name }` +3. On success: transitions to detail view with `isNew: true, editing: true` (opens AI chat in creation mode) + +## Shared Components (`CapabilityPage.tsx`) + +Located at `../CapabilityPage.tsx`. Exports used by automation: + +- **`CapabilityChat`** - AI chat wired to Pi agent via `usePiChat()` + `EmbeddableChat`. Injects `promptFrontmatter` prefix before every message. For `isNew === true`, uses rich creation guides (`buildTaskCreationPrefix`, `buildSkillCreationPrefix`, `buildToolCreationPrefix`). +- **`FrontmatterBlock`** - Collapsible YAML frontmatter display. +- **`CapabilityDetail`** type - `{ dirName, name, description, scope, body, rawFrontmatter, filePath, chatSessionId }`. +- **`CapabilityPage`** - Legacy standalone two-panel page (used by `/skills`, `/tasks`, `/processes` routes, not by `/automation`). + +## Backend + +All four working routers (`skills`, `tools`, `tasks`, `processes`) follow the exact same pattern. Registered on the protected router in `hono.ts`. + +### API Endpoints (per capability type) + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/{type}` | List all items (native + global + user merged) | +| `GET` | `/{type}/:name` | Get detail: frontmatter, body, rawYaml, filePath, chatSessionId | +| `POST` | `/{type}` | Create new item (dir + stub `.md`) | +| `DELETE` | `/{type}/:name` | Delete item directory | +| `GET` | `/{type}/:name/chat` | Get saved chat messages | +| `PUT` | `/{type}/:name/chat` | Save chat messages + session meta | +| `DELETE` | `/{type}/:name/chat` | Delete chat directory | + +Route files: +- `src/servers/api/skills/skills.ts` +- `src/servers/api/tools/tools.ts` +- `src/servers/api/tasks/tasks.ts` (also parses `trigger:` block from frontmatter) +- `src/servers/api/processes/processes.ts` + +### Task Logs + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/task-logs` | List all log metadata (newest first) | +| `GET` | `/task-logs/:filename` | Get full log with messages | + +Route file: `src/servers/api/task-logs/task-logs.ts` + +## Storage Model + +Everything is stored on the filesystem. No database tables. + +``` +DATA_PATH/ + skills/ <- global scope + {skill-name}/ + SKILL.md <- frontmatter + markdown body + chat/ + meta.json <- { id: sessionId } + messages.json <- ChatMessage[] + tasks/ <- global scope + {task-name}/TASK.md + tools/ <- global scope + {tool-name}/TOOL.md + processes/ <- global scope + {process-name}/PROCESS.md + {user-email}/ <- user scope + skills/ + tasks/ + tools/ + processes/ + logs/tasks/ <- task execution logs + {timestamp}-{dirName}.json + +SEED_PATH/ <- native scope (read-only, shipped with app) + skills/ (sharp, whisper-cpp, google-mail-api, ffmpeg, mutagen, fizzy-cli, mlxaudio) + tasks/ (convert-to-mp3, sync-gmail-inbox, tiktok-trends, transcribe-audio-file, ...) + tools/ (apify, browser, email-db, ffmpeg, gmail, ocr, web-fetch, web-search, ...) +``` + +Path helpers: `src/servers/data-path.ts` (`getNativeSkillsDir`, `getGlobalSkillsDir`, `getUserSkillsDir`, etc.) + +### Scope & Permissions + +Three tiers with override resolution: **user > global > native**. + +- `native`: seed directory, read-only, shipped with the app +- `global`: shared data directory, writable by Super Admin +- `user`: per-user directory, writable by the owning user + +Regular users (`Member`) can only create/delete `user`-scoped items. `Super Admin` can also create/delete `global` items. + +## Markdown File Formats + +### TASK.md +```yaml +--- +name: Task Name +description: What the task does. +version: 1 +author: pastilhas +tags: [tag1, tag2] +skills: [skill-name] # optional, skills the agent can consult +tools: [tool_name] # optional, tools the agent can call +trigger: # optional, when omitted: only runnable from Automation page + - type: file + extensions: [mp3, flac] + - type: directory +inputs: # optional, parameters the user fills in before running + - name: param_name + type: string # string | number | boolean | select + required: true + default: some value +--- + +(Agent instructions in markdown) +``` + +### SKILL.md +```yaml +--- +name: skill-name +description: When to use this skill. +--- + +(Knowledge base / reference documentation in markdown) +``` + +### TOOL.md +```yaml +--- +name: tool_name +label: Tool Display Name +description: What the tool does. +language: typescript # typescript | bash | python +inputs: + param_name: + type: string # string | number | boolean | enum | object + description: What this parameter is for. + optional: true + sensitive: true +--- + +(Usage notes, output format, examples in markdown) +``` + +### PROCESS.md +Same structure as SKILL.md (name + description frontmatter, markdown body). + +## Task Execution Flow + +1. User clicks "Run" on a task in `CapabilityDetailView` +2. `parseInputs()` extracts `inputs:` from YAML frontmatter +3. If inputs exist: shows input form dialog; if none: runs immediately +4. Opens `TaskRunnerModal` with prompt: `"Read the task instructions at {filePath} and execute them"` (+ input values if any) +5. Modal connects to Pi agent WebSocket via `usePiChat()` +6. Agent reads the `TASK.md`, resolves skill/tool references, executes in the user's sandboxed container +7. `task-logger.ts` (`createTaskLog` -> `appendToLog` -> `finalizeLog`) writes execution log to `DATA_PATH/{email}/logs/tasks/` + +## Task Trigger Integration + +Tasks with `trigger:` in their frontmatter appear in the file browser context menu: +- `type: file` + `extensions: [mp3]` -> right-click on `.mp3` files shows this task +- `type: directory` -> right-click on directories shows this task + +Implemented via `useTasks` hook in the `officerdev` workspace, which provides `getMatchingTasks(fileName, entryType)`. + +## Seed Sync (Startup) + +On server startup (`bootstrap.ts`): +- `syncSeedSkills()` and `syncSeedTools()` copy/update native seeds to global based on `version:` in frontmatter +- `syncSeedTasks()` exists in code but is **not called** in bootstrap + +## Legacy Routes + +Standalone pages still exist using the older `CapabilityPage` component: +- `/skills` -> `CapabilityPage` (two-panel, list + detail/chat) +- `/tasks` -> `CapabilityPage` +- `/processes` -> `CapabilityPage` + +These share the same API endpoints but use the single-component layout instead of the workspace view. diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx index b48e29d4..0ee8b4b0 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/ServerSettings/AIHarnessesSection.tsx @@ -44,15 +44,12 @@ const PI_PROVIDERS: { key: string; piId: string }[] = [ { key: 'MiniMax', piId: 'minimax' }, { key: 'Hugging Face', piId: 'huggingface' }, { key: 'Azure OpenAI', piId: 'azure-openai-responses' }, - { key: 'OpenCode Zen', piId: 'zai' }, + { key: 'OpenCode Zen', piId: 'opencode' }, + { key: 'ZAI', piId: 'zai' }, { key: 'Cerebras', piId: 'cerebras' }, ]; -type ProbeState = - | { step: 'url' } - | { step: 'probing' } - | { step: 'auth'; probe: ProbeResult } - | { step: 'saving' }; +type ProbeState = { step: 'url' } | { step: 'probing' } | { step: 'auth'; probe: ProbeResult } | { step: 'saving' }; export const AIHarnessesSection = () => { const client = useClient(); @@ -119,8 +116,7 @@ export const AIHarnessesSection = () => { refetchInterval: 30_000, }); - const getStoredMasked = (piId: string) => - storedKeys.find((k) => k.provider === piId)?.value ?? ''; + const getStoredMasked = (piId: string) => storedKeys.find((k) => k.provider === piId)?.value ?? ''; const saveApiKey = async (piId: string) => { const value = keyInputs[piId]; @@ -142,7 +138,7 @@ export const AIHarnessesSection = () => { } }; - const disconnectProvider = async (provider: typeof PI_PROVIDERS[number]) => { + const disconnectProvider = async (provider: (typeof PI_PROVIDERS)[number]) => { await client.put('/server-settings/pi-mono/api-keys', { provider: provider.piId, value: '' }); queryClient.invalidateQueries({ queryKey: ['PI_MONO_API_KEYS'] }); queryClient.invalidateQueries({ queryKey: ['PI_MONO_REMOTE_HEALTH'] }); @@ -160,7 +156,9 @@ export const AIHarnessesSection = () => { } }; - const handleProbe = async (auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }) => { + const handleProbe = async ( + auth?: { type: 'api-key'; apiKey: string } | { type: 'basic'; username: string; password: string }, + ) => { setProbeState({ step: 'probing' }); try { const result = await client.post('/server-settings/pi-mono/local-providers/probe', { @@ -185,9 +183,10 @@ export const AIHarnessesSection = () => { }; const handleAuthSubmit = async (probe: ProbeResult) => { - const auth = probe.authType === 'basic' - ? { type: 'basic' as const, username: authUsername, password: authPassword } - : { type: 'api-key' as const, apiKey: authApiKey }; + const auth = + probe.authType === 'basic' + ? { type: 'basic' as const, username: authUsername, password: authPassword } + : { type: 'api-key' as const, apiKey: authApiKey }; // Re-probe with credentials to verify they work setProbeState({ step: 'probing' }); @@ -276,302 +275,306 @@ export const AIHarnessesSection = () => { {piMonoVersion?.version && ( <> - {/* Local Providers */} -
- Local Providers -
- {localProviders.map((lp: LocalProviderEntry) => ( -
- - {lp.name} - {lp.url} - -
- ))} - {addingLocal ? ( -
- {/* Step 1: Name + URL inputs stacked */} - setLocalName(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Escape') resetLocalForm(); - }} - disabled={probeState.step !== 'url'} - autoFocus - /> - setLocalUrl(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Escape') resetLocalForm(); - if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe(); - }} - disabled={probeState.step !== 'url'} - /> -
- {probeState.step === 'url' && ( - - )} - {(probeState.step === 'probing' || probeState.step === 'saving') && ( - - )} - -
- - {/* Step 2: Auth form (if needed) */} - {probeState.step === 'auth' && ( -
- - {probeState.probe.name} requires authentication - - {probeState.probe.authType === 'basic' ? ( - <> - setAuthUsername(ev.target.value)} - autoFocus - /> - setAuthPassword(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter' && authUsername && authPassword) handleAuthSubmit(probeState.probe); - }} - /> - - - ) : ( - <> - setAuthApiKey(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe); - }} - autoFocus - /> - - - )} -
- )} -
- ) : ( - - )} -
-
- - {/* Remote Providers */} -
- Remote Providers -
- {connectedProviders.map((provider) => ( -
-
+ {/* Local Providers */} +
+ Local Providers +
+ {localProviders.map((lp: LocalProviderEntry) => ( +
- {provider.key} + {lp.name} + {lp.url} -
- {editingProvider === provider.key && ( -
- setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))} - onKeyDown={(ev) => { - if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId); - if (ev.key === 'Escape') setEditingProvider(null); - }} - autoFocus - /> + ))} + {addingLocal ? ( +
+ {/* Step 1: Name + URL inputs stacked */} + setLocalName(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Escape') resetLocalForm(); + }} + disabled={probeState.step !== 'url'} + autoFocus + /> + setLocalUrl(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Escape') resetLocalForm(); + if (ev.key === 'Enter' && localUrl.trim() && probeState.step === 'url') handleProbe(); + }} + disabled={probeState.step !== 'url'} + /> +
+ {probeState.step === 'url' && ( + + )} + {(probeState.step === 'probing' || probeState.step === 'saving') && ( + + )} -
- )} -
- ))} - {editingProvider && (() => { - const provider = PI_PROVIDERS.find((p) => p.key === editingProvider); - if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null; - return ( -
- - setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))} - onKeyDown={(ev) => { - if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId); - if (ev.key === 'Escape') setEditingProvider(null); - }} - autoFocus - /> - - + + {/* Step 2: Auth form (if needed) */} + {probeState.step === 'auth' && ( +
+ + {probeState.probe.name} requires authentication + + {probeState.probe.authType === 'basic' ? ( + <> + setAuthUsername(ev.target.value)} + autoFocus + /> + setAuthPassword(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && authUsername && authPassword) + handleAuthSubmit(probeState.probe); + }} + /> + + + ) : ( + <> + setAuthApiKey(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && authApiKey) handleAuthSubmit(probeState.probe); + }} + autoFocus + /> + + + )} +
+ )}
- ); - })()} - {unconnectedProviders.length > 0 && ( - - )} + ) : ( + + )} +
+
+ + {/* Remote Providers */} +
+ Remote Providers +
+ {connectedProviders.map((provider) => ( +
+
+ + {provider.key} + + +
+ {editingProvider === provider.key && ( +
+ setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId); + if (ev.key === 'Escape') setEditingProvider(null); + }} + autoFocus + /> + + +
+ )} +
+ ))} + {editingProvider && + (() => { + const provider = PI_PROVIDERS.find((p) => p.key === editingProvider); + if (!provider || connectedProviders.some((cp) => cp.piId === provider.piId)) return null; + return ( +
+ + setKeyInputs((prev) => ({ ...prev, [provider.piId]: ev.target.value }))} + onKeyDown={(ev) => { + if (ev.key === 'Enter' && keyInputs[provider.piId]) saveApiKey(provider.piId); + if (ev.key === 'Escape') setEditingProvider(null); + }} + autoFocus + /> + + +
+ ); + })()} + {unconnectedProviders.length > 0 && ( + + )} +
+ + + + No providers found. + + {unconnectedProviders.map((provider) => ( + { + setEditingProvider(provider.key); + setCommandOpen(false); + }} + > + {provider.key} + + ))} + + +
- - - - No providers found. - - {unconnectedProviders.map((provider) => ( - { - setEditingProvider(provider.key); - setCommandOpen(false); - }} - > - {provider.key} - - ))} - - - -
)}
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx index 4df5b5c4..6d3c7b95 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/SystemSettings.tsx @@ -30,6 +30,7 @@ const PROVIDER_DISPLAY: Record = { anthropic: 'Anthropic', openai: 'OpenAI', opencode: 'OpenCode Zen', + zai: 'ZAI', google: 'Google', groq: 'Groq', mistral: 'Mistral', @@ -52,12 +53,42 @@ const groups: SettingsSectionGroup[] = [ label: 'AI', icon: Bot, sections: [ - { key: 'ai-harnesses', icon: Terminal, title: 'Providers', description: 'Remote and local AI providers', content: }, - { key: 'model-visibility', icon: Eye, title: 'Models', description: 'Enable or disable models', content: }, - { key: 'chat-defaults', icon: Terminal, title: 'Chat Defaults', description: 'Model, prompt, and temperature', content: }, - { key: 'tts', icon: Volume2, title: 'Text to Speech', description: 'TTS provider and voice', content: }, + { + key: 'ai-harnesses', + icon: Terminal, + title: 'Providers', + description: 'Remote and local AI providers', + content: , + }, + { + key: 'model-visibility', + icon: Eye, + title: 'Models', + description: 'Enable or disable models', + content: , + }, + { + key: 'chat-defaults', + icon: Terminal, + title: 'Chat Defaults', + description: 'Model, prompt, and temperature', + content: , + }, + { + key: 'tts', + icon: Volume2, + title: 'Text to Speech', + description: 'TTS provider and voice', + content: , + }, { key: 'stt', icon: Mic, title: 'Speech to Text', description: 'Whisper server URL', content: }, - { key: 'ocr', icon: ScanText, title: 'OCR', description: 'Vision model for text extraction', content: }, + { + key: 'ocr', + icon: ScanText, + title: 'OCR', + description: 'Vision model for text extraction', + content: , + }, ], }, { @@ -142,7 +173,10 @@ const SystemTerminalPanel = () => {
Run Command -
@@ -325,7 +359,9 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => { return (
- {label} + + {label} +
providerGroups.map((g) => g.provider), [providerGroups]); // Auto-select first provider if none selected or stale - const selected = providers.includes(activeProvider) ? activeProvider : providers[0] ?? ''; + const selected = providers.includes(activeProvider) ? activeProvider : (providers[0] ?? ''); const currentGroup = providerGroups.find((g) => g.provider === selected); @@ -430,7 +466,11 @@ function ModelVisibilitySection() { }, []); if (providerGroups.length === 0) { - return

No models available. Configure API keys in AI Settings.

; + return ( +

+ No models available. Configure API keys in AI Settings. +

+ ); } return ( @@ -463,17 +503,35 @@ function ModelVisibilitySection() { {/* Enabled section */} - {enabled.length === 0 && Drag models here to enable} + {enabled.length === 0 && ( + Drag models here to enable + )} {enabled.map((m) => ( - + ))} {/* Disabled section */} - {disabled.length === 0 && All models enabled} + {disabled.length === 0 && ( + All models enabled + )} {disabled.map((m) => ( - + ))}
diff --git a/src/server.tsx b/src/server.tsx index 715f1dd9..ab8bd058 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -122,7 +122,7 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'pi' const url = new URL(req.url); const sessionId = url.searchParams.get('sessionId') ?? undefined; - const sandboxed = url.searchParams.get('sandboxed') !== 'false'; + const sandboxed = user.role !== 'Super Admin'; const cwd = url.searchParams.get('cwd') ?? undefined; const command = url.searchParams.get('command') ?? undefined; const cols = url.searchParams.get('cols') ? Number(url.searchParams.get('cols')) : undefined; @@ -272,37 +272,4 @@ void initTerminalSidecars(); } })(); -// Ensure pi is installed -(async () => { - try { - const check = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(check.stdout).text(); - await check.exited; - if (check.exitCode === 0) { - console.log(`[pi] found: ${output.trim()}`); - return; - } - } catch { - // not found - } - - console.log('[pi] not found, installing...'); - try { - const install = Bun.spawn(['npm', 'install', '-g', '@mariozechner/pi-coding-agent'], { - stdout: 'pipe', - stderr: 'pipe', - }); - const stderr = await new Response(install.stderr).text(); - await install.exited; - if (install.exitCode !== 0) { - console.error('[pi] install failed:', stderr.trim()); - return; - } - const ver = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - const version = await new Response(ver.stdout).text(); - await ver.exited; - console.log(`[pi] installed: ${version.trim()}`); - } catch (err) { - console.error('[pi] install failed:', err); - } -})(); +// Pi check/install is handled by bootstrap.ts (imported above) diff --git a/src/servers/api/auth/verify.ts b/src/servers/api/auth/verify.ts index 73435623..fa4d1389 100644 --- a/src/servers/api/auth/verify.ts +++ b/src/servers/api/auth/verify.ts @@ -6,6 +6,7 @@ import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; import { validatePassword } from './validate-password'; import { validateUsername } from './validate-username'; +import { provisionLinuxUser } from '../users/provision'; export const verifyHandler: Handler = async function (ctx) { const { verificationCode, name, username, password, confirmPassword } = ctx.get('body'); @@ -42,6 +43,11 @@ export const verifyHandler: Handler = async function (ctx) { const finalUser = await getUserById(userInfo.id); if (!finalUser) throw errors.NOT_FOUND('User not found'); + // Provision Linux user for terminal/Pi/Claude Code isolation + provisionLinuxUser(finalUser.email, finalUser.username ?? '').catch((err) => { + console.error('[verify] failed to provision Linux user:', err); + }); + // Issue a token so the user is logged in immediately const token = await sign({ id: finalUser.id, diff --git a/src/servers/api/pi/SNAP_NODE_COMPATIBILITY.md b/src/servers/api/pi/SNAP_NODE_COMPATIBILITY.md new file mode 100644 index 00000000..63a05806 --- /dev/null +++ b/src/servers/api/pi/SNAP_NODE_COMPATIBILITY.md @@ -0,0 +1,133 @@ +# Snap Node Compatibility Issue + +## Problem + +When using Officer with **snap node** (`/snap/bin/node`), the Pi harness fails with the following error: + +``` +[Pi] [INFO] Pi process exited +code=1 +``` + +This occurs on the first chat message, before Pi even processes the command. + +## Root Cause + +The snap version of Node.js has an incompatibility with how Bun's `spawn()` function sets up piped stdin file descriptors. When Officer tries to spawn a Pi process with `stdin: 'pipe'`, the process immediately exits with code 1, preventing the RPC communication from working. + +This does **NOT** happen with: +- System node installed via apt/package manager +- NodeSource node +- Homebrew node (on macOS) +- Any non-snap Node.js installation + +## Solution + +**Uninstall snap node and install a system-managed version instead:** + +### Step 1: Remove snap node + +```bash +sudo snap remove node +``` + +### Step 2: Install Node.js via apt (recommended) + +```bash +# Add NodeSource repository for Node 20 LTS +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + +# Install Node.js +sudo apt-get install -y nodejs + +# Verify installation +node --version +which node # Should be /usr/bin/node (NOT /snap/bin/node) +``` + +### Alternative: Using system apt repository + +If NodeSource is unavailable in your region: + +```bash +sudo apt-get update +sudo apt-get install -y nodejs npm +``` + +### Alternative: Using nvm (Node Version Manager) + +For more control over Node.js versions: + +```bash +# Install nvm +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash + +# Install Node LTS +nvm install --lts +nvm use --lts + +# Verify +which node # Should be ~/.nvm/versions/node/*/bin/node +``` + +## Verification + +After installing a non-snap Node.js: + +```bash +# Verify node is not from snap +which node +# Output should NOT contain "/snap/" + +# Verify node works +node --version + +# Clear npm cache +npm cache clean --force +npm install -g pi-coding-agent +``` + +Then restart Officer and try the chat functionality again - it should work! + +## Troubleshooting + +### Still seeing the error after reinstalling Node? + +1. **Restart Officer service** (if running as a service): + ```bash + sudo systemctl restart officer + # or + bun dev # if running locally + ``` + +2. **Verify Bun can find the correct node**: + ```bash + bun run "which node" + which node + # Both should show the same path, not /snap/bin/node + ``` + +3. **Check Pi installation**: + ```bash + pi --version + pi --list-models + ``` + +### Error logs to look for + +If you still see the error, check Officer logs for: + +``` +Pi process exited with code 1 +nodeVersion: ... +nodeExePath: /snap/bin/node +isSnapNode: true +``` + +This confirms snap node is the issue. + +## Why snap node has this issue + +The snap package environment isolates certain system calls and file descriptor handling, which conflicts with Bun's pipe setup mechanism. The snap version of Node.js doesn't properly inherit file descriptor flags when pipes are created by Bun, causing the process to fail on startup. + +This is a known incompatibility and not a bug in Officer or Pi itself. diff --git a/src/servers/api/pi/list-models.ts b/src/servers/api/pi/list-models.ts index 96bec623..b96fe45f 100644 --- a/src/servers/api/pi/list-models.ts +++ b/src/servers/api/pi/list-models.ts @@ -40,23 +40,30 @@ export async function listPiModels(): Promise { } try { - const proc = Bun.spawn(['pi', '--list-models'], { + // Resolve absolute path to pi binary (PATH may differ under pm2/systemd) + const piBin = (() => { + const r = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' }); + return r.stdout.toString().trim() || 'pi'; + })(); + + // Use spawnSync — Bun.spawn (async) loses stdout under pm2 + const proc = Bun.spawnSync({ + cmd: [piBin, '--list-models'], stdout: 'pipe', stderr: 'pipe', env: { ...process.env, PI_CODING_AGENT_DIR: PI_CONFIG_DIR }, }); - const output = await new Response(proc.stdout).text(); - await proc.exited; + const output = proc.stdout.toString(); if (proc.exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderr.trim() }); - return []; + const stderrText = proc.stderr.toString(); + logger.error('pi --list-models failed', { exitCode: proc.exitCode, stderr: stderrText.trim(), piBin }); + return [CLAUDE_CODE_MODEL]; } const lines = output.trim().split('\n'); - if (lines.length < 2) return []; + if (lines.length < 2) return [CLAUDE_CODE_MODEL]; // Parse fixed-width table: provider, model, context, max-out, thinking, images const header = lines[0]!; @@ -89,16 +96,14 @@ export async function listPiModels(): Promise { const thinking = extractCol(line, 4); const images = extractCol(line, 5); - // zai and opencode are the same service — prefer opencode, skip zai duplicates - const displayProvider = provider === 'zai' ? 'opencode' : provider; - const dedupeKey = `${displayProvider}/${model}`; + const dedupeKey = `${provider}/${model}`; if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); models.push({ id: `${provider}/${model}`, name: model, - provider: displayProvider, + provider, contextWindow: parseSize(context), maxTokens: parseSize(maxOut), reasoning: thinking === 'yes', diff --git a/src/servers/api/pi/pi-bridge.ts b/src/servers/api/pi/pi-bridge.ts index a0c83f0c..64d1cd9c 100644 --- a/src/servers/api/pi/pi-bridge.ts +++ b/src/servers/api/pi/pi-bridge.ts @@ -1,34 +1,56 @@ -import { join, relative } from "path"; -import { homedir } from "node:os"; -import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs"; -import type { Subprocess } from "bun"; -import type { PiEvent, MessageCost } from "./types"; -import { readSearxngConfig } from "../server-settings/searxng"; -import { PI_CONFIG_DIR, DATA_PATH, getHomeDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalExtensionsDir, getUserExtensionsDir, getGlobalToolsDir, getUserToolsDir, getNativeResourcesDir, getGlobalResourcesDir } from "../../data-path"; -import { ensureDockerContainer } from "../terminal/websocket"; -import { getServerIntegration, getUserIntegration } from "officerdb"; -import { logger } from "./logger"; -import { parseFrontmatter } from "../skills/skills"; -import { getRelayPort } from "../browser/relay"; -import { registerUserToken } from "../browser/relay-auth"; +import { join } from 'path'; +import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import type { Subprocess } from 'bun'; +import type { PiEvent, MessageCost } from './types'; +import { readSearxngConfig } from '../server-settings/searxng'; +import { + PI_CONFIG_DIR, + DATA_PATH, + getHomeDir, + getGlobalSkillsDir, + getUserSkillsDir, + getGlobalExtensionsDir, + getUserExtensionsDir, + getGlobalToolsDir, + getUserToolsDir, + getNativeResourcesDir, + getGlobalResourcesDir, + toShellUsername, +} from '../../data-path'; +import { getServerIntegration, getUserIntegration } from 'officerdb'; +import { logger } from './logger'; +import { parseFrontmatter } from '../skills/skills'; +import { getRelayPort } from '../browser/relay'; +import { registerUserToken } from '../browser/relay-auth'; + +// Resolve pi as [node, cli.js] — Bun.spawn async pipes break with shebang scripts under pm2 +const PI_CMD = (() => { + const whichResult = Bun.spawnSync({ cmd: ['which', 'pi'], stdout: 'pipe', stderr: 'ignore' }); + const piBin = whichResult.stdout.toString().trim() || 'pi'; + // Follow symlink to get the actual .js file, then invoke via node directly + const readlinkResult = Bun.spawnSync({ cmd: ['readlink', '-f', piBin], stdout: 'pipe', stderr: 'ignore' }); + const realPath = readlinkResult.stdout.toString().trim(); + const nodeResult = Bun.spawnSync({ cmd: ['which', 'node'], stdout: 'pipe', stderr: 'ignore' }); + const nodeBin = nodeResult.stdout.toString().trim() || 'node'; + if (realPath && realPath.endsWith('.js')) { + return [nodeBin, realPath]; + } + // Fallback: use pi binary directly (works for non-pm2 environments) + return [piBin]; +})(); export type PiEventHandler = (event: PiEvent) => void; -type PathOverrides = { global: string; user: string }; - -function collectSkillFlags(email: string, containerPaths?: PathOverrides): string[] { +function collectSkillFlags(email: string): string[] { const flags: string[] = []; - const pairs: Array<[hostDir: string, outputDir: string]> = [ - [getGlobalSkillsDir(), containerPaths?.global ?? getGlobalSkillsDir()], - [getUserSkillsDir(email), containerPaths?.user ?? getUserSkillsDir(email)], - ]; + const dirs = [getGlobalSkillsDir(), getUserSkillsDir(email)]; - for (const [hostDir, outputDir] of pairs) { - if (!existsSync(hostDir)) continue; - for (const entry of readdirSync(hostDir, { withFileTypes: true })) { + for (const dir of dirs) { + if (!existsSync(dir)) continue; + for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - if (existsSync(join(hostDir, entry.name, 'SKILL.md'))) { - flags.push('--skill', `${outputDir}/${entry.name}`); + if (existsSync(join(dir, entry.name, 'SKILL.md'))) { + flags.push('--skill', `${dir}/${entry.name}`); } } } @@ -36,19 +58,16 @@ function collectSkillFlags(email: string, containerPaths?: PathOverrides): strin return flags; } -function collectExtensionFlags(email: string, containerPaths?: PathOverrides): string[] { +function collectExtensionFlags(email: string): string[] { const flags: string[] = []; - const pairs: Array<[hostDir: string, outputDir: string]> = [ - [getGlobalExtensionsDir(), containerPaths?.global ?? getGlobalExtensionsDir()], - [getUserExtensionsDir(email), containerPaths?.user ?? getUserExtensionsDir(email)], - ]; + const dirs = [getGlobalExtensionsDir(), getUserExtensionsDir(email)]; - for (const [hostDir, outputDir] of pairs) { - if (!existsSync(hostDir)) continue; - for (const entry of readdirSync(hostDir, { withFileTypes: true })) { + for (const dir of dirs) { + if (!existsSync(dir)) continue; + for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; - if (existsSync(join(hostDir, entry.name, 'index.ts'))) { - flags.push('--extension', `${outputDir}/${entry.name}/index.ts`); + if (existsSync(join(dir, entry.name, 'index.ts'))) { + flags.push('--extension', `${dir}/${entry.name}/index.ts`); } } } @@ -78,14 +97,22 @@ export function generateResourceSkill(outputDir: string): string | null { for (const [name, baseDir] of resourceDirs) { const resourceMd = join(baseDir, name, 'RESOURCE.md'); let mdContent = ''; - try { mdContent = readFileSync(resourceMd, 'utf-8'); } catch { continue; } + try { + mdContent = readFileSync(resourceMd, 'utf-8'); + } catch { + continue; + } const { frontmatter } = parseFrontmatter(mdContent); // Merge native + global config let nativeConfig: Record = {}; let globalConfig: Record = {}; - try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {} - try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {} + try { + nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); + } catch {} + try { + globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); + } catch {} const config: Record = {}; for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; @@ -94,13 +121,15 @@ export function generateResourceSkill(outputDir: string): string | null { const hasValues = Object.values(config).some((v) => v !== ''); const configLines = Object.entries(config) .filter(([, v]) => v) - .map(([k, v]) => /key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`); + .map(([k, v]) => (/key|secret|password|token/i.test(k) ? `- **${k}**: (configured)` : `- **${k}**: ${v}`)); - sections.push([ - `### ${frontmatter.name || name}`, - hasValues ? 'Status: **configured**' : 'Status: not configured', - ...configLines, - ].join('\n')); + sections.push( + [ + `### ${frontmatter.name || name}`, + hasValues ? 'Status: **configured**' : 'Status: not configured', + ...configLines, + ].join('\n'), + ); } const skillContent = [ @@ -145,8 +174,12 @@ function buildResourcesEnv(): string { for (const [name] of resourceDirs) { let nativeConfig: Record = {}; let globalConfig: Record = {}; - try { nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); } catch {} - try { globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); } catch {} + try { + nativeConfig = JSON.parse(readFileSync(join(nativeDir, name, 'config.json'), 'utf-8')); + } catch {} + try { + globalConfig = JSON.parse(readFileSync(join(globalDir, name, 'config.json'), 'utf-8')); + } catch {} const config: Record = {}; for (const key of Object.keys(nativeConfig)) config[key] = globalConfig[key] ?? nativeConfig[key]!; @@ -161,7 +194,6 @@ function buildResourcesEnv(): string { return JSON.stringify(result); } - async function getApifyToken(): Promise { try { const integration = await getServerIntegration('apify'); @@ -195,22 +227,16 @@ async function resolveApiKeyForModel(model: string): Promise { try { const authFile = Bun.file(join(PI_CONFIG_DIR, 'auth.json')); if (!(await authFile.exists())) return null; - const auth = await authFile.json() as Record; + const auth = (await authFile.json()) as Record; return auth[provider]?.key?.trim() || null; } catch { return null; } } -type SandboxOptions = { - userId: number; - username: string; - email: string; - homeDir: string; -}; - type SpawnPiOptions = { sessionFile?: string; + username?: string; }; export async function spawnPi( @@ -219,132 +245,86 @@ export async function spawnPi( userId: number, email: string, onEvent: PiEventHandler, - sandbox?: SandboxOptions, options?: SpawnPiOptions, ): Promise { - let proc: Subprocess; + const searxng = await readSearxngConfig(); + const skillFlags = collectSkillFlags(email); + const extensionFlags = collectExtensionFlags(email); - if (sandbox) { - const container = await ensureDockerContainer(sandbox.email, sandbox.userId, sandbox.homeDir, sandbox.username); - const searxng = await readSearxngConfig(); - const dockerPath = Bun.which('docker') ?? 'docker'; - const containerId = container.dockerId; - const containerHome = `/home/${sandbox.username}`; + const resourceSkillDir = generateResourceSkill(DATA_PATH); + const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : []; - // Collect skill/extension flags using container-side paths - const skillFlags = collectSkillFlags(sandbox.email, { - global: '/officer/skills', - user: '/officer/user/skills', - }); - const extensionFlags = collectExtensionFlags(sandbox.email, { - global: '/officer/extensions', - user: '/officer/user/extensions', - }); + const piArgs = [ + ...PI_CMD, + '--mode', + 'rpc', + '--no-skills', + '--no-prompt-templates', + '--no-themes', + ...skillFlags, + ...extensionFlags, + ...resourceSkillFlags, + ]; + if (model) piArgs.push('--model', model); + if (options?.sessionFile) piArgs.push('--session', options.sessionFile); - // Generate resource context skill (host-side, mounted into container) - const resourceSkillHost = generateResourceSkill(DATA_PATH); - const resourceSkillFlags = resourceSkillHost ? ['--skill', '/officer/generated/available-resources'] : []; + const apiKey = await resolveApiKeyForModel(model); + if (apiKey) piArgs.push('--api-key', apiKey); - const piArgs = [ - 'pi', '--mode', 'rpc', - '--no-skills', '--no-prompt-templates', '--no-themes', - ...skillFlags, - ...extensionFlags, - ...resourceSkillFlags, - ]; - if (model) piArgs.push('--model', model); - if (options?.sessionFile) piArgs.push('--session', options.sessionFile); - - // Pass API key for the model's provider so the container doesn't need auth.json - const apiKey = await resolveApiKeyForModel(model); - if (apiKey) piArgs.push('--api-key', apiKey); - - const resourcesEnv = buildResourcesEnv(); - - const browserRelayEnv = await getBrowserRelayEnv(sandbox.userId); - const apifyToken = await getApifyToken(); - - const envFlags = [ - '-e', `HOME=${containerHome}`, - '-e', `OFFICER_USER_HOME=${containerHome}`, - '-e', `OFFICER_USER_ROOT=/officer/user`, - '-e', `PI_TOOLS_DIRS=/officer/tools:/officer/user/tools`, - '-e', `PI_SEARXNG_URL=${searxng.url}`, - '-e', `OFFICER_RESOURCES=${resourcesEnv}`, - '-e', `OFFICER_EMAIL_DB=/officer/data/emails.db`, - ...(apifyToken ? ['-e', `OFFICER_APIFY_TOKEN=${apifyToken}`] : []), - ...Object.entries(browserRelayEnv).flatMap(([k, v]) => ['-e', `${k}=${v}`]), - ]; - - const rel = relative(sandbox.homeDir, cwd); - const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome; - proc = Bun.spawn([ - dockerPath, 'exec', '-i', - '-u', `${sandbox.username}`, - '-w', workdir, - ...envFlags, - containerId, - ...piArgs, - ], { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }); - - logger.info('Spawned Pi in container', { - containerId, - model, - skills: skillFlags.filter((f) => f !== '--skill').length, - extensions: extensionFlags.filter((f) => f !== '--extension').length, - }); - } else { - const searxng = await readSearxngConfig(); - const skillFlags = collectSkillFlags(email); - const extensionFlags = collectExtensionFlags(email); - - // Generate resource context skill - const resourceSkillDir = generateResourceSkill(DATA_PATH); - const resourceSkillFlags = resourceSkillDir ? ['--skill', resourceSkillDir] : []; - - const args = ['pi', '--mode', 'rpc', '--no-skills', '--no-prompt-templates', '--no-themes', ...skillFlags, ...extensionFlags, ...resourceSkillFlags]; - if (model) args.push('--model', model); - if (options?.sessionFile) args.push('--session', options.sessionFile); - - if (!existsSync(cwd)) { - mkdirSync(cwd, { recursive: true }); - } - - const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); - const browserRelayEnv = await getBrowserRelayEnv(userId); - const apifyTokenLocal = await getApifyToken(); - - proc = Bun.spawn(args, { - cwd, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - HOME: getHomeDir(email), - OFFICER_USER_HOME: getHomeDir(email), - OFFICER_USER_ROOT: join(DATA_PATH, email), - PI_CODING_AGENT_DIR: PI_CONFIG_DIR, - PI_TOOLS_DIRS: toolsDirs, - PI_SEARXNG_URL: searxng.url, - OFFICER_RESOURCES: buildResourcesEnv(), - OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), - ...(apifyTokenLocal ? { OFFICER_APIFY_TOKEN: apifyTokenLocal } : {}), - ...browserRelayEnv, - }, - }); - - logger.info('Spawned Pi locally', { - model, - skills: skillFlags.filter((f) => f !== '--skill').length, - extensions: extensionFlags.filter((f) => f !== '--extension').length, - }); + if (!existsSync(cwd)) { + mkdirSync(cwd, { recursive: true }); } + const homeDir = getHomeDir(email); + const toolsDirs = [getGlobalToolsDir(), getUserToolsDir(email)].join(':'); + const browserRelayEnv = await getBrowserRelayEnv(userId); + const apifyToken = await getApifyToken(); + const shellUsername = options?.username ?? toShellUsername('', email); + + const env: Record = { + HOME: homeDir, + OFFICER_USER_HOME: homeDir, + OFFICER_USER_ROOT: join(DATA_PATH, email), + PI_CODING_AGENT_DIR: PI_CONFIG_DIR, + PI_TOOLS_DIRS: toolsDirs, + PI_SEARXNG_URL: searxng.url, + OFFICER_RESOURCES: buildResourcesEnv(), + OFFICER_EMAIL_DB: join(DATA_PATH, email, 'emails.db'), + TERM: 'xterm-256color', + PATH: process.env.PATH ?? '', + ...(apifyToken ? { OFFICER_APIFY_TOKEN: apifyToken } : {}), + ...browserRelayEnv, + }; + + const isServiceUser = shellUsername === (process.env.USER ?? ''); + + // For service user, keep real HOME so Pi finds its config + if (isServiceUser) { + env.HOME = process.env.HOME ?? ''; + } + + const proc = isServiceUser + ? Bun.spawn(piArgs, { + cwd, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, ...env }, + }) + : Bun.spawn(['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...piArgs], { + cwd, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + logger.info('Spawned Pi as user', { + username: shellUsername, + model, + skills: skillFlags.filter((f) => f !== '--skill').length, + extensions: extensionFlags.filter((f) => f !== '--extension').length, + }); + // Read stdout JSON event stream (runs in background) const stdout = proc.stdout as ReadableStream; const reader = stdout.getReader(); @@ -360,7 +340,7 @@ export async function spawnPi( buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() ?? ''; - + for (const line of lines) { if (!line.trim()) continue; try { @@ -388,12 +368,14 @@ export async function spawnPi( const stderr = proc.stderr as ReadableStream; const stderrReader = stderr.getReader(); const stderrDecoder = new TextDecoder(); + let stderrOutput = ''; (async () => { try { while (true) { const { done, value } = await stderrReader.read(); if (done) break; const text = stderrDecoder.decode(value, { stream: true }); + stderrOutput += text; if (text.trim()) logger.info('Pi stderr', { text: text.trim() }); } } catch { @@ -403,7 +385,19 @@ export async function spawnPi( // Handle process exit proc.exited.then((code) => { - logger.info('Pi process exited', { code }); + if (code === 1) { + // Exit code 1 often indicates a startup issue, possibly snap node + piped stdin incompatibility + logger.error('Pi process exited with code 1', { + nodeVersion: process.version, + nodeExePath: process.execPath, + isSnapNode: process.execPath?.includes('/snap/'), + hint: 'If node is from snap (/snap/bin/node), uninstall snap node and install via apt instead', + }); + } else if (code !== 0) { + logger.error('Pi process exited with error code', { code }); + } else { + logger.info('Pi process exited normally'); + } }); return proc; @@ -462,7 +456,11 @@ function parsePiEvent(event: Record, currentStreamBuffer: strin if (typeof result === 'object' && result !== null) { resultObj = result as Record; } else if (typeof result === 'string') { - try { resultObj = JSON.parse(result); } catch { /* not JSON */ } + try { + resultObj = JSON.parse(result); + } catch { + /* not JSON */ + } } const isError = (event.isError as boolean) ?? (resultObj?.isError as boolean) ?? false; const output = result != null ? (typeof result === 'string' ? result : JSON.stringify(result)) : ''; @@ -513,21 +511,14 @@ function writeRpcCommand(proc: Subprocess, command: Record): vo } } -export function setThinkingLevel( - process: Subprocess, - level: string, -): void { +export function setThinkingLevel(process: Subprocess, level: string): void { writeRpcCommand(process, { type: 'set_thinking_level', level, }); } -export function sendPrompt( - process: Subprocess, - prompt: string, - requestId: string -): void { +export function sendPrompt(process: Subprocess, prompt: string, requestId: string): void { writeRpcCommand(process, { type: 'prompt', id: requestId, @@ -535,20 +526,14 @@ export function sendPrompt( }); } -export function abort( - process: Subprocess, - requestId: string -): void { +export function abort(process: Subprocess, requestId: string): void { writeRpcCommand(process, { type: 'abort', id: requestId, }); } -export function cancelExtensionUi( - process: Subprocess, - id: unknown -): void { +export function cancelExtensionUi(process: Subprocess, id: unknown): void { writeRpcCommand(process, { type: 'extension_ui_response', id, diff --git a/src/servers/api/pi/rest.ts b/src/servers/api/pi/rest.ts index e557e01e..0c64fffe 100644 --- a/src/servers/api/pi/rest.ts +++ b/src/servers/api/pi/rest.ts @@ -30,6 +30,7 @@ piRestRouter.get('/pi/models', async (ctx: Context) => { providerNames[`officer-local-${lp.id}`] = lp.name; } + logger.info('Models endpoint', { count: models.length, providers: [...new Set(models.map((m) => m.provider))] }); return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' }); } catch (err) { logger.error('Failed to list models', { error: String(err) }); @@ -51,7 +52,9 @@ piRestRouter.post('/pi/sessions', async (ctx: Context) => { const body = await ctx.req.json().catch(() => ({})); const userHome = getHomeDir(user.email); const filterCwd = body.cwd ? resolveBaseCwd(user.email, body.cwdRoot, body.cwd) : null; - const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined; + const contextFilter = body.context + ? { context: body.context as string, contextId: body.contextId as string | undefined } + : undefined; try { let sessions = await storage.listUserSessions(userHome, contextFilter); @@ -119,6 +122,14 @@ piRestRouter.get('/pi/sessions/:sessionId', async (ctx: Context) => { } }); +/** + * PUT /api/pi/sessions/:sessionId/messages + * Client-side message save — no-op, sessions are persisted server-side via WebSocket events + */ +piRestRouter.put('/pi/sessions/:sessionId/messages', async (ctx: Context) => { + return ctx.json({ success: true }); +}); + /** * PATCH /api/pi/sessions/:sessionId * Update session metadata (e.g., rename) @@ -162,9 +173,14 @@ piRestRouter.patch('/pi/sessions/:sessionId', async (ctx: Context) => { } } - const updatedMeta = await storage.updateSessionMeta(userHome, sessionId, { - title: body.title, - }, groupSlug); + const updatedMeta = await storage.updateSessionMeta( + userHome, + sessionId, + { + title: body.title, + }, + groupSlug, + ); return ctx.json({ success: true, @@ -246,7 +262,9 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => { } const body = await ctx.req.json().catch(() => ({})); - const contextFilter = body.context ? { context: body.context as string, contextId: body.contextId as string | undefined } : undefined; + const contextFilter = body.context + ? { context: body.context as string, contextId: body.contextId as string | undefined } + : undefined; const userHome = getHomeDir(user.email); try { @@ -260,7 +278,12 @@ piRestRouter.delete('/pi/sessions', async (ctx: Context) => { // Skip sessions that fail to delete } } - logger.info('Bulk deleted sessions', { email: user.email, deleted, total: sessions.length, context: contextFilter?.context }); + logger.info('Bulk deleted sessions', { + email: user.email, + deleted, + total: sessions.length, + context: contextFilter?.context, + }); return ctx.json({ success: true, deleted }); } catch (err) { logger.error('Failed to bulk delete sessions', { email: user.email, error: String(err) }); diff --git a/src/servers/api/pi/websocket.ts b/src/servers/api/pi/websocket.ts index a868f2d7..72a5f1b4 100644 --- a/src/servers/api/pi/websocket.ts +++ b/src/servers/api/pi/websocket.ts @@ -30,6 +30,7 @@ type WSData = { email: string; username: string; role: string; + sandboxed: boolean; provider: string; }; @@ -70,11 +71,11 @@ export async function open(ws: ServerWebSocket): Promise { export function message(ws: ServerWebSocket, raw: string | Buffer): void { const data = typeof raw === 'string' ? raw : raw.toString(); - + (async () => { try { const clientMsg = JSON.parse(data) as ClientMessage; - + if (clientMsg.type === 'chat') { await handleChat(ws, clientMsg); } else if (clientMsg.type === 'resume') { @@ -91,7 +92,7 @@ export function message(ws: ServerWebSocket, raw: string | Buffer): void export function close(ws: ServerWebSocket): void { // logger.info('WebSocket connection closed', { email: ws.data.email }); - + const sessionId = wsToSessionMap.get(ws); if (sessionId) { sessionManager.detachWs(sessionId); @@ -118,7 +119,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora const text = event.text || session.streamBuffer; if (text) { sendToClient(ws, { type: 'assistant:text', text }); - + const assistantMsg: Message = { id: randomUUID(), timestamp: Date.now(), @@ -137,7 +138,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora // Flush any pending streaming text first if (session.streamBuffer) { sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer }); - + const assistantMsg: Message = { id: randomUUID(), timestamp: Date.now(), @@ -194,7 +195,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora // Flush any remaining streaming buffer if (session.streamBuffer) { sendToClient(ws, { type: 'assistant:text', text: session.streamBuffer }); - + const assistantMsg: Message = { id: randomUUID(), timestamp: Date.now(), @@ -209,7 +210,7 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora } sendToClient(ws, { type: 'result', sessionId, cost: event.cost }); - + session.isGenerating = false; session.meta.cost.inputTokens += event.cost.inputTokens; session.meta.cost.outputTokens += event.cost.outputTokens; @@ -243,11 +244,24 @@ function createEventHandler(sessionId: string, model: string, cwd: string, stora async function handleChat( ws: ServerWebSocket, - msg: { prompt: string; displayText?: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[]; thinking?: string; context?: string; contextId?: string } + msg: { + prompt: string; + displayText?: string; + sessionId?: string; + model?: string; + cwd?: string; + cwdRoot?: string; + sandboxed?: boolean; + groupSlug?: string; + attachmentIds?: string[]; + thinking?: string; + context?: string; + contextId?: string; + }, ): Promise { const { email, username, userId } = ws.data; const sessionId = msg.sessionId || randomUUID(); - + // Use provided model, or fall back to user default, or use system default let model = msg.model; let modelSource = 'client-provided'; @@ -262,7 +276,7 @@ async function handleChat( modelSource = 'system-default'; } } - + logger.info('Model selected for chat', { sessionId, model, @@ -276,41 +290,39 @@ async function handleChat( } const homeDir = getHomeDir(email); - const sandboxed = msg.sandboxed ?? false; - const cwd = sandboxed + const cwd = ws.data.sandboxed ? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd) : resolveHostCwd(msg.cwdRoot, msg.cwd); const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); - session.sandboxed = sandboxed; session.userId = userId; sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); - sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId }); + sendToClient(ws, { + type: 'session:init', + sessionId, + model, + cwd, + context: session.meta.context, + contextId: session.meta.contextId, + }); if (!session.piProcess) { try { const onEvent = createEventHandler(sessionId, model, cwd, homeDir); - const sandbox = sandboxed ? { userId, username, email, homeDir } : undefined; // If session has history, save to disk and pass --session for context replay - let spawnOptions: { sessionFile?: string } | undefined; + let spawnOptions: { sessionFile?: string; username?: string } | undefined; if (session.messages.length > 0) { await storage.saveSession(homeDir, sessionId, session.meta, session.messages); const hostPath = await storage.getSessionFilePath(homeDir, sessionId); if (hostPath) { - if (sandboxed) { - const containerHome = `/home/${username}`; - const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions'); - const relativePart = hostPath.slice(sessionsPrefix.length); - spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` }; - } else { - spawnOptions = { sessionFile: hostPath }; - } + spawnOptions = { sessionFile: hostPath, username }; } } + if (!spawnOptions) spawnOptions = { username }; - session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, sandbox, spawnOptions); + session.piProcess = await piBridge.spawnPi(cwd, model, userId, email, onEvent, spawnOptions); // Null out piProcess when the process dies so next message triggers respawn const proc = session.piProcess; @@ -321,7 +333,12 @@ async function handleChat( } }); - logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed, hasSessionFile: !!spawnOptions?.sessionFile }); + logger.info('Spawned Pi process for session', { + sessionId, + model, + cwd, + hasSessionFile: !!spawnOptions?.sessionFile, + }); } catch (err) { logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' }); @@ -360,34 +377,39 @@ async function handleClaudeCodeChat( ws: ServerWebSocket, sessionId: string, model: string, - msg: { prompt: string; displayText?: string; groupSlug?: string; context?: string; contextId?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean }, + msg: { + prompt: string; + displayText?: string; + groupSlug?: string; + context?: string; + contextId?: string; + cwd?: string; + cwdRoot?: string; + sandboxed?: boolean; + }, ): Promise { const { email, username, userId } = ws.data; const homeDir = getHomeDir(email); - const sandboxed = msg.sandboxed ?? false; - // Claude Code always operates on the user's data home (not OS home). - // Resolve cwd relative to data directory, then remap for container if sandboxed. - const dataCwd = resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd); - let cwd: string; - if (sandboxed) { - const containerHome = `/home/${username}`; - cwd = dataCwd.startsWith(homeDir) - ? `${containerHome}${dataCwd.slice(homeDir.length)}` - : containerHome; - } else { - cwd = dataCwd; - } + const cwd = ws.data.sandboxed + ? resolveSandboxedCwd(email, msg.cwdRoot, msg.cwd) + : resolveHostCwd(msg.cwdRoot, msg.cwd); const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); - session.sandboxed = sandboxed; session.userId = userId; sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); - sendToClient(ws, { type: 'session:init', sessionId, model, cwd, context: session.meta.context, contextId: session.meta.contextId }); + sendToClient(ws, { + type: 'session:init', + sessionId, + model, + cwd, + context: session.meta.context, + contextId: session.meta.contextId, + }); // Add user message to session const userMsg: Message = { @@ -416,7 +438,6 @@ async function handleClaudeCodeChat( prompt: msg.prompt, sessionKey: sessionId, cwd, - sandboxed, onEvent, }); @@ -438,7 +459,7 @@ async function handleClaudeCodeChat( async function handleResume( ws: ServerWebSocket, - msg: { sessionId: string; cwd?: string; cwdRoot?: string } + msg: { sessionId: string; cwd?: string; cwdRoot?: string }, ): Promise { const { email } = ws.data; const { sessionId } = msg; @@ -451,11 +472,11 @@ async function handleResume( try { const { meta, messages } = await storage.loadSession(homeDir, sessionId); - + session = sessionManager.getOrCreate(sessionId, email, meta.cwd, meta.model); session.messages = messages; session.meta = meta; - + logger.info('Loaded session from disk', { sessionId, messageCount: messages.length }); } catch (err) { logger.error('Failed to load session from disk', { sessionId, error: String(err) }); @@ -467,33 +488,40 @@ async function handleResume( sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); - sendToClient(ws, { type: 'session:init', sessionId, model: session.model, cwd: session.cwd, context: session.meta.context, contextId: session.meta.contextId }); + sendToClient(ws, { + type: 'session:init', + sessionId, + model: session.model, + cwd: session.cwd, + context: session.meta.context, + contextId: session.meta.contextId, + }); // Spawn fresh Pi process if needed if (!session.piProcess) { try { const homeDir = getHomeDir(email); - const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, email, homeDir } : undefined; const onEvent = createEventHandler(sessionId, session.model, session.cwd, homeDir); // If session has history, save to disk and pass --session for context replay - let spawnOptions: { sessionFile?: string } | undefined; + let spawnOptions: { sessionFile?: string; username?: string } | undefined; if (session.messages.length > 0) { await storage.saveSession(homeDir, sessionId, session.meta, session.messages); const hostPath = await storage.getSessionFilePath(homeDir, sessionId); if (hostPath) { - if (sandbox) { - const containerHome = `/home/${ws.data.username}`; - const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions'); - const relativePart = hostPath.slice(sessionsPrefix.length); - spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` }; - } else { - spawnOptions = { sessionFile: hostPath }; - } + spawnOptions = { sessionFile: hostPath, username: ws.data.username }; } } + if (!spawnOptions) spawnOptions = { username: ws.data.username }; - session.piProcess = await piBridge.spawnPi(session.cwd, session.model, session.userId!, email, onEvent, sandbox, spawnOptions); + session.piProcess = await piBridge.spawnPi( + session.cwd, + session.model, + session.userId!, + email, + onEvent, + spawnOptions, + ); // Null out piProcess when the process dies so next message triggers respawn const proc = session.piProcess; @@ -504,7 +532,11 @@ async function handleResume( } }); - logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed, hasSessionFile: !!spawnOptions?.sessionFile }); + logger.info('Spawned fresh Pi process for resumed session', { + sessionId, + model: session.model, + hasSessionFile: !!spawnOptions?.sessionFile, + }); } catch (err) { logger.error('Failed to spawn Pi process for resume', { sessionId, error: String(err) }); sendToClient(ws, { type: 'error', message: 'Failed to start Pi process' }); @@ -519,7 +551,7 @@ async function handleResume( isGenerating: session.isGenerating, streamingText: session.streamBuffer, }); - + logger.info('Session resumed successfully', { sessionId, messageCount: session.messages.length }); } catch (err) { logger.error('Unexpected error in handleResume', { sessionId, error: String(err) }); @@ -536,7 +568,7 @@ async function handleStop(ws: ServerWebSocket): Promise { if (session?.piProcess) { try { if (session.model === 'claude-code') { - // Claude Code: kill the docker exec process directly + // Claude Code: kill the process directly session.piProcess.kill(); logger.info('Killed Claude Code process', { sessionId }); } else { diff --git a/src/servers/api/server-settings/pi-mono.ts b/src/servers/api/server-settings/pi-mono.ts index e0371e51..bb01395c 100644 --- a/src/servers/api/server-settings/pi-mono.ts +++ b/src/servers/api/server-settings/pi-mono.ts @@ -31,21 +31,24 @@ type ProbeResult = { }; type PiModelConfig = { - providers: Record; + providers: Record< + string, + { + baseUrl: string; + apiKey?: string; + api: string; + models: { + id: string; + name: string; + reasoning: boolean; + input: string[]; + contextWindow: number; + maxTokens: number; + cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; + }[]; + _officer?: OfficerMeta; + } + >; }; type OfficerMeta = { @@ -143,7 +146,7 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise m.id.includes('lm-studio')); - const apiType = isLmStudio ? 'lmstudio' as const : 'openai-compatible' as const; + const apiType = isLmStudio ? ('lmstudio' as const) : ('openai-compatible' as const); const name = isLmStudio ? 'LM Studio' : 'OpenAI-compatible'; return { success: true, @@ -172,7 +175,13 @@ async function probeUrl(url: string, auth?: LocalProvider['auth']): Promise = {}; if (lp.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${lp.auth.apiKey}`; - else if (lp.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`; + else if (lp.auth?.type === 'basic') + headers['Authorization'] = `Basic ${btoa(`${lp.auth.username}:${lp.auth.password}`)}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); @@ -223,7 +233,12 @@ async function fetchModelsFromProvider( const data = await res.json(); if (lp.apiType === 'ollama' && data.models) { - return data.models.map((m: { name: string }) => ({ id: m.name, name: m.name, contextWindow: 128000, maxTokens: 4096 })); + return data.models.map((m: { name: string }) => ({ + id: m.name, + name: m.name, + contextWindow: 128000, + maxTokens: 4096, + })); } else if (data.data) { return data.data.map((m: { id: string }) => ({ id: m.id, name: m.id, contextWindow: 128000, maxTokens: 4096 })); } @@ -242,9 +257,7 @@ async function addLocalProviderToModelsConfig(lp: LocalProvider): Promise logger.warn('No models found for local provider', { provider: lp.name }); } - const baseUrl = lp.apiType === 'ollama' - ? `${lp.url}/v1` - : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; + const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; const config = await readModelsConfig(); config.providers[`officer-local-${lp.id}`] = { @@ -297,9 +310,7 @@ async function refreshLocalProviders(): Promise { }; const models = await fetchModelsFromProvider(lp); - const baseUrl = lp.apiType === 'ollama' - ? `${lp.url}/v1` - : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; + const baseUrl = lp.apiType === 'ollama' ? `${lp.url}/v1` : lp.url.endsWith('/v1') ? lp.url : `${lp.url}/v1`; entry.baseUrl = baseUrl; entry.apiKey = lp.auth?.type === 'api-key' ? lp.auth.apiKey : 'none'; @@ -350,8 +361,8 @@ export const PROVIDERS: { key: string; piId: string }[] = [ { key: 'MiniMax', piId: 'minimax' }, { key: 'Hugging Face', piId: 'huggingface' }, { key: 'Azure OpenAI', piId: 'azure-openai-responses' }, - { key: 'OpenCode', piId: 'opencode' }, - { key: 'OpenCode Zen', piId: 'zai' }, + { key: 'OpenCode Zen', piId: 'opencode' }, + { key: 'ZAI', piId: 'zai' }, { key: 'Cerebras', piId: 'cerebras' }, ]; @@ -368,9 +379,10 @@ const maskValue = (value: string) => { piMonoRouter.get('/api-keys', async (ctx) => { const auth = await readAuthJson(); - const keys = PROVIDERS - .filter((p) => auth[p.piId]?.key?.trim()) - .map((p) => ({ provider: p.piId, value: maskValue(auth[p.piId]!.key) })); + const keys = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map((p) => ({ + provider: p.piId, + value: maskValue(auth[p.piId]!.key), + })); return ctx.json({ keys }); }); @@ -403,81 +415,114 @@ piMonoRouter.put('/access-policy', async (ctx) => { return ctx.json(body); }); -const REMOTE_HEALTH_CONFIG: Record string); - headers: (key: string) => Record; -}> = { +const REMOTE_HEALTH_CONFIG: Record< + string, + { + url: string | ((key: string) => string); + headers: (key: string) => Record; + } +> = { openai: { url: 'https://api.openai.com/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, - anthropic: { url: 'https://api.anthropic.com/v1/models', headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }) }, + anthropic: { + url: 'https://api.anthropic.com/v1/models', + headers: (k) => ({ 'x-api-key': k, 'anthropic-version': '2023-06-01' }), + }, google: { url: (k) => `https://generativelanguage.googleapis.com/v1beta/models?key=${k}`, headers: () => ({}) }, groq: { url: 'https://api.groq.com/openai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, mistral: { url: 'https://api.mistral.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, xai: { url: 'https://api.x.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, openrouter: { url: 'https://openrouter.ai/api/v1/auth/key', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, cerebras: { url: 'https://api.cerebras.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, - opencode: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, - zai: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, + opencode: { url: 'https://opencode.ai/zen/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, + zai: { url: 'https://opencode.ai/v1/models', headers: (k) => ({ Authorization: `Bearer ${k}` }) }, }; piMonoRouter.get('/api-keys/health', async (ctx) => { const auth = await readAuthJson(); const results: Record = {}; - const checks = PROVIDERS - .filter((p) => auth[p.piId]?.key?.trim()) - .map(async (p) => { - const config = REMOTE_HEALTH_CONFIG[p.piId]; - if (!config) { - results[p.piId] = null; - return; - } - const key = auth[p.piId]!.key; - const url = typeof config.url === 'function' ? config.url(key) : config.url; - const headers = config.headers(key); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - try { - const res = await fetch(url, { headers, signal: controller.signal }); - results[p.piId] = res.ok; - } catch { - results[p.piId] = false; - } finally { - clearTimeout(timer); - } - }); + const checks = PROVIDERS.filter((p) => auth[p.piId]?.key?.trim()).map(async (p) => { + const config = REMOTE_HEALTH_CONFIG[p.piId]; + if (!config) { + results[p.piId] = null; + return; + } + const key = auth[p.piId]!.key; + const url = typeof config.url === 'function' ? config.url(key) : config.url; + const headers = config.headers(key); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const res = await fetch(url, { headers, signal: controller.signal }); + results[p.piId] = res.ok; + } catch { + results[p.piId] = false; + } finally { + clearTimeout(timer); + } + }); await Promise.all(checks); return ctx.json(results); }); -const GLOBAL_DIRS = ['/usr/local/bin', '/usr/bin']; +/** + * Resolve the full path to the `pi` binary. + * Checks PATH first, then falls back to the npm global bin directory + * (which may not be in PATH when the server is managed by pm2). + */ +/** + * Finds the Pi package directory (containing package.json). + * Checks: PATH → npm global prefix → ~/.npm-global fallback. + */ +async function resolvePiPackageDir(): Promise { + const candidates: string[] = []; -const getPaths = async () => { + // 1. Try PATH try { - const proc = Bun.spawn(['which', '-a', 'pi'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(proc.stdout).text(); + const proc = Bun.spawn(['which', 'pi'], { stdout: 'pipe', stderr: 'pipe' }); + const output = (await new Response(proc.stdout).text()).trim(); await proc.exited; - if (proc.exitCode !== 0) return { path: null, globalPath: null }; - const paths = [...new Set(output.trim().split('\n'))]; - const path = paths[0] ?? null; - const globalPath = paths.find((p) => GLOBAL_DIRS.some((dir) => p.startsWith(dir))) ?? null; - return { path, globalPath }; - } catch { - return { path: null, globalPath: null }; + if (proc.exitCode === 0 && output) { + // Resolve symlink: bin/pi -> ../lib/node_modules/.../dist/cli.js + const resolved = (await Bun.file(output).exists()) ? output : null; + if (resolved) { + // Walk up from bin to find the package + const npmGlobalLib = join(output, '..', '..', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'); + candidates.push(npmGlobalLib); + } + } + } catch {} + + // 2. Common locations + const home = process.env.HOME ?? ''; + candidates.push( + join(home, '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'), + '/usr/local/lib/node_modules/@mariozechner/pi-coding-agent', + ); + + for (const dir of candidates) { + const pkgFile = join(dir, 'package.json'); + if (await Bun.file(pkgFile).exists()) return dir; } -}; + return null; +} + +/** Read Pi version directly from its package.json — avoids shebang/spawn issues. */ +async function getPiVersion(): Promise<{ version: string | null; path: string | null }> { + const dir = await resolvePiPackageDir(); + if (!dir) return { version: null, path: null }; + try { + const pkg = await Bun.file(join(dir, 'package.json')).json(); + return { version: pkg.version ?? null, path: dir }; + } catch { + return { version: null, path: dir }; + } +} piMonoRouter.get('/version', async (ctx) => { - try { - const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(proc.stdout).text(); - await proc.exited; - if (proc.exitCode !== 0) return ctx.json({ version: null, path: null, globalPath: null }); - const { path, globalPath } = await getPaths(); - return ctx.json({ version: output.trim(), path, globalPath }); - } catch { - return ctx.json({ version: null, path: null, globalPath: null }); - } + const { version, path } = await getPiVersion(); + return ctx.json({ version, path, globalPath: path }); }); piMonoRouter.post('/install', async (ctx) => { @@ -486,16 +531,16 @@ piMonoRouter.post('/install', async (ctx) => { stdout: 'pipe', stderr: 'pipe', }); + const stderr = await new Response(proc.stderr).text(); await proc.exited; if (proc.exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); return ctx.json({ version: null, path: null, globalPath: null, error: stderr.trim() }, 500); } - const versionProc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - const output = await new Response(versionProc.stdout).text(); - await versionProc.exited; - const { path, globalPath } = await getPaths(); - return ctx.json({ version: output.trim(), path, globalPath }); + + const { version, path } = await getPiVersion(); + if (!version) + return ctx.json({ version: null, path, globalPath: path, error: 'Installed but package.json not found' }, 500); + return ctx.json({ version, path, globalPath: path }); } catch { return ctx.json({ version: null, path: null, globalPath: null, error: 'Installation failed' }, 500); } @@ -505,10 +550,12 @@ piMonoRouter.post('/install', async (ctx) => { piMonoRouter.get('/local-providers', async (ctx) => { const providers = await readLocalProviders(); - return ctx.json(providers.map((p) => ({ - ...p, - auth: p.auth ? { type: p.auth.type } : undefined, - }))); + return ctx.json( + providers.map((p) => ({ + ...p, + auth: p.auth ? { type: p.auth.type } : undefined, + })), + ); }); piMonoRouter.post('/local-providers/probe', async (ctx) => { @@ -519,7 +566,12 @@ piMonoRouter.post('/local-providers/probe', async (ctx) => { }); piMonoRouter.post('/local-providers', async (ctx) => { - const body = await ctx.req.json<{ url: string; name?: string; apiType: LocalProvider['apiType']; auth?: LocalProvider['auth'] }>(); + const body = await ctx.req.json<{ + url: string; + name?: string; + apiType: LocalProvider['apiType']; + auth?: LocalProvider['auth']; + }>(); const provider: LocalProvider = { id: crypto.randomUUID(), @@ -549,24 +601,27 @@ piMonoRouter.get('/local-providers/health', async (ctx) => { const providers = await readLocalProviders(); const results: Record = {}; - await Promise.all(providers.map(async (p) => { - const base = p.url.replace(/\/+$/, ''); - const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models'; - const headers: Record = {}; - if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`; - else if (p.auth?.type === 'basic') headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`; + await Promise.all( + providers.map(async (p) => { + const base = p.url.replace(/\/+$/, ''); + const path = p.apiType === 'ollama' ? '/api/tags' : '/v1/models'; + const headers: Record = {}; + if (p.auth?.type === 'api-key') headers['Authorization'] = `Bearer ${p.auth.apiKey}`; + else if (p.auth?.type === 'basic') + headers['Authorization'] = `Basic ${btoa(`${p.auth.username}:${p.auth.password}`)}`; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 3000); - try { - const res = await fetch(`${base}${path}`, { headers, signal: controller.signal }); - results[p.id] = res.ok; - } catch { - results[p.id] = false; - } finally { - clearTimeout(timer); - } - })); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const res = await fetch(`${base}${path}`, { headers, signal: controller.signal }); + results[p.id] = res.ok; + } catch { + results[p.id] = false; + } finally { + clearTimeout(timer); + } + }), + ); return ctx.json(results); }); diff --git a/src/servers/api/terminal/Dockerfile.terminal-sidecar b/src/servers/api/terminal/Dockerfile.terminal-sidecar deleted file mode 100644 index e0dbb067..00000000 --- a/src/servers/api/terminal/Dockerfile.terminal-sidecar +++ /dev/null @@ -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"] diff --git a/src/servers/api/terminal/entrypoint.sh b/src/servers/api/terminal/entrypoint.sh deleted file mode 100755 index e31c36ca..00000000 --- a/src/servers/api/terminal/entrypoint.sh +++ /dev/null @@ -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 diff --git a/src/servers/api/terminal/pty-sidecar.mjs b/src/servers/api/terminal/pty-sidecar.mjs index 62f905da..45c776be 100644 --- a/src/servers/api/terminal/pty-sidecar.mjs +++ b/src/servers/api/terminal/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) { diff --git a/src/servers/api/terminal/templates/.zshenv b/src/servers/api/terminal/templates/.zshenv new file mode 100644 index 00000000..33cf15ff --- /dev/null +++ b/src/servers/api/terminal/templates/.zshenv @@ -0,0 +1 @@ +skip_global_compinit=1 diff --git a/src/servers/api/terminal/templates/.zshrc b/src/servers/api/terminal/templates/.zshrc index 56766a0b..5ab94bfe 100644 --- a/src/servers/api/terminal/templates/.zshrc +++ b/src/servers/api/terminal/templates/.zshrc @@ -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 diff --git a/src/servers/api/terminal/templates/starship-officer.toml b/src/servers/api/terminal/templates/starship-officer.toml index b87ec7a3..316d5df1 100644 --- a/src/servers/api/terminal/templates/starship-officer.toml +++ b/src/servers/api/terminal/templates/starship-officer.toml @@ -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 diff --git a/src/servers/api/terminal/websocket.ts b/src/servers/api/terminal/websocket.ts index a5083037..17a01b9e 100644 --- a/src/servers/api/terminal/websocket.ts +++ b/src/servers/api/terminal/websocket.ts @@ -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; 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, BridgeSession>(); -const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json'); -let dockerImageReady = false; -let containersCache: Record | null = null; let hostSidecarProcess: ReturnType | null = null; const sendOutput = (ws: ServerWebSocket, data: string) => { @@ -43,14 +35,14 @@ const sendOutput = (ws: ServerWebSocket, data: string) => { } }; -const connectSidecar = async (port: number): Promise => { +const connectSidecar = async (): Promise => { 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((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 => { 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 => { 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> => { - if (containersCache) return containersCache; - const data = await Bun.file(containerMapPath) - .json() - .catch(() => ({})); - containersCache = data as Record; - return containersCache; -}; - -const saveContainerMap = async (map: Record) => { - containersCache = map; - await Bun.write(containerMapPath, JSON.stringify(map, null, 2)); -}; - -const getAvailablePort = (map: Record, 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 => { - 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) { 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 - } - } }; diff --git a/src/servers/api/users/provision.ts b/src/servers/api/users/provision.ts new file mode 100644 index 00000000..507c5576 --- /dev/null +++ b/src/servers/api/users/provision.ts @@ -0,0 +1,147 @@ +import { existsSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { DATA_PATH, getHomeDir, toShellUsername } from '@@/data-path'; +import { generateContainerContext, generateClaudeSettings } from '@@/generate-container-context'; + +const TEMPLATE_DIR = join(import.meta.dir, '../terminal/templates'); + +const run = (cmd: string[], opts?: { cwd?: string }): boolean => { + const result = Bun.spawnSync({ cmd, stdout: 'ignore', stderr: 'pipe', ...opts }); + if (result.exitCode !== 0) { + console.error(`[provision] command failed: ${cmd.join(' ')}`, result.stderr.toString().trim()); + } + return result.exitCode === 0; +}; + +const linuxUserExists = (username: string): boolean => { + const result = Bun.spawnSync({ cmd: ['id', username], stdout: 'ignore', stderr: 'ignore' }); + return result.exitCode === 0; +}; + +const copyTemplate = async (src: string, dest: string) => { + if (existsSync(dest)) return; + const content = await Bun.file(src).text(); + await Bun.write(dest, content); +}; + +export async function provisionLinuxUser(email: string, username: string): Promise { + const shellUsername = toShellUsername(username, email); + const homeDir = getHomeDir(email); + const userRoot = join(DATA_PATH, email); + + console.log(`[provision] provisioning Linux user ${shellUsername} for ${email}`); + + // Ensure data directories exist + mkdirSync(userRoot, { recursive: true }); + mkdirSync(homeDir, { recursive: true }); + + // Create Linux user if not exists + if (!linuxUserExists(shellUsername)) { + const ok = run(['sudo', 'useradd', '-d', homeDir, '-s', '/bin/zsh', '-M', shellUsername]); + if (!ok) { + console.error(`[provision] failed to create Linux user ${shellUsername}`); + return false; + } + console.log(`[provision] created Linux user ${shellUsername}`); + } else { + console.log(`[provision] Linux user ${shellUsername} already exists`); + } + + // Set ownership and permissions on user data directory + // chmod 770 so the service user (in the user's group) can read/write for background jobs + run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]); + run(['sudo', 'chmod', '770', userRoot]); + + // Add the service user to the new user's group so server jobs can access user data + const serviceUser = process.env.USER ?? ''; + if (serviceUser && serviceUser !== shellUsername) { + run(['sudo', 'usermod', '-aG', shellUsername, serviceUser]); + } + + // Seed shell config files + await seedShellConfigs(homeDir); + + // Generate and write CLAUDE.md + settings.json + const contextFile = generateContainerContext(email); + const claudeDir = join(homeDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + + // Copy context to user's .claude dir + const contextContent = await Bun.file(contextFile).text(); + await Bun.write(join(claudeDir, 'CLAUDE.md'), contextContent); + + const settingsFile = generateClaudeSettings(email, shellUsername); + const settingsContent = await Bun.file(settingsFile).text(); + await Bun.write(join(claudeDir, 'settings.json'), settingsContent); + + // Fix ownership after seeding + run(['sudo', 'chown', '-R', `${shellUsername}:${shellUsername}`, userRoot]); + + console.log(`[provision] provisioning complete for ${shellUsername}`); + return true; +} + +async function seedShellConfigs(homeDir: string): Promise { + // .zshenv (must be first — prevents system compinit before oh-my-zsh) + await copyTemplate(join(TEMPLATE_DIR, '.zshenv'), join(homeDir, '.zshenv')); + + // .zshrc + await copyTemplate(join(TEMPLATE_DIR, '.zshrc'), join(homeDir, '.zshrc')); + + // .tmux.conf + await copyTemplate(join(TEMPLATE_DIR, '.tmux.conf'), join(homeDir, '.tmux.conf')); + + // starship config + const configDir = join(homeDir, '.config'); + mkdirSync(configDir, { recursive: true }); + await copyTemplate(join(TEMPLATE_DIR, 'starship-officer.toml'), join(configDir, 'starship-officer.toml')); + + // Oh My Zsh — copy from host install + const ohMyZshDest = join(homeDir, '.oh-my-zsh'); + if (!existsSync(ohMyZshDest)) { + const hostOhMyZsh = join(process.env.HOME ?? '', '.oh-my-zsh'); + if (existsSync(hostOhMyZsh)) { + Bun.spawnSync({ cmd: ['cp', '-r', hostOhMyZsh, ohMyZshDest], stdout: 'ignore', stderr: 'ignore' }); + } else { + Bun.spawnSync({ + cmd: ['git', 'clone', '--depth=1', 'https://github.com/ohmyzsh/ohmyzsh.git', ohMyZshDest], + stdout: 'ignore', + stderr: 'ignore', + }); + } + } + + // LazyVim config + const nvimDir = join(homeDir, '.config', 'nvim'); + if (!existsSync(nvimDir)) { + const hostNvim = join(process.env.HOME ?? '', '.config', 'nvim'); + if (existsSync(hostNvim)) { + Bun.spawnSync({ cmd: ['cp', '-r', hostNvim, nvimDir], stdout: 'ignore', stderr: 'ignore' }); + } + } + + // Ensure .local/bin exists + mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true }); + + // Ensure .pi/agent/sessions exists + mkdirSync(join(homeDir, '.pi', 'agent', 'sessions'), { recursive: true }); +} + +export function deprovisionLinuxUser(email: string, username: string): boolean { + const shellUsername = toShellUsername(username, email); + console.log(`[provision] deprovisioning Linux user ${shellUsername}`); + + if (!linuxUserExists(shellUsername)) { + console.log(`[provision] Linux user ${shellUsername} does not exist, skipping`); + return true; + } + + const ok = run(['sudo', 'userdel', shellUsername]); + if (!ok) { + console.error(`[provision] failed to delete Linux user ${shellUsername}`); + return false; + } + + console.log(`[provision] deprovisioned Linux user ${shellUsername}`); + return true; +} diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index 972c50d1..699c6e1a 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -6,6 +6,7 @@ import { sendMail } from 'emailer'; import * as errors from '@@/custom-errors'; import { originMiddleware } from '@@/_middlewares'; import { updateUserHandler } from './update-user'; +import { deprovisionLinuxUser } from './provision'; export const usersRouter = createRouter(); usersRouter.use(originMiddleware); @@ -35,9 +36,10 @@ usersRouter.post('/invite', async (ctx) => { } const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin'); - const assignedRole = (typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number])) - ? (role as (typeof USER_ROLES)[number]) - : ('Member' as const); + const assignedRole = + typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number]) + ? (role as (typeof USER_ROLES)[number]) + : ('Member' as const); const existing = await getUserByEmail(email); if (existing) throw errors.CONFLICT('A user with this email already exists'); @@ -101,6 +103,9 @@ usersRouter.delete('/:id', async (ctx) => { const target = await getUserById(id); if (!target) throw errors.NOT_FOUND('User not found'); + // Deprovision Linux user before deleting from database + deprovisionLinuxUser(target.email, target.username ?? ''); + await deleteUser(id); return ctx.json({ ok: true }); }); diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index 8530670e..f306a63b 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -1,4 +1,6 @@ import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; import { DATA_PATH } from './data-path'; import { syncSeedSkills } from './sync-skills'; import { syncSeedTools } from './sync-tools'; @@ -13,14 +15,29 @@ import { startWhatsAppBotIfConfigured } from './channels/whatsapp/bot'; mkdirSync(DATA_PATH, { recursive: true }); -async function ensurePiInstalled(): Promise { - try { - const proc = Bun.spawn(['pi', '--version'], { stdout: 'pipe', stderr: 'pipe' }); - await proc.exited; - return proc.exitCode === 0; - } catch { - return false; +/** Check common locations for the Pi package directory. */ +async function findPiPackageDir(): Promise { + const candidates = [ + join(homedir(), '.npm-global', 'lib', 'node_modules', '@mariozechner', 'pi-coding-agent'), + '/usr/local/lib/node_modules/@mariozechner/pi-coding-agent', + ]; + for (const dir of candidates) { + if (await Bun.file(join(dir, 'package.json')).exists()) return dir; } + return null; +} + +async function ensurePiInstalled(): Promise { + const dir = await findPiPackageDir(); + if (!dir) return false; + try { + const pkg = await Bun.file(join(dir, 'package.json')).json(); + if (pkg.version) { + console.log(`[bootstrap] Pi found: ${pkg.version}`); + return true; + } + } catch {} + return false; } async function installPi(): Promise { diff --git a/src/servers/channels/send-and-await.ts b/src/servers/channels/send-and-await.ts index 8ae10eba..65830d2b 100644 --- a/src/servers/channels/send-and-await.ts +++ b/src/servers/channels/send-and-await.ts @@ -91,7 +91,10 @@ export async function sendAndAwait(params: SendAndAwaitParams): Promise((resolve) => { releaseLock = resolve; }); - sessionLocks.set(sessionId, existing.then(() => lockPromise)); + sessionLocks.set( + sessionId, + existing.then(() => lockPromise), + ); await existing; @@ -291,23 +294,18 @@ async function doSend(sessionId: string, params: SendAndAwaitParams): Promise { try { if (!session.piProcess) { - let spawnOptions: { sessionFile?: string } | undefined; + let spawnOptions: { sessionFile?: string; username?: string } | undefined; if (session.messages.length > 0) { await storage.saveSession(homeDir, sessionId, session.meta, session.messages); const hostPath = await storage.getSessionFilePath(homeDir, sessionId); if (hostPath) { - // Remap host path to container path - const containerHome = `/home/${username}`; - const sessionsPrefix = join(homeDir, '.pi', 'agent', 'sessions'); - const relativePart = hostPath.slice(sessionsPrefix.length); - spawnOptions = { sessionFile: `${containerHome}/.pi/agent/sessions${relativePart}` }; + spawnOptions = { sessionFile: hostPath, username }; } } + if (!spawnOptions) spawnOptions = { username }; const dispatcher = createDispatcher(sessionId); - const sandbox = { userId, username, email, homeDir }; - session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, sandbox, spawnOptions); - session.sandboxed = true; + session.piProcess = await piBridge.spawnPi(cwd, model!, userId, email, dispatcher, spawnOptions); const proc = session.piProcess; proc.exited.then(() => { diff --git a/src/servers/channels/send-claude-code.ts b/src/servers/channels/send-claude-code.ts index b2a6e7be..6256a5a9 100644 --- a/src/servers/channels/send-claude-code.ts +++ b/src/servers/channels/send-claude-code.ts @@ -1,8 +1,16 @@ -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { existsSync, symlinkSync, rmdirSync } from 'node:fs'; -import { ensureDockerContainer } from '@@/api/terminal/websocket'; -import { getHomeDir, DATA_PATH, getNativeToolsDir, getGlobalToolsDir, getUserToolsDir, getNativeSkillsDir, getGlobalSkillsDir, getUserSkillsDir } from '@@/data-path'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { + getHomeDir, + DATA_PATH, + getNativeToolsDir, + getGlobalToolsDir, + getUserToolsDir, + getNativeSkillsDir, + getGlobalSkillsDir, + getUserSkillsDir, + toShellUsername, +} from '@@/data-path'; import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/tools/tools'; import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills'; import { buildHostToolEnv } from '@@/api/pi/pi-bridge'; @@ -11,6 +19,12 @@ import type { MessageCost, PiEvent } from '@@/api/pi/types'; const SEND_TIMEOUT_MS = 5 * 60 * 1000; +// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH +const CLAUDE_BIN = (() => { + const result = Bun.spawnSync({ cmd: ['which', 'claude'], stdout: 'pipe', stderr: 'ignore' }); + return result.stdout.toString().trim() || 'claude'; +})(); + type ClaudeCodeParams = { userId: number; email: string; @@ -85,9 +99,16 @@ async function buildToolsSystemPrompt(email: string): Promise { Array.from(mergedTools.entries()).map(async ([dirName, filePath]) => { const raw = await Bun.file(filePath).text(); const { frontmatter, body } = parseToolFrontmatter(raw); - const toolDir = dirname(filePath); + const toolDir = join(filePath, '..'); const hasImpl = await Bun.file(`${toolDir}/index.ts`).exists(); - return { dirName, name: frontmatter.name || dirName, description: frontmatter.description, body, toolDir, hasImpl }; + return { + dirName, + name: frontmatter.name || dirName, + description: frontmatter.description, + body, + toolDir, + hasImpl, + }; }), ); const runnerPath = `${getNativeToolsDir()}/run.ts`; @@ -125,45 +146,59 @@ async function buildToolsSystemPrompt(email: string): Promise { export async function sendClaudeCode(params: ClaudeCodeParams): Promise { const { userId, email, username, prompt, sessionKey } = params; const homeDir = getHomeDir(email); + const shellUsername = toShellUsername(username, email); + const toolEnv = await buildHostToolEnv(userId, email); - const container = await ensureDockerContainer(email, userId, homeDir, username); - const dockerPath = Bun.which('docker') ?? 'docker'; - const containerId = container.dockerId; - const containerHome = `/home/${username}`; - - const args = [ - dockerPath, 'exec', '-i', - '-u', username, - '-w', containerHome, - '-e', `HOME=${containerHome}`, - containerId, - 'claude', '-p', prompt, - '--dangerously-skip-permissions', - '--output-format', 'json', - ]; + const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json']; const existingSession = claudeCodeSessions.get(sessionKey); if (existingSession) { - args.push('--resume', existingSession); + claudeArgs.push('--resume', existingSession); } - logger.info('Claude Code exec', { sessionKey, containerId, resume: existingSession ?? null }); + const isServiceUser = shellUsername === (process.env.USER ?? ''); - const proc = Bun.spawn(args, { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', + // For service user, keep real HOME so Claude Code finds its credentials + const env: Record = { + ...toolEnv, + ...(isServiceUser ? { HOME: process.env.HOME ?? '' } : {}), + PATH: process.env.PATH ?? '', + TERM: 'xterm-256color', + }; + + logger.info('Claude Code exec', { + sessionKey, + username: shellUsername, + isServiceUser, + resume: existingSession ?? null, }); + const proc = isServiceUser + ? Bun.spawn(claudeArgs, { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, ...env }, + }) + : Bun.spawn( + ['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs], + { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }, + ); + const timeout = setTimeout(() => { - try { proc.kill(); } catch { /* already dead */ } + try { + proc.kill(); + } catch { + /* already dead */ + } }, SEND_TIMEOUT_MS); try { - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const exitCode = await proc.exited; clearTimeout(timeout); @@ -233,7 +268,6 @@ type ClaudeCodeStreamingParams = { prompt: string; sessionKey: string; cwd?: string; - sandboxed?: boolean; onEvent: (event: PiEvent) => void; }; @@ -242,12 +276,19 @@ type ClaudeCodeStreamingHandle = { }; export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams): Promise { - const { userId, email, username, prompt, sessionKey, cwd, sandboxed = false, onEvent } = params; + const { userId, email, username, prompt, sessionKey, cwd, onEvent } = params; + + const shellUsername = toShellUsername(username, email); + const homeDir = getHomeDir(email); + const workDir = cwd ?? homeDir; const claudeArgs = [ - 'claude', '-p', prompt, + CLAUDE_BIN, + '-p', + prompt, '--dangerously-skip-permissions', - '--output-format', 'stream-json', + '--output-format', + 'stream-json', '--verbose', '--include-partial-messages', ]; @@ -263,69 +304,51 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams) claudeArgs.push('--append-system-prompt', systemPrompt); } - let proc: ReturnType; + const toolEnv = await buildHostToolEnv(userId, email); + const { CLAUDECODE: _, ...cleanEnv } = process.env; - if (sandboxed) { - // Container execution via docker exec - const homeDir = getHomeDir(email); - const container = await ensureDockerContainer(email, userId, homeDir, username); - const dockerPath = Bun.which('docker') ?? 'docker'; - const containerId = container.dockerId; - const containerHome = `/home/${username}`; - const workDir = cwd ?? containerHome; + const isServiceUser = shellUsername === (process.env.USER ?? ''); - const args = [ - dockerPath, 'exec', - '-u', username, - '-w', workDir, - '-e', `HOME=${containerHome}`, - containerId, - ...claudeArgs, - ]; + // For service user, keep real HOME so Claude Code finds its credentials + const env: Record = { + ...toolEnv, + ...(isServiceUser ? { HOME: cleanEnv.HOME ?? '' } : {}), + PATH: cleanEnv.PATH ?? '', + TERM: 'xterm-256color', + }; - logger.info('Claude Code streaming exec (container)', { sessionKey, containerId, resume: existingSession ?? null }); + logger.info('Claude Code streaming exec', { + sessionKey, + username: shellUsername, + isServiceUser, + cwd: workDir, + resume: existingSession ?? null, + }); - proc = Bun.spawn(args, { - stdin: 'ignore', - stdout: 'pipe', - stderr: 'pipe', - }); - } else { - // Host execution — run claude directly - const claudePath = Bun.which('claude') ?? 'claude'; - claudeArgs[0] = claudePath; - const workDir = cwd ?? homedir(); - - logger.info('Claude Code streaming exec (host)', { sessionKey, cwd: workDir, resume: existingSession ?? null }); - - const { CLAUDECODE: _, ...cleanEnv } = process.env; - const toolEnv = await buildHostToolEnv(userId, email); - - // Ensure Claude Code can find ~/.claude credentials in the user's data home. - // Symlink the host's .claude config into the data home if not already there. - const dataHome = toolEnv.HOME!; - const hostClaudeConfig = join(homedir(), '.claude'); - const targetClaudeConfig = join(dataHome, '.claude'); - const targetCredentials = join(targetClaudeConfig, '.credentials.json'); - if (!existsSync(targetCredentials) && existsSync(hostClaudeConfig)) { - try { - // Remove empty placeholder dir if it exists, then symlink - if (existsSync(targetClaudeConfig)) rmdirSync(targetClaudeConfig); - symlinkSync(hostClaudeConfig, targetClaudeConfig); - } catch { /* race, permission, or non-empty dir */ } - } - - proc = Bun.spawn(claudeArgs, { - cwd: workDir, - stdin: 'ignore', - stdout: 'pipe', - stderr: 'pipe', - env: { ...cleanEnv, ...toolEnv }, - }); - } + const proc = isServiceUser + ? Bun.spawn(claudeArgs, { + cwd: workDir, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + env: { ...cleanEnv, ...env }, + }) + : Bun.spawn( + ['sudo', '-u', shellUsername, 'env', ...Object.entries(env).map(([k, v]) => `${k}=${v}`), ...claudeArgs], + { + cwd: workDir, + stdin: 'ignore', + stdout: 'pipe', + stderr: 'pipe', + }, + ); const timeout = setTimeout(() => { - try { proc.kill(); } catch { /* already dead */ } + try { + proc.kill(); + } catch { + /* already dead */ + } onEvent({ type: 'error', message: 'Claude Code timed out after 5 minutes' }); }, SEND_TIMEOUT_MS); @@ -493,12 +516,19 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams) if (!gotResult) { const exitCode = await proc.exited; const stderr = await new Response(proc.stderr as ReadableStream).text(); - logger.info('Claude Code exited without result event', { sessionKey, exitCode, stderr: stderr.trim().slice(0, 500) }); + logger.info('Claude Code exited without result event', { + sessionKey, + exitCode, + stderr: stderr.trim().slice(0, 500), + }); if (textBuffer) { onEvent({ type: 'text', text: textBuffer }); } if (exitCode !== 0) { - onEvent({ type: 'error', message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}` }); + onEvent({ + type: 'error', + message: `Claude Code exited with code ${exitCode}: ${stderr.trim().slice(0, 200)}`, + }); } else { onEvent({ type: 'result', cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 } }); } diff --git a/src/servers/generate-container-context.ts b/src/servers/generate-container-context.ts index 047092f0..a7fd8bde 100644 --- a/src/servers/generate-container-context.ts +++ b/src/servers/generate-container-context.ts @@ -1,6 +1,16 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { getGlobalToolsDir, getUserToolsDir, getGlobalSkillsDir, getUserSkillsDir, getGlobalTasksDir, getUserTasksDir, getGlobalResourcesDir, getHomeDir, DATA_PATH } from '@@/data-path'; +import { + getGlobalToolsDir, + getUserToolsDir, + getGlobalSkillsDir, + getUserSkillsDir, + getGlobalTasksDir, + getUserTasksDir, + getGlobalResourcesDir, + getHomeDir, + DATA_PATH, +} from '@@/data-path'; type HookEntry = { type: string; command: string }; type HookRule = { matcher?: Record; hooks: HookEntry[] }; @@ -53,9 +63,15 @@ export function generateContainerContext(email: string): string { const tasks = dedup([...scanDir(getGlobalTasksDir(), 'TASK.md'), ...scanDir(getUserTasksDir(email), 'TASK.md')]); const resources = scanDir(getGlobalResourcesDir(), 'RESOURCE.md'); - const content = `# Officer — Container Environment + const globalToolsDir = getGlobalToolsDir(); + const userToolsDir = getUserToolsDir(email); + const globalSkillsDir = getGlobalSkillsDir(); + const userSkillsDir = getUserSkillsDir(email); + const userDataDir = join(DATA_PATH, email); -This is a sandboxed development container managed by the Officer platform. + const content = `# Officer — User Environment + +This is an isolated Linux user environment managed by the Officer platform. ## Directory Layout @@ -64,14 +80,14 @@ This is a sandboxed development container managed by the Officer platform. | \`~\` | User home directory (read-write) | | \`~/Projects/\` | User projects | | \`~/Downloads/\` | Downloaded files | -| \`/officer/tools/\` | Global tools (read-only) | -| \`/officer/user/tools/\` | User tools (read-only) | -| \`/officer/skills/\` | Reference skills (read-only) | -| \`/officer/data/\` | User data (emails.db, attachments, etc.) | +| \`${globalToolsDir}/\` | Global tools | +| \`${userToolsDir}/\` | User tools | +| \`${globalSkillsDir}/\` | Reference skills | +| \`${userDataDir}/\` | User data (emails.db, attachments, etc.) | ## Available Tools -Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`/officer/tools//TOOL.md\` or \`/officer/user/tools//TOOL.md\`. +Tools are callable capabilities used by the Officer AI agent (Pi). Each tool has a \`TOOL.md\` with documentation and an \`index.ts\` that exports an \`execute()\` function. Read individual tool docs at \`${globalToolsDir}//TOOL.md\` or \`${userToolsDir}//TOOL.md\`. ${formatList(tools)} ## Available Skills @@ -91,7 +107,7 @@ Resources are external service integrations (TTS, STT, OCR, etc.) configured in ${formatList(resources)} ## Creating New Tools -Create a directory in \`/officer/user/tools//\` with two files: +Create a directory in \`${userToolsDir}//\` with two files: **TOOL.md** — Frontmatter metadata + markdown documentation: \`\`\`yaml @@ -118,8 +134,6 @@ export async function execute(_toolCallId: string, params: Record = {}; try { claudeJson = JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) as Record; @@ -176,7 +190,7 @@ export function generateClaudeSettings(email: string, username?: string): string // no existing config } const projects = (claudeJson.projects ?? {}) as Record>; - const projectKey = containerHome; + const projectKey = homeDir; if (!projects[projectKey]) projects[projectKey] = {}; projects[projectKey]!.hasTrustDialogAccepted = true; claudeJson.projects = projects; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index b3b95ad6..107cce6c 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -85,12 +85,7 @@ function SessionChat({ sessionId, model }: SessionChatProps) { navigate('/chat', { replace: true }); }} /> - +
); } @@ -132,13 +127,13 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) { const location = useLocation(); const locationState = location.state as ChatLocationState; const { user } = useAuth(); - const showToggle = allowHostMode && user?.role === 'Super Admin'; - const [cwdMode, setCwdMode] = useState<'user' | 'host'>('user'); + const isSuperAdmin = user?.role === 'Super Admin'; const chat = usePiChat(undefined, locationState?.model); - const sandboxed = showToggle ? cwdMode === 'user' : true; - const cwd = !sandboxed ? { path: getHostHome() } : locationState?.cwd; + // Super Admin always operates as host — no toggle needed + const sandboxed = !isSuperAdmin; + const cwd = isSuperAdmin ? { path: getHostHome() } : locationState?.cwd; const initialMessage = locationState?.initialMessage ? { @@ -157,7 +152,6 @@ function NewChat({ allowHostMode }: { allowHostMode?: boolean }) { isGenerating={chat.isGenerating} onDelete={undefined} /> - {!chat.hasStarted && showToggle && } { +export const TerminalHeader = () => { const { cwd } = useWorkspace(); - const { user } = useAuth(); - const { mode, toggle } = useTerminalMode(panelId); - const isHost = mode === 'host'; - const scoped = cwd !== '~'; - const Icon = isHost && !scoped ? Monitor : TerminalSquare; return ( <> - + Terminal - {user?.role === 'Super Admin' && !scoped && ( - - )} {cwd} ); diff --git a/src/workspaces/officerdev/src/hooks/usePiChat.ts b/src/workspaces/officerdev/src/hooks/usePiChat.ts index b315320f..cc967068 100644 --- a/src/workspaces/officerdev/src/hooks/usePiChat.ts +++ b/src/workspaces/officerdev/src/hooks/usePiChat.ts @@ -23,7 +23,16 @@ type UsePiChatOptions = { }; export function usePiChat(initialSessionId?: string, initialModel?: string | null, options?: UsePiChatOptions) { - const { replaceUrl = true, storage, resourceChatDir, taskInfo, projectScoped, context, contextId, onTurnComplete } = options ?? {}; + const { + replaceUrl = true, + storage, + resourceChatDir, + taskInfo, + projectScoped, + context, + contextId, + onTurnComplete, + } = options ?? {}; const [messages, setMessages] = useState([]); const [streamingText, setStreamingText] = useState(''); const [isGenerating, setIsGenerating] = useState(false); @@ -77,9 +86,10 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul function commitStreaming() { if (!streamingRef.current) return; - setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text: streamingRef.current }]); + const text = streamingRef.current; streamingRef.current = ''; setStreamingText(''); + setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]); } function handleMessage(data: unknown) { @@ -286,7 +296,10 @@ export function usePiChat(initialSessionId?: string, initialModel?: string | nul setHasStarted(true); } - setMessages((prev) => [...prev, { role: 'user', text: displayText ?? text, ...(images?.length ? { images } : {}) }]); + setMessages((prev) => [ + ...prev, + { role: 'user', text: displayText ?? text, ...(images?.length ? { images } : {}) }, + ]); setIsGenerating(true); streamingRef.current = ''; setStreamingText(''); diff --git a/src/workspaces/state/src/useModels.ts b/src/workspaces/state/src/useModels.ts index 960a0f9e..f70a8c9a 100644 --- a/src/workspaces/state/src/useModels.ts +++ b/src/workspaces/state/src/useModels.ts @@ -30,7 +30,11 @@ export function usePiModels() { queryKey: ['PI_MODELS'], enabled: isAuthenticated, queryFn: async () => { - const data = await client.get<{ models: ModelOption[]; providerNames?: Record; hostHome?: string }>('/pi/models'); + const data = await client.get<{ + models: ModelOption[]; + providerNames?: Record; + hostHome?: string; + }>('/pi/models'); if (data.providerNames) { globalProviderNames = data.providerNames; @@ -48,13 +52,14 @@ export function usePiModels() { return models; } -/** Filter models by system-wide access policy. New providers pass through. */ +/** Filter models by system-wide access policy. Super Admin sees all. New providers pass through. */ export function useVisiblePiModels() { const models = usePiModels(); + const { user } = useAuth(); const { policy } = useAccessPolicy(); const allowed = policy.allowedModels; - if (allowed.length === 0) return models; + if (user?.role === 'Super Admin' || allowed.length === 0) return models; const allowedSet = new Set(allowed); const allowedProviderSet = new Set(allowed.map((key) => key.split(':')[0]));