Skip to content

Windows/Claude Desktop: all SSH commands fail with exit 255 (empty stderr) — spawned ssh inherits a stripped env missing %ProgramData% #10

Description

@Krolikfarm

Title: Windows + Claude Desktop: every SSH command fails with exit 255 (empty stderr) — spawned ssh inherits a stripped env missing %ProgramData%

Summary

When the extension runs as a DXT under Claude Desktop on Windows, every operation against a reachable host fails with code: 255 and empty stdout/stderr, while the same ssh command run by hand from PowerShell/cmd succeeds instantly. checkConnectivity returns connected: false, runRemoteCommand/runCommandBatch return { stdout: "", stderr: "", code: 255 }.

Root cause: the ssh.exe child process is spawned inheriting the extension's process.env, and Claude Desktop launches the extension with a stripped, allow-listed environment that omits ProgramData/ALLUSERSPROFILE. Win32‑OpenSSH needs %ProgramData% to locate its global config (%ProgramData%\ssh\) at startup and exits 255 immediately, before producing any output, when it's unset.

Environment

  • OS: Windows 11 (x64)
  • Claude Desktop: MSIX/Store package (C:\Program Files\WindowsApps\Claude_*)
  • SSH client: bundled Microsoft Win32‑OpenSSH (C:\Windows\System32\OpenSSH\ssh.exe)
  • mcp-ssh: 1.3.8 (also reproduces on the version shipped in the current .dxt)
  • Host: key-based auth (ed25519, no passphrase), ~/.ssh/config alias, StrictHostKeyChecking accept-new

Symptom

// checkConnectivity
{ "connected": false, "message": "Connection failed" }

// runRemoteCommand
{ "stdout": "", "stderr": "", "code": 255 }

Manual run of the exact same command from a normal shell → exit 0.

Root cause

Claude Desktop hands the extension's Node process a curated environment of ~13 variables:

APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, MCP_SILENT, PATH,
PROCESSOR_ARCHITECTURE, PROGRAMFILES, SYSTEMDRIVE, SYSTEMROOT,
TEMP, USERNAME, USERPROFILE

Notably missing: ProgramData, ALLUSERSPROFILE, windir, ComSpec, TMP, PATHEXT, …

Because the child ssh inherits this env, and Win32‑OpenSSH resolves %ProgramData% very early to find its global config directory, ssh.exe aborts with exit code 255 before it writes anything (even ssh -E <logfile> -vvv produces no log file).

Evidence (env bisection)

I captured the live extension process's exact environment and replayed the same ssh invocation, adding back one missing variable at a time:

[stripped baseline]        EXIT=255  stdout=''          ← matches the extension
[+ ComSpec]                EXIT=255  stdout=''
[+ windir]                 EXIT=255  stdout=''
[+ PATHEXT]                EXIT=255  stdout=''
[+ ProgramData/ALLUSERS]   EXIT=0    stdout='connected' ← fixed
[+ TMP]                    EXIT=255  stdout=''

Restoring only ProgramData (+ALLUSERSPROFILE) flips ssh from 255 → 0. Confirmed not related to keys, passphrase, askpass, host‑key, the resolved binary, console vs. no‑console, or MSIX network capabilities — all of those reproduce as success.

Affected code (server.mjs)

For key-auth hosts there is no password, so buildSpawnEnv() returns null and spawnOptions.env is left unset → the child inherits the stripped process.env:

async buildSpawnEnv(hostAlias) {
  const password = await this.getPasswordForHost(hostAlias);
  if (!password) return null;            // ← key-only hosts: no env override
  return {
    ...process.env,                      // ← even here, %ProgramData% is missing
    MCP_SSH_PASS: password,
    SSH_ASKPASS: askpassScript,
    SSH_ASKPASS_REQUIRE: 'force'
  };
}

// runRemoteCommand:
if (passwordEnv) { spawnOptions.env = passwordEnv; /* ... */ }
const child = this._spawn(SSH_BIN,
  ['-o', 'StrictHostKeyChecking=accept-new', '--', hostAlias, command],
  spawnOptions);

// uploadFile / downloadFile:
if (passwordEnv) options.env = passwordEnv;

Both paths are affected: password hosts spread a process.env that is itself missing ProgramData, and key-only hosts inherit it implicitly.

Suggested fix

Guarantee the variables Win32‑OpenSSH needs are present for every spawned ssh/scp. Simplest, covers all spawn sites — normalize once at module load:

if (process.platform === 'win32') {
  if (!process.env.ProgramData) {
    process.env.ProgramData = process.env.ALLUSERSPROFILE || 'C:\\ProgramData';
  }
  if (!process.env.ALLUSERSPROFILE) {
    process.env.ALLUSERSPROFILE = process.env.ProgramData;
  }
}

(Or build an explicit base env for every spawn so the child never depends on a host launcher passing a complete environment.) Other consumers under restricted launchers may also benefit from restoring windir/ComSpec/SystemRoot.

Workaround (until released)

Add the same block to the top of the installed server.mjs, or add to the DXT manifest.jsonserver.mcp_config.env:

"env": {
  "MCP_SILENT": "false",
  "ProgramData": "C:\\ProgramData",
  "ALLUSERSPROFILE": "C:\\ProgramData"
}

then fully restart Claude Desktop. (Manifest env values do reach the process — that's how MCP_SILENT arrives.) Note: setting Windows user/system environment variables does not help, because the host passes only an allow-listed subset.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions