fix: support Windows SSH remote instances

This commit is contained in:
Bohdan Triapitsyn
2026-07-22 17:48:12 +03:00
parent 32df7e1997
commit f26557701c
4 changed files with 509 additions and 128 deletions
+1
View File
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
- Desktop/Windows: SSH remote instances now connect through native Windows OpenSSH without relying on unsupported connection sharing. Password authentication and port forwarding work through hidden background processes, and connection failures now show the underlying SSH error instead of a generic message.
- **Chat attachments:** added Office and OpenDocument files (`.docx`, `.pptx`, `.xlsx`, `.odt`, `.odp`, and `.ods`), with readable text and supported embedded images extracted before sending. Attachments also support more source-code formats, notebooks, HAR files with credentials and cookies removed, SVG and Draw.io files, and HEIC/HEIF images; the composer warns when the selected model may ignore an attachment type.
- **Performance:** opening and switching sessions now prioritizes the selected and visible chats in large workspaces. Failed refreshes keep the existing session list, parent sessions no longer disappear when their sub-sessions load first, and session data no longer crosses between instances, projects, or worktrees.
- **Sessions/Worktrees**: idle root sessions can now be moved with their sub-sessions and uncommitted changes into a new worktree. Worktree creation also recovers when an earlier Git operation left the repository locked.
+1
View File
@@ -132,6 +132,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
- Desktop host switcher and deep-link imports.
- Local and remote instance handling.
- SSH host import, connections, logs, and port forwarding.
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
- Tunnel lifecycle integration through the web server runtime.
- Auto-update checks, downloads, and restart/apply flow.
+285 -128
View File
@@ -17,7 +17,9 @@ const MONITOR_INITIAL_POLL_MS = 2000;
const MONITOR_STEADY_POLL_MS = 10000;
const MONITOR_STABILIZE_TICKS = 5;
const SSH_STATUS_EVENT = 'openchamber:ssh-instance-status';
const WINDOWS_HIDDEN_SPAWN_OPTIONS = process.platform === 'win32' ? { windowsHide: true } : {};
const MAX_PROCESS_ERROR_CHARS = 2000;
const MAX_PROCESS_ERROR_CAPTURE_CHARS = MAX_PROCESS_ERROR_CHARS * 2;
const childProcessDiagnostics = new WeakMap();
const nowMillis = () => Date.now();
@@ -218,71 +220,12 @@ const parseSshCommand = (raw) => {
return { destination, args };
};
const runOutput = async (command, args, options = {}) => {
return await new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ['pipe', 'pipe', 'pipe'],
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
...options,
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr });
});
});
};
const buildSshArgs = (parsed, preDestinationArgs = [], remoteCommand = null) => {
const args = [...parsed.args, ...preDestinationArgs, parsed.destination];
if (remoteCommand) args.push(remoteCommand);
return args;
};
const runRemoteCommand = async (parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) => {
const args = buildSshArgs(parsed, [
'-o', 'ControlMaster=no',
'-o', `ControlPath=${controlPath}`,
'-o', `ConnectTimeout=${timeoutSec}`,
'-T',
], `sh -lc ${shellQuote(script)}`);
const { code, stdout, stderr } = await runOutput('ssh', args);
if (code !== 0) {
throw new Error((stderr || stdout || 'Remote command failed').trim());
}
return stdout;
};
const controlMasterOperation = async (parsed, controlPath, op) => {
return await runOutput('ssh', buildSshArgs(parsed, [
'-o', 'ControlMaster=no',
'-o', `ControlPath=${controlPath}`,
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=3',
'-O', op,
]));
};
const isControlMasterAlive = async (parsed, controlPath) => {
const { code } = await controlMasterOperation(parsed, controlPath, 'check');
return code === 0;
};
const stopControlMasterBestEffort = async (parsed, controlPath) => {
try {
await controlMasterOperation(parsed, controlPath, 'exit');
} catch {
}
};
const askpassScriptContent = () => `#!/bin/bash
PROMPT="$1"
@@ -331,6 +274,27 @@ const writeAskpassScript = async (scriptPath) => {
await fsp.chmod(scriptPath, 0o700);
};
const windowsAskpassScriptContent = () => `$value = [Environment]::GetEnvironmentVariable('OPENCHAMBER_SSH_ASKPASS_VALUE')
if ($null -ne $value) {
[Console]::Out.WriteLine($value)
}
`;
const windowsAskpassWrapperContent = () => `@echo off\r
"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0askpass.ps1"\r
`;
const sanitizeProcessDiagnostic = (value, secret = '') => {
let sanitized = String(value || '')
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
.trim();
if (secret) sanitized = sanitized.split(secret).join('[redacted]');
if (sanitized.length > MAX_PROCESS_ERROR_CHARS) {
sanitized = `${sanitized.slice(0, MAX_PROCESS_ERROR_CHARS - 3)}...`;
}
return sanitized;
};
const randomPortCandidate = (seed) => {
let hash = 0;
const source = `${seed}:${Date.now()}`;
@@ -420,6 +384,8 @@ export class ElectronSshManager {
this.settingsFilePath = options.settingsFilePath;
this.appVersion = options.appVersion;
this.emit = options.emit;
this.platform = options.platform || process.platform;
this.spawnProcess = options.spawn || spawn;
this.logs = new Map();
this.statuses = new Map();
this.sessions = new Map();
@@ -427,6 +393,162 @@ export class ElectronSshManager {
this.reconnectAttempts = new Map();
this.connectAttempts = new Map();
this.connecting = new Map();
this.sshAuth = new WeakMap();
}
usesControlMaster() {
return this.platform !== 'win32';
}
hiddenSpawnOptions() {
return this.platform === 'win32' ? { windowsHide: true } : {};
}
authEnvironment(parsed) {
const auth = this.sshAuth.get(parsed);
if (!auth) return process.env;
return {
...process.env,
SSH_ASKPASS_REQUIRE: 'force',
SSH_ASKPASS: auth.askpassPath,
DISPLAY: '1',
...(auth.sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: auth.sshPassword.trim() } : {}),
};
}
independentConnectionArgs() {
return [
'-o', 'ControlMaster=no',
'-o', 'ControlPath=none',
'-o', 'StrictHostKeyChecking=accept-new',
];
}
trackSshProcess(child, parsed) {
const diagnostics = { stderr: '', error: null, parsed };
childProcessDiagnostics.set(child, diagnostics);
const auth = this.sshAuth.get(parsed);
auth?.children.add(child);
child.stderr?.on('data', (chunk) => {
diagnostics.stderr = `${diagnostics.stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS);
});
child.on('error', (error) => {
diagnostics.error = error;
});
child.on('close', () => {
auth?.children.delete(child);
});
return child;
}
processErrorDetail(child, fallback) {
const diagnostics = childProcessDiagnostics.get(child);
const auth = diagnostics ? this.sshAuth.get(diagnostics.parsed) : null;
const detail = sanitizeProcessDiagnostic(
diagnostics?.error instanceof Error ? diagnostics.error.message : diagnostics?.stderr,
auth?.sshPassword,
);
return detail || fallback;
}
spawnSsh(parsed, preDestinationArgs, options, remoteCommand = null) {
const child = this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), {
...options,
...this.hiddenSpawnOptions(),
env: this.authEnvironment(parsed),
});
return this.trackSshProcess(child, parsed);
}
async runSshOutput(parsed, preDestinationArgs, remoteCommand = null) {
return await new Promise((resolve, reject) => {
const child = this.trackSshProcess(this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), {
stdio: ['pipe', 'pipe', 'pipe'],
...this.hiddenSpawnOptions(),
env: this.authEnvironment(parsed),
}), parsed);
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk) => {
stderr = `${stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS);
});
child.on('error', () => {
reject(new Error(this.processErrorDetail(child, 'Failed to start SSH process')));
});
child.on('close', (code) => {
const auth = this.sshAuth.get(parsed);
resolve({
code: typeof code === 'number' ? code : -1,
stdout,
stderr: sanitizeProcessDiagnostic(stderr, auth?.sshPassword),
});
});
});
}
async runRemoteCommand(parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) {
const connectionArgs = this.usesControlMaster()
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`]
: this.independentConnectionArgs();
const { code, stdout, stderr } = await this.runSshOutput(parsed, [
...connectionArgs,
'-o', `ConnectTimeout=${timeoutSec}`,
'-T',
], `sh -lc ${shellQuote(script)}`);
if (code !== 0) {
const auth = this.sshAuth.get(parsed);
throw new Error(sanitizeProcessDiagnostic(stderr || stdout, auth?.sshPassword) || 'Remote command failed');
}
return stdout;
}
async controlMasterOperation(parsed, controlPath, op) {
return await this.runSshOutput(parsed, [
'-o', 'ControlMaster=no',
'-o', `ControlPath=${controlPath}`,
'-o', 'BatchMode=yes',
'-o', 'ConnectTimeout=3',
'-O', op,
]);
}
async isControlMasterAlive(parsed, controlPath) {
const { code } = await this.controlMasterOperation(parsed, controlPath, 'check');
return code === 0;
}
async stopControlMasterBestEffort(parsed, controlPath) {
if (!this.usesControlMaster()) return;
try {
await this.controlMasterOperation(parsed, controlPath, 'exit');
} catch {
}
}
async writeAskpassFiles(sessionDir) {
if (this.platform === 'win32') {
const scriptPath = path.join(sessionDir, 'askpass.ps1');
const wrapperPath = path.join(sessionDir, 'askpass.cmd');
try {
await fsp.writeFile(scriptPath, windowsAskpassScriptContent());
await fsp.writeFile(wrapperPath, windowsAskpassWrapperContent());
} catch (error) {
await Promise.allSettled([
fsp.rm(scriptPath, { force: true }),
fsp.rm(wrapperPath, { force: true }),
]);
throw error;
}
return { askpassPath: wrapperPath, cleanupPaths: [wrapperPath, scriptPath] };
}
const askpassPath = path.join(sessionDir, 'askpass.sh');
await writeAskpassScript(askpassPath);
return { askpassPath, cleanupPaths: [askpassPath] };
}
appendLogWithLevel(id, level, message) {
@@ -791,7 +913,7 @@ export class ElectronSshManager {
}
async resolveSshConfig(parsed) {
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, ['-G']));
const { code, stdout, stderr } = await this.runSshOutput(parsed, ['-G']);
if (code !== 0) {
throw new Error(stderr.trim() || 'Failed to resolve SSH config');
}
@@ -820,41 +942,34 @@ export class ElectronSshManager {
return path.join(os.tmpdir(), `ocssh-${Math.abs(hash).toString(16)}.sock`);
}
async spawnMasterProcess(parsed, controlPath, askpassPath, sshPassword) {
const child = spawn('ssh', buildSshArgs(parsed, [
async spawnMasterProcess(parsed, controlPath) {
return this.spawnSsh(parsed, [
'-o', 'ControlMaster=yes',
'-o', `ControlPath=${controlPath}`,
'-o', `ControlPersist=${DEFAULT_CONTROL_PERSIST_SEC}`,
'-N',
]), {
], {
stdio: ['ignore', 'pipe', 'pipe'],
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
env: {
...process.env,
SSH_ASKPASS_REQUIRE: 'force',
SSH_ASKPASS: askpassPath,
DISPLAY: '1',
...(sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: sshPassword.trim() } : {}),
},
});
return child;
}
async waitForMasterReady(parsed, controlPath, timeoutSec, master) {
const deadline = Date.now() + (timeoutSec * 1000);
let pollMs = 250;
while (Date.now() < deadline) {
const { code } = await runOutput('ssh', buildSshArgs(parsed, [
const { code } = await this.runSshOutput(parsed, [
'-o', 'ControlMaster=no',
'-o', `ControlPath=${controlPath}`,
'-O', 'check',
]));
]);
if (code === 0) return;
const exited = master.exitCode;
if (typeof exited === 'number') {
throw new Error('SSH master process exited before ready');
throw new Error(this.processErrorDetail(master, 'SSH master process exited before ready'));
}
const spawnError = childProcessDiagnostics.get(master)?.error;
if (spawnError) throw new Error(this.processErrorDetail(master, 'Failed to start SSH master process'));
await new Promise((resolve) => setTimeout(resolve, pollMs));
pollMs = Math.min(pollMs * 2, 2000);
}
@@ -868,7 +983,7 @@ export class ElectronSshManager {
async remoteCommandExists(parsed, controlPath, commandName) {
try {
const output = await runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
return output.trim() === 'yes';
} catch {
return false;
@@ -877,7 +992,7 @@ export class ElectronSshManager {
async currentRemoteOpenChamberVersion(parsed, controlPath) {
try {
const output = await runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
return parseVersionToken(output);
} catch {
return null;
@@ -907,7 +1022,7 @@ export class ElectronSshManager {
let lastError = null;
for (const command of commands) {
try {
await runRemoteCommand(parsed, controlPath, command);
await this.runRemoteCommand(parsed, controlPath, command);
return;
} catch (error) {
lastError = error;
@@ -920,7 +1035,7 @@ export class ElectronSshManager {
const authPayload = openchamberPassword ? JSON.stringify({ password: openchamberPassword }) : '{}';
const authEnabled = openchamberPassword ? '1' : '0';
const script = `AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE="$(mktemp)"; COOKIE_FILE="$(mktemp)"; cleanup(){ rm -f "$BODY_FILE" "$COOKIE_FILE"; }; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ "${authEnabled}" = "1" ]; then AUTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' -c "$COOKIE_FILE" -H 'content-type: application/json' --data ${shellQuote(authPayload)} http://127.0.0.1:${port}/auth/session || true)"; if [ "$AUTH_STATUS" = "200" ]; then INFO_STATUS="$(curl -sS --max-time 3 -b "$COOKIE_FILE" -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; HEALTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/health || true)"; elif command -v wget >/dev/null 2>&1; then wget -qO "$BODY_FILE" http://127.0.0.1:${port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:${port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' "$INFO_STATUS" "$AUTH_STATUS" "$HEALTH_STATUS"; cat "$BODY_FILE" 2>/dev/null || true`;
const output = await runRemoteCommand(parsed, controlPath, script);
const output = await this.runRemoteCommand(parsed, controlPath, script);
const lines = output.split(/\r?\n/);
const infoStatus = parseProbeStatusLine(lines[0], 'INFO_STATUS=') || 0;
const authStatus = parseProbeStatusLine(lines[1], 'AUTH_STATUS=') || 0;
@@ -963,14 +1078,14 @@ export class ElectronSshManager {
if (secret) {
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
}
const output = await runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value));
return port || desiredPort;
}
async stopRemoteServerBestEffort(parsed, controlPath, remotePort) {
try {
await runRemoteCommand(
await this.runRemoteCommand(
parsed,
controlPath,
`if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`,
@@ -980,23 +1095,23 @@ export class ElectronSshManager {
}
async spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort) {
return spawn('ssh', buildSshArgs(parsed, [
'-o', 'ControlMaster=no',
'-o', `ControlPath=${controlPath}`,
const connectionArgs = this.usesControlMaster()
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`]
: this.independentConnectionArgs();
return this.spawnSsh(parsed, [
...connectionArgs,
'-o', 'ExitOnForwardFailure=yes',
'-N',
'-L', `${bindHost}:${localPort}:127.0.0.1:${remotePort}`,
]), {
], {
stdio: ['ignore', 'ignore', 'pipe'],
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
});
}
async spawnExtraForward(parsed, controlPath, forward) {
const args = [
'-o', 'ControlMaster=no',
'-o', `ControlPath=${controlPath}`,
'-O', 'forward',
];
const args = this.usesControlMaster()
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`, '-O', 'forward']
: [...this.independentConnectionArgs(), '-o', 'ExitOnForwardFailure=yes', '-N'];
if (forward.type === 'local') {
args.push('-L', `${forward.localHost || '127.0.0.1'}:${forward.localPort}:${forward.remoteHost || '127.0.0.1'}:${forward.remotePort}`);
} else if (forward.type === 'remote') {
@@ -1004,10 +1119,20 @@ export class ElectronSshManager {
} else {
args.push('-D', `${forward.localHost || '127.0.0.1'}:${forward.localPort}`);
}
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, args));
if (!this.usesControlMaster()) {
const child = this.spawnSsh(parsed, args, { stdio: ['ignore', 'ignore', 'pipe'] });
await new Promise((resolve) => setTimeout(resolve, 250));
if (typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error) {
throw new Error(this.processErrorDetail(child, `Failed to configure extra SSH forward ${forward.id}`));
}
return child;
}
const { code, stdout, stderr } = await this.runSshOutput(parsed, args);
if (code !== 0) {
throw new Error((stderr || stdout || `Failed to configure extra SSH forward ${forward.id}`).trim());
}
return null;
}
async ensureRemoteServer(instance, parsed, controlPath) {
@@ -1060,11 +1185,18 @@ export class ElectronSshManager {
this.sessions.delete(id);
if (session) {
if (session.startedByUs && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
}
await stopControlMasterBestEffort(session.parsed, session.controlPath);
for (const child of [session.mainForward, session.master]) {
await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
const auth = this.sshAuth.get(session.parsed);
const children = new Set([
session.mainForward,
session.master,
...session.extraForwards.map((entry) => entry.child),
...(auth?.children || []),
].filter(Boolean));
for (const child of children) {
try {
child.kill('SIGTERM');
} catch {
@@ -1074,10 +1206,13 @@ export class ElectronSshManager {
await fsp.rm(session.controlPath, { force: true });
} catch {
}
try {
await fsp.rm(path.join(session.sessionDir, 'askpass.sh'), { force: true });
} catch {
for (const askpassFilePath of session.askpassCleanupPaths) {
try {
await fsp.rm(askpassFilePath, { force: true });
} catch {
}
}
this.sshAuth.delete(session.parsed);
}
this.clearRetryAttempt(id);
@@ -1096,22 +1231,40 @@ export class ElectronSshManager {
const sessionDir = this.ensureSessionDir(id);
const controlPath = this.controlPathForInstance(id);
try { await fsp.rm(controlPath, { force: true }); } catch {}
const askpassPath = path.join(sessionDir, 'askpass.sh');
await writeAskpassScript(askpassPath);
const { askpassPath, cleanupPaths: askpassCleanupPaths } = await this.writeAskpassFiles(sessionDir);
const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value?.trim() : null;
this.sshAuth.set(parsed, { askpassPath, sshPassword, children: new Set() });
const session = {
instance,
parsed,
sessionDir,
controlPath,
askpassCleanupPaths,
localPort: null,
remotePort: null,
startedByUs: false,
master: null,
mainForward: null,
mainForwardDetached: false,
extraForwards: [],
};
this.sessions.set(id, session);
this.setStatus(id, 'master_connecting', 'Establishing SSH ControlMaster');
const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value : null;
const master = await this.spawnMasterProcess(parsed, controlPath, askpassPath, sshPassword);
await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, master);
this.setStatus(id, 'master_connecting', this.usesControlMaster() ? 'Establishing SSH ControlMaster' : 'Checking SSH connectivity');
if (this.usesControlMaster()) {
session.master = await this.spawnMasterProcess(parsed, controlPath);
await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, session.master);
}
this.setStatus(id, 'remote_probe', 'Probing remote platform');
const remoteOs = (await runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
const remoteOs = (await this.runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
if (!['linux', 'darwin'].includes(remoteOs)) {
master.kill('SIGTERM');
throw new Error(`Unsupported remote OS: ${remoteOs}`);
}
const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath);
session.remotePort = remotePort;
session.startedByUs = startedByUs;
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
@@ -1124,22 +1277,24 @@ export class ElectronSshManager {
}
const mainForward = await this.spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort);
session.mainForward = mainForward;
let mainForwardDetached = false;
await new Promise((resolve) => setTimeout(resolve, 250));
if (typeof mainForward.exitCode === 'number') {
if (mainForward.exitCode === 0) {
if (typeof mainForward.exitCode === 'number' || childProcessDiagnostics.get(mainForward)?.error) {
if (this.usesControlMaster() && mainForward.exitCode === 0) {
mainForwardDetached = true;
this.appendLogWithLevel(id, 'INFO', 'Main tunnel helper exited after ControlMaster handoff');
} else {
master.kill('SIGTERM');
throw new Error(`Failed to start main port forward (status: ${mainForward.exitCode})`);
throw new Error(this.processErrorDetail(mainForward, `Failed to start main port forward (status: ${mainForward.exitCode ?? 'spawn error'})`));
}
}
session.mainForwardDetached = mainForwardDetached;
const extraErrors = [];
for (const forward of instance.portForwards.filter((item) => item.enabled)) {
try {
await this.spawnExtraForward(parsed, controlPath, forward);
const extraForward = await this.spawnExtraForward(parsed, controlPath, forward);
if (extraForward) session.extraForwards.push({ id: forward.id, child: extraForward });
if (forward.type === 'local' && forward.localPort) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!(await isLocalTunnelReachable(forward.localPort))) {
@@ -1161,19 +1316,7 @@ export class ElectronSshManager {
await this.persistLocalPort(id, localPort);
}
this.sessions.set(id, {
instance,
parsed,
sessionDir,
controlPath,
localPort,
remotePort,
startedByUs,
master,
masterDetached: false,
mainForward,
mainForwardDetached,
});
session.localPort = localPort;
this.clearRetryAttempt(id);
this.setStatus(
@@ -1206,12 +1349,26 @@ export class ElectronSshManager {
if (!session.mainForwardDetached) {
if (typeof session.mainForward.exitCode === 'number') {
if (session.mainForward.exitCode === 0) {
if (this.usesControlMaster() && session.mainForward.exitCode === 0) {
session.mainForwardDetached = true;
detachedNotice = 'Main tunnel helper exited after ControlMaster handoff';
} else {
droppedReason = `Main SSH forward exited (${session.mainForward.exitCode})`;
droppedReason = this.processErrorDetail(session.mainForward, `Main SSH forward exited (${session.mainForward.exitCode})`);
}
} else if (childProcessDiagnostics.get(session.mainForward)?.error) {
droppedReason = this.processErrorDetail(session.mainForward, 'Main SSH forward failed');
}
}
if (!droppedReason) {
const stoppedExtraForward = session.extraForwards.find(({ child }) => (
typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error
));
if (stoppedExtraForward) {
droppedReason = this.processErrorDetail(
stoppedExtraForward.child,
`Extra SSH forward ${stoppedExtraForward.id} exited`,
);
}
}
@@ -1220,7 +1377,7 @@ export class ElectronSshManager {
// Fast path: cheap TCP probe before expensive SSH subprocess
if (await isLocalTunnelReachable(session.localPort)) {
// Tunnel alive — skip SSH check
} else if (!await isControlMasterAlive(session.parsed, session.controlPath)) {
} else if (!await this.isControlMasterAlive(session.parsed, session.controlPath)) {
droppedReason = 'SSH ControlMaster is not reachable';
} else {
detachedNotice = 'Local tunnel unreachable but ControlMaster is alive';
+222
View File
@@ -4,12 +4,27 @@ import fsp from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:events';
import { PassThrough } from 'node:stream';
import { ElectronSshManager } from './ssh-manager.mjs';
const servers = [];
const tempDirs = [];
const createChild = () => {
const child = new EventEmitter();
child.stdin = new PassThrough();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.exitCode = null;
child.kill = () => {
child.exitCode = 0;
return true;
};
return child;
};
const listen = async (server) => {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
servers.push(server);
@@ -35,6 +50,213 @@ afterEach(async () => {
});
describe('ElectronSshManager', () => {
test('runs Windows SSH commands without ControlMaster and hides the process window', async () => {
const calls = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '0.0.0-test',
emit: () => undefined,
platform: 'win32',
spawn: (command, args, options) => {
calls.push({ command, args, options });
const child = createChild();
queueMicrotask(() => {
child.stdout.end('Linux\n');
child.exitCode = 0;
child.emit('close', 0);
});
return child;
},
});
const parsed = { destination: 'user@example.test', args: [] };
await expect(manager.runRemoteCommand(parsed, 'C:\\Temp\\unused.sock', 'uname -s')).resolves.toBe('Linux\n');
expect(calls).toHaveLength(1);
expect(calls[0].command).toBe('ssh');
expect(calls[0].options.windowsHide).toBe(true);
expect(calls[0].args).toContain('ControlMaster=no');
expect(calls[0].args).toContain('ControlPath=none');
expect(calls[0].args).toContain('StrictHostKeyChecking=accept-new');
expect(calls[0].args).not.toContain('ControlPath=C:\\Temp\\unused.sock');
});
test('creates a PowerShell-backed askpass helper on Windows', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-askpass-test-'));
tempDirs.push(tempDir);
const manager = new ElectronSshManager({
settingsFilePath: path.join(tempDir, 'settings.json'),
appVersion: '0.0.0-test',
emit: () => undefined,
platform: 'win32',
});
const result = await manager.writeAskpassFiles(tempDir);
expect(path.basename(result.askpassPath)).toBe('askpass.cmd');
expect(result.cleanupPaths.map((filePath) => path.basename(filePath))).toEqual(['askpass.cmd', 'askpass.ps1']);
expect(await fsp.readFile(path.join(tempDir, 'askpass.cmd'), 'utf8')).toContain('WindowsPowerShell');
expect(await fsp.readFile(path.join(tempDir, 'askpass.ps1'), 'utf8')).toContain('OPENCHAMBER_SSH_ASKPASS_VALUE');
});
test('runs each Windows port forward as an independent hidden SSH process', async () => {
const calls = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '0.0.0-test',
emit: () => undefined,
platform: 'win32',
spawn: (command, args, options) => {
calls.push({ command, args, options });
return createChild();
},
});
const parsed = { destination: 'user@example.test', args: [] };
manager.sshAuth.set(parsed, {
askpassPath: 'C:\\OpenChamber\\askpass.cmd',
sshPassword: 'secret-value',
children: new Set(),
});
await manager.spawnMainForward(parsed, 'C:\\Temp\\unused.sock', '127.0.0.1', 3000, 4000);
await manager.spawnExtraForward(parsed, 'C:\\Temp\\unused.sock', {
id: 'dynamic-1',
type: 'dynamic',
localHost: '127.0.0.1',
localPort: 5000,
});
expect(calls).toHaveLength(2);
for (const call of calls) {
expect(call.command).toBe('ssh');
expect(call.args).toContain('ControlPath=none');
expect(call.args).toContain('-N');
expect(call.options.windowsHide).toBe(true);
expect(call.options.env.SSH_ASKPASS).toBe('C:\\OpenChamber\\askpass.cmd');
expect(call.options.env.OPENCHAMBER_SSH_ASKPASS_VALUE).toBe('secret-value');
}
expect(calls[0].args).toContain('-L');
expect(calls[1].args).toContain('-D');
});
test('keeps ControlMaster-backed forwarding on non-Windows platforms', async () => {
const calls = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '0.0.0-test',
emit: () => undefined,
platform: 'darwin',
spawn: (command, args, options) => {
calls.push({ command, args, options });
return createChild();
},
});
const parsed = { destination: 'user@example.test', args: [] };
await manager.spawnMainForward(parsed, '/tmp/control.sock', '127.0.0.1', 3000, 4000);
expect(calls).toHaveLength(1);
expect(calls[0].args).toContain('ControlPath=/tmp/control.sock');
expect(calls[0].args).not.toContain('ControlPath=none');
expect(calls[0].options.windowsHide).toBeUndefined();
});
test('stops in-flight commands and forwards when disconnecting Windows SSH', async () => {
const killedChildren = [];
const spawnedChildren = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '0.0.0-test',
emit: () => undefined,
platform: 'win32',
spawn: () => {
const child = createChild();
child.kill = () => {
killedChildren.push(child);
child.exitCode = 1;
child.emit('close', 1);
return true;
};
spawnedChildren.push(child);
return child;
},
});
const parsed = { destination: 'user@example.test', args: [] };
const mainForward = createChild();
const extraForward = createChild();
for (const child of [mainForward, extraForward]) {
child.kill = () => {
killedChildren.push(child);
child.exitCode = 0;
return true;
};
}
manager.sshAuth.set(parsed, {
askpassPath: 'C:\\OpenChamber\\askpass.cmd',
sshPassword: null,
children: new Set(),
});
manager.sessions.set('ssh-1', {
instance: { remoteOpenchamber: { mode: 'external', keepRunning: true } },
parsed,
controlPath: 'C:\\Temp\\unused.sock',
askpassCleanupPaths: [],
startedByUs: false,
remotePort: null,
master: null,
mainForward,
extraForwards: [{ id: 'dynamic-1', child: extraForward }],
});
let commandError = null;
const command = manager.runRemoteCommand(parsed, 'C:\\Temp\\unused.sock', 'uname -s').catch((error) => {
commandError = error;
});
await manager.disconnectInternal('ssh-1', false);
await command;
expect(commandError?.message).toBe('Remote command failed');
expect(spawnedChildren).toHaveLength(1);
expect(new Set(killedChildren)).toEqual(new Set([spawnedChildren[0], mainForward, extraForward]));
expect(manager.sessions.has('ssh-1')).toBe(false);
});
test('reports bounded, sanitized, and redacted SSH master stderr when startup fails', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '0.0.0-test',
emit: () => undefined,
spawn: () => {
const child = createChild();
queueMicrotask(() => {
child.exitCode = 1;
child.emit('close', 1);
});
return child;
},
});
const parsed = { destination: 'user@example.test', args: [] };
const master = createChild();
manager.sshAuth.set(parsed, {
askpassPath: '/tmp/askpass.sh',
sshPassword: 'secret-value',
children: new Set(),
});
manager.trackSshProcess(master, parsed);
master.stderr.write(`muxclient socket failed: secret-value\u0007${'x'.repeat(3000)}`);
master.exitCode = 255;
try {
await manager.waitForMasterReady(parsed, '/tmp/control.sock', 1, master);
throw new Error('Expected SSH master startup to fail');
} catch (error) {
expect(error.message).toStartWith('muxclient socket failed: [redacted]');
expect(error.message).not.toContain('secret-value');
expect(error.message).not.toContain('\u0007');
expect(error.message.length).toBeLessThanOrEqual(2000);
}
});
test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => {
let loginPayload = null;
const server = http.createServer(async (req, res) => {