mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-24 08:34:23 +00:00
375 lines
11 KiB
TypeScript
375 lines
11 KiB
TypeScript
import { EventEmitter } from 'node:events';
|
|
|
|
import type { AnyAuthMethod, ConnectConfig, SFTPWrapper, Stats as SFTPStats } from 'ssh2';
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { SSHKaos as SSHKaosType } from '#/ssh';
|
|
|
|
interface CreateHarnessState {
|
|
attemptedKeys: string[];
|
|
connectConfigs: ConnectConfig[];
|
|
endCalls: number;
|
|
readFileCalls: string[];
|
|
}
|
|
|
|
interface CreateHarnessOptions {
|
|
onConnect?: (client: EventEmitter, config: ConnectConfig, state: CreateHarnessState) => void;
|
|
readFileValues?: Record<string, string>;
|
|
sftp?: SFTPWrapper;
|
|
sftpError?: Error;
|
|
}
|
|
|
|
function makeStats(isDirectory: boolean): SFTPStats {
|
|
return {
|
|
mode: isDirectory ? 0o040755 : 0o100644,
|
|
uid: 1000,
|
|
gid: 1000,
|
|
size: 0,
|
|
atime: 0,
|
|
mtime: 0,
|
|
isDirectory: () => isDirectory,
|
|
isFile: () => !isDirectory,
|
|
isBlockDevice: () => false,
|
|
isCharacterDevice: () => false,
|
|
isSymbolicLink: () => false,
|
|
isFIFO: () => false,
|
|
isSocket: () => false,
|
|
};
|
|
}
|
|
|
|
function createSuccessfulSftp(): SFTPWrapper {
|
|
return {
|
|
realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void {
|
|
if (path === '.') {
|
|
callback(undefined, '/home/tester');
|
|
return;
|
|
}
|
|
callback(undefined, path);
|
|
},
|
|
stat(path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void {
|
|
callback(undefined, makeStats(path !== '/home/tester/file.txt'));
|
|
},
|
|
end(): void {
|
|
// no-op
|
|
},
|
|
} as SFTPWrapper;
|
|
}
|
|
|
|
async function loadSSHModule(options: CreateHarnessOptions = {}): Promise<{
|
|
SSHKaos: typeof SSHKaosType;
|
|
state: CreateHarnessState;
|
|
}> {
|
|
vi.resetModules();
|
|
|
|
const state: CreateHarnessState = {
|
|
attemptedKeys: [],
|
|
connectConfigs: [],
|
|
endCalls: 0,
|
|
readFileCalls: [],
|
|
};
|
|
|
|
class MockClient extends EventEmitter {
|
|
connect(config: ConnectConfig): void {
|
|
state.connectConfigs.push(config);
|
|
if (options.onConnect) {
|
|
options.onConnect(this, config, state);
|
|
return;
|
|
}
|
|
queueMicrotask(() => {
|
|
this.emit('ready');
|
|
});
|
|
}
|
|
|
|
end(): void {
|
|
state.endCalls += 1;
|
|
queueMicrotask(() => {
|
|
this.emit('close');
|
|
});
|
|
}
|
|
|
|
sftp(callback: (err?: Error, sftp?: SFTPWrapper) => void): void {
|
|
if (options.sftpError) {
|
|
callback(options.sftpError);
|
|
return;
|
|
}
|
|
callback(undefined, options.sftp ?? createSuccessfulSftp());
|
|
}
|
|
}
|
|
|
|
vi.doMock('ssh2', () => ({
|
|
Client: MockClient,
|
|
utils: {
|
|
sftp: {
|
|
STATUS_CODE: {
|
|
NO_SUCH_FILE: 2,
|
|
PERMISSION_DENIED: 3,
|
|
FAILURE: 4,
|
|
NO_CONNECTION: 6,
|
|
CONNECTION_LOST: 7,
|
|
OP_UNSUPPORTED: 8,
|
|
},
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.doMock('node:fs/promises', () => ({
|
|
readFile: vi.fn(async (path: string) => {
|
|
state.readFileCalls.push(path);
|
|
const value = options.readFileValues?.[path];
|
|
if (value === undefined) {
|
|
throw new Error(`Unexpected readFile(${path})`);
|
|
}
|
|
return value;
|
|
}),
|
|
}));
|
|
|
|
const { SSHKaos } = await import('#/ssh');
|
|
return { SSHKaos, state };
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.doUnmock('ssh2');
|
|
vi.restoreAllMocks();
|
|
vi.resetModules();
|
|
});
|
|
|
|
describe('SSHKaos.create()', () => {
|
|
it('initializes cwd equal to gethome() when no cwd option is passed', async () => {
|
|
// Pins the Python test_ssh_kaos.py::test_pathclass_home_and_cwd invariant:
|
|
// on a fresh SSH connection without an explicit cwd, `getcwd()` must equal
|
|
// `gethome()`. The smoke-level SSH suite can't cover this because its
|
|
// beforeEach chdirs into a per-test remote dir; this mock harness lets us
|
|
// check the invariant without a live SSH server.
|
|
const { SSHKaos } = await loadSSHModule();
|
|
|
|
const ssh = await SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
});
|
|
|
|
expect(ssh.pathClass()).toBe('posix');
|
|
expect(ssh.gethome()).toBe('/home/tester');
|
|
expect(ssh.getcwd()).toBe('/home/tester');
|
|
expect(ssh.getcwd()).toBe(ssh.gethome());
|
|
});
|
|
|
|
it('rejects a cwd that resolves to a regular file', async () => {
|
|
const sftp = {
|
|
realpath(path: string, callback: (err: Error | undefined, absPath: string) => void): void {
|
|
if (path === '.') {
|
|
callback(undefined, '/home/tester');
|
|
return;
|
|
}
|
|
callback(undefined, '/home/tester/file.txt');
|
|
},
|
|
stat(_path: string, callback: (err: Error | undefined, stats: SFTPStats) => void): void {
|
|
callback(undefined, makeStats(false));
|
|
},
|
|
end(): void {
|
|
// no-op
|
|
},
|
|
} as SFTPWrapper;
|
|
|
|
const { SSHKaos, state } = await loadSSHModule({ sftp });
|
|
|
|
const error = await SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
cwd: 'file.txt',
|
|
}).catch((error: unknown) => error);
|
|
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect((error as Error).name).toBe('KaosValueError');
|
|
expect((error as Error).message).toMatch(/not a directory/);
|
|
expect(state.endCalls).toBe(1);
|
|
});
|
|
|
|
it('tries multiple private keys via authHandler until one succeeds', async () => {
|
|
const { SSHKaos, state } = await loadSSHModule({
|
|
readFileValues: {
|
|
'/keys/second': 'second-key',
|
|
},
|
|
onConnect(client, config, harnessState) {
|
|
const authHandler = config.authHandler;
|
|
if (typeof authHandler !== 'function') {
|
|
client.emit('error', new Error('missing authHandler'));
|
|
return;
|
|
}
|
|
|
|
const requestNextKey = (): void => {
|
|
authHandler([], false, (auth: string | AnyAuthMethod | false) => {
|
|
expect(auth).not.toBe(false);
|
|
expect(typeof auth).toBe('object');
|
|
if (auth === false || typeof auth !== 'object') {
|
|
client.emit('error', new Error('authHandler returned no auth method'));
|
|
return;
|
|
}
|
|
if (auth.type !== 'publickey') {
|
|
client.emit('error', new Error(`unexpected auth type: ${auth.type}`));
|
|
return;
|
|
}
|
|
const key =
|
|
typeof auth.key === 'string'
|
|
? auth.key
|
|
: Buffer.isBuffer(auth.key)
|
|
? auth.key.toString('utf-8')
|
|
: JSON.stringify(auth.key);
|
|
harnessState.attemptedKeys.push(key);
|
|
if (key === 'first-key') {
|
|
requestNextKey();
|
|
return;
|
|
}
|
|
queueMicrotask(() => {
|
|
client.emit('ready');
|
|
});
|
|
});
|
|
};
|
|
|
|
requestNextKey();
|
|
},
|
|
});
|
|
|
|
const ssh = await SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
keyContents: ['first-key'],
|
|
keyPaths: ['/keys/second'],
|
|
});
|
|
|
|
expect(ssh.getcwd()).toBe('/home/tester');
|
|
expect(state.readFileCalls).toEqual(['/keys/second']);
|
|
expect(state.attemptedKeys).toEqual(['first-key', 'second-key']);
|
|
expect(state.connectConfigs[0]?.authHandler).toBeTypeOf('function');
|
|
expect(state.connectConfigs[0]?.privateKey).toBeUndefined();
|
|
});
|
|
|
|
it('ends the client when opening SFTP fails after connect succeeds', async () => {
|
|
const { SSHKaos, state } = await loadSSHModule({
|
|
sftpError: new Error('sftp open failed'),
|
|
});
|
|
|
|
await expect(
|
|
SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
}),
|
|
).rejects.toThrow(/sftp open failed/);
|
|
expect(state.endCalls).toBe(1);
|
|
});
|
|
|
|
it('merges extraOptions into the ssh2 ConnectConfig', async () => {
|
|
const { SSHKaos, state } = await loadSSHModule();
|
|
|
|
await SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
extraOptions: {
|
|
keepaliveInterval: 10_000,
|
|
readyTimeout: 5_000,
|
|
// Cast to the ssh2 Algorithms shape — deliberately narrow for the test
|
|
// harness so we can assert pass-through without pulling the full ssh2
|
|
// type graph.
|
|
algorithms: { cipher: ['aes256-ctr'] } as never,
|
|
},
|
|
});
|
|
|
|
const cfg = state.connectConfigs[0];
|
|
expect(cfg).toBeDefined();
|
|
expect(cfg?.keepaliveInterval).toBe(10_000);
|
|
expect(cfg?.readyTimeout).toBe(5_000);
|
|
expect(cfg?.algorithms).toEqual({ cipher: ['aes256-ctr'] });
|
|
// Managed fields must still come from top-level options.
|
|
expect(cfg?.host).toBe('example.com');
|
|
expect(cfg?.username).toBe('tester');
|
|
});
|
|
|
|
it('managed fields override extraOptions when both are specified', async () => {
|
|
const { SSHKaos, state } = await loadSSHModule();
|
|
|
|
await SSHKaos.create({
|
|
host: 'managed.example.com',
|
|
username: 'managed',
|
|
extraOptions: {
|
|
// Malicious / accidental attempts to override managed fields must
|
|
// NOT win — the top-level values take precedence.
|
|
host: 'attacker.example.com',
|
|
username: 'attacker',
|
|
port: 1234,
|
|
} as never,
|
|
});
|
|
|
|
const cfg = state.connectConfigs[0];
|
|
expect(cfg?.host).toBe('managed.example.com');
|
|
expect(cfg?.username).toBe('managed');
|
|
expect(cfg?.port).toBe(22); // default, not the 1234 from extraOptions
|
|
});
|
|
|
|
it('forwards a password-only connection without building an authHandler', async () => {
|
|
// Password auth without any private keys must wire ssh2 ConnectConfig.password
|
|
// directly rather than constructing an authHandler (the handler is only
|
|
// needed when we're rotating through multiple private keys).
|
|
const { SSHKaos, state } = await loadSSHModule();
|
|
|
|
const ssh = await SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
password: 'hunter2',
|
|
});
|
|
|
|
const cfg = state.connectConfigs[0];
|
|
expect(cfg?.password).toBe('hunter2');
|
|
expect(cfg?.authHandler).toBeUndefined();
|
|
expect(ssh.getcwd()).toBe('/home/tester');
|
|
});
|
|
|
|
it('queues password after private keys when both are provided', async () => {
|
|
// When the user supplies both keyContents and a password, buildAuthHandler
|
|
// should try every private key first, then fall back to password auth.
|
|
// We observe this by walking the handler to exhaustion and recording
|
|
// which auth entries it yields.
|
|
const yielded: string[] = [];
|
|
const { SSHKaos } = await loadSSHModule({
|
|
onConnect(client, config) {
|
|
const handler = config.authHandler;
|
|
if (typeof handler !== 'function') {
|
|
client.emit('error', new Error('missing authHandler'));
|
|
return;
|
|
}
|
|
|
|
// Drain the handler — it yields one auth method per call until it
|
|
// runs out, at which point it must emit `false` to signal "no more
|
|
// auth methods" (but we stop as soon as we see the password entry).
|
|
const pump = (): void => {
|
|
handler([], false, (auth: string | AnyAuthMethod | false) => {
|
|
if (auth === false) {
|
|
client.emit('error', new Error('unexpected end of auth queue'));
|
|
return;
|
|
}
|
|
if (typeof auth !== 'object') {
|
|
client.emit('error', new Error(`unexpected auth: ${auth}`));
|
|
return;
|
|
}
|
|
yielded.push(auth.type);
|
|
if (auth.type === 'password') {
|
|
queueMicrotask(() => {
|
|
client.emit('ready');
|
|
});
|
|
return;
|
|
}
|
|
pump();
|
|
});
|
|
};
|
|
pump();
|
|
},
|
|
});
|
|
|
|
await SSHKaos.create({
|
|
host: 'example.com',
|
|
username: 'tester',
|
|
keyContents: ['only-key'],
|
|
password: 'hunter2',
|
|
});
|
|
|
|
expect(yielded).toEqual(['publickey', 'password']);
|
|
});
|
|
});
|