From 032cfa34066a6b9ac06929933b3b1aace80dee36 Mon Sep 17 00:00:00 2001 From: edmen12 <158250033+edmen12@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:52:36 +0800 Subject: [PATCH 1/3] Add Linux-first runtime support --- .gitattributes | 3 + .github/workflows/linux-ci.yml | 69 +++++++ LINUX.md | 109 +++++++++++ README.md | 4 + .../desktop-commander-device.service.template | 27 +++ package-lock.json | 2 +- package.json | 5 +- scripts/install-linux-service.sh | 181 ++++++++++++++++++ src/config-manager.ts | 15 +- src/handlers/process-handlers.ts | 8 +- src/index.ts | 6 + src/npm-scripts/linux-service.ts | 30 +++ src/platform/processes.ts | 129 +++++++++++++ src/platform/runtime.ts | 57 ++++++ src/remote-device/device-authenticator.ts | 4 +- src/server.ts | 12 +- src/tools/process.ts | 128 ++++++++++--- src/tools/schemas.ts | 6 +- src/utils/open-browser.ts | 11 +- test/test-linux-platform.js | 113 +++++++++++ test/test-linux-service-installer.js | 54 ++++++ 21 files changed, 912 insertions(+), 61 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/linux-ci.yml create mode 100644 LINUX.md create mode 100644 deploy/linux/desktop-commander-device.service.template create mode 100755 scripts/install-linux-service.sh create mode 100644 src/npm-scripts/linux-service.ts create mode 100644 src/platform/processes.ts create mode 100644 src/platform/runtime.ts create mode 100644 test/test-linux-platform.js create mode 100644 test/test-linux-service-installer.js diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..5d74dae2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +*.service text eol=lf +*.service.template text eol=lf diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml new file mode 100644 index 00000000..8e9d75a5 --- /dev/null +++ b/.github/workflows/linux-ci.yml @@ -0,0 +1,69 @@ +name: Linux CI + +on: + push: + branches: [main, "feat/**"] + pull_request: + +permissions: + contents: read + +env: + DESKTOP_COMMANDER_DISABLE_TELEMETRY: "true" + DESKTOP_COMMANDER_HEADLESS: "true" + +jobs: + ubuntu-matrix: + name: Ubuntu ${{ matrix.os }} / Node ${{ matrix.node }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, ubuntu-24.04] + node: [20, 22] + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y procps ripgrep + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm run build + - run: node dist/npm-scripts/verify-ripgrep.js + - run: node test/test-linux-platform.js + - run: node test/test-linux-service-installer.js + + debian-12: + name: Debian 12 / Node 20 + runs-on: ubuntu-24.04 + container: node:20-bookworm + + steps: + - name: Install system dependencies + run: apt-get update && apt-get install -y --no-install-recommends git procps ripgrep bash ca-certificates systemd + - uses: actions/checkout@v4 + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm run build + - run: node dist/npm-scripts/verify-ripgrep.js + - run: node test/test-linux-platform.js + - run: node test/test-linux-service-installer.js + - name: Validate systemd installer syntax + run: bash -n scripts/install-linux-service.sh + + full-linux-suite: + name: Full Linux test suite + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y python3 procps ripgrep + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm test diff --git a/LINUX.md b/LINUX.md new file mode 100644 index 00000000..80563d74 --- /dev/null +++ b/LINUX.md @@ -0,0 +1,109 @@ +# Desktop Commander on Linux + +Desktop Commander supports desktop Linux and headless Ubuntu/Debian servers. +The Linux runtime uses native shells, `ps`, filesystem permissions, signals, +and systemd rather than Windows-specific process APIs. + +## Supported baseline + +- Ubuntu 22.04 and 24.04 +- Debian 12 +- Node.js 20 and 22 +- Bash, POSIX `sh`, Zsh, and Fish +- Desktop sessions using X11 or Wayland +- Headless VPS and server environments + +## Local development + +```bash +git clone https://github.com/wonderwhy-er/DesktopCommanderMCP.git +cd DesktopCommanderMCP +npm ci +npm run build +node test/test-linux-platform.js +``` + +Run the MCP server directly: + +```bash +node dist/index.js +``` + +## Headless remote device + +A Linux host without `DISPLAY`, `WAYLAND_DISPLAY`, or `MIR_SOCKET` is detected +as headless. Browser launch is skipped and the device authorization URL and code +remain visible in the terminal or systemd journal. + +You can force the runtime mode: + +```bash +export DESKTOP_COMMANDER_HEADLESS=true +``` + +Start a remote device manually: + +```bash +desktop-commander remote --persist-session --disable-no-sleep +``` + +The persisted device session is stored under: + +```text +~/.desktop-commander-device/device.json +``` + +The file is created with mode `0600`. + +## systemd installation + +Install the package globally first, then run: + +```bash +sudo desktop-commander linux-service --user "$USER" +``` + +The installer refuses to create a root-owned remote agent by default. A root +service requires the explicit `--allow-root` flag and grants the connected AI +full root-level host access, so it is not recommended. + +For a non-standard executable path: + +```bash +sudo desktop-commander linux-service \ + --user "$USER" \ + --bin "$HOME/.npm-global/bin/desktop-commander" +``` + +From a source checkout, the equivalent installer is +`sudo ./scripts/install-linux-service.sh --user "$USER"`. + +The installer enables the service but does not start it by default. Start it and +watch the first authorization flow with: + +```bash +sudo systemctl start desktop-commander-device +sudo journalctl -u desktop-commander-device -f +``` + +Use `--start` to start it immediately during installation. + +## Service management + +```bash +systemctl status desktop-commander-device +sudo systemctl restart desktop-commander-device +sudo systemctl stop desktop-commander-device +journalctl -u desktop-commander-device --since today +``` + +The unit uses `KillMode=control-group`, so child shells and long-running commands +are stopped with the service. It also enables `NoNewPrivileges`, `PrivateTmp`, +and read-only protection for system directories. + +## Security boundary + +Run the service as a dedicated non-root user. Desktop Commander can execute +arbitrary commands with that user's permissions. Directory allowlists and the +command blocklist reduce mistakes but do not provide sandbox isolation. Use a +container or VM when the connected AI must not reach the wider host. diff --git a/README.md b/README.md index 08582104..07022bf6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ Execute long-running terminal commands on your computer and manage processes thr - Command blocklist for accidental execution - [Docker isolation](#option-6-docker-installation--auto-updates-no-nodejs-required) for complete isolation +## Linux support + +Linux desktop and headless Ubuntu/Debian deployments are documented in [LINUX.md](LINUX.md), including systemd installation and runtime behavior. + ## How to install ### Install in Claude Desktop diff --git a/deploy/linux/desktop-commander-device.service.template b/deploy/linux/desktop-commander-device.service.template new file mode 100644 index 00000000..865b24e4 --- /dev/null +++ b/deploy/linux/desktop-commander-device.service.template @@ -0,0 +1,27 @@ +[Unit] +Description=Desktop Commander Remote Device +Documentation=https://github.com/wonderwhy-er/DesktopCommanderMCP +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User=__USER__ +Group=__GROUP__ +WorkingDirectory=__HOME__ +Environment="HOME=__HOME__" +Environment=NODE_ENV=production +Environment=DESKTOP_COMMANDER_HEADLESS=true +Environment="PATH=__PATH__" +ExecStart="__BINARY__" remote --persist-session --disable-no-sleep +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 +KillMode=control-group +UMask=0077 +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full + +[Install] +WantedBy=multi-user.target diff --git a/package-lock.json b/package-lock.json index aaa597f1..e4b26cbe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "typescript": "^5.3.3" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "optionalDependencies": { "caffeinate": "^1.0.1" diff --git a/package.json b/package.json index af1faf52..3a7b8ec3 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "bugs": "https://github.com/wonderwhy-er/DesktopCommanderMCP/issues", "type": "module", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "bin": { "desktop-commander": "dist/index.js", @@ -35,7 +35,7 @@ "bump": "node scripts/sync-version.js --bump", "bump:minor": "node scripts/sync-version.js --bump --minor", "bump:major": "node scripts/sync-version.js --bump --major", - "build": "tsc && shx cp setup-claude-server.js uninstall-claude-server.js track-installation.js dist/ && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && shx mkdir -p dist/remote-device/scripts && shx cp src/remote-device/scripts/blocking-offline-update.js dist/remote-device/scripts/ && node scripts/build-ui-runtime.cjs", + "build": "tsc && shx cp setup-claude-server.js uninstall-claude-server.js track-installation.js dist/ && shx chmod +x dist/*.js && shx mkdir -p dist/data && shx cp src/data/onboarding-prompts.json dist/data/ && shx mkdir -p dist/remote-device/scripts && shx cp src/remote-device/scripts/blocking-offline-update.js dist/remote-device/scripts/ && node scripts/build-ui-runtime.cjs && shx mkdir -p dist/linux && shx cp scripts/install-linux-service.sh deploy/linux/desktop-commander-device.service.template dist/linux/ && shx chmod +x dist/linux/install-linux-service.sh", "watch": "tsc --watch", "start": "node dist/index.js", "start:debug": "node --inspect-brk=9229 dist/index.js", @@ -46,6 +46,7 @@ "clean": "shx rm -rf dist", "test": "npm run build && node test/run-all-tests.js", "test:integration": "npm run build && node test/integration/run-all-integration-tests.js", + "test:linux": "npm run build && node test/test-linux-platform.js && node test/test-linux-service-installer.js", "test:debug": "node --inspect test/run-all-tests.js", "validate:tools": "npm run build && node scripts/validate-tools-sync.js", "link:local": "npm run build && npm link", diff --git a/scripts/install-linux-service.sh b/scripts/install-linux-service.sh new file mode 100755 index 00000000..302dce6e --- /dev/null +++ b/scripts/install-linux-service.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_NAME="desktop-commander-device" +TARGET_USER="${SUDO_USER:-${USER:-}}" +DC_BINARY="" +START_SERVICE=false +ALLOW_ROOT=false +DRY_RUN=false +TEMP_UNIT="" + +cleanup() { + if [[ -n "${TEMP_UNIT}" ]]; then + rm -f -- "${TEMP_UNIT}" + fi +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +Install Desktop Commander Remote Device as a systemd service. + +Usage: sudo desktop-commander linux-service [options] + --user USER Non-root Linux user that runs the service + --bin PATH Absolute path to the desktop-commander executable + --service NAME systemd service name + --start Start the service after installation + --allow-root Explicitly allow a root-owned service (not recommended) + --dry-run Validate and print the generated unit without installing + -h, --help Show this help +EOF +} + +require_value() { + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Option $1 requires a value." >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --user) require_value "$@"; TARGET_USER="$2"; shift 2 ;; + --bin) require_value "$@"; DC_BINARY="$2"; shift 2 ;; + --service) require_value "$@"; SERVICE_NAME="$2"; shift 2 ;; + --start) START_SERVICE=true; shift ;; + --allow-root) ALLOW_ROOT=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 2 ;; + esac +done + +if [[ "$(uname -s)" != "Linux" ]]; then + echo "This installer only supports Linux." >&2 + exit 1 +fi + +if [[ "${DRY_RUN}" != "true" && "${EUID}" -ne 0 ]]; then + echo "Run this installer with sudo or as root." >&2 + exit 1 +fi + +if ! command -v systemctl >/dev/null 2>&1 || + ! command -v systemd-analyze >/dev/null 2>&1; then + echo "systemd is required but systemctl/systemd-analyze was not found." >&2 + exit 1 +fi + +if [[ -z "${TARGET_USER}" ]] || ! id "${TARGET_USER}" >/dev/null 2>&1; then + echo "Linux user does not exist: ${TARGET_USER:-}" >&2 + exit 1 +fi + +if [[ "${TARGET_USER}" == "root" && "${ALLOW_ROOT}" != "true" ]]; then + echo "Refusing to install a root-owned remote agent. Pass a non-root --user." >&2 + echo "Use --allow-root only when full host-level access is intentional." >&2 + exit 1 +fi + +if [[ ! "${SERVICE_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9_.@-]*$ ]]; then + echo "Invalid service name: ${SERVICE_NAME}" >&2 + exit 1 +fi + +TARGET_GROUP="$(id -gn "${TARGET_USER}")" +TARGET_HOME="$(getent passwd "${TARGET_USER}" | cut -d: -f6)" +if [[ -z "${TARGET_HOME}" || "${TARGET_HOME}" != /* ]]; then + echo "Could not resolve an absolute home directory for ${TARGET_USER}." >&2 + exit 1 +fi + +find_user_binary() { + if command -v runuser >/dev/null 2>&1; then + runuser -u "${TARGET_USER}" -- sh -lc 'command -v desktop-commander' || true + elif command -v sudo >/dev/null 2>&1; then + sudo -u "${TARGET_USER}" -H sh -lc 'command -v desktop-commander' || true + elif command -v su >/dev/null 2>&1; then + su -s /bin/sh "${TARGET_USER}" -c 'command -v desktop-commander' || true + fi +} + +if [[ -z "${DC_BINARY}" ]]; then + DC_BINARY="$(command -v desktop-commander || true)" +fi +if [[ -z "${DC_BINARY}" ]]; then + DC_BINARY="$(find_user_binary)" +fi + +if [[ -z "${DC_BINARY}" || "${DC_BINARY}" != /* ]]; then + echo "desktop-commander executable must be an absolute path. Pass --bin /absolute/path." >&2 + exit 1 +fi + +DC_BINARY="$(realpath -e -- "${DC_BINARY}")" +if [[ ! -x "${DC_BINARY}" ]]; then + echo "desktop-commander executable is not executable: ${DC_BINARY}" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +PACKAGED_TEMPLATE="${SCRIPT_DIR}/desktop-commander-device.service.template" +REPO_TEMPLATE="${SCRIPT_DIR}/../deploy/linux/desktop-commander-device.service.template" +if [[ -f "${PACKAGED_TEMPLATE}" ]]; then + TEMPLATE="${PACKAGED_TEMPLATE}" +else + TEMPLATE="${REPO_TEMPLATE}" +fi + +if [[ ! -f "${TEMPLATE}" ]]; then + echo "Service template not found: ${TEMPLATE}" >&2 + exit 1 +fi + +UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service" +BINARY_DIR="$(dirname "${DC_BINARY}")" +SERVICE_PATH="${BINARY_DIR}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +TEMP_UNIT="$(mktemp "/tmp/${SERVICE_NAME}.XXXXXX.service")" + +escape_unit_value() { + printf '%s' "$1" | sed -e 's/%/%%/g' -e 's/[\\&|]/\\&/g' +} + +sed \ + -e "s|__USER__|$(escape_unit_value "${TARGET_USER}")|g" \ + -e "s|__GROUP__|$(escape_unit_value "${TARGET_GROUP}")|g" \ + -e "s|__HOME__|$(escape_unit_value "${TARGET_HOME}")|g" \ + -e "s|__BINARY__|$(escape_unit_value "${DC_BINARY}")|g" \ + -e "s|__PATH__|$(escape_unit_value "${SERVICE_PATH}")|g" \ + "${TEMPLATE}" > "${TEMP_UNIT}" + +systemd-analyze verify "${TEMP_UNIT}" + +if [[ "${DRY_RUN}" == "true" ]]; then + cat "${TEMP_UNIT}" + exit 0 +fi + +install -m 0644 "${TEMP_UNIT}" "${UNIT_PATH}" +install -d -m 0700 -o "${TARGET_USER}" -g "${TARGET_GROUP}" \ + "${TARGET_HOME}/.desktop-commander-device" \ + "${TARGET_HOME}/.claude-server-commander" + +systemctl daemon-reload +systemctl enable -- "${SERVICE_NAME}.service" + +if [[ "${START_SERVICE}" == "true" ]]; then + systemctl restart -- "${SERVICE_NAME}.service" +fi + +cat < { - if (os.platform() === 'win32') { - return 'powershell.exe'; - } - // Use user's actual shell from environment - // On macOS, default to zsh (default since Catalina) since process.env.SHELL - // may not be set when running inside Claude Desktop - const fallbackShell = os.platform() === 'darwin' ? '/bin/zsh' : '/bin/sh'; - const userShell = process.env.SHELL || fallbackShell; - // Return just the shell path - we'll handle login shell flag elsewhere - return userShell; - })(), + defaultShell: getDefaultShell(), allowedDirectories: [], telemetryEnabled: true, // Default to opt-out approach (telemetry on by default) fileWriteLineLimit: 50, // Default line limit for file write operations (changed from 100) diff --git a/src/handlers/process-handlers.ts b/src/handlers/process-handlers.ts index fef82042..eaabcf58 100644 --- a/src/handlers/process-handlers.ts +++ b/src/handlers/process-handlers.ts @@ -1,9 +1,9 @@ -import { +import { listProcesses, killProcess } from '../tools/process.js'; -import { +import { KillProcessArgsSchema } from '../tools/schemas.js'; @@ -12,8 +12,8 @@ import { ServerResult } from '../types.js'; /** * Handle list_processes command */ -export async function handleListProcesses(): Promise { - return listProcesses(); +export async function handleListProcesses(args: unknown): Promise { + return listProcesses(args); } /** diff --git a/src/index.ts b/src/index.ts index 995940a1..47093942 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { runUninstall } from './npm-scripts/uninstall.js'; import { capture } from './utils/capture.js'; import { logToStderr, logger } from './utils/logger.js'; import { runRemote } from './npm-scripts/remote.js'; +import { runLinuxServiceInstaller } from './npm-scripts/linux-service.js'; import { ensureChromeAvailable } from './tools/pdf/markdown.js'; // Store messages to defer until after initialization @@ -41,6 +42,11 @@ async function runServer() { return; } + if (process.argv[2] === 'linux-service') { + await runLinuxServiceInstaller(); + return; + } + // Parse command line arguments for onboarding control const DISABLE_ONBOARDING = process.argv.includes('--no-onboarding'); if (DISABLE_ONBOARDING) { diff --git a/src/npm-scripts/linux-service.ts b/src/npm-scripts/linux-service.ts new file mode 100644 index 00000000..dcee5c14 --- /dev/null +++ b/src/npm-scripts/linux-service.ts @@ -0,0 +1,30 @@ +import { spawn } from 'child_process'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export async function runLinuxServiceInstaller(): Promise { + if (process.platform !== 'linux') { + throw new Error('The linux-service command can only run on Linux.'); + } + + const scriptPath = path.resolve(__dirname, '../linux/install-linux-service.sh'); + await fs.access(scriptPath); + const args = process.argv.slice(3); + + const exitCode = await new Promise((resolve, reject) => { + const child = spawn('bash', [scriptPath, ...args], { + stdio: 'inherit', + env: process.env, + }); + child.once('error', reject); + child.once('close', (code) => resolve(code ?? 1)); + }); + + if (exitCode !== 0) { + throw new Error(`Linux service installer exited with code ${exitCode}`); + } +} diff --git a/src/platform/processes.ts b/src/platform/processes.ts new file mode 100644 index 00000000..ee460586 --- /dev/null +++ b/src/platform/processes.ts @@ -0,0 +1,129 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import type { ProcessInfo } from '../types.js'; + +const execFileAsync = promisify(execFile); +const PROCESS_OUTPUT_LIMIT = 10 * 1024 * 1024; + +export interface DetailedProcessInfo extends ProcessInfo { + ppid?: number; + user?: string; + runtime?: string; + args?: string; +} + +function parsePosixArgsOutput(output: string): Map { + const argsByPid = new Map(); + + for (const line of output.split(/\r?\n/)) { + const match = line.match(/^\s*(\d+)(?:\s+(.*))?$/); + if (!match) continue; + argsByPid.set(Number(match[1]), match[2]?.trim() || ''); + } + + return argsByPid; +} + +export function parsePosixProcessOutput( + summaryOutput: string, + argsOutput = '', +): DetailedProcessInfo[] { + const processes: DetailedProcessInfo[] = []; + const argsByPid = parsePosixArgsOutput(argsOutput); + const pattern = /^\s*(\d+)\s+(\d+)\s+(\S+)\s+([\d.]+)\s+([\d.]+)\s+(\S+)\s+(.+?)\s*$/; + + for (const line of summaryOutput.split(/\r?\n/)) { + if (!line.trim()) continue; + const match = line.match(pattern); + if (!match) continue; + + const pid = Number(match[1]); + processes.push({ + pid, + ppid: Number(match[2]), + user: match[3], + cpu: match[4], + memory: match[5], + runtime: match[6], + command: match[7], + args: argsByPid.get(pid), + }); + } + + return processes; +} + +interface WindowsProcessRecord { + pid: number; + ppid?: number; + command?: string; + args?: string | null; + memoryMb?: number; +} + +export function parseWindowsProcessOutput(output: string): DetailedProcessInfo[] { + if (!output.trim()) return []; + const parsed = JSON.parse(output) as WindowsProcessRecord | WindowsProcessRecord[]; + const records = Array.isArray(parsed) ? parsed : [parsed]; + + return records.map((record) => ({ + pid: Number(record.pid), + ppid: record.ppid === undefined ? undefined : Number(record.ppid), + command: record.command || 'unknown', + args: record.args || undefined, + cpu: 'n/a', + memory: record.memoryMb === undefined ? 'n/a' : `${record.memoryMb} MB`, + })); +} + +function getWindowsProcessScript(includeArgs: boolean): string { + const argsProperty = includeArgs ? 'args = $_.CommandLine;' : ''; + + return [ + '$items = Get-CimInstance Win32_Process | ForEach-Object {', + ' [pscustomobject]@{', + ' pid = $_.ProcessId; ppid = $_.ParentProcessId; command = $_.Name;', + ` ${argsProperty} memoryMb = [math]::Round($_.WorkingSetSize / 1MB, 1)`, + ' }', + '};', + '$items | ConvertTo-Json -Compress', + ].join(' '); +} + +async function runPs( + platform: NodeJS.Platform, + format: string, +): Promise { + const mode = platform === 'darwin' ? '-axo' : '-eo'; + const { stdout } = await execFileAsync('ps', [mode, format], { + encoding: 'utf8', + windowsHide: true, + maxBuffer: PROCESS_OUTPUT_LIMIT, + }); + return String(stdout); +} + +export async function listPlatformProcesses( + platform: NodeJS.Platform = process.platform, + includeArgs = false, +): Promise { + if (platform === 'win32') { + const { stdout } = await execFileAsync( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', getWindowsProcessScript(includeArgs)], + { encoding: 'utf8', windowsHide: true, maxBuffer: PROCESS_OUTPUT_LIMIT }, + ); + return parseWindowsProcessOutput(String(stdout)); + } + + const runtimeField = platform === 'darwin' ? 'etime=' : 'etimes='; + const summaryFormat = `pid=,ppid=,user=,%cpu=,%mem=,${runtimeField},comm=`; + const summaryOutput = await runPs(platform, summaryFormat); + + if (!includeArgs) { + return parsePosixProcessOutput(summaryOutput); + } + + const argsOutput = await runPs(platform, 'pid=,args='); + return parsePosixProcessOutput(summaryOutput, argsOutput); +} diff --git a/src/platform/runtime.ts b/src/platform/runtime.ts new file mode 100644 index 00000000..35006f68 --- /dev/null +++ b/src/platform/runtime.ts @@ -0,0 +1,57 @@ +export type RuntimeEnvironment = NodeJS.ProcessEnv; + +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']); + +function parseBoolean(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (TRUE_VALUES.has(normalized)) return true; + if (FALSE_VALUES.has(normalized)) return false; + return undefined; +} + +export function getDefaultShell( + platform: NodeJS.Platform = process.platform, + env: RuntimeEnvironment = process.env, +): string { + if (platform === 'win32') { + return 'powershell.exe'; + } + + if (env.SHELL?.trim()) { + return env.SHELL; + } + + return platform === 'darwin' ? '/bin/zsh' : '/bin/sh'; +} + +export function hasGraphicalSession( + platform: NodeJS.Platform = process.platform, + env: RuntimeEnvironment = process.env, +): boolean { + if (platform === 'win32' || platform === 'darwin') { + return true; + } + + return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY || env.MIR_SOCKET); +} + +export function isHeadlessEnvironment( + platform: NodeJS.Platform = process.platform, + env: RuntimeEnvironment = process.env, +): boolean { + const configured = parseBoolean(env.DESKTOP_COMMANDER_HEADLESS); + if (configured !== undefined) { + return configured; + } + + return platform === 'linux' && !hasGraphicalSession(platform, env); +} + +export function getRuntimeLabel( + platform: NodeJS.Platform = process.platform, + env: RuntimeEnvironment = process.env, +): 'desktop' | 'headless' { + return isHeadlessEnvironment(platform, env) ? 'headless' : 'desktop'; +} diff --git a/src/remote-device/device-authenticator.ts b/src/remote-device/device-authenticator.ts index 4a945641..708a807e 100644 --- a/src/remote-device/device-authenticator.ts +++ b/src/remote-device/device-authenticator.ts @@ -1,4 +1,4 @@ -import open from 'open'; +import { openBrowser } from '../utils/open-browser.js'; import os from 'os'; import crypto from 'crypto'; import { captureRemote } from '../utils/capture.js'; @@ -101,7 +101,7 @@ export class DeviceAuthenticator { console.log(` Code expires in ${Math.floor(deviceAuth.expires_in / 60)} minutes.\n`); // Try to open browser automatically - open(deviceAuth.verification_uri_complete).catch(() => { + openBrowser(deviceAuth.verification_uri_complete).catch(() => { console.log(' - Could not open browser automatically.'); console.log(` - Please visit: ${deviceAuth.verification_uri}\n`); }); diff --git a/src/server.ts b/src/server.ts index 1a21c2a8..618ab470 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1094,10 +1094,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { { name: "list_processes", description: ` - List all running processes. - - Returns process information including PID, command name, CPU usage, and memory usage. - + List running processes with bounded, offset-based pagination. + + By default, command-line arguments are omitted because they may contain credentials. + Set includeArgs=true only when needed; sensitive values are redacted and each command + line is truncated. Use offset and limit to page through results. + ${CMD_PREFIX_DESCRIPTION}`, inputSchema: zodToJsonSchema(ListProcessesArgsSchema), annotations: { @@ -1466,7 +1468,7 @@ async function handleCallToolRequest(request: CallToolRequest): Promise { - const command = os.platform() === 'win32' ? 'tasklist' : 'ps aux'; - try { - const { stdout } = await execAsync(command); - const processes = stdout.split('\n') - .slice(1) - .filter(Boolean) - .map(line => { - const parts = line.split(/\s+/); - return { - pid: parseInt(parts[1]), - command: parts[parts.length - 1], - cpu: parts[2], - memory: parts[3], - } as ProcessInfo; - }); +const SENSITIVE_FLAG_PATTERN = new RegExp( + `(^|\\s)(--?(?:${SENSITIVE_NAME}))(=|\\s+)(?:"[^"]*"|'[^']*'|\\S+)`, + 'gi', +); +const SENSITIVE_ENV_PATTERN = new RegExp( + `\\b([A-Za-z0-9_]*(?:${SENSITIVE_NAME})[A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|\\S+)`, + 'gi', +); + +export function redactProcessArgs(args: string): string { + const redacted = args + .replace(SENSITIVE_FLAG_PATTERN, '$1$2$3[REDACTED]') + .replace(SENSITIVE_ENV_PATTERN, '$1=[REDACTED]') + .replace(/\b(Bearer)\s+\S+/gi, '$1 [REDACTED]') + .replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s\/@]+):[^@\s]+@/gi, '$1:[REDACTED]@'); + + if (redacted.length <= PROCESS_ARG_LIMIT) { + return redacted; + } + + return `${redacted.slice(0, PROCESS_ARG_LIMIT - 3)}...`; +} + +interface ProcessListOptions { + includeArgs: boolean; + offset: number; + limit: number; +} + +export function formatProcessList( + processes: DetailedProcessInfo[], + options: ProcessListOptions, +): string { + const page = processes.slice(options.offset, options.offset + options.limit); + const start = page.length === 0 ? 0 : options.offset + 1; + const end = options.offset + page.length; + const header = `Processes: ${processes.length} total. Showing ${start}-${end}.`; + + const rows = page.map((processInfo) => { + const fields = [ + `PID: ${processInfo.pid}`, + processInfo.ppid === undefined ? null : `PPID: ${processInfo.ppid}`, + processInfo.user ? `User: ${processInfo.user}` : null, + `Command: ${processInfo.command}`, + `CPU: ${processInfo.cpu}`, + `Memory: ${processInfo.memory}`, + processInfo.runtime ? `Runtime: ${processInfo.runtime}` : null, + options.includeArgs && processInfo.args + ? `Args: ${redactProcessArgs(processInfo.args)}` + : null, + ].filter((field): field is string => Boolean(field)); + + return fields.join(', '); + }); + + return [header, ...rows].join('\n'); +} +export async function listProcesses(args: unknown = {}): Promise { + const parsed = ListProcessesArgsSchema.safeParse(args); + if (!parsed.success) { return { content: [{ - type: "text", - text: processes.map(p => - `PID: ${p.pid}, Command: ${p.command}, CPU: ${p.cpu}, Memory: ${p.memory}` - ).join('\n') + type: 'text', + text: `Error: Invalid arguments for list_processes: ${parsed.error}`, }], + isError: true, + }; + } + + try { + const options = parsed.data; + const processes = await listPlatformProcesses(process.platform, options.includeArgs); + return { + content: [{ type: 'text', text: formatProcessList(processes, options) }], }; } catch (error) { return { - content: [{ type: "text", text: `Error: Failed to list processes: ${error instanceof Error ? error.message : String(error)}` }], + content: [{ + type: 'text', + text: `Error: Failed to list processes: ${error instanceof Error ? error.message : String(error)}`, + }], isError: true, }; } @@ -43,7 +108,7 @@ export async function killProcess(args: unknown): Promise { const parsed = KillProcessArgsSchema.safeParse(args); if (!parsed.success) { return { - content: [{ type: "text", text: `Error: Invalid arguments for kill_process: ${parsed.error}` }], + content: [{ type: 'text', text: `Error: Invalid arguments for kill_process: ${parsed.error}` }], isError: true, }; } @@ -51,11 +116,14 @@ export async function killProcess(args: unknown): Promise { try { process.kill(parsed.data.pid); return { - content: [{ type: "text", text: `Successfully terminated process ${parsed.data.pid}` }], + content: [{ type: 'text', text: `Successfully terminated process ${parsed.data.pid}` }], }; } catch (error) { return { - content: [{ type: "text", text: `Error: Failed to kill process: ${error instanceof Error ? error.message : String(error)}` }], + content: [{ + type: 'text', + text: `Error: Failed to kill process: ${error instanceof Error ? error.message : String(error)}`, + }], isError: true, }; } diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index 6257f2af..2c716359 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -21,7 +21,11 @@ export const SetConfigValueArgsSchema = z.object({ }); // Empty schemas -export const ListProcessesArgsSchema = z.object({}); +export const ListProcessesArgsSchema = z.object({ + includeArgs: z.boolean().optional().default(false), + offset: z.number().int().min(0).optional().default(0), + limit: z.number().int().min(1).max(200).optional().default(100), +}); // Terminal tools schemas export const StartProcessArgsSchema = z.object({ diff --git a/src/utils/open-browser.ts b/src/utils/open-browser.ts index d65376ae..8597d277 100644 --- a/src/utils/open-browser.ts +++ b/src/utils/open-browser.ts @@ -1,14 +1,19 @@ import { execFile, spawn } from 'child_process'; -import os from 'os'; import { logToStderr } from './logger.js'; +import { isHeadlessEnvironment } from '../platform/runtime.js'; /** * Open a URL in the default browser (cross-platform) * Uses execFile/spawn with args array to avoid shell injection */ export async function openBrowser(url: string): Promise { - const platform = os.platform(); - + const platform = process.platform; + + if (isHeadlessEnvironment(platform)) { + logToStderr('info', `Headless environment detected. Open this URL manually: ${url}`); + return; + } + return new Promise((resolve, reject) => { const callback = (error: Error | null) => { if (error) { diff --git a/test/test-linux-platform.js b/test/test-linux-platform.js new file mode 100644 index 00000000..95295e33 --- /dev/null +++ b/test/test-linux-platform.js @@ -0,0 +1,113 @@ +import assert from 'assert'; +import { + getDefaultShell, + hasGraphicalSession, + isHeadlessEnvironment, + getRuntimeLabel, +} from '../dist/platform/runtime.js'; +import { + parsePosixProcessOutput, + parseWindowsProcessOutput, + listPlatformProcesses, +} from '../dist/platform/processes.js'; +import { + formatProcessList, + redactProcessArgs, +} from '../dist/tools/process.js'; + +function testRuntimeDetection() { + assert.strictEqual(getDefaultShell('linux', {}), '/bin/sh'); + assert.strictEqual(getDefaultShell('darwin', {}), '/bin/zsh'); + assert.strictEqual(getDefaultShell('linux', { SHELL: '/bin/bash' }), '/bin/bash'); + assert.strictEqual(getDefaultShell('win32', { COMSPEC: 'cmd.exe' }), 'powershell.exe'); + + assert.strictEqual(hasGraphicalSession('linux', {}), false); + assert.strictEqual(hasGraphicalSession('linux', { DISPLAY: ':0' }), true); + assert.strictEqual(isHeadlessEnvironment('linux', {}), true); + assert.strictEqual(isHeadlessEnvironment('linux', { WAYLAND_DISPLAY: 'wayland-0' }), false); + assert.strictEqual(isHeadlessEnvironment('linux', { DESKTOP_COMMANDER_HEADLESS: 'false' }), false); + assert.strictEqual(isHeadlessEnvironment('win32', {}), false); + assert.strictEqual(getRuntimeLabel('linux', {}), 'headless'); +} + +function testProcessParsing() { + const summary = [ + ' 101 1 root 0.0 0.1 42 systemd', + ' 220 101 app 1.5 2.3 15 worker name', + ].join('\n'); + const args = [ + '101 /sbin/init', + '220 /opt/worker name --port 3000', + ].join('\n'); + const linux = parsePosixProcessOutput(summary, args); + + assert.strictEqual(linux.length, 2); + assert.deepStrictEqual(linux[1], { + pid: 220, + ppid: 101, + user: 'app', + cpu: '1.5', + memory: '2.3', + runtime: '15', + command: 'worker name', + args: '/opt/worker name --port 3000', + }); + + const windows = parseWindowsProcessOutput(JSON.stringify({ + pid: 500, + ppid: 100, + command: 'node.exe', + args: 'node.exe server.js', + memoryMb: 128.5, + })); + assert.strictEqual(windows.length, 1); + assert.strictEqual(windows[0].memory, '128.5 MB'); +} + +function testSafeProcessFormatting() { + const secretArgs = 'app --token abc123 PASSWORD=hunter2 https://me:secret@example.com'; + const redacted = redactProcessArgs(secretArgs); + assert(!redacted.includes('abc123')); + assert(!redacted.includes('hunter2')); + assert(!redacted.includes(':secret@')); + + const processInfo = [{ + pid: 1, + ppid: 0, + user: 'root', + command: 'app', + cpu: '0.0', + memory: '0.1', + runtime: '5', + args: secretArgs, + }]; + const safeDefault = formatProcessList(processInfo, { + includeArgs: false, + offset: 0, + limit: 100, + }); + assert(!safeDefault.includes('Args:')); + + const explicitArgs = formatProcessList(processInfo, { + includeArgs: true, + offset: 0, + limit: 100, + }); + assert(explicitArgs.includes('[REDACTED]')); + assert(!explicitArgs.includes('abc123')); +} + +async function main() { + testRuntimeDetection(); + testProcessParsing(); + testSafeProcessFormatting(); + const liveProcesses = await listPlatformProcesses(); + assert(liveProcesses.length > 0, 'live process listing should return results'); + assert(liveProcesses.every((item) => item.args === undefined)); + console.log(`✓ Linux platform adapter tests passed (${liveProcesses.length} live processes)`); +} + +main().catch((error) => { + console.error('✗ Linux platform adapter tests failed:', error); + process.exit(1); +}); diff --git a/test/test-linux-service-installer.js b/test/test-linux-service-installer.js new file mode 100644 index 00000000..4b942938 --- /dev/null +++ b/test/test-linux-service-installer.js @@ -0,0 +1,54 @@ +import assert from 'assert'; +import { execFileSync, spawnSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +if (process.platform !== 'linux') { + console.log('✓ Linux service installer tests skipped on non-Linux platform'); + process.exit(0); +} + +const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); +const script = path.join(root, 'scripts', 'install-linux-service.sh'); +const binary = path.join(root, 'dist', 'index.js'); +const user = typeof process.getuid === 'function' && process.getuid() === 0 + ? 'nobody' + : os.userInfo().username; + +const unit = execFileSync('bash', [ + script, + '--dry-run', + '--user', user, + '--bin', binary, + '--service', 'desktop-commander-test', +], { encoding: 'utf8' }); + +assert(unit.includes(`User=${user}`)); +assert(unit.includes(`ExecStart="${binary}" remote --persist-session --disable-no-sleep`)); +assert(unit.includes('NoNewPrivileges=true')); +assert(unit.includes('UMask=0077')); + +const invalidName = spawnSync('bash', [ + script, + '--dry-run', + '--user', user, + '--bin', binary, + '--service', '../escape', +], { encoding: 'utf8' }); +assert.notStrictEqual(invalidName.status, 0); +assert.match(invalidName.stderr, /Invalid service name/); + +if (typeof process.getuid === 'function' && process.getuid() === 0) { + const rootRejected = spawnSync('bash', [ + script, + '--dry-run', + '--user', 'root', + '--bin', binary, + ], { encoding: 'utf8' }); + assert.notStrictEqual(rootRejected.status, 0); + assert.match(rootRejected.stderr, /Refusing to install a root-owned remote agent/); +} + +assert(fs.existsSync(script)); +console.log('✓ Linux service installer tests passed'); From 05c4267dd85a07227729db0ed22a8d73c017f6f1 Mon Sep 17 00:00:00 2001 From: edmen12 <158250033+edmen12@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:14:05 +0800 Subject: [PATCH 2/3] Address CodeRabbit review findings --- .github/workflows/linux-ci.yml | 6 ++++++ LINUX.md | 2 +- src/platform/processes.ts | 1 + src/tools/process.ts | 5 +++++ test/test-linux-platform.js | 9 +++++++++ 5 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml index 8e9d75a5..00a99bba 100644 --- a/.github/workflows/linux-ci.yml +++ b/.github/workflows/linux-ci.yml @@ -24,6 +24,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} @@ -45,6 +47,8 @@ jobs: - name: Install system dependencies run: apt-get update && apt-get install -y --no-install-recommends git procps ripgrep bash ca-certificates systemd - uses: actions/checkout@v4 + with: + persist-credentials: false - run: npm ci --ignore-scripts --no-audit --no-fund - run: npm run build - run: node dist/npm-scripts/verify-ripgrep.js @@ -59,6 +63,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/LINUX.md b/LINUX.md index 80563d74..03803692 100644 --- a/LINUX.md +++ b/LINUX.md @@ -94,7 +94,7 @@ Use `--start` to start it immediately during installation. systemctl status desktop-commander-device sudo systemctl restart desktop-commander-device sudo systemctl stop desktop-commander-device -journalctl -u desktop-commander-device --since today +sudo journalctl -u desktop-commander-device --since today ``` The unit uses `KillMode=control-group`, so child shells and long-running commands diff --git a/src/platform/processes.ts b/src/platform/processes.ts index ee460586..c8c028fa 100644 --- a/src/platform/processes.ts +++ b/src/platform/processes.ts @@ -99,6 +99,7 @@ async function runPs( encoding: 'utf8', windowsHide: true, maxBuffer: PROCESS_OUTPUT_LIMIT, + env: { ...process.env, LC_ALL: 'C' }, }); return String(stdout); } diff --git a/src/tools/process.ts b/src/tools/process.ts index 554071d1..b5e654e3 100644 --- a/src/tools/process.ts +++ b/src/tools/process.ts @@ -21,6 +21,10 @@ const SENSITIVE_FLAG_PATTERN = new RegExp( `(^|\\s)(--?(?:${SENSITIVE_NAME}))(=|\\s+)(?:"[^"]*"|'[^']*'|\\S+)`, 'gi', ); +const SENSITIVE_WINDOWS_FLAG_PATTERN = new RegExp( + `(^|\\s)(/(?:${SENSITIVE_NAME}|rp|p))(:|=|\\s+)(?:"[^"]*"|'[^']*'|\\S+)`, + 'gi', +); const SENSITIVE_ENV_PATTERN = new RegExp( `\\b([A-Za-z0-9_]*(?:${SENSITIVE_NAME})[A-Za-z0-9_]*)=(?:"[^"]*"|'[^']*'|\\S+)`, 'gi', @@ -29,6 +33,7 @@ const SENSITIVE_ENV_PATTERN = new RegExp( export function redactProcessArgs(args: string): string { const redacted = args .replace(SENSITIVE_FLAG_PATTERN, '$1$2$3[REDACTED]') + .replace(SENSITIVE_WINDOWS_FLAG_PATTERN, '$1$2$3[REDACTED]') .replace(SENSITIVE_ENV_PATTERN, '$1=[REDACTED]') .replace(/\b(Bearer)\s+\S+/gi, '$1 [REDACTED]') .replace(/([a-z][a-z0-9+.-]*:\/\/[^:\s\/@]+):[^@\s]+@/gi, '$1:[REDACTED]@'); diff --git a/test/test-linux-platform.js b/test/test-linux-platform.js index 95295e33..ee1162b8 100644 --- a/test/test-linux-platform.js +++ b/test/test-linux-platform.js @@ -71,6 +71,15 @@ function testSafeProcessFormatting() { assert(!redacted.includes('hunter2')); assert(!redacted.includes(':secret@')); + const windowsArgs = 'schtasks /create /p SecretPwd123 app.exe /TOKEN:abc456 /api-key="key789" /password "secret value"'; + const windowsRedacted = redactProcessArgs(windowsArgs); + assert(!windowsRedacted.includes('SecretPwd123')); + assert(!windowsRedacted.includes('abc456')); + assert(!windowsRedacted.includes('key789')); + assert(!windowsRedacted.includes('secret value')); + assert(windowsRedacted.includes('/p [REDACTED]')); + assert(windowsRedacted.includes('/TOKEN:[REDACTED]')); + const processInfo = [{ pid: 1, ppid: 0, From 6644477b4e4ec55f116fe8b5289829b01e579be1 Mon Sep 17 00:00:00 2001 From: edmen12 <158250033+edmen12@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:07:54 +0800 Subject: [PATCH 3/3] Fix Node 20 markdown test setup --- test/test-markdown-editor-edit-diff.js | 3 +++ test/test-markdown-editor-roundtrip.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/test/test-markdown-editor-edit-diff.js b/test/test-markdown-editor-edit-diff.js index 1234ae67..fbb4fe8a 100644 --- a/test/test-markdown-editor-edit-diff.js +++ b/test/test-markdown-editor-edit-diff.js @@ -35,6 +35,9 @@ globalThis.HTMLElement = dom.window.HTMLElement; globalThis.Node = dom.window.Node; globalThis.DOMParser = dom.window.DOMParser; globalThis.getComputedStyle = dom.window.getComputedStyle; +if (!globalThis.navigator) { + globalThis.navigator = dom.window.navigator; +} // Tiptap's focus() calls requestAnimationFrame which jsdom doesn't ship // by default. Stub with a synchronous no-op — we don't need real focus // behaviour for these tests. diff --git a/test/test-markdown-editor-roundtrip.js b/test/test-markdown-editor-roundtrip.js index fdcd8259..46c20d8c 100644 --- a/test/test-markdown-editor-roundtrip.js +++ b/test/test-markdown-editor-roundtrip.js @@ -31,6 +31,9 @@ globalThis.HTMLElement = dom.window.HTMLElement; globalThis.Node = dom.window.Node; globalThis.DOMParser = dom.window.DOMParser; globalThis.getComputedStyle = dom.window.getComputedStyle; +if (!globalThis.navigator) { + globalThis.navigator = dom.window.navigator; +} const { Editor } = await import('@tiptap/core'); const StarterKit = (await import('@tiptap/starter-kit')).default;