From 7f22125284e62ea6f6c1e1f9252d166315968037 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:54:04 -0400 Subject: [PATCH 1/9] fix: launch Hermes safely on Windows --- src/app/api/board/[id]/chat/route.test.ts | 14 +++++ src/app/api/board/[id]/chat/route.ts | 6 +++ src/app/api/chat/send/route.ts | 2 + src/lib/harness-adapters.test.ts | 13 ++++- src/lib/harness-adapters.ts | 60 ++++++++++++++++++++- src/lib/server/adapter-manifest-scaffold.ts | 40 ++++++++++++++ 6 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/lib/server/adapter-manifest-scaffold.ts diff --git a/src/app/api/board/[id]/chat/route.test.ts b/src/app/api/board/[id]/chat/route.test.ts index 73b54927d..9f46fef4f 100644 --- a/src/app/api/board/[id]/chat/route.test.ts +++ b/src/app/api/board/[id]/chat/route.test.ts @@ -58,4 +58,18 @@ assert.match( "task sessions forward the familiar's resolved model to the daemon", ); +// Windows Hermes configurations created before prompt_flag support point to a +// POSIX-only launcher. The route must repair that known manifest before the +// daemon creates its PTY, or retrying a task will fail before Hermes starts. +assert.match( + source, + /import\s*\{[^}]*\bensureAdapterManifestScaffold\b[^}]*\}\s*from\s*"@\/lib\/server\/adapter-manifest-scaffold"/, + "route imports the adapter-manifest migration helper", +); +assert.match( + source, + /await\s+ensureAdapterManifestScaffold\(binding\.harness\)[\s\S]{0,2600}callDaemon/, + "route repairs the trusted harness manifest before daemon session creation", +); + console.log("board chat route.test.ts: ok"); diff --git a/src/app/api/board/[id]/chat/route.ts b/src/app/api/board/[id]/chat/route.ts index e9f9ab2de..ff481539e 100644 --- a/src/app/api/board/[id]/chat/route.ts +++ b/src/app/api/board/[id]/chat/route.ts @@ -9,6 +9,7 @@ import { isAllowedHarness, MAX_SESSION_JSON_BYTES, normalizeProjectRoot } from " import { issueContentionKey, shouldIsolateInWorktree, type IssueWorktreeKind } from "@/lib/issue-worktree"; import { provisionIssueWorktree, resolveRepoRoot } from "@/lib/server/issue-worktree-provision"; import { assertProjectAccess, ProjectAccessDeniedError } from "@/lib/project-permissions"; +import { ensureAdapterManifestScaffold } from "@/lib/server/adapter-manifest-scaffold"; // Match the daemon's "harness X is not a supported harness" rejection // from `/api/v1/sessions`. The daemon emits this when the requested @@ -125,6 +126,11 @@ export async function POST( return NextResponse.json({ ok: false, error: "unsupported harness" }, { status: 400 }); } + // Repair the known Windows-only Hermes manifest before asking the daemon to + // create its PTY. Without this the daemon attempts the POSIX-only + // `hermes-coven` shim and the task can never start. + await ensureAdapterManifestScaffold(binding.harness); + // ── Intelligent worktree isolation ──────────────────────────────────────── // If another card already has a live session for a *different* issue in the // same GitHub repo, this issue gets its own dedicated git worktree so the diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index 0ab107085..09e2bcdc1 100644 --- a/src/app/api/chat/send/route.ts +++ b/src/app/api/chat/send/route.ts @@ -72,6 +72,7 @@ import { } from "@/lib/server/chat-stop-registry"; import { openRunBuffer, type RunBufferHandle } from "@/lib/server/chat-stream-buffer"; import { COMPATIBILITY_ADAPTERS } from "@/lib/harness-adapters"; +import { ensureAdapterManifestScaffold } from "@/lib/server/adapter-manifest-scaffold"; import { loadProjects } from "@/lib/cave-projects"; import { chatProjectAccessId } from "@/lib/chat-project-access"; import { openClawLaunchCommand, openClawSpawnEnv } from "@/lib/openclaw-bin"; @@ -912,6 +913,7 @@ export async function POST(req: Request) { { status: 403, headers: { "content-type": "application/json" } }, ); } + await ensureAdapterManifestScaffold(binding.harness); // Cave's Read-only control is a security promise, not a prompt hint. // OpenCode's one-shot CLI exposes no read-only/sandbox flag, so spawning it // directly would let its configured permissions write to the workspace. diff --git a/src/lib/harness-adapters.test.ts b/src/lib/harness-adapters.test.ts index cb8e57a76..10fe15c8d 100644 --- a/src/lib/harness-adapters.test.ts +++ b/src/lib/harness-adapters.test.ts @@ -7,6 +7,7 @@ import { adapterSetupState, runtimeSourceSetupState, adapterManifestScaffoldForHarness, + isLegacyWindowsHermesManifest, covenHelpSupportsAdapterList, covenRunSupportsModelFlag, covenRunSupportsAddDirFlag, @@ -306,7 +307,7 @@ assert.deepEqual( // Scaffolds come straight from the synced coven-runtimes registry — the exact // conformance-tested adapter documents (cave-laxg retired the hand-written // copilot/hermes copies). -const hermesManifest = adapterManifestScaffoldForHarness("hermes"); +const hermesManifest = adapterManifestScaffoldForHarness("hermes", "linux"); assert.equal(hermesManifest?.filename, "hermes.json"); { const parsed = JSON.parse(hermesManifest?.contents ?? "{}"); @@ -317,6 +318,16 @@ assert.equal(hermesManifest?.filename, "hermes.json"); assert.deepEqual(adapter?.non_interactive_prompt_prefix_args, ["chat", "--source", "coven", "-Q"]); assert.ok(typeof adapter?.install_hint === "string" && adapter.install_hint.length > 0); } +{ + const windowsManifest = adapterManifestScaffoldForHarness("hermes", "win32"); + const adapter = JSON.parse(windowsManifest?.contents ?? "{}").adapters?.[0]; + assert.equal(adapter?.executable, "hermes", "Windows launches Hermes directly, never through a cmd shim"); + assert.equal(adapter?.prompt_flag, "-q", "Windows binds the prompt as Hermes's query flag"); + assert.equal(adapter?.interactive_prompt_flag, "-q", "interactive task startup uses the same safe prompt binding"); + assert.ok(isLegacyWindowsHermesManifest(hermesManifest?.contents ?? "", "win32")); + assert.ok(!isLegacyWindowsHermesManifest(windowsManifest?.contents ?? "", "win32")); + assert.ok(!isLegacyWindowsHermesManifest(hermesManifest?.contents ?? "", "linux")); +} assert.equal(adapterManifestScaffoldForHarness("codex"), null, "curated runtimes without a registry manifest scaffold nothing"); // Registry-accepted runtimes scaffold their exact adapter manifest from the diff --git a/src/lib/harness-adapters.ts b/src/lib/harness-adapters.ts index de02b080f..fa333d6a7 100644 --- a/src/lib/harness-adapters.ts +++ b/src/lib/harness-adapters.ts @@ -52,6 +52,11 @@ export type AdapterManifestScaffold = { contents: string; }; +type AdapterManifestDocument = { + adapters?: Array>; + [key: string]: unknown; +}; + // The hand-curated seed: Cave-specific labels, install copy, and probe args. // Curated entries win over registry entries with the same id. const CURATED_ADAPTERS: CompatibilityAdapter[] = [ @@ -380,13 +385,66 @@ export function runtimeSourceSetupState( // Cave copies were retired in favor of the registry versions, cave-laxg). export function adapterManifestScaffoldForHarness( harnessId: string, + platform = process.platform, ): AdapterManifestScaffold | null { const registry = REGISTRY_RUNTIMES.find( (runtime) => runtime.id === canonicalHarnessId(harnessId), ); if (!registry) return null; + + // The registry's Hermes recipe uses a POSIX bash shim because the original + // generic adapter contract appended prompts positionally. Current Coven + // supports `prompt_flag`, which binds a prompt as one argv value instead. + // Use that native contract on Windows: a .cmd shim would route untrusted + // prompt text back through cmd.exe parsing. + const manifest = registry.adapterManifest as AdapterManifestDocument; + const windowsHermes = platform === "win32" && registry.id === "hermes"; + const adapterManifest = windowsHermes + ? { + ...manifest, + adapters: manifest.adapters?.map((adapter) => + adapter.id === "hermes" + ? { + ...adapter, + executable: "hermes", + prompt_flag: "-q", + interactive_prompt_flag: "-q", + install_hint: + "Install Hermes Agent, add it to PATH, and complete Hermes setup before using this adapter.", + description: + "Hermes adapter with native model forwarding and prompt-flag routing for Windows.", + } + : adapter, + ), + } + : manifest; return { filename: `${registry.id}.json`, - contents: `${JSON.stringify(registry.adapterManifest, null, 2)}\n`, + contents: `${JSON.stringify(adapterManifest, null, 2)}\n`, }; } + +/** + * A Cave-generated Hermes manifest from before the Windows prompt-flag route + * always invokes the POSIX-only `hermes-coven` shim. It is safe to replace + * that known shape, but never a user-authored manifest with another command. + */ +export function isLegacyWindowsHermesManifest( + contents: string, + platform = process.platform, +): boolean { + if (platform !== "win32") return false; + try { + const parsed = JSON.parse(contents) as AdapterManifestDocument; + const adapters = parsed.adapters; + return ( + Array.isArray(adapters) && + adapters.length === 1 && + adapters[0]?.id === "hermes" && + adapters[0]?.executable === "hermes-coven" && + typeof adapters[0]?.prompt_flag !== "string" + ); + } catch { + return false; + } +} diff --git a/src/lib/server/adapter-manifest-scaffold.ts b/src/lib/server/adapter-manifest-scaffold.ts new file mode 100644 index 000000000..54f7a7e8c --- /dev/null +++ b/src/lib/server/adapter-manifest-scaffold.ts @@ -0,0 +1,40 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import { + adapterManifestScaffoldForHarness, + isLegacyWindowsHermesManifest, +} from "@/lib/harness-adapters"; +import { isManifestShadowedByBuiltin } from "./adapter-conflict-heal"; + +async function pathExists(targetPath: string): Promise { + try { + await stat(targetPath); + return true; + } catch { + return false; + } +} + +/** + * Ensure Cave's trusted adapter scaffold exists. On Windows this also repairs + * only the previous Hermes shim manifest, whose executable can never spawn + * there. User-authored manifests are deliberately left untouched. + */ +export async function ensureAdapterManifestScaffold(harness: string): Promise { + const scaffold = adapterManifestScaffoldForHarness(harness); + if (!scaffold) return false; + + const adaptersDir = path.join(homedir(), ".coven", "adapters"); + const manifestPath = path.join(adaptersDir, scaffold.filename); + if (await isManifestShadowedByBuiltin(manifestPath)) return false; + + const exists = await pathExists(manifestPath); + const shouldRepair = exists && isLegacyWindowsHermesManifest(await readFile(manifestPath, "utf8")); + if (!exists || shouldRepair) { + await mkdir(adaptersDir, { recursive: true }); + await writeFile(manifestPath, scaffold.contents, "utf8"); + return true; + } + return false; +} From c144abe860d77f74dc3d20704080102d04017b13 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:28:07 -0400 Subject: [PATCH 2/9] fix: honor COVEN_HOME when repairing adapters --- src/lib/server/adapter-conflict-heal.test.ts | 27 +++++++++++++++++++- src/lib/server/adapter-manifest-scaffold.ts | 18 ++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/lib/server/adapter-conflict-heal.test.ts b/src/lib/server/adapter-conflict-heal.test.ts index 67314dfbf..3fb0f61c9 100644 --- a/src/lib/server/adapter-conflict-heal.test.ts +++ b/src/lib/server/adapter-conflict-heal.test.ts @@ -3,7 +3,7 @@ // registry error, which bricked every `coven run` (chat surfaced it as codex // turns ending "No assistant text returned"). import assert from "node:assert/strict"; -import { mkdtemp, readFile as readFileFs, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile as readFileFs, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -14,6 +14,8 @@ import { shadowedMarkerPath, SHADOWED_MANIFEST_SUFFIX, } from "./adapter-conflict-heal.ts"; +import { ensureAdapterManifestScaffold } from "./adapter-manifest-scaffold.ts"; +import { adapterManifestScaffoldForHarness } from "../harness-adapters.ts"; const CLI_ERROR = "Error: external harness adapter `copilot` in /Users/buns/.coven/adapters/copilot.json conflicts with a built-in harness"; @@ -102,6 +104,29 @@ test("marker suffix keeps quarantined files off the CLI's .json dir scan", () => assert.ok(SHADOWED_MANIFEST_SUFFIX.startsWith(".")); }); +test("Hermes manifest repair uses the active COVEN_HOME adapters directory", async () => { + const covenHome = await mkdtemp(path.join(tmpdir(), "coven-home-")); + const manifestPath = path.join(covenHome, "adapters", "hermes.json"); + const legacyManifest = adapterManifestScaffoldForHarness("hermes", "linux"); + assert.ok(legacyManifest); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, legacyManifest.contents, "utf8"); + const previous = process.env.COVEN_HOME; + process.env.COVEN_HOME = covenHome; + try { + assert.equal( + await ensureAdapterManifestScaffold("hermes", { platform: "win32" }), + true, + ); + const manifest = JSON.parse(await readFileFs(manifestPath, "utf8")); + assert.equal(manifest.adapters[0].executable, "hermes"); + assert.equal(manifest.adapters[0].prompt_flag, "-q"); + } finally { + if (previous === undefined) delete process.env.COVEN_HOME; + else process.env.COVEN_HOME = previous; + } +}); + // Wiring pins: the chat send route must detect the conflict from harness // stderr and heal+retry; both scaffold sites must refuse to resurrect a // quarantined manifest. diff --git a/src/lib/server/adapter-manifest-scaffold.ts b/src/lib/server/adapter-manifest-scaffold.ts index 54f7a7e8c..a447f0778 100644 --- a/src/lib/server/adapter-manifest-scaffold.ts +++ b/src/lib/server/adapter-manifest-scaffold.ts @@ -1,11 +1,10 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; import { adapterManifestScaffoldForHarness, isLegacyWindowsHermesManifest, } from "@/lib/harness-adapters"; -import { isManifestShadowedByBuiltin } from "./adapter-conflict-heal"; +import { covenAdaptersDir, isManifestShadowedByBuiltin } from "./adapter-conflict-heal"; async function pathExists(targetPath: string): Promise { try { @@ -21,16 +20,23 @@ async function pathExists(targetPath: string): Promise { * only the previous Hermes shim manifest, whose executable can never spawn * there. User-authored manifests are deliberately left untouched. */ -export async function ensureAdapterManifestScaffold(harness: string): Promise { - const scaffold = adapterManifestScaffoldForHarness(harness); +export async function ensureAdapterManifestScaffold( + harness: string, + options: { adaptersDir?: string; platform?: NodeJS.Platform } = {}, +): Promise { + const platform = options.platform ?? process.platform; + const scaffold = adapterManifestScaffoldForHarness(harness, platform); if (!scaffold) return false; - const adaptersDir = path.join(homedir(), ".coven", "adapters"); + const adaptersDir = options.adaptersDir ?? covenAdaptersDir(); const manifestPath = path.join(adaptersDir, scaffold.filename); if (await isManifestShadowedByBuiltin(manifestPath)) return false; const exists = await pathExists(manifestPath); - const shouldRepair = exists && isLegacyWindowsHermesManifest(await readFile(manifestPath, "utf8")); + const shouldRepair = exists && isLegacyWindowsHermesManifest( + await readFile(manifestPath, "utf8"), + platform, + ); if (!exists || shouldRepair) { await mkdir(adaptersDir, { recursive: true }); await writeFile(manifestPath, scaffold.contents, "utf8"); From b1ed7041b4cb4230945a46e00123fb980d27ec86 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:34:12 -0400 Subject: [PATCH 3/9] fix: preserve custom Hermes adapter manifests --- src/lib/harness-adapters.test.ts | 12 ++++++++++++ src/lib/harness-adapters.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/lib/harness-adapters.test.ts b/src/lib/harness-adapters.test.ts index 10fe15c8d..dad1f398a 100644 --- a/src/lib/harness-adapters.test.ts +++ b/src/lib/harness-adapters.test.ts @@ -327,6 +327,18 @@ assert.equal(hermesManifest?.filename, "hermes.json"); assert.ok(isLegacyWindowsHermesManifest(hermesManifest?.contents ?? "", "win32")); assert.ok(!isLegacyWindowsHermesManifest(windowsManifest?.contents ?? "", "win32")); assert.ok(!isLegacyWindowsHermesManifest(hermesManifest?.contents ?? "", "linux")); + assert.ok( + !isLegacyWindowsHermesManifest( + JSON.stringify({ + adapters: [{ + ...JSON.parse(hermesManifest?.contents ?? "{}").adapters?.[0], + model_flag: "--custom-model", + }], + }), + "win32", + ), + "a user-authored Hermes adapter is never replaced during migration", + ); } assert.equal(adapterManifestScaffoldForHarness("codex"), null, "curated runtimes without a registry manifest scaffold nothing"); diff --git a/src/lib/harness-adapters.ts b/src/lib/harness-adapters.ts index fa333d6a7..5215654c4 100644 --- a/src/lib/harness-adapters.ts +++ b/src/lib/harness-adapters.ts @@ -57,6 +57,22 @@ type AdapterManifestDocument = { [key: string]: unknown; }; +const LEGACY_HERMES_INTERACTIVE_PREFIX_ARGS = ["chat", "--source", "coven"]; +const LEGACY_HERMES_NON_INTERACTIVE_PREFIX_ARGS = [ + "chat", + "--source", + "coven", + "-Q", +]; + +function hasExactStringArray(value: unknown, expected: readonly string[]): boolean { + return ( + Array.isArray(value) && + value.length === expected.length && + value.every((entry, index) => entry === expected[index]) + ); +} + // The hand-curated seed: Cave-specific labels, install copy, and probe args. // Curated entries win over registry entries with the same id. const CURATED_ADAPTERS: CompatibilityAdapter[] = [ @@ -442,6 +458,16 @@ export function isLegacyWindowsHermesManifest( adapters.length === 1 && adapters[0]?.id === "hermes" && adapters[0]?.executable === "hermes-coven" && + adapters[0]?.label === "Hermes Agent" && + adapters[0]?.model_flag === "--model" && + hasExactStringArray( + adapters[0]?.interactive_prompt_prefix_args, + LEGACY_HERMES_INTERACTIVE_PREFIX_ARGS, + ) && + hasExactStringArray( + adapters[0]?.non_interactive_prompt_prefix_args, + LEGACY_HERMES_NON_INTERACTIVE_PREFIX_ARGS, + ) && typeof adapters[0]?.prompt_flag !== "string" ); } catch { From 8a99e4df09e698491f69db1b10bda6a8b073433b Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:45:45 -0400 Subject: [PATCH 4/9] fix: load adapter scaffold in direct API tests --- src/lib/server/adapter-manifest-scaffold.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/server/adapter-manifest-scaffold.ts b/src/lib/server/adapter-manifest-scaffold.ts index a447f0778..e60182ced 100644 --- a/src/lib/server/adapter-manifest-scaffold.ts +++ b/src/lib/server/adapter-manifest-scaffold.ts @@ -3,8 +3,8 @@ import path from "node:path"; import { adapterManifestScaffoldForHarness, isLegacyWindowsHermesManifest, -} from "@/lib/harness-adapters"; -import { covenAdaptersDir, isManifestShadowedByBuiltin } from "./adapter-conflict-heal"; +} from "../harness-adapters.ts"; +import { covenAdaptersDir, isManifestShadowedByBuiltin } from "./adapter-conflict-heal.ts"; async function pathExists(targetPath: string): Promise { try { From 3bf4d7c339ddecae3dc0245e9acb70e61d54dd04 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:50:30 -0400 Subject: [PATCH 5/9] fix: honor COVEN_HOME for adapter scaffolding --- src/app/api/config/route.test.ts | 4 +-- src/app/api/config/route.ts | 32 ++------------------ src/app/api/familiars/route.test.ts | 5 +++ src/app/api/familiars/route.ts | 16 +++------- src/app/api/onboarding/setup/route.test.ts | 11 +++++++ src/app/api/onboarding/setup/route.ts | 24 +++------------ src/lib/server/adapter-conflict-heal.test.ts | 16 +++++++--- 7 files changed, 40 insertions(+), 68 deletions(-) diff --git a/src/app/api/config/route.test.ts b/src/app/api/config/route.test.ts index 17a38781f..7c59c815e 100644 --- a/src/app/api/config/route.test.ts +++ b/src/app/api/config/route.test.ts @@ -9,8 +9,8 @@ const source = readFileSync( assert.match( source, - /adapterManifestScaffoldForHarness/, - "config PATCH should use adapter scaffold metadata when harness bindings change", + /ensureAdapterManifestScaffold/, + "config PATCH should use the shared adapter scaffold writer when harness bindings change", ); assert.match( diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts index e884dd565..7a8d5af33 100644 --- a/src/app/api/config/route.ts +++ b/src/app/api/config/route.ts @@ -1,13 +1,9 @@ export const dynamic = "force-dynamic"; export const runtime = "nodejs"; -import { mkdir, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import path from "node:path"; import { NextRequest, NextResponse } from "next/server"; import { loadConfig, saveConfig } from "@/lib/cave-config"; -import { adapterManifestScaffoldForHarness } from "@/lib/harness-adapters"; -import { isManifestShadowedByBuiltin } from "@/lib/server/adapter-conflict-heal"; +import { ensureAdapterManifestScaffold } from "@/lib/server/adapter-manifest-scaffold"; const ALLOWED_TOP_LEVEL_KEYS = new Set([ "addons", @@ -21,15 +17,6 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([ ]); type ConfigPatchBody = Record; -async function pathExists(targetPath: string): Promise { - try { - await stat(targetPath); - return true; - } catch { - return false; - } -} - function harnessesFromConfigPatch(body: ConfigPatchBody): string[] { const harnesses = new Set(); const defaults = body.defaults; @@ -56,23 +43,8 @@ function harnessesFromConfigPatch(body: ConfigPatchBody): string[] { async function scaffoldAdapterManifestsFromPatch(body: ConfigPatchBody): Promise { const harnesses = harnessesFromConfigPatch(body); if (harnesses.length === 0) return; - const adaptersDir = path.join(homedir(), ".coven", "adapters"); - let ensuredAdaptersDir = false; for (const harness of harnesses) { - const scaffold = adapterManifestScaffoldForHarness(harness); - if (!scaffold) continue; - if (!ensuredAdaptersDir) { - await mkdir(adaptersDir, { recursive: true }); - ensuredAdaptersDir = true; - } - const manifestPath = path.join(adaptersDir, scaffold.filename); - // A quarantined manifest means the installed CLI ships this id as a - // built-in harness and fatally rejects the external copy — never - // resurrect it (cave-1c05). - if (await isManifestShadowedByBuiltin(manifestPath)) continue; - if (!(await pathExists(manifestPath))) { - await writeFile(manifestPath, scaffold.contents, "utf8"); - } + await ensureAdapterManifestScaffold(harness); } } diff --git a/src/app/api/familiars/route.test.ts b/src/app/api/familiars/route.test.ts index 83d47a933..77082ac55 100644 --- a/src/app/api/familiars/route.test.ts +++ b/src/app/api/familiars/route.test.ts @@ -80,6 +80,11 @@ assert.equal( // normalization by the onboarding-familiars helpers this route reuses. assert.match(source, /export async function POST\(/, "route should create a familiar via POST"); +assert.match( + source, + /await ensureAdapterManifestScaffold\(draft\.harness\)/, + "POST should scaffold adapters through the COVEN_HOME-aware shared writer", +); // Reuses the shared onboarding write primitives so a UI-created familiar is // identical to a setup-created one. diff --git a/src/app/api/familiars/route.ts b/src/app/api/familiars/route.ts index db6835c99..fef1a9686 100644 --- a/src/app/api/familiars/route.ts +++ b/src/app/api/familiars/route.ts @@ -12,7 +12,7 @@ import { normalizeFamiliarDraft, type OnboardingFamiliarInput, } from "@/lib/onboarding-familiars"; -import { adapterManifestScaffoldForHarness } from "@/lib/harness-adapters"; +import { ensureAdapterManifestScaffold } from "@/lib/server/adapter-manifest-scaffold"; import { scaffoldFamiliarContractFiles } from "@/lib/server/familiar-contract-files"; import { removedFamiliarIds, takeTombstone } from "@/lib/server/familiar-tombstones"; @@ -153,7 +153,6 @@ export async function POST(req: Request) { const covenDir = covenHome(); const familiarsToml = path.join(covenDir, "familiars.toml"); - const adaptersDir = path.join(covenDir, "adapters"); await mkdir(covenDir, { recursive: true }); @@ -204,16 +203,9 @@ export async function POST(req: Request) { // tombstoned ids, so a stale entry would make the new familiar invisible. await takeTombstone(draft.id).catch(() => {}); - // Scaffold the harness adapter manifest if it's missing (parity with - // onboarding) so a familiar bound to a not-yet-configured harness still works. - const adapterManifest = adapterManifestScaffoldForHarness(draft.harness); - if (adapterManifest) { - await mkdir(adaptersDir, { recursive: true }); - const manifestPath = path.join(adaptersDir, adapterManifest.filename); - if (!(await pathExists(manifestPath))) { - await writeFile(manifestPath, adapterManifest.contents, "utf8"); - } - } + // Scaffold the harness adapter manifest if it is missing, or repair the + // known Windows Hermes shim before the new familiar can launch it. + await ensureAdapterManifestScaffold(draft.harness); // Upsert only this familiar's binding. No `defaults` key → global defaults // are preserved (see the doc comment above). diff --git a/src/app/api/onboarding/setup/route.test.ts b/src/app/api/onboarding/setup/route.test.ts index 478336e28..1361795f2 100644 --- a/src/app/api/onboarding/setup/route.test.ts +++ b/src/app/api/onboarding/setup/route.test.ts @@ -20,6 +20,17 @@ import { readFile } from "node:fs/promises"; const source = await readFile(new URL("./route.ts", import.meta.url), "utf8"); +assert.match( + source, + /const covenDir = covenHome\(\)/, + "setup should honor COVEN_HOME when writing familiar and adapter state", +); +assert.match( + source, + /await ensureAdapterManifestScaffold\(harness\)/, + "setup should use the shared platform-aware adapter writer", +); + // 1. The route must still load the existing config so we can carry over // user-set fields when writing. assert.match( diff --git a/src/app/api/onboarding/setup/route.ts b/src/app/api/onboarding/setup/route.ts index b8995a1b2..b2b7fc41f 100644 --- a/src/app/api/onboarding/setup/route.ts +++ b/src/app/api/onboarding/setup/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import path from "node:path"; -import { caveHome } from "@/lib/coven-paths"; +import { caveHome, covenHome } from "@/lib/coven-paths"; import { loadConfig, normalizeMultiHostConfig, @@ -20,7 +19,7 @@ import { isTrustedOnboardingHarness, } from "@/lib/harness-adapters"; import { defaultModelForRuntime } from "@/lib/runtime-models"; -import { isManifestShadowedByBuiltin } from "@/lib/server/adapter-conflict-heal"; +import { ensureAdapterManifestScaffold } from "@/lib/server/adapter-manifest-scaffold"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -71,14 +70,12 @@ export async function POST(req: Request) { const model = (draft?.model ?? body.model ?? defaultModelForRuntime(harness)).trim() || defaultModelForRuntime(harness); - const home = homedir(); - const covenDir = path.join(home, ".coven"); + const covenDir = covenHome(); const caveDir = caveHome(); const familiarsToml = path.join(covenDir, "familiars.toml"); const configJson = path.join(caveDir, "config.json"); const conversationsDir = path.join(caveDir, "conversations"); const memoryDir = path.join(covenDir, "memory"); - const adaptersDir = path.join(covenDir, "adapters"); const wrote: string[] = []; @@ -86,21 +83,10 @@ export async function POST(req: Request) { await mkdir(caveDir, { recursive: true }); await mkdir(conversationsDir, { recursive: true }); await mkdir(memoryDir, { recursive: true }); - await mkdir(adaptersDir, { recursive: true }); const adapterManifest = adapterManifestScaffoldForHarness(harness); - if (adapterManifest) { - const manifestPath = path.join(adaptersDir, adapterManifest.filename); - // A quarantined manifest means the installed CLI ships this id as a - // built-in harness and fatally rejects the external copy — never - // resurrect it (cave-1c05). - if ( - !(await isManifestShadowedByBuiltin(manifestPath)) && - !(await pathExists(manifestPath)) - ) { - await writeFile(manifestPath, adapterManifest.contents, "utf8"); - wrote.push(`adapters/${adapterManifest.filename}`); - } + if (adapterManifest && await ensureAdapterManifestScaffold(harness)) { + wrote.push(`adapters/${adapterManifest.filename}`); } const familiarsExists = await pathExists(familiarsToml); diff --git a/src/lib/server/adapter-conflict-heal.test.ts b/src/lib/server/adapter-conflict-heal.test.ts index 3fb0f61c9..7a9375e2a 100644 --- a/src/lib/server/adapter-conflict-heal.test.ts +++ b/src/lib/server/adapter-conflict-heal.test.ts @@ -128,8 +128,8 @@ test("Hermes manifest repair uses the active COVEN_HOME adapters directory", asy }); // Wiring pins: the chat send route must detect the conflict from harness -// stderr and heal+retry; both scaffold sites must refuse to resurrect a -// quarantined manifest. +// stderr and heal+retry; every scaffold site must route through the shared +// writer, which refuses to resurrect a quarantined manifest. test("chat send route wires conflict detection and heal-retry", async () => { const source = await readFileFs( path.join(process.cwd(), "src/app/api/chat/send/route.ts"), @@ -141,16 +141,22 @@ test("chat send route wires conflict detection and heal-retry", async () => { assert.match(source, /pushProgress\(\s*"adapter-heal"/); }); -test("scaffold sites refuse to resurrect quarantined manifests", async () => { +test("scaffold sites use the shared quarantine-aware manifest writer", async () => { + const helper = await readFileFs( + path.join(process.cwd(), "src/lib/server/adapter-manifest-scaffold.ts"), + "utf8", + ); + assert.match(helper, /isManifestShadowedByBuiltin\(manifestPath\)/); for (const file of [ "src/app/api/config/route.ts", + "src/app/api/familiars/route.ts", "src/app/api/onboarding/setup/route.ts", ]) { const source = await readFileFs(path.join(process.cwd(), file), "utf8"); assert.match( source, - /isManifestShadowedByBuiltin\(manifestPath\)/, - `${file} must check the shadowed marker before scaffolding`, + /ensureAdapterManifestScaffold/, + `${file} must use the shared manifest writer`, ); } }); From f1723ee99da8ba94345dcbe788eb8605c6158f96 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:56:05 -0400 Subject: [PATCH 6/9] fix: preserve custom Hermes adapter manifests --- src/lib/harness-adapters.test.ts | 4 +-- src/lib/harness-adapters.ts | 61 ++++++++++++++++---------------- 2 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/lib/harness-adapters.test.ts b/src/lib/harness-adapters.test.ts index dad1f398a..5d5f42195 100644 --- a/src/lib/harness-adapters.test.ts +++ b/src/lib/harness-adapters.test.ts @@ -332,12 +332,12 @@ assert.equal(hermesManifest?.filename, "hermes.json"); JSON.stringify({ adapters: [{ ...JSON.parse(hermesManifest?.contents ?? "{}").adapters?.[0], - model_flag: "--custom-model", + environment: { HERMES_PROFILE: "custom" }, }], }), "win32", ), - "a user-authored Hermes adapter is never replaced during migration", + "a user-authored Hermes adapter with custom setup is never replaced during migration", ); } assert.equal(adapterManifestScaffoldForHarness("codex"), null, "curated runtimes without a registry manifest scaffold nothing"); diff --git a/src/lib/harness-adapters.ts b/src/lib/harness-adapters.ts index 5215654c4..c8fe894e7 100644 --- a/src/lib/harness-adapters.ts +++ b/src/lib/harness-adapters.ts @@ -57,19 +57,35 @@ type AdapterManifestDocument = { [key: string]: unknown; }; -const LEGACY_HERMES_INTERACTIVE_PREFIX_ARGS = ["chat", "--source", "coven"]; -const LEGACY_HERMES_NON_INTERACTIVE_PREFIX_ARGS = [ - "chat", - "--source", - "coven", - "-Q", -]; - -function hasExactStringArray(value: unknown, expected: readonly string[]): boolean { +function isExactJsonValue(actual: unknown, expected: unknown): boolean { + if (actual === expected) return true; + if (Array.isArray(actual) || Array.isArray(expected)) { + return ( + Array.isArray(actual) && + Array.isArray(expected) && + actual.length === expected.length && + actual.every((entry, index) => isExactJsonValue(entry, expected[index])) + ); + } + if ( + !actual || + !expected || + typeof actual !== "object" || + typeof expected !== "object" + ) { + return false; + } + const actualRecord = actual as Record; + const expectedRecord = expected as Record; + const actualKeys = Object.keys(actualRecord).sort(); + const expectedKeys = Object.keys(expectedRecord).sort(); return ( - Array.isArray(value) && - value.length === expected.length && - value.every((entry, index) => entry === expected[index]) + actualKeys.length === expectedKeys.length && + actualKeys.every( + (key, index) => + key === expectedKeys[index] && + isExactJsonValue(actualRecord[key], expectedRecord[key]), + ) ); } @@ -452,24 +468,9 @@ export function isLegacyWindowsHermesManifest( if (platform !== "win32") return false; try { const parsed = JSON.parse(contents) as AdapterManifestDocument; - const adapters = parsed.adapters; - return ( - Array.isArray(adapters) && - adapters.length === 1 && - adapters[0]?.id === "hermes" && - adapters[0]?.executable === "hermes-coven" && - adapters[0]?.label === "Hermes Agent" && - adapters[0]?.model_flag === "--model" && - hasExactStringArray( - adapters[0]?.interactive_prompt_prefix_args, - LEGACY_HERMES_INTERACTIVE_PREFIX_ARGS, - ) && - hasExactStringArray( - adapters[0]?.non_interactive_prompt_prefix_args, - LEGACY_HERMES_NON_INTERACTIVE_PREFIX_ARGS, - ) && - typeof adapters[0]?.prompt_flag !== "string" - ); + const legacyManifest = REGISTRY_RUNTIMES.find((runtime) => runtime.id === "hermes") + ?.adapterManifest; + return !!legacyManifest && isExactJsonValue(parsed, legacyManifest); } catch { return false; } From 2d94cbc69e9faa763307ea719394b711a56e66ba Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:01:38 -0400 Subject: [PATCH 7/9] fix: skip Hermes POSIX shim on Windows --- src/app/api/onboarding/install/route.test.ts | 6 ++++++ src/app/api/onboarding/install/route.ts | 15 ++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/app/api/onboarding/install/route.test.ts b/src/app/api/onboarding/install/route.test.ts index 3e7d08a3b..e4572082a 100644 --- a/src/app/api/onboarding/install/route.test.ts +++ b/src/app/api/onboarding/install/route.test.ts @@ -44,6 +44,12 @@ assert.match( "hermes Windows installer URL is pinned to the official script", ); +assert.match( + source, + /targetName === "hermes"\s*&&\s*installed\.path\s*&&\s*process\.platform !== "win32"/, + "the POSIX-only hermes-coven shim is not attempted after a Windows Hermes install", +); + assert.equal( source.match(/rejectNonLocalRequest\(req\)/g)?.length, 3, diff --git a/src/app/api/onboarding/install/route.ts b/src/app/api/onboarding/install/route.ts index 67d5edb97..bc3364985 100644 --- a/src/app/api/onboarding/install/route.ts +++ b/src/app/api/onboarding/install/route.ts @@ -673,11 +673,16 @@ async function finishInstallJob( ); } - // Hermes has no positional prompt slot, so the harness's `-- ""` - // convention needs the hermes-coven shim to remap it onto -q. This remains - // best-effort: a shim failure never turns an otherwise successful install - // into a failed tool update. - if (installOk && targetName === "hermes" && installed.path) { + // POSIX Hermes installs need a shim because the harness convention passes + // prompts positionally. Windows uses the native adapter recipe with `-q`, + // so attempting to install the bash shim there would only report a false + // setup failure after an otherwise successful install. + if ( + installOk && + targetName === "hermes" && + installed.path && + process.platform !== "win32" + ) { try { const shim = await installHermesShim(installed.path); appendOutput( From e56a609919af36f0688d5fecf010b66526a8c34f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:11:11 -0400 Subject: [PATCH 8/9] fix: preserve custom Hermes manifests --- src/lib/harness-adapters.test.ts | 7 +++ src/lib/harness-adapters.ts | 47 +++----------------- src/lib/server/adapter-conflict-heal.test.ts | 16 +++++++ 3 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/lib/harness-adapters.test.ts b/src/lib/harness-adapters.test.ts index 5d5f42195..eeabc3b6f 100644 --- a/src/lib/harness-adapters.test.ts +++ b/src/lib/harness-adapters.test.ts @@ -327,6 +327,13 @@ assert.equal(hermesManifest?.filename, "hermes.json"); assert.ok(isLegacyWindowsHermesManifest(hermesManifest?.contents ?? "", "win32")); assert.ok(!isLegacyWindowsHermesManifest(windowsManifest?.contents ?? "", "win32")); assert.ok(!isLegacyWindowsHermesManifest(hermesManifest?.contents ?? "", "linux")); + assert.ok( + !isLegacyWindowsHermesManifest( + JSON.stringify(JSON.parse(hermesManifest?.contents ?? "{}")), + "win32", + ), + "a formatting-only user-authored manifest is never replaced during migration", + ); assert.ok( !isLegacyWindowsHermesManifest( JSON.stringify({ diff --git a/src/lib/harness-adapters.ts b/src/lib/harness-adapters.ts index c8fe894e7..e5099ab05 100644 --- a/src/lib/harness-adapters.ts +++ b/src/lib/harness-adapters.ts @@ -57,38 +57,6 @@ type AdapterManifestDocument = { [key: string]: unknown; }; -function isExactJsonValue(actual: unknown, expected: unknown): boolean { - if (actual === expected) return true; - if (Array.isArray(actual) || Array.isArray(expected)) { - return ( - Array.isArray(actual) && - Array.isArray(expected) && - actual.length === expected.length && - actual.every((entry, index) => isExactJsonValue(entry, expected[index])) - ); - } - if ( - !actual || - !expected || - typeof actual !== "object" || - typeof expected !== "object" - ) { - return false; - } - const actualRecord = actual as Record; - const expectedRecord = expected as Record; - const actualKeys = Object.keys(actualRecord).sort(); - const expectedKeys = Object.keys(expectedRecord).sort(); - return ( - actualKeys.length === expectedKeys.length && - actualKeys.every( - (key, index) => - key === expectedKeys[index] && - isExactJsonValue(actualRecord[key], expectedRecord[key]), - ) - ); -} - // The hand-curated seed: Cave-specific labels, install copy, and probe args. // Curated entries win over registry entries with the same id. const CURATED_ADAPTERS: CompatibilityAdapter[] = [ @@ -466,12 +434,11 @@ export function isLegacyWindowsHermesManifest( platform = process.platform, ): boolean { if (platform !== "win32") return false; - try { - const parsed = JSON.parse(contents) as AdapterManifestDocument; - const legacyManifest = REGISTRY_RUNTIMES.find((runtime) => runtime.id === "hermes") - ?.adapterManifest; - return !!legacyManifest && isExactJsonValue(parsed, legacyManifest); - } catch { - return false; - } + // Only the byte-for-byte document Cave emitted can be safely migrated. + // Parsing and comparing JSON values would also replace a user's equivalent + // hand-authored document just because they chose another key order or + // formatting style. + const legacyManifest = REGISTRY_RUNTIMES.find((runtime) => runtime.id === "hermes") + ?.adapterManifest; + return !!legacyManifest && contents === `${JSON.stringify(legacyManifest, null, 2)}\n`; } diff --git a/src/lib/server/adapter-conflict-heal.test.ts b/src/lib/server/adapter-conflict-heal.test.ts index 7a9375e2a..edbc94815 100644 --- a/src/lib/server/adapter-conflict-heal.test.ts +++ b/src/lib/server/adapter-conflict-heal.test.ts @@ -127,6 +127,22 @@ test("Hermes manifest repair uses the active COVEN_HOME adapters directory", asy } }); +test("Hermes manifest repair preserves a formatting-only user manifest", async () => { + const covenHome = await mkdtemp(path.join(tmpdir(), "coven-home-")); + const manifestPath = path.join(covenHome, "adapters", "hermes.json"); + const legacyManifest = adapterManifestScaffoldForHarness("hermes", "linux"); + assert.ok(legacyManifest); + const userContents = JSON.stringify(JSON.parse(legacyManifest.contents)); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await writeFile(manifestPath, userContents, "utf8"); + + assert.equal( + await ensureAdapterManifestScaffold("hermes", { adaptersDir: path.dirname(manifestPath), platform: "win32" }), + false, + ); + assert.equal(await readFileFs(manifestPath, "utf8"), userContents); +}); + // Wiring pins: the chat send route must detect the conflict from harness // stderr and heal+retry; every scaffold site must route through the shared // writer, which refuses to resurrect a quarantined manifest. From f38bc68473aa440e02d0bdc077ed4937c50bf71f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg <5861166+romgenie@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:17:08 -0400 Subject: [PATCH 9/9] fix: preserve non-file adapter manifests --- src/lib/server/adapter-conflict-heal.test.ts | 17 +++++++++++++- src/lib/server/adapter-manifest-scaffold.ts | 24 ++++++++++++-------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/src/lib/server/adapter-conflict-heal.test.ts b/src/lib/server/adapter-conflict-heal.test.ts index edbc94815..a5f8b04f0 100644 --- a/src/lib/server/adapter-conflict-heal.test.ts +++ b/src/lib/server/adapter-conflict-heal.test.ts @@ -3,7 +3,7 @@ // registry error, which bricked every `coven run` (chat surfaced it as codex // turns ending "No assistant text returned"). import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile as readFileFs, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile as readFileFs, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; @@ -143,6 +143,21 @@ test("Hermes manifest repair preserves a formatting-only user manifest", async ( assert.equal(await readFileFs(manifestPath, "utf8"), userContents); }); +test("adapter scaffolding preserves a user-managed non-file manifest path", async () => { + const covenHome = await mkdtemp(path.join(tmpdir(), "coven-home-")); + const manifestPath = path.join(covenHome, "adapters", "hermes.json"); + await mkdir(manifestPath, { recursive: true }); + + assert.equal( + await ensureAdapterManifestScaffold("hermes", { + adaptersDir: path.dirname(manifestPath), + platform: "win32", + }), + false, + ); + assert.equal((await lstat(manifestPath)).isDirectory(), true); +}); + // Wiring pins: the chat send route must detect the conflict from harness // stderr and heal+retry; every scaffold site must route through the shared // writer, which refuses to resurrect a quarantined manifest. diff --git a/src/lib/server/adapter-manifest-scaffold.ts b/src/lib/server/adapter-manifest-scaffold.ts index e60182ced..c27e4f152 100644 --- a/src/lib/server/adapter-manifest-scaffold.ts +++ b/src/lib/server/adapter-manifest-scaffold.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { adapterManifestScaffoldForHarness, @@ -6,12 +6,14 @@ import { } from "../harness-adapters.ts"; import { covenAdaptersDir, isManifestShadowedByBuiltin } from "./adapter-conflict-heal.ts"; -async function pathExists(targetPath: string): Promise { +type ManifestPathKind = "missing" | "file" | "other"; + +async function manifestPathKind(targetPath: string): Promise { try { - await stat(targetPath); - return true; - } catch { - return false; + return (await lstat(targetPath)).isFile() ? "file" : "other"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; } } @@ -32,12 +34,16 @@ export async function ensureAdapterManifestScaffold( const manifestPath = path.join(adaptersDir, scaffold.filename); if (await isManifestShadowedByBuiltin(manifestPath)) return false; - const exists = await pathExists(manifestPath); - const shouldRepair = exists && isLegacyWindowsHermesManifest( + // A directory or symlink is an intentional user-owned setup, not a Cave + // scaffold. Do not follow it or replace its target while repairing Hermes. + const kind = await manifestPathKind(manifestPath); + if (kind === "other") return false; + + const shouldRepair = kind === "file" && isLegacyWindowsHermesManifest( await readFile(manifestPath, "utf8"), platform, ); - if (!exists || shouldRepair) { + if (kind === "missing" || shouldRepair) { await mkdir(adaptersDir, { recursive: true }); await writeFile(manifestPath, scaffold.contents, "utf8"); return true;