Files
2026-07-13 13:36:53 +08:00

341 lines
12 KiB
TypeScript

import { execFileSync } from "child_process";
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { describe, expect, it, vi } from "vitest";
vi.mock("../src/main/locale", () => ({
getAppLocale: () => "en",
}));
import {
buildRemoteHermesCmd,
sshSetConfigValue,
buildGatewayStartCommand,
buildGatewayStopCommand,
buildGatewayStatusCommand,
parseHermesProfileListOutput,
selectSshProfiles,
} from "../src/main/ssh-remote";
import type { SshProfileInfo } from "../src/main/ssh-remote";
import type { SshConfig } from "../src/main/ssh-tunnel";
function profile(
name: string,
overrides: Partial<SshProfileInfo> = {},
): SshProfileInfo {
return {
name,
path: name === "default" ? "~/.hermes" : `~/.hermes/profiles/${name}`,
isDefault: name === "default",
isActive: name === "default",
model: "",
provider: "auto",
hasEnv: false,
hasSoul: false,
skillCount: 0,
gatewayRunning: false,
...overrides,
};
}
/** The `then` clause of the leading `if` — the systemd-managed branch. */
function systemdBranch(command: string): string {
return command.slice(command.indexOf("then"), command.indexOf("else"));
}
const sshConfig: SshConfig = {
host: "example.test",
port: 22,
username: "hermes",
keyPath: "",
remotePort: 8642,
localPort: 18642,
};
function runWithHermesShim(command: string): Buffer {
const home = mkdtempSync(join(tmpdir(), "hermes-ssh-cmd-home-"));
// Install the shim at a path buildRemoteHermesCmd PROBES BY ABSOLUTE PATH
// ($HOME/.local/bin/hermes), not just on PATH. The command runs under
// `bash -lc` (a login shell), which re-sources /etc/profile and RESETS PATH —
// so a shim reachable only via a prepended PATH entry is dropped, and the
// final `command -v hermes` fallback then finds either nothing (clean CI
// container → the test fails) or a real host hermes (dev box → the test
// passes for the wrong reason). Placing the shim at the probed absolute
// location makes the `[ -x $HOME/.local/bin/hermes ]` branch fire first,
// independent of login-shell PATH behavior and of whether a real hermes
// exists on the host. PATH is still prepended as belt-and-suspenders.
const localBin = join(home, ".local", "bin");
mkdirSync(localBin, { recursive: true });
const hermes = join(localBin, "hermes");
writeFileSync(
hermes,
[
"#!/usr/bin/env bash",
'if [ "$1" = "doctor" ]; then',
' printf "doctor stderr preserved\\\\n" >&2',
" exit 0",
"fi",
'printf "%s\\\\0" "$@"',
"",
].join("\n"),
);
chmodSync(hermes, 0o755);
return execFileSync("bash", ["-lc", command], {
env: {
...process.env,
HOME: home,
PATH: `${localBin}:${process.env.PATH || ""}`,
},
});
}
function parseNulArgs(output: Buffer): string[] {
const parts = output.toString("utf8").split("\0");
if (parts.at(-1) === "") parts.pop();
return parts;
}
describe("ssh remote config writes", () => {
it.each([
["quote", 'bad"value'],
["backslash", "bad\\value"],
["newline", "bad\nvalue"],
["carriage return", "bad\rvalue"],
])(
"rejects YAML-breaking %s values before remote writes",
async (_name, value) => {
await expect(
sshSetConfigValue(sshConfig, "base_url", value),
).rejects.toThrow("Config value contains illegal characters");
},
);
});
describe("ssh Hermes command quoting", () => {
it("shell-quotes the whole sh script without dropping per-argument quoting", () => {
const command = buildRemoteHermesCmd([
"kanban",
"create",
"My task title",
"--triage",
"--json",
]);
expect(command).not.toContain(
"sh -c '[ -x $HOME/hermes-agent/.venv/bin/hermes ] && exec $HOME/hermes-agent/.venv/bin/hermes 'kanban' 'create'",
);
expect(command).toContain(
`$HOME/hermes-agent/.venv/bin/hermes '"'"'kanban'"'"'`,
);
});
it.each([
[
"multi-word title",
["kanban", "create", "My task title", "--triage", "--json"],
],
[
"multiline markdown body",
[
"kanban",
"create",
"My task title",
"--body",
"first line\n- bullet one\n- bullet two",
"--triage",
"--json",
],
],
[
"single quote in user input",
["kanban", "create", "User's task", "--json"],
],
])(
"preserves %s",
(_name, expectedArgs) => {
const command = buildRemoteHermesCmd(expectedArgs);
expect(parseNulArgs(runWithHermesShim(command))).toEqual(expectedArgs);
},
30000,
);
it("preserves existing extraShell redirects", () => {
const output = runWithHermesShim(
buildRemoteHermesCmd(["doctor"], " 2>&1"),
).toString("utf8");
expect(output).toBe("doctor stderr preserved\n");
}, 30000);
});
describe("ssh gateway commands (issue #285)", () => {
it("detects a systemd hermes.service unit before acting", () => {
for (const cmd of [
buildGatewayStartCommand(),
buildGatewayStopCommand(),
buildGatewayStatusCommand(),
]) {
expect(cmd).toContain("systemctl list-unit-files hermes.service");
expect(cmd.indexOf("if ")).toBeLessThan(cmd.indexOf("else"));
}
});
it("start prefers systemd, falling back to a venv-probed nohup only without a unit", () => {
const cmd = buildGatewayStartCommand();
expect(cmd).toContain("systemctl start hermes.service");
expect(cmd).toContain("sudo -n systemctl start hermes.service");
// The nohup fallback must live in the else branch — never alongside
// systemd, where it would strand the unit in a restart crash-loop — and it
// resolves the CLI via the venv/launcher probe (not bare `hermes`) so it
// works when `hermes` is not on the non-interactive SSH PATH.
expect(cmd).toContain("nohup");
expect(cmd).toContain("venv/bin/hermes");
expect(cmd).toContain("$HOME/.hermes/gateway.log");
expect(systemdBranch(cmd)).not.toContain("nohup");
expect(systemdBranch(cmd)).not.toContain("venv/bin/hermes");
});
it("stop routes through systemd, else a venv-probed gateway stop", () => {
const cmd = buildGatewayStopCommand();
expect(cmd).toContain("systemctl stop hermes.service");
// Resolves the CLI via the venv/launcher probe (not bare `hermes`); the
// recorded-pid kill remains the last resort in the else branch.
expect(cmd).toContain("venv/bin/hermes");
expect(systemdBranch(cmd)).not.toContain("venv/bin/hermes");
expect(systemdBranch(cmd)).not.toContain("kill");
});
it("status reports the systemd unit state when managed", () => {
const cmd = buildGatewayStatusCommand();
expect(cmd).toContain("systemctl is-active hermes.service");
expect(cmd).toContain("gateway.pid");
expect(systemdBranch(cmd)).not.toContain("gateway.pid");
});
});
describe("buildRemoteHermesCmd venv probe (issue #284)", () => {
const cmd = buildRemoteHermesCmd(["--version"]);
it("probes explicit remote launcher hooks before default install paths", () => {
const configLauncher = "$HOME/.config/hermes-desktop/remote-hermes";
const legacyLauncher = "$HOME/.hermes/desktop-remote-hermes";
expect(cmd).toContain(configLauncher);
expect(cmd).toContain(legacyLauncher);
expect(cmd.indexOf(configLauncher)).toBeLessThan(
cmd.indexOf("$HOME/hermes-agent/.venv/bin/hermes"),
);
});
it("probes both .venv and venv for every install base", () => {
for (const base of [
"$HOME/hermes-agent",
"$HOME/.hermes/hermes-agent",
"/opt/hermes/hermes-agent",
]) {
expect(cmd).toContain(`${base}/.venv/bin/hermes`);
expect(cmd).toContain(`${base}/venv/bin/hermes`);
}
});
it("probes ~/.local/bin where pip --user installs a wrapper", () => {
expect(cmd).toContain("$HOME/.local/bin/hermes");
});
it("does not bake in deployment-specific managed runtime defaults", () => {
expect(cmd).not.toContain("/projects/hermes-runtime");
expect(cmd).not.toContain("sudo -n -u hermes");
});
it("does not probe the /usr/local/bin sudo-wrapper it deliberately bypasses", () => {
expect(cmd).not.toContain("/usr/local/bin/hermes");
});
it("still falls back to bare hermes on PATH", () => {
expect(cmd).toContain("command -v hermes");
});
});
describe("parseHermesProfileListOutput", () => {
it("parses the Hermes profile table used by managed SSH launchers", () => {
const profiles = parseHermesProfileListOutput(`
Profile Model Gateway Alias Distribution
─────────────── ─────────────────────────── ─────────── ─────────── ────────────────────
◆default gpt-5.5 running — —
biz-office gpt-5.5 running biz-office —
finance-accounting gpt-5.5 stopped finance-accounting —
marketing gpt-5.5 running marketing —
`);
expect(profiles.map((p) => p.name)).toEqual([
"default",
"biz-office",
"finance-accounting",
"marketing",
]);
expect(profiles.find((p) => p.name === "default")?.isActive).toBe(true);
expect(profiles.find((p) => p.name === "marketing")?.gatewayRunning).toBe(
true,
);
expect(
profiles.find((p) => p.name === "finance-accounting")?.gatewayRunning,
).toBe(false);
});
it("marks default active when the table has no active marker", () => {
const profiles = parseHermesProfileListOutput(`
Profile Model Gateway
default gpt-5.5 running
marketing gpt-5.5 stopped
`);
expect(profiles.find((p) => p.name === "default")?.isActive).toBe(true);
expect(profiles.find((p) => p.name === "marketing")?.isActive).toBe(false);
});
});
describe("selectSshProfiles", () => {
it("prefers the launcher runtime over an equal-length home-directory scan", () => {
// Greptile P1: a managed install whose launcher reports the SAME profile
// count as the SSH user's ~/.hermes scan must still surface the launcher's
// live state (correct HERMES_HOME, real gateway status), not the stale scan.
const launcher = {
present: true,
profiles: [profile("default", { gatewayRunning: true, model: "gpt-5.5" })],
};
const scanned = [profile("default", { gatewayRunning: false })];
const result = selectSshProfiles(launcher, scanned);
expect(result).toBe(launcher.profiles);
expect(result[0].gatewayRunning).toBe(true);
});
it("prefers the launcher even when the home-directory scan has more profiles", () => {
const launcher = { present: true, profiles: [profile("default")] };
const scanned = [profile("default"), profile("leftover-home-profile")];
expect(selectSshProfiles(launcher, scanned)).toBe(launcher.profiles);
});
it("falls back to the filesystem scan when no launcher is configured", () => {
// Without a launcher, launcher.profiles is empty and the richer scan wins.
const launcher = { present: false, profiles: [] };
const scanned = [profile("default"), profile("marketing")];
expect(selectSshProfiles(launcher, scanned)).toBe(scanned);
});
it("falls back to the scan when a launcher exists but returns no profiles", () => {
const launcher = { present: true, profiles: [] };
const scanned = [profile("default")];
expect(selectSshProfiles(launcher, scanned)).toBe(scanned);
});
it("returns launcher profiles when the scan is empty", () => {
const launcher = { present: true, profiles: [profile("default")] };
expect(selectSshProfiles(launcher, [])).toBe(launcher.profiles);
});
});