mirror of
https://github.com/stablyai/orca.git
synced 2026-10-24 16:03:45 +00:00
* fix(ssh): answer every MFA stage, not just the first ssh2 walks one flat auth-method list exactly once, so keyboard-interactive could only ever be offered a single time. A host running `AuthenticationMethods keyboard-interactive,keyboard-interactive` (or any ladder ending in a second challenge) partial-succeeds the first stage and then finds the list exhausted, which the user sees as "All configured authentication methods failed" — the reports in #8622 and #16820. Orca's own auth handler now runs for every target instead of only multi-key ones, and rebuilds its queue on each SSH_MSG_USERAUTH_FAILURE that carries partial success, narrowed to the methods the host still offers. Narrowing also stops keys being re-offered after the host has moved past publickey, which is what exhausts MaxAuthTries before the challenge is ever shown. Covered by a real ssh2 server fixture that stages partial success. * fix(git): say where a failing clone ran and why nothing could prompt Clones go through nonInteractiveGitEnv, so `ssh` runs with BatchMode=yes and an emptied SSH_ASKPASS. On a remote or paired-runtime clone that produces `fatal: Could not read from remote repository.` while the same `git clone` typed by hand on that box succeeds — the divergence in #14533. Nothing in the message said the clone ran on the other machine, under its keys, with the prompt deliberately disabled. getGitCloneFailureMessage now appends that fact, and names the two recognisable shapes: a publickey refusal (load the key into an agent there) and a host-key failure (record the key in that machine's known_hosts). Unrecognised SSH failures still get the where-it-ran note; non-SSH failures are untouched. One builder, so the SSH-target relay path and the runtime path both get it. * fix(ssh): stop dialling a bare alias no ssh_config block claims A wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so shouldUseSystemSshTransport picks the system transport for an alias whose own Host block was renamed or deleted, and buildSshArgs then dials that alias verbatim: no -l, no -p, no Hostname. Orca connects as the wildcard's user to the wildcard's host and discards the endpoint it stored (#11746). The signal #11746 assumed (hostBlockMatch, from the still-open #11707) does not exist, and `ssh -G` cannot supply it — it prints the merged config and answers for unknown aliases too. The config file is the only source of truth, so: - parseSshConfigAliasClaims retains raw Host patterns and flags Match blocks, which parseSshConfig discards because it mints importable targets. - sshConfigMayClaimAlias is sound in the negative direction only: an unreadable file, any Match block, or any non-catch-all pattern that might match all answer "claimed", so absence of evidence is never read as evidence of absence. Only a proven-unclaimed alias licenses an override. - buildSshArgs then states Hostname/Port/User, and only those: the wildcard is still the route, and -o Hostname does not change block selection, so the proxy keeps applying and %h expands to the host we mean. The verdict is injected rather than read inside buildSshArgs, so an arg builder does not answer differently per machine. Default is today's behaviour. Scoped to the system-SSH transport and the connection's own command/transport path. Port-forward processes and the ssh2 transport (#11707) are unchanged. * fix(ssh): read a negated Host group as uncertainty, and gate clone SSH guidance `Host * !prod` applies to every alias but `prod`, yet skipping both the catch-all and the `!` pattern answered "unclaimed" for `stage` — which licences overriding Hostname/Port/User against a block the user wrote. Any negation now makes the whole group uncertain; the function is only sound in the negative direction. Also require an ssh(1) diagnostic beside "could not read from remote repository" before appending the SSH clone note: git prints that same line for the HTTP remote helper, where advice about keys and agents is simply wrong. * fix(i18n): restore the activity-options key the rebase dropped * fix(i18n): union en.json with main so the rebase cannot drop keys
307 lines
7.9 KiB
TypeScript
307 lines
7.9 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { homedir } from 'node:os'
|
|
import type { SshTarget } from '../../shared/ssh-types'
|
|
import { expandSshConfigIncludes } from './ssh-config-include-expander'
|
|
import { resolveSshConfigHomePath } from './ssh-config-path-expansion'
|
|
export { parseSshGOutput, resolveWithSshG, type SshResolvedConfig } from './ssh-g-config-resolution'
|
|
|
|
export type SshConfigHost = {
|
|
host: string
|
|
hostname?: string
|
|
port?: number
|
|
user?: string
|
|
identityFile?: string
|
|
identityAgent?: string
|
|
identitiesOnly?: boolean
|
|
gssapiAuthentication?: boolean
|
|
proxyCommand?: string
|
|
proxyUseFdpass?: boolean
|
|
proxyJump?: string
|
|
}
|
|
|
|
/**
|
|
* Parse an OpenSSH config file into structured host entries.
|
|
* Handles Host blocks with single or multiple patterns.
|
|
* Ignores wildcard-only patterns (e.g. "Host *").
|
|
*/
|
|
export function parseSshConfig(content: string): SshConfigHost[] {
|
|
const hosts: SshConfigHost[] = []
|
|
let current: SshConfigHost[] = []
|
|
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.trim()
|
|
if (!line || line.startsWith('#')) {
|
|
continue
|
|
}
|
|
|
|
const directive = parseConfigDirective(line)
|
|
if (!directive) {
|
|
continue
|
|
}
|
|
|
|
const { key, rawValue } = directive
|
|
|
|
if (key === 'host') {
|
|
if (current.length > 0) {
|
|
appendHosts(hosts, current)
|
|
}
|
|
|
|
const patterns = splitHostPatterns(rawValue)
|
|
const concretePatterns = patterns.filter(
|
|
(pattern) => !pattern.startsWith('!') && !pattern.includes('*') && !pattern.includes('?')
|
|
)
|
|
if (concretePatterns.length === 0) {
|
|
current = []
|
|
continue
|
|
}
|
|
|
|
current = concretePatterns.map((pattern) => ({ host: pattern }))
|
|
continue
|
|
}
|
|
|
|
if (key === 'match') {
|
|
if (current.length > 0) {
|
|
appendHosts(hosts, current)
|
|
}
|
|
current = []
|
|
continue
|
|
}
|
|
|
|
if (current.length === 0) {
|
|
continue
|
|
}
|
|
|
|
const value = parseScalarConfigValue(rawValue)
|
|
|
|
// Why: OpenSSH uses the first obtained value for single-valued parameters.
|
|
switch (key) {
|
|
case 'hostname':
|
|
for (const host of current) {
|
|
host.hostname ??= value
|
|
}
|
|
break
|
|
case 'port':
|
|
for (const host of current) {
|
|
host.port ??= Number.parseInt(value, 10) || 22
|
|
}
|
|
break
|
|
case 'user':
|
|
for (const host of current) {
|
|
host.user ??= value
|
|
}
|
|
break
|
|
case 'identityfile':
|
|
for (const host of current) {
|
|
host.identityFile = resolveSshConfigHomePath(value)
|
|
}
|
|
break
|
|
case 'identityagent':
|
|
for (const host of current) {
|
|
host.identityAgent ??= resolveSshConfigHomePath(value)
|
|
}
|
|
break
|
|
case 'identitiesonly':
|
|
for (const host of current) {
|
|
host.identitiesOnly ??= value.toLowerCase() === 'yes'
|
|
}
|
|
break
|
|
case 'gssapiauthentication':
|
|
for (const host of current) {
|
|
host.gssapiAuthentication ??= value.toLowerCase() === 'yes'
|
|
}
|
|
break
|
|
case 'proxycommand':
|
|
for (const host of current) {
|
|
// Why: OpenSSH treats ProxyCommand as a shell snippet and preserves
|
|
// the rest of the line, including quotes and `#` characters.
|
|
host.proxyCommand ??= rawValue.trim()
|
|
}
|
|
break
|
|
case 'proxyusefdpass':
|
|
for (const host of current) {
|
|
host.proxyUseFdpass ??= value.toLowerCase() === 'yes'
|
|
}
|
|
break
|
|
case 'proxyjump':
|
|
for (const host of current) {
|
|
host.proxyJump ??= value
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if (current.length > 0) {
|
|
appendHosts(hosts, current)
|
|
}
|
|
return hosts
|
|
}
|
|
|
|
function appendHosts(target: SshConfigHost[], entries: SshConfigHost[]): void {
|
|
// Why: generated SSH configs can put many concrete aliases on one Host line;
|
|
// spreading that block into push can exceed JavaScript's argument limit.
|
|
for (const entry of entries) {
|
|
target.push(entry)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Every `Host` pattern list in a config, plus whether any `Match` block is present.
|
|
*
|
|
* Deliberately raw where {@link parseSshConfig} is not: that one keeps only concrete aliases
|
|
* because it mints importable targets, so a `Host prod.*` or a `Match host prod` route is
|
|
* invisible to it. Answering "does anything in this file claim this alias?" needs those back.
|
|
*/
|
|
export type SshConfigAliasClaims = {
|
|
hostPatternGroups: string[][]
|
|
hasMatchBlock: boolean
|
|
}
|
|
|
|
export function parseSshConfigAliasClaims(content: string): SshConfigAliasClaims {
|
|
const hostPatternGroups: string[][] = []
|
|
let hasMatchBlock = false
|
|
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.trim()
|
|
if (!line || line.startsWith('#')) {
|
|
continue
|
|
}
|
|
const directive = parseConfigDirective(line)
|
|
if (!directive) {
|
|
continue
|
|
}
|
|
if (directive.key === 'host') {
|
|
const patterns = splitHostPatterns(directive.rawValue)
|
|
if (patterns.length > 0) {
|
|
hostPatternGroups.push(patterns)
|
|
}
|
|
continue
|
|
}
|
|
if (directive.key === 'match') {
|
|
hasMatchBlock = true
|
|
}
|
|
}
|
|
|
|
return { hostPatternGroups, hasMatchBlock }
|
|
}
|
|
|
|
function parseConfigDirective(line: string): { key: string; rawValue: string } | null {
|
|
const match = line.match(/^([^=\s]+)(?:\s*=\s*|\s+)(.*)$/)
|
|
if (!match) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
key: match[1].toLowerCase(),
|
|
rawValue: match[2].trim()
|
|
}
|
|
}
|
|
|
|
function parseScalarConfigValue(input: string): string {
|
|
// Why: scalar OpenSSH directives strip wrapping quotes and inline comments;
|
|
// keeping them would turn valid config values into bad hostnames/users/paths.
|
|
return splitOpenSshArguments(input)[0] ?? ''
|
|
}
|
|
|
|
function splitHostPatterns(input: string): string[] {
|
|
return splitOpenSshArguments(input)
|
|
}
|
|
|
|
function splitOpenSshArguments(input: string): string[] {
|
|
const args: string[] = []
|
|
let current = ''
|
|
let inQuotes = false
|
|
let escaped = false
|
|
|
|
for (const char of input) {
|
|
if (escaped) {
|
|
current += char
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
if (inQuotes && char === '\\') {
|
|
escaped = true
|
|
continue
|
|
}
|
|
|
|
if (char === '"') {
|
|
inQuotes = !inQuotes
|
|
continue
|
|
}
|
|
|
|
// Why: multi-alias import must not turn OpenSSH inline comments into targets.
|
|
if (!inQuotes && char === '#') {
|
|
break
|
|
}
|
|
|
|
if (!inQuotes && /\s/.test(char)) {
|
|
if (current) {
|
|
args.push(current)
|
|
current = ''
|
|
}
|
|
continue
|
|
}
|
|
|
|
current += char
|
|
}
|
|
|
|
if (current) {
|
|
args.push(current)
|
|
}
|
|
|
|
return args
|
|
}
|
|
|
|
/** Read and parse the user's ~/.ssh/config file. Returns empty array if not found. */
|
|
export function loadUserSshConfig(): SshConfigHost[] {
|
|
const configPath = join(homedir(), '.ssh', 'config')
|
|
if (!existsSync(configPath)) {
|
|
return []
|
|
}
|
|
|
|
try {
|
|
const content = expandSshConfigIncludes(configPath)
|
|
return parseSshConfig(content)
|
|
} catch {
|
|
console.warn(`[ssh] Failed to read SSH config at ${configPath}`)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** Convert parsed SSH config hosts into SshTarget objects for import. */
|
|
export function sshConfigHostsToTargets(
|
|
hosts: SshConfigHost[],
|
|
existingTargetHosts: Set<string>
|
|
): SshTarget[] {
|
|
const targets: SshTarget[] = []
|
|
const seenLabels = new Set(existingTargetHosts)
|
|
|
|
for (const entry of hosts) {
|
|
const effectiveHost = entry.hostname || entry.host
|
|
const label = entry.host
|
|
|
|
if (seenLabels.has(label)) {
|
|
continue
|
|
}
|
|
seenLabels.add(label)
|
|
|
|
targets.push({
|
|
id: `ssh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
label,
|
|
configHost: entry.host,
|
|
host: effectiveHost,
|
|
port: entry.port ?? 22,
|
|
username: entry.user ?? '',
|
|
identityFile: entry.identityFile,
|
|
identityAgent: entry.identityAgent,
|
|
identitiesOnly: entry.identitiesOnly,
|
|
gssapiAuthentication: entry.gssapiAuthentication,
|
|
proxyCommand: entry.proxyCommand,
|
|
jumpHost: entry.proxyJump
|
|
})
|
|
}
|
|
|
|
return targets
|
|
}
|