summaryrefslogtreecommitdiffhomepage
path: root/packages/ssh/src/errors.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/errors.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/errors.test.ts')
-rw-r--r--packages/ssh/src/errors.test.ts90
1 files changed, 90 insertions, 0 deletions
diff --git a/packages/ssh/src/errors.test.ts b/packages/ssh/src/errors.test.ts
new file mode 100644
index 0000000..234fa49
--- /dev/null
+++ b/packages/ssh/src/errors.test.ts
@@ -0,0 +1,90 @@
+import { describe, expect, it } from "vitest";
+import { type FsError, fsError, mapSshError, sftpStatusToErrno } from "./errors.js";
+
+describe("sftpStatusToErrno", () => {
+ it("maps SSH_FX_NO_SUCH_FILE (3) → ENOENT", () => {
+ expect(sftpStatusToErrno(3)).toBe("ENOENT");
+ });
+
+ it("maps SSH_FX_PERMISSION_DENIED (4) → EACCES", () => {
+ expect(sftpStatusToErrno(4)).toBe("EACCES");
+ });
+
+ it("maps SSH_FX_FILE_ALREADY_EXISTS (11) → EEXIST", () => {
+ expect(sftpStatusToErrno(11)).toBe("EEXIST");
+ });
+
+ it("maps SSH_FX_NOT_A_DIRECTORY (20) → ENOTDIR", () => {
+ expect(sftpStatusToErrno(20)).toBe("ENOTDIR");
+ });
+
+ it("returns undefined for codes with no errno analog", () => {
+ expect(sftpStatusToErrno(1)).toBeUndefined(); // SSH_FX_EOF
+ expect(sftpStatusToErrno(999)).toBeUndefined();
+ });
+});
+
+describe("fsError", () => {
+ it("builds an Error carrying a .code string", () => {
+ const err: FsError = fsError("ENOENT", "no such file: /x");
+ expect(err).toBeInstanceOf(Error);
+ expect(err.code).toBe("ENOENT");
+ expect(err.message).toBe("no such file: /x");
+ });
+});
+
+describe("mapSshError", () => {
+ it("maps a numeric SFTP status code on .code → ENOENT", () => {
+ const err = mapSshError(Object.assign(new Error("fail"), { code: 3 }), "readFile /x");
+ expect(err.code).toBe("ENOENT");
+ expect(err.message).toContain("readFile /x");
+ expect(err.message).toContain("fail");
+ });
+
+ it("maps an SFTP_* string code → ENOENT", () => {
+ const err = mapSshError(
+ Object.assign(new Error("nope"), { code: "SFTP_STATUS_NO_SUCH_FILE" }),
+ "stat /y",
+ );
+ expect(err.code).toBe("ENOENT");
+ });
+
+ it("maps an SFTP permission-denied string → EACCES", () => {
+ const err = mapSshError(
+ Object.assign(new Error("denied"), { code: "SFTP_STATUS_PERMISSION_DENIED" }),
+ "readFile /y",
+ );
+ expect(err.code).toBe("EACCES");
+ });
+
+ it("falls back to message-text sniffing when .code is absent (No such file)", () => {
+ const err = mapSshError(new Error("No such file or directory"), "readFile /z");
+ expect(err.code).toBe("ENOENT");
+ });
+
+ it("falls back to message-text sniffing for permission denied", () => {
+ const err = mapSshError(new Error("Permission denied"), "writeFile /z");
+ expect(err.code).toBe("EACCES");
+ });
+
+ it("surfaces HOST KEY CHANGED as EHOSTUNREACH", () => {
+ const err = mapSshError(new Error("HOST KEY CHANGED for localhost"), "connect");
+ expect(err.code).toBe("EHOSTUNREACH");
+ });
+
+ it("defaults unrecognized errors to EIO", () => {
+ const err = mapSshError(new Error("something weird happened"), "readdir /a");
+ expect(err.code).toBe("EIO");
+ });
+
+ it("never throws — maps a non-Error value", () => {
+ const err = mapSshError("just a string", "readdir /a");
+ expect(err.code).toBe("EIO");
+ expect(err.message).toContain("just a string");
+ });
+
+ it("includes the context prefix in the message", () => {
+ const err = mapSshError(new Error("boom"), "writeFile /path/file");
+ expect(err.message).toContain("writeFile /path/file");
+ });
+});