Skip to content
Open
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
39 changes: 39 additions & 0 deletions src/config-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,30 @@ import path from 'path';
import os from 'os';
import { logger } from './logger.js';

/**
* Parse auto-reply config from env vars:
* SSH_SERVER_FOO_AUTO_REPLY_COUNT=2
* SSH_SERVER_FOO_AUTO_REPLY_1_PATTERN="START GLOBUS Y/N"
* SSH_SERVER_FOO_AUTO_REPLY_1_RESPONSE="N"
* SSH_SERVER_FOO_AUTO_REPLY_2_PATTERN="Enter password"
* SSH_SERVER_FOO_AUTO_REPLY_2_RESPONSE="secret"
*/
function parseAutoReplies(env, serverKey) {
const count = parseInt(env[`SSH_SERVER_${serverKey}_AUTO_REPLY_COUNT`] || '0');
if (count === 0) return undefined;

const replies = [];
for (let i = 1; i <= count; i++) {
const pattern = env[`SSH_SERVER_${serverKey}_AUTO_REPLY_${i}_PATTERN`];
const response = env[`SSH_SERVER_${serverKey}_AUTO_REPLY_${i}_RESPONSE`];
if (pattern && response !== undefined) {
const timeout = parseInt(env[`SSH_SERVER_${serverKey}_AUTO_REPLY_${i}_TIMEOUT`] || '5000');
replies.push({ pattern, response, timeout });
}
}
return replies.length > 0 ? replies : undefined;
}

export class ConfigLoader {
constructor() {
this.servers = new Map();
Expand Down Expand Up @@ -94,6 +118,8 @@ export class ConfigLoader {
description: serverConfig.description,
platform: serverConfig.platform ? serverConfig.platform.toLowerCase() : undefined,
proxyJump: serverConfig.proxy_jump,
promptPattern: serverConfig.prompt_pattern,
autoReplies: serverConfig.auto_reply ? (Array.isArray(serverConfig.auto_reply) ? serverConfig.auto_reply : [serverConfig.auto_reply]) : undefined,
source: 'toml'
});
}
Expand Down Expand Up @@ -143,6 +169,8 @@ export class ConfigLoader {
description: env[`SSH_SERVER_${match[1]}_DESCRIPTION`],
platform: (env[`SSH_SERVER_${match[1]}_PLATFORM`] || '').toLowerCase() || undefined,
proxyJump: env[`SSH_SERVER_${match[1]}_PROXYJUMP`],
promptPattern: env[`SSH_SERVER_${match[1]}_PROMPT_PATTERN`],
autoReplies: parseAutoReplies(env, match[1]),
source: 'env'
};

Expand Down Expand Up @@ -196,6 +224,8 @@ export class ConfigLoader {
if (server.description) serverConfig.description = server.description;
if (server.platform) serverConfig.platform = server.platform;
if (server.proxyJump) serverConfig.proxy_jump = server.proxyJump;
if (server.promptPattern) serverConfig.prompt_pattern = server.promptPattern;
if (server.autoReplies) serverConfig.auto_reply = server.autoReplies;

config.ssh_servers[name] = serverConfig;
}
Expand Down Expand Up @@ -225,6 +255,15 @@ export class ConfigLoader {
if (server.description) lines.push(`SSH_SERVER_${upperName}_DESCRIPTION="${server.description}"`);
if (server.platform) lines.push(`SSH_SERVER_${upperName}_PLATFORM=${server.platform}`);
if (server.proxyJump) lines.push(`SSH_SERVER_${upperName}_PROXYJUMP=${server.proxyJump}`);
if (server.promptPattern) lines.push(`SSH_SERVER_${upperName}_PROMPT_PATTERN=${server.promptPattern}`);
if (server.autoReplies) {
lines.push(`SSH_SERVER_${upperName}_AUTO_REPLY_COUNT=${server.autoReplies.length}`);
server.autoReplies.forEach((reply, i) => {
lines.push(`SSH_SERVER_${upperName}_AUTO_REPLY_${i + 1}_PATTERN="${reply.pattern}"`);
lines.push(`SSH_SERVER_${upperName}_AUTO_REPLY_${i + 1}_RESPONSE="${reply.response}"`);
if (reply.timeout) lines.push(`SSH_SERVER_${upperName}_AUTO_REPLY_${i + 1}_TIMEOUT=${reply.timeout}`);
});
}
lines.push('');
}

Expand Down
6 changes: 5 additions & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1448,7 +1448,11 @@ registerToolConditional(
async ({ server: serverName, name }) => {
try {
const ssh = await getConnection(serverName);
const session = await createSession(serverName, ssh);
const serverConfig = servers[serverName.toLowerCase()] || {};
const session = await createSession(serverName, ssh, {
promptPattern: serverConfig.promptPattern,
autoReplies: serverConfig.autoReplies
});

const sessionName = name || `Session on ${serverName}`;

Expand Down
102 changes: 92 additions & 10 deletions src/session-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const SESSION_STATES = {
};

class SSHSession {
constructor(id, serverName, ssh) {
constructor(id, serverName, ssh, options = {}) {
this.id = id;
this.serverName = serverName;
this.ssh = ssh;
Expand All @@ -35,6 +35,44 @@ class SSHSession {
this.shell = null;
this.outputBuffer = '';
this.errorBuffer = '';

// Custom prompt pattern (configurable per server)
this.promptPattern = this._compilePromptPattern(options.promptPattern);

// Auto-replies for login prompts (e.g., "START GLOBUS Y/N" → "N")
this.autoReplies = (options.autoReplies || []).map(r => ({
pattern: new RegExp(this._escapeForRegex(r.pattern)),
response: r.response,
timeout: r.timeout || 5000,
matched: false
}));
}

/**
* Compile a prompt pattern string into a RegExp.
* If not provided, falls back to the default [$#>] pattern.
*/
_compilePromptPattern(pattern) {
if (!pattern) return /[$#>]\s*$/;
try {
// If the pattern looks like a regex (contains special chars), use as-is
// Otherwise, escape it and add end-of-line anchor
if (pattern.startsWith('/') && pattern.lastIndexOf('/') > 0) {
const lastSlash = pattern.lastIndexOf('/');
return new RegExp(pattern.slice(1, lastSlash), pattern.slice(lastSlash + 1));
}
return new RegExp(this._escapeForRegex(pattern) + '\\s*$');
} catch (e) {
logger.warn(`Invalid prompt pattern "${pattern}", using default`, { error: e.message });
return /[$#>]\s*$/;
}
}

/**
* Escape a string for use in a RegExp (literal matching).
*/
_escapeForRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/**
Expand Down Expand Up @@ -79,8 +117,8 @@ class SSHSession {
});
});

// Wait for shell prompt
await this.waitForPrompt();
// Wait for shell prompt, handling auto-replies during login
await this._initializeShell();

// Allow context queries through standard execute flow
this.state = SESSION_STATES.READY;
Expand All @@ -102,19 +140,63 @@ class SSHSession {
}
}

/**
* Handle shell initialization with auto-replies.
* Loops until the shell prompt is detected, sending auto-replies as needed.
*/
async _initializeShell() {
if (this.autoReplies.length === 0) {
// No auto-replies configured — simple wait
await this.waitForPrompt(15000);
return;
}

const startTime = Date.now();
const totalTimeout = this.autoReplies.reduce((sum, r) => sum + r.timeout, 0) + 15000;
let lastBufferLength = 0;

logger.info(`Session ${this.id}: waiting for shell with ${this.autoReplies.length} auto-reply rule(s)`);

while (Date.now() - startTime < totalTimeout) {
// Check if shell prompt is ready
if (this.outputBuffer.match(this.promptPattern)) {
logger.info(`Session ${this.id}: shell prompt detected`);
return;
}

// Check auto-reply patterns (only when new data arrived)
if (this.outputBuffer.length > lastBufferLength) {
lastBufferLength = this.outputBuffer.length;

for (const reply of this.autoReplies) {
if (reply.matched) continue;
if (reply.pattern.test(this.outputBuffer)) {
logger.info(`Session ${this.id}: auto-reply matched pattern "${reply.pattern}"`);
this.shell.write(reply.response + '\n');
reply.matched = true;
// Clear buffer after sending reply to avoid re-matching
await new Promise(resolve => setTimeout(resolve, 200));
break;
}
}
}

await new Promise(resolve => setTimeout(resolve, 100));
}

throw new Error(`Timeout waiting for shell prompt after ${Math.floor(totalTimeout / 1000)}s (auto-replies: ${this.autoReplies.filter(r => r.matched).length}/${this.autoReplies.length} matched)`);
}

/**
* Wait for shell prompt
*/
async waitForPrompt(timeout = 5000) {
const startTime = Date.now();

while (Date.now() - startTime < timeout) {
// Check if we have a prompt (ends with $ or # typically)
if (this.outputBuffer.match(/[$#>]\s*$/)) {
if (this.outputBuffer.match(this.promptPattern)) {
return true;
}

// Wait a bit
await new Promise(resolve => setTimeout(resolve, 100));
}

Expand Down Expand Up @@ -192,7 +274,7 @@ class SSHSession {

// Remove the prompt (last line)
const lastLine = lines[lines.length - 1];
if (lastLine.match(/[$#>]\s*$/)) {
if (lastLine && lastLine.match(this.promptPattern)) {
lines.pop();
}

Expand Down Expand Up @@ -287,10 +369,10 @@ class SSHSession {
/**
* Create a new SSH session
*/
export async function createSession(serverName, ssh) {
export async function createSession(serverName, ssh, options = {}) {
const sessionId = `ssh_${Date.now()}_${uuidv4().substring(0, 8)}`;

const session = new SSHSession(sessionId, serverName, ssh);
const session = new SSHSession(sessionId, serverName, ssh, options);
sessions.set(sessionId, session);

try {
Expand Down