Skip to content

Commit 9667b0b

Browse files
Add remote workspace tools
1 parent b5dba8a commit 9667b0b

9 files changed

Lines changed: 222 additions & 10 deletions

File tree

CHANGELOG.md

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

3+
## 0.4.0
4+
5+
- Added remote workspace bootstrap checks for OS, shell, user, workspace path, and common development tools.
6+
- Added remote workspace tree, text search, and git status tools.
7+
- Added exact-text replacement for allowlisted writable files.
8+
- Added optional `workspaceRoot` to saved SSH connection profiles.
9+
310
## 0.3.1
411

512
- Fixed stdio lifecycle handling so async SSH tool calls return results reliably when stdin closes.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "remote-ssh",
3-
"version": "0.3.1",
3+
"version": "0.4.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 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.",
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, remote workspace bootstrap checks, tree/search/git tools, 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": [

plugins/remote-ssh/CHANGELOG.md

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

3+
## 0.4.0
4+
5+
- Added remote workspace bootstrap checks for OS, shell, user, workspace path, and common development tools.
6+
- Added remote workspace tree, text search, and git status tools.
7+
- Added exact-text replacement for allowlisted writable files.
8+
- Added optional `workspaceRoot` to saved SSH connection profiles.
9+
310
## 0.3.1
411

512
- Fixed stdio lifecycle handling so async SSH tool calls return results reliably when stdin closes.

plugins/remote-ssh/CONFIGURATION.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Advanced users can override this with `REMOTE_SSH_CONFIG_FILE` or provide epheme
2424
| SSH Host | `sshHost` | `user@hostname` or a host from `~/.ssh/config`. |
2525
| SSH Port | `port` | Defaults to `22`. |
2626
| Identity File | `identityFile` | Optional. Leave empty to use default SSH behavior. |
27+
| Workspace Root | `workspaceRoot` | Optional default remote project root. |
2728
| Allowed Paths | `allowedPaths` | Optional safety policy for file tools. |
2829
| Allow Writes | `allowWrites` | Defaults to `false`. |
2930

@@ -38,6 +39,7 @@ Set this environment variable to a JSON object keyed by host alias.
3839
"host": "10.0.10.20",
3940
"port": 22,
4041
"identityFile": "~/.ssh/id_ed25519_prod",
42+
"workspaceRoot": "/srv/app",
4143
"allowedPaths": ["/srv/app", "/var/log/app"],
4244
"allowWrites": false,
4345
"allowCommandExecution": true,
@@ -78,6 +80,7 @@ Set this environment variable to a JSON file path. The file can contain the host
7880
| `sshConfigHost` | string | optional | Existing host alias from `~/.ssh/config`. |
7981
| `port` | number | `22` | SSH port. |
8082
| `identityFile` | string | optional | Private key path. Supports `~`. |
83+
| `workspaceRoot` | string | optional | Default remote project root for workspace tools. |
8184
| `knownHostsFile` | string | optional | Custom known hosts file. |
8285
| `proxyJump` | string | optional | SSH jump host value passed through `-J`. |
8386
| `strictHostKeyChecking` | boolean | `true` | Keep host key verification enabled by default. |

plugins/remote-ssh/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ Modern teams often keep source code, logs, services, and deployment tools on rem
1111
This plugin exposes clear tools for common remote work:
1212

1313
- add and save SSH connections conversationally
14+
- inspect whether a remote host is ready for development
15+
- browse bounded remote workspace trees
16+
- search text inside remote workspaces
17+
- check remote Git status
1418
- discover configured host aliases
1519
- run non-interactive remote commands
1620
- list directories
@@ -108,10 +112,15 @@ Use Remote SSH to tail the last 100 lines of /var/log/nginx/error.log on hms.
108112
| `remote_test_connection` | Validates a saved SSH connection. |
109113
| `remote_hosts` | Lists configured host aliases and non-secret policy metadata. |
110114
| `remote_run` | Runs a non-interactive command on a configured host. |
115+
| `remote_workspace_bootstrap` | Checks remote OS, user, shell, workspace path, and dev tools. |
116+
| `remote_tree` | Shows a bounded remote workspace tree. |
117+
| `remote_search_text` | Searches text inside a remote workspace. |
118+
| `remote_git_status` | Runs Git status in a remote workspace. |
111119
| `remote_list_dir` | Lists an allowlisted directory. |
112120
| `remote_stat` | Returns metadata for an allowlisted path. |
113121
| `remote_read_file` | Reads an allowlisted UTF-8 text file. |
114122
| `remote_tail_file` | Reads the last lines of an allowlisted text/log file. |
123+
| `remote_replace_in_file` | Replaces exact text in an allowlisted writable file. |
115124
| `remote_write_file` | Writes UTF-8 text when `allowWrites=true`. |
116125

117126
## Security

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.3.1",
3+
"version": "0.4.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: 168 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.3.1";
10+
const SERVER_VERSION = "0.4.0";
1111
const PROTOCOL_VERSION = "2024-11-05";
1212
const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024;
1313
const DEFAULT_CONNECT_TIMEOUT_SECONDS = 15;
@@ -121,11 +121,13 @@ function parseSshHost(input) {
121121

122122
function cleanHostProfile(args) {
123123
const parsed = parseSshHost(args.sshHost);
124+
const allowedPaths = Array.isArray(args.allowedPaths) ? args.allowedPaths : [];
124125
const profile = {
125126
...parsed,
126127
port: args.port || 22,
127128
identityFile: args.identityFile || undefined,
128-
allowedPaths: Array.isArray(args.allowedPaths) ? args.allowedPaths : [],
129+
workspaceRoot: args.workspaceRoot || undefined,
130+
allowedPaths: allowedPaths.length > 0 ? allowedPaths : args.workspaceRoot ? [args.workspaceRoot] : [],
129131
allowWrites: Boolean(args.allowWrites),
130132
strictHostKeyChecking: args.strictHostKeyChecking !== false,
131133
connectTimeoutSeconds: args.connectTimeoutSeconds || DEFAULT_CONNECT_TIMEOUT_SECONDS,
@@ -193,6 +195,10 @@ function shellQuote(value) {
193195
return `'${String(value).replace(/'/g, `'\\''`)}'`;
194196
}
195197

198+
function base64Text(value) {
199+
return Buffer.from(String(value), "utf8").toString("base64");
200+
}
201+
196202
function normalizeRemotePath(remotePath) {
197203
if (!remotePath || typeof remotePath !== "string") {
198204
throw new Error("Remote path must be a non-empty string.");
@@ -221,6 +227,23 @@ function assertPathAllowed(config, remotePath, operation) {
221227
return normalized;
222228
}
223229

230+
function resolveRemoteWorkspacePath(config, root, relativePath, operation) {
231+
const workspaceRoot = normalizeRemotePath(root || config.workspaceRoot || "/");
232+
assertPathAllowed(config, workspaceRoot, operation);
233+
234+
const requested = relativePath ? String(relativePath) : ".";
235+
if (requested.startsWith("/")) {
236+
return assertPathAllowed(config, requested, operation);
237+
}
238+
239+
const resolved = path.posix.normalize(path.posix.join(workspaceRoot, requested));
240+
if (resolved !== workspaceRoot && !resolved.startsWith(`${workspaceRoot}/`)) {
241+
throw new Error(`${operation} denied because path escapes workspace root: ${requested}`);
242+
}
243+
244+
return assertPathAllowed(config, resolved, operation);
245+
}
246+
224247
function assertCommandAllowed(config, command) {
225248
if (!config.allowCommandExecution) {
226249
throw new Error(`Command execution is disabled for host alias ${config.alias}.`);
@@ -341,6 +364,7 @@ const tools = [
341364
sshHost: { type: "string", description: "user@hostname or a host alias from ~/.ssh/config." },
342365
port: { type: "integer", minimum: 1, maximum: 65535, default: 22 },
343366
identityFile: { type: "string", description: "Optional private key path. Supports ~." },
367+
workspaceRoot: { type: "string", description: "Optional default remote project/workspace root." },
344368
allowedPaths: {
345369
type: "array",
346370
items: { type: "string" },
@@ -404,6 +428,82 @@ const tools = [
404428
additionalProperties: false,
405429
},
406430
},
431+
{
432+
name: "remote_workspace_bootstrap",
433+
description: "Inspect remote OS, user, shell, workspace path, and common development tools.",
434+
inputSchema: {
435+
type: "object",
436+
properties: {
437+
host: { type: "string" },
438+
root: { type: "string", description: "Optional absolute remote workspace root." },
439+
},
440+
required: ["host"],
441+
additionalProperties: false,
442+
},
443+
},
444+
{
445+
name: "remote_tree",
446+
description: "Show a bounded remote workspace tree using find.",
447+
inputSchema: {
448+
type: "object",
449+
properties: {
450+
host: { type: "string" },
451+
root: { type: "string", description: "Optional absolute remote workspace root." },
452+
path: { type: "string", description: "Relative path inside the workspace root.", default: "." },
453+
maxDepth: { type: "integer", minimum: 1, maximum: 8, default: 3 },
454+
limit: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
455+
},
456+
required: ["host"],
457+
additionalProperties: false,
458+
},
459+
},
460+
{
461+
name: "remote_search_text",
462+
description: "Search for text inside a remote workspace. Uses rg when available, with grep fallback.",
463+
inputSchema: {
464+
type: "object",
465+
properties: {
466+
host: { type: "string" },
467+
root: { type: "string", description: "Optional absolute remote workspace root." },
468+
path: { type: "string", description: "Relative path inside the workspace root.", default: "." },
469+
query: { type: "string", description: "Literal text or regex to search for." },
470+
regex: { type: "boolean", default: false },
471+
limit: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
472+
},
473+
required: ["host", "query"],
474+
additionalProperties: false,
475+
},
476+
},
477+
{
478+
name: "remote_git_status",
479+
description: "Run git status in a remote workspace.",
480+
inputSchema: {
481+
type: "object",
482+
properties: {
483+
host: { type: "string" },
484+
root: { type: "string", description: "Optional absolute remote workspace root." },
485+
},
486+
required: ["host"],
487+
additionalProperties: false,
488+
},
489+
},
490+
{
491+
name: "remote_replace_in_file",
492+
description: "Replace exact UTF-8 text in an allowlisted remote file. Requires allowWrites=true.",
493+
inputSchema: {
494+
type: "object",
495+
properties: {
496+
host: { type: "string" },
497+
root: { type: "string", description: "Optional absolute remote workspace root." },
498+
path: { type: "string", description: "Absolute path or relative path inside the workspace root." },
499+
oldText: { type: "string", description: "Exact text to replace." },
500+
newText: { type: "string", description: "Replacement text." },
501+
expectedReplacements: { type: "integer", minimum: 1, default: 1 },
502+
},
503+
required: ["host", "path", "oldText", "newText"],
504+
additionalProperties: false,
505+
},
506+
},
407507
{
408508
name: "remote_read_file",
409509
description: "Read a UTF-8 text file from a configured SSH host.",
@@ -524,6 +624,70 @@ async function callTool(name, args) {
524624
assertCommandAllowed(config, args.command);
525625
return textResult(await runSsh(config, args.command, name));
526626
}
627+
if (name === "remote_workspace_bootstrap") {
628+
const root = args.root || config.workspaceRoot || "~";
629+
const rootTarget = root === "~" ? "~" : shellQuote(root);
630+
const command = [
631+
"printf 'user='; whoami",
632+
"printf 'host='; hostname",
633+
"printf 'os='; uname -a",
634+
"printf 'shell='; printf %s \"$SHELL\"; printf '\\n'",
635+
`printf 'workspace='; cd ${rootTarget} 2>/dev/null && pwd || printf 'unavailable'`,
636+
"p=$(command -v node 2>/dev/null || true); printf 'node=%s\\n' \"${p:-missing}\"",
637+
"p=$(command -v npm 2>/dev/null || true); printf 'npm=%s\\n' \"${p:-missing}\"",
638+
"p=$(command -v git 2>/dev/null || true); printf 'git=%s\\n' \"${p:-missing}\"",
639+
"p=$(command -v rg 2>/dev/null || true); printf 'rg=%s\\n' \"${p:-missing}\"",
640+
"p=$(command -v python3 2>/dev/null || true); printf 'python3=%s\\n' \"${p:-missing}\"",
641+
].join("; ");
642+
return textResult(await runSsh(config, command, name));
643+
}
644+
if (name === "remote_tree") {
645+
const target = resolveRemoteWorkspacePath(config, args.root, args.path || ".", "tree");
646+
const maxDepth = Math.max(1, Math.min(Number(args.maxDepth || 3), 8));
647+
const limit = Math.max(1, Math.min(Number(args.limit || 200), 1000));
648+
const command = `find ${shellQuote(target)} -maxdepth ${maxDepth} -mindepth 1 -not -path '*/.git/*' -print | sort | head -n ${limit}`;
649+
return textResult(await runSsh(config, command, name));
650+
}
651+
if (name === "remote_search_text") {
652+
const target = resolveRemoteWorkspacePath(config, args.root, args.path || ".", "search");
653+
const limit = Math.max(1, Math.min(Number(args.limit || 200), 1000));
654+
const fixed = args.regex ? "" : "-F";
655+
const grepMode = args.regex ? "-E" : "-F";
656+
const command = [
657+
"if command -v rg >/dev/null 2>&1; then",
658+
`rg --line-number --hidden --glob '!.git' ${fixed} ${shellQuote(args.query)} ${shellQuote(target)} | head -n ${limit};`,
659+
"else",
660+
`grep -RIn ${grepMode} --exclude-dir=.git ${shellQuote(args.query)} ${shellQuote(target)} | head -n ${limit};`,
661+
"fi",
662+
].join(" ");
663+
return textResult(await runSsh(config, command, name));
664+
}
665+
if (name === "remote_git_status") {
666+
const root = resolveRemoteWorkspacePath(config, args.root, ".", "git status");
667+
return textResult(await runSsh(config, `cd ${shellQuote(root)} && git status --short --branch`, name));
668+
}
669+
if (name === "remote_replace_in_file") {
670+
if (!config.allowWrites) {
671+
throw new Error(`Writes are disabled for host alias ${config.alias}. Set allowWrites=true to enable.`);
672+
}
673+
const remotePath = resolveRemoteWorkspacePath(config, args.root, args.path, "replace");
674+
const expected = Math.max(1, Number(args.expectedReplacements || 1));
675+
const script = [
676+
"import base64, pathlib",
677+
`p = pathlib.Path(${JSON.stringify(remotePath)})`,
678+
`old = base64.b64decode(${JSON.stringify(base64Text(args.oldText))}).decode('utf-8')`,
679+
`new = base64.b64decode(${JSON.stringify(base64Text(args.newText))}).decode('utf-8')`,
680+
"text = p.read_text(encoding='utf-8')",
681+
"count = text.count(old)",
682+
`expected = ${expected}`,
683+
"if count != expected:",
684+
" raise SystemExit(f'expected {expected} replacement(s), found {count}')",
685+
"p.write_text(text.replace(old, new), encoding='utf-8')",
686+
"print(f'replaced {count} occurrence(s)')",
687+
].join("\n");
688+
const command = `python3 -c ${shellQuote(script)}`;
689+
return textResult(await runSsh(config, command, name));
690+
}
527691
if (name === "remote_read_file") {
528692
const remotePath = assertPathAllowed(config, args.path, "read");
529693
return textResult(await runSsh(config, `cat -- ${shellQuote(remotePath)}`, name));
@@ -617,12 +781,14 @@ if (require.main === module) {
617781
module.exports = {
618782
assertCommandAllowed,
619783
assertPathAllowed,
784+
base64Text,
620785
cleanHostProfile,
621786
defaultConfigFile,
622787
expandHome,
623788
handle,
624789
loadHostConfig,
625790
normalizeRemotePath,
791+
resolveRemoteWorkspacePath,
626792
shellQuote,
627793
startServer,
628794
tools,

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,23 @@ Use this skill when the user asks Codex to inspect or operate a configured remot
1515
- SSH host as `user@hostname` or a `~/.ssh/config` host
1616
- optional port
1717
- optional identity file
18+
- optional workspace root
1819
- optional allowed remote paths
1920
Then use `remote_add_host` and test with `remote_test_connection`.
20-
3. Prefer narrow tools over broad shell access:
21+
3. When the user wants to work in a remote project, prefer workspace tools:
22+
- use `remote_workspace_bootstrap` to check environment readiness
23+
- use `remote_tree` to inspect project structure
24+
- use `remote_search_text` to locate code
25+
- use `remote_git_status` before and after edits
26+
- use `remote_replace_in_file` for narrow exact edits when writes are enabled
27+
4. Prefer narrow tools over broad shell access:
2128
- use `remote_list_dir` before reading unknown paths
2229
- use `remote_read_file` for file inspection
2330
- use `remote_write_file` only when the user clearly wants a remote edit
2431
- use `remote_run` for tests, service status, logs, and other explicit commands
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.
32+
5. Keep remote commands non-interactive.
33+
6. Do not request or echo private key material. Only ask for the identity file path.
34+
7. Summarize remote command output clearly when the user cannot see the tool output.
2835

2936
## Safety
3037

0 commit comments

Comments
 (0)