Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
450 lines
15 KiB
JavaScript
450 lines
15 KiB
JavaScript
// Created: 2026-07-17
|
|
// Purpose: Verify SFTP retries KI first after EDR removes keyboard-interactive.
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { EventEmitter } = require("node:events");
|
|
const Module = require("node:module");
|
|
const {
|
|
beginTransportDial,
|
|
failTransportDial,
|
|
getTransportStats,
|
|
resetSshTransportRegistryForTests,
|
|
} = require("./sshConnectionPool.cjs");
|
|
const { shouldRegisterFreshSftpTransport } = require("./sftpBridge/openConnection.cjs");
|
|
|
|
test("sudo SFTP connections are never parked in the shared transport registry", () => {
|
|
assert.equal(shouldRegisterFreshSftpTransport({ sudo: true, reuseTransport: true }), false);
|
|
assert.equal(shouldRegisterFreshSftpTransport({ sudo: false, reuseTransport: true }), true);
|
|
});
|
|
|
|
/** Build a minimal renderer sender for SFTP progress and auth prompt IPC. */
|
|
function makeSender() {
|
|
return {
|
|
id: 1,
|
|
isDestroyed: () => false,
|
|
sent: [],
|
|
send(channel, payload) {
|
|
this.sent.push({ channel, payload });
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Load the SFTP bridge with ssh2-sftp-client mocked for auth retry scenarios. */
|
|
function loadSftpBridgeWithAuthRetryMocks(t, options = {}) {
|
|
const bridgePath = require.resolve("./sftpBridge.cjs");
|
|
const authHelperPath = require.resolve("./sshAuthHelper.cjs");
|
|
const originalLoad = Module._load;
|
|
const realAuthHelper = require(authHelperPath);
|
|
|
|
/** Mock SFTP client that simulates password rejection removing KI, then KI retry success. */
|
|
class MockSftpClient extends EventEmitter {
|
|
/** Create a mock ssh2-sftp-client instance with an embedded ssh2 client emitter. */
|
|
constructor() {
|
|
super();
|
|
MockSftpClient.instances.push(this);
|
|
this.sftp = null;
|
|
this.client = new EventEmitter();
|
|
this.client.setMaxListeners = () => {};
|
|
this.client._sock = { setTimeout() {} };
|
|
this.client.connect = (opts) => {
|
|
this.client.connectOpts = opts;
|
|
this.connect(opts);
|
|
};
|
|
this.client.sftp = (cb) => {
|
|
if (options.sftpError) {
|
|
setImmediate(() => cb(options.sftpError));
|
|
return;
|
|
}
|
|
const channel = new EventEmitter();
|
|
channel.readdir = () => {};
|
|
channel.stat = () => {};
|
|
channel.mkdir = () => {};
|
|
channel.unlink = () => {};
|
|
setImmediate(() => cb(null, channel));
|
|
};
|
|
this.client.end = () => {
|
|
this.client.ended = true;
|
|
};
|
|
this.client.destroy = () => {
|
|
this.client.destroyed = true;
|
|
};
|
|
}
|
|
|
|
/** Drive the fake server-side auth flow for each connection attempt. */
|
|
connect(opts) {
|
|
const attemptIndex = MockSftpClient.instances.length;
|
|
const offered = [];
|
|
this.authMethodsOffered = offered;
|
|
setImmediate(() => {
|
|
this.client.emit("connect");
|
|
this.client.emit("handshake");
|
|
if (options.alwaysSucceed) {
|
|
this.client.emit("ready");
|
|
return;
|
|
}
|
|
const offerNext = (methodsLeft, partialSuccess) => {
|
|
let nextMethod;
|
|
opts.authHandler(methodsLeft, partialSuccess, (method) => {
|
|
nextMethod = method;
|
|
offered.push(method);
|
|
});
|
|
return nextMethod;
|
|
};
|
|
|
|
offerNext(null, null);
|
|
const firstMethods = options.dynamicAuth
|
|
? ["publickey", "password", "keyboard-interactive"]
|
|
: ["password", "keyboard-interactive"];
|
|
const first = offerNext(firstMethods, false);
|
|
if (attemptIndex === 1 && options.dynamicAuth) {
|
|
assert.equal(first?.type, "publickey");
|
|
const password = offerNext(["password", "keyboard-interactive"], false);
|
|
assert.equal(password?.type, "password");
|
|
offerNext(["publickey"], false);
|
|
const err = new Error("All configured authentication methods failed");
|
|
err.level = "client-authentication";
|
|
this.client.emit("error", err);
|
|
return;
|
|
}
|
|
if (attemptIndex === 1) {
|
|
assert.equal(first, "password");
|
|
offerNext(["publickey"], false);
|
|
const err = new Error("All configured authentication methods failed");
|
|
err.level = "client-authentication";
|
|
this.client.emit("error", err);
|
|
return;
|
|
}
|
|
|
|
if (options.dynamicAuth) {
|
|
assert.equal(first?.type, "publickey");
|
|
const keyboardInteractive = offerNext(["keyboard-interactive"], false);
|
|
assert.equal(keyboardInteractive, "keyboard-interactive");
|
|
} else {
|
|
assert.equal(first, "keyboard-interactive");
|
|
}
|
|
this.client.emit("ready");
|
|
});
|
|
}
|
|
|
|
/** Mark the high-level SFTP client as ended. */
|
|
end() {
|
|
this.ended = true;
|
|
}
|
|
}
|
|
MockSftpClient.instances = [];
|
|
|
|
Module._load = function patchedLoad(request, parent, isMain) {
|
|
if (request === "ssh2-sftp-client") {
|
|
return MockSftpClient;
|
|
}
|
|
if (request === "./sshAuthHelper.cjs") {
|
|
return {
|
|
...realAuthHelper,
|
|
findAllDefaultPrivateKeys: async () => {
|
|
if (options.defaultKeysError) throw options.defaultKeysError;
|
|
return options.defaultKeys || [];
|
|
},
|
|
getAvailableAgentSocket: async () => null,
|
|
prepareSystemSshAgentForAuth: async () => null,
|
|
};
|
|
}
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
|
|
delete require.cache[bridgePath];
|
|
const bridge = require("./sftpBridge.cjs");
|
|
|
|
t.after(() => {
|
|
delete require.cache[bridgePath];
|
|
Module._load = originalLoad;
|
|
});
|
|
|
|
return { bridge, MockSftpClient };
|
|
}
|
|
|
|
test("simultaneous SFTP browser opens share one physical SSH dial", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
const options = {
|
|
hostname: "192.168.9.138",
|
|
hostId: "host-sftp-shared",
|
|
port: 22,
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "saved-login-password",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
fileProtocol: "sftp",
|
|
};
|
|
|
|
const [first, second] = await Promise.all([
|
|
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-shared-1" }),
|
|
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-shared-2" }),
|
|
]);
|
|
|
|
assert.equal(first.sftpId, "sftp-shared-1");
|
|
assert.equal(second.sftpId, "sftp-shared-2");
|
|
assert.equal(MockSftpClient.instances.length, 1);
|
|
assert.equal(sftpClients.size, 2);
|
|
});
|
|
|
|
test("sequential SFTP opens without a terminal authenticate only once", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
const options = {
|
|
hostname: "no-terminal.example",
|
|
hostId: "host-no-terminal",
|
|
port: 22,
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "saved-login-password",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
fileProtocol: "sftp",
|
|
};
|
|
|
|
const first = await bridge.openSftp(
|
|
{ sender: makeSender() },
|
|
{ ...options, sessionId: "sftp-no-terminal-1" },
|
|
);
|
|
await bridge.closeSftp(null, { sftpId: first.sftpId });
|
|
assert.equal(sftpClients.size, 0);
|
|
assert.equal(getTransportStats().idle, 1);
|
|
|
|
const second = await bridge.openSftp(
|
|
{ sender: makeSender() },
|
|
{ ...options, sessionId: "sftp-no-terminal-2" },
|
|
);
|
|
assert.equal(second.sftpId, "sftp-no-terminal-2");
|
|
assert.equal(MockSftpClient.instances.length, 1, "the second open must not repeat SSH authentication");
|
|
assert.equal(sftpClients.size, 1);
|
|
});
|
|
|
|
test("explicitly dedicated SFTP opens stay outside shared dial coordination", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, { alwaysSucceed: true });
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
const options = {
|
|
hostname: "192.168.9.138",
|
|
hostId: "host-sftp-dedicated",
|
|
port: 22,
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "saved-login-password",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
fileProtocol: "sftp",
|
|
reuseTransport: false,
|
|
};
|
|
|
|
await Promise.all([
|
|
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-dedicated-1" }),
|
|
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-dedicated-2" }),
|
|
]);
|
|
|
|
assert.equal(MockSftpClient.instances.length, 2);
|
|
assert.deepEqual(getTransportStats(), {
|
|
transports: 0,
|
|
pendingDials: 0,
|
|
live: 0,
|
|
idle: 0,
|
|
leases: 0,
|
|
defaultIdleTtlMs: 0,
|
|
});
|
|
|
|
await bridge.openSftp(
|
|
{ sender: makeSender() },
|
|
{ ...options, reuseTransport: true, sessionId: "sftp-shareable-after-dedicated" },
|
|
);
|
|
assert.equal(
|
|
MockSftpClient.instances.length,
|
|
3,
|
|
"a later shareable open must dial instead of borrowing a dedicated connection",
|
|
);
|
|
});
|
|
|
|
test("early SFTP preparation failure releases the coordinated dial", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge } = loadSftpBridgeWithAuthRetryMocks(t, {
|
|
alwaysSucceed: true,
|
|
defaultKeysError: new Error("identity discovery failed"),
|
|
});
|
|
bridge.init({ sftpClients: new Map(), sessions: new Map(), electronModule: {} });
|
|
const options = {
|
|
hostname: "early-failure.example",
|
|
hostId: "host-early-failure",
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
};
|
|
|
|
await assert.rejects(
|
|
bridge.openSftp({ sender: makeSender() }, options),
|
|
/identity discovery failed/,
|
|
);
|
|
assert.equal(getTransportStats().pendingDials, 0);
|
|
|
|
const retry = beginTransportDial(options, { kind: "channel" });
|
|
assert.equal(retry.role, "leader", "the failed opener must not leave a permanent join slot");
|
|
failTransportDial(retry, new Error("test cleanup"));
|
|
});
|
|
|
|
test("forced SFTP subsystem rejection closes the authenticated client without retaining pool state", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, {
|
|
alwaysSucceed: true,
|
|
sftpError: new Error("SFTP subsystem rejected"),
|
|
});
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
|
|
await assert.rejects(
|
|
bridge.openSftp(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "sftp-subsystem-rejected",
|
|
hostname: "no-sftp.example",
|
|
hostId: "host-no-sftp",
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "secret",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
fileProtocol: "sftp",
|
|
},
|
|
),
|
|
/SFTP subsystem rejected/,
|
|
);
|
|
|
|
assert.equal(MockSftpClient.instances.length, 1);
|
|
assert.equal(MockSftpClient.instances[0].client.ended, true);
|
|
assert.equal(MockSftpClient.instances[0].client.destroyed, true);
|
|
assert.equal(sftpClients.size, 0);
|
|
assert.deepEqual(getTransportStats(), {
|
|
transports: 0,
|
|
pendingDials: 0,
|
|
live: 0,
|
|
idle: 0,
|
|
leases: 0,
|
|
defaultIdleTtlMs: 0,
|
|
});
|
|
});
|
|
|
|
test("concurrent SFTP waiter survives the leader's recoverable auth retry", async (t) => {
|
|
resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
|
t.after(() => resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 }));
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t);
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
const options = {
|
|
hostname: "retry-shared.example",
|
|
hostId: "host-retry-shared",
|
|
port: 22,
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "saved-login-password",
|
|
useSshAgent: false,
|
|
verifyHostKeys: false,
|
|
fileProtocol: "sftp",
|
|
};
|
|
|
|
const [leader, waiter] = await Promise.all([
|
|
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-retry-leader" }),
|
|
bridge.openSftp({ sender: makeSender() }, { ...options, sessionId: "sftp-retry-waiter" }),
|
|
]);
|
|
|
|
assert.equal(leader.sftpId, "sftp-retry-leader");
|
|
assert.equal(waiter.sftpId, "sftp-retry-waiter");
|
|
assert.equal(MockSftpClient.instances.length, 2, "only the leader's initial and retry dials are physical");
|
|
assert.equal(sftpClients.size, 2);
|
|
assert.equal(getTransportStats().pendingDials, 0);
|
|
});
|
|
|
|
test("openSftp retries keyboard-interactive first when password rejection removes KI", async (t) => {
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t);
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
|
|
const result = await bridge.openSftp(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "sftp-edr-mfa",
|
|
hostname: "192.168.9.138",
|
|
port: 22,
|
|
username: "root",
|
|
authMethod: "password",
|
|
password: "saved-login-password",
|
|
sshTcpConnectTimeoutMs: 45_000,
|
|
sshAuthReadyTimeoutMs: 300_000,
|
|
},
|
|
);
|
|
|
|
assert.equal(result.sftpId, "sftp-edr-mfa");
|
|
assert.equal(MockSftpClient.instances.length, 2);
|
|
assert.deepEqual(MockSftpClient.instances[0].authMethodsOffered, [
|
|
"none",
|
|
"password",
|
|
false,
|
|
]);
|
|
assert.deepEqual(MockSftpClient.instances[1].authMethodsOffered, [
|
|
"none",
|
|
"keyboard-interactive",
|
|
]);
|
|
assert.equal(sftpClients.has("sftp-edr-mfa"), true);
|
|
});
|
|
|
|
test("openSftp retries keyboard-interactive after dynamic auth password rejection removes KI", async (t) => {
|
|
const { bridge, MockSftpClient } = loadSftpBridgeWithAuthRetryMocks(t, {
|
|
dynamicAuth: true,
|
|
defaultKeys: [{
|
|
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nmock-default-key\n-----END OPENSSH PRIVATE KEY-----",
|
|
keyPath: "id_ed25519",
|
|
keyName: "id_ed25519",
|
|
}],
|
|
});
|
|
const sftpClients = new Map();
|
|
bridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
|
|
|
const result = await bridge.openSftp(
|
|
{ sender: makeSender() },
|
|
{
|
|
sessionId: "sftp-edr-mfa-auto",
|
|
hostname: "192.168.9.138",
|
|
port: 22,
|
|
username: "root",
|
|
authMethod: "auto",
|
|
password: "saved-login-password",
|
|
sshTcpConnectTimeoutMs: 45_000,
|
|
sshAuthReadyTimeoutMs: 300_000,
|
|
},
|
|
);
|
|
|
|
assert.equal(result.sftpId, "sftp-edr-mfa-auto");
|
|
assert.equal(MockSftpClient.instances.length, 2);
|
|
assert.deepEqual(
|
|
MockSftpClient.instances[0].authMethodsOffered.map((method) => (
|
|
method?.type === "publickey" ? "publickey" : method?.type || method
|
|
)),
|
|
["none", "publickey", "password", false],
|
|
);
|
|
assert.deepEqual(
|
|
MockSftpClient.instances[1].authMethodsOffered.map((method) => (
|
|
method?.type === "publickey" ? "publickey" : method?.type || method
|
|
)),
|
|
["none", "publickey", "keyboard-interactive"],
|
|
);
|
|
assert.equal(sftpClients.has("sftp-edr-mfa-auto"), true);
|
|
});
|