diff --git a/src/agents/hermes.ts b/src/agents/hermes.ts index 17aebf3..d2e9ba6 100644 --- a/src/agents/hermes.ts +++ b/src/agents/hermes.ts @@ -1,13 +1,15 @@ +import { homedir } from "node:os"; import { join } from "node:path"; -import { mkdir, writeFile, readFile } from "node:fs/promises"; +import { mkdir, writeFile, readFile, rm } from "node:fs/promises"; import { existsSync } from "node:fs"; import { parse, stringify } from "yaml"; import { which } from "../util/which.js"; import { run } from "../util/run.js"; -import { opperHome } from "../auth/paths.js"; +import { takeSnapshot, restoreSnapshot, rotateBackups } from "../util/backup.js"; import { OpperError } from "../errors.js"; import { PICKER_MODELS } from "../config/models.js"; import { assetPath } from "../util/assets.js"; +import type { SnapshotHandle } from "../util/backup.js"; import type { AgentAdapter, DetectResult, @@ -15,18 +17,24 @@ import type { } from "./types.js"; /** - * Opper-managed HERMES_HOME root. Each `opper launch hermes` runs against - * this isolated directory: the user's main `~/.hermes/` is never read or - * mutated. Skills, sessions, and caches persist across launches inside it. + * The user's real Hermes home. We run `opper launch hermes` against it — not an + * isolated dir — so the user's own skills, toolsets, agent preferences, and + * other providers all load. The Opper bits (the `opper` model/provider block and + * the provider plugin) are added transiently and restored on exit, so a launch + * leaves the user's config exactly as it was. */ function hermesHome(): string { - return join(opperHome(), "hermes-home"); + return process.env.HERMES_HOME ?? join(homedir(), ".hermes"); } function hermesConfigPath(): string { return join(hermesHome(), "config.yaml"); } +function opperPluginDir(home: string): string { + return join(home, "plugins", "model-providers", "opper"); +} + async function detect(): Promise { const path = await which("hermes"); if (!path) return { installed: false }; @@ -76,25 +84,19 @@ async function configure(): Promise { } async function unconfigure(): Promise { - // Nothing persistent in the user's environment — the Opper-managed - // HERMES_HOME is only touched at launch time. + // Launch does snapshot → write → restore, so there's normally nothing to undo. + // Drop the provider plugin defensively in case a previous launch was killed + // before its restore ran. + await rm(opperPluginDir(hermesHome()), { recursive: true, force: true }); } /** - * Writes the minimum config Hermes needs to talk to Opper. Hermes (since - * v0.5+) refuses to honour `OPENAI_BASE_URL` from the environment — the - * base URL must live in config.yaml — so we bake it into our isolated - * HERMES_HOME before each launch. The api key is passed via OPPER_API_KEY - * env at spawn time so the secret never lands on disk. + * Write the Opper `model` + `providers.opper` blocks into the user's real + * config.yaml, preserving every other key. Restored on exit by the spawn + * snapshot. */ async function writeOpperConfig(routing: OpperRouting): Promise { - const home = hermesHome(); - await mkdir(home, { recursive: true }); - const path = hermesConfigPath(); - // Preserve any non-model settings the user might have customised in this - // Opper-managed home (toolsets, agent preferences, …). Only the model - // block is owned by us. const existing: Record = existsSync(path) ? ((parse(await readFile(path, "utf8")) as Record) ?? {}) : {}; @@ -104,19 +106,13 @@ async function writeOpperConfig(routing: OpperRouting): Promise { default: routing.model, }; - // `model.provider` and the providers entry must share the same key. Hermes - // resolves the request-time api key from the ACTIVE provider's `key_env`, so - // a mismatch (e.g. provider "custom" but config under "opper") leaves the - // active provider with no key and Hermes sends a "no-key-required" placeholder - // that Opper rejects with 401. The shipped `opper` provider plugin - // (writeOpperPlugin) registers a matching profile so this name resolves and - // the session/affinity headers are emitted; it also makes the `/model` picker - // show "Opper (N models)". - // - // The curated `models:` dict is the fallback Hermes uses when live discovery - // from `/v1/models` fails. `key_env: OPPER_API_KEY` matches the env - // we export at spawn, so no api key lands on disk. We use a dedicated env name - // (not OPENAI_API_KEY) so the launch never clobbers the user's real OpenAI key. + // `model.provider` and the providers entry must share the same key — Hermes + // resolves the request-time api key from the ACTIVE provider's `key_env`. The + // shipped `opper` plugin registers a matching profile and emits the + // session/affinity headers. `key_env: OPPER_API_KEY` matches the env we export + // at spawn, so no api key lands on disk; the dedicated name avoids clobbering + // the user's real OPENAI_API_KEY. The curated `models:` dict is the fallback + // when live discovery from `/v1/models` fails. const opperModels: Record> = {}; for (const m of PICKER_MODELS) opperModels[m.id] = {}; const providers = (existing.providers as Record | undefined) ?? {}; @@ -132,34 +128,92 @@ async function writeOpperConfig(routing: OpperRouting): Promise { } /** - * Install the Opper provider plugin into the isolated HERMES_HOME. It registers - * the `opper` provider (the one `writeOpperConfig` selects) and emits the - * per-session `X-Opper-Trace-Id` / `X-Opper-Parent-Span-Id` headers that drive - * provider affinity and the session-root span tree. Rewritten each launch so - * CLI upgrades ship plugin changes. + * Ship the Opper provider plugin into the real Hermes home so Hermes loads it, + * and return a restore fn. The plugin registers the `opper` provider and emits + * the per-session `X-Opper-Trace-Id` / `X-Opper-Parent-Span-Id` headers that + * drive provider affinity and the session-root span tree. Any pre-existing files + * are captured and put back; a dir we created is removed — so a one-off launch + * leaves the user's plugins untouched. */ -async function writeOpperPlugin(home: string): Promise { - const dir = join(home, "plugins", "model-providers", "opper"); +async function installPlugin(home: string): Promise<() => Promise> { + const dir = opperPluginDir(home); + const files = ["__init__.py", "plugin.yaml"]; + const dirExisted = existsSync(dir); + const prior: Record = {}; + for (const f of files) { + const p = join(dir, f); + prior[f] = existsSync(p) ? await readFile(p, "utf8") : null; + } + await mkdir(dir, { recursive: true }); - for (const file of ["__init__.py", "plugin.yaml"]) { - const contents = await readFile(assetPath(join("hermes-opper-plugin", file)), "utf8"); - await writeFile(join(dir, file), contents, { mode: 0o644 }); + for (const f of files) { + const contents = await readFile(assetPath(join("hermes-opper-plugin", f)), "utf8"); + await writeFile(join(dir, f), contents, { mode: 0o644 }); } + + return async () => { + try { + if (!dirExisted) { + await rm(dir, { recursive: true, force: true }); + // Best-effort: drop now-empty parents we may have created. + await rm(join(home, "plugins", "model-providers"), { recursive: false, force: true }).catch(() => {}); + await rm(join(home, "plugins"), { recursive: false, force: true }).catch(() => {}); + } else { + for (const f of files) { + const p = join(dir, f); + if (prior[f] !== null) await writeFile(p, prior[f]!, { mode: 0o644 }); + else await rm(p, { force: true }); + } + } + } catch (err) { + process.stderr.write( + `opper: failed to restore Hermes plugin after launch: ${ + err instanceof Error ? err.message : String(err) + }\n`, + ); + } + }; } async function spawn(args: string[], routing: OpperRouting): Promise { - await writeOpperConfig(routing); - await writeOpperPlugin(hermesHome()); - - const result = run("hermes", args, { - inherit: true, - env: { - ...process.env, - HERMES_HOME: hermesHome(), - OPPER_API_KEY: routing.apiKey, - }, - }); - return result.code; + const home = hermesHome(); + const path = hermesConfigPath(); + await mkdir(home, { recursive: true }); + + // Snapshot the real config.yaml (whole-file backup) so the user's settings + // come back exactly; if there was none, we delete the one we write. + const configExisted = existsSync(path); + let snapshot: SnapshotHandle | undefined; + if (configExisted) { + snapshot = await takeSnapshot("hermes", path); + await rotateBackups("hermes", 20); + } + const restorePlugin = await installPlugin(home); + + try { + await writeOpperConfig(routing); + const result = run("hermes", args, { + inherit: true, + env: { + ...process.env, + HERMES_HOME: home, + OPPER_API_KEY: routing.apiKey, + }, + }); + return result.code; + } finally { + try { + if (configExisted && snapshot) await restoreSnapshot(snapshot, path); + else await rm(path, { force: true }); + } catch (err) { + process.stderr.write( + `opper: failed to restore ${path} after launch${ + snapshot ? ` — recover with: cp "${snapshot.backupPath}" "${path}"` : "" + }: ${err instanceof Error ? err.message : String(err)}\n`, + ); + } + await restorePlugin(); + } } export const hermes: AgentAdapter = { diff --git a/test/agents/hermes.test.ts b/test/agents/hermes.test.ts index af8ceec..84ed51e 100644 --- a/test/agents/hermes.test.ts +++ b/test/agents/hermes.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, + mkdirSync, writeFileSync, readFileSync, existsSync, @@ -38,39 +39,18 @@ describe("hermes adapter — metadata", () => { describe("hermes adapter — detect", () => { it("returns installed=false when `which hermes` returns null", async () => { whichMock.mockResolvedValue(null); - const result = await hermes.detect(); - expect(result.installed).toBe(false); - expect(result.version).toBeUndefined(); + expect((await hermes.detect()).installed).toBe(false); }); - it("returns installed=true with semver when --version succeeds", async () => { + it("returns installed=true with semver and the real ~/.hermes config path", async () => { whichMock.mockResolvedValue("/usr/local/bin/hermes"); runMock.mockReturnValue({ code: 0, stdout: "hermes 1.2.3\n", stderr: "" }); const result = await hermes.detect(); expect(result.installed).toBe(true); expect(result.version).toBe("1.2.3"); - // configPath now points at the Opper-managed HERMES_HOME, not ~/.hermes. - expect(result.configPath).toMatch(/hermes-home\/config\.yaml$/); - }); - - it("returns installed=true with no version when --version output has no semver token", async () => { - whichMock.mockResolvedValue("/usr/local/bin/hermes"); - runMock.mockReturnValue({ - code: 0, - stdout: "hermes vupdate available — run `hermes update`\n", - stderr: "", - }); - const result = await hermes.detect(); - expect(result.installed).toBe(true); - expect(result.version).toBeUndefined(); - }); - - it("returns installed=true with undefined version when --version fails", async () => { - whichMock.mockResolvedValue("/usr/local/bin/hermes"); - runMock.mockReturnValue({ code: 1, stdout: "", stderr: "boom" }); - const result = await hermes.detect(); - expect(result.installed).toBe(true); - expect(result.version).toBeUndefined(); + // The user's real home, not an isolated dir. + expect(result.configPath).toMatch(/\.hermes\/config\.yaml$/); + expect(result.configPath).not.toMatch(/hermes-home/); }); }); @@ -78,9 +58,7 @@ describe("hermes adapter — install", () => { it("throws OpperError(AGENT_NOT_FOUND) when the installer exits non-zero", async () => { runMock.mockClear(); runMock.mockReturnValue({ code: 1, stdout: "", stderr: "boom" }); - await expect(hermes.install!()).rejects.toMatchObject({ - code: "AGENT_NOT_FOUND", - }); + await expect(hermes.install!()).rejects.toMatchObject({ code: "AGENT_NOT_FOUND" }); }); it("resolves when the installer exits 0", async () => { @@ -90,168 +68,128 @@ describe("hermes adapter — install", () => { }); }); -describe("hermes adapter — spawn (isolated HERMES_HOME)", () => { +describe("hermes adapter — spawn (real ~/.hermes, transient)", () => { let sandbox: string; + let prevHome: string | undefined; let prevOpperHome: string | undefined; + function configPath(): string { + return join(sandbox, ".hermes", "config.yaml"); + } + function pluginDir(): string { + return join(sandbox, ".hermes", "plugins", "model-providers", "opper"); + } + beforeEach(() => { sandbox = mkdtempSync(join(tmpdir(), "opper-hermes-")); + prevHome = process.env.HOME; prevOpperHome = process.env.OPPER_HOME; - process.env.OPPER_HOME = join(sandbox, ".opper"); + process.env.HOME = sandbox; + process.env.OPPER_HOME = join(sandbox, ".opper"); // sandbox the backups dir runMock.mockReset(); }); afterEach(() => { rmSync(sandbox, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; if (prevOpperHome === undefined) delete process.env.OPPER_HOME; else process.env.OPPER_HOME = prevOpperHome; }); - it("creates the Opper-managed hermes-home and writes an opper-provider config.yaml", async () => { - runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + it("writes the opper model + provider AND ships the plugin into ~/.hermes mid-launch", async () => { + let mid: { cfg: Record; pluginSrc: string } | undefined; + runMock.mockImplementation(() => { + mid = { + cfg: parse(readFileSync(configPath(), "utf8")) as Record, + pluginSrc: readFileSync(join(pluginDir(), "__init__.py"), "utf8"), + }; + return { code: 0, stdout: "", stderr: "" }; + }); - const code = await hermes.spawn!(["chat"], ROUTING); + const SESSION_URL = "https://api.opper.ai/v3/session/sess_test"; + const code = await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_URL }); expect(code).toBe(0); - const configPath = join(sandbox, ".opper", "hermes-home", "config.yaml"); - expect(existsSync(configPath)).toBe(true); - const written = parse(readFileSync(configPath, "utf8")) as { - model?: Record; + const model = mid?.cfg.model as Record; + expect(model).toEqual({ provider: "opper", base_url: SESSION_URL, default: "claude-opus-4-7" }); + expect(model).not.toHaveProperty("api_key"); // key goes via env, not disk + const providers = mid?.cfg.providers as { + opper?: { base_url?: string; key_env?: string; models?: Record }; }; - expect(written.model).toEqual({ - provider: "opper", - base_url: "https://api.opper.ai/v3/compat", - default: "claude-opus-4-7", - }); - // api_key intentionally NOT written to disk — it goes via env. - expect(written.model).not.toHaveProperty("api_key"); + expect(providers.opper?.base_url).toBe(SESSION_URL); + expect(providers.opper?.key_env).toBe("OPPER_API_KEY"); + expect(Object.keys(providers.opper?.models ?? {})).toContain("claude-opus-4-7"); + expect(mid?.pluginSrc).toContain("X-Opper-Trace-Id"); }); - it("passes HERMES_HOME and OPPER_API_KEY through to the hermes process env", async () => { + it("passes the real HERMES_HOME and OPPER_API_KEY through the env", async () => { runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); - await hermes.spawn!([], ROUTING); - - expect(runMock).toHaveBeenCalledTimes(1); const [, , runOpts] = runMock.mock.calls[0]!; const env = (runOpts as { env: Record }).env; - expect(env.HERMES_HOME).toBe(join(sandbox, ".opper", "hermes-home")); + expect(env.HERMES_HOME).toBe(join(sandbox, ".hermes")); expect(env.OPPER_API_KEY).toBe("op_live_test"); }); - it("preserves non-model settings already present in the Opper-managed config.yaml", async () => { - // First launch creates the dir + writes model: + it("leaves nothing behind when there was no prior config (one-off launch)", async () => { runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); + expect(existsSync(configPath())).toBe(false); await hermes.spawn!([], ROUTING); - - // User (or a previous Hermes run) added a `toolsets:` block to that file. - const configPath = join(sandbox, ".opper", "hermes-home", "config.yaml"); - writeFileSync( - configPath, - [ - "model:", - " provider: custom", - " base_url: https://stale.example", - " default: stale-model", - "toolsets:", - " - hermes-cli", - " - web", - ].join("\n") + "\n", - "utf8", - ); - - // Second launch must rewrite model: but leave toolsets: alone. - await hermes.spawn!([], ROUTING); - const after = parse(readFileSync(configPath, "utf8")) as { - model?: Record; - toolsets?: unknown; - }; - expect(after.model).toEqual({ - provider: "opper", - base_url: "https://api.opper.ai/v3/compat", - default: "claude-opus-4-7", - }); - expect(after.toolsets).toEqual(["hermes-cli", "web"]); - }); - - it("writes a providers.opper block with all picker model ids on every spawn", async () => { - runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); - const SESSION_URL = "https://api.opper.ai/v3/session/sess_test"; - await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_URL }); - - const configPath = join(sandbox, ".opper", "hermes-home", "config.yaml"); - const written = parse(readFileSync(configPath, "utf8")) as { - providers?: { opper?: { name?: string; base_url?: string; key_env?: string; models?: Record } }; - }; - // The provider block is keyed "opper" to match model.provider, so Hermes - // resolves the api key from this provider's key_env. - expect(written.providers?.opper).toBeDefined(); - expect(written.providers?.opper?.name).toBe("Opper"); - expect(written.providers?.opper?.base_url).toBe(SESSION_URL); - expect(written.providers?.opper?.key_env).toBe("OPPER_API_KEY"); - const ids = Object.keys(written.providers?.opper?.models ?? {}); - // Spot-check both ends: the curated 5 plus the 5 added later. - expect(ids).toContain("claude-opus-4-7"); - expect(ids).toContain("gpt-5.5"); - expect(ids).toContain("gemini-3.1-pro-preview"); - expect(ids).toContain("deepinfra/kimi-k2.6"); - expect(ids).toContain("fireworks/minimax-m2p7"); - expect(ids).toContain("deepinfra/deepseek-v4-flash"); + expect(existsSync(configPath())).toBe(false); + expect(existsSync(pluginDir())).toBe(false); }); - it("rewrites providers.opper.base_url with the per-session URL on each spawn", async () => { - runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); - const SESSION_A = "https://api.opper.ai/v3/session/sess_a"; - const SESSION_B = "https://api.opper.ai/v3/session/sess_b"; - - await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_A }); - await hermes.spawn!([], { ...ROUTING, baseUrl: SESSION_B }); - - const configPath = join(sandbox, ".opper", "hermes-home", "config.yaml"); - const after = parse(readFileSync(configPath, "utf8")) as { - model?: { base_url?: string }; - providers?: { opper?: { base_url?: string } }; - }; - // Both routing surfaces must follow the latest session URL — otherwise - // the picker row keeps pointing at a stale session. - expect(after.model?.base_url).toBe(SESSION_B); - expect(after.providers?.opper?.base_url).toBe(SESSION_B); - }); + it("restores the user's pre-existing config.yaml and plugin exactly", async () => { + mkdirSync(join(sandbox, ".hermes"), { recursive: true }); + const userConfig = + ["model:", " provider: anthropic", " default: claude", "toolsets:", " - web"].join("\n") + "\n"; + writeFileSync(configPath(), userConfig, "utf8"); + mkdirSync(pluginDir(), { recursive: true }); + writeFileSync(join(pluginDir(), "__init__.py"), "# user's own\n", "utf8"); - it("installs the Opper provider plugin into HERMES_HOME on spawn", async () => { runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); await hermes.spawn!([], ROUTING); - const pluginDir = join( - sandbox, ".opper", "hermes-home", "plugins", "model-providers", "opper", - ); - expect(existsSync(join(pluginDir, "__init__.py"))).toBe(true); - expect(existsSync(join(pluginDir, "plugin.yaml"))).toBe(true); - // The plugin emits the headers that drive session grouping + affinity. - const src = readFileSync(join(pluginDir, "__init__.py"), "utf8"); - expect(src).toContain("X-Opper-Trace-Id"); - expect(src).toContain("X-Opper-Parent-Span-Id"); + // Whole-file restore: the user's config + their plugin file come back verbatim. + expect(readFileSync(configPath(), "utf8")).toBe(userConfig); + expect(readFileSync(join(pluginDir(), "__init__.py"), "utf8")).toBe("# user's own\n"); }); it("propagates non-zero exit codes from run()", async () => { runMock.mockReturnValue({ code: 2, stdout: "", stderr: "" }); - const code = await hermes.spawn!([], ROUTING); - expect(code).toBe(2); + expect(await hermes.spawn!([], ROUTING)).toBe(2); }); - it("does not touch the user's real ~/.hermes/ directory", async () => { - runMock.mockReturnValue({ code: 0, stdout: "", stderr: "" }); - await hermes.spawn!([], ROUTING); - // Nothing under sandbox/.hermes should exist — we only write under .opper. - expect(existsSync(join(sandbox, ".hermes"))).toBe(false); + it("restores config even when run() throws", async () => { + mkdirSync(join(sandbox, ".hermes"), { recursive: true }); + const userConfig = "model:\n provider: anthropic\n"; + writeFileSync(configPath(), userConfig, "utf8"); + runMock.mockImplementation(() => { + throw new Error("spawn blew up"); + }); + await expect(hermes.spawn!([], ROUTING)).rejects.toThrow("spawn blew up"); + expect(readFileSync(configPath(), "utf8")).toBe(userConfig); }); }); describe("hermes adapter — isConfigured / configure / unconfigure", () => { + let sandbox: string; + let prevHome: string | undefined; + beforeEach(() => { + sandbox = mkdtempSync(join(tmpdir(), "opper-hermes-")); + prevHome = process.env.HOME; + process.env.HOME = sandbox; runMock.mockReset(); }); + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }); + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + }); + it("isConfigured collapses to installed", async () => { whichMock.mockResolvedValue(null); expect(await hermes.isConfigured()).toBe(false); @@ -260,14 +198,16 @@ describe("hermes adapter — isConfigured / configure / unconfigure", () => { expect(await hermes.isConfigured()).toBe(true); }); - it("configure throws AGENT_NOT_FOUND when not installed", async () => { + it("configure throws when hermes is not installed", async () => { whichMock.mockResolvedValue(null); - await expect(hermes.configure({})).rejects.toMatchObject({ - code: "AGENT_NOT_FOUND", - }); + await expect(hermes.configure({})).rejects.toMatchObject({ code: "AGENT_NOT_FOUND" }); }); - it("unconfigure is a no-op", async () => { - await expect(hermes.unconfigure()).resolves.toBeUndefined(); + it("unconfigure removes a leftover opper plugin dir", async () => { + const dir = join(sandbox, ".hermes", "plugins", "model-providers", "opper"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "__init__.py"), "x\n", "utf8"); + await hermes.unconfigure(); + expect(existsSync(dir)).toBe(false); }); });