Skip to content

Commit 7e2611b

Browse files
Add conversational SSH connection setup
1 parent 075642b commit 7e2611b

9 files changed

Lines changed: 277 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.3.0
4+
5+
- Added conversational SSH connection management with `remote_add_host`.
6+
- Added `remote_remove_host` for deleting saved connection profiles.
7+
- Added `remote_test_connection` for validating saved profiles.
8+
- Added default config discovery at `~/.codex/remote-ssh-hosts.json`.
9+
- Reduced setup friction so users no longer need environment variables for normal use.
10+
311
## 0.2.0
412

513
- Added Zain Technologies LTD marketplace metadata.

plugins/remote-ssh/.codex-plugin/plugin.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "remote-ssh",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Enterprise-grade Remote SSH tools for Codex with host policies, audit logging, and safe file operations.",
55
"author": {
66
"name": "Zain Technologies LTD",
@@ -22,7 +22,7 @@
2222
"interface": {
2323
"displayName": "Remote SSH",
2424
"shortDescription": "Enterprise Remote SSH operations for Codex",
25-
"longDescription": "Remote SSH by Zain Technologies LTD connects Codex to trusted servers, devboxes, and private infrastructure through a local MCP bridge. It supports host aliases, path allowlists, write opt-in controls, blocked command policies, timeouts, output limits, and JSONL audit logs for professional remote development and operations workflows.",
25+
"longDescription": "Remote SSH by Zain Technologies LTD connects Codex to trusted servers, devboxes, and private infrastructure through a local MCP bridge. It supports conversational connection setup, saved host aliases, path allowlists, write opt-in controls, blocked command policies, timeouts, output limits, and JSONL audit logs for professional remote development and operations workflows.",
2626
"developerName": "Zain Technologies LTD",
2727
"category": "Development",
2828
"capabilities": [
@@ -34,9 +34,9 @@
3434
"privacyPolicyURL": "https://github.com/ZainTechnologiesLTD/codex-remote-ssh/blob/main/PRIVACY.md",
3535
"termsOfServiceURL": "https://github.com/ZainTechnologiesLTD/codex-remote-ssh/blob/main/TERMS.md",
3636
"defaultPrompt": [
37+
"Add a new SSH connection.",
3738
"Check my configured remote host health.",
38-
"Tail the latest application log over SSH.",
39-
"Run tests on my remote devbox."
39+
"Tail the latest application log over SSH."
4040
],
4141
"brandColor": "#2563EB",
4242
"composerIcon": "./assets/app-icon.svg",

plugins/remote-ssh/CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.3.0
4+
5+
- Added conversational SSH connection management with `remote_add_host`.
6+
- Added `remote_remove_host` for deleting saved connection profiles.
7+
- Added `remote_test_connection` for validating saved profiles.
8+
- Added default config discovery at `~/.codex/remote-ssh-hosts.json`.
9+
- Reduced setup friction so users no longer need environment variables for normal use.
10+
311
## 0.2.0
412

513
- Added Zain Technologies LTD marketplace metadata.
@@ -12,4 +20,3 @@
1220

1321
- Initial plugin scaffold.
1422
- Added basic MCP tools for commands, file reads, directory listing, and file writes.
15-

plugins/remote-ssh/CONFIGURATION.md

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,31 @@
11
# Configuration Reference
22

3-
Codex Remote SSH reads host aliases from either `REMOTE_SSH_HOSTS` or `REMOTE_SSH_CONFIG_FILE`.
3+
Codex Remote SSH can save connection profiles automatically. Most users should ask Codex to add a connection instead of editing JSON by hand.
4+
5+
```text
6+
Add an SSH connection named my-server for user@hostname on port 22 using identity file ~/.ssh/id_rsa.
7+
```
8+
9+
Saved profiles are stored at:
10+
11+
```text
12+
~/.codex/remote-ssh-hosts.json
13+
```
14+
15+
Advanced users can override this with `REMOTE_SSH_CONFIG_FILE` or provide ephemeral configuration with `REMOTE_SSH_HOSTS`.
16+
17+
## Automatic Connection Setup
18+
19+
`remote_add_host` accepts the same fields users expect from a modern Remote SSH form:
20+
21+
| Form Field | Tool Field | Notes |
22+
| --- | --- | --- |
23+
| Name | `name` | Friendly alias such as `my-server`. |
24+
| SSH Host | `sshHost` | `user@hostname` or a host from `~/.ssh/config`. |
25+
| SSH Port | `port` | Defaults to `22`. |
26+
| Identity File | `identityFile` | Optional. Leave empty to use default SSH behavior. |
27+
| Allowed Paths | `allowedPaths` | Optional safety policy for file tools. |
28+
| Allow Writes | `allowWrites` | Defaults to `false`. |
429

530
## `REMOTE_SSH_HOSTS`
631

@@ -74,4 +99,3 @@ $env:REMOTE_SSH_AUDIT_LOG="$HOME\.codex\remote-ssh-audit.jsonl"
7499
```
75100

76101
Each record includes timestamp, tool name, host alias, command phase, exit code, timeout status, and duration.
77-

plugins/remote-ssh/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Modern teams often keep source code, logs, services, and deployment tools on rem
1010

1111
This plugin exposes clear tools for common remote work:
1212

13+
- add and save SSH connections conversationally
1314
- discover configured host aliases
1415
- run non-interactive remote commands
1516
- list directories
@@ -46,7 +47,19 @@ For local development, keep this plugin folder under a repo-level `plugins/remot
4647

4748
## Configuration
4849

49-
Configure hosts through `REMOTE_SSH_HOSTS` or `REMOTE_SSH_CONFIG_FILE`.
50+
Most users should add connections conversationally:
51+
52+
```text
53+
Add an SSH connection named hms for hmsadmin@192.168.128.7 using identity file ~/.ssh/id_ed25519_hms.
54+
```
55+
56+
The plugin saves profiles to:
57+
58+
```text
59+
~/.codex/remote-ssh-hosts.json
60+
```
61+
62+
Advanced users can still configure hosts through `REMOTE_SSH_HOSTS` or `REMOTE_SSH_CONFIG_FILE`.
5063

5164
PowerShell example:
5265

@@ -90,6 +103,9 @@ Use Remote SSH to tail the last 100 lines of /var/log/nginx/error.log on hms.
90103

91104
| Tool | Purpose |
92105
| --- | --- |
106+
| `remote_add_host` | Saves or updates an SSH connection profile. |
107+
| `remote_remove_host` | Removes a saved SSH connection profile. |
108+
| `remote_test_connection` | Validates a saved SSH connection. |
93109
| `remote_hosts` | Lists configured host aliases and non-secret policy metadata. |
94110
| `remote_run` | Runs a non-interactive command on a configured host. |
95111
| `remote_list_dir` | Lists an allowlisted directory. |

plugins/remote-ssh/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zaintechnologiesltd/codex-remote-ssh",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Enterprise-grade Remote SSH MCP tools for OpenAI Codex.",
55
"license": "MIT",
66
"author": {

plugins/remote-ssh/scripts/remote-ssh-mcp.js

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const os = require("node:os");
77
const path = require("node:path");
88
const readline = require("node:readline");
99

10-
const SERVER_VERSION = "0.2.0";
10+
const SERVER_VERSION = "0.3.0";
1111
const PROTOCOL_VERSION = "2024-11-05";
1212
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
1313
const DEFAULT_CONNECT_TIMEOUT_SECONDS = 15;
@@ -56,6 +56,24 @@ function parseJson(raw, label) {
5656
}
5757
}
5858

59+
function defaultConfigFile() {
60+
return path.join(os.homedir(), ".codex", "remote-ssh-hosts.json");
61+
}
62+
63+
function activeConfigFile() {
64+
return expandHome(process.env.REMOTE_SSH_CONFIG_FILE || defaultConfigFile());
65+
}
66+
67+
function ensureConfigShape(config) {
68+
if (!config || typeof config !== "object" || Array.isArray(config)) {
69+
return { hosts: {} };
70+
}
71+
if (config.hosts && typeof config.hosts === "object" && !Array.isArray(config.hosts)) {
72+
return config;
73+
}
74+
return { hosts: config };
75+
}
76+
5977
function loadHostConfig() {
6078
const configFile = process.env.REMOTE_SSH_CONFIG_FILE;
6179
if (configFile) {
@@ -64,7 +82,58 @@ function loadHostConfig() {
6482
return parsed.hosts || parsed;
6583
}
6684

67-
return parseJson(process.env.REMOTE_SSH_HOSTS || "{}", "REMOTE_SSH_HOSTS");
85+
const envHosts = process.env.REMOTE_SSH_HOSTS;
86+
if (envHosts) {
87+
return parseJson(envHosts, "REMOTE_SSH_HOSTS");
88+
}
89+
90+
const resolved = defaultConfigFile();
91+
if (!fs.existsSync(resolved)) return {};
92+
const parsed = parseJson(fs.readFileSync(resolved, "utf8"), resolved);
93+
return parsed.hosts || parsed;
94+
}
95+
96+
function readWritableConfig() {
97+
const resolved = activeConfigFile();
98+
if (!fs.existsSync(resolved)) return { file: resolved, config: { hosts: {} } };
99+
return {
100+
file: resolved,
101+
config: ensureConfigShape(parseJson(fs.readFileSync(resolved, "utf8"), resolved)),
102+
};
103+
}
104+
105+
function writeWritableConfig(file, config) {
106+
fs.mkdirSync(path.dirname(file), { recursive: true });
107+
fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
108+
}
109+
110+
function parseSshHost(input) {
111+
const value = String(input || "").trim();
112+
if (!value) throw new Error("SSH host is required.");
113+
if (value.includes("@")) {
114+
const [user, ...hostParts] = value.split("@");
115+
const host = hostParts.join("@");
116+
if (!user || !host) throw new Error("SSH host must look like user@hostname.");
117+
return { user, host };
118+
}
119+
return { sshConfigHost: value };
120+
}
121+
122+
function cleanHostProfile(args) {
123+
const parsed = parseSshHost(args.sshHost);
124+
const profile = {
125+
...parsed,
126+
port: args.port || 22,
127+
identityFile: args.identityFile || undefined,
128+
allowedPaths: Array.isArray(args.allowedPaths) ? args.allowedPaths : [],
129+
allowWrites: Boolean(args.allowWrites),
130+
strictHostKeyChecking: args.strictHostKeyChecking !== false,
131+
connectTimeoutSeconds: args.connectTimeoutSeconds || DEFAULT_CONNECT_TIMEOUT_SECONDS,
132+
commandTimeoutMs: args.commandTimeoutMs || DEFAULT_COMMAND_TIMEOUT_MS,
133+
maxOutputBytes: args.maxOutputBytes || DEFAULT_MAX_OUTPUT_BYTES,
134+
};
135+
136+
return Object.fromEntries(Object.entries(profile).filter(([, value]) => value !== undefined));
68137
}
69138

70139
function getHost(alias) {
@@ -262,6 +331,57 @@ function textResult(payload) {
262331
}
263332

264333
const tools = [
334+
{
335+
name: "remote_add_host",
336+
description: "Add or update a saved SSH connection profile in the Remote SSH config file.",
337+
inputSchema: {
338+
type: "object",
339+
properties: {
340+
name: { type: "string", description: "Friendly connection name, used as the host alias." },
341+
sshHost: { type: "string", description: "user@hostname or a host alias from ~/.ssh/config." },
342+
port: { type: "integer", minimum: 1, maximum: 65535, default: 22 },
343+
identityFile: { type: "string", description: "Optional private key path. Supports ~." },
344+
allowedPaths: {
345+
type: "array",
346+
items: { type: "string" },
347+
description: "Optional remote path allowlist for file tools.",
348+
default: [],
349+
},
350+
allowWrites: { type: "boolean", default: false },
351+
strictHostKeyChecking: { type: "boolean", default: true },
352+
connectTimeoutSeconds: { type: "integer", minimum: 1, default: 15 },
353+
commandTimeoutMs: { type: "integer", minimum: 1000, default: 120000 },
354+
maxOutputBytes: { type: "integer", minimum: 1024, default: 1048576 },
355+
overwrite: { type: "boolean", default: false },
356+
},
357+
required: ["name", "sshHost"],
358+
additionalProperties: false,
359+
},
360+
},
361+
{
362+
name: "remote_remove_host",
363+
description: "Remove a saved SSH connection profile from the Remote SSH config file.",
364+
inputSchema: {
365+
type: "object",
366+
properties: {
367+
name: { type: "string", description: "Host alias to remove." },
368+
},
369+
required: ["name"],
370+
additionalProperties: false,
371+
},
372+
},
373+
{
374+
name: "remote_test_connection",
375+
description: "Test a configured SSH connection with a small non-interactive command.",
376+
inputSchema: {
377+
type: "object",
378+
properties: {
379+
host: { type: "string", description: "Configured host alias." },
380+
},
381+
required: ["host"],
382+
additionalProperties: false,
383+
},
384+
},
265385
{
266386
name: "remote_hosts",
267387
description: "List configured SSH host aliases and their non-secret policy metadata.",
@@ -355,6 +475,36 @@ const tools = [
355475
];
356476

357477
async function callTool(name, args) {
478+
if (name === "remote_add_host") {
479+
const alias = String(args.name || "").trim();
480+
if (!alias || !/^[A-Za-z0-9_.-]+$/.test(alias)) {
481+
throw new Error("Connection name must contain only letters, numbers, dots, underscores, or hyphens.");
482+
}
483+
const { file, config } = readWritableConfig();
484+
if (config.hosts[alias] && !args.overwrite) {
485+
throw new Error(`Connection ${alias} already exists. Pass overwrite=true to update it.`);
486+
}
487+
config.hosts[alias] = cleanHostProfile(args);
488+
writeWritableConfig(file, config);
489+
return textResult({
490+
saved: true,
491+
name: alias,
492+
configFile: file,
493+
profile: redactConfig({ alias, ...config.hosts[alias] }),
494+
});
495+
}
496+
497+
if (name === "remote_remove_host") {
498+
const alias = String(args.name || "").trim();
499+
const { file, config } = readWritableConfig();
500+
if (!config.hosts[alias]) {
501+
throw new Error(`Connection ${alias} does not exist in ${file}.`);
502+
}
503+
delete config.hosts[alias];
504+
writeWritableConfig(file, config);
505+
return textResult({ removed: true, name: alias, configFile: file });
506+
}
507+
358508
if (name === "remote_hosts") {
359509
const hosts = loadHostConfig();
360510
return textResult({
@@ -366,6 +516,10 @@ async function callTool(name, args) {
366516

367517
const config = getHost(args.host);
368518

519+
if (name === "remote_test_connection") {
520+
return textResult(await runSsh(config, "printf 'connected\\n'; hostname; whoami", name));
521+
}
522+
369523
if (name === "remote_run") {
370524
assertCommandAllowed(config, args.command);
371525
return textResult(await runSsh(config, args.command, name));
@@ -465,6 +619,8 @@ if (require.main === module) {
465619
module.exports = {
466620
assertCommandAllowed,
467621
assertPathAllowed,
622+
cleanHostProfile,
623+
defaultConfigFile,
468624
expandHome,
469625
handle,
470626
loadHostConfig,

plugins/remote-ssh/skills/remote-ssh/SKILL.md

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,21 @@ Use this skill when the user asks Codex to inspect or operate a configured remot
1010
## Workflow
1111

1212
1. Confirm the target host alias, remote path, and command intent from the user's request.
13-
2. Prefer narrow tools over broad shell access:
13+
2. When the user wants to add a connection, collect:
14+
- friendly name
15+
- SSH host as `user@hostname` or a `~/.ssh/config` host
16+
- optional port
17+
- optional identity file
18+
- optional allowed remote paths
19+
Then use `remote_add_host` and test with `remote_test_connection`.
20+
3. Prefer narrow tools over broad shell access:
1421
- use `remote_list_dir` before reading unknown paths
1522
- use `remote_read_file` for file inspection
1623
- use `remote_write_file` only when the user clearly wants a remote edit
1724
- use `remote_run` for tests, service status, logs, and other explicit commands
18-
3. Keep remote commands non-interactive.
19-
4. Do not request or echo private key material.
20-
5. Summarize remote command output clearly when the user cannot see the tool output.
25+
4. Keep remote commands non-interactive.
26+
5. Do not request or echo private key material. Only ask for the identity file path.
27+
6. Summarize remote command output clearly when the user cannot see the tool output.
2128

2229
## Safety
2330

@@ -28,4 +35,3 @@ If authentication fails, guide the user to verify SSH access locally first:
2835
```powershell
2936
ssh -i $HOME\.ssh\id_ed25519_hms user@host "hostname"
3037
```
31-

0 commit comments

Comments
 (0)