@godspeedai/cognate-execution-ssh 0.0.0-stage → 0.1.0
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 +201 -0
- package/README.md +40 -2
- package/package.json +30 -4
- package/src/fixture.ts +158 -0
- package/src/hostKeys.ts +80 -0
- package/src/index.ts +323 -0
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
|
-
#
|
|
1
|
+
# @godspeedai/cognate-execution-ssh — an SSH host as an execution world
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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.0",
|
|
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 };
|
package/src/hostKeys.ts
ADDED
|
@@ -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
|
+
}
|