Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ docs/_*.png

# IDE state
.vs/
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
2 changes: 1 addition & 1 deletion postcss.config.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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)
}
})
})
Loading