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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/app/api/agent-runtimes/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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')

Expand Down
55 changes: 55 additions & 0 deletions src/components/settings/agent-runtimes-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<RuntimeCapabilities, 'receipts'>; 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
Expand Down Expand Up @@ -274,6 +300,35 @@ export function AgentRuntimesSection({ showFeedback }: Props) {

<p className="text-xs text-muted-foreground/70">{rt.description}</p>

{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. */
<div className="flex flex-wrap items-center gap-1 mt-1.5" title="Integration capability depth — declared per adapter version, independent of install state">
{CAPABILITY_LABELS.map(({ key, label }) => (
<span
key={key}
className={`text-[10px] px-1.5 py-px rounded border font-mono ${
rt.capabilities![key]
? 'bg-primary/10 text-primary/90 border-primary/25'
: 'bg-muted/10 text-muted-foreground/40 border-border/15'
}`}
>
{label}
</span>
))}
<span
className={`text-[10px] px-1.5 py-px rounded border font-mono ${
rt.capabilities.receipts.telemetry
? 'bg-primary/10 text-primary/90 border-primary/25'
: 'bg-muted/10 text-muted-foreground/40 border-border/15'
}`}
>
telemetry
</span>
</div>
)}

{rt.installed && rt.authRequired && (
<p className={`text-2xs mt-1 ${rt.authenticated ? 'text-emerald-400/70' : 'text-amber-400'}`}>
{rt.authenticated ? 'Authenticated' : rt.authHint}
Expand Down
82 changes: 82 additions & 0 deletions src/lib/__tests__/runtime-capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -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<keyof Omit<RuntimeCapabilities, 'receipts'>> = [
'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)
}
})
})
101 changes: 101 additions & 0 deletions src/lib/agent-runtimes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RuntimeId, RuntimeCapabilities> = {
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
// ---------------------------------------------------------------------------
Expand Down