Files
buzz/desktop/test-loader-hooks.mjs
T
95fdf97880 feat(acp): bring your own harness (BYOH) — generic ACP runtime seam + settings gallery (#2773)
## What

Implements a "bring your own harness" (BYOH) generic ACP mechanism —
replacing per-harness backend code with a data-driven 3-tier system:

- **Tier 1 (compiled-in builtins):** goose, claude, codex, buzz-agent —
unchanged behavior
- **Tier 2 (bundled presets):** cursor, omp, grok, opencode, kimi, amp,
hermes, openclaw, and any future additions — defined in
`PRESET_HARNESSES`, no code duplication, icons stay
TerminalSquare/bundled-asset-only
- **Tier 3 (user-defined custom):** JSON definitions saved to
`custom_harnesses/` under app data; managed via Settings → Agents UI

## Changes

### Core data model
- `HarnessDefinition` — id, label, command, args, env, install URL/hint
- `PRESET_HARNESSES` static table — single source of truth for all
presets; `preset_harness_ids()` derives reserved IDs (D-11: no
hand-maintained copy)
- `source: "builtin" | "preset" | "custom"` tagging on every catalog
entry

### Persistence (B-4, B-6)
- `save_custom_harness_to_dir(dir, definition, rename_old_id)` —
backup-swap atomic write (backs up target → .bak, commits temp → target,
restores .bak on failure, removes .bak on success); safe on Windows
where `fs::rename` over an existing file is "access denied"
- `save_and_warm` / `delete_and_warm` — hold `PERSIST_MUTEX` for the
write + registry-warm pair, eliminating the lost-update race (B-6) where
two concurrent saves could interleave their warm calls and leave a stale
registry snapshot
- Validate-before-mutate: both IDs and env validated before any
filesystem mutation

### Env validation boundary (B-3)
- `validate_harness_definition_pub` calls `validate_user_env_keys` on
definition env at save AND load
- Rejects malformed keys (BUZZ_AUTH_TAG=x forgery shape), reserved keys
(BUZZ_MANAGED_AGENT etc.), NUL bytes, oversized values

### TypeScript boundary (B-2 / Thufir CRITICAL)
- `RawAcpRuntimeCatalogEntry` now declares `definition_env?:
Record<string,string>` and `source: "builtin" | "preset" | "custom"`
- `fromRawAcpRuntimeCatalogEntry` maps `definition_env → definitionEnv`
(camelCase); absent field defaults to `{}`
- Edit form reads `entry.definitionEnv` — env no longer erased on
save-then-edit cycle

### Unified descriptor (Phase A / Thufir F4)
- `EffectiveHarnessDescriptor { command, args, env }` in `readiness.rs`
- `resolve_effective_harness_descriptor()` — single resolver used by
spawn, spawn_hash, summary, get_agent_models (both saved and unsaved),
and readiness
- No competing arg-resolution forms

### Other fixes
- B-5: stop freezing `runtime.defaultArgs` into `record.agent_args` on
normal create paths
- B-7: readiness exec-check — `MissingBinary` variant for custom
commands not found on PATH
- B-8: onboarding transition — `setTimeout(0)` removed, parent-owned
route intent via `navigateAfterComplete` prop
- C-9: collector-discriminating sweep tests with injectable filters
- C-10: `HarnessManagementCard` uses `harnessGalleryLogic` helpers
(killed duplicate filter/sort)
- D-11: `BUILTIN_IDS` derived from `PRESET_HARNESSES` (no
hand-maintained copy)
- D-12: `mobile/pubspec.lock` churn reverted
- D-13: false ownership fast-path comment fixed
- D-14: URL scheme validation for `installInstructionsUrl`
- D-15: OpenClaw Gateway env-locus README line

### Tests added
**B-4 persistence (6 tests):**
`save_to_dir_create_writes_file_and_loads_back`,
`save_to_dir_same_id_edit_replaces_content`,
`save_to_dir_backup_is_cleaned_up_after_same_id_edit`,
`save_to_dir_rename_removes_old_file_and_creates_new`,
`save_to_dir_rename_nonexistent_old_id_is_non_fatal`,
`save_to_dir_roundtrip_with_env_preserves_values`

**B-3 env validation (6 tests):**
`validate_rejects_malformed_key_with_equals_sign`,
`validate_rejects_reserved_key_buzz_managed_agent`,
`validate_rejects_reserved_key_case_insensitive`,
`validate_rejects_nul_byte_in_value`,
`validate_rejects_value_over_per_value_size_limit`,
`validate_accepts_well_formed_env`

**B-2 API boundary (4 TS tests in tauri.test.mjs):**
`fromRawAcpRuntimeCatalogEntry maps definition_env to definitionEnv`,
`defaults definitionEnv to {} when absent`, `preserves source preset`,
`env round-trips through edit payload shape`

## Preset catalog

| ID | Label | Command |
|----|-------|---------|
| `cursor` | Cursor | `cursor-agent acp` |
| `omp` | Oh My Pi | `omp acp` |
| `grok` | Grok Build | `grok agent --always-approve stdio` |
| `opencode` | OpenCode | `opencode acp` |
| `kimi` | Kimi Code | `kimi acp` |
| `amp` | Amp | `amp-acp` |
| `hermes` | Hermes Agent | `hermes-acp` |
| `openclaw` | OpenClaw | `openclaw acp` |

## Review-fix pass (2026-07-26, Eva)

Fixes from the three-way review (Wren / Dawn / Eva) in the
buzz-generic-acp-harnesses thread, pushed as new commits (no rewrite):

1. **installHint edit round-trip** — form seeding extracted to
`formValuesFromCatalogEntry` (single source of truth), input rendered,
full-definition lossless round-trip regression.
2. **Dangling-delete coherence** — delete allowed; confirm counts
referencing agents (direct pin + persona-inherited); summary rows render
`harness (deleted): <id>`; spawn errors become actionable sentences
(`user_facing_harness_error`); composed delete→summary→start test.
3. **Comma-in-args** — rejected at `validate_harness_definition` (shared
by save AND disk load), mirrored inline in the form.
4. **Registry publish race** — collision/dup filtering moved into
`load_custom_harnesses` (both loaders inherit shadowing rules);
discovery publishes by re-reading the dir under `persist_mutex` (lock
scoped to publish only); deterministic interleaving regressions for
save-during-discovery and delete-during-discovery.
5. **Mechanical** — discarded `belongs_to_us` sweep arg deleted,
`load_global_agent_config` hoisted out of the per-record summary loop,
duplicated doc paragraph + stray SAFETY comment removed.
6. **PGID test de-flaked** — leader kept alive through the assertion.

Known follow-up (filed in review, not blocking): file-size split-outs
queued in `check-file-sizes.mjs` entries.

## Gate table — head `bf53f1d60`

| Gate | Result |
|------|--------|
| `cargo test --lib` (desktop/src-tauri) | **1701 passed**, 0 failed, 14
ignored |
| desktop JS suite (`pnpm test`) | **3605 passed**, 0 failed |
| `tsc --noEmit` | clean |
| `biome check` + file-size/px/pubkey checks | clean |
| `cargo clippy --lib -- -D warnings` | clean |
| `cargo fmt --check` | clean |

PR head: `bf53f1d60e3cbd07392e1287b83bb37ba90d0d33` — includes merge of
origin/main (`c2a4ee711`, conflicts in agent_models composed with
#2890's live Databricks discovery)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-07-26 20:06:20 -04:00

173 lines
5.5 KiB
JavaScript

import { fileURLToPath } from "node:url";
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
const srcRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"src",
);
const repoRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
function resolveSourcePath(basePath) {
// Existence decides, not path.extname — a dotted basename like
// `ProfileAvatarEditor.utils` (→ .utils.ts on disk) looks like an
// extension but still needs resolving.
if (fs.existsSync(basePath) && fs.statSync(basePath).isFile()) {
return basePath;
}
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mjs"]) {
const candidate = `${basePath}${extension}`;
if (fs.existsSync(candidate)) {
return candidate;
}
}
for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mjs"]) {
const candidate = path.join(basePath, `index${extension}`);
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
// emoji-mart ships a bundled CJS main that node's cjs-module-lexer cannot
// extract named exports from (`import { init } from "emoji-mart"` throws
// under node ESM even though the bundler handles it). Tests never exercise
// the picker, so serve inert stubs for the emoji-mart entrypoints.
const stubModules = new Map([
[
"emoji-mart",
"export const init = () => {};\n" +
"export const SearchIndex = { search: async () => [] };\n" +
"export default {};\n",
],
["@emoji-mart/react", "export default function Picker() { return null; }\n"],
]);
const STUB_URL_PREFIX = "buzz-test-stub:";
// Vite resolves asset imports (`./logo.png`, `./logo.png?inline`) to a URL or
// base64 string at bundle time; node's ESM resolver has no such loader and
// throws on the query suffix. Serve an inert string so components that embed
// assets stay unit-testable.
const ASSET_SPECIFIER = /\.(?:png|jpe?g|gif|svg|webp|avif|ico)(?:\?[^/]*)?$/;
const ASSET_URL_PREFIX = "buzz-test-asset:";
export function resolve(specifier, context, nextResolve) {
if (ASSET_SPECIFIER.test(specifier)) {
return {
shortCircuit: true,
url: `${ASSET_URL_PREFIX}${specifier}`,
};
}
if (stubModules.has(specifier)) {
return {
shortCircuit: true,
url: `${STUB_URL_PREFIX}${specifier}`,
};
}
if (specifier === "@features-manifest") {
const resolved = path.join(repoRoot, "preview-features.json");
return nextResolve(resolved, context);
}
if (specifier.startsWith("@/")) {
const stripped = specifier.slice(2);
// Preserve explicit extensions (.mjs, .js, .json, .ts, etc.). The bundler
// tolerates extensionless `@/` imports for source files; node's ESM
// resolver does not, so resolve against the extensions the app uses.
// Otherwise paths like `@/.../foo.mjs` would be coerced into `foo.mjs.ts`
// and fail to resolve.
const resolved = resolveSourcePath(`${srcRoot}/${stripped}`);
return nextResolve(resolved ?? `${srcRoot}/${stripped}`, context);
}
// Resolve extensionless relative TS imports (e.g. `./parseImeta`) — the app's
// bundler adds the extension, but node's ESM resolver does not. Without this,
// any .ts that relative-imports a sibling .ts can't be imported from a test,
// which previously forced stale inlined copies of the source under test.
// Dotted basenames (`./ProfileAvatarEditor.utils`) look like extensions to
// path.extname, so resolveSourcePath existence-checks instead.
if (
(specifier.startsWith("./") || specifier.startsWith("../")) &&
context.parentURL?.startsWith("file:")
) {
const parentPath = fileURLToPath(context.parentURL);
const resolved = resolveSourcePath(
path.resolve(path.dirname(parentPath), specifier),
);
if (resolved) {
return nextResolve(resolved, context);
}
return nextResolve(specifier, context);
}
return nextResolve(specifier, context);
}
export async function load(url, context, nextLoad) {
if (url.startsWith(ASSET_URL_PREFIX)) {
return {
format: "module",
shortCircuit: true,
source: 'export default "test-asset";\n',
};
}
if (url.startsWith(STUB_URL_PREFIX)) {
return {
format: "module",
shortCircuit: true,
source: stubModules.get(url.slice(STUB_URL_PREFIX.length)) ?? "",
};
}
// The app bundler loads .json imports without attributes (e.g. the bare
// `@emoji-mart/data` entrypoint); node's ESM resolver requires
// `with { type: "json" }` on every hop. Serve json here so transitive
// imports from source under test don't need bundler-only semantics.
if (url.endsWith(".json")) {
return {
format: "json",
shortCircuit: true,
source: fs.readFileSync(fileURLToPath(url), "utf8"),
};
}
// Vite handles side-effect CSS imports (e.g. `import "./card-texture.css"`
// in shared/ui) at bundle time; node's ESM loader has no CSS support. Serve
// them as empty modules so components with style imports stay unit-testable.
if (url.endsWith(".css")) {
return {
format: "module",
shortCircuit: true,
source: "",
};
}
if (url.endsWith(".tsx")) {
const source = fs.readFileSync(fileURLToPath(url), "utf8");
const transpiled = ts.transpileModule(source, {
compilerOptions: {
jsx: ts.JsxEmit.ReactJSX,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2020,
},
fileName: fileURLToPath(url),
});
return {
format: "module",
shortCircuit: true,
source: transpiled.outputText,
};
}
return nextLoad(url, context);
}