@@ -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.3.1 " ;
10+ const SERVER_VERSION = "0.4.0 " ;
1111const PROTOCOL_VERSION = "2024-11-05" ;
1212const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024 ;
1313const DEFAULT_CONNECT_TIMEOUT_SECONDS = 15 ;
@@ -121,11 +121,13 @@ function parseSshHost(input) {
121121
122122function 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+
196202function 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+
224247function 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) {
617781module . 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,
0 commit comments