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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/app/api/board/[id]/chat/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
6 changes: 6 additions & 0 deletions src/app/api/board/[id]/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/chat/send/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions src/app/api/config/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 2 additions & 30 deletions src/app/api/config/route.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -21,15 +17,6 @@ const ALLOWED_TOP_LEVEL_KEYS = new Set([
]);
type ConfigPatchBody = Record<string, unknown>;

async function pathExists(targetPath: string): Promise<boolean> {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}

function harnessesFromConfigPatch(body: ConfigPatchBody): string[] {
const harnesses = new Set<string>();
const defaults = body.defaults;
Expand All @@ -56,23 +43,8 @@ function harnessesFromConfigPatch(body: ConfigPatchBody): string[] {
async function scaffoldAdapterManifestsFromPatch(body: ConfigPatchBody): Promise<void> {
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);
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/app/api/familiars/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 4 additions & 12 deletions src/app/api/familiars/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions src/app/api/onboarding/install/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 10 additions & 5 deletions src/app/api/onboarding/install/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,11 +673,16 @@ async function finishInstallJob(
);
}

// Hermes has no positional prompt slot, so the harness's `-- "<prompt>"`
// 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(
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/onboarding/setup/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 5 additions & 19 deletions src/app/api/onboarding/setup/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -71,36 +70,23 @@ 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[] = [];

await mkdir(covenDir, { recursive: true });
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);
Expand Down
32 changes: 31 additions & 1 deletion src/lib/harness-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
adapterSetupState,
runtimeSourceSetupState,
adapterManifestScaffoldForHarness,
isLegacyWindowsHermesManifest,
covenHelpSupportsAdapterList,
covenRunSupportsModelFlag,
covenRunSupportsAddDirFlag,
Expand Down Expand Up @@ -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 ?? "{}");
Expand All @@ -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
Expand Down
Loading
Loading