diff --git a/src/app/api/board/[id]/chat/route.test.ts b/src/app/api/board/[id]/chat/route.test.ts index 685a8a97a..2c383833c 100644 --- a/src/app/api/board/[id]/chat/route.test.ts +++ b/src/app/api/board/[id]/chat/route.test.ts @@ -83,4 +83,17 @@ assert.match( "task launch canonicalizes package and binary harness aliases before daemon validation", ); +// 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.ok( + source.indexOf("await ensureAdapterManifestScaffold(binding.harness)") < source.indexOf("const res = await 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 675b5584d..9c086fdf1 100644 --- a/src/app/api/board/[id]/chat/route.ts +++ b/src/app/api/board/[id]/chat/route.ts @@ -10,6 +10,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"; import { isSshRuntime } from "@/lib/familiar-runtime"; // Match the daemon's "harness X is not a supported harness" rejection @@ -130,6 +131,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); + // A familiar can be reconfigured from one runtime to another without the // card being reassigned. Model ids are runtime-specific, so only forward an // override that was chosen for this exact canonical harness. Legacy or stale diff --git a/src/app/api/chat/send/route.ts b/src/app/api/chat/send/route.ts index cc2317a22..227a7647b 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"; @@ -927,6 +928,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/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/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( 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/harness-adapters.test.ts b/src/lib/harness-adapters.test.ts index cb8e57a76..eeabc3b6f 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,35 @@ 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.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({ + adapters: [{ + ...JSON.parse(hermesManifest?.contents ?? "{}").adapters?.[0], + environment: { HERMES_PROFILE: "custom" }, + }], + }), + "win32", + ), + "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"); // 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..e5099ab05 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,60 @@ 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; + // 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 67314dfbf..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 { 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"; @@ -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,9 +104,63 @@ 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; + } +}); + +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); +}); + +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; 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"), @@ -116,16 +172,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`, ); } }); diff --git a/src/lib/server/adapter-manifest-scaffold.ts b/src/lib/server/adapter-manifest-scaffold.ts new file mode 100644 index 000000000..c27e4f152 --- /dev/null +++ b/src/lib/server/adapter-manifest-scaffold.ts @@ -0,0 +1,52 @@ +import { lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { + adapterManifestScaffoldForHarness, + isLegacyWindowsHermesManifest, +} from "../harness-adapters.ts"; +import { covenAdaptersDir, isManifestShadowedByBuiltin } from "./adapter-conflict-heal.ts"; + +type ManifestPathKind = "missing" | "file" | "other"; + +async function manifestPathKind(targetPath: string): Promise { + try { + return (await lstat(targetPath)).isFile() ? "file" : "other"; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing"; + throw error; + } +} + +/** + * 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, + options: { adaptersDir?: string; platform?: NodeJS.Platform } = {}, +): Promise { + const platform = options.platform ?? process.platform; + const scaffold = adapterManifestScaffoldForHarness(harness, platform); + if (!scaffold) return false; + + const adaptersDir = options.adaptersDir ?? covenAdaptersDir(); + const manifestPath = path.join(adaptersDir, scaffold.filename); + if (await isManifestShadowedByBuiltin(manifestPath)) return false; + + // 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 (kind === "missing" || shouldRepair) { + await mkdir(adaptersDir, { recursive: true }); + await writeFile(manifestPath, scaffold.contents, "utf8"); + return true; + } + return false; +}