chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:46:20 +08:00
commit eb21b65815
830 changed files with 113645 additions and 0 deletions
+504
View File
@@ -0,0 +1,504 @@
/**
* SSH agent integration tests.
*
* These tests confirm that SSH agent authentication continues to work
* correctly across the following scenarios that are NOT covered by the
* existing session-ssh.spec.js suite:
*
* 1. Agent when sshKeysPath contains NON-matching key files.
* The createAuthHandler isMethodAllowed fix (commit f05ef847) makes the
* agent method eligible whenever the server advertises "publickey". A
* regression to that fix would cause all agent logins to fail silently
* whenever the user has any key files on disk.
*
* 2. Agent discovered via process.env.SSH_AUTH_SOCK (no explicit sshAgent
* option). The existing tests always pass sshAgent explicitly; this
* covers the implicit path used by most desktop setups.
*
* 3. exports.test() (connection probe) with SSH agent.
* Before commit a357c4a3 the ws object was not forwarded to the probe,
* so the host-key verification prompt (added in 409f3291) would wait
* forever when connecting to a new host.
*
* All tests mock the electron environment and run exclusively with Node.js
* built-ins + the packages already bundled with electerm.
*/
process.env.NODE_ENV = 'development'
const { describe, test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { once } = require('node:events')
const { spawnSync } = require('node:child_process')
const { Server, utils } = require('@electerm/ssh2')
const { session, test: sshTest } = require('../../src/app/server/session-ssh')
// ─── constants ────────────────────────────────────────────────────────────────
const USERNAME = 'agent-tester'
// A fresh server host key for every test run.
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
comment: 'electerm-agent-test-host'
})
// ─── helpers ──────────────────────────────────────────────────────────────────
function parseKey (key) {
let parsed = utils.parseKey(key)
if (Array.isArray(parsed)) parsed = parsed[0]
if (parsed instanceof Error) throw parsed
return parsed
}
function publicKeyMatchesCtx (ctx, publicKeyPem) {
return Buffer.compare(
parseKey(publicKeyPem).getPublicSSH(),
ctx.key.data
) === 0
}
function runCommand (command, args, options = {}) {
const result = spawnSync(command, args, { encoding: 'utf8', ...options })
if (result.error) throw result.error
if (result.status !== 0) {
throw new Error(
`${command} ${args.join(' ')} exited ${result.status}: ${result.stderr || result.stdout}`
)
}
return result.stdout
}
function generateKey ({ dir, name, type = 'ed25519', passphrase = '', bits }) {
const keyPath = path.join(dir, name)
const args = ['-q', '-t', type]
if (bits) args.push('-b', String(bits))
args.push('-N', passphrase, '-f', keyPath, '-C', `electerm-${name}`)
runCommand('ssh-keygen', args)
return {
keyPath,
privateKey: fs.readFileSync(keyPath, 'utf8'),
publicKey: fs.readFileSync(`${keyPath}.pub`, 'utf8')
}
}
/**
* Start ssh-agent on a unique socket and load the given key into it.
* Returns { env, kill }.
*/
function startAgent (keyPath) {
// Use a socket path that is predictable enough for debugging but unique.
const socketPath = path.join(
os.tmpdir(),
`ea-agent-${process.pid}-${Date.now()}.sock`
)
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
const sockMatch = output.match(/SSH_AUTH_SOCK=([^;]+)/)
const pidMatch = output.match(/SSH_AGENT_PID=([^;]+)/)
if (!sockMatch || !pidMatch) {
throw new Error(`Cannot parse ssh-agent output:\n${output}`)
}
const env = {
...process.env,
SSH_AUTH_SOCK: sockMatch[1],
SSH_AGENT_PID: pidMatch[1]
}
if (keyPath) {
runCommand('ssh-add', [keyPath], { env })
}
return {
env,
kill () {
try { runCommand('ssh-agent', ['-k'], { env }) } catch (_) { /* ignore */ }
}
}
}
/**
* Start a minimal SSH server that only accepts publickey auth for USERNAME
* using the provided public-key string.
*
* Returns { port, close }.
*/
async function startServer (allowedPublicKey, { debug = false } = {}) {
const clients = new Set()
const server = new Server({ hostKeys: [HOST_KEY.private] }, (client) => {
clients.add(client)
client.on('close', () => clients.delete(client))
client.on('end', () => clients.delete(client))
client.on('authentication', (ctx) => {
if (debug) {
console.log('[server] auth method:', ctx.method, 'user:', ctx.username)
}
if (ctx.method === 'none') {
return ctx.reject(['publickey'])
}
if (
ctx.method === 'publickey' &&
ctx.username === USERNAME &&
publicKeyMatchesCtx(ctx, allowedPublicKey)
) {
if (debug) console.log('[server] accepted publickey auth')
return ctx.accept()
}
// reject but keep publickey in the advertised list so the client can
// try the ssh agent after failing with a file key
return ctx.reject(['publickey'])
})
client.on('ready', () => {
client.on('session', (accept) => {
const sess = accept()
sess.on('env', (accept) => accept())
sess.on('pty', (accept) => accept())
sess.on('shell', (accept) => {
const stream = accept()
stream.write('electerm-agent-test ready\n')
})
})
})
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const { port } = server.address()
if (debug) console.log('[server] listening on port', port)
return {
port,
async close () {
for (const c of clients) c.end()
await new Promise((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()))
})
}
}
}
/**
* Minimal ws mock that automatically trusts unknown host keys.
* Any non-confirm interactive prompt rejects with an error so tests fail
* loudly if an unexpected prompt appears.
*/
function makeWs ({ allowNonConfirmPrompts = false, debug = false } = {}) {
let pendingHandler = null
let pendingOptions = null
return {
s (payload) {
if (payload?.action !== 'session-interactive') return
pendingOptions = payload.options
if (debug) {
console.log('[ws] session-interactive', JSON.stringify({
mode: payload.options?.mode,
name: payload.options?.name,
prompts: (payload.options?.prompts || []).map(p => p.prompt)
}))
}
},
once (handler) {
const opts = pendingOptions
queueMicrotask(() => {
if (opts?.mode === 'confirm') {
// Auto-trust the host key
if (debug) console.log('[ws] auto-trusting host key')
handler({ results: ['trust'] })
} else if (allowNonConfirmPrompts) {
handler({ results: pendingHandler ? pendingHandler(opts) : [] })
} else {
// Fail loudly agent login should not need interactive prompts
handler({ results: [] }) // empty results → reject('User cancel')
}
})
pendingHandler = null
},
close () {}
}
}
function setEnv (name, value) {
if (value === undefined) delete process.env[name]
else process.env[name] = value
}
// ─── fixture management ────────────────────────────────────────────────────────
describe('SSH agent auth', () => {
let tmpDir
let savedEnv
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-agent-test-'))
savedEnv = {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK,
SSH_AGENT_PID: process.env.SSH_AGENT_PID,
sshKeysPath: process.env.sshKeysPath
}
const homeDir = path.join(tmpDir, 'home')
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
fs.mkdirSync(homeDir, { recursive: true })
fs.mkdirSync(sshKeysDir, { recursive: true })
setEnv('HOME', homeDir)
setEnv('USERPROFILE', homeDir)
setEnv('SSH_AUTH_SOCK', undefined)
setEnv('SSH_AGENT_PID', undefined)
setEnv('sshKeysPath', sshKeysDir)
})
afterEach(() => {
setEnv('HOME', savedEnv.HOME)
setEnv('USERPROFILE', savedEnv.USERPROFILE)
setEnv('SSH_AUTH_SOCK', savedEnv.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', savedEnv.SSH_AGENT_PID)
setEnv('sshKeysPath', savedEnv.sshKeysPath)
fs.rmSync(tmpDir, { recursive: true, force: true })
})
// ── test 1 ─────────────────────────────────────────────────────────────────
test(
'agent auth succeeds even when sshKeysPath contains non-matching key files',
async (t) => {
// This is the primary regression test for the isMethodAllowed fix
// (commit f05ef847). Without that fix the auth handler would return
// false as soon as the key-file publickey attempt failed, because
// "agent" is not literally in the server's ["publickey"] allow-list,
// and the connection would be rejected with "All configured
// authentication methods failed" before the agent was ever tried.
let agent
try {
agent = startAgent() // start without a key first we load it below
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const sshKeysDir = process.env.sshKeysPath
// Generate the key the server will accept and load it into the agent.
const agentKey = generateKey({ dir: tmpDir, name: 'agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
// Generate a DIFFERENT key that lives in sshKeysPath.
// The code will try this one first (publickey auth) and it will fail.
// Agent must then be tried automatically.
const wrongKey = generateKey({ dir: sshKeysDir, name: 'wrong-key' })
// Also place the matching .pub file so getSSHKeys() finds it.
fs.copyFileSync(`${wrongKey.keyPath}.pub`, path.join(sshKeysDir, 'wrong-key.pub'))
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true // enables ssh2 protocol debug output
}, makeWs({ debug: true }))
// If we reach here the agent key was accepted.
assert.ok(term, 'session should resolve when agent auth succeeds')
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
// ── test 2 ─────────────────────────────────────────────────────────────────
test(
'agent auth works via implicit process.env.SSH_AUTH_SOCK (no explicit sshAgent option)',
async (t) => {
// The getAgent() method falls back to process.env.SSH_AUTH_SOCK when
// initOptions.sshAgent is not set. This covers the typical desktop
// user who relies on a system-managed agent socket.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'implicit-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
// Expose the agent socket via the environment variable only do NOT
// set initOptions.sshAgent.
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
// No sshAgent option should discover SSH_AUTH_SOCK automatically
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true
}, makeWs({ debug: true }))
assert.ok(term, 'session should resolve when implicit agent is used')
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
// ── test 3 ─────────────────────────────────────────────────────────────────
test(
'exports.test (connection probe) returns true with SSH agent on a new host',
async (t) => {
// Before commit a357c4a3, exports.test() did not forward ws to the
// TerminalSsh instance. The host-key verifier added in 409f3291 calls
// onKeyboardEvent() which sends a session-interactive event to ws and
// then awaits a reply. With ws === undefined the await never resolved,
// causing the probe to hang. This test would time out under that bug.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'probe-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
try {
const ok = await sshTest({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
readyTimeout: 10000,
debug: true
}, makeWs({ debug: true })) // ws must be forwarded (fixed in a357c4a3)
assert.equal(ok, true, 'sshTest should return true when agent auth succeeds')
} finally {
await server.close()
agent.kill()
}
}
)
// ── test 4 ─────────────────────────────────────────────────────────────────
test(
'agent auth with a new host requires host-key confirmation and then succeeds',
async (t) => {
// Confirm that the host-key verification dialog (409f3291) does not
// interfere with subsequent agent-based authentication. A fresh HOME
// dir is used so known_hosts starts empty.
let agent
try {
agent = startAgent()
} catch (err) {
if (err.code === 'ENOENT') {
t.skip('ssh-agent / ssh-keygen not available')
return
}
throw err
}
const agentKey = generateKey({ dir: tmpDir, name: 'new-host-agent-key' })
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(agentKey.publicKey, { debug: true })
let confirmCount = 0
const ws = {
s (payload) {
if (payload?.action !== 'session-interactive') return
if (payload.options?.mode === 'confirm') {
confirmCount++
console.log('[ws] host-key confirm dialog received (#' + confirmCount + ')')
} else {
// Any non-confirm prompt during agent login is unexpected
console.error('[ws] unexpected non-confirm prompt:', payload.options)
}
},
once (handler) {
queueMicrotask(() => handler({ results: ['trust'] }))
},
close () {}
}
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 10000,
debug: true
}, ws)
assert.ok(term, 'session should resolve after trusting the host key')
assert.equal(
confirmCount,
1,
'exactly one host-key confirmation prompt should appear for a new host'
)
// Verify the host was saved to known_hosts
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
const knownHostsContent = fs.readFileSync(knownHostsPath, 'utf8')
assert.match(
knownHostsContent,
/127\.0\.0\.1/,
'server should be recorded in known_hosts after trusting'
)
} finally {
term?.kill()
await server.close()
agent.kill()
}
}
)
})