Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,30 @@ When they say "local", use `ssh_disconnect`.
3. Ask anything — `bash` commands automatically run on the remote server.
4. Say **"local"** to disconnect.

Remote mode is fail-closed. Before every Bash command, the plugin verifies that the
matching SSH control connection is active. If verification fails, the requested command
is blocked rather than executed locally. Reconnect with `ssh_connect`, or say **"local"**
to explicitly return to local execution. Remote intent is retained when a session is
resumed, so restarting OpenCode does not silently restore local Bash execution.

## Hosts with interactive authentication

The plugin cannot receive interactive SSH prompts. Authenticate a control connection in
a separate terminal, replacing `myHost` with an alias from `~/.ssh/config`:

```sh
ssh -M -S "$HOME/.ssh/cm-myHost" myHost
```

Keep that session connected, then say **"ssh myHost"** in OpenCode. The plugin detects
and reuses the authenticated connection. Automatic connection attempts use SSH batch
mode, so TOTP or password prompts are never displayed inside OpenCode.

## How it works

- Opens SSH ControlMaster (`ssh -MNf ...`) for a persistent connection.
- Hooks into `tool.execute.before` to wrap `bash` calls with SSH at runtime.
- Verifies the control connection before every Bash call and blocks on failure.
- Disables local file tools (`read`, `write`, `edit`, `glob`, `grep`) in remote mode.
- Injects remote-mode instructions into the system prompt.

Expand Down
183 changes: 155 additions & 28 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { execSync } from "child_process"
import { execFileSync } from "child_process"
import path from "path"
import os from "os"
import { writeFileSync } from "fs"
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"
import { createHash, randomBytes } from "crypto"
import { z } from "zod"

type SessionState = {
host: string
socketPath: string
status: "connecting" | "connected" | "unavailable"
}

const sessionMap = new Map<string, SessionState>()
Expand All @@ -15,12 +17,81 @@ function sock(host: string) {
return path.join(os.homedir(), ".ssh", `cm-${host}`)
}

function validHost(host: string) {
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host)
}

function shellQuote(value: string) {
return `'${value.replaceAll("'", `'"'"'`)}'`
}

function controlMasterActive(socketPath: string, host: string) {
try {
execFileSync("ssh", ["-O", "check", "-S", socketPath, host], { stdio: "pipe" })
return true
} catch {
return false
}
}

function blockedBashCommand(host: string) {
const message = `opencode-ssh: remote connection to ${host} is unavailable; command blocked to prevent local execution. Run ssh_connect again or say "local".\n`
return `printf '%s' ${shellQuote(message)} >&2; exit 125`
}

const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-ssh-"))
const stateDir = path.join(os.homedir(), ".cache", "opencode-ssh", "sessions")
mkdirSync(stateDir, { recursive: true, mode: 0o700 })

function statePath(sessionID: string) {
return path.join(stateDir, createHash("sha256").update(sessionID).digest("hex"))
}

function saveSessionState(sessionID: string, state: SessionState) {
const target = statePath(sessionID)
const temporary = `${target}.${randomBytes(16).toString("hex")}`
try {
writeFileSync(temporary, JSON.stringify(state), { flag: "wx", mode: 0o600 })
renameSync(temporary, target)
} finally {
rmSync(temporary, { force: true })
}
}

function getSessionState(sessionID: string) {
const current = sessionMap.get(sessionID)
if (current) return current

try {
const saved = JSON.parse(readFileSync(statePath(sessionID), "utf8")) as Partial<SessionState>
const valid =
typeof saved.host === "string" &&
validHost(saved.host) &&
saved.socketPath === sock(saved.host) &&
["connecting", "connected", "unavailable"].includes(saved.status ?? "")

if (!valid) return undefined

const state = saved as SessionState
sessionMap.set(sessionID, state)
return state
} catch {
return undefined
}
}

function deleteSessionState(sessionID: string) {
sessionMap.delete(sessionID)
rmSync(statePath(sessionID), { force: true })
}

function ensureSentinel(): string {
const p = path.join(os.tmpdir(), "__opencode_remote_mode__")
const p = path.join(tempDir, "remote-mode")
try {
writeFileSync(
p,
"This tool is not available in remote SSH mode.\nUse the bash tool with cat/tee/grep/find over SSH instead.\n",
{ flag: "wx", mode: 0o600 },
)
} catch {}
return p
Expand All @@ -37,29 +108,66 @@ export default {
tool: {
ssh_connect: {
description:
"Open a persistent SSH connection to a remote server. Call this when the user says 'ssh <host>' or asks to connect to a server. The host must be defined in ~/.ssh/config.",
"Open or reuse a persistent SSH connection. Never retry automatically after failure. Interactive authentication must first be completed in a separate terminal using the control-socket command returned by this tool.",
args: {
host: z.string().describe("SSH host name from ~/.ssh/config, e.g. 'myHost'"),
},
async execute(args, ctx) {
const host = args.host
if (!validHost(host)) {
return "Invalid SSH host. Use a host alias from ~/.ssh/config."
}
const socketPath = sock(host)

const existing = sessionMap.get(ctx.sessionID)
const existing = getSessionState(ctx.sessionID)
if (existing) {
if (existing.host === host && controlMasterActive(existing.socketPath, existing.host)) {
existing.status = "connected"
saveSessionState(ctx.sessionID, existing)
return `Already connected to ${host}.`
}
try {
execSync(`ssh -O stop -S "${existing.socketPath}" "${existing.host}"`, { stdio: "pipe" })
execFileSync("ssh", ["-O", "stop", "-S", existing.socketPath, existing.host], { stdio: "pipe" })
} catch {}
}

const state: SessionState = { host, socketPath, status: "connecting" }
sessionMap.set(ctx.sessionID, state)
saveSessionState(ctx.sessionID, state)

if (controlMasterActive(socketPath, host)) {
state.status = "connected"
saveSessionState(ctx.sessionID, state)
return {
title: `Connected to ${host}`,
output: [
`Reused the existing SSH connection to **${host}**. All bash commands will now run remotely.`,
`Say "local" to disconnect.`,
].join("\n"),
}
}

try {
execSync(`ssh -MNf -S "${socketPath}" "${host}" 2>&1`, { stdio: "pipe", timeout: 15000 })
execFileSync("ssh", ["-o", "BatchMode=yes", "-MNf", "-S", socketPath, host], {
stdio: "pipe",
timeout: 15000,
})
} catch (e) {
state.status = "unavailable"
saveSessionState(ctx.sessionID, state)
const msg = e instanceof Error ? e.message : String(e)
return `Failed to connect to ${host}: ${msg}`
return [
`Failed to connect to ${host}: ${msg}`,
`Do not retry automatically. Interactive authentication is disabled inside OpenCode.`,
`Remote mode remains fail-closed: bash commands are blocked until connection succeeds or the user says "local".`,
`In a separate terminal, run this exact command and complete authentication there:`,
`ssh -M -S ${shellQuote(socketPath)} ${shellQuote(host)}`,
`Keep that terminal connected, then ask the user to run "ssh ${host}" again in OpenCode.`,
].join("\n")
}

sessionMap.set(ctx.sessionID, { host, socketPath })
state.status = "connected"
saveSessionState(ctx.sessionID, state)

return {
title: `Connected to ${host}`,
Expand All @@ -76,27 +184,36 @@ export default {
description: "Close the persistent SSH connection and return to local mode. Call this when the user says 'local' or asks to disconnect.",
args: {},
async execute(_args, ctx) {
const state = sessionMap.get(ctx.sessionID)
const state = getSessionState(ctx.sessionID)
if (!state) return "Not currently connected."

try {
execSync(`ssh -O stop -S "${state.socketPath}" "${state.host}"`, { stdio: "pipe" })
execFileSync("ssh", ["-O", "stop", "-S", state.socketPath, state.host], { stdio: "pipe" })
} catch {}
sessionMap.delete(ctx.sessionID)
deleteSessionState(ctx.sessionID)

return "Disconnected. Commands now run locally."
},
},
},

"tool.execute.before": async (input, output) => {
const state = sessionMap.get(input.sessionID)
const state = getSessionState(input.sessionID)
if (!state) return

if (input.tool === "bash") {
if (state.status !== "connected" || !controlMasterActive(state.socketPath, state.host)) {
state.status = "unavailable"
saveSessionState(input.sessionID, state)
output.args.command = blockedBashCommand(state.host)
output.args.description = `[blocked remote ${state.host}] SSH connection unavailable`
return
}

const cmd = output.args.command
if (cmd.startsWith(`ssh -S "${state.socketPath}"`)) return
output.args.command = `ssh -S "${state.socketPath}" "${state.host}" ${cmd}`
const wrappedPrefix = ["ssh", "-S", state.socketPath, state.host].map(shellQuote).join(" ")
if (cmd.startsWith(`${wrappedPrefix} `)) return
output.args.command = ["ssh", "-S", state.socketPath, state.host, cmd].map(shellQuote).join(" ")
if (output.args.description) {
output.args.description = `[remote ${state.host}] ${output.args.description}`
}
Expand All @@ -111,7 +228,7 @@ export default {
}

if (input.tool === "write") {
output.args.filePath = path.join(os.tmpdir(), `__opencode_remote_write__`)
output.args.filePath = path.join(tempDir, `remote-write-${randomBytes(16).toString("hex")}`)
output.args.content = "This tool is not available in remote mode."
return
}
Expand Down Expand Up @@ -139,21 +256,31 @@ export default {
},

"experimental.chat.system.transform": async (input, output) => {
const state = input.sessionID ? sessionMap.get(input.sessionID) : undefined
const state = input.sessionID ? getSessionState(input.sessionID) : undefined
if (!state) return

output.system.push(
[
"",
"## Remote SSH Mode",
"",
`You are connected to **${state.host}** via persistent SSH.`,
"",
"1. The **bash** tool is automatically wrapped with SSH. Do NOT add SSH yourself.",
"2. Use bash with `cat`, `tee`, `grep`, `find` for all remote file ops.",
"3. Native `read`/`write`/`edit`/`glob`/`grep` tools are disabled in remote mode.",
"4. Say \"local\" to disconnect.",
].join("\n"),
state.status === "connected"
? [
"",
"## Remote SSH Mode",
"",
`You are connected to **${state.host}** via persistent SSH.`,
"Local platform and working-directory metadata describe the OpenCode host, not the remote host.",
"",
"1. The **bash** tool verifies the connection and is automatically wrapped with SSH. Do NOT add SSH yourself.",
"2. Use bash with `cat`, `tee`, `grep`, `find` for all remote file ops.",
"3. Native `read`/`write`/`edit`/`glob`/`grep` tools are disabled in remote mode.",
"4. Say \"local\" to disconnect.",
].join("\n")
: [
"",
"## Remote SSH Mode Unavailable",
"",
`Remote mode was requested for **${state.host}**, but its SSH connection is unavailable.`,
"All bash and native file-tool operations are blocked to prevent accidental local execution.",
"Use ssh_connect to reconnect, or say \"local\" to explicitly return to local mode.",
].join("\n"),
)
},
}
Expand Down