diff --git a/src/app/api/agent-runtimes/route.ts b/src/app/api/agent-runtimes/route.ts index 2c75b2cdd..0585ec4a9 100644 --- a/src/app/api/agent-runtimes/route.ts +++ b/src/app/api/agent-runtimes/route.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs' import { NextRequest, NextResponse } from 'next/server' import { requireRole } from '@/lib/auth' -import { detectAllRuntimes, detectRuntime, startInstall, getInstallJob, getActiveJobs, generateDockerSidecar } from '@/lib/agent-runtimes' +import { detectAllRuntimes, detectRuntime, getRuntimeCapabilities, startInstall, getInstallJob, getActiveJobs, generateDockerSidecar } from '@/lib/agent-runtimes' import type { RuntimeId, DeploymentMode } from '@/lib/agent-runtimes' import { runtimeInstallsEnabled } from '@/lib/runtime-install-security' import { clearHermesDetectionCache } from '@/lib/hermes-sessions' @@ -17,7 +17,13 @@ export async function GET(request: NextRequest) { // Clear caches so freshly-installed runtimes are detected immediately clearHermesDetectionCache() - const runtimes = detectAllRuntimes() + // Capability manifests are declared per adapter version (#900) — composed + // here so detection (host state) and capability depth (adapter code) stay + // separate truths. + const runtimes = detectAllRuntimes().map((runtime) => ({ + ...runtime, + capabilities: getRuntimeCapabilities(runtime.id), + })) const activeJobs = getActiveJobs() const isDocker = existsSync('/.dockerenv') diff --git a/src/components/settings/agent-runtimes-section.tsx b/src/components/settings/agent-runtimes-section.tsx index b6dc4a1b7..5bcf97fdc 100644 --- a/src/components/settings/agent-runtimes-section.tsx +++ b/src/components/settings/agent-runtimes-section.tsx @@ -6,6 +6,18 @@ import { Loader } from '@/components/ui/loader' import { RuntimeSetupModal } from '@/components/onboarding/runtime-setup-modal' import { apiFetch, ApiError } from '@/lib/api-client' +interface RuntimeCapabilities { + dispatch: boolean + session_resume: boolean + pty: boolean + workspace_cwd: boolean + tool_policy: boolean + budget_cap: boolean + structured_output: boolean + skills_inventory: boolean + receipts: { diff: boolean; tests: boolean; artifact: boolean; browser: boolean; telemetry: boolean } +} + interface RuntimeStatus { id: string name: string @@ -16,8 +28,22 @@ interface RuntimeStatus { authRequired: boolean authHint: string authenticated: boolean + /** Declared per adapter version (#900) — depth of the MC integration, not host state. */ + capabilities?: RuntimeCapabilities } +/** Technical identifiers, deliberately untranslated (matches runtime names). */ +const CAPABILITY_LABELS: Array<{ key: keyof Omit; label: string }> = [ + { key: 'dispatch', label: 'dispatch' }, + { key: 'session_resume', label: 'sessions' }, + { key: 'pty', label: 'pty' }, + { key: 'workspace_cwd', label: 'cwd' }, + { key: 'tool_policy', label: 'tools' }, + { key: 'budget_cap', label: 'budget' }, + { key: 'structured_output', label: 'structured' }, + { key: 'skills_inventory', label: 'skills' }, +] + interface InstallJob { id: string runtime: string @@ -274,6 +300,35 @@ export function AgentRuntimesSection({ showFeedback }: Props) {

{rt.description}

