Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.36",
"version": "1.0.38",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
30 changes: 23 additions & 7 deletions packages/cli/src/__tests__/ssh-keys-cov.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe("generateSshKey race recovery", () => {
});

describe("discoverSshKeys with unknown key type", () => {
it("labels key as UNKNOWN when ssh-keygen fails", () => {
it("labels key as UNKNOWN when ssh-keygen -lf fails (after verification passes)", () => {
const sshDir = join(tmpDir, ".ssh");
mkdirSync(sshDir, {
recursive: true,
Expand All @@ -134,8 +134,11 @@ describe("discoverSshKeys with unknown key type", () => {
});
writeFileSync(join(sshDir, "id_custom.pub"), "some-key AAAA fake\n");

// ssh-keygen throws
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation(() => {
// verify (`-y`) succeeds with matching pub; getKeyType (`-lf`) throws
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
if (args[1] === "-y") {
return makeSyncResult("some-key AAAA fake\n");
}
throw new Error("command not found");
});

Expand All @@ -145,7 +148,7 @@ describe("discoverSshKeys with unknown key type", () => {
expect(keys[0].type).toBe("UNKNOWN");
});

it("labels key as UNKNOWN when ssh-keygen output has no parenthesized type", () => {
it("labels key as UNKNOWN when ssh-keygen -lf output has no parenthesized type", () => {
const sshDir = join(tmpDir, ".ssh");
mkdirSync(sshDir, {
recursive: true,
Expand All @@ -156,9 +159,12 @@ describe("discoverSshKeys with unknown key type", () => {
});
writeFileSync(join(sshDir, "id_weird.pub"), "weird-key AAAA fake\n");

const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(
makeSyncResult("256 SHA256:abc user@host"), // no (TYPE) suffix
);
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
if (args[1] === "-y") {
return makeSyncResult("weird-key AAAA fake\n");
}
return makeSyncResult("256 SHA256:abc user@host"); // no (TYPE) suffix
});

const keys = discoverSshKeys();
spawnSpy.mockRestore();
Expand Down Expand Up @@ -210,6 +216,16 @@ describe("discoverSshKeys sorting", () => {

const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
const path = String(args[args.length - 1]);
if (args[1] === "-y") {
// verify (`-y`) call: return the matching pub file contents from disk
if (path.endsWith("id_ed25519")) {
return makeSyncResult("ssh-ed25519 AAAA\n");
}
if (path.endsWith("id_rsa")) {
return makeSyncResult("ssh-rsa AAAA\n");
}
return makeSyncResult("ecdsa-sha2 AAAA\n");
}
if (path.includes("ed25519")) {
return makeSyncResult("256 SHA256:x (ED25519)");
}
Expand Down
196 changes: 184 additions & 12 deletions packages/cli/src/__tests__/ssh-keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tryCatch } from "@openrouter/spawn-shared";
import { mockClackPrompts } from "./test-helpers";
Expand All @@ -18,9 +18,16 @@ mockClackPrompts({

// ── Import after @clack/prompts mock ────────────────────────────────────────

const { discoverSshKeys, generateSshKey, getSshFingerprint, ensureSshKeys, getSshKeyOpts, _resetCache } = await import(
"../shared/ssh-keys"
);
const {
discoverSshKeys,
generateSshKey,
getSshFingerprint,
ensureSshKeys,
getSshKeyOpts,
verifyKeyPair,
repairPubFromPriv,
_resetCache,
} = await import("../shared/ssh-keys");

// ─── Temp dir helpers ───────────────────────────────────────────────────────

Expand Down Expand Up @@ -124,6 +131,46 @@ function sshKeygenMd5Result(): Bun.SyncSubprocess<"pipe", "pipe"> {
return makeSyncResult("256 MD5:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99 user@host (ED25519)");
}

/**
* Smart mock for Bun.spawnSync that handles all three ssh-keygen invocations
* used by ssh-keys.ts:
* - `ssh-keygen -y -P "" -f <priv>` (verifyKeyPair) — returns the contents
* of the corresponding .pub file from disk so verification reports "match"
* - `ssh-keygen -lf <pub>` (getKeyType) — returns lf output for the given
* keyType (default ED25519, or RSA if pub path contains "rsa")
* - `ssh-keygen -lf <pub> -E md5` (getSshFingerprint) — returns MD5 output
*
* Pass `mismatch: true` to make verifyKeyPair return "mismatch" instead.
*/
function smartSshKeygenMock(opts: { mismatch?: boolean } = {}): (args: string[]) => Bun.SyncSubprocess<"pipe", "pipe"> {
return (args: string[]) => {
if (args[1] === "-y") {
const privPath = args[args.length - 1];
const pubPath = `${privPath}.pub`;
if (opts.mismatch) {
return makeSyncResult("ssh-ed25519 AAAADIFFERENT spawn\n");
}
const pubText = unwrapOrEmpty(() => readFileSync(pubPath, "utf-8"));
return makeSyncResult(pubText);
}
if (args.includes("-E") && args[args.indexOf("-E") + 1] === "md5") {
return sshKeygenMd5Result();
}
if (args[1] === "-lf") {
const pubPath = args[2];
const type = pubPath.includes("rsa") ? "RSA" : "ED25519";
return sshKeygenLfResult(type);
}
// Default: empty success
return makeSyncResult("");
};
}

function unwrapOrEmpty<T>(fn: () => T): T | "" {
const r = tryCatch(fn);
return r.ok ? r.data : "";
}

/**
* Build a mock spawnSync result that simulates successful ssh-keygen key generation.
* Also writes the expected output files so existsSync checks pass.
Expand Down Expand Up @@ -180,7 +227,7 @@ describe("discoverSshKeys", () => {

it("discovers a single key pair", () => {
createFakeKeyPair("id_ed25519", "ed25519");
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(sshKeygenLfResult("ED25519"));
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation(smartSshKeygenMock());
const keys = discoverSshKeys();
spawnSpy.mockRestore();
expect(keys).toHaveLength(1);
Expand All @@ -189,6 +236,57 @@ describe("discoverSshKeys", () => {
expect(keys[0].privPath).toContain("id_ed25519");
expect(keys[0].pubPath).toContain("id_ed25519.pub");
});

it("auto-repairs pairs whose .pub does not match the local private key", () => {
const { pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
const staleContents = readFileSync(pubPath, "utf-8");
const derivedContents = "ssh-ed25519 AAAADERIVED spawn\n";

const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
if (args[1] === "-y") {
// ssh-keygen -y derives the *correct* pub from the priv
return makeSyncResult(derivedContents);
}
if (args.includes("-E") && args[args.indexOf("-E") + 1] === "md5") {
return sshKeygenMd5Result();
}
return sshKeygenLfResult("ED25519");
});

const keys = discoverSshKeys();
spawnSpy.mockRestore();

// Pair is returned, not skipped
expect(keys).toHaveLength(1);
expect(keys[0].name).toBe("id_ed25519");
expect(keys[0].pubPath).toBe(pubPath);

// .pub has been rewritten with the derived contents
expect(readFileSync(pubPath, "utf-8")).toBe(derivedContents);

// The stale contents are preserved in a backup file
const sshDir = join(tmpDir, ".ssh");
const files = readdirSync(sshDir);
const backup = files.find((f) => f.startsWith("id_ed25519.pub.spawn-backup-"));
expect(backup).toBeDefined();
if (backup) {
expect(readFileSync(join(sshDir, backup), "utf-8")).toBe(staleContents);
}
});

it("skips pairs that ssh-keygen cannot derive (e.g. passphrase-protected)", () => {
createFakeKeyPair("id_ed25519", "ed25519");
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
if (args[1] === "-y") {
// Simulate ssh-keygen -y failing (e.g. passphrase prompt rejected)
return makeSyncResult("", 1);
}
return sshKeygenLfResult("ED25519");
});
const keys = discoverSshKeys();
spawnSpy.mockRestore();
expect(keys).toEqual([]);
});
});

// ─── generateSshKey ─────────────────────────────────────────────────────────
Expand Down Expand Up @@ -279,7 +377,7 @@ describe("ensureSshKeys", () => {

it("uses single key silently when only one is found", async () => {
createFakeKeyPair("id_rsa", "rsa");
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(sshKeygenLfResult("RSA"));
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation(smartSshKeygenMock());
const keys = await ensureSshKeys();
spawnSpy.mockRestore();
expect(keys).toHaveLength(1);
Expand All @@ -290,11 +388,7 @@ describe("ensureSshKeys", () => {
createFakeKeyPair("id_ed25519", "ed25519");
createFakeKeyPair("id_rsa", "rsa");

const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation((args: string[]) => {
const pubPath = args[args.length - 1];
const type = pubPath.includes("ed25519") ? "ED25519" : "RSA";
return sshKeygenLfResult(type);
});
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation(smartSshKeygenMock());

const keys = await ensureSshKeys();
spawnSpy.mockRestore();
Expand All @@ -305,7 +399,7 @@ describe("ensureSshKeys", () => {

it("caches results across calls", async () => {
createFakeKeyPair("id_ed25519", "ed25519");
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(sshKeygenLfResult("ED25519"));
const spawnSpy = spyOn(Bun, "spawnSync").mockImplementation(smartSshKeygenMock());

const keys1 = await ensureSshKeys();
const keys2 = await ensureSshKeys();
Expand All @@ -314,6 +408,84 @@ describe("ensureSshKeys", () => {
});
});

// ─── verifyKeyPair ──────────────────────────────────────────────────────────

describe("verifyKeyPair", () => {
it("returns 'match' when the derived public key equals the .pub file (ignoring comment)", () => {
const { privPath, pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
// Same key core as createFakeKeyPair writes, with a different comment field
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(
makeSyncResult("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFake different-comment\n"),
);
const result = verifyKeyPair(privPath, pubPath);
spawnSpy.mockRestore();
expect(result).toBe("match");
});

it("returns 'mismatch' when the derived public key differs from the .pub file", () => {
const { privPath, pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(
makeSyncResult("ssh-ed25519 AAAACOMPLETELYDIFFERENT spawn\n"),
);
const result = verifyKeyPair(privPath, pubPath);
spawnSpy.mockRestore();
expect(result).toBe("mismatch");
});

it("returns 'unverifiable' when ssh-keygen exits non-zero (e.g. passphrase)", () => {
const { privPath, pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(makeSyncResult("", 1));
const result = verifyKeyPair(privPath, pubPath);
spawnSpy.mockRestore();
expect(result).toBe("unverifiable");
});

it("returns 'unverifiable' when the .pub file is missing or empty", () => {
const { privPath, pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
rmSync(pubPath);
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(
makeSyncResult("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFake spawn\n"),
);
const result = verifyKeyPair(privPath, pubPath);
spawnSpy.mockRestore();
expect(result).toBe("unverifiable");
});
});

// ─── repairPubFromPriv ──────────────────────────────────────────────────────

describe("repairPubFromPriv", () => {
it("rewrites the .pub from the derived key and backs up the original", () => {
const { privPath, pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
const stale = readFileSync(pubPath, "utf-8");
const derived = "ssh-ed25519 AAAADERIVEDCONTENT spawn\n";
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(makeSyncResult(derived));

const backupPath = repairPubFromPriv(privPath, pubPath);
spawnSpy.mockRestore();

expect(backupPath).not.toBeNull();
expect(backupPath).toContain(".spawn-backup-");
expect(readFileSync(pubPath, "utf-8")).toBe(derived);
if (backupPath) {
expect(readFileSync(backupPath, "utf-8")).toBe(stale);
}
});

it("returns null when the private key cannot be derived (e.g. passphrase)", () => {
const { privPath, pubPath } = createFakeKeyPair("id_ed25519", "ed25519");
const stale = readFileSync(pubPath, "utf-8");
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue(makeSyncResult("", 1));

const backupPath = repairPubFromPriv(privPath, pubPath);
spawnSpy.mockRestore();

expect(backupPath).toBeNull();
// .pub is untouched, no backup created
expect(readFileSync(pubPath, "utf-8")).toBe(stale);
});
});

// ─── getSshKeyOpts ──────────────────────────────────────────────────────────

describe("getSshKeyOpts", () => {
Expand Down
Loading
Loading