@godspeedai/cognate-execution-ssh 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,41 @@
1
- # Temporary Holding Version
1
+ # @godspeedai/cognate-execution-ssh — an SSH host as an execution world
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ `sshExecutionWorld(options)` is an `ExecutionWorldProvider`: files over SFTP, commands over `exec`,
4
+ behind the same `filesystem.*` / `process.exec` contracts as every other world. Nothing above the
5
+ provider knows it is SSH.
6
+
7
+ ```ts
8
+ const world = sshExecutionWorld({
9
+ worldId: "build-box", host: "build.example.com", username: "dev",
10
+ auth: { kind: "key", privateKeyPath: "~/.ssh/id_ed25519" }, // or { kind: "agent" } / { kind: "password", password }
11
+ hostKeys: { fingerprints: ["SHA256:…"] }, // or { knownHostsPath } — REQUIRED
12
+ jump: [{ host: "bastion", username: "dev", auth: { kind: "agent" }, hostKeys: { knownHostsPath } }], // ProxyJump
13
+ });
14
+ ```
15
+
16
+ ## What it guarantees
17
+
18
+ | Guarantee | How |
19
+ | --- | --- |
20
+ | The server is who you pinned | A host-key policy is mandatory; no TOFU, no accept-anything mode. Mismatch → `HostKeyMismatchError`, never retried, raised before any authentication. Hashed/plain `known_hosts`, `[host]:port`, wildcards, negation and `@revoked` are honoured. |
21
+ | Trust is a human act | `probeHostKey()` reads the key and *refuses* it, so nothing is authenticated or sent; show the fingerprint to a person, then pin it. |
22
+ | Fail closed | An unreachable host, changed key or disposed world raises `WorldUnavailableError`. Nothing ever falls back to local execution. |
23
+ | Containment | `resolve` rejects lexical escapes; a symlink inside the root pointing outside is refused before read/write/stat/list (SFTP `realpath`). |
24
+ | A cancelled command really ends | Commands run under a supervisor: own process group, ended on `TERM` **or** when stdin reaches EOF (closed channel, dropped connection) — so it works against servers that ignore signal requests, and the whole process *tree* dies. |
25
+ | Reconnect | A dropped connection is re-dialled on the next operation; idempotent file operations retry once, commands and writes never do. |
26
+ | Disposal | `dispose()` closes live channels, SFTP and every hop; idempotent; later calls fail closed. |
27
+ | No secrets leak | `metadata` carries host, port, user, auth *kind* and the verified host-key fingerprint — never a password, passphrase or key. |
28
+
29
+ ## Testing
30
+
31
+ - `test/ssh.test.ts` runs the shared conformance suite and every behaviour above against a real
32
+ ssh2 `Server` (`./fixture`: auth, `exec` on a real shell, an SFTP subset, direct-tcpip forwarding).
33
+ - The live gate (`just remote-workspaces-live`) runs the same suite against a **real OpenSSH sshd**
34
+ built in user space (`just setup-openssh`), and optionally against your own host
35
+ (`COGNATE_SSH_HOST`, `COGNATE_SSH_USER`, `COGNATE_SSH_KEY`).
36
+
37
+ ## Known limits
38
+
39
+ Remote login shell must accept `exec sh -c '<posix script>'`. `version` is `mtime(seconds):size`. The
40
+ symlink check is check-then-use (a concurrent swap between check and use is not prevented). No
41
+ keyboard-interactive/2FA, no `@cert-authority`. See `.agents/DEBT.md` (DEBT-026).
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "@godspeedai/cognate-execution-ssh",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "SSH host as an execution world: SFTP filesystem and remote exec over ssh2, with mandatory host-key verification, ProxyJump and clean disposal. Same contracts as every other provider.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/GodSpeedAI/Cognate.git",
10
+ "directory": "packages/execution-ssh"
11
+ },
12
+ "engines": {
13
+ "bun": ">=1.4.0"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "exports": {
22
+ ".": "./src/index.ts",
23
+ "./fixture": "./src/fixture.ts"
24
+ },
25
+ "dependencies": {
26
+ "@godspeedai/cognate-execution": "^0.1.1",
27
+ "ssh2": "1.17.0"
28
+ },
29
+ "devDependencies": {
30
+ "@types/ssh2": "^1.15.6"
31
+ }
32
+ }
package/src/fixture.ts ADDED
@@ -0,0 +1,158 @@
1
+ // A real SSH server (ssh2 `Server`) for deterministic tests: password/public-key auth, `exec` that
2
+ // runs a real local shell, an SFTP subset backed by the real filesystem, and direct-tcpip forwarding
3
+ // (so ProxyJump can be exercised). It is a test double for the *remote end only* — the provider
4
+ // under test is the genuine client. Counters expose connection/session lifecycle for disposal tests.
5
+ import { createServer as netServer, connect } from "node:net";
6
+ import { promises as fsp, constants as fsc } from "node:fs";
7
+ import { dirname, resolve } from "node:path";
8
+ import { Server, utils } from "ssh2";
9
+ import type { Connection, ServerChannel } from "ssh2";
10
+ import { fingerprintOf } from "./hostKeys.ts";
11
+
12
+ const { STATUS_CODE, OPEN_MODE } = utils.sftp;
13
+
14
+ export interface SshFixtureOptions {
15
+ readonly users: Readonly<Record<string, { readonly password?: string; readonly publicKeys?: readonly string[] }>>;
16
+ /** Reuse a host key (PEM) so a test can present a *different* identity on the same port. */
17
+ readonly hostKeyPem?: string;
18
+ /** Set to refuse `signal` requests like a server that ignores them. */
19
+ readonly ignoreSignals?: boolean;
20
+ }
21
+
22
+ export interface SshFixture {
23
+ readonly port: number;
24
+ readonly hostKeyPem: string;
25
+ readonly hostKeyBlob: Buffer;
26
+ readonly fingerprint: string;
27
+ readonly knownHostsLine: (host?: string) => string;
28
+ readonly stats: { connections: number; open: number; execs: number; sftp: number; sftpOpens: number; liveExecs: number };
29
+ dropConnections(): void;
30
+ close(): Promise<void>;
31
+ }
32
+
33
+ const attrsOf = (st: import("node:fs").Stats) => ({ mode: st.mode, uid: st.uid, gid: st.gid, size: st.size, atime: Math.floor(st.atimeMs / 1000), mtime: Math.floor(st.mtimeMs / 1000) });
34
+
35
+ export async function startSshFixture(options: SshFixtureOptions): Promise<SshFixture> {
36
+ const generated = options.hostKeyPem ? undefined : utils.generateKeyPairSync("ed25519");
37
+ const hostKeyPem = options.hostKeyPem ?? generated!.private;
38
+ const parsed = utils.parseKey(hostKeyPem);
39
+ if (parsed instanceof Error) throw parsed;
40
+ const hostKeyBlob = parsed.getPublicSSH();
41
+ const stats = { connections: 0, open: 0, execs: 0, sftp: 0, sftpOpens: 0, liveExecs: 0 };
42
+ const conns = new Set<Connection>();
43
+ const sockets = new Set<import("node:net").Socket>();
44
+
45
+ const server = new Server({ hostKeys: [hostKeyPem] }, (client) => {
46
+ stats.connections++; stats.open++; conns.add(client);
47
+ client.on("close", () => { stats.open--; conns.delete(client); });
48
+ client.on("error", () => {});
49
+ client.on("authentication", (ctx) => {
50
+ const user = options.users[ctx.username];
51
+ if (!user) return ctx.reject();
52
+ if (ctx.method === "password" && user.password !== undefined && ctx.password === user.password) return ctx.accept();
53
+ if (ctx.method === "publickey" && user.publicKeys) {
54
+ const offered = ctx.key;
55
+ const ok = user.publicKeys.some((k) => { const p = utils.parseKey(k); return !(p instanceof Error) && p.getPublicSSH().equals(offered.data); });
56
+ if (ok && (!ctx.signature || ctx.blob)) {
57
+ if (!ctx.signature) return ctx.accept();
58
+ const p = utils.parseKey(user.publicKeys.find((k) => { const q = utils.parseKey(k); return !(q instanceof Error) && q.getPublicSSH().equals(offered.data); })!);
59
+ if (!(p instanceof Error) && p.verify(ctx.blob!, ctx.signature, ctx.hashAlgo as never) === true) return ctx.accept();
60
+ }
61
+ }
62
+ return ctx.reject(["password", "publickey"]);
63
+ });
64
+ client.on("ready", () => {
65
+ client.on("session", (accept) => {
66
+ const session = accept();
67
+ let child: ReturnType<typeof Bun.spawn> | undefined;
68
+ session.on("signal", (acc) => { acc?.(); if (!options.ignoreSignals) child?.kill(); });
69
+ session.on("exec", (acc, _rej, info) => {
70
+ const channel: ServerChannel = acc();
71
+ stats.execs++; stats.liveExecs++;
72
+ const proc = Bun.spawn(["sh", "-c", info.command], { stdout: "pipe", stderr: "pipe", stdin: "pipe" });
73
+ child = proc;
74
+ const stdin = proc.stdin;
75
+ channel.on("data", (d: Buffer) => { try { stdin.write(d); } catch { /* child gone */ } });
76
+ // Like sshd without a pty: channel EOF/close closes the command's stdin; it does NOT kill it.
77
+ const closeStdin = () => { try { void stdin.end(); } catch { /* already closed */ } };
78
+ channel.on("end", closeStdin); channel.on("close", closeStdin);
79
+ const pump = async (src: ReadableStream<Uint8Array>, write: (b: Buffer) => void) => { for await (const c of src) write(Buffer.from(c)); };
80
+ void Promise.all([pump(child.stdout as ReadableStream<Uint8Array>, (b) => channel.write(b)), pump(child.stderr as ReadableStream<Uint8Array>, (b) => channel.stderr.write(b))]).then(async () => {
81
+ const code = await child!.exited;
82
+ stats.liveExecs--;
83
+ try { channel.exit(code ?? 255); channel.end(); } catch { /* channel gone */ }
84
+ });
85
+ });
86
+ session.on("sftp", (acc) => { stats.sftp++; serveSftp(acc(), stats); });
87
+ });
88
+ client.on("tcpip", (acc, rej, info) => {
89
+ const sock = connect(info.destPort, info.destIP);
90
+ sockets.add(sock);
91
+ sock.once("error", () => rej());
92
+ sock.once("connect", () => { const ch = acc(); ch.pipe(sock).pipe(ch); ch.on("close", () => sock.destroy()); });
93
+ sock.on("close", () => sockets.delete(sock));
94
+ });
95
+ });
96
+ });
97
+ await new Promise<void>((res) => server.listen(0, "127.0.0.1", res));
98
+ const port = (server.address() as { port: number }).port;
99
+ return {
100
+ port, hostKeyPem, hostKeyBlob, fingerprint: fingerprintOf(hostKeyBlob), stats,
101
+ knownHostsLine: (host = "127.0.0.1") => `[${host}]:${port} ${parsed.type} ${hostKeyBlob.toString("base64")}`,
102
+ dropConnections() { for (const c of conns) c.end(); for (const s of sockets) s.destroy(); },
103
+ async close() { for (const c of conns) c.end(); for (const s of sockets) s.destroy(); await new Promise<void>((r) => server.close(() => r())); },
104
+ };
105
+ }
106
+
107
+ function serveSftp(sftp: import("ssh2").SFTPWrapper, stats: { sftpOpens: number }): void {
108
+ type H = { kind: "file"; fd: import("node:fs/promises").FileHandle } | { kind: "dir"; entries: string[]; path: string; done: boolean };
109
+ const handles = new Map<number, H>();
110
+ let next = 1;
111
+ const handleBuf = (id: number) => { const b = Buffer.alloc(4); b.writeUInt32BE(id); return b; };
112
+ const idOf = (b: Buffer) => b.readUInt32BE(0);
113
+ const fail = (reqid: number, e: unknown) => sftp.status(reqid, (e as NodeJS.ErrnoException)?.code === "ENOENT" ? STATUS_CODE.NO_SUCH_FILE : (e as NodeJS.ErrnoException)?.code === "EACCES" ? STATUS_CODE.PERMISSION_DENIED : STATUS_CODE.FAILURE);
114
+ const ok = (reqid: number) => sftp.status(reqid, STATUS_CODE.OK);
115
+
116
+ sftp.on("REALPATH", async (reqid, path) => {
117
+ try { const real = await fsp.realpath(path); sftp.name(reqid, [{ filename: real, longname: real, attrs: {} as never }]); } catch (e) { fail(reqid, e); }
118
+ });
119
+ const doStat = (follow: boolean) => async (reqid: number, path: string) => {
120
+ try { const st = await (follow ? fsp.stat(path) : fsp.lstat(path)); sftp.attrs(reqid, attrsOf(st) as never); } catch (e) { fail(reqid, e); }
121
+ };
122
+ sftp.on("STAT", doStat(true)); sftp.on("LSTAT", doStat(false));
123
+ sftp.on("FSTAT", async (reqid, h) => { const e = handles.get(idOf(h)); if (e?.kind !== "file") return sftp.status(reqid, STATUS_CODE.FAILURE); try { sftp.attrs(reqid, attrsOf(await e.fd.stat()) as never); } catch (er) { fail(reqid, er); } });
124
+ sftp.on("OPEN", async (reqid, path, flags) => {
125
+ stats.sftpOpens++;
126
+ try {
127
+ let f = 0;
128
+ if ((flags & OPEN_MODE.READ) && (flags & OPEN_MODE.WRITE)) f = fsc.O_RDWR; else if (flags & OPEN_MODE.WRITE) f = fsc.O_WRONLY; else f = fsc.O_RDONLY;
129
+ if (flags & OPEN_MODE.CREAT) f |= fsc.O_CREAT; if (flags & OPEN_MODE.TRUNC) f |= fsc.O_TRUNC; if (flags & OPEN_MODE.EXCL) f |= fsc.O_EXCL; if (flags & OPEN_MODE.APPEND) f |= fsc.O_APPEND;
130
+ const fd = await fsp.open(path, f, 0o644);
131
+ const id = next++; handles.set(id, { kind: "file", fd }); sftp.handle(reqid, handleBuf(id));
132
+ } catch (e) { fail(reqid, e); }
133
+ });
134
+ sftp.on("READ", async (reqid, h, offset, length) => {
135
+ const e = handles.get(idOf(h)); if (e?.kind !== "file") return sftp.status(reqid, STATUS_CODE.FAILURE);
136
+ try { const buf = Buffer.alloc(length); const { bytesRead } = await e.fd.read(buf, 0, length, offset); if (bytesRead === 0) sftp.status(reqid, STATUS_CODE.EOF); else sftp.data(reqid, buf.subarray(0, bytesRead)); } catch (er) { fail(reqid, er); }
137
+ });
138
+ sftp.on("WRITE", async (reqid, h, offset, data) => {
139
+ const e = handles.get(idOf(h)); if (e?.kind !== "file") return sftp.status(reqid, STATUS_CODE.FAILURE);
140
+ try { await e.fd.write(data, 0, data.length, offset); ok(reqid); } catch (er) { fail(reqid, er); }
141
+ });
142
+ sftp.on("CLOSE", async (reqid, h) => { const e = handles.get(idOf(h)); handles.delete(idOf(h)); if (e?.kind === "file") await e.fd.close().catch(() => {}); ok(reqid); });
143
+ sftp.on("OPENDIR", async (reqid, path) => { try { const entries = await fsp.readdir(path); const id = next++; handles.set(id, { kind: "dir", entries, path, done: false }); sftp.handle(reqid, handleBuf(id)); } catch (e) { fail(reqid, e); } });
144
+ sftp.on("READDIR", async (reqid, h) => {
145
+ const e = handles.get(idOf(h)); if (e?.kind !== "dir" || e.done) return sftp.status(reqid, STATUS_CODE.EOF);
146
+ e.done = true;
147
+ const names = await Promise.all(e.entries.map(async (n) => ({ filename: n, longname: n, attrs: attrsOf(await fsp.lstat(resolve(e.path, n))) as never })));
148
+ sftp.name(reqid, names);
149
+ });
150
+ sftp.on("MKDIR", async (reqid, path) => { try { await fsp.mkdir(path); ok(reqid); } catch (e) { fail(reqid, e); } });
151
+ sftp.on("REMOVE", async (reqid, path) => { try { await fsp.unlink(path); ok(reqid); } catch (e) { fail(reqid, e); } });
152
+ sftp.on("RMDIR", async (reqid, path) => { try { await fsp.rmdir(path); ok(reqid); } catch (e) { fail(reqid, e); } });
153
+ sftp.on("RENAME", async (reqid, a, b) => { try { await fsp.mkdir(dirname(b), { recursive: true }); await fsp.rename(a, b); ok(reqid); } catch (e) { fail(reqid, e); } });
154
+ sftp.on("SETSTAT", (reqid) => ok(reqid)); sftp.on("FSETSTAT", (reqid) => ok(reqid));
155
+ sftp.on("end", () => { for (const e of handles.values()) if (e.kind === "file") void e.fd.close().catch(() => {}); handles.clear(); });
156
+ }
157
+
158
+ export { netServer };
@@ -0,0 +1,80 @@
1
+ // Host identity verification. A connection is refused unless the server's key matches an expected
2
+ // SHA-256 fingerprint or an OpenSSH `known_hosts` entry; there is no trust-on-first-use and no
3
+ // "accept anything" mode. Hashed (`|1|salt|hash`) and plain entries, `[host]:port`, wildcards,
4
+ // negation and `@revoked` are honoured; `@cert-authority` entries are ignored (not supported).
5
+ import { createHash, createHmac } from "node:crypto";
6
+ import { readFileSync } from "node:fs";
7
+
8
+ export interface HostKeyPolicy {
9
+ /** Accept exactly these `SHA256:<base64>` fingerprints (as printed by `ssh-keygen -lf`). */
10
+ readonly fingerprints?: readonly string[];
11
+ /** OpenSSH known_hosts file contents. */
12
+ readonly knownHosts?: string;
13
+ /** Path to an OpenSSH known_hosts file, read at connect time. */
14
+ readonly knownHostsPath?: string;
15
+ }
16
+
17
+ export function fingerprintOf(key: Buffer): string {
18
+ return `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`;
19
+ }
20
+
21
+ export function policyIsEmpty(policy: HostKeyPolicy): boolean {
22
+ return !(policy.fingerprints?.length || policy.knownHosts || policy.knownHostsPath);
23
+ }
24
+
25
+ function wildcard(pattern: string, value: string): boolean {
26
+ const re = new RegExp(`^${pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
27
+ return re.test(value);
28
+ }
29
+
30
+ function hostMatches(field: string, names: readonly string[]): boolean {
31
+ if (field.startsWith("|1|")) {
32
+ const [, , salt, hash] = field.split("|");
33
+ if (!salt || !hash) return false;
34
+ const saltBytes = Buffer.from(salt, "base64");
35
+ return names.some((n) => createHmac("sha1", saltBytes).update(n).digest("base64") === hash);
36
+ }
37
+ let matched = false;
38
+ for (const raw of field.split(",")) {
39
+ const negated = raw.startsWith("!");
40
+ const hit = names.some((n) => wildcard(negated ? raw.slice(1) : raw, n));
41
+ if (hit && negated) return false;
42
+ if (hit) matched = true;
43
+ }
44
+ return matched;
45
+ }
46
+
47
+ /** Returns "trusted" | "revoked" | "unknown" for one server key against known_hosts text. */
48
+ export function checkKnownHosts(text: string, host: string, port: number, key: Buffer): "trusted" | "revoked" | "unknown" {
49
+ const names = port === 22 ? [host, `[${host}]:22`] : [`[${host}]:${port}`];
50
+ const b64 = key.toString("base64");
51
+ let trusted = false;
52
+ for (const line of text.split("\n")) {
53
+ const t = line.trim();
54
+ if (!t || t.startsWith("#")) continue;
55
+ const parts = t.split(/\s+/);
56
+ let marker: string | undefined;
57
+ if (parts[0]!.startsWith("@")) marker = parts.shift();
58
+ if (marker === "@cert-authority") continue;
59
+ const [hosts, , keyB64] = parts;
60
+ if (!hosts || !keyB64 || keyB64 !== b64 || !hostMatches(hosts, names)) continue;
61
+ if (marker === "@revoked") return "revoked";
62
+ trusted = true;
63
+ }
64
+ return trusted ? "trusted" : "unknown";
65
+ }
66
+
67
+ export function verifyHostKey(policy: HostKeyPolicy, host: string, port: number, key: Buffer): boolean {
68
+ const text = policy.knownHosts ?? (policy.knownHostsPath ? safeRead(policy.knownHostsPath) : undefined);
69
+ const known = text === undefined ? "unknown" : checkKnownHosts(text, host, port, key);
70
+ if (known === "revoked") return false; // a revoked key is never accepted, even if pinned elsewhere
71
+ return known === "trusted" || policy.fingerprints?.includes(fingerprintOf(key)) === true;
72
+ }
73
+
74
+ function safeRead(path: string): string | undefined {
75
+ try {
76
+ return readFileSync(path, "utf8");
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
package/src/index.ts ADDED
@@ -0,0 +1,323 @@
1
+ // An SSH host as an execution world (ssh2). Files go over SFTP, commands over `exec`. Everything
2
+ // the contracts promise holds here: containment at `resolve` plus a symlink-safe check before each
3
+ // file operation; a missing path is `undefined`; timeouts and aborts end the remote command; and the
4
+ // world FAILS CLOSED — an unreachable host, a changed host key or a disposed world raises
5
+ // WorldUnavailableError and nothing ever falls back to local execution.
6
+ //
7
+ // Mechanism only is borrowed from the studied donor (connection reuse, lazy SFTP, ProxyJump via
8
+ // forwardOut); the code is original and, unlike the donor, host-key verification is mandatory.
9
+ import { readFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { Client } from "ssh2";
13
+ import type { ClientChannel, ConnectConfig, SFTPWrapper } from "ssh2";
14
+ import { PathEscapeError, WorldUnavailableError, createRootGuard, shellQuote, supervisedExecScript } from "@godspeedai/cognate-execution";
15
+ import type { DirEntry, ExecResult, ExecSpec, ExecutionWorldProvider, FileStat } from "@godspeedai/cognate-execution";
16
+ import { checkKnownHosts, fingerprintOf, policyIsEmpty, verifyHostKey } from "./hostKeys.ts";
17
+ import type { HostKeyPolicy } from "./hostKeys.ts";
18
+
19
+ export { fingerprintOf, checkKnownHosts, verifyHostKey } from "./hostKeys.ts";
20
+ export type { HostKeyPolicy } from "./hostKeys.ts";
21
+
22
+ export type SshAuth =
23
+ | { readonly kind: "password"; readonly password: string }
24
+ | { readonly kind: "key"; readonly privateKey?: string; readonly privateKeyPath?: string; readonly passphrase?: string }
25
+ | { readonly kind: "agent"; readonly socket?: string };
26
+
27
+ export interface SshHop {
28
+ readonly host: string;
29
+ readonly port?: number;
30
+ readonly username: string;
31
+ readonly auth: SshAuth;
32
+ readonly hostKeys: HostKeyPolicy;
33
+ }
34
+
35
+ export interface SshWorldOptions extends SshHop {
36
+ readonly worldId: string;
37
+ /** Intermediate hosts, outermost first (ProxyJump). Each hop is verified like the target. */
38
+ readonly jump?: readonly SshHop[];
39
+ readonly readyTimeoutMs?: number;
40
+ readonly keepaliveMs?: number;
41
+ readonly defaultTimeoutMs?: number;
42
+ readonly maxOutputBytes?: number;
43
+ }
44
+
45
+ /** The server's identity did not match the configured policy. Never retried, never bypassed. */
46
+ export class HostKeyMismatchError extends WorldUnavailableError {
47
+ override readonly name = "HostKeyMismatchError";
48
+ constructor(worldId: string, readonly host: string, readonly offered: string) {
49
+ super(worldId, `host key for ${host} (${offered}) does not match the configured host-key policy`);
50
+ }
51
+ }
52
+
53
+ const DEFAULT_TIMEOUT_MS = 30_000;
54
+ const DEFAULT_MAX_OUTPUT = 10 * 1024 * 1024;
55
+ const NO_SUCH_FILE = 2;
56
+
57
+ function expandHome(path: string): string {
58
+ return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
59
+ }
60
+
61
+ function authConfig(auth: SshAuth): Partial<ConnectConfig> {
62
+ switch (auth.kind) {
63
+ case "password": return { password: auth.password };
64
+ case "key": {
65
+ const privateKey = auth.privateKey ?? (auth.privateKeyPath ? readFileSync(expandHome(auth.privateKeyPath), "utf8") : undefined);
66
+ if (!privateKey) throw new Error("ssh key auth needs privateKey or privateKeyPath");
67
+ return { privateKey, ...(auth.passphrase ? { passphrase: auth.passphrase } : {}) };
68
+ }
69
+ case "agent": {
70
+ const agent = auth.socket ?? process.env.SSH_AUTH_SOCK;
71
+ if (!agent) throw new Error("ssh agent auth needs a socket (SSH_AUTH_SOCK is unset)");
72
+ return { agent };
73
+ }
74
+ }
75
+ }
76
+
77
+ function connectReady(client: Client, config: ConnectConfig): Promise<void> {
78
+ return new Promise<void>((resolve, reject) => {
79
+ const done = () => { client.removeListener("error", fail); resolve(); };
80
+ const fail = (e: Error) => { client.removeListener("ready", done); reject(e); };
81
+ client.once("ready", done);
82
+ client.once("error", fail);
83
+ client.connect(config);
84
+ });
85
+ }
86
+
87
+ const call = <T>(fn: (cb: (err: Error | null | undefined, value: T) => void) => void): Promise<T> =>
88
+ new Promise<T>((resolve, reject) => fn((err, value) => (err ? reject(err) : resolve(value))));
89
+
90
+ export function sshExecutionWorld(options: SshWorldOptions): ExecutionWorldProvider {
91
+ const { worldId } = options;
92
+ const port = options.port ?? 22;
93
+ const maxOut = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT;
94
+ const hops: readonly SshHop[] = [...(options.jump ?? []), options];
95
+ for (const hop of hops) if (policyIsEmpty(hop.hostKeys)) throw new Error(`ssh world ${worldId}: a host-key policy (fingerprints or known_hosts) is required for ${hop.host}`);
96
+
97
+ let disposed = false;
98
+ let clients: Client[] = [];
99
+ let opening: Promise<Client> | undefined;
100
+ let sftpOpening: Promise<SFTPWrapper> | undefined;
101
+ let sftp: SFTPWrapper | undefined;
102
+ let observedFingerprint: string | undefined;
103
+ const channels = new Set<ClientChannel>();
104
+
105
+ const closed = (): never => { throw new WorldUnavailableError(worldId, "execution world is disposed"); };
106
+
107
+ function invalidate(): void {
108
+ const old = clients;
109
+ clients = []; opening = undefined; sftpOpening = undefined; sftp = undefined;
110
+ for (const c of old.reverse()) try { c.end(); } catch { /* already closed */ }
111
+ }
112
+
113
+ async function open(): Promise<Client> {
114
+ const made: Client[] = [];
115
+ try {
116
+ for (let i = 0; i < hops.length; i++) {
117
+ const hop = hops[i]!;
118
+ const hopPort = hop.port ?? (hop === options ? port : 22);
119
+ const client = new Client();
120
+ client.on("error", () => { /* surfaced via connectReady / invalidate; never an uncaught process error */ });
121
+ made.push(client);
122
+ let mismatch: string | undefined;
123
+ const config: ConnectConfig = {
124
+ host: hop.host, port: hopPort, username: hop.username,
125
+ readyTimeout: options.readyTimeoutMs ?? 20_000,
126
+ keepaliveInterval: options.keepaliveMs ?? 15_000, keepaliveCountMax: 3,
127
+ ...authConfig(hop.auth),
128
+ hostVerifier: (key: Buffer) => {
129
+ const ok = verifyHostKey(hop.hostKeys, hop.host, hopPort, key);
130
+ if (!ok) mismatch = fingerprintOf(key);
131
+ else if (i === hops.length - 1) observedFingerprint = fingerprintOf(key);
132
+ return ok;
133
+ },
134
+ };
135
+ const prev = made[i - 1];
136
+ try {
137
+ if (!prev) await connectReady(client, config);
138
+ else {
139
+ const sock = await call<NodeJS.ReadableStream & NodeJS.WritableStream>((cb) => prev.forwardOut("127.0.0.1", 0, hop.host, hopPort, cb as never));
140
+ await connectReady(client, { ...config, sock: sock as never });
141
+ }
142
+ } catch (e) {
143
+ if (mismatch) throw new HostKeyMismatchError(worldId, hop.host, mismatch);
144
+ throw e;
145
+ }
146
+ }
147
+ const target = made[made.length - 1]!;
148
+ clients = made;
149
+ target.on("close", () => { if (clients[clients.length - 1] === target) invalidate(); });
150
+ target.on("error", () => { if (clients[clients.length - 1] === target) invalidate(); });
151
+ return target;
152
+ } catch (e) {
153
+ for (const c of made.reverse()) try { c.end(); } catch { /* best effort */ }
154
+ throw e instanceof WorldUnavailableError ? e : new WorldUnavailableError(worldId, `cannot connect to ${options.username}@${options.host}:${port}: ${e instanceof Error ? e.message : String(e)}`);
155
+ }
156
+ }
157
+
158
+ async function getClient(): Promise<Client> {
159
+ if (disposed) closed();
160
+ opening ??= open().catch((e) => { opening = undefined; throw e; });
161
+ const c = await opening;
162
+ if (disposed) closed();
163
+ return c;
164
+ }
165
+
166
+ async function getSftp(): Promise<SFTPWrapper> {
167
+ if (sftp) return sftp;
168
+ sftpOpening ??= (async () => {
169
+ const client = await getClient();
170
+ const s = await call<SFTPWrapper>((cb) => client.sftp(cb as never));
171
+ const drop = () => { if (sftp === s) { sftp = undefined; sftpOpening = undefined; } };
172
+ s.on("close", drop); s.on("end", drop);
173
+ sftp = s;
174
+ return s;
175
+ })().catch((e) => { sftpOpening = undefined; throw e instanceof WorldUnavailableError ? e : new WorldUnavailableError(worldId, `sftp unavailable: ${e instanceof Error ? e.message : String(e)}`); });
176
+ return sftpOpening;
177
+ }
178
+
179
+ const isConnectionError = (e: unknown) => e instanceof Error && /not connected|no sftp connection|channel (closed|open failure)|ECONNRESET|socket/i.test(e.message) && !(e instanceof WorldUnavailableError);
180
+ /** Idempotent operations get exactly one transparent reconnect; anything else surfaces the failure. */
181
+ async function withSftp<T>(fn: (s: SFTPWrapper) => Promise<T>): Promise<T> {
182
+ if (disposed) closed();
183
+ try {
184
+ return await fn(await getSftp());
185
+ } catch (e) {
186
+ if (!isConnectionError(e)) throw e;
187
+ invalidate();
188
+ return fn(await getSftp());
189
+ }
190
+ }
191
+
192
+ const statRaw = (path: string) => withSftp(async (s) => {
193
+ try { return await call<import("ssh2").Stats>((cb) => s.stat(path, cb as never)); }
194
+ catch (e) { if ((e as { code?: number }).code === NO_SUCH_FILE) return undefined; throw e; }
195
+ });
196
+ const realpath = (path: string) => withSftp(async (s) => {
197
+ try { return await call<string>((cb) => s.realpath(path, cb as never)); }
198
+ catch (e) { if ((e as { code?: number }).code === NO_SUCH_FILE) return undefined; throw e; }
199
+ });
200
+ const guard = createRootGuard(realpath);
201
+ const kindOf = (st: { isFile(): boolean; isDirectory(): boolean }): FileStat["kind"] => (st.isFile() ? "file" : st.isDirectory() ? "directory" : "other");
202
+ const versionOf = (st: { size: number; mtime: number }) => `${st.mtime}:${st.size}`;
203
+
204
+ async function exec(spec: ExecSpec, o?: { readonly signal?: AbortSignal }): Promise<ExecResult> {
205
+ const client = await getClient();
206
+ const command = `exec sh -c ${shellQuote(supervisedExecScript(spec.argv, spec.cwd, spec.env))}`;
207
+ return new Promise<ExecResult>((resolve, reject) => {
208
+ let timedOut = false;
209
+ let stream: ClientChannel | undefined;
210
+ let settled = false;
211
+ const out: Buffer[] = []; const err: Buffer[] = [];
212
+ let outSize = 0; let errSize = 0;
213
+ const kill = () => {
214
+ if (!stream) return;
215
+ // TERM reaches the supervisor (which ends the command's process group); closing the channel
216
+ // is the fallback for servers that ignore signal requests: stdin EOF triggers the same cleanup.
217
+ try { stream.signal("TERM"); } catch { /* server may ignore signals */ }
218
+ try { stream.close(); } catch { /* already closed */ }
219
+ };
220
+ const timer = setTimeout(() => { timedOut = true; kill(); }, spec.timeoutMs ?? options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS);
221
+ const onAbort = () => kill();
222
+ const finish = (r: ExecResult) => {
223
+ if (settled) return;
224
+ settled = true; clearTimeout(timer); o?.signal?.removeEventListener("abort", onAbort);
225
+ if (stream) channels.delete(stream);
226
+ resolve(r);
227
+ };
228
+ client.exec(command, { pty: false }, (e, s) => {
229
+ if (e) { clearTimeout(timer); reject(new WorldUnavailableError(worldId, `exec failed: ${e.message}`)); return; }
230
+ stream = s; channels.add(s);
231
+ s.on("data", (d: Buffer) => { if (outSize < maxOut) { out.push(d); outSize += d.length; } });
232
+ s.stderr.on("data", (d: Buffer) => { if (errSize < maxOut) { err.push(d); errSize += d.length; } });
233
+ s.on("close", (code: number | null) => finish({ stdout: Buffer.concat(out).subarray(0, maxOut).toString("utf8"), stderr: Buffer.concat(err).subarray(0, maxOut).toString("utf8"), exitCode: code ?? -1, timedOut }));
234
+ if (o?.signal?.aborted) kill(); else o?.signal?.addEventListener("abort", onAbort, { once: true });
235
+ });
236
+ });
237
+ }
238
+
239
+ return {
240
+ worldId,
241
+ kind: "ssh",
242
+ get metadata() {
243
+ return {
244
+ host: options.host, port: String(port), username: options.username, auth: options.auth.kind,
245
+ ...(options.jump?.length ? { jump: options.jump.map((h) => `${h.username}@${h.host}`).join(",") } : {}),
246
+ ...(observedFingerprint ? { hostKey: observedFingerprint } : {}),
247
+ };
248
+ },
249
+ fileSystem: {
250
+ resolve: (root, path) => guard.resolve(root, path),
251
+ async stat(path): Promise<FileStat | undefined> {
252
+ await guard.assertContained(path);
253
+ const st = await statRaw(path);
254
+ return st ? { kind: kindOf(st), size: st.size, version: versionOf(st) } : undefined;
255
+ },
256
+ async exists(path) { await guard.assertContained(path); return (await statRaw(path)) !== undefined; },
257
+ async version(path) { await guard.assertContained(path); const st = await statRaw(path); return st ? versionOf(st) : undefined; },
258
+ async readText(path) {
259
+ await guard.assertContained(path);
260
+ return withSftp(async (s) => (await call<Buffer>((cb) => s.readFile(path, cb as never))).toString("utf8"));
261
+ },
262
+ async list(path): Promise<readonly DirEntry[]> {
263
+ await guard.assertContained(path);
264
+ return withSftp(async (s) => (await call<{ filename: string; attrs: { isFile(): boolean; isDirectory(): boolean } }[]>((cb) => s.readdir(path, cb as never)))
265
+ .filter((e) => e.filename !== "." && e.filename !== "..")
266
+ .map((e) => ({ name: e.filename, kind: kindOf(e.attrs) })));
267
+ },
268
+ async writeText(path, content) {
269
+ await guard.assertContained(path);
270
+ if (disposed) closed();
271
+ // Writes are not retried: a half-completed write must surface, not repeat.
272
+ const s = await getSftp();
273
+ await call<void>((cb) => s.writeFile(path, Buffer.from(content, "utf8"), cb as never));
274
+ },
275
+ },
276
+ process: { exec },
277
+ async dispose() {
278
+ if (disposed) return;
279
+ disposed = true;
280
+ for (const ch of channels) try { ch.close(); } catch { /* closing */ }
281
+ channels.clear();
282
+ if (sftp) try { sftp.end(); } catch { /* closing */ }
283
+ invalidate();
284
+ },
285
+ };
286
+ }
287
+
288
+ export { PathEscapeError };
289
+
290
+ export interface HostKeyProbe {
291
+ readonly fingerprint: string;
292
+ readonly keyType: string;
293
+ /** Whether the key is already trusted in the given known_hosts text (never decides trust by itself). */
294
+ readonly known?: "trusted" | "unknown" | "revoked";
295
+ }
296
+
297
+ /**
298
+ * Reads a server's host key and stops: the verifier records the key and then REFUSES it, so nothing
299
+ * is ever authenticated or sent. The caller shows the fingerprint to a human, who decides whether to
300
+ * pin it; trust is a human act, never a side effect of connecting.
301
+ */
302
+ export function probeHostKey(options: { readonly host: string; readonly port?: number; readonly timeoutMs?: number; readonly knownHosts?: string }): Promise<HostKeyProbe> {
303
+ const port = options.port ?? 22;
304
+ return new Promise((resolve, reject) => {
305
+ const client = new Client();
306
+ let seen: Buffer | undefined;
307
+ let settled = false;
308
+ const finish = (fn: () => void) => { if (settled) return; settled = true; try { client.end(); } catch { /* closing */ } fn(); };
309
+ client.on("error", (e) => finish(() => {
310
+ if (seen) resolve(describe(seen));
311
+ else reject(new Error(`cannot reach ${options.host}:${port}: ${e.message}`));
312
+ }));
313
+ const describe = (key: Buffer): HostKeyProbe => {
314
+ const typeLength = key.readUInt32BE(0);
315
+ return {
316
+ fingerprint: fingerprintOf(key),
317
+ keyType: key.subarray(4, 4 + typeLength).toString("ascii"),
318
+ ...(options.knownHosts === undefined ? {} : { known: checkKnownHosts(options.knownHosts, options.host, port, key) }),
319
+ };
320
+ };
321
+ client.connect({ host: options.host, port, username: "cognate-probe", readyTimeout: options.timeoutMs ?? 10_000, hostVerifier: (key: Buffer) => { seen = key; return false; } });
322
+ });
323
+ }