@@ -7,7 +7,7 @@ const os = require("node:os");
77const path = require ( "node:path" ) ;
88const readline = require ( "node:readline" ) ;
99
10- const SERVER_VERSION = "0.2 .0" ;
10+ const SERVER_VERSION = "0.3 .0" ;
1111const PROTOCOL_VERSION = "2024-11-05" ;
1212const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 ;
1313const 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+
5977function 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
70139function getHost ( alias ) {
@@ -262,6 +331,57 @@ function textResult(payload) {
262331}
263332
264333const 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
357477async function callTool ( name , args ) {
478+ if ( name === "remote_add_host" ) {
479+ const alias = String ( args . name || "" ) . trim ( ) ;
480+ if ( ! alias || ! / ^ [ A - Z a - z 0 - 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) {
465619module . exports = {
466620 assertCommandAllowed,
467621 assertPathAllowed,
622+ cleanHostProfile,
623+ defaultConfigFile,
468624 expandHome,
469625 handle,
470626 loadHostConfig,
0 commit comments