+ {rt.capabilities && ( + /* Capability matrix (#900): depth of the MC integration is + shown honestly — a dim chip means this adapter cannot + supply that control or receipt, not that it is off. */ +
+ {CAPABILITY_LABELS.map(({ key, label }) => ( + + {label} + + ))} + + telemetry + +
+ )} + {rt.installed && rt.authRequired && (

{rt.authenticated ? 'Authenticated' : rt.authHint} diff --git a/src/lib/__tests__/runtime-capabilities.test.ts b/src/lib/__tests__/runtime-capabilities.test.ts new file mode 100644 index 000000000..ce1ee0f55 --- /dev/null +++ b/src/lib/__tests__/runtime-capabilities.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' + +// The manifests are static declarations; mock the CLI-probing neighbors so +// importing agent-runtimes never spawns anything in the test environment. +vi.mock('@/lib/hermes-sessions', () => ({ + hasHermesCliBinary: vi.fn(() => false), + clearHermesDetectionCache: vi.fn(), + isHermesInstalled: vi.fn(() => false), + isHermesGatewayRunning: vi.fn(() => false), +})) +vi.mock('@/lib/opencode-sessions', () => ({ scanOpenCodeSessions: vi.fn(() => []) })) +vi.mock('@/lib/config', () => ({ + config: { dataDir: '/tmp/mc-test', homeDir: '/tmp/mc-test', workspaceRoot: '/tmp/mc-test' }, + ensureDirExists: vi.fn(), +})) + +import { + RUNTIME_CAPABILITIES, + getRuntimeCapabilities, + type RuntimeCapabilities, + type RuntimeId, +} from '@/lib/agent-runtimes' + +const RUNTIME_IDS: RuntimeId[] = ['openclaw', 'hermes', 'claude', 'codex', 'opencode'] +const CAPABILITY_KEYS: Array> = [ + 'dispatch', 'session_resume', 'pty', 'workspace_cwd', + 'tool_policy', 'budget_cap', 'structured_output', 'skills_inventory', +] +const RECEIPT_KEYS = ['diff', 'tests', 'artifact', 'browser', 'telemetry'] as const + +describe('runtime capability manifests (#900)', () => { + it('every runtime declares a complete boolean manifest', () => { + for (const id of RUNTIME_IDS) { + const manifest = getRuntimeCapabilities(id) + expect(manifest, id).toBeDefined() + for (const key of CAPABILITY_KEYS) { + expect(typeof manifest[key], `${id}.${key}`).toBe('boolean') + } + for (const key of RECEIPT_KEYS) { + expect(typeof manifest.receipts[key], `${id}.receipts.${key}`).toBe('boolean') + } + } + expect(Object.keys(RUNTIME_CAPABILITIES).sort()).toEqual([...RUNTIME_IDS].sort()) + }) + + it('claude declares the shipped truths (#602, #720)', () => { + const claude = getRuntimeCapabilities('claude') + expect(claude.dispatch).toBe(true) + expect(claude.session_resume).toBe(true) + expect(claude.workspace_cwd).toBe(true) + expect(claude.tool_policy).toBe(true) + expect(claude.budget_cap).toBe(true) + expect(claude.structured_output).toBe(true) + expect(claude.receipts.telemetry).toBe(true) + expect(claude.pty).toBe(false) + }) + + it('hermes stays honest until upstream ships machine-readable inventory', () => { + // Flip only when NousResearch/hermes-agent#71274 lands and the provider + // from #777 actually consumes it. + expect(getRuntimeCapabilities('hermes').skills_inventory).toBe(false) + expect(getRuntimeCapabilities('hermes').dispatch).toBe(false) + }) + + it('read-only scanners never claim dispatch depth', () => { + expect(getRuntimeCapabilities('opencode').dispatch).toBe(false) + for (const key of CAPABILITY_KEYS) { + if (key === 'dispatch') continue + expect(getRuntimeCapabilities('opencode')[key], `opencode.${key}`).toBe(false) + } + }) + + it('no adapter claims evidence receipts that nothing produces yet', () => { + for (const id of RUNTIME_IDS) { + const receipts = getRuntimeCapabilities(id).receipts + expect(receipts.diff, `${id}.receipts.diff`).toBe(false) + expect(receipts.tests, `${id}.receipts.tests`).toBe(false) + expect(receipts.artifact, `${id}.receipts.artifact`).toBe(false) + expect(receipts.browser, `${id}.receipts.browser`).toBe(false) + } + }) +}) diff --git a/src/lib/agent-runtimes.ts b/src/lib/agent-runtimes.ts index aed218f9d..a55dde1fc 100644 --- a/src/lib/agent-runtimes.ts +++ b/src/lib/agent-runtimes.ts @@ -259,6 +259,107 @@ export function getRuntimeMeta(id: RuntimeId): RuntimeMeta | undefined { return RUNTIME_META[id] } +/** + * Declared capability depth per runtime adapter (#900, manifests slice). + * + * These are properties of the adapter integration code in this repository — + * declared per adapter version, never probed from the host. Detection + * (installed/running/authenticated) answers "is it here"; a manifest answers + * "what can Mission Control honestly do through it". Every `true` must cite a + * shipping code path; when in doubt, declare `false` — the whole point of the + * matrix is that missing depth is visible instead of implied. + */ +export interface RuntimeCapabilities { + /** MC can dispatch tasks through this adapter. */ + dispatch: boolean + /** Dispatch can resume/fork a persistent per-agent session. */ + session_resume: boolean + /** PTY attachment to live runtime sessions. */ + pty: boolean + /** Per-dispatch working-directory control. */ + workspace_cwd: boolean + /** Per-dispatch tool allowlist enforcement. */ + tool_policy: boolean + /** Per-dispatch execution budget caps. */ + budget_cap: boolean + /** Machine-readable dispatch output (not prose scraping). */ + structured_output: boolean + /** Adapter reports the runtime's canonical skills inventory. */ + skills_inventory: boolean + receipts: { + diff: boolean + tests: boolean + artifact: boolean + browser: boolean + /** Token/cost telemetry captured from dispatched runs. */ + telemetry: boolean + } +} + +const NO_RECEIPTS = { diff: false, tests: false, artifact: false, browser: false, telemetry: false } + +export const RUNTIME_CAPABILITIES: Record = { + openclaw: { + dispatch: true, // gateway `agent` invoke + chat.send (task-dispatch.ts) + session_resume: true, // tasks.metadata.target_session routes to a live gateway session + pty: true, // PTY setup route attaches to gateway sessions + workspace_cwd: false, + tool_policy: false, + budget_cap: false, + structured_output: false, + skills_inventory: false, // MC scans file roots itself; the gateway does not report canonical inventory + receipts: { ...NO_RECEIPTS, telemetry: true }, // gateway session token stats + }, + hermes: { + dispatch: false, // no dispatcher branch; runtime_type: 'hermes' provisions profiles only + session_resume: false, + pty: false, + workspace_cwd: false, + tool_policy: false, + budget_cap: false, + structured_output: false, + skills_inventory: false, // pending upstream hermes-agent#71274 (`skills list --json`) + receipts: { ...NO_RECEIPTS }, + }, + claude: { + dispatch: true, // callClaudeViaCli + session_resume: true, // #602: per-agent base session, resume/fork per task + pty: false, + workspace_cwd: true, // #720 sandbox cwd + tool_policy: true, // #720 --allowedTools + budget_cap: true, // #720 --max-budget-usd + structured_output: true, // --output-format json + skills_inventory: false, + receipts: { ...NO_RECEIPTS, telemetry: true }, // usage tokens recorded per dispatch + }, + codex: { + dispatch: true, // callCodexViaCli + session_resume: false, + pty: false, + workspace_cwd: false, + tool_policy: false, + budget_cap: false, + structured_output: false, + skills_inventory: false, + receipts: { ...NO_RECEIPTS }, + }, + opencode: { + dispatch: false, // read-only session scanner; no dispatch path + session_resume: false, + pty: false, + workspace_cwd: false, + tool_policy: false, + budget_cap: false, + structured_output: false, + skills_inventory: false, + receipts: { ...NO_RECEIPTS }, + }, +} + +export function getRuntimeCapabilities(id: RuntimeId): RuntimeCapabilities { + return RUNTIME_CAPABILITIES[id] +} + // --------------------------------------------------------------------------- // In-memory job store — ephemeral, not persisted across restarts // ---------------------------------------------------------------------------