diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 568657b..b2defa6 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -42,7 +42,10 @@ This capability model is [ADR-0016](adr/0016-capability-driven-integration-adapters.md) (Accepted); the controls below implement it. `ak host` owns execution-host lifecycle and selection (`status`, `pick`, `refresh`, and `off`), with `ak x host` as its plumbing spelling. Inference providers and bindings remain -separate axes even though some provider controls share that workflow. +separate axes even though some provider controls share that workflow. `ak host status` also +checks every binding declared in `kit.json` and prints a warning naming any entry with an +unknown host, unknown provider, or unsupported transport — warnings only; nothing is changed +or removed on your behalf. --- diff --git a/docs/TRANSCRIPTS.md b/docs/TRANSCRIPTS.md index c94b5e0..6de247d 100644 --- a/docs/TRANSCRIPTS.md +++ b/docs/TRANSCRIPTS.md @@ -175,15 +175,15 @@ and image-only pastes get the right kind" (the two edges). ## 4. The `readSession` pipeline — how one session becomes a payload -`readSession(id, opts)` (`usage-index.mjs:1350-1438`) is the only way +`readSession(id, opts)` (`usage-index.mjs:1457-1513`) is the only way transcript content leaves the module, and every step is a gate: ### 4.1 Locate, contain, bound 1. **Id grammar before any filesystem access** — `VALID_ID` (`/^[A-Za-z0-9._-]{1,128}$/`, `usage-index.mjs:83`) rejects traversal - shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1382-1386`). -2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1392`), + shapes with `ERR_INVALID_SESSION_ID` (`usage-index.mjs:1399-1406`). +2. **Locate by id** across both roots (`locate`, `usage-index.mjs:1409`), consulting the scan cache when present but never requiring it — `readSession` works with no prior `buildIndex`. 3. **Realpath containment** (`usage-index.mjs:1388-1402`) — the resolved file @@ -198,8 +198,8 @@ transcript content leaves the module, and every step is a gate: ### 4.2 Parse and price The file is parsed with `withTurns: true` by the provider's parser -(`usage-index.mjs:1483-1490`), and `meta` is assembled -(`usage-index.mjs:1467-1495`) with the same fields the Sessions view rows +(`usage-index.mjs:1502-1509`), and `meta` is assembled +(`usage-index.mjs:1518-1545`) with the same fields the Sessions view rows carry — `prompts`, `responses`, `exceptions`, `sidechain`, `threadSource`, `models`, `tools`, `skill`/`plugin`, worktree — plus a `cost` priced from the same per-model usage rows `aggregate()` uses (the header used to render a @@ -211,7 +211,7 @@ Every turn body is passed through `maskSecrets` (`usage-index.mjs:196` — the 23 secret shapes) **server-side, before serialization**, then length-capped at `MAX_TURN_CHARS` (40,000, `usage-index.mjs:77`) with the marker appended -(`usage-index.mjs:1530-1540`). Two invariants: +(`usage-index.mjs:1552-1561`). Two invariants: - **Presence is the signal.** `truncated`/`originalChars` are emitted only when the slice fired, so a complete turn cannot be misread as abridged. diff --git a/docs/USAGE-SCORECARD-METRICS.md b/docs/USAGE-SCORECARD-METRICS.md index d27e0ec..0a0e32c 100644 --- a/docs/USAGE-SCORECARD-METRICS.md +++ b/docs/USAGE-SCORECARD-METRICS.md @@ -467,7 +467,7 @@ session that runs from 23:58 local to 00:05 local is billed to the day its *first* row landed on (test: `tests/kit/usage-index.test.mjs:634`, "a session that opens before midnight is counted on its first billed day"). Accumulation: -`byDay[row.day].cost += rowCost` (`usage-index.mjs:899`). Bar height: +`byDay[row.day].cost += rowCost` (`usage-index.mjs:1020`). Bar height: `h = maxDay ? max(2, cost/maxDay*100) : 2` (`dashboard/client.mjs`) — every non-empty day gets a visually nonzero bar (floor of 2%), so a very cheap day is never rendered as invisible. @@ -964,7 +964,7 @@ commit `540be18` on this branch. Claude's parser passes `responses: 1` per assistant turn (`usage-index.mjs:556`, the current equivalent), but Codex's call passed no such field at all. Because `byModel[model].responses` is summed -directly from each usage row's `responses` field (`usage-index.mjs:974`, +directly from each usage row's `responses` field (`usage-index.mjs:1022`, `m.responses += row.responses`), **every** Codex model in §10's "Models in Play" list displayed `0 resp` regardless of real token/cost volume or actual `agent_message` count. **Fix:** `parseCodex` now passes `responses: diff --git a/docs/adr/0015-managed-codex-native-statusline.md b/docs/adr/0015-managed-codex-native-statusline.md index ec311ba..dde03f6 100644 --- a/docs/adr/0015-managed-codex-native-statusline.md +++ b/docs/adr/0015-managed-codex-native-statusline.md @@ -2,6 +2,11 @@ - **Status:** Accepted - **Date:** 2026-07-28 +- **Updated:** 2026-08-14 +- **Update note:** The boolean `statuslineSupported` consumer this ADR's context + describes was removed as dead code in the Phase 0 consistency pass (finding + F-07): it had zero call sites. The registry capability `commandStatusline` is + the surviving vocabulary for this distinction. - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0006](0006-primary-host-and-ambidextrous-mirroring.md), diff --git a/docs/adr/0016-capability-driven-integration-adapters.md b/docs/adr/0016-capability-driven-integration-adapters.md index 8459156..29e0416 100644 --- a/docs/adr/0016-capability-driven-integration-adapters.md +++ b/docs/adr/0016-capability-driven-integration-adapters.md @@ -3,12 +3,21 @@ - **Status:** Accepted; compatibility clauses superseded by [ADR-0020](0020-ga-stable-surfaces.md) - **Date:** 2026-07-28 -- **Updated:** 2026-08-04 +- **Updated:** 2026-08-14 - **Update note:** Added read-only Codex plugin-hook compatibility facts, runtime-selected Ruflo project-memory store proofs, and the non-correlatable OpenRouter account-analytics boundary; removed the pre-GA compatibility command, persisted fields, and adapter bootstrap. ADR-0023 now requires each host adapter to declare its setup trust posture and changes for host-neutral preflight. + Phase 0 consistency pass (2026-08-14): host adapters gained a required + `enabledByDefault` boolean and the three enabled-host default literals now + derive from it via `defaultHostMap()` (F-15); the `observability` axis is + recorded as terminal — validation metadata with referential integrity only, + deliberately not a dispatch surface, no collector loop exists (F-12); the + non-throwing `validateBinding` is wired into `ak host status` as per-entry + warnings (F-16); and the integrations migrator derives each host's native + default provider from the provider registry's host-login entries instead of a + literal map, inferring no binding at all for hosts without one (F-13). - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0001](0001-one-routing-policy-many-projections.md), [ADR-0003](0003-auto-seed-dual-host-provenance.md), diff --git a/src/commands/x/host.mjs b/src/commands/x/host.mjs index f4454c0..5d7f445 100644 --- a/src/commands/x/host.mjs +++ b/src/commands/x/host.mjs @@ -18,7 +18,9 @@ import { parseRouteSpecs, formatModelHelp, PRIMARY_HOSTS, DEFAULT_PRIMARY_HOST, import { loadKitConfig, saveKitConfig } from '../../lib/config.mjs'; import { OPENCODE_LIFECYCLE_ADAPTER, reconcileOpencodeGuidance } from '../../lib/opencode.mjs'; import { runLifecycle } from '../../lib/adapters/lifecycle.mjs'; -import { routableHostIds } from '../../lib/adapters/index.mjs'; +import { + routableHostIds, defaultHostMap, validateBinding, HOST_REGISTRY, PROVIDER_REGISTRY, +} from '../../lib/adapters/index.mjs'; import { newlyEnabledHostTrustManifest, trustManifestLines, } from '../../lib/trust-manifest.mjs'; @@ -149,6 +151,16 @@ export async function run({ flags, positionals, pkgRoot }) { return 2; } +/** Structured, friendly warnings for invalid user-declared bindings in kit.json + * integrations.bindings (F-16: validateBinding had zero call sites — wired + * here so a bad entry surfaces as a warning instead of silent garbage or an + * uncaught TypeError reaching the user). A valid binding list yields []. */ +export function bindingWarnings(cfg) { + return (cfg.integrations?.bindings ?? []).flatMap((binding, index) => + validateBinding(binding, { hosts: HOST_REGISTRY, providers: PROVIDER_REGISTRY }).map((error) => + `kit.json integrations.bindings[${index}].${error.path.replace(/^binding\./, '')}: ${error.code} (${JSON.stringify(error.value)})`)); +} + async function status({ flags, cwd }) { const cfg = loadKitConfig(); const facts = await collectIntegrationFacts({ cwd, cfg }); @@ -170,6 +182,8 @@ async function status({ flags, cwd }) { return 0; } + for (const message of bindingWarnings(cfg)) warn(message); + const dflt = isDefault(cfg); console.log(bold('ruflo agent hosts') + dim(` (wiring scope: ${scope})`)); for (const h of HOSTS) { @@ -331,7 +345,7 @@ async function off({ cwd, pkgRoot }) { models: [], maxBudgetUsd: null, }; - cfg.integrations.hosts = { claude: true, codex: false, opencode: false }; + cfg.integrations.hosts = defaultHostMap(); cfg.routing.primaryHost = DEFAULT_PRIMARY_HOST; cfg.routing.routes = {}; cfg.integrations.ownership ??= {}; diff --git a/src/lib/adapters/bindings.mjs b/src/lib/adapters/bindings.mjs index f4135b2..c4e0e26 100644 --- a/src/lib/adapters/bindings.mjs +++ b/src/lib/adapters/bindings.mjs @@ -69,6 +69,10 @@ export function resolveBinding(bindings, { return candidates.length === 1 ? candidates[0] : null; } +// Non-throwing counterpart to assertValidBinding — wired into `ak host status` +// (src/commands/x/host.mjs bindingWarnings) so an invalid user-declared +// kit.json binding surfaces as a friendly warning list instead of an uncaught +// TypeError (F-16). export function validateBinding(binding, registries, constraints = {}) { const errors = []; const hostIds = new Set(constraints.hosts ?? (registries?.hosts ?? []).map((entry) => entry.id)); diff --git a/src/lib/adapters/config.mjs b/src/lib/adapters/config.mjs index d168484..8d975d8 100644 --- a/src/lib/adapters/config.mjs +++ b/src/lib/adapters/config.mjs @@ -1,8 +1,35 @@ import { isDeepStrictEqual } from 'node:util'; import { immutable } from './schema.mjs'; +import { PROVIDER_REGISTRY } from './registries.mjs'; export const CURRENT_INTEGRATIONS_VERSION = 2; +// A host's native provider = the registry provider with host-login +// credentials whose projections include that host (anthropic -> claude, +// openai -> codex). Derived from the registry instead of a literal map so +// it can't drift from the adapters it describes, and so a host with no such +// provider (e.g. opencode) simply has none — never a synthetic stand-in +// (F-13). Two host-login providers claiming the same host would silently +// last-write-wins in a plain Map build, defeating the whole point of +// deriving this from the registry — fail loudly at load time instead. +function buildNativeProviderByHost(providers) { + const byHost = new Map(); + for (const provider of providers) { + if (provider.credentials?.kind !== 'host-login') continue; + for (const host of provider.projections) { + const existing = byHost.get(host); + if (existing && existing !== provider.id) { + throw new Error( + `adapter registry ambiguity: both '${existing}' and '${provider.id}' claim host-login for host '${host}'`, + ); + } + byHost.set(host, provider.id); + } + } + return byHost; +} +const NATIVE_PROVIDER_BY_HOST = buildNativeProviderByHost(PROVIDER_REGISTRY); + const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); const own = (value, key) => plain(value) && Object.hasOwn(value, key); @@ -61,22 +88,30 @@ export function migrateIntegrationConfig(config = {}, _options = {}) { ? structuredClone(providers.hosts) : structuredClone(existing.hosts ?? {}); const enabled = Object.entries(hosts).filter(([, on]) => on).map(([host]) => host); - const defaults = { claude: 'anthropic', codex: 'openai' }; const priorBindings = mergeBindings( Array.isArray(existing.bindings) ? existing.bindings : [], Array.isArray(providers.bindings) ? providers.bindings : [], ); const seenHosts = new Set(priorBindings.map((binding) => binding.host)); - const inferred = enabled.filter((host) => !seenHosts.has(host)).map((host) => ({ - id: `${defaults[host] ?? 'unknown'}-via-${host}`, - host, - provider: defaults[host] ?? null, - model: null, - transport: 'native', - endpoint: null, - provenance: defaults[host] ? 'inferred' : 'unknown', - managedBy: 'unknown', - })); + // A host with no registered native provider (F-13) gets no inferred + // binding at all — nothing is restamped on any load, and it stays that + // way until a real binding is declared for it. + const inferred = enabled + .filter((host) => !seenHosts.has(host)) + .map((host) => { + const provider = NATIVE_PROVIDER_BY_HOST.get(host); + return provider ? { + id: `${provider}-via-${host}`, + host, + provider, + model: null, + transport: 'native', + endpoint: null, + provenance: 'inferred', + managedBy: 'unknown', + } : null; + }) + .filter((binding) => binding !== null); const ownership = structuredClone(existing.ownership ?? {}); const reverseMarker = 'rufloCodexMcp'; const hasLegacyCodex = (own(providers, 'codexMcp') && providers.codexMcp != null) diff --git a/src/lib/adapters/registries.mjs b/src/lib/adapters/registries.mjs index cd16551..d9cbcd5 100644 --- a/src/lib/adapters/registries.mjs +++ b/src/lib/adapters/registries.mjs @@ -93,6 +93,7 @@ export function validateHostAdapter(value, { ['detect-never-overwrite', 'managed', 'unmanaged'], 'host.install.externalInstallPolicy'); validateCapabilities(value.capabilities, HOST_CAPABILITIES, 'host.capabilities'); validateHostTrust(value.trust); + if (typeof value.enabledByDefault !== 'boolean') throw new TypeError('host.enabledByDefault must be boolean'); assertId(value.configProjection, 'host.configProjection'); assertStringArray(value.observability, 'host.observability'); if (projections && !projections[value.configProjection]) { @@ -153,7 +154,7 @@ const OBSERVABILITY_MAP = registryFrom([ const hostEntries = [ { - id: 'claude', label: 'Claude Code', + id: 'claude', label: 'Claude Code', enabledByDefault: true, install: { bin: 'claude', npmPackage: '@anthropic-ai/claude-code', externalInstallPolicy: 'detect-never-overwrite' }, capabilities: { canDriveSession: true, canBePrimary: true, canRouteActivities: true, commandStatusline: true, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true }, auth: { apiKeyEnv: ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'], loginFile: ['.claude', '.credentials.json'], keyOverridesLogin: false }, @@ -178,7 +179,7 @@ const hostEntries = [ configProjection: 'claude', observability: ['claude-transcripts', 'claude-statusline'], }, { - id: 'codex', label: 'OpenAI Codex', + id: 'codex', label: 'OpenAI Codex', enabledByDefault: false, install: { bin: 'codex', npmPackage: '@openai/codex', externalInstallPolicy: 'detect-never-overwrite' }, capabilities: { canDriveSession: true, canBePrimary: true, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: true, nativeMcpConfig: true, nativeGuidance: true }, auth: { apiKeyEnv: ['OPENAI_API_KEY'], loginFile: ['.codex', 'auth.json'], keyOverridesLogin: true }, @@ -199,7 +200,7 @@ const hostEntries = [ configProjection: 'codex', observability: ['codex-transcripts', 'codex-app-server'], }, { - id: 'opencode', label: 'OpenCode', + id: 'opencode', label: 'OpenCode', enabledByDefault: false, install: { bin: 'opencode', npmPackage: 'opencode-ai', externalInstallPolicy: 'detect-never-overwrite' }, capabilities: { canDriveSession: true, canBePrimary: false, canRouteActivities: true, commandStatusline: false, transcripts: true, usage: false, nativeMcpConfig: true, nativeGuidance: true }, auth: { apiKeyEnv: [], loginFile: ['.local', 'share', 'opencode', 'auth.json'], keyOverridesLogin: false }, @@ -247,6 +248,9 @@ const PROVIDER_MAP = registryFrom(providerEntries, (entry) => validateProviderAdapter(entry, { projections: PROJECTION_MAP, observability: OBSERVABILITY_MAP }), 'provider'); export const PROJECTION_REGISTRY = immutable(Object.values(PROJECTION_MAP)); +// Validation metadata + referential integrity only (host.observability entries +// are cross-checked against this set) — deliberately NOT a dispatch surface; +// no collector loop maps an observability id to a live collector (F-12). export const OBSERVABILITY_REGISTRY = immutable(Object.values(OBSERVABILITY_MAP)); export const HOST_REGISTRY = immutable(Object.values(HOST_MAP)); export const PROVIDER_REGISTRY = immutable(Object.values(PROVIDER_MAP)); @@ -274,6 +278,13 @@ export const providerIds = (predicate = () => true) => PROVIDER_REGISTRY.filter( export const primaryHostIds = () => hostIds((host) => host.capabilities.canBePrimary); export const routableHostIds = () => hostIds((host) => host.capabilities.canRouteActivities); export const managedHostIds = () => hostIds((host) => host.capabilities.canDriveSession); +/** Fresh {hostId: enabledByDefault} for every session-driving host — the single + * source for the `{claude:true, codex:false, opencode:false}` literal that used + * to be triplicated across config.mjs, providers.mjs and x/host.mjs (F-15). + * Returns a new object per call since every call site mutates its copy. */ +export const defaultHostMap = () => Object.fromEntries( + HOST_REGISTRY.filter((host) => host.capabilities.canDriveSession).map((host) => [host.id, host.enabledByDefault]), +); export function hostsWithCapability(entriesOrCapability, maybeCapability) { const entries = Array.isArray(entriesOrCapability) ? entriesOrCapability : HOST_REGISTRY; const capability = Array.isArray(entriesOrCapability) ? maybeCapability : entriesOrCapability; diff --git a/src/lib/config.mjs b/src/lib/config.mjs index 718026f..20bb0b2 100644 --- a/src/lib/config.mjs +++ b/src/lib/config.mjs @@ -8,6 +8,7 @@ import { CURRENT_INTEGRATIONS_VERSION, migrateIntegrationConfig, } from './adapters/config.mjs'; +import { defaultHostMap } from './adapters/registries.mjs'; import { DEFAULT_PRIMARY_HOST, ROUTING_SCHEMA_VERSION, @@ -25,7 +26,7 @@ const DEFAULTS = { mcp: { register: true, excludeFamilies: [] }, integrations: { version: CURRENT_INTEGRATIONS_VERSION, - hosts: { claude: true, codex: false, opencode: false }, + hosts: defaultHostMap(), bindings: [], }, routing: { diff --git a/src/lib/execution/subprocess.mjs b/src/lib/execution/subprocess.mjs index ed67438..890f1c8 100644 --- a/src/lib/execution/subprocess.mjs +++ b/src/lib/execution/subprocess.mjs @@ -81,6 +81,62 @@ export function createJsonlSummaryCapture(select, label) { }; } +/** Retain only the final non-empty plain-text line, bounded the same way as + * the JSONL capture above. For hosts whose oneshot mode emits plain text on + * stdout instead of JSONL (Hermes-class hosts): there is no structured event + * to select a field from, so the summary is simply the last non-empty line + * seen — an oversized line is discarded without poisoning a later one. */ +export function createPlainTextSummaryCapture(label) { + if (typeof label !== 'string' || !label) throw new TypeError('plain-text summary capture requires a label'); + let line = ''; + let discarding = false; + let selected = null; + let selectedTooLarge = false; + + const consume = (value) => { + if (!value.trim()) return; + if (Buffer.byteLength(value, 'utf8') > SUMMARY_LINE_LIMIT) { + selectedTooLarge = true; + selected = null; + return; + } + selected = value; + }; + + return { + write(chunk) { + let remaining = String(chunk); + while (remaining) { + const newline = remaining.indexOf('\n'); + const fragment = newline === -1 ? remaining : remaining.slice(0, newline); + remaining = newline === -1 ? '' : remaining.slice(newline + 1); + if (!discarding) { + const next = `${line}${fragment}`; + if (Buffer.byteLength(next, 'utf8') > SUMMARY_LINE_LIMIT) { + line = ''; + discarding = true; + } else { + line = next; + } + } + if (newline !== -1) { + if (!discarding) consume(line.replace(/\r$/, '')); + line = ''; + discarding = false; + } + } + }, + read() { + if (!discarding && line) { + consume(line.replace(/\r$/, '')); + line = ''; + } + if (selectedTooLarge) throw new TypeError(`${label} final output exceeded the ${SUMMARY_LINE_LIMIT}-byte cap`); + return selected; + }, + }; +} + function waitForChild(child, stdout, stderr) { return new Promise((resolve) => { let settled = false; diff --git a/src/lib/footprint/install.mjs b/src/lib/footprint/install.mjs index 56ea3d0..b59735e 100644 --- a/src/lib/footprint/install.mjs +++ b/src/lib/footprint/install.mjs @@ -155,11 +155,20 @@ function safeGlobalRoot() { } } +/** The npm-global root for a package, or null when either the global root is + * unknown or there is no package to look up. `pkg` is absent for a host the + * registry permits to skip npmPackage (host.install.npmPackage is optional + * in the schema); path.join(dir, undefined) throws, so both operands must be + * present before a root is even attempted. */ +export function npmPackageRoot(globalRootDir, pkg) { + return globalRootDir && pkg ? path.join(globalRootDir, pkg) : null; +} + /** The managed-tool descriptors this collector measures, composed from the * registry plus the modules that own the non-npm tools. `kind` says how the * root is found, not how it was installed. */ export function managedTools({ pkgRoot = null, globalRootDir = null } = {}) { - const npmRoot = (pkg) => (globalRootDir ? path.join(globalRootDir, pkg) : null); + const npmRoot = (pkg) => npmPackageRoot(globalRootDir, pkg); const tools = [ { id: 'ruflo', label: 'ruflo', pkg: 'ruflo', bin: 'ruflo', kind: 'npm', root: npmRoot('ruflo') }, { diff --git a/src/lib/hosts.mjs b/src/lib/hosts.mjs index bed8b70..197b217 100644 --- a/src/lib/hosts.mjs +++ b/src/lib/hosts.mjs @@ -34,7 +34,6 @@ export const HOST_ADAPTERS = Object.fromEntries(HOST_REGISTRY guidanceFile: host.legacy.guidanceFile, configFormat: host.legacy.configFormat, statusline: host.legacy.statusline, - statuslineSupported: host.capabilities.commandStatusline, aqeProvider: host.legacy.aqeProvider, envMarkers: host.legacy.envMarkers, auth: host.auth, @@ -48,12 +47,6 @@ export function adapterFor(id) { return HOST_ADAPTERS[id] ?? null; } -/** Whether a host supports a command-backed statusline (claude yes, codex no). - * Callers use this for the "show + explain" UX around claude-only features. */ -export function statuslineSupported(id) { - return !!HOST_ADAPTERS[id]?.statuslineSupported; -} - /** * Which host is driving the current session, as a first-class detected axis. * Precedence: explicit override → confirmed claude markers → any CODEX_* marker diff --git a/src/lib/live/event-schema.mjs b/src/lib/live/event-schema.mjs index 0eb0b98..ace0f84 100644 --- a/src/lib/live/event-schema.mjs +++ b/src/lib/live/event-schema.mjs @@ -3,6 +3,12 @@ export const LIVE_SCHEMA_VERSION = 2; const HOSTS = new Set(['claude', 'codex', 'opencode', 'internal']); // `internal` is read-only historical vocabulary. Live-source registration still // admits only ruflo/aqe, and no current writer emits the retired dual-run label. +// A host id outside this closed set is not folded into `internal` — that would +// misrecord a real, novel assistant as ak-internal activity. A safely-shaped +// unknown id (see SAFE_HOST_ID) passes through verbatim so per-id truth is +// preserved; anything unsafe or non-string becomes the explicit 'unknown-host' +// bucket, which can never collide with 'internal' or a real host. +const SAFE_HOST_ID = /^[a-z][a-z0-9-]{0,31}$/; const SURFACES = new Set(['native', 'ruflo', 'aqe', 'plugin', 'skill', 'internal']); const CONFIDENCE = new Set(['observed', 'configured', 'correlated', 'inferred', 'unknown', 'assumed', 'planned']); const PROVIDER_PROVENANCE = new Set(['observed', 'configured', 'inferred', 'unknown']); @@ -49,6 +55,15 @@ function count(value) { return Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : null; } +// 'unknown-host' is itself SAFE_HOST_ID-shaped, so a host literally named +// 'unknown-host' is indistinguishable from the bucket. Accepted: no consumer +// branches on bucketed-vs-declared, and inventing an out-of-grammar sentinel +// risks its own key-syntax interactions downstream. +export function resolveHost(value) { + if (HOSTS.has(value)) return value; + return typeof value === 'string' && SAFE_HOST_ID.test(value) ? value : 'unknown-host'; +} + function safeWorkspace(value) { if (!value || typeof value !== 'object') return null; const capturedAt = timestamp(value.capturedAt); @@ -104,7 +119,7 @@ export function createLiveEvent(input, { now = () => new Date().toISOString() } if (!sessionId || !action || !actorId) { throw new TypeError('live event requires sessionId, action and actor.id'); } - const host = HOSTS.has(input.host) ? input.host : 'internal'; + const host = resolveHost(input.host); // Preserve the historical actor-level identity accepted from adapters while // exposing the axes at the event level. Provider intentionally has no host // fallback: a transcript proves its host, not which provider served it. @@ -150,7 +165,12 @@ export function createLiveEvent(input, { now = () => new Date().toISOString() } kind: actorKind, label: text(input.actor?.label, 96), role: text(input.actor?.role, 96), - host: HOSTS.has(input.actor?.host) ? input.actor.host : host, + // No declared host is a legitimate inference (the actor ran in the + // session's host); a declared-but-unrecognized host is not the same + // thing and must run through the same resolution as the event-level + // host, or it would silently misattribute a novel actor to whichever + // host the session happened to be. + host: input.actor?.host == null ? host : resolveHost(input.actor.host), provider, model, }, @@ -160,7 +180,7 @@ export function createLiveEvent(input, { now = () => new Date().toISOString() } kind: ACTOR_KINDS.has(input.target.kind) ? input.target.kind : 'agent', label: text(input.target.label, 96), role: text(input.target.role, 96), - host: HOSTS.has(input.target.host) ? input.target.host : host, + host: input.target.host == null ? host : resolveHost(input.target.host), } : null, status: STATUS.has(input.status) ? input.status : 'unknown', signal: { diff --git a/src/lib/live/index.mjs b/src/lib/live/index.mjs index 766339f..17cfae1 100644 --- a/src/lib/live/index.mjs +++ b/src/lib/live/index.mjs @@ -1,4 +1,4 @@ -export { LIVE_SCHEMA_VERSION, createLiveEvent } from './event-schema.mjs'; +export { LIVE_SCHEMA_VERSION, createLiveEvent, resolveHost } from './event-schema.mjs'; export { LiveReplayStream } from './replay-stream.mjs'; export { emptyLiveProjection, reduceLiveEvent, serializeLiveProjection, sweepLiveProjection, diff --git a/src/lib/live/structured-adapter.mjs b/src/lib/live/structured-adapter.mjs index 8e45c0d..2c57138 100644 --- a/src/lib/live/structured-adapter.mjs +++ b/src/lib/live/structured-adapter.mjs @@ -20,8 +20,10 @@ export function adaptStructuredEvent(record, { const sessionHost = record.sessionHost ?? record.session_host ?? record.host; if (![sid, actorId, action].every((value) => typeof value === 'string' && value)) return []; return [createLiveEvent({ - sessionId: sid, host: ['claude', 'codex', 'opencode'].includes(sessionHost) - ? sessionHost : 'internal', + // createLiveEvent already resolves input.host through the same policy + // (known host, safely-shaped novel id, or 'unknown-host'); passing the + // raw value through avoids resolving it here a second time. + sessionId: sid, host: sessionHost, surface, project: project ?? record.project, // The configured source owns repository attribution. A structured record // cannot supply an arbitrary opaque key that collides with another repo. @@ -32,13 +34,20 @@ export function adaptStructuredEvent(record, { id: actorId, kind: record.kind === 'gate' ? 'gate' : (record.kind === 'subagent' ? 'subagent' : 'agent'), role: record.role, - host: ['claude', 'codex', 'opencode'].includes(actorHost) ? actorHost : 'internal', + // createLiveEvent resolves a *declared* actor host through the same + // policy as the session host (known id, safe novel id, or + // 'unknown-host'), and only inherits the session host when it's + // undefined here — so raw passthrough already gets full per-id truth; + // pre-resolving would just run the same regex twice. + host: actorHost, provider: record.provider, model: record.model, }, action, target: record.targetId ? { id: record.targetId, kind: record.targetKind, role: record.targetRole ?? record.target_role, + // Same resolution contract as actor.host above: declared values + // are resolved, undefined inherits the session host. host: record.targetHost ?? record.target_host, } : null, status: record.status, diff --git a/src/lib/providers.mjs b/src/lib/providers.mjs index de01136..25cc121 100644 --- a/src/lib/providers.mjs +++ b/src/lib/providers.mjs @@ -32,7 +32,7 @@ import { bold, dim, cyan } from './output.mjs'; import { configuredPolicyToAgentOverrides, seedActivityRoutes, resolveRoutes, routingSummary, divergedRoutes, migrateRetiredRoutes, ACTIVITIES, AGENT_ACTIVITY_MAP, PRIMARY_HOSTS } from './routing.mjs'; import { HOST_ADAPTERS } from './hosts.mjs'; import { - HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts, + HOST_REGISTRY, PROVIDER_REGISTRY, normalizeIntegrationFacts, defaultHostMap, } from './adapters/index.mjs'; import { CURRENT_INTEGRATIONS_VERSION } from './adapters/config.mjs'; import { opencodeMcpStatus } from './opencode.mjs'; @@ -544,7 +544,7 @@ export function applySetupHostFlags(cfg, flags = {}) { version: CURRENT_INTEGRATIONS_VERSION, bindings: [], }); - integrations.hosts ?? (integrations.hosts = { claude: true, codex: false, opencode: false }); + integrations.hosts ?? (integrations.hosts = defaultHostMap()); const routing = cfg.routing ?? (cfg.routing = { version: ROUTING_SCHEMA_VERSION, primaryHost: DEFAULT_PRIMARY_HOST, diff --git a/src/lib/qeCourt.mjs b/src/lib/qeCourt.mjs index d219b4c..ec38e60 100644 --- a/src/lib/qeCourt.mjs +++ b/src/lib/qeCourt.mjs @@ -8,6 +8,21 @@ // protocol itself here — this module only reads/defaults the `routing` block // of an EXISTING .claude/skills/qe-court/config.json; it never creates the // file and never touches any other key in it. +// +// Deliberate fail-closed divergence from upstream referee.ts (F-11): +// unregistered provider ids are never collapsed into one shared 'unknown' +// vendor — each keeps a distinct `unregistered:` tag so two different +// unrecognized providers never spuriously "share" a vendor for the purpose +// of *counting* distinct vendors. validatePanel applies the same "can't +// prove independence" epistemics symmetrically to both checks it runs: +// - vendor-diversity: unregistered vendors are excluded from the count +// entirely, so they can never help satisfy minVendors. +// - writerIsNeverJuror: an unregistered writer and an unregistered jury +// are treated as COULD-BE-colluding (flagged) even when their tags +// differ, because ak can't prove two ids it doesn't recognize are +// actually different vendors. +// Net effect: ak is never looser than a model where every unrecognized id +// shared one 'unknown' vendor — it may flag stricter, but never the reverse. import path from 'node:path'; import { readJson } from './settings.mjs'; import { installedVersion, cmpVersions } from './versions.mjs'; @@ -20,7 +35,10 @@ export function qeCourtShipped() { return !!v && cmpVersions(v, QE_COURT_MIN_VERSION) >= 0; } -/** Map a provider id to its coarse vendor — ported from qe-court's referee.js. */ +/** Map a provider id to its coarse vendor — ported from qe-court's referee.js. + * Known prefixes are byte-identical to upstream; an unrecognized id gets its + * own `unregistered:` tag instead of collapsing into one 'unknown' + * bucket (F-11 — see module comment). */ export function vendorOf(providerId) { const p = String(providerId).toLowerCase(); if (p.startsWith('claude')) return 'claude'; @@ -28,7 +46,7 @@ export function vendorOf(providerId) { if (p === 'codex' || p === 'openai' || p.startsWith('gpt') || p.startsWith('o3') || p.startsWith('o4')) return 'gpt'; if (p.startsWith('openrouter')) return 'openrouter'; if (p === 'ollama' || p === 'local') return 'local'; - return 'unknown'; + return `unregistered:${p}`; } /** Flatten config.json's `routing` map (role -> {provider, model?}) into the @@ -46,7 +64,11 @@ export function panelFromRouting(routing) { export function validatePanel(panel, policy = {}) { const minVendors = policy.minVendors ?? policy.minDistinctVendors ?? 2; const violations = []; - const vendorsSeated = new Set(panel.map((s) => vendorOf(s.provider))); + // unregistered vendors can never help satisfy minVendors — ak can't prove + // two unrecognized ids are independent vendors (F-11 fail-closed divergence). + const vendorsSeated = new Set( + panel.map((s) => vendorOf(s.provider)).filter((v) => !v.startsWith('unregistered:')), + ); if (vendorsSeated.size < minVendors) violations.push('vendor-diversity'); const jury = panel.find((s) => s.role === 'jury'); const writerLike = panel.filter((s) => s.role === 'defense' || s.role === 'writer'); @@ -54,7 +76,17 @@ export function validatePanel(panel, policy = {}) { violations.push('missing-jury'); } else if (policy.writerIsNeverJuror !== false) { const juryVendor = vendorOf(jury.provider); - if (writerLike.some((w) => vendorOf(w.provider) === juryVendor)) violations.push('writerIsNeverJuror'); + const juryUnregistered = juryVendor.startsWith('unregistered:'); + // Same vendor tag always collides. Two DIFFERENT unregistered tags also + // collide (fail closed) — ak can't prove an unrecognized writer id and + // an unrecognized jury id are actually different vendors, so it must + // assume they could be the same one rather than silently trust they + // aren't (mirrors the vendor-diversity exclusion above). + const collides = writerLike.some((w) => { + const writerVendor = vendorOf(w.provider); + return writerVendor === juryVendor || (juryUnregistered && writerVendor.startsWith('unregistered:')); + }); + if (collides) violations.push('writerIsNeverJuror'); } return violations; } diff --git a/src/lib/usage-index.mjs b/src/lib/usage-index.mjs index 8050331..f43c026 100644 --- a/src/lib/usage-index.mjs +++ b/src/lib/usage-index.mjs @@ -828,6 +828,10 @@ function codexIdFromName(name) { return stem.replace(/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-/, ''); } +// Truthfulness policy: dispatch explicitly on the three known providers. A +// codex/claude 2-way ternary silently mis-files anything unrecognized as +// claude; an entry this module doesn't know how to parse must be declined +// (same contract as a parse failure), never mis-labeled. function parseFile(entry) { if (entry.provider === 'opencode') { try { @@ -838,6 +842,7 @@ function parseFile(entry) { return parsed; } catch { return null; } // a parser bug must not cost the user their whole index } + if (entry.provider !== 'codex' && entry.provider !== 'claude') return null; let raw; try { raw = fs.readFileSync(entry.file, 'utf8'); } catch { return null; } try { @@ -1163,10 +1168,13 @@ function aggregate(records, { days, now, cutoff, deps }) { const _inflight = new Map(); let _memo = null; -/** Identity of a scan: two calls sharing it must produce the same aggregate. */ +/** Identity of a scan: two calls sharing it must produce the same aggregate. + * Roots are folded in as sorted [key, value] pairs rather than a hardcoded + * claude/codex/opencode triple, so a root this module doesn't (yet) special- + * case still changes the key instead of colliding with every other scan. */ function scanKey(o = {}) { - const r = o.roots || {}; - return JSON.stringify([Number(o.days) || 14, !!o.force, r.claude || '', r.codex || '', r.opencode || '', o.cachePath || '']); + const roots = Object.entries(o.roots || {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + return JSON.stringify([Number(o.days) || 14, !!o.force, roots, o.cachePath || '']); } /** Drop process-level state (single-flight promises, read memo, lazy deps). */ @@ -1403,8 +1411,13 @@ function locate(id, r, cacheFile) { const hitFile = idIndexFor(cache).get(id); if (hitFile) { const e = cache.entries[hitFile]; - if (e?.session?.id === id && statSafe(hitFile)) { - const provider = e.session.provider === 'codex' ? 'codex' : 'claude'; + // locate() only ever resolves a claude or codex FILE — opencode sessions + // are resolved upstream via the SQLite store before locate is called. A + // cache entry whose provider is neither is not this function's to answer; + // falling through to the scan loops below reports "not found here" + // instead of silently rewriting the provider to claude. + const provider = e?.session?.provider; + if ((provider === 'claude' || provider === 'codex') && e.session.id === id && statSafe(hitFile)) { return { file: hitFile, provider, id, dirName: provider === 'claude' ? path.basename(path.dirname(hitFile)) : null }; } } diff --git a/tests/kit/adapter-registries.test.mjs b/tests/kit/adapter-registries.test.mjs index 11a65a9..9009b96 100644 --- a/tests/kit/adapter-registries.test.mjs +++ b/tests/kit/adapter-registries.test.mjs @@ -6,6 +6,8 @@ import { PROJECTION_REGISTRY, OBSERVABILITY_REGISTRY, validateRegistries, + validateHostAdapter, + defaultHostMap, } from '../../src/lib/adapters/index.mjs'; import { validHost, validProvider, validRegistries, @@ -118,3 +120,34 @@ test('credential descriptors contain environment variable names, never credentia { path: 'providers[0].credentials.names[0]', code: 'invalid-env-name', value: 'sk-secret-value' }, ]); }); + +// F-15: the {claude:true, codex:false, opencode:false} enablement literal used +// to be triplicated across config.mjs, providers.mjs and x/host.mjs. It is now +// derived from HOST_REGISTRY.enabledByDefault via defaultHostMap(). +test('defaultHostMap mirrors the historical host-enablement default', () => { + assert.deepEqual(defaultHostMap(), { claude: true, codex: false, opencode: false }); +}); + +test('defaultHostMap returns a fresh, independently mutable object every call', () => { + const first = defaultHostMap(); + first.codex = true; + assert.notEqual(first, defaultHostMap()); + assert.deepEqual(defaultHostMap(), { claude: true, codex: false, opencode: false }); +}); + +test('every built-in host declares a boolean enabledByDefault', () => { + for (const host of HOST_REGISTRY) { + assert.equal(typeof host.enabledByDefault, 'boolean', `${host.id}.enabledByDefault must be boolean`); + } +}); + +test('validateHostAdapter rejects a host with a missing or non-boolean enabledByDefault', () => { + assert.throws(() => validateHostAdapter(validHost()), /host\.enabledByDefault must be boolean/); + assert.throws(() => validateHostAdapter(validHost({ enabledByDefault: 'yes' })), + /host\.enabledByDefault must be boolean/); +}); + +test('validateHostAdapter accepts a host with an explicit boolean enabledByDefault', () => { + assert.doesNotThrow(() => validateHostAdapter(validHost({ enabledByDefault: true }))); + assert.doesNotThrow(() => validateHostAdapter(validHost({ enabledByDefault: false }))); +}); diff --git a/tests/kit/footprint-collectors.test.mjs b/tests/kit/footprint-collectors.test.mjs index 7ac7101..bb92112 100644 --- a/tests/kit/footprint-collectors.test.mjs +++ b/tests/kit/footprint-collectors.test.mjs @@ -27,6 +27,7 @@ import { } from '../../src/lib/footprint/projects.mjs'; import { collectCatalog, tomlTableNames } from '../../src/lib/footprint/catalog.mjs'; import { collectConsumers } from '../../src/lib/footprint/consumers.mjs'; +import { npmPackageRoot, managedTools } from '../../src/lib/footprint/install.mjs'; const DAY = 86_400_000; @@ -1122,3 +1123,35 @@ test('allocated size falls back to apparent size where blocks are unusable', (t) // nothing, so the platform argument is what changed the answer. assert.equal(run('darwin').bytes.value, 0); }); + +// ── install: managed-tool npm root resolution ─────────────────────────────── +// The registry schema permits host.install.npmPackage to be absent (only +// `bin` and `externalInstallPolicy` are required — see validateHostAdapter). +// managedTools() cannot inject a synthetic host into HOST_REGISTRY, so this +// exercises the exact guard it relies on: npmPackageRoot(globalRootDir, pkg) +// must return null instead of throwing when either operand is missing. + +test('npmPackageRoot returns null, not a throw, for a host-like entry with no npmPackage', () => { + assert.equal(npmPackageRoot('/opt/npm/global/node_modules', undefined), null); + assert.equal(npmPackageRoot('/opt/npm/global/node_modules', null), null); +}); + +test('npmPackageRoot returns null when the global root itself is unknown', () => { + assert.equal(npmPackageRoot(null, 'ruflo'), null); +}); + +test('npmPackageRoot joins the global root and package when both are known', () => { + assert.equal( + npmPackageRoot('/opt/npm/global/node_modules', 'ruflo'), + path.join('/opt/npm/global/node_modules', 'ruflo'), + ); +}); + +test('managedTools resolves every real registry entry without throwing when a global root is known', () => { + const tools = managedTools({ globalRootDir: '/opt/npm/global/node_modules' }); + assert.ok(tools.length > 0); + // Anti-vacuity: real hosts DO carry npmPackage today, so this also proves + // the guard did not regress the populated-pkg path. + const ruflo = tools.find((tool) => tool.id === 'ruflo'); + assert.equal(ruflo.root, path.join('/opt/npm/global/node_modules', 'ruflo')); +}); diff --git a/tests/kit/host-defaults.test.mjs b/tests/kit/host-defaults.test.mjs new file mode 100644 index 0000000..6523be0 --- /dev/null +++ b/tests/kit/host-defaults.test.mjs @@ -0,0 +1,57 @@ +// F-15 (single-source host-enablement default) + F-16 (validateBinding wiring) +// regression coverage for `ak host` — see src/commands/x/host.mjs. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { bindingWarnings } from '../../src/commands/x/host.mjs'; + +const validBinding = { + id: 'ollama-via-claude', host: 'claude', provider: 'ollama', + transport: 'anthropic-compatible', endpoint: 'http://127.0.0.1:11434', +}; + +test('bindingWarnings is empty for an empty or absent binding list', () => { + assert.deepEqual(bindingWarnings({ integrations: {} }), []); + assert.deepEqual(bindingWarnings({ integrations: { bindings: [] } }), []); +}); + +test('bindingWarnings leaves a valid user-declared binding untouched', () => { + assert.deepEqual(bindingWarnings({ integrations: { bindings: [validBinding] } }), []); +}); + +test('bindingWarnings reports an unknown host as a friendly, structured message', () => { + const bindings = [{ id: 'bad', host: 'not-a-host', provider: 'ollama', transport: 'native' }]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^kit\.json integrations\.bindings\[0\]\.host: unknown-host \("not-a-host"\)$/); +}); + +test('bindingWarnings reports an unknown provider as a friendly, structured message', () => { + // An unknown provider also fails the transport check (no known transports + // for it to support) — validateBinding cascades both errors, by design. + const bindings = [{ id: 'bad', host: 'claude', provider: 'not-a-provider', transport: 'native' }]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 2); + assert.ok(warnings.some((w) => /^kit\.json integrations\.bindings\[0\]\.provider: unknown-provider \("not-a-provider"\)$/.test(w))); + assert.ok(warnings.some((w) => /^kit\.json integrations\.bindings\[0\]\.transport: unsupported-transport \("native"\)$/.test(w))); +}); + +test('bindingWarnings reports an unsupported transport as a friendly, structured message', () => { + const bindings = [{ id: 'bad', host: 'claude', provider: 'ollama', transport: 'carrier-pigeon' }]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^kit\.json integrations\.bindings\[0\]\.transport: unsupported-transport \("carrier-pigeon"\)$/); +}); + +test('bindingWarnings indexes each entry independently and leaves valid entries silent', () => { + const bindings = [ + validBinding, + { id: 'bad', host: 'not-a-host', provider: 'ollama', transport: 'native' }, + ]; + const warnings = bindingWarnings({ integrations: { bindings } }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /^kit\.json integrations\.bindings\[1\]\.host: unknown-host/); +}); + +test('bindingWarnings never throws — it always returns a plain array', () => { + assert.doesNotThrow(() => bindingWarnings({ integrations: { bindings: [{}] } })); +}); diff --git a/tests/kit/hosts.test.mjs b/tests/kit/hosts.test.mjs index f49e2f2..ffd56d8 100644 --- a/tests/kit/hosts.test.mjs +++ b/tests/kit/hosts.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { HOST_IDS, adapterFor, statuslineSupported, drivingHost } from '../../src/lib/hosts.mjs'; +import { HOST_IDS, adapterFor, drivingHost } from '../../src/lib/hosts.mjs'; import { hostAuthState } from '../../src/lib/providers.mjs'; // ── HOST_ADAPTERS descriptors ──────────────────────────────────────────────── @@ -15,7 +15,6 @@ test('claude adapter targets CLAUDE.md/json and supports a statusline', () => { const a = adapterFor('claude'); assert.equal(a.guidanceFile, 'claude'); assert.equal(a.configFormat, 'json'); - assert.equal(a.statuslineSupported, true); assert.equal(a.aqeProvider, 'claude-code'); }); @@ -23,7 +22,6 @@ test('codex adapter targets AGENTS.md/toml and has NO command-backed statusline' const a = adapterFor('codex'); assert.equal(a.guidanceFile, 'agents'); assert.equal(a.configFormat, 'toml'); - assert.equal(a.statuslineSupported, false); assert.deepEqual(a.statusline, { mode: 'builtin', scope: 'user', customCommand: false, multiline: false }); assert.equal(a.aqeProvider, 'codex'); }); @@ -32,18 +30,12 @@ test('opencode adapter targets its own AGENTS.md/json, no statusline, no aqe pro const a = adapterFor('opencode'); assert.equal(a.guidanceFile, 'agents-opencode'); assert.equal(a.configFormat, 'json'); - assert.equal(a.statuslineSupported, false); assert.equal(a.aqeProvider, null); assert.deepEqual(a.envMarkers, []); }); test('adapterFor returns null for an unknown host', () => { - assert.equal(adapterFor('gemini'), null); -}); - -test('statuslineSupported is true for claude and false for codex', () => { - assert.equal(statuslineSupported('claude'), true); - assert.equal(statuslineSupported('codex'), false); + assert.equal(adapterFor('zz-not-a-registered-host'), null); }); // ── drivingHost detection (env-only, deterministic) ────────────────────────── @@ -104,6 +96,6 @@ test('hostAuthState reports none for an absent claude with no key', () => { }); test('hostAuthState returns unknown for an unrecognized host', () => { - const a = hostAuthState('gemini', { env: {}, present: true }); + const a = hostAuthState('zz-not-a-registered-host', { env: {}, present: true }); assert.equal(a.mode, 'unknown'); }); diff --git a/tests/kit/integration-config.test.mjs b/tests/kit/integration-config.test.mjs index f0c2749..7cebf95 100644 --- a/tests/kit/integration-config.test.mjs +++ b/tests/kit/integration-config.test.mjs @@ -107,7 +107,10 @@ test('legacy host routes never manufacture observed provider provenance', () => const migrated = migrateIntegrationConfig(structuredClone(legacyDual)); const route = migrated.integrations.bindings.find((binding) => binding.host === 'codex'); assert.ok(route, 'migration should expose a normalized binding for an enabled legacy host'); - assert.ok(['inferred', 'unknown'].includes(route.provenance)); + // the 'unknown' provenance path no longer exists post-F-13: a host with no + // registered native provider gets no binding at all, so any binding that + // IS produced is always 'inferred'. + assert.equal(route.provenance, 'inferred'); assert.notEqual(route.provenance, 'observed'); }); @@ -127,8 +130,8 @@ test('legacy OpenCode ownership markers migrate canonically without inferring a assert.equal(Object.hasOwn(migrated.providers, key), false); } const binding = migrated.integrations.bindings.find(({ host }) => host === 'opencode'); - assert.equal(binding.provider, null); - assert.equal(binding.provenance, 'unknown'); + assert.equal(binding, undefined, + 'opencode has no host-login native provider in the registry, so migration infers no binding for it (F-13)'); }); test('legacy Codex ownership markers migrate together and are retired', () => { @@ -244,6 +247,54 @@ test('migration never persists API keys found in process environment', () => { } }); +test('migrating claude+codex+opencode infers registry-native bindings and nothing for opencode', () => { + const migrated = migrateIntegrationConfig({ + providers: { hosts: { claude: true, codex: true, opencode: true } }, + }); + const byHost = Object.fromEntries(migrated.integrations.bindings.map((b) => [b.host, b])); + assert.equal(byHost.claude.id, 'anthropic-via-claude'); + assert.equal(byHost.claude.provider, 'anthropic'); + assert.equal(byHost.claude.provenance, 'inferred'); + assert.equal(byHost.codex.id, 'openai-via-codex'); + assert.equal(byHost.codex.provider, 'openai'); + assert.equal(byHost.codex.provenance, 'inferred'); + assert.equal(Object.hasOwn(byHost, 'opencode'), false, + 'opencode has no host-login native provider registered, so it gets no inferred binding'); +}); + +test('inferring native bindings is idempotent — a second migration pass never restamps', () => { + const first = migrateIntegrationConfig({ + providers: { hosts: { claude: true, codex: true, opencode: true } }, + }); + const second = migrateIntegrationConfig(structuredClone(first)); + assert.deepEqual(second, first); +}); + +test('a prior user binding for a host wins over the registry-derived default', () => { + const priorBinding = { + id: 'openrouter-via-codex', + host: 'codex', + provider: 'openrouter', + model: 'openai/gpt-5.4', + transport: 'native', + endpoint: null, + provenance: 'user', + managedBy: 'user', + }; + const migrated = migrateIntegrationConfig({ + providers: { hosts: { claude: true, codex: true }, bindings: [priorBinding] }, + }); + const codexBindings = migrated.integrations.bindings.filter((b) => b.host === 'codex'); + assert.equal(codexBindings.length, 1); + assert.deepEqual(codexBindings[0], priorBinding); +}); + +test('migrateIntegrationConfig handles an empty hosts map without inferring anything', () => { + const migrated = migrateIntegrationConfig({ providers: { hosts: {} } }); + assert.deepEqual(migrated.integrations.bindings, []); + assert.deepEqual(migrated.integrations.hosts, {}); +}); + test('endpoint validation accepts loopback HTTP and remote HTTPS', () => { for (const endpoint of [ 'http://127.0.0.1:11434', diff --git a/tests/kit/live-adapters.test.mjs b/tests/kit/live-adapters.test.mjs index 2ce5d37..8fcde40 100644 --- a/tests/kit/live-adapters.test.mjs +++ b/tests/kit/live-adapters.test.mjs @@ -173,6 +173,60 @@ test('AQE court evidence preserves leader and member execution hosts independent assert.equal(event.signal.kind, 'relationship'); }); +test('AQE court evidence resolves a novel safe actor/target host instead of inheriting the session host', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'court-2', sessionHost: 'claude', agentId: 'court-lead-2', + actorHost: 'hermes', kind: 'agent', role: 'court-lead', + action: 'court.member.seated', targetId: 'jury-hermes', targetKind: 'subagent', + targetRole: 'jury', targetHost: 'hermes', status: 'running', + }, { surface: 'aqe', adapter: 'aqe-fixture', observedAt: now }); + assert.equal(event.host, 'claude'); + assert.equal(event.actor.host, 'hermes'); + assert.equal(event.target.host, 'hermes'); +}); + +test('AQE court evidence buckets an unsafe actor/target host as unknown-host, not the session host', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'court-3', sessionHost: 'claude', agentId: 'court-lead-3', + actorHost: 'Hermes!', kind: 'agent', role: 'court-lead', + action: 'court.member.seated', targetId: 'jury-unsafe', targetKind: 'subagent', + targetRole: 'jury', targetHost: 'Hermes!', status: 'running', + }, { surface: 'aqe', adapter: 'aqe-fixture', observedAt: now }); + assert.equal(event.host, 'claude'); + assert.equal(event.actor.host, 'unknown-host'); + assert.equal(event.target.host, 'unknown-host'); +}); + +test('AQE court evidence lets an actor/target with no declared host inherit the session host', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'court-4', sessionHost: 'claude', agentId: 'court-lead-4', + kind: 'agent', role: 'court-lead', + action: 'court.member.seated', targetId: 'jury-absent', targetKind: 'subagent', + targetRole: 'jury', status: 'running', + }, { surface: 'aqe', adapter: 'aqe-fixture', observedAt: now }); + assert.equal(event.host, 'claude'); + assert.equal(event.actor.host, 'claude'); + assert.equal(event.target.host, 'claude'); +}); + +test('structured adapter passes a novel safely-shaped host id through, never internal', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'hermes-1', sessionHost: 'hermes', agentId: 'hermes-agent', + actorHost: 'hermes', action: 'session.started', status: 'running', + }, { surface: 'ruflo', adapter: 'ruflo-hook', observedAt: now }); + assert.equal(event.host, 'hermes'); + assert.equal(event.actor.host, 'hermes'); +}); + +test('structured adapter buckets an unsafe-shaped host id as unknown-host, never internal', () => { + const [event] = adaptStructuredEvent({ + sessionId: 'bad-1', sessionHost: 'Hermes!', agentId: 'bad-agent', + actorHost: 'Hermes!', action: 'session.started', status: 'running', + }, { surface: 'ruflo', adapter: 'ruflo-hook', observedAt: now }); + assert.equal(event.host, 'unknown-host'); + assert.equal(event.actor.host, 'unknown-host'); +}); + test('explicit tool names classify skill, plugin and MCP nodes without inspecting input', () => { for (const [name, kind] of [ ['Skill', 'skill'], ['plugin:review', 'plugin'], ['mcp__ruflo__swarm_status', 'mcp'], diff --git a/tests/kit/live-core.test.mjs b/tests/kit/live-core.test.mjs index be086f5..17f8338 100644 --- a/tests/kit/live-core.test.mjs +++ b/tests/kit/live-core.test.mjs @@ -7,8 +7,8 @@ import path from 'node:path'; import { createLiveEvent, LiveReplayStream, emptyLiveProjection, inspectGitWorkspace, parseGitNumstat, - reduceLiveEvent, resolveProjectIdentity, resolveProjectLabel, serializeLiveProjection, - sweepLiveProjection, stableProjectKey, WorkspaceSnapshotStore, + reduceLiveEvent, resolveHost, resolveProjectIdentity, resolveProjectLabel, + serializeLiveProjection, sweepLiveProjection, stableProjectKey, WorkspaceSnapshotStore, } from '../../src/lib/live/index.mjs'; const base = (over = {}) => ({ @@ -59,6 +59,55 @@ test('event schema preserves retired compatibility provenance as internal', () = assert.equal(createLiveEvent(base({ surface: 'dual-run' })).surface, 'internal'); }); +test('event schema passes a novel but safely-shaped host id through verbatim', () => { + assert.equal(createLiveEvent(base({ host: 'hermes' })).host, 'hermes'); +}); + +test('event schema buckets an unsafe-shaped host id as unknown-host, never internal', () => { + assert.equal(createLiveEvent(base({ host: 'Hermes!' })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: 'my host' })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: 'a'.repeat(33) })).host, 'unknown-host'); +}); + +test('event schema buckets a missing or malformed host as unknown-host, never internal', () => { + assert.equal(createLiveEvent(base({ host: undefined })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: null })).host, 'unknown-host'); + assert.equal(createLiveEvent(base({ host: 123 })).host, 'unknown-host'); +}); + +test('event schema keeps known hosts and the historical internal input unchanged', () => { + assert.equal(createLiveEvent(base({ host: 'claude' })).host, 'claude'); + assert.equal(createLiveEvent(base({ host: 'codex' })).host, 'codex'); + assert.equal(createLiveEvent(base({ host: 'opencode' })).host, 'opencode'); + assert.equal(createLiveEvent(base({ host: 'internal' })).host, 'internal'); +}); + +// The bucket is itself SAFE_HOST_ID-shaped by construction (see event-schema.mjs); +// this pins the accepted ambiguity between a declared host literally named +// 'unknown-host' and a value that was bucketed into it. +test('resolveHost is idempotent on its own unknown-host bucket', () => { + assert.equal(resolveHost('unknown-host'), 'unknown-host'); +}); + +test('event schema resolves a declared actor/target host instead of inheriting the session host, but still inherits when one is absent', () => { + assert.equal(createLiveEvent(base({ + actor: { id: 's1', kind: 'session', host: 'hermes' }, + })).actor.host, 'hermes'); + assert.equal(createLiveEvent(base({ + actor: { id: 's1', kind: 'session', host: 'Hermes!' }, + })).actor.host, 'unknown-host'); + assert.equal(createLiveEvent(base({ + actor: { id: 's1', kind: 'session' }, + })).actor.host, 'claude'); + + const target = (host) => createLiveEvent(base({ + action: 'tool.started', target: { id: 'call', kind: 'tool', host }, + })).target.host; + assert.equal(target('hermes'), 'hermes'); + assert.equal(target('Hermes!'), 'unknown-host'); + assert.equal(target(undefined), 'claude'); +}); + test('event schema assigns privacy-safe project and host-qualified session identities', () => { const event = createLiveEvent(base({ sessionId: 'shared:id', diff --git a/tests/kit/qeCourt.test.mjs b/tests/kit/qeCourt.test.mjs index aeb270b..b08e9a6 100644 --- a/tests/kit/qeCourt.test.mjs +++ b/tests/kit/qeCourt.test.mjs @@ -26,8 +26,9 @@ test('vendorOf classifies codex/openai/gpt/o3/o4 as the gpt vendor', () => { } }); -test('vendorOf classifies unknown providers as unknown', () => { - assert.equal(vendorOf('some-new-thing'), 'unknown'); +test('vendorOf tags an unregistered provider with its own id instead of a shared "unknown" bucket', () => { + assert.equal(vendorOf('some-new-thing'), 'unregistered:some-new-thing'); + assert.equal(vendorOf('hermes-something'), 'unregistered:hermes-something'); }); // panelFromRouting @@ -102,6 +103,76 @@ test('validatePanel binds the shipped option names and requires a jury', () => { ); }); +test('validatePanel never lets two distinct unregistered providers satisfy minVendors, and treats them as possibly-colluding', () => { + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + // distinct unregistered ids never count toward vendor-diversity, AND ak + // can't prove they're different vendors either — so writerIsNeverJuror + // fails closed too (this is the reviewer's minimal repro for F-11 round 2). + assert.deepEqual(validatePanel(panel), ['vendor-diversity', 'writerIsNeverJuror']); +}); + +test('validatePanel does NOT trip writerIsNeverJuror when only the jury is unregistered and all writers are registered', () => { + const panel = [ + { role: 'defense', provider: 'claude-code' }, + { role: 'jury', provider: 'hermes-a' }, + ]; + // matches old shared-'unknown'-vendor behavior: a registered writer vendor + // can never equal an unregistered jury tag, so no collusion is assumed. + assert.deepEqual(validatePanel(panel), ['vendor-diversity']); +}); + +test('direction of divergence: ak is never looser than the old shared-"unknown"-vendor model', () => { + // Reviewer's minimal repro: under the OLD code, every unrecognized id + // mapped to the single literal 'unknown' vendor, so vendorOf(jury) === + // vendorOf(defense) always held here and tripped writerIsNeverJuror. The + // per-id `unregistered:` tagging must not silently drop that + // violation — new violations must be a superset of what old code found. + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + const oldViolations = ['vendor-diversity', 'writerIsNeverJuror']; + const newViolations = validatePanel(panel); + for (const violation of oldViolations) { + assert.ok(newViolations.includes(violation), `expected new violations to retain '${violation}'`); + } +}); + +test('validatePanel: minVendors 0 never flags vendor-diversity, even for an all-unregistered panel', () => { + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + assert.deepEqual(validatePanel(panel, { minVendors: 0, writerIsNeverJuror: false }), []); +}); + +test('validatePanel: minVendors 1 flags vendor-diversity when only unregistered vendors are seated', () => { + const panel = [ + { role: 'defense', provider: 'hermes-a' }, + { role: 'jury', provider: 'zeta-b' }, + ]; + assert.deepEqual(validatePanel(panel, { minVendors: 1, writerIsNeverJuror: false }), ['vendor-diversity']); +}); + +test('validatePanel handles an empty panel: vendor-diversity and missing-jury both flagged', () => { + assert.deepEqual(validatePanel([]), ['vendor-diversity', 'missing-jury']); +}); + +test('validatePanel still trips writerIsNeverJuror when writer and jury share the same unregistered id', () => { + const panel = [ + { role: 'defense', provider: 'hermes-x' }, + { role: 'prosecutor.a', provider: 'codex' }, + { role: 'prosecutor.b', provider: 'claude-code' }, + { role: 'jury', provider: 'hermes-x' }, + ]; + // registered gpt + claude vendors satisfy minVendors on their own, so this + // isolates writerIsNeverJuror: same unregistered id seated as writer and juror. + assert.deepEqual(validatePanel(panel), ['writerIsNeverJuror']); +}); + test('validatePanel honors an explicit writerIsNeverJuror:false policy', () => { const panel = [ { role: 'defense', provider: 'claude-code' }, diff --git a/tests/kit/routing.test.mjs b/tests/kit/routing.test.mjs index 89ba320..89bfafb 100644 --- a/tests/kit/routing.test.mjs +++ b/tests/kit/routing.test.mjs @@ -155,7 +155,7 @@ test('routingSummary counts totals, provenance, and per-host tallies', () => { test('validateRoute accepts claude/codex and rejects an unknown host', () => { assert.deepEqual(validateRoute({ host: 'claude', model: 'claude-opus-4-8' }), []); assert.deepEqual(validateRoute({ host: 'codex' }), []); - assert.ok(validateRoute({ host: 'gemini' }).length > 0); + assert.ok(validateRoute({ host: 'zz-not-a-registered-host' }).length > 0); }); test('parseRouteSpecs parses valid specs and warns on bad ones', () => { @@ -163,7 +163,7 @@ test('parseRouteSpecs parses valid specs and warns on bad ones', () => { 'implementation:claude:claude-opus-4-8', 'testing:codex', 'nonsense:claude', // unknown activity - 'review:gemini', // unknown host + 'review:zz-not-a-registered-host', // unknown host ]); assert.deepEqual(policy.implementation, { host: 'claude', model: 'claude-opus-4-8', provenance: 'user' }); assert.deepEqual(policy.testing, { host: 'codex', provenance: 'user' }); @@ -210,10 +210,10 @@ test('an explicit OpenCode route materializes for ak run', () => { }); test('host-neutral run plan rejects a host without activity-routing capability', () => { - const policy = { implementation: { host: 'gemini', provenance: 'user' } }; + const policy = { implementation: { host: 'zz-not-a-registered-host', provenance: 'user' } }; assert.throws( () => materializeRunPlan(policy, { template: 'feature', task: 'x' }), - /route for "implementation" cannot materialize: host "gemini" requires canRouteActivities/, + /route for "implementation" cannot materialize: host "zz-not-a-registered-host" requires canRouteActivities/, ); }); diff --git a/tests/kit/setup-host-flags.test.mjs b/tests/kit/setup-host-flags.test.mjs index 5c08e1d..9a760ff 100644 --- a/tests/kit/setup-host-flags.test.mjs +++ b/tests/kit/setup-host-flags.test.mjs @@ -47,7 +47,7 @@ test('--primary-host claude does not enable codex', () => { test('an unknown --primary-host is ignored with a warning, codex untouched', () => { const cfg = freshCfg(); - const r = applySetupHostFlags(cfg, { 'primary-host': 'gemini' }); + const r = applySetupHostFlags(cfg, { 'primary-host': 'zz-not-a-registered-host' }); assert.equal(cfg.routing.primaryHost, 'claude'); assert.equal(cfg.integrations.hosts.codex, false); assert.equal(r.warnings.length, 1); diff --git a/tests/kit/subprocess-execution.test.mjs b/tests/kit/subprocess-execution.test.mjs index b8f7823..4ba6356 100644 --- a/tests/kit/subprocess-execution.test.mjs +++ b/tests/kit/subprocess-execution.test.mjs @@ -5,6 +5,7 @@ import { createClaudeExecutionAdapter as createRealClaudeExecutionAdapter } from import { createCodexExecutionAdapter as createRealCodexExecutionAdapter } from '../../src/lib/execution/codex.mjs'; import { executeWorker } from '../../src/lib/execution/runner.mjs'; import { HANDOFF_END, HANDOFF_START } from '../../src/lib/execution/handoff.mjs'; +import { createPlainTextSummaryCapture } from '../../src/lib/execution/subprocess.mjs'; const passthroughResolve = (command, args) => ({ command, args, resolved: true }); const createClaudeExecutionAdapter = (options = {}) => createRealClaudeExecutionAdapter({ @@ -289,3 +290,43 @@ test('a subprocess surviving TERM and KILL is reported as orphaned, never timed assert.match(terminal.failure.reason, /did not terminate/); assert.deepEqual(result.signals.slice(0, 2), ['SIGTERM', 'SIGKILL']); }); + +// ── plain-text summary capture (Hermes-class hosts) ────────────────────────── +// De-risks Phase 3: some oneshot hosts emit plain text on stdout rather than +// JSONL. createPlainTextSummaryCapture mirrors createJsonlSummaryCapture's +// write/read contract without the JSON parsing step — the summary is simply +// the last non-empty line seen, bounded the same way. + +test('plain-text capture reads null for empty output', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + assert.equal(capture.read(), null); +}); + +test('plain-text capture selects the final non-empty line across multiple writes', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write('first line\nsecond line\n'); + capture.write('final line'); + assert.equal(capture.read(), 'final line'); +}); + +test('plain-text capture ignores whitespace-only output', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write(' \n\t\n '); + assert.equal(capture.read(), null); +}); + +test('plain-text capture keeps a real line even when trailing whitespace-only lines follow', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write('real summary\n \n'); + assert.equal(capture.read(), 'real summary'); +}); + +test('plain-text capture discards an oversized line without poisoning a later one', () => { + const capture = createPlainTextSummaryCapture('Hermes'); + capture.write(`${'x'.repeat(300 * 1024)}\nfinal line\n`); + assert.equal(capture.read(), 'final line'); +}); + +test('plain-text capture requires a label', () => { + assert.throws(() => createPlainTextSummaryCapture(), TypeError); +}); diff --git a/tests/kit/usage-index.test.mjs b/tests/kit/usage-index.test.mjs index ab47823..355a3b1 100644 --- a/tests/kit/usage-index.test.mjs +++ b/tests/kit/usage-index.test.mjs @@ -1088,6 +1088,61 @@ test('readCache/locate memo is invalidated when the cache file changes on disk ( 'must still resolve via filename fallback after the cache entry was externally removed — proves the memo picked up the mtime change'); }); +// F-08 (major): the scorecard used to collapse any provider that was neither +// 'codex' nor 'claude' into 'claude' (locate()'s 2-way ternary), and parseFile +// had the same 2-way shape for the JSONL path. Policy: explicit per-id, or +// excluded-and-labeled — NEVER mis-filed as claude. +test('locate() excludes a cached entry with an unrecognized provider instead of mis-filing it as claude (F-08)', async () => { + _resetForTest(); + const sb = sandbox(); + await buildIndex(opts(sb)); + + const raw = JSON.parse(fs.readFileSync(sb.cachePath, 'utf8')); + const [claudeFile, claudeEntry] = Object.entries(raw.entries).find(([, e]) => e.session.provider === 'claude'); + // Doctor the on-disk cache the way an external write (or a forward- + // incompatible future provider) would: same real file, unrecognized + // provider string, a fresh id so the id→file index resolves through it. + const fakeId = 'ffff9999'; + raw.entries[claudeFile] = { ...claudeEntry, session: { ...claudeEntry.session, id: fakeId, provider: 'someother' } }; + fs.writeFileSync(sb.cachePath, JSON.stringify(raw)); + + const result = await readSession(fakeId, opts(sb)); + assert.equal(result, null, + 'an unrecognized provider must never be silently parsed via parseClaude / reported as claude — it must be excluded'); +}); + +test('locate() still resolves known claude/codex cache hits after the truthfulness fix (F-08 regression guard)', async () => { + _resetForTest(); + const sb = sandbox(); + await buildIndex(opts(sb)); // populates the id→file cache both reads below hit + + const claudeHit = await readSession('aaaa1111', opts(sb)); + assert.equal(claudeHit.meta.host, 'claude'); + assert.equal(claudeHit.meta.transcriptProvider, 'claude'); + + const codexHit = await readSession('dddd4444', opts(sb)); + assert.equal(codexHit.meta.host, 'codex'); + assert.equal(codexHit.meta.transcriptProvider, 'codex'); +}); + +// F-08's third bug: scanKey hardcoded the roots portion to a claude/codex/ +// opencode triple, so two scans differing only by a 4th root key (unused by +// scan() today, but a future root) hashed identically and could join the same +// in-flight promise — serving one scan's aggregate to a caller who asked a +// different question. buildIndex's single-flight coalescing is the direct +// consumer of scanKey, so it is the most direct place to observe the fix: +// concurrent calls that differ only by an extra root key must NOT collapse +// into the same in-flight promise (and therefore not the same result object). +test('scanKey distinguishes root sets that differ only by a root key the module does not otherwise use (F-08)', async () => { + _resetForTest(); + const sb = sandbox(); + const a = buildIndex(opts(sb, { roots: { ...sb.roots } })); + const b = buildIndex(opts(sb, { roots: { ...sb.roots, somethingElse: '/nonexistent/for-test' } })); + const [aggA, aggB] = await Promise.all([a, b]); + assert.notEqual(aggA, aggB, + 'a distinct 4th root must not collide with a scan lacking it — they must not share the in-flight promise / result object'); +}); + test('scan prunes cached entries past the 366-day keep window, keeps recent/undated ones', async () => { _resetForTest(); const sb = sandbox();