summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/hostkey.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
committerAdam Malczewski <[email protected]>2026-06-25 16:19:58 +0900
commit652010b6c054b69d813e8a2c724d6db039242119 (patch)
treeb70e4dd1591381a6c017c0c02f9502474b1d612d /packages/ssh/src/hostkey.test.ts
parent350b9b8e247bb1c24f49a884fdade18e44b115eb (diff)
downloaddispatch-652010b6c054b69d813e8a2c724d6db039242119.tar.gz
dispatch-652010b6c054b69d813e8a2c724d6db039242119.zip
feat(ssh): wave 5b — the ssh package (remote ExecBackend over ssh2)
Wave 5b of transparent SSH support. NEW standard extension @dispatch/ssh makes remote execution actually work over SSH, transparently. ssh2 verified to run under Bun (load-bearing decision #1 confirmed: connects to local sshd :22 + execs). - config.ts: ~/.ssh/config reader via ssh-config -> Computer[]/ComputerEntry[] (read-only discovery; resolves hostName/port/user/identityFile/knownHost). - hostkey.ts: known_hosts auto-trust-and-pin (present->verify/reject-on-mismatch, absent->accept+append; the accept-new analog). - errors.ts: pure ssh2/SFTP -> node:fs-style .code error mapping (so tools' existing ENOENT branches work unchanged). - pool.ts: SshConnectionPool (per-alias ssh2.Client, lazy connect, keep-alive, idle reap ~15m); key-only auth from ~/.ssh (config IdentityFile or default id_ed25519/id_rsa); no agent-forwarding, no PTY. - backend.ts: SshExecBackend implements ExecBackend (spawn via client.exec with shell-quoted cwd; fs via SFTP). - service.ts + extension.ts: activate provides BOTH handles the other units consume — remoteExecBackendFactoryHandle (exec-backend: computerId->SshExecBackend) AND computerServiceHandle (transport-http: listComputers/getComputer/getStatus/test). - orchestrator: added packages/ssh to root tsconfig.json refs + bun install. Tests: 45 pass + 6 sshd-integration skipped (it.skipIf(!process.env.SSH_TEST_HOST)). Verified: tsc -b EXIT 0, biome clean, 1690 vitest pass (was 1641, +49). CRs for wave 5c: host-bin registration; CR-5 transport-http barrel re-export; CR-6 usageCount wiring (deferred-ok, defaults to 0). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/ssh/src/hostkey.test.ts')
-rw-r--r--packages/ssh/src/hostkey.test.ts105
1 files changed, 105 insertions, 0 deletions
diff --git a/packages/ssh/src/hostkey.test.ts b/packages/ssh/src/hostkey.test.ts
new file mode 100644
index 0000000..1975777
--- /dev/null
+++ b/packages/ssh/src/hostkey.test.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+import { decideHostKey, type HostKeyFingerprint, isKnownHost } from "./hostkey.js";
+
+const fp = (token: string, key = "AAA"): HostKeyFingerprint => ({
+ knownHostToken: token,
+ keyBase64: key,
+ keyType: "ssh-ed25519",
+});
+
+describe("decideHostKey — present + match → accept, no append", () => {
+ it("accepts when the pinned key matches exactly", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBeUndefined();
+ expect(d.reason).toContain("matches");
+ });
+
+ it("matches ignoring leading/trailing whitespace differences", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBeUndefined();
+ });
+
+ it("matches a comma-host token list containing the alias", () => {
+ const known = "hostA,myhost,hostB ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ expect(d.accept).toBe(true);
+ });
+});
+
+describe("decideHostKey — present + mismatch → REJECT, no append", () => {
+ it("rejects loudly when the pinned key differs", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "BBB"));
+ expect(d.accept).toBe(false);
+ expect(d.append).toBeUndefined(); // never pin a mismatched key
+ expect(d.reason).toContain("HOST KEY CHANGED");
+ expect(d.reason).toContain("myhost");
+ });
+
+ it("does not pin on mismatch (the user must clear the stale line)", () => {
+ const known = "myhost ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "DIFFERENT"));
+ expect(d.append).toBeUndefined();
+ });
+});
+
+describe("decideHostKey — absent (first connect) → accept + pin", () => {
+ it("accepts and produces the pin line to append", () => {
+ const d = decideHostKey("", fp("newhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("newhost ssh-ed25519 AAA");
+ expect(d.reason).toContain("first connect");
+ expect(d.reason).toContain("newhost");
+ });
+
+ it("ignores comment + empty lines when searching", () => {
+ const known = "# a comment\n\n \notherhost ssh-ed25519 ZZZ\n";
+ const d = decideHostKey(known, fp("newhost", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("newhost ssh-ed25519 AAA");
+ });
+
+ it("pins a bracketed token for a non-default port", () => {
+ const d = decideHostKey("", fp("[localhost]:2222", "AAA"));
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("[localhost]:2222 ssh-ed25519 AAA");
+ });
+});
+
+describe("decideHostKey — first field must match the token", () => {
+ it("does not match a host that appears only as a substring of another token", () => {
+ const known = "myhost-extra ssh-ed25519 AAA\n";
+ const d = decideHostKey(known, fp("myhost", "AAA"));
+ // "myhost" is not an exact first-field (nor comma element) → absent → pin.
+ expect(d.accept).toBe(true);
+ expect(d.append).toBe("myhost ssh-ed25519 AAA");
+ });
+});
+
+describe("isKnownHost", () => {
+ it("returns true when the token is a known_hosts first field", () => {
+ expect(isKnownHost("a.example ssh-ed25519 AAA\n", "a.example")).toBe(true);
+ });
+
+ it("returns true for a comma-list token", () => {
+ expect(isKnownHost("a,b,c ssh-ed25519 AAA\n", "b")).toBe(true);
+ });
+
+ it("returns false when the token is absent", () => {
+ expect(isKnownHost("a.example ssh-ed25519 AAA\n", "b.example")).toBe(false);
+ });
+
+ it("returns false for an empty known_hosts", () => {
+ expect(isKnownHost("", "anything")).toBe(false);
+ });
+
+ it("ignores comments and blanks", () => {
+ const known = "# comment\n\nfoo ssh-ed25519 AAA\n";
+ expect(isKnownHost(known, "foo")).toBe(true);
+ expect(isKnownHost(known, "bar")).toBe(false);
+ });
+});