feat(remote): support password-authenticated SSH hosts, storing the password optionally

The remote path always passed `-o BatchMode=yes`, which disables every
interactive prompt, so password-only hosts could never be used. BatchMode is not
an oversight: Codeman launches ssh non-interactively from a service process with
no terminal and nobody watching, and without it ssh hangs on a password prompt
no one will ever answer — a dead pane, which is worse than an error.

So the password goes through sshpass, handed to ssh in the SSHPASS environment
variable: never in argv (same-host users can read /proc) and never in a temp
file. The variable itself is injected into the pane with socket-scoped
`tmux setenv`, the same rule every other secret here follows.

⚠️ One thing measured, and a naive implementation will hit it: BatchMode=yes and
sshpass are mutually exclusive. The former disables the password prompt, and
answering that prompt is exactly how sshpass works, so using both yields
`Permission denied (publickey,password)` — which reads like a wrong password
rather than wrong arguments. With a password we therefore send BatchMode=no plus
NumberOfPasswordPrompts=1, the latter so a wrong password fails immediately
instead of hanging (also measured).

⚠️ The preflight probe runs in the server process, not in the pane, so
`tmux setenv` does not reach it and that path passes the variable through the
child environment instead. A missing sshpass is reported as a named prerequisite
during the probe as well; otherwise it surfaces as a pane dying with
"sshpass: command not found", which reads like a broken host.

Storage and exposure:
- remote-hosts.json now holds a secret, so it is written 0600, and an existing
  file is explicitly tightened once (writeFile's mode only applies on create)
- the API always redacts, returning only a `passwordSet` boolean
- updates merge the stored password, because a redacted host posted back carries
  no `password` and a straight write would silently erase it; an explicit empty
  string still means "clear"
- the schema deliberately does not apply NO_SHELL_META to `password`: a password
  legitimately contains `$` and backticks, and unlike the path fields it is never
  interpolated into a shell string
This commit is contained in:
d fei
2026-09-03 02:01:44 -07:00
parent 3fbaadadfb
commit c2492a3523
6 changed files with 292 additions and 8 deletions
+32
View File
@@ -2110,6 +2110,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/**
* Configure Gemini-specific environment on a tmux session.
*/
/**
* Hand a remote host's stored SSH password to the pane as `SSHPASS`.
*
* `buildSshConnectionArgs` launches such a host through `sshpass -e`, which
* reads exactly this variable. Injected with socket-scoped `tmux setenv` so the
* secret never appears in `ps` — the same discipline the CLI API keys follow —
* and inherited by `respawn-pane`, so a respawned wrapper reconnects without the
* server having to re-send anything.
*
* ⚠️ A user's own `envOverrides` cannot reach this variable: `SSHPASS` matches
* no entry in ALLOWED_ENV_PREFIXES / ALLOWED_ENV_KEYS, so unlike the DeepSeek
* case there is no later `applyEnvOverrides` pass that could override it.
*/
private _configureSshPassword(muxName: string, remote?: SessionRemote): void {
if (!remote?.password) return;
try {
execSync(`${this.tmux()} setenv -t "${muxName}" SSHPASS ${shellescape(remote.password)}`, { stdio: 'ignore' });
} catch {
// Best-effort like the sibling _configure* helpers: a failed setenv surfaces
// as an auth failure in the pane, which is visible, rather than as a thrown
// session-create that hides the real cause.
}
}
private _configureGemini(muxName: string): void {
setGeminiEnvVars(this.tmux(), muxName);
}
@@ -2373,6 +2397,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
// A password-authenticated remote host needs SSHPASS in the pane (see
// _configureSshPassword); a no-op for key auth and every non-remote session.
this._configureSshPassword(muxName, remote);
this.applyEnvOverrides(muxName, envOverrides);
// Replace the shell with the actual command (no echo in terminal). Keep
@@ -2602,6 +2630,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
// Re-apply user env overrides before respawn so the new shell inherits them.
// A password-authenticated remote host needs SSHPASS in the pane (see
// _configureSshPassword); a no-op for key auth and every non-remote session.
this._configureSshPassword(muxName, remote);
this.applyEnvOverrides(muxName, envOverrides);
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).