From 81d90f1911e685265eb258f1abbc536adb11bd8e Mon Sep 17 00:00:00 2001 From: Johnny Li Date: Sun, 5 Jul 2026 00:29:00 -0700 Subject: [PATCH] Add SSH-based remote agent support ACP only speaks JSON-RPC over stdio, so there's no way to point the extension at an agent (e.g. Hermes Agent) running on another machine. Add an optional `remote: { host, user?, port?, identityFile? }` block to acp.agents entries: when set, command/args are launched over `ssh` instead of locally, tunneling the agent's stdio. Docs updated per Hermes's own ACP docs (https://hermes-agent.nousresearch.com/docs/user-guide/features/acp), which confirm the agent side has no built-in network transport either. --- CHANGELOG.md | 5 ++++ README.md | 28 +++++++++++++++++- package.json | 45 +++++++++++++++++++++++++++- src/config/AgentConfig.ts | 23 +++++++++++++++ src/core/AgentManager.ts | 62 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 160 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0070f3b4..41bff974 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to the "vscode-acp" extension will be documented in this fil Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [Unreleased] + +### Added +- **Remote agents over SSH**: any `acp.agents` entry can now set a `remote: { host, user?, port?, identityFile? }` block to run `command`/`args` on a remote machine instead of locally — the agent's stdio is tunneled through `ssh`. Useful for agents like Hermes Agent installed on a remote server. See the "Remote Agents" section in the README for setup and limitations. + ## [0.2.0] - 2026-05-16 ### Added diff --git a/README.md b/README.md index 23a7b5d9..da3b6bb1 100644 --- a/README.md +++ b/README.md @@ -56,11 +56,37 @@ You can add custom agent configurations in settings. > **Note on Hermes Agent**: Hermes is a Python package, not an npm package. Install it via the [Hermes Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) (Linux/macOS/WSL2 only — Windows requires [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install)). Make sure `hermes` is on your `PATH` and launch VS Code from the same shell/venv. Configure credentials with `hermes model`. +## Remote Agents (e.g. a remote Hermes install) + +ACP itself only speaks JSON-RPC over stdio ([Hermes's ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp) confirm the agent side has no built-in network transport either), so there's no "host" field baked into the protocol. This extension bridges that by optionally tunneling an agent's stdio through `ssh`: add a `remote` block to any agent entry and `command`/`args` are executed on that host instead of locally. + +```json +"acp.agents": { + "Hermes Agent (Remote)": { + "command": "hermes", + "args": ["acp"], + "remote": { + "host": "my-remote-host", + "user": "johnny", + "port": 22, + "identityFile": "~/.ssh/id_ed25519" + } + } +} +``` + +Requirements: +- Passwordless SSH access to the host (key-based auth — there's no interactive password prompt support). +- `hermes` on the remote account's `PATH`, with credentials already configured there (`hermes model`) — see the [Hermes ACP docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/acp) for installing/configuring Hermes itself. +- The remote command runs via a login shell (`bash -lc`) so `PATH` picks up nvm/pyenv/venv entries from the remote profile, same as local agents. + +Limitation: only the agent's stdio is tunneled. If the agent calls back into the editor's file-system/terminal capabilities with paths that only exist on the remote host, those calls still resolve against your *local* filesystem. For agents that need full remote file access, use [VS Code Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) and install this extension inside that remote window instead — it declares no `extensionKind`, so it runs on the remote side there and `command` resolves against the remote `PATH` directly, with no `remote` block needed. + ## Extension Settings | Setting | Default | Description | |---------|---------|-------------| -| `acp.agents` | *(11 agents)* | Agent configurations. Each key is the agent name, value has `command`, `args`, and `env`. | +| `acp.agents` | *(11 agents)* | Agent configurations. Each key is the agent name, value has `command`, `args`, `env`, and an optional `remote` (SSH) target. | | `acp.autoApprovePermissions` | `ask` | How agent permission requests are handled: `ask` or `allowAll`. | | `acp.defaultWorkingDirectory` | `""` | Default working directory for agent sessions. Empty uses current workspace. | | `acp.logTraffic` | `true` | Log all ACP protocol traffic to the ACP Traffic output channel. | diff --git a/package.json b/package.json index 59a6eb4a..5869cc13 100644 --- a/package.json +++ b/package.json @@ -279,7 +279,50 @@ "properties": { "acp.agents": { "type": "object", - "description": "Agent configurations. Each key is the agent name, value has command, args, and env.", + "description": "Agent configurations. Each key is the agent name, value has command, args, env, and an optional remote (SSH) target.", + "additionalProperties": { + "type": "object", + "required": ["command"], + "properties": { + "command": { + "type": "string", + "description": "Executable to run (locally, or on the remote host if \"remote\" is set)." + }, + "args": { + "type": "array", + "items": { "type": "string" }, + "description": "Command-line arguments." + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Environment variables for the agent process." + }, + "remote": { + "type": "object", + "required": ["host"], + "description": "Run command/args on this remote host over SSH instead of locally (e.g. a remote Hermes Agent install).", + "properties": { + "host": { + "type": "string", + "description": "Remote hostname/IP, or a Host alias from ~/.ssh/config." + }, + "user": { + "type": "string", + "description": "SSH user. Omit to use ssh's default (current user / ssh config)." + }, + "port": { + "type": "number", + "description": "SSH port. Omit to use the default (22) or ssh config." + }, + "identityFile": { + "type": "string", + "description": "Path to a private key file. Supports a leading \"~\"." + } + } + } + } + }, "default": { "GitHub Copilot": { "command": "npx", diff --git a/src/config/AgentConfig.ts b/src/config/AgentConfig.ts index 91ca5743..f34404c5 100644 --- a/src/config/AgentConfig.ts +++ b/src/config/AgentConfig.ts @@ -1,5 +1,21 @@ import * as vscode from 'vscode'; +/** + * SSH target for running an agent's command on a remote machine instead of + * spawning it locally. `command`/`args`/`env` are unchanged — they describe + * how to launch the agent on the remote host. + */ +export interface RemoteConfigEntry { + /** Remote hostname or IP (e.g., a host from your SSH config, or a raw address) */ + host: string; + /** SSH user. Omit to use ssh's default (current user / ssh config). */ + user?: string; + /** SSH port. Omit to use the default (22) or ssh config. */ + port?: number; + /** Path to a private key file. Supports a leading "~". */ + identityFile?: string; +} + /** * Configuration for a single ACP agent. */ @@ -12,6 +28,13 @@ export interface AgentConfigEntry { env?: Record; /** Display name */ displayName?: string; + /** + * If set, `command`/`args` are run on this remote host over SSH instead of + * locally. The agent process's stdio is tunneled through the ssh session, + * so it works with any ACP agent (e.g. Hermes Agent) already installed on + * the remote machine. + */ + remote?: RemoteConfigEntry; } /** diff --git a/src/core/AgentManager.ts b/src/core/AgentManager.ts index 5c88e138..8ff840a0 100644 --- a/src/core/AgentManager.ts +++ b/src/core/AgentManager.ts @@ -1,5 +1,7 @@ import { spawn, ChildProcess } from 'node:child_process'; import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { EventEmitter } from 'node:events'; import { log, logError } from '../utils/Logger'; import { sendEvent, sendError } from '../utils/TelemetryManager'; @@ -58,6 +60,45 @@ function resolveUnixShell(): { shell: string; useLoginFlag: boolean } { return { shell: '/bin/sh', useLoginFlag: false }; } +function expandHome(path: string): string { + if (path === '~') { + return homedir(); + } + if (path.startsWith('~/')) { + return join(homedir(), path.slice(2)); + } + return path; +} + +/** + * Build the `ssh` command + args that tunnel an agent's stdio to a remote host. + * + * No `-t` flag is passed: ssh must NOT allocate a pty here, or the JSON-RPC + * stream on stdio would get mangled by terminal line-discipline processing. + * The remote command runs through `bash -lc` so the remote user's login + * profile (nvm, pyenv, venvs, etc.) is sourced — mirroring the local-spawn + * behavior below. + */ +function buildRemoteSshInvocation(config: AgentConfigEntry): { command: string; args: string[] } { + const remote = config.remote!; + const sshArgs: string[] = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new']; + + if (remote.port) { + sshArgs.push('-p', String(remote.port)); + } + if (remote.identityFile) { + sshArgs.push('-i', expandHome(remote.identityFile)); + } + + const target = remote.user ? `${remote.user}@${remote.host}` : remote.host; + sshArgs.push(target); + + const remoteCommandStr = [config.command, ...(config.args || [])].map(shellEscape).join(' '); + sshArgs.push(`bash -lc ${shellEscape(remoteCommandStr)}`); + + return { command: 'ssh', args: sshArgs }; +} + export interface AgentInstance { id: string; name: string; @@ -77,9 +118,28 @@ export class AgentManager extends EventEmitter { */ spawnAgent(name: string, config: AgentConfigEntry, cwd?: string): AgentInstance { const id = `agent_${this.nextId++}`; - log(`Spawning agent "${name}" (${id}): ${config.command} ${(config.args || []).join(' ')}`); const child = (() => { + if (config.remote) { + const { command, args } = buildRemoteSshInvocation(config); + log(`Spawning agent "${name}" (${id}) over SSH to ${config.remote.host}: ${config.command} ${(config.args || []).join(' ')}`); + sendEvent('agent/spawn/remote', { transport: 'ssh' }); + + // `cwd` (the local workspace folder) doesn't apply to a remote process — + // the remote command runs in the remote login shell's default directory. + // Note: this only tunnels the agent's stdio. If the agent calls back into + // the editor's fs/terminal capabilities with remote-side paths, those + // calls are still resolved against the *local* filesystem, so remote + // agents work best for conversation/execution that doesn't depend on + // client-side file access to remote-only paths. + return spawn(command, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, ...(config.env || {}) }, + }); + } + + log(`Spawning agent "${name}" (${id}): ${config.command} ${(config.args || []).join(' ')}`); + if (process.platform === 'win32') { // On Windows, commands like npx are batch scripts (.cmd) that require // shell resolution via cmd.exe.