diff --git a/frontend/src/css/forms.css b/frontend/src/css/forms.css
index b787e29..30bd814 100644
--- a/frontend/src/css/forms.css
+++ b/frontend/src/css/forms.css
@@ -571,3 +571,102 @@
padding: var(--space-1) var(--space-2);
height: 30px;
}
+
+/* -----------------------------------------------------------------------
+ Login Script Editor — expect-style send/wait steps
+ ----------------------------------------------------------------------- */
+.form-help {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ margin-bottom: var(--space-3);
+ line-height: 1.5;
+}
+
+.login-script-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+}
+
+.login-script-empty {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ font-style: italic;
+ padding: var(--space-2) 0;
+}
+
+.login-script-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ background: var(--bg-base);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-sm);
+ padding: var(--space-2);
+}
+
+.login-script-type {
+ width: 90px;
+ min-width: 90px;
+ font-size: var(--text-xs);
+ padding: var(--space-1) var(--space-2);
+ height: 30px;
+}
+
+.login-script-value {
+ flex: 1;
+ font-size: var(--text-xs);
+ padding: var(--space-1) var(--space-2);
+ height: 30px;
+}
+
+.login-script-timeout {
+ width: 60px;
+ min-width: 60px;
+ font-size: var(--text-xs);
+ padding: var(--space-1) var(--space-2);
+ height: 30px;
+}
+
+.login-script-timeout-label {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+ min-width: 10px;
+}
+
+.login-script-actions {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+}
+
+.login-script-btn {
+ background: var(--bg-elevated);
+ color: var(--text-secondary);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-xs);
+ width: 24px;
+ height: 24px;
+ font-size: 10px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s, color 0.15s;
+}
+
+.login-script-btn:hover {
+ background: var(--bg-surface);
+ color: var(--text-primary);
+}
+
+.login-script-btn-remove:hover {
+ background: var(--danger);
+ color: #fff;
+}
+
+.login-script-add-row {
+ display: flex;
+ gap: var(--space-2);
+}
diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js
index 172aa74..7b6bbb1 100644
--- a/frontend/src/js/profiles.js
+++ b/frontend/src/js/profiles.js
@@ -27,6 +27,7 @@ const DEFAULTS = {
portForwards: [],
startupCommand: '',
envVars: {},
+ loginScripts: [],
},
},
telnet: {
@@ -39,6 +40,7 @@ const DEFAULTS = {
encoding: 'utf-8',
negotiationDebug: false,
lineMode: false,
+ loginScripts: [],
},
},
raw: {
@@ -390,6 +392,9 @@ class ProfileEditor {
}),
]));
+ // Login Scripts (expect-style)
+ sections.push(this._renderLoginScriptSection('ssh', ssh.loginScripts || []));
+
// SSH Agent
sections.push(this._renderSection('SSH Agent', [
this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, {
@@ -451,6 +456,9 @@ class ProfileEditor {
this._field('toggle', 'telnet.negotiationDebug', 'Debug Negotiation', tel.negotiationDebug),
], true));
+ // Login Scripts (expect-style)
+ sections.push(this._renderLoginScriptSection('telnet', tel.loginScripts || []));
+
sections.push(this._renderSection('Reconnection', [
this._field('toggle', 'telnet.autoReconnect', 'Auto-Reconnect', tel.autoReconnect, {
help: 'Automatically reconnect with exponential backoff when the connection drops',
@@ -571,6 +579,175 @@ class ProfileEditor {
* @returns {HTMLElement}
* @private
*/
+ /**
+ * Renders the login script section (expect-style send/wait steps).
+ * @param {string} prefix - 'ssh' or 'telnet'
+ * @param {Array} steps - Array of {type, value, timeout}
+ * @returns {HTMLElement}
+ * @private
+ */
+ _renderLoginScriptSection(prefix, steps) {
+ const section = createElement('div', 'form-section');
+ const header = createElement('div', 'form-section-header');
+ let collapsed = true;
+
+ header.innerHTML = `
+ ▸
+ Login Script
+ `;
+
+ const content = createElement('div', 'form-section-content');
+ content.style.display = 'none';
+
+ const helpText = createElement('div', 'form-help', {
+ textContent: 'Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection.',
+ });
+ content.appendChild(helpText);
+
+ // Hidden input to hold JSON
+ const hiddenInput = createElement('input', '', {
+ type: 'hidden',
+ id: `field-${prefix}.loginScripts`,
+ dataset: { field: `${prefix}.loginScripts` },
+ });
+ content.appendChild(hiddenInput);
+
+ // List container
+ const listContainer = createElement('div', 'login-script-list');
+ content.appendChild(listContainer);
+
+ // Working copy
+ let currentSteps = JSON.parse(JSON.stringify(steps || []));
+
+ const renderSteps = () => {
+ listContainer.innerHTML = '';
+ hiddenInput.value = JSON.stringify(currentSteps);
+
+ if (currentSteps.length === 0) {
+ const empty = createElement('div', 'login-script-empty', {
+ textContent: 'No login script steps configured.',
+ });
+ listContainer.appendChild(empty);
+ }
+
+ currentSteps.forEach((step, idx) => {
+ const row = createElement('div', 'login-script-row');
+
+ const typeSelect = createElement('select', 'form-input login-script-type');
+ ['wait', 'send'].forEach(t => {
+ const opt = createElement('option', '', { value: t, textContent: t === 'wait' ? 'Wait for' : 'Send' });
+ if (t === step.type) opt.selected = true;
+ typeSelect.appendChild(opt);
+ });
+ typeSelect.addEventListener('change', () => {
+ currentSteps[idx].type = typeSelect.value;
+ hiddenInput.value = JSON.stringify(currentSteps);
+ renderSteps();
+ });
+
+ const valueInput = createElement('input', 'form-input login-script-value', {
+ type: 'text',
+ value: step.value || '',
+ placeholder: step.type === 'wait' ? 'Text to match...' : 'Text to send...',
+ autocapitalize: 'off',
+ autocorrect: 'off',
+ spellcheck: false,
+ });
+ valueInput.addEventListener('input', () => {
+ currentSteps[idx].value = valueInput.value;
+ hiddenInput.value = JSON.stringify(currentSteps);
+ });
+
+ row.appendChild(typeSelect);
+ row.appendChild(valueInput);
+
+ // Timeout field for wait steps
+ if (step.type === 'wait') {
+ const timeoutInput = createElement('input', 'form-input login-script-timeout', {
+ type: 'number',
+ value: step.timeout || 10,
+ min: 1,
+ max: 120,
+ title: 'Timeout (seconds)',
+ });
+ timeoutInput.style.width = '60px';
+ timeoutInput.addEventListener('input', () => {
+ currentSteps[idx].timeout = parseInt(timeoutInput.value, 10) || 10;
+ hiddenInput.value = JSON.stringify(currentSteps);
+ });
+ const timeoutLabel = createElement('span', 'login-script-timeout-label', { textContent: 's' });
+ row.appendChild(timeoutInput);
+ row.appendChild(timeoutLabel);
+ }
+
+ // Move up/down/remove buttons
+ const actions = createElement('div', 'login-script-actions');
+
+ if (idx > 0) {
+ const upBtn = createElement('button', 'login-script-btn', { textContent: '▲', title: 'Move up', type: 'button' });
+ upBtn.addEventListener('mousedown', e => e.preventDefault());
+ upBtn.addEventListener('click', () => {
+ [currentSteps[idx - 1], currentSteps[idx]] = [currentSteps[idx], currentSteps[idx - 1]];
+ renderSteps();
+ });
+ actions.appendChild(upBtn);
+ }
+
+ if (idx < currentSteps.length - 1) {
+ const downBtn = createElement('button', 'login-script-btn', { textContent: '▼', title: 'Move down', type: 'button' });
+ downBtn.addEventListener('mousedown', e => e.preventDefault());
+ downBtn.addEventListener('click', () => {
+ [currentSteps[idx], currentSteps[idx + 1]] = [currentSteps[idx + 1], currentSteps[idx]];
+ renderSteps();
+ });
+ actions.appendChild(downBtn);
+ }
+
+ const removeBtn = createElement('button', 'login-script-btn login-script-btn-remove', { textContent: '✕', title: 'Remove', type: 'button' });
+ removeBtn.addEventListener('mousedown', e => e.preventDefault());
+ removeBtn.addEventListener('click', () => {
+ currentSteps.splice(idx, 1);
+ renderSteps();
+ });
+ actions.appendChild(removeBtn);
+
+ row.appendChild(actions);
+ listContainer.appendChild(row);
+ });
+ };
+
+ renderSteps();
+
+ // Add step buttons
+ const addRow = createElement('div', 'login-script-add-row');
+ const addWaitBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Wait', type: 'button' });
+ addWaitBtn.addEventListener('mousedown', e => e.preventDefault());
+ addWaitBtn.addEventListener('click', () => {
+ currentSteps.push({ type: 'wait', value: '', timeout: 10 });
+ renderSteps();
+ });
+ const addSendBtn = createElement('button', 'btn btn-small btn-secondary', { textContent: '+ Send', type: 'button' });
+ addSendBtn.addEventListener('mousedown', e => e.preventDefault());
+ addSendBtn.addEventListener('click', () => {
+ currentSteps.push({ type: 'send', value: '', timeout: 0 });
+ renderSteps();
+ });
+ addRow.appendChild(addWaitBtn);
+ addRow.appendChild(addSendBtn);
+ content.appendChild(addRow);
+
+ header.addEventListener('mousedown', (e) => e.preventDefault());
+ header.addEventListener('click', () => {
+ collapsed = !collapsed;
+ content.style.display = collapsed ? 'none' : 'block';
+ header.querySelector('.form-section-chevron').textContent = collapsed ? '▸' : '▾';
+ });
+
+ section.appendChild(header);
+ section.appendChild(content);
+ return section;
+ }
+
_renderSection(title, fields, startCollapsed = false) {
const section = createElement('div', 'form-section');
const header = createElement('div', 'form-section-header');
@@ -973,6 +1150,18 @@ class ProfileEditor {
fi++;
}
p.ssh.portForwards = pfs;
+
+ // Login scripts
+ const loginScriptsRaw = val('ssh.loginScripts');
+ if (loginScriptsRaw) {
+ try {
+ p.ssh.loginScripts = JSON.parse(loginScriptsRaw);
+ } catch (_) {
+ p.ssh.loginScripts = [];
+ }
+ } else {
+ p.ssh.loginScripts = [];
+ }
}
// Telnet-specific
@@ -985,6 +1174,18 @@ class ProfileEditor {
p.telnet.negotiationDebug = val('telnet.negotiationDebug') || false;
p.telnet.autoReconnect = val('telnet.autoReconnect') || false;
p.telnet.maxReconnectTries = val('telnet.maxReconnectTries') ?? 5;
+
+ // Login scripts
+ const telLoginScriptsRaw = val('telnet.loginScripts');
+ if (telLoginScriptsRaw) {
+ try {
+ p.telnet.loginScripts = JSON.parse(telLoginScriptsRaw);
+ } catch (_) {
+ p.telnet.loginScripts = [];
+ }
+ } else {
+ p.telnet.loginScripts = [];
+ }
}
// RAW-specific
diff --git a/internal/models/models.go b/internal/models/models.go
index c1d2ff1..9922fdf 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -97,12 +97,20 @@ type SSHConfig struct {
AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host
AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
+ LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
JumpHosts []JumpHost `json:"jumpHosts"`
PortForwards []PortForward `json:"portForwards"`
StartupCommand string `json:"startupCommand"`
EnvVars map[string]string `json:"envVars"`
}
+// LoginScriptStep represents a single step in an expect-style auto-login sequence.
+type LoginScriptStep struct {
+ Type string `json:"type"` // "wait" or "send"
+ Value string `json:"value"` // text to wait for / text to send
+ Timeout int `json:"timeout"` // max seconds to wait (for "wait" steps; 0 = use default 10s)
+}
+
// JumpHost describes a proxy/bastion host used for SSH multi-hop connections.
type JumpHost struct {
Host string `json:"host"`
@@ -135,14 +143,15 @@ type PortForward struct {
// TelnetConfig holds Telnet-specific settings for a connection profile.
type TelnetConfig struct {
- LocalEcho bool `json:"localEcho"`
- LineEnding LineEnding `json:"lineEnding"`
- TerminalType string `json:"terminalType"`
- Encoding string `json:"encoding"`
- NegotiationDebug bool `json:"negotiationDebug"`
- LineMode bool `json:"lineMode"`
- AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
- MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
+ LocalEcho bool `json:"localEcho"`
+ LineEnding LineEnding `json:"lineEnding"`
+ TerminalType string `json:"terminalType"`
+ Encoding string `json:"encoding"`
+ NegotiationDebug bool `json:"negotiationDebug"`
+ LineMode bool `json:"lineMode"`
+ AutoReconnect bool `json:"autoReconnect"` // reconnect on unexpected disconnect
+ MaxReconnectTries int `json:"maxReconnectTries"` // 0 = use default (5)
+ LoginScripts []LoginScriptStep `json:"loginScripts"` // expect-style auto-login sequence
}
// RAWConfig holds RAW TCP-specific settings for a connection profile.
diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go
index 0c1020c..ba4b4d3 100644
--- a/internal/sessions/manager.go
+++ b/internal/sessions/manager.go
@@ -7,6 +7,7 @@ import (
"context"
"encoding/base64"
"fmt"
+ "strings"
"sync"
"time"
@@ -38,6 +39,11 @@ type Session struct {
recorder *recording.Recorder // active recording, nil if not recording
reconnectCount int // number of reconnection attempts so far
cancelReconn context.CancelFunc // cancels an in-progress reconnect backoff
+
+ // Login script support — temporary buffer for expect-style matching.
+ loginBuf []byte
+ loginBufMu sync.Mutex
+ loginDone chan struct{} // closed when login script completes or is abandoned
}
// Manager handles the lifecycle of all active sessions.
@@ -463,6 +469,20 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
if rec != nil {
rec.WriteOutput(data)
}
+ // Feed data to the login script buffer if active.
+ sess.loginBufMu.Lock()
+ if sess.loginDone != nil {
+ select {
+ case <-sess.loginDone:
+ // Login script finished — stop buffering.
+ default:
+ sess.loginBuf = append(sess.loginBuf, data...)
+ if len(sess.loginBuf) > 16384 {
+ sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:]
+ }
+ }
+ }
+ sess.loginBufMu.Unlock()
},
// onError — report connection errors to the frontend.
func(err error) {
@@ -520,6 +540,11 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
}
}
+ // Execute login scripts if configured.
+ if sshConf != nil && len(sshConf.LoginScripts) > 0 {
+ go m.executeLoginScripts(sess, sshConf.LoginScripts)
+ }
+
return nil
}
@@ -542,6 +567,19 @@ func (m *Manager) connectTelnet(sess *Session) error {
if rec != nil {
rec.WriteOutput(data)
}
+ // Feed data to the login script buffer if active.
+ sess.loginBufMu.Lock()
+ if sess.loginDone != nil {
+ select {
+ case <-sess.loginDone:
+ default:
+ sess.loginBuf = append(sess.loginBuf, data...)
+ if len(sess.loginBuf) > 16384 {
+ sess.loginBuf = sess.loginBuf[len(sess.loginBuf)-16384:]
+ }
+ }
+ }
+ sess.loginBufMu.Unlock()
},
// onError
func(err error) {
@@ -574,6 +612,13 @@ func (m *Manager) connectTelnet(sess *Session) error {
}
sess.telnetClient = client
+
+ // Execute login scripts if configured.
+ telConf := profile.Telnet
+ if telConf != nil && len(telConf.LoginScripts) > 0 {
+ go m.executeLoginScripts(sess, telConf.LoginScripts)
+ }
+
return nil
}
@@ -1157,6 +1202,103 @@ func (m *Manager) closeSession(sess *Session) {
m.mu.Unlock()
}
+// ---------------------------------------------------------------------------
+// Login script execution (expect-style send/wait)
+// ---------------------------------------------------------------------------
+
+// executeLoginScripts processes an expect-style login script sequence against
+// a connected session. "wait" steps block until the specified text appears in
+// the session output (with a timeout). "send" steps write text to the session
+// input. The session's loginBuf/loginDone fields are used for pattern matching.
+func (m *Manager) executeLoginScripts(sess *Session, steps []models.LoginScriptStep) {
+ // Initialise the login buffer so the onData hook starts accumulating.
+ sess.loginBufMu.Lock()
+ sess.loginBuf = nil
+ sess.loginDone = make(chan struct{})
+ sess.loginBufMu.Unlock()
+
+ defer func() {
+ sess.loginBufMu.Lock()
+ close(sess.loginDone)
+ sess.loginBuf = nil
+ sess.loginBufMu.Unlock()
+ }()
+
+ // Short delay to allow the shell to start and produce its initial prompt.
+ time.Sleep(500 * time.Millisecond)
+
+ for i, step := range steps {
+ // Check session is still alive.
+ m.mu.RLock()
+ state := sess.Info.State
+ m.mu.RUnlock()
+ if state != models.SessionConnected {
+ return
+ }
+
+ switch step.Type {
+ case "wait":
+ timeout := time.Duration(step.Timeout) * time.Second
+ if timeout <= 0 {
+ timeout = 10 * time.Second
+ }
+ if err := m.waitForOutput(sess, step.Value, timeout); err != nil {
+ m.emitEvent("session:debug", map[string]interface{}{
+ "sessionID": sess.Info.ID,
+ "message": fmt.Sprintf("login script step %d timed out waiting for %q", i+1, step.Value),
+ })
+ return
+ }
+
+ case "send":
+ data := []byte(step.Value + "\n")
+ if err := m.WriteToSession(sess.Info.ID, data); err != nil {
+ m.emitEvent("session:debug", map[string]interface{}{
+ "sessionID": sess.Info.ID,
+ "message": fmt.Sprintf("login script step %d failed to send: %v", i+1, err),
+ })
+ return
+ }
+ // Small inter-step delay for the remote to process.
+ time.Sleep(200 * time.Millisecond)
+
+ default:
+ // Unknown step type — skip.
+ }
+ }
+}
+
+// waitForOutput polls the session's login buffer until the specified text
+// appears or the timeout expires.
+func (m *Manager) waitForOutput(sess *Session, pattern string, timeout time.Duration) error {
+ if pattern == "" {
+ return nil
+ }
+
+ deadline := time.After(timeout)
+ ticker := time.NewTicker(100 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-deadline:
+ return fmt.Errorf("timeout waiting for %q", pattern)
+ case <-ticker.C:
+ sess.loginBufMu.Lock()
+ found := strings.Contains(string(sess.loginBuf), pattern)
+ if found {
+ // Clear the buffer up to the match so subsequent waits start fresh.
+ idx := strings.Index(string(sess.loginBuf), pattern)
+ sess.loginBuf = sess.loginBuf[idx+len(pattern):]
+ }
+ sess.loginBufMu.Unlock()
+ if found {
+ return nil
+ }
+ }
+ }
+}
+
// handleSessionClosed is called when a protocol client reports closure.
func (m *Manager) handleSessionClosed(sessionID string) {
m.mu.Lock()