feat: route agents by runtime manifests (#886) (#889)

Co-authored-by: bradgroux <brad@digitalmeld.io>
This commit is contained in:
Brad Groux 2026-07-16 10:54:05 -05:00 committed by GitHub
parent 6f020263c0
commit a42dc62ba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2153 additions and 175 deletions

View file

@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
race-safe version-skew cache invalidation, canonical immutable run snapshots
and digests, complete capability posture, and attempt/history/trace/log
persistence (#885).
- Added validated runtime-manifest registration and heartbeat refresh, custom
provider discovery, manifest-authoritative host posture, and provider-neutral
required-capability routing with structured evidence for every attempted
primary, fallback, or rejected candidate (#886).
- Added an admin-governed SQLite journal maintenance workflow with non-mutating
previews, restart-time exclusive conversion, verified backups, durable stage
journals, forward-only crash recovery, integrity verification, in-place mode
@ -25,6 +29,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added Hermes to normalized provider profiles and Settings selectors, and made
explicitly configured providers without an executable task adapter fail
closed instead of silently dispatching through OpenClaw (#885).
- Made host provider, model, tool, and sandbox capability decisions consume
validated manifests. Legacy free-form registration posture remains visible
but cannot satisfy required runtime capabilities. Runtime evidence writes are
identity-bound, reject unredacted secrets and misspelled fields, expire with
heartbeat liveness, and invalidate when provider identity changes. Unresolved
sandbox presets fail closed instead of borrowing aggregate host signals
(#886).
- Classified the authoritative SQLite filesystem before database open, limited
WAL to recognized durable local filesystems, refused known-unsafe and unknown
storage before creating database sidecars, and exposed redacted filesystem,

View file

@ -42,6 +42,29 @@ They do not imply that adjacent roadmap work already exists. For example,
provider-neutral approvals, reattachment, follow-up/fork/steer controls, and MCP
governance remain unsupported or unknown until their dedicated issues land.
Agents and supervisors can register the same validated manifest with
`POST /api/agents/register` and refresh it through the heartbeat endpoint. Host
provider, model, `tool.*`, and sandbox posture is derived only from those
manifests. Legacy free-form registration fields remain visible but are not
trusted for runtime requirements. Route and host-preview requests can declare
`requiredRuntimeCapabilities`; `supported` evidence qualifies, `advisory`
evidence qualifies with a warning, and `unsupported`, `unknown`, missing, or
failed-probe evidence rejects the candidate. All requirements must be satisfied
by one manifest, so capabilities are never composed across providers.
Self-registration requires an authenticated agent key/token whose identity
matches the registry agent ID; operators with `agent:write` can register on an
agent's behalf. Unknown request fields and unredacted secret-like evidence are
rejected. Only registrations with a current five-minute heartbeat qualify for
routing. Probe-evidence age enforcement is completed by #887; until then,
operators must refresh the manifest whenever provider identity or evidence
changes.
Host previews fail closed on a bare `sandboxPresetId` until #887 resolves each
preset control through the same manifest evaluator. For a capability-only
preview during this intermediate contract, omit the preset ID and provide the
required filesystem, network, environment, and credential capability IDs
directly.
## Sandbox Policy Presets
Use **Settings -> Agents -> Sandbox Policies** to manage reusable filesystem, network, environment, and credential controls for agent execution. Built-in presets are immutable; custom presets can be created, edited, disabled, or deleted.

View file

@ -84,20 +84,36 @@ Registers a new agent or updates an existing one. Sets status to `online` automa
],
"version": "2.0.0",
"metadata": { "role": "lead", "reference": "Interstellar" },
"providerRuntimeManifest": { "schemaVersion": "provider-runtime-manifest/v1", "...": "..." },
"sessionKey": "optional-openclaw-session-key"
}
```
| Field | Type | Required | Description |
| -------------- | ------ | -------- | ---------------------------------------- |
| `id` | string | ✅ | Unique identifier (1-50 chars) |
| `name` | string | ✅ | Display name (1-100 chars) |
| `model` | string | | Model identifier |
| `provider` | string | | Provider name |
| `capabilities` | array | | List of `{ name, description? }` objects |
| `version` | string | | Agent version or build info |
| `metadata` | object | | Freeform key-value data |
| `sessionKey` | string | | OpenClaw session key for routing |
| Field | Type | Required | Description |
| ------------------------- | ------ | -------- | -------------------------------------------------------------------------------------- |
| `id` | string | ✅ | Unique identifier (1-50 chars) |
| `name` | string | ✅ | Display name (1-100 chars) |
| `model` | string | | Model identifier |
| `provider` | string | | Provider name |
| `capabilities` | array | | List of `{ name, description? }` objects |
| `version` | string | | Agent version or build info |
| `metadata` | object | | Freeform key-value data |
| `providerRuntimeManifest` | object | | Validated runtime capability evidence; see the Provider Runtime Manifest API reference |
| `sessionKey` | string | | OpenClaw session key for routing |
The manifest digest is recomputed on registration. Forged, incomplete, or
forward-incompatible manifests, secret-like diagnostic fields, and unknown or
misspelled request fields are rejected with `400`. Legacy `provider`,
`model`, `capabilities`, and metadata fields remain available for compatibility
and display, but they cannot satisfy required runtime capability routing.
Authoritative manifest writes require `telemetry:write` plus either a key/token
identity matching the target agent ID or `agent:write`. Use a distinct named API
key for each self-registering agent; do not share a generic telemetry key for
runtime evidence. Once a record contains authoritative evidence, re-registration,
heartbeat, replacement, and deregistration remain identity-bound so another
telemetry writer cannot refresh, invalidate, or delete it. Re-registering with a
changed provider, model, or version and without replacement evidence invalidates
the previous manifest.
**Response:** `201 Created`
@ -133,16 +149,21 @@ Updates the agent's last-seen timestamp and optionally changes status or task as
"status": "busy",
"currentTaskId": "task_20260206_abc123",
"currentTaskTitle": "Implement authentication flow",
"metadata": { "progress": 0.65 }
"metadata": { "progress": 0.65 },
"providerRuntimeManifest": { "schemaVersion": "provider-runtime-manifest/v1", "...": "..." }
}
```
| Field | Type | Values | Description |
| ------------------ | -------------- | ------------------------ | --------------------------------------- |
| `status` | string | `online`, `busy`, `idle` | Agent's current state |
| `currentTaskId` | string \| null | | Task ID being worked on (null to clear) |
| `currentTaskTitle` | string \| null | | Task title (null to clear) |
| `metadata` | object | | Merge additional metadata |
| Field | Type | Values | Description |
| ------------------------- | -------------- | ------------------------ | ---------------------------------------------------------------------- |
| `status` | string | `online`, `busy`, `idle` | Agent's current state |
| `currentTaskId` | string \| null | | Task ID being worked on (null to clear) |
| `currentTaskTitle` | string \| null | | Task title (null to clear) |
| `metadata` | object | | Merge additional metadata |
| `providerRuntimeManifest` | object | | Replace the validated runtime manifest after a provider/version change |
Heartbeat manifest replacement uses the same authenticated identity binding as
registration. Ordinary telemetry-only heartbeats remain backward compatible.
**Response:** `200 OK` — Returns updated agent object.

View file

@ -244,7 +244,7 @@ GET /api/tasks
Returns all active tasks. Supports query filters.
**Response** `200`:
**Response** `200` (abridged manifest assessments):
```json
{
@ -1799,7 +1799,8 @@ Accepts either a task ID or ad-hoc metadata:
```json
{
"taskId": "TASK-001"
"taskId": "TASK-001",
"requiredRuntimeCapabilities": ["run.resume", "tool.mcp"]
}
```
@ -1810,7 +1811,8 @@ Accepts either a task ID or ad-hoc metadata:
"type": "bug",
"priority": "high",
"project": "rubicon",
"subtaskCount": 3
"subtaskCount": 3,
"requiredRuntimeCapabilities": ["run.resume", "tool.mcp"]
}
```
@ -1819,9 +1821,49 @@ Accepts either a task ID or ad-hoc metadata:
```json
{
"agent": "codex-1",
"model": "claude-sonnet-4.5",
"model": "gpt-5.5",
"rule": "high-priority-bugs",
"confidence": 0.95,
"reason": "Matched rule: High priority bugs. Selected manifest sha256:... with supported capability evidence.",
"runtimeSelection": {
"requiredCapabilities": ["run.resume", "tool.mcp"],
"compatible": true,
"selectedManifest": {
"manifestDigest": "sha256:...",
"provider": "codex-cli",
"compatible": true
},
"candidates": [
{
"manifestDigest": "sha256:...",
"provider": "codex-cli",
"compatible": true
}
]
},
"runtimeCandidates": [
{
"agent": "codex-1",
"available": true,
"selected": true,
"reason": "Agent is healthy",
"selection": {
"requiredCapabilities": ["run.resume", "tool.mcp"],
"compatible": true,
"selectedManifest": {
"manifestDigest": "sha256:...",
"provider": "codex-cli",
"compatible": true
},
"candidates": [
{
"manifestDigest": "sha256:...",
"provider": "codex-cli",
"compatible": true
}
]
}
}
],
"traceId": "govtrace_1760000000000_ab12cd"
}
```
@ -1829,6 +1871,10 @@ Accepts either a task ID or ad-hoc metadata:
When an enabled team roster exists, `/api/agents/route` evaluates the roster
before legacy routing rules. Roster-selected responses use a `team-roster:`
rule prefix.
When runtime requirements are present, `runtimeCandidates` preserves every
agent manifest evaluation attempted by the rule/fallback chain. Exactly one
entry is marked `selected` on success; terminal `409 Conflict` details preserve
all rejected entries and their structured manifest assessments.
### Get/Update Routing Configuration
@ -1913,6 +1959,51 @@ evidence. Failed probes and unknown versions are not positively cached.
Explicitly configured providers without a task execution adapter fail with
`409 Conflict` instead of falling back to OpenClaw.
### Runtime Manifest Registration And Routing
`POST /api/agents/register` and
`POST /api/agents/register/:id/heartbeat` accept an optional
`providerRuntimeManifest` object using the contract above. The server validates
the complete capability inventory and recomputes the canonical digest before
storing it. Secret-like evidence and unknown request fields are rejected rather
than silently stored or stripped. Invalid persisted manifests are ignored on
restart so capability routing fails closed. An agent may write its own manifest
when its authenticated key/token identity matches the registry ID; otherwise
`agent:write` is required in addition to registry write access. Every later
mutation of that authoritative record remains identity-bound. Changing the
registered provider, model, or version without replacement evidence invalidates
the prior manifest.
`POST /api/agents/route` and `POST /api/agents/hosts/preview` accept:
```json
{
"requiredRuntimeCapabilities": ["run.resume", "tool.mcp"]
}
```
Host provider, model, `tool.*`, and sandbox posture is aggregated from validated
manifests. Legacy registry fields are returned as display-only posture and
cannot satisfy these requirements. A single manifest must match the requested
provider and model and satisfy every required capability. `supported` evidence
qualifies; `advisory` qualifies with a warning; `unsupported`, `unknown`,
missing, or failed-probe evidence rejects the candidate with structured reasons.
Custom provider identifiers use the same schema and selection path without a
central provider branch. Registration enables discovery and routing only; an
execution adapter is still required before launch.
Only live registrations with a heartbeat no older than five minutes contribute
runtime evidence. `requiredTools` values using `tool.*` are evaluated through
the same single-manifest path; legacy named tools cannot qualify a host. Probe
timestamp freshness and action-level enforcement are completed by #887, so
agents must refresh evidence when their provider runtime changes. A bare
`sandboxPresetId` does not qualify a host in this intermediate contract because
the preset's individual controls have not yet been resolved into manifest
requirements. For a capability-only preview, omit the preset ID and supply the
relevant filesystem, network, environment, and credential IDs in
`requiredRuntimeCapabilities`; #887 wires actual preset resolution into the
same evaluator.
---
## Team Roster Manifests

View file

@ -372,6 +372,9 @@ Service discovery and liveness tracking for AI agents.
- **Heartbeat tracking** — Agents send periodic heartbeats; marked offline after configurable timeout (default 5 min)
- **Status lifecycle** — Online → Busy → Idle → Offline with automatic transitions
- **Capabilities declaration** — Agents declare what they can do (code-review, research, testing, etc.)
- **Validated runtime registration** — Registration and heartbeat payloads can carry a digest-verified provider runtime manifest for provider-neutral capability decisions
- **Capability-aware host routing** — Route and host-preview requests can require runtime capabilities; one matching manifest must satisfy the full set, with advisory warnings and fail-closed unsupported or unknown evidence
- **Authenticated runtime evidence** — Manifest writes are bound to the registering agent identity (or `agent:write`), reject unredacted diagnostics and request typos, expire with registry heartbeat liveness, and preserve structured evidence for every attempted route candidate
- **Stats endpoint**`GET /api/agents/register/stats` returns total, online, busy, idle, offline counts
- **File-based persistence** — Registry stored in `.veritas-kanban/agent-registry.json`

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { AgentHostService } from '../services/agent-host-service';
import type { RegisteredAgent } from '../services/agent-registry-service';
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
function agent(
id: string,
@ -13,6 +14,7 @@ function agent(
model: 'gpt-5',
provider: 'codex-cli',
capabilities: [{ name: 'code' }],
providerRuntimeManifest: providerRuntimeManifestFixture(),
status: 'idle',
registeredAt: '2026-06-01T12:00:00.000Z',
lastHeartbeat: '2026-06-01T12:00:00.000Z',
@ -73,7 +75,14 @@ describe('AgentHostService', () => {
),
agent(
'other',
{ provider: 'openclaw', model: 'other-model' },
{
provider: 'openclaw',
model: 'other-model',
providerRuntimeManifest: providerRuntimeManifestFixture({
provider: 'openclaw',
models: ['other-model'],
}),
},
{ hostId: 'host-d', hostName: 'D Host', supportedAgents: ['other'] }
),
]);
@ -84,7 +93,7 @@ describe('AgentHostService', () => {
provider: 'codex-cli',
model: 'gpt-5',
workspacePath: '/Users/bradgroux/Projects/veritas-kanban/server',
requiredTools: ['code'],
requiredTools: ['tool.calls'],
},
now
);
@ -125,7 +134,7 @@ describe('AgentHostService', () => {
const service = serviceFor([
agent(
'custom',
{ provider: 'custom' },
{ provider: 'custom', providerRuntimeManifest: undefined },
{ hostId: 'host-custom', hostName: 'Custom Host', providers: ['custom'] }
),
]);
@ -144,11 +153,270 @@ describe('AgentHostService', () => {
expect(preview.previews[0].checks.find((check) => check.id === 'sandbox-policy')).toMatchObject(
{
passed: false,
detail: 'Host does not report sandbox capability support for preset codex-repo-contained.',
detail:
'Sandbox preset codex-repo-contained cannot qualify a host until its required controls are resolved into requiredRuntimeCapabilities.',
}
);
});
it('does not let another manifest or a single sandbox signal qualify an unresolved preset', () => {
const service = serviceFor([
agent(
'codex',
{
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'filesystem.read': 'unsupported' },
}),
},
{ hostId: 'shared-host' }
),
agent(
'other',
{
providerRuntimeManifest: providerRuntimeManifestFixture({
provider: 'other-provider',
capabilityStates: { 'filesystem.read': 'supported' },
}),
},
{ hostId: 'shared-host' }
),
]);
const preview = service.preview(
{ agent: 'codex', sandboxPresetId: 'codex-repo-contained' },
now
);
expect(preview.previews[0]?.runtimeSelection?.selectedManifest?.provider).toBe('codex-cli');
expect(preview.previews[0]?.checks.find((check) => check.id === 'sandbox-policy')?.passed).toBe(
false
);
expect(preview.decision.selectedHostId).toBeUndefined();
});
it('uses a custom provider manifest without a central provider branch', () => {
const service = serviceFor([
agent('custom', {
provider: 'custom-runtime',
model: 'custom-model',
providerRuntimeManifest: providerRuntimeManifestFixture({
provider: 'custom-runtime',
models: ['custom-model'],
capabilityStates: { 'run.resume': 'supported' },
}),
}),
]);
const preview = service.preview(
{
agent: 'custom',
provider: 'custom-runtime',
model: 'custom-model',
requiredRuntimeCapabilities: ['run.resume'],
},
now
);
expect(preview.decision.selectedHostId).toBe('host-custom');
expect(preview.previews[0]?.runtimeSelection?.selectedManifest?.provider).toBe(
'custom-runtime'
);
});
it('does not compose provider, model, or tool requirements across manifests', () => {
const service = serviceFor([
agent(
'provider-a',
{
providerRuntimeManifest: providerRuntimeManifestFixture({
provider: 'provider-a',
models: ['model-a'],
capabilityStates: { 'tool.mcp': 'unsupported' },
}),
},
{ hostId: 'shared-host' }
),
agent(
'provider-b',
{
providerRuntimeManifest: providerRuntimeManifestFixture({
provider: 'provider-b',
models: ['model-b'],
capabilityStates: { 'tool.mcp': 'supported' },
}),
},
{ hostId: 'shared-host' }
),
]);
const providerModelPreview = service.preview({ provider: 'provider-a', model: 'model-b' }, now);
const providerToolPreview = service.preview(
{ provider: 'provider-a', requiredTools: ['tool.mcp'] },
now
);
expect(providerModelPreview.decision.selectedHostId).toBeUndefined();
expect(providerModelPreview.previews[0]?.runtimeSelection?.compatible).toBe(false);
expect(providerToolPreview.decision.selectedHostId).toBeUndefined();
expect(providerToolPreview.previews[0]?.runtimeSelection?.compatible).toBe(false);
});
it('accepts advisory tool capability evidence with a warning', () => {
const service = serviceFor([
agent('codex', {
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'tool.mcp': 'advisory' },
}),
}),
]);
const preview = service.preview({ agent: 'codex', requiredTools: ['tool.mcp'] }, now);
expect(preview.decision.selectedHostId).toBe('host-codex');
expect(preview.previews[0]?.warnings).toContain(
'Required runtime capabilities have advisory evidence: tool.mcp.'
);
});
it('normalizes tool capability requirements before manifest evaluation', () => {
const service = serviceFor([
agent('codex', {
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'tool.mcp': 'unsupported' },
}),
}),
]);
const preview = service.preview({ agent: 'codex', requiredTools: [' tool.mcp '] }, now);
expect(preview.request.requiredTools).toEqual(['tool.mcp']);
expect(preview.previews[0]?.runtimeSelection?.requiredCapabilities).toEqual(['tool.mcp']);
expect(preview.previews[0]?.runtimeSelection?.compatible).toBe(false);
expect(preview.decision.selectedHostId).toBeUndefined();
});
it('does not let legacy named tools qualify a host', () => {
const service = serviceFor([
agent(
'legacy',
{ providerRuntimeManifest: undefined },
{ hostId: 'legacy-host', tools: ['code'] }
),
]);
const preview = service.preview({ agent: 'legacy', requiredTools: ['code'] }, now);
expect(preview.decision.selectedHostId).toBeUndefined();
expect(preview.previews[0]?.reasons).toContain(
'Legacy named tool requirements cannot qualify host runtime posture: code. Use requiredRuntimeCapabilities with a tool.* identifier.'
);
});
it('does not let an offline sibling lend runtime evidence to a live host', () => {
const service = serviceFor([
agent(
'live',
{ providerRuntimeManifest: undefined },
{ hostId: 'shared-host', hostName: 'Shared Host' }
),
agent('offline', { status: 'offline' }, { hostId: 'shared-host', hostName: 'Shared Host' }),
]);
const preview = service.preview(
{ provider: 'codex-cli', requiredRuntimeCapabilities: ['run.start'] },
now
);
expect(preview.previews[0]?.posture).toBe('connected');
expect(preview.previews[0]?.runtimeSelection?.candidates).toHaveLength(0);
expect(preview.previews[0]?.warnings).toContain(
'Runtime manifest from agent offline was excluded because its registration is offline or outside the five-minute heartbeat window.'
);
expect(preview.decision.selectedHostId).toBeUndefined();
});
it('allows advisory runtime evidence with a concrete warning', () => {
const service = serviceFor([
agent('codex', {
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'run.resume': 'advisory' },
}),
}),
]);
const preview = service.preview(
{ agent: 'codex', requiredRuntimeCapabilities: ['run.resume'] },
now
);
expect(preview.decision.selectedHostId).toBe('host-codex');
expect(preview.previews[0]?.warnings).toContain(
'Required runtime capabilities have advisory evidence: run.resume.'
);
});
it.each(['unsupported', 'unknown'] as const)(
'rejects %s required runtime capability evidence',
(state) => {
const service = serviceFor([
agent('codex', {
providerRuntimeManifest: providerRuntimeManifestFixture({
capabilityStates: { 'run.resume': state },
}),
}),
]);
const preview = service.preview(
{ agent: 'codex', requiredRuntimeCapabilities: ['run.resume'] },
now
);
expect(preview.decision.selectedHostId).toBeUndefined();
expect(preview.previews[0]?.runtimeSelection?.candidates[0]?.capabilities[0]).toMatchObject({
state,
satisfied: false,
});
}
);
it('keeps legacy posture visible without letting it satisfy runtime requirements', () => {
const service = serviceFor([
agent(
'legacy',
{ provider: 'custom', model: 'legacy-model', providerRuntimeManifest: undefined },
{
providers: ['custom'],
models: ['legacy-model'],
tools: ['tool.calls'],
sandboxCapabilities: ['filesystem.write'],
}
),
]);
const health = service.getHealth(now);
const preview = service.preview(
{
agent: 'legacy',
provider: 'custom',
model: 'legacy-model',
requiredRuntimeCapabilities: ['tool.calls'],
},
now
);
expect(health.hosts[0]).toMatchObject({
supportedProviders: [],
supportedModels: [],
supportedTools: [],
legacyRuntimePosture: {
providers: ['custom'],
models: ['legacy-model'],
tools: expect.arrayContaining(['code', 'tool.calls']),
},
});
expect(preview.decision.selectedHostId).toBeUndefined();
});
it('disables auto-routing when no host is registered', () => {
const service = serviceFor([]);

View file

@ -7,6 +7,8 @@ import {
type AppConfig,
} from '@veritas-kanban/shared';
import type { AgentHealthStatus } from '../services/agent-health-service';
import type { RegisteredAgent } from '../services/agent-registry-service.js';
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
// Mock ConfigService
const mockGetConfig = vi.fn();
@ -120,6 +122,21 @@ function requireFallbackResult(
return result;
}
function registeredAgent(
id: string,
providerRuntimeManifest = providerRuntimeManifestFixture()
): RegisteredAgent {
return {
id,
name: id,
capabilities: [],
providerRuntimeManifest,
status: 'idle',
registeredAt: '2026-07-15T12:00:00.000Z',
lastHeartbeat: new Date().toISOString(),
};
}
describe('AgentRoutingService', () => {
let service: AgentRoutingService;
@ -167,6 +184,234 @@ describe('AgentRoutingService', () => {
expect(result.reason).toContain('High-priority code');
});
it('routes to a manifest that supports every required runtime capability', async () => {
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{
list: () => [
registeredAgent(
'claude-code',
providerRuntimeManifestFixture({
models: ['opus'],
capabilityStates: { 'run.resume': 'supported' },
})
),
],
}
);
const result = await service.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.start', 'run.resume'] }
);
expect(result.result.agent).toBe('claude-code');
expect(result.result.runtimeSelection).toMatchObject({
compatible: true,
selectedManifest: { advisory: false },
});
});
it('routes with a warning when the only matching evidence is advisory', async () => {
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{
list: () => [
registeredAgent(
'claude-code',
providerRuntimeManifestFixture({
models: ['opus'],
capabilityStates: { 'run.resume': 'advisory' },
})
),
],
}
);
const result = await service.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.resume'] }
);
expect(result.result.agent).toBe('claude-code');
expect(result.result.runtimeSelection?.selectedManifest?.advisory).toBe(true);
expect(result.result.reason).toContain('advisory capability evidence');
});
it('rejects an unsupported primary and selects a capable fallback', async () => {
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{
list: () => [
registeredAgent(
'claude-code',
providerRuntimeManifestFixture({
models: ['opus'],
capabilityStates: { 'run.resume': 'unsupported' },
})
),
registeredAgent(
'amp',
providerRuntimeManifestFixture({
provider: 'custom-amp',
capabilityStates: { 'run.resume': 'supported' },
})
),
],
}
);
const result = await service.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.resume'] }
);
expect(result.result.agent).toBe('amp');
expect(result.result.reason).toContain('unavailable');
expect(result.result.runtimeSelection?.compatible).toBe(true);
expect(result.result.runtimeCandidates).toHaveLength(2);
expect(result.result.runtimeCandidates).toEqual([
expect.objectContaining({ agent: 'claude-code', available: false, selected: false }),
expect.objectContaining({ agent: 'amp', available: true, selected: true }),
]);
});
it('routes a directly registered concrete custom manifest for a custom provider category', async () => {
const config = structuredClone(BASE_CONFIG);
const claude = requireAgent(config, 'claude-code');
claude.provider = 'custom';
mockGetConfig.mockResolvedValue(config);
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{
list: () => [
registeredAgent(
'claude-code',
providerRuntimeManifestFixture({
provider: 'custom-runtime',
models: ['opus'],
capabilityStates: { 'run.resume': 'supported' },
})
),
],
}
);
const result = await service.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.resume'] }
);
expect(result.result.agent).toBe('claude-code');
expect(result.result.runtimeSelection?.selectedManifest?.provider).toBe('custom-runtime');
});
it('does not borrow runtime evidence from another agent sharing a provider', async () => {
const config = structuredClone(BASE_CONFIG);
requireAgent(config, 'claude-code').provider = 'codex-cli';
mockGetConfig.mockResolvedValue(config);
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{
list: () => [
registeredAgent(
'unrelated-agent',
providerRuntimeManifestFixture({
provider: 'codex-cli',
models: ['opus'],
capabilityStates: { 'run.resume': 'supported' },
})
),
],
}
);
const error = (await service
.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.resume'] }
)
.catch((caught: unknown) => caught)) as {
statusCode: number;
details: {
runtimeCandidates: Array<{ selected: boolean; selection: { compatible: boolean } }>;
};
};
expect(error.statusCode).toBe(409);
expect(error.details.runtimeCandidates.length).toBeGreaterThan(0);
expect(error.details.runtimeCandidates.every((candidate) => !candidate.selected)).toBe(true);
expect(
error.details.runtimeCandidates.every((candidate) => !candidate.selection.compatible)
).toBe(true);
});
it('fails closed on ambiguous name matches and stale registrations', async () => {
const duplicateName = 'Claude Code';
const stale = registeredAgent('claude-code');
stale.lastHeartbeat = '2026-01-01T00:00:00.000Z';
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{
list: () => [
stale,
{ ...registeredAgent('duplicate-a'), name: duplicateName },
{ ...registeredAgent('duplicate-b'), name: duplicateName },
],
}
);
await expect(
service.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.start'] }
)
).rejects.toMatchObject({
statusCode: 409,
details: expect.objectContaining({
runtimeCandidates: expect.arrayContaining([
expect.objectContaining({
reason:
'Registered agent "claude-code" is offline or outside the five-minute heartbeat window.',
}),
]),
}),
});
});
it('fails closed when no candidate has a registered runtime manifest', async () => {
service = new AgentRoutingService(
undefined,
{ checkAgent: mockCheckAgent },
{ list: () => [] }
);
const error = (await service
.resolveAgentWithTrace(
{ type: 'code', priority: 'high' },
{ requiredRuntimeCapabilities: ['run.resume'] }
)
.catch((caught: unknown) => caught)) as {
statusCode: number;
details: {
reason: string;
runtimeCandidates: Array<{ selected: boolean }>;
};
};
expect(error.statusCode).toBe(409);
expect(error.details.reason).toBe(
'No registry identity matches configured agent "claude-code".'
);
expect(error.details.runtimeCandidates.every((candidate) => !candidate.selected)).toBe(true);
});
it('returns a governance trace with evaluated and matched routing rules', async () => {
const result = await service.resolveAgentWithTrace(
{

View file

@ -0,0 +1,62 @@
import {
KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS,
PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION,
PROVIDER_RUNTIME_PROBE_REVISION,
type ProviderRuntimeCapabilityId,
type ProviderRuntimeCapabilityState,
type ProviderRuntimeManifest,
type ProviderRuntimeProbeState,
} from '@veritas-kanban/shared';
import { calculateProviderRuntimeManifestDigest } from '../../utils/provider-runtime-manifest-digest.js';
interface ProviderRuntimeManifestFixtureOptions {
provider?: string;
adapter?: string;
providerVersion?: string;
models?: string[];
probeState?: ProviderRuntimeProbeState;
capabilityStates?: Partial<Record<ProviderRuntimeCapabilityId, ProviderRuntimeCapabilityState>>;
}
export function providerRuntimeManifestFixture(
options: ProviderRuntimeManifestFixtureOptions = {}
): ProviderRuntimeManifest {
const provider = options.provider ?? 'codex-cli';
const defaultSupported = new Set<ProviderRuntimeCapabilityId>([
'run.start',
'run.status',
'tool.calls',
'filesystem.read',
'filesystem.write',
'environment.allowlist',
]);
const payload: Omit<ProviderRuntimeManifest, 'digest'> = {
schemaVersion: PROVIDER_RUNTIME_MANIFEST_SCHEMA_VERSION,
probeRevision: PROVIDER_RUNTIME_PROBE_REVISION,
provider,
adapter: options.adapter ?? provider,
protocolVersion: 'fixture-runtime/v1',
providerVersion: options.providerVersion ?? `${provider} 1.0.0`,
models: options.models ?? ['gpt-5'],
capabilities: KNOWN_PROVIDER_RUNTIME_CAPABILITY_IDS.map((id) => {
const state =
options.capabilityStates?.[id] ?? (defaultSupported.has(id) ? 'supported' : 'unknown');
return {
id,
state,
source: 'contract-test' as const,
reason: `Fixture reports ${id} as ${state}.`,
};
}),
probe: {
state: options.probeState ?? 'ready',
probedAt: '2026-07-15T12:00:00.000Z',
source: 'fixture',
diagnostics: [],
},
};
return {
...payload,
digest: calculateProviderRuntimeManifestDigest(payload),
};
}

View file

@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { selectProviderRuntimeManifest } from '../services/provider-runtime-capability-service.js';
import { providerRuntimeManifestFixture } from './fixtures/provider-runtime-manifest.js';
describe('selectProviderRuntimeManifest', () => {
it('selects one manifest with supported required capabilities', () => {
const selection = selectProviderRuntimeManifest({
manifests: [providerRuntimeManifestFixture()],
provider: 'codex-cli',
model: 'gpt-5',
requiredCapabilities: ['run.start', 'tool.calls'],
});
expect(selection.compatible).toBe(true);
expect(selection.selectedManifest).toMatchObject({
provider: 'codex-cli',
advisory: false,
});
});
it('allows advisory evidence and surfaces a warning', () => {
const selection = selectProviderRuntimeManifest({
manifests: [
providerRuntimeManifestFixture({
capabilityStates: { 'run.resume': 'advisory' },
}),
],
requiredCapabilities: ['run.resume'],
});
expect(selection.compatible).toBe(true);
expect(selection.selectedManifest?.advisory).toBe(true);
expect(selection.selectedManifest?.warnings[0]).toContain('run.resume');
});
it.each(['unsupported', 'unknown'] as const)('rejects %s capability evidence', (state) => {
const selection = selectProviderRuntimeManifest({
manifests: [
providerRuntimeManifestFixture({
capabilityStates: { 'run.resume': state },
}),
],
requiredCapabilities: ['run.resume'],
});
expect(selection.compatible).toBe(false);
expect(selection.candidates[0]?.capabilities[0]).toMatchObject({ state, satisfied: false });
});
it('does not compose required capabilities across manifests', () => {
const selection = selectProviderRuntimeManifest({
manifests: [
providerRuntimeManifestFixture({
provider: 'custom-a',
capabilityStates: { 'run.start': 'supported', 'run.resume': 'unsupported' },
}),
providerRuntimeManifestFixture({
provider: 'custom-b',
capabilityStates: { 'run.start': 'unsupported', 'run.resume': 'supported' },
}),
],
requiredCapabilities: ['run.start', 'run.resume'],
});
expect(selection.compatible).toBe(false);
expect(selection.candidates).toHaveLength(2);
});
it('rejects failed probes and provider or model mismatches', () => {
const selection = selectProviderRuntimeManifest({
manifests: [
providerRuntimeManifestFixture({ probeState: 'failed' }),
providerRuntimeManifestFixture({ provider: 'custom', models: ['other-model'] }),
],
provider: 'custom',
model: 'gpt-5',
requiredCapabilities: ['run.start'],
});
expect(selection.compatible).toBe(false);
expect(selection.candidates.flatMap((candidate) => candidate.reasons).join(' ')).toContain(
'not reported'
);
});
it('orders mixed probe states deterministically', () => {
const manifests = (['failed', 'degraded', 'ready'] as const).map((probeState) =>
providerRuntimeManifestFixture({
provider: `provider-${probeState}`,
probeState,
capabilityStates: { 'run.resume': 'advisory' },
})
);
const first = selectProviderRuntimeManifest({
manifests,
provider: 'non-matching-provider',
requiredCapabilities: ['run.resume'],
});
const second = selectProviderRuntimeManifest({
manifests: [...manifests].reverse(),
provider: 'non-matching-provider',
requiredCapabilities: ['run.resume'],
});
expect(first.candidates.map((candidate) => candidate.probeState)).toEqual([
'ready',
'degraded',
'failed',
]);
expect(second.candidates.map((candidate) => candidate.manifestDigest)).toEqual(
first.candidates.map((candidate) => candidate.manifestDigest)
);
});
});

View file

@ -226,12 +226,14 @@ describe('ProviderRuntimeManifestService', () => {
});
it('redacts provider identity output before it enters the immutable snapshot', async () => {
const manifest = await new ProviderRuntimeManifestService().probe(
request('fixture 1.0.0 token=private-value sk-proj-abcdefghijklmnopqrstuvwxyz')
);
const unsafe = request('fixture 1.0.0 token=private-value sk-proj-abcdefghijklmnopqrstuvwxyz');
unsafe.identity.source = 'fixture --version authorization=private-source-value';
const manifest = await new ProviderRuntimeManifestService().probe(unsafe);
expect(manifest.providerVersion).toBe('fixture 1.0.0 token=[REDACTED] [REDACTED]');
expect(manifest.probe.source).toBe('fixture --version authorization=[REDACTED]');
expect(JSON.stringify(manifest)).not.toContain('private-value');
expect(JSON.stringify(manifest)).not.toContain('private-source-value');
expect(JSON.stringify(manifest)).not.toContain('sk-proj-');
});

View file

@ -90,6 +90,7 @@ import policyRoutes from '../../routes/policies.js';
import toolPolicyRoutes from '../../routes/tool-policies.js';
import { agentPermissionRoutes } from '../../routes/agent-permissions.js';
import { agentRoutingRoutes } from '../../routes/agent-routing.js';
import { errorHandler } from '../../middleware/error-handler.js';
const policyBody = {
id: 'agent-created-policy',
@ -124,6 +125,7 @@ function createApp() {
app.use('/api/tool-policies', toolPolicyRoutes);
app.use('/api/agents/permissions', agentPermissionRoutes);
app.use('/api/agents', agentRoutingRoutes);
app.use(errorHandler);
return app;
}
@ -376,11 +378,72 @@ describe('admin-only governance routes', () => {
.send({ type: 'feature', priority: 'medium' })
.expect(200);
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith({
type: 'feature',
priority: 'medium',
project: undefined,
subtasks: undefined,
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith(
{
type: 'feature',
priority: 'medium',
project: undefined,
subtasks: undefined,
},
{ requiredRuntimeCapabilities: undefined }
);
});
it('passes required runtime capabilities through ad-hoc routing requests', async () => {
const app = createApp();
await request(app)
.post('/api/agents/route')
.set('X-API-Key', 'agent-key')
.send({
type: 'feature',
priority: 'medium',
requiredRuntimeCapabilities: ['run.resume', 'tool.mcp'],
})
.expect(200);
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith(
expect.objectContaining({ type: 'feature', priority: 'medium' }),
{ requiredRuntimeCapabilities: ['run.resume', 'tool.mcp'] }
);
});
it('passes required runtime capabilities through task routing requests', async () => {
const app = createApp();
const task = { id: 'task-1', type: 'feature', priority: 'medium' };
mockTaskService.getTask.mockResolvedValue(task);
await request(app)
.post('/api/agents/route')
.set('X-API-Key', 'agent-key')
.send({ taskId: 'task-1', requiredRuntimeCapabilities: ['run.resume'] })
.expect(200);
expect(mockAgentRoutingService.resolveAgentWithTrace).toHaveBeenCalledWith(task, {
taskId: 'task-1',
requiredRuntimeCapabilities: ['run.resume'],
});
});
it('rejects misspelled capability fields and malformed task routing bodies', async () => {
const app = createApp();
await request(app)
.post('/api/agents/route')
.set('X-API-Key', 'agent-key')
.send({ type: 'feature', requiredRuntimeCapabilites: ['run.resume'] })
.expect(400);
await request(app)
.post('/api/agents/route')
.set('X-API-Key', 'agent-key')
.send({ taskId: '', type: 'feature' })
.expect(400);
await request(app)
.post('/api/agents/hosts/preview')
.set('X-API-Key', 'agent-key')
.send({ requiredRuntimeCapabilites: ['run.resume'] })
.expect(400);
expect(mockAgentRoutingService.resolveAgentWithTrace).not.toHaveBeenCalled();
});
});

View file

@ -7,6 +7,9 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import request from 'supertest';
import express from 'express';
import { errorHandler } from '../../middleware/error-handler.js';
import { providerRuntimeManifestFixture } from '../fixtures/provider-runtime-manifest.js';
import { calculateProviderRuntimeManifestDigest } from '../../utils/provider-runtime-manifest-digest.js';
import type { AuthContext, AuthenticatedRequest } from '../../middleware/auth.js';
const routeMocks = vi.hoisted(() => ({
getAgentStatus: vi.fn(() => ({
@ -24,6 +27,9 @@ vi.mock('../../storage/fs-helpers.js', () => ({
readFileSync: vi.fn().mockReturnValue('{}'),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
rename: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('../../services/task-service.js', () => ({
@ -43,6 +49,7 @@ const { agentRegistryRoutes } = await import('../../routes/agent-registry.js');
describe('Agent Registry Routes', () => {
let app: express.Express;
let authContext: AuthContext;
beforeEach(() => {
disposeAgentRegistryService();
@ -53,9 +60,18 @@ describe('Agent Registry Routes', () => {
});
routeMocks.getEvents.mockResolvedValue([]);
routeMocks.listTasks.mockResolvedValue([]);
authContext = {
role: 'admin',
isLocalhost: true,
authMethod: 'disabled',
};
app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as AuthenticatedRequest).auth = authContext;
next();
});
app.use('/api/agents/register', agentRegistryRoutes);
app.use(errorHandler);
});
@ -124,6 +140,144 @@ describe('Agent Registry Routes', () => {
expect(res.body.id).toBe('minimal-agent');
expect(res.body.capabilities).toEqual([]);
});
it('registers a validated custom provider runtime manifest', async () => {
const providerRuntimeManifest = providerRuntimeManifestFixture({
provider: 'custom-runtime',
adapter: 'custom-adapter',
models: ['custom-model'],
});
const res = await request(app).post('/api/agents/register').send({
id: 'custom-agent',
name: 'Custom Agent',
providerRuntimeManifest,
});
expect(res.status).toBe(201);
expect(res.body.providerRuntimeManifest).toMatchObject({
provider: 'custom-runtime',
adapter: 'custom-adapter',
digest: providerRuntimeManifest.digest,
});
});
it('rejects forged or incomplete provider runtime manifests', async () => {
const valid = providerRuntimeManifestFixture();
const forged = { ...valid, providerVersion: 'tampered' };
const incompletePayload = {
...valid,
capabilities: valid.capabilities.slice(0, 1),
};
const incomplete = {
...incompletePayload,
digest: calculateProviderRuntimeManifestDigest(incompletePayload),
};
const forgedResponse = await request(app).post('/api/agents/register').send({
id: 'forged-agent',
name: 'Forged Agent',
providerRuntimeManifest: forged,
});
const incompleteResponse = await request(app).post('/api/agents/register').send({
id: 'incomplete-agent',
name: 'Incomplete Agent',
providerRuntimeManifest: incomplete,
});
expect(forgedResponse.status).toBe(400);
expect(incompleteResponse.status).toBe(400);
});
it('rejects unredacted secrets in external runtime evidence', async () => {
const valid = providerRuntimeManifestFixture();
const unsafePayload = {
...valid,
capabilities: valid.capabilities.map((capability, index) =>
index === 0 ? { ...capability, reason: 'token=secret-value' } : capability
),
};
const unsafe = {
...unsafePayload,
digest: calculateProviderRuntimeManifestDigest(unsafePayload),
};
const response = await request(app).post('/api/agents/register').send({
id: 'unsafe-agent',
name: 'Unsafe Agent',
providerRuntimeManifest: unsafe,
});
expect(response.status).toBe(400);
expect(JSON.stringify(response.body)).not.toContain('secret-value');
});
it('rejects misspelled manifest fields instead of silently dropping evidence', async () => {
const response = await request(app).post('/api/agents/register').send({
id: 'typo-agent',
name: 'Typo Agent',
providerRuntimeManfiest: providerRuntimeManifestFixture(),
});
expect(response.status).toBe(400);
});
it('binds manifest writes to the authenticated agent identity', async () => {
authContext = {
role: 'agent',
keyName: 'other-agent',
isLocalhost: false,
authMethod: 'api-key',
};
const denied = await request(app).post('/api/agents/register').send({
id: 'custom-agent',
name: 'Custom Agent',
providerRuntimeManifest: providerRuntimeManifestFixture(),
});
expect(denied.status).toBe(403);
authContext.keyName = 'custom-agent';
const allowed = await request(app).post('/api/agents/register').send({
id: 'custom-agent',
name: 'Custom Agent',
providerRuntimeManifest: providerRuntimeManifestFixture(),
});
expect(allowed.status).toBe(201);
});
it('prevents another telemetry agent from replacing or clearing authoritative evidence', async () => {
const manifest = providerRuntimeManifestFixture();
await request(app).post('/api/agents/register').send({
id: 'protected-agent',
name: 'Protected Agent',
provider: 'codex-cli',
providerRuntimeManifest: manifest,
});
authContext = {
role: 'agent',
keyName: 'other-agent',
isLocalhost: false,
authMethod: 'api-key',
};
await request(app)
.post('/api/agents/register')
.send({
id: 'protected-agent',
name: 'Protected Agent',
provider: 'openclaw',
})
.expect(403);
await request(app)
.post('/api/agents/register/protected-agent/heartbeat')
.send({ status: 'online' })
.expect(403);
await request(app).delete('/api/agents/register/protected-agent').expect(403);
const record = await request(app).get('/api/agents/register/protected-agent').expect(200);
expect(record.body.provider).toBe('codex-cli');
expect(record.body.providerRuntimeManifest.digest).toBe(manifest.digest);
});
});
// ── Heartbeat ────────────────────────────────────────────────
@ -165,6 +319,39 @@ describe('Agent Registry Routes', () => {
expect(res.body.metadata).toEqual({ ping: 12345 });
});
it('replaces the validated runtime manifest via heartbeat', async () => {
const first = providerRuntimeManifestFixture({ providerVersion: 'fixture 1.0.0' });
const upgraded = providerRuntimeManifestFixture({ providerVersion: 'fixture 2.0.0' });
await request(app).post('/api/agents/register').send({
id: 'manifest-agent',
name: 'Manifest Agent',
providerRuntimeManifest: first,
});
const res = await request(app)
.post('/api/agents/register/manifest-agent/heartbeat')
.send({ providerRuntimeManifest: upgraded });
expect(res.status).toBe(200);
expect(res.body.providerRuntimeManifest).toMatchObject({
providerVersion: 'fixture 2.0.0',
digest: upgraded.digest,
});
});
it('rejects a misspelled heartbeat manifest field', async () => {
await request(app).post('/api/agents/register').send({
id: 'manifest-agent',
name: 'Manifest Agent',
});
const response = await request(app)
.post('/api/agents/register/manifest-agent/heartbeat')
.send({ providerRuntimeManfiest: providerRuntimeManifestFixture() });
expect(response.status).toBe(400);
});
it('should clear task when status is idle', async () => {
await request(app)
.post('/api/agents/register')

View file

@ -11,12 +11,17 @@ vi.mock('../../storage/fs-helpers.js', () => ({
readFileSync: vi.fn().mockReturnValue('{}'),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
rename: vi.fn().mockResolvedValue(undefined),
}));
// Must import after mock
const { getAgentRegistryService, disposeAgentRegistryService, createTaskSyncToken } =
await import('../../services/agent-registry-service.js');
import type { RegisteredAgent, TaskSyncContext } from '../../services/agent-registry-service.js';
import { providerRuntimeManifestFixture } from '../fixtures/provider-runtime-manifest.js';
import { existsSync, readFileSync } from '../../storage/fs-helpers.js';
const TASK_SYNC_CONTEXT: TaskSyncContext = createTaskSyncToken('task-service');
const TASK_RECONCILE_CONTEXT: TaskSyncContext = createTaskSyncToken('task-reconciler');
@ -25,6 +30,8 @@ describe('AgentRegistryService', () => {
beforeEach(() => {
// Ensure fresh instance for each test
disposeAgentRegistryService();
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(readFileSync).mockReturnValue('{}');
});
afterEach(() => {
@ -115,6 +122,77 @@ describe('AgentRegistryService', () => {
newField: true,
});
});
it('validates, freezes, and preserves the runtime manifest on re-register', () => {
const service = getAgentRegistryService();
const providerRuntimeManifest = providerRuntimeManifestFixture();
const first = service.register({
id: 'manifest-agent',
name: 'Manifest Agent',
providerRuntimeManifest,
});
const second = service.register({
id: 'manifest-agent',
name: 'Manifest Agent Updated',
});
expect(first.providerRuntimeManifest?.digest).toBe(providerRuntimeManifest.digest);
expect(Object.isFrozen(first.providerRuntimeManifest)).toBe(true);
expect(Object.isFrozen(first.providerRuntimeManifest?.capabilities)).toBe(true);
expect(second.providerRuntimeManifest?.digest).toBe(providerRuntimeManifest.digest);
});
it.each([{ provider: 'openclaw' }, { model: 'different-model' }, { version: '2.0.0' }])(
'invalidates preserved runtime evidence when identity changes: %o',
(identityChange) => {
const service = getAgentRegistryService();
service.register({
id: 'manifest-agent',
name: 'Manifest Agent',
provider: 'codex-cli',
model: 'gpt-5',
version: '1.0.0',
providerRuntimeManifest: providerRuntimeManifestFixture(),
});
const updated = service.register({
id: 'manifest-agent',
name: 'Manifest Agent',
...identityChange,
});
expect(updated.providerRuntimeManifest).toBeUndefined();
}
);
});
describe('load()', () => {
it('ignores invalid persisted runtime evidence', () => {
const invalid = {
...providerRuntimeManifestFixture(),
providerVersion: 'tampered-after-digest',
};
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
agents: {
persisted: {
id: 'persisted',
name: 'Persisted Agent',
capabilities: [],
status: 'idle',
registeredAt: '2026-07-15T12:00:00.000Z',
lastHeartbeat: '2026-07-15T12:00:00.000Z',
providerRuntimeManifest: invalid,
},
},
})
);
const service = getAgentRegistryService();
expect(service.get('persisted')?.providerRuntimeManifest).toBeUndefined();
});
});
// ── Heartbeat ────────────────────────────────────────────────
@ -233,6 +311,24 @@ describe('AgentRegistryService', () => {
ping: expect.any(Number),
});
});
it('replaces the runtime manifest on heartbeat', () => {
const service = getAgentRegistryService();
const first = providerRuntimeManifestFixture({ providerVersion: 'fixture 1.0.0' });
const upgraded = providerRuntimeManifestFixture({ providerVersion: 'fixture 2.0.0' });
service.register({
id: 'manifest-agent',
name: 'Manifest Agent',
providerRuntimeManifest: first,
});
const updated = service.heartbeat('manifest-agent', {
providerRuntimeManifest: upgraded,
});
expect(updated?.providerRuntimeManifest?.digest).toBe(upgraded.digest);
expect(updated?.providerRuntimeManifest?.providerVersion).toBe('fixture 2.0.0');
});
});
// ── Listing ──────────────────────────────────────────────────

View file

@ -18,8 +18,10 @@ import { getAgentHealthClassifierService } from '../services/agent-health-classi
import { getTaskService } from '../services/task-service.js';
import { getTelemetryService } from '../services/telemetry-service.js';
import { asyncHandler } from '../middleware/async-handler.js';
import { NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { ForbiddenError, NotFoundError, ValidationError } from '../middleware/error-handler.js';
import { getAgentStatus } from './agent-status.js';
import { ProviderRuntimeManifestSchema } from '../schemas/provider-runtime-manifest-schemas.js';
import { hasPermission, type AuthenticatedRequest } from '../middleware/auth.js';
const router: RouterType = Router();
@ -30,23 +32,29 @@ const capabilitySchema = z.object({
description: z.string().max(200).optional(),
});
const registerSchema = z.object({
id: z.string().min(1).max(50),
name: z.string().min(1).max(100),
model: z.string().max(100).optional(),
provider: z.string().max(50).optional(),
capabilities: z.array(capabilitySchema).optional(),
version: z.string().max(50).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
sessionKey: z.string().max(200).optional(),
});
const registerSchema = z
.object({
id: z.string().min(1).max(50),
name: z.string().min(1).max(100),
model: z.string().max(100).optional(),
provider: z.string().max(50).optional(),
capabilities: z.array(capabilitySchema).optional(),
version: z.string().max(50).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
providerRuntimeManifest: ProviderRuntimeManifestSchema.optional(),
sessionKey: z.string().max(200).optional(),
})
.strict();
const heartbeatSchema = z.object({
status: z.enum(['online', 'busy', 'idle']).optional(),
currentTaskId: z.string().max(100).optional().nullable(),
currentTaskTitle: z.string().max(200).optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const heartbeatSchema = z
.object({
status: z.enum(['online', 'busy', 'idle']).optional(),
currentTaskId: z.string().max(100).optional().nullable(),
currentTaskTitle: z.string().max(200).optional().nullable(),
metadata: z.record(z.string(), z.unknown()).optional(),
providerRuntimeManifest: ProviderRuntimeManifestSchema.optional(),
})
.strict();
// ─── Routes ──────────────────────────────────────────────────────
@ -116,8 +124,13 @@ router.post(
if (!parsed.success) {
throw new ValidationError('Invalid registration', parsed.error.issues);
}
const registry = getAgentRegistryService();
assertTrustedRuntimeEvidenceMutation(
req as AuthenticatedRequest,
parsed.data.providerRuntimeManifest !== undefined ||
registry.get(parsed.data.id)?.providerRuntimeManifest !== undefined,
parsed.data.id
);
const agent = registry.register(parsed.data);
res.status(201).json(agent);
})
@ -166,8 +179,13 @@ router.post(
if (!parsed.success) {
throw new ValidationError('Invalid heartbeat', parsed.error.issues);
}
const registry = getAgentRegistryService();
assertTrustedRuntimeEvidenceMutation(
req as AuthenticatedRequest,
parsed.data.providerRuntimeManifest !== undefined ||
registry.get(req.params.id as string)?.providerRuntimeManifest !== undefined,
req.params.id as string
);
const agent = registry.heartbeat(req.params.id as string, {
...parsed.data,
currentTaskId: parsed.data.currentTaskId ?? undefined,
@ -190,6 +208,11 @@ router.delete(
'/:id',
asyncHandler(async (req, res) => {
const registry = getAgentRegistryService();
assertTrustedRuntimeEvidenceMutation(
req as AuthenticatedRequest,
registry.get(req.params.id as string)?.providerRuntimeManifest !== undefined,
req.params.id as string
);
const removed = registry.deregister(req.params.id as string);
if (!removed) {
throw new NotFoundError('Agent not found');
@ -198,4 +221,25 @@ router.delete(
})
);
function assertTrustedRuntimeEvidenceMutation(
req: AuthenticatedRequest,
requiresIdentityBinding: boolean,
agentId: string
): void {
if (!requiresIdentityBinding) return;
if (hasPermission(req.auth, 'agent:write')) return;
const principalIds = [req.auth?.keyName, req.auth?.tokenName, req.auth?.clientId]
.filter((value): value is string => Boolean(value?.trim()))
.map((value) => value.trim().toLowerCase());
const selfRegistration =
req.auth?.role === 'agent' && principalIds.includes(agentId.trim().toLowerCase());
if (selfRegistration) return;
throw new ForbiddenError(
'Modifying authoritative provider runtime evidence requires a matching agent identity or agent:write permission',
{ required: ['telemetry:write', 'matching-agent-identity-or-agent:write'] }
);
}
export { router as agentRegistryRoutes };

View file

@ -20,16 +20,31 @@ const router: RouterType = Router();
// ─── Validation Schemas ──────────────────────────────────────────
const routeByTaskIdSchema = z.object({
taskId: z.string().min(1),
});
const runtimeCapabilityIdSchema = z
.string()
.trim()
.min(2)
.max(80)
.regex(/^[a-z][a-z0-9.-]*$/);
const routeByMetadataSchema = z.object({
type: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
subtaskCount: z.number().int().nonnegative().optional(),
});
const requiredRuntimeCapabilitiesSchema = z.array(runtimeCapabilityIdSchema).max(64).optional();
const routeByTaskIdSchema = z
.object({
taskId: z.string().min(1),
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
})
.strict();
const routeByMetadataSchema = z
.object({
type: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
subtaskCount: z.number().int().nonnegative().optional(),
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
})
.strict();
const routingMatchSchema = z.object({
type: z.union([z.string(), z.array(z.string())]).optional(),
@ -59,18 +74,21 @@ const routingConfigSchema = z.object({
maxRetries: z.number().int().min(0).max(3),
});
const hostPreviewSchema = z.object({
agent: z.string().max(100).optional(),
provider: z.string().max(80).optional(),
model: z.string().max(100).optional(),
workspacePath: z.string().max(1000).optional(),
requiredTools: z.array(z.string().max(80)).max(50).optional(),
verificationGates: z.array(z.string().max(200)).max(50).optional(),
sandboxPresetId: z.string().max(80).optional(),
manualHostId: z.string().max(120).optional(),
projectDefaultHostId: z.string().max(120).optional(),
autoRouting: z.boolean().optional(),
});
const hostPreviewSchema = z
.object({
agent: z.string().max(100).optional(),
provider: z.string().max(80).optional(),
model: z.string().max(100).optional(),
workspacePath: z.string().max(1000).optional(),
requiredTools: z.array(z.string().max(80)).max(50).optional(),
requiredRuntimeCapabilities: requiredRuntimeCapabilitiesSchema,
verificationGates: z.array(z.string().max(200)).max(50).optional(),
sandboxPresetId: z.string().max(80).optional(),
manualHostId: z.string().max(120).optional(),
projectDefaultHostId: z.string().max(120).optional(),
autoRouting: z.boolean().optional(),
})
.strict();
// ─── Routes ──────────────────────────────────────────────────────
@ -94,7 +112,10 @@ router.post(
if (!task) {
throw new NotFoundError('Task not found');
}
const result = await routing.resolveAgentWithTrace(task, { taskId: taskIdParse.data.taskId });
const result = await routing.resolveAgentWithTrace(task, {
taskId: taskIdParse.data.taskId,
requiredRuntimeCapabilities: taskIdParse.data.requiredRuntimeCapabilities,
});
const trace = await getGovernanceTraceService().record(result.trace);
return res.json({ ...result.result, traceId: trace.id });
}
@ -102,20 +123,23 @@ router.post(
// Fall back to metadata
const metaParse = routeByMetadataSchema.safeParse(req.body);
if (metaParse.success) {
const { type, priority, project, subtaskCount } = metaParse.data;
const result = await routing.resolveAgentWithTrace({
type: type || 'feature',
priority: priority || 'medium',
project,
subtasks: subtaskCount
? Array.from({ length: subtaskCount }, (_, i) => ({
id: `stub_${i}`,
title: '',
completed: false,
created: new Date().toISOString(),
}))
: undefined,
});
const { type, priority, project, subtaskCount, requiredRuntimeCapabilities } = metaParse.data;
const result = await routing.resolveAgentWithTrace(
{
type: type || 'feature',
priority: priority || 'medium',
project,
subtasks: subtaskCount
? Array.from({ length: subtaskCount }, (_, i) => ({
id: `stub_${i}`,
title: '',
completed: false,
created: new Date().toISOString(),
}))
: undefined,
},
{ requiredRuntimeCapabilities }
);
const trace = await getGovernanceTraceService().record(result.trace);
return res.json({ ...result.result, traceId: trace.id });
}

View file

@ -6,6 +6,7 @@ import {
type ProviderRuntimeManifest,
} from '@veritas-kanban/shared';
import { calculateProviderRuntimeManifestDigest } from '../utils/provider-runtime-manifest-digest.js';
import { containsUnredactedProviderRuntimeSecret } from '../utils/provider-runtime-manifest-sanitize.js';
const identifierSchema = z
.string()
@ -80,6 +81,30 @@ export const ProviderRuntimeManifestSchema = z
message: 'Provider runtime manifest digest does not match its canonical payload',
});
}
const sensitiveFields: Array<{ path: (string | number)[]; value: string }> = [
{ path: ['providerVersion'], value: manifest.providerVersion },
...(manifest.providerBuild
? [{ path: ['providerBuild'], value: manifest.providerBuild }]
: []),
{ path: ['probe', 'source'], value: manifest.probe.source },
...manifest.probe.diagnostics.map((value, index) => ({
path: ['probe', 'diagnostics', index],
value,
})),
...manifest.capabilities.map((capability, index) => ({
path: ['capabilities', index, 'reason'],
value: capability.reason,
})),
];
for (const field of sensitiveFields) {
if (containsUnredactedProviderRuntimeSecret(field.value)) {
context.addIssue({
code: 'custom',
path: field.path,
message: 'Provider runtime evidence must redact credentials and secrets before ingestion',
});
}
}
});
export function parseProviderRuntimeManifest(input: unknown): ProviderRuntimeManifest {

View file

@ -9,11 +9,20 @@ import type {
AgentHostPreviewRequest,
AgentHostRecord,
AgentHostRoutingDecision,
ProviderRuntimeCapabilityId,
ProviderRuntimeManifest,
ProviderRuntimeSelection,
SandboxProviderCapabilityId,
} from '@veritas-kanban/shared';
import { getAgentRegistryService, type RegisteredAgent } from './agent-registry-service.js';
import {
AGENT_HEARTBEAT_TIMEOUT_MS,
getAgentRegistryService,
registeredAgentIsLive,
type RegisteredAgent,
} from './agent-registry-service.js';
import { selectProviderRuntimeManifest } from './provider-runtime-capability-service.js';
const STALE_HEARTBEAT_MS = 10 * 60 * 1000;
const STALE_HEARTBEAT_MS = AGENT_HEARTBEAT_TIMEOUT_MS;
const DISCONNECTED_HEARTBEAT_MS = 30 * 60 * 1000;
interface AgentHostRegistryReader {
@ -31,6 +40,11 @@ interface HostAccumulator {
supportedModels: Set<string>;
supportedTools: Set<string>;
sandboxCapabilities: Set<SandboxProviderCapabilityId>;
manifestRegistrations: HostManifestRegistration[];
legacyProviders: Set<string>;
legacyModels: Set<string>;
legacyTools: Set<string>;
legacySandboxCapabilities: Set<SandboxProviderCapabilityId>;
workspaceLabels: Set<string>;
workspaceRoots: string[];
activeSessions: number;
@ -43,8 +57,15 @@ interface HostAccumulator {
diagnostics: Set<string>;
}
interface HostManifestRegistration {
agentId: string;
agentName: string;
manifest: ProviderRuntimeManifest;
}
interface ResolvedAgentHost extends AgentHostRecord {
workspaceRoots: string[];
manifestRegistrations: HostManifestRegistration[];
}
export class AgentHostService {
@ -61,12 +82,13 @@ export class AgentHostService {
preview(request: AgentHostPreviewRequest, now = new Date()): AgentHostCompatibilityResponse {
const hosts = this.buildHosts(now);
const previews = hosts.map((host) => this.previewHost(host, request));
const normalizedRequest = normalizeRequest(request);
const previews = hosts.map((host) => this.previewHost(host, normalizedRequest));
return {
generatedAt: now.toISOString(),
request: normalizeRequest(request),
request: normalizedRequest,
previews,
decision: this.resolveDecision(previews, request),
decision: this.resolveDecision(previews, normalizedRequest),
};
}
@ -86,28 +108,53 @@ export class AgentHostService {
host.supportedAgents.add(agent.name);
for (const item of stringArray(metadata.supportedAgents)) host.supportedAgents.add(item);
for (const capability of agent.capabilities) {
host.supportedTools.add(capability.name);
host.legacyTools.add(capability.name);
}
if (agent.provider) host.supportedProviders.add(agent.provider);
for (const item of stringArray(metadata.providers)) host.supportedProviders.add(item);
if (agent.provider) host.legacyProviders.add(agent.provider);
for (const item of stringArray(metadata.providers)) host.legacyProviders.add(item);
for (const capability of sandboxCapabilityArray(metadata.sandboxCapabilities)) {
host.sandboxCapabilities.add(capability);
host.legacySandboxCapabilities.add(capability);
}
for (const capability of inferredSandboxCapabilities(agent.provider)) {
host.sandboxCapabilities.add(capability);
host.legacySandboxCapabilities.add(capability);
}
for (const provider of stringArray(metadata.providers)) {
for (const capability of inferredSandboxCapabilities(provider)) {
host.sandboxCapabilities.add(capability);
host.legacySandboxCapabilities.add(capability);
}
}
if (agent.model) host.supportedModels.add(agent.model);
for (const item of stringArray(metadata.models)) host.supportedModels.add(item);
if (agent.model) host.legacyModels.add(agent.model);
for (const item of stringArray(metadata.models)) host.legacyModels.add(item);
for (const item of stringArray(metadata.tools)) host.supportedTools.add(item);
for (const item of stringArray(metadata.requiredTools)) host.supportedTools.add(item);
for (const item of stringArray(metadata.tools)) host.legacyTools.add(item);
for (const item of stringArray(metadata.requiredTools)) host.legacyTools.add(item);
const manifest = agent.providerRuntimeManifest;
if (manifest && registrationManifestIsCurrent(agent, now)) {
host.manifestRegistrations.push({ agentId: agent.id, agentName: agent.name, manifest });
if (manifest.probe.state === 'failed') {
host.diagnostics.add(
`Provider manifest ${manifest.digest} failed its readiness probe and is display-only.`
);
} else {
host.supportedProviders.add(manifest.provider);
host.supportedProviders.add(manifest.adapter);
for (const model of manifest.models) host.supportedModels.add(model);
for (const capability of manifest.capabilities) {
if (capability.state !== 'supported') continue;
if (capability.id.startsWith('tool.')) host.supportedTools.add(capability.id);
if (isSandboxCapability(capability.id)) {
host.sandboxCapabilities.add(capability.id);
}
}
}
} else if (manifest) {
host.diagnostics.add(
`Runtime manifest from agent ${agent.id} was excluded because its registration is offline or outside the five-minute heartbeat window.`
);
}
const rawRoots = stringArray(metadata.workspaceRoots);
for (const root of rawRoots) {
@ -148,6 +195,7 @@ export class AgentHostService {
host: ResolvedAgentHost,
request: AgentHostPreviewRequest
): AgentHostCompatibilityPreview {
const runtimeSelection = this.selectRuntimeManifest(host, request);
const checks: AgentHostCompatibilityCheck[] = [
{
id: 'heartbeat',
@ -181,8 +229,9 @@ export class AgentHostService {
host.supportedModels,
'No model requirement supplied.'
),
this.requiredToolsCheck(host, request.requiredTools),
this.sandboxPolicyCheck(host, request.sandboxPresetId),
this.runtimeCapabilitiesCheck(runtimeSelection, request),
this.requiredToolsCheck(runtimeSelection, request.requiredTools),
this.sandboxPolicyCheck(request.sandboxPresetId),
{
id: 'verification-gates',
label: 'Verification gates',
@ -210,7 +259,9 @@ export class AgentHostService {
...host.diagnostics,
...checks
.filter(
(check) => check.passed && /unknown|not supplied|will be recorded/i.test(check.detail)
(check) =>
check.passed &&
/unknown|not supplied|will be recorded|advisory|legacy/i.test(check.detail)
)
.map((check) => check.detail),
];
@ -221,6 +272,7 @@ export class AgentHostService {
posture: host.posture,
compatible: reasons.length === 0,
checks,
runtimeSelection,
reasons,
warnings: uniqueSorted(warnings),
};
@ -274,8 +326,8 @@ export class AgentHostService {
return {
id,
label,
passed: true,
detail: `${label} support is unknown for this host.`,
passed: false,
detail: `${label} support is unavailable because no validated runtime manifest reports it.`,
};
}
const matched = supported.some((candidate) => normalize(candidate) === normalize(value));
@ -289,8 +341,60 @@ export class AgentHostService {
};
}
private selectRuntimeManifest(
host: ResolvedAgentHost,
request: AgentHostPreviewRequest
): ProviderRuntimeSelection {
const registrations = host.manifestRegistrations.filter(
(registration) =>
!request.agent ||
normalize(request.agent) === normalize(registration.agentId) ||
normalize(request.agent) === normalize(registration.agentName)
);
return selectProviderRuntimeManifest({
manifests: registrations.map((registration) => registration.manifest),
provider: request.provider,
model: request.model,
requiredCapabilities: [
...(request.requiredRuntimeCapabilities ?? []),
...(request.requiredTools ?? []).filter((tool) => tool.startsWith('tool.')),
],
});
}
private runtimeCapabilitiesCheck(
selection: ProviderRuntimeSelection,
request: AgentHostPreviewRequest
): AgentHostCompatibilityCheck {
const required = selection.requiredCapabilities;
const hasManifestRequirement = Boolean(
request.provider || request.model || required.length > 0
);
if (!hasManifestRequirement) {
return {
id: 'runtime-capabilities',
label: 'Runtime manifest',
passed: true,
detail: 'No provider, model, or runtime capability requirement supplied.',
};
}
return {
id: 'runtime-capabilities',
label: 'Runtime manifest',
passed: selection.compatible,
detail: selection.compatible
? required.length > 0
? selection.selectedManifest?.advisory
? `Required runtime capabilities have advisory evidence: ${required.join(', ')}.`
: `Required runtime capabilities are supported: ${required.join(', ')}.`
: 'Provider and model requirements are satisfied by one validated runtime manifest.'
: selection.reason,
};
}
private requiredToolsCheck(
host: AgentHostRecord,
selection: ProviderRuntimeSelection,
requiredTools: string[] | undefined
): AgentHostCompatibilityCheck {
const tools = (requiredTools ?? []).map((tool) => tool.trim()).filter(Boolean);
@ -302,23 +406,23 @@ export class AgentHostService {
detail: 'No required tools supplied.',
};
}
const supported = new Set(host.supportedTools.map(normalize));
const missing = tools.filter((tool) => !supported.has(normalize(tool)));
const runtimeTools = tools.filter((tool) => tool.startsWith('tool.'));
const legacyNamedTools = tools.filter((tool) => !tool.startsWith('tool.'));
const runtimePassed = runtimeTools.length === 0 || selection.compatible;
const legacyNamesPassed = legacyNamedTools.length === 0;
return {
id: 'required-tools',
label: 'Required tools',
passed: missing.length === 0,
detail:
missing.length === 0
? 'Required tools are registered on this host.'
: `Missing required tools: ${missing.join(', ')}.`,
passed: runtimePassed && legacyNamesPassed,
detail: !legacyNamesPassed
? `Legacy named tool requirements cannot qualify host runtime posture: ${legacyNamedTools.join(', ')}. Use requiredRuntimeCapabilities with a tool.* identifier.`
: runtimePassed
? 'Required tool capabilities qualify through one validated runtime manifest.'
: selection.reason,
};
}
private sandboxPolicyCheck(
host: AgentHostRecord,
sandboxPresetId: string | undefined
): AgentHostCompatibilityCheck {
private sandboxPolicyCheck(sandboxPresetId: string | undefined): AgentHostCompatibilityCheck {
if (!sandboxPresetId) {
return {
id: 'sandbox-policy',
@ -331,11 +435,8 @@ export class AgentHostService {
return {
id: 'sandbox-policy',
label: 'Sandbox policy',
passed: host.sandboxCapabilities.length > 0,
detail:
host.sandboxCapabilities.length > 0
? `Host reports ${host.sandboxCapabilities.length} sandbox capability signal(s) for preset ${sandboxPresetId}.`
: `Host does not report sandbox capability support for preset ${sandboxPresetId}.`,
passed: false,
detail: `Sandbox preset ${sandboxPresetId} cannot qualify a host until its required controls are resolved into requiredRuntimeCapabilities.`,
};
}
@ -444,6 +545,11 @@ function getOrCreateHost(
supportedModels: new Set(),
supportedTools: new Set(),
sandboxCapabilities: new Set(),
manifestRegistrations: [],
legacyProviders: new Set(),
legacyModels: new Set(),
legacyTools: new Set(),
legacySandboxCapabilities: new Set(),
workspaceLabels: new Set(),
workspaceRoots: [],
activeSessions: 0,
@ -475,6 +581,16 @@ function finalizeHost(host: HostAccumulator, now: Date): ResolvedAgentHost {
if (host.lastFailure) {
host.diagnostics.add('Supervisor reported a recent failure.');
}
if (
host.legacyProviders.size > 0 ||
host.legacyModels.size > 0 ||
host.legacyTools.size > 0 ||
host.legacySandboxCapabilities.size > 0
) {
host.diagnostics.add(
'Legacy provider, model, tool, and sandbox metadata is display-only and cannot satisfy runtime capability requirements.'
);
}
return {
id: host.id,
@ -490,6 +606,17 @@ function finalizeHost(host: HostAccumulator, now: Date): ResolvedAgentHost {
sandboxCapabilities: uniqueSorted(
Array.from(host.sandboxCapabilities)
) as SandboxProviderCapabilityId[],
providerRuntimeManifests: uniqueManifests(
host.manifestRegistrations.map((registration) => registration.manifest)
),
legacyRuntimePosture: {
providers: uniqueSorted(Array.from(host.legacyProviders)),
models: uniqueSorted(Array.from(host.legacyModels)),
tools: uniqueSorted(Array.from(host.legacyTools)),
sandboxCapabilities: uniqueSorted(
Array.from(host.legacySandboxCapabilities)
) as SandboxProviderCapabilityId[],
},
workspaceLabels: uniqueSorted(Array.from(host.workspaceLabels)),
activeSessions: host.activeSessions,
queueDepth: host.queueDepth,
@ -500,6 +627,7 @@ function finalizeHost(host: HostAccumulator, now: Date): ResolvedAgentHost {
diagnostics: uniqueSorted(Array.from(host.diagnostics)),
registeredAgentIds: uniqueSorted(host.registeredAgentIds),
workspaceRoots: uniqueSorted(host.workspaceRoots),
manifestRegistrations: host.manifestRegistrations,
};
}
@ -516,6 +644,10 @@ function resolvePosture(host: HostAccumulator, overloaded: boolean, now: Date):
return 'connected';
}
function registrationManifestIsCurrent(agent: RegisteredAgent, now: Date): boolean {
return registeredAgentIsLive(agent, now.getTime());
}
function summarizeHosts(
hosts: Pick<AgentHostRecord, 'posture' | 'overloaded'>[]
): AgentHostHealthResponse['summary'] {
@ -537,7 +669,11 @@ function summarizeHosts(
}
function stripInternalHostFields(host: ResolvedAgentHost): AgentHostRecord {
const { workspaceRoots: _workspaceRoots, ...publicHost } = host;
const {
workspaceRoots: _workspaceRoots,
manifestRegistrations: _manifestRegistrations,
...publicHost
} = host;
return publicHost;
}
@ -548,6 +684,7 @@ function normalizeRequest(request: AgentHostPreviewRequest): AgentHostPreviewReq
model: trimOptional(request.model),
workspacePath: trimOptional(request.workspacePath),
requiredTools: uniqueSorted(request.requiredTools ?? []),
requiredRuntimeCapabilities: uniqueSorted(request.requiredRuntimeCapabilities ?? []),
verificationGates: uniqueSorted(request.verificationGates ?? []),
sandboxPresetId: trimOptional(request.sandboxPresetId),
manualHostId: trimOptional(request.manualHostId),
@ -601,6 +738,12 @@ const SANDBOX_CAPABILITY_IDS = new Set<SandboxProviderCapabilityId>([
'credential.broker',
]);
function isSandboxCapability(
value: ProviderRuntimeCapabilityId
): value is SandboxProviderCapabilityId {
return SANDBOX_CAPABILITY_IDS.has(value as SandboxProviderCapabilityId);
}
function sandboxCapabilityArray(value: unknown): SandboxProviderCapabilityId[] {
return stringArray(value).filter((item): item is SandboxProviderCapabilityId =>
SANDBOX_CAPABILITY_IDS.has(item as SandboxProviderCapabilityId)
@ -683,6 +826,15 @@ function uniqueSorted(values: string[]): string[] {
);
}
function uniqueManifests(manifests: ProviderRuntimeManifest[]): ProviderRuntimeManifest[] {
const byDigest = new Map<string, ProviderRuntimeManifest>();
for (const manifest of manifests) byDigest.set(manifest.digest, manifest);
return Array.from(byDigest.values()).sort(
(left, right) =>
left.provider.localeCompare(right.provider) || left.digest.localeCompare(right.digest)
);
}
function normalize(value: string): string {
return value.trim().toLowerCase();
}

View file

@ -20,6 +20,8 @@ import {
} from '../storage/fs-helpers.js';
import { createLogger } from '../lib/logger.js';
import { getRuntimeDir } from '../utils/paths.js';
import type { ProviderRuntimeManifest } from '@veritas-kanban/shared';
import { parseProviderRuntimeManifest } from '../schemas/provider-runtime-manifest-schemas.js';
const log = createLogger('agent-registry');
@ -47,6 +49,8 @@ export interface RegisteredAgent {
version?: string;
/** Freeform metadata */
metadata?: Record<string, unknown>;
/** Validated provider runtime evidence used for capability decisions. */
providerRuntimeManifest?: ProviderRuntimeManifest;
/** Current status */
status: 'online' | 'busy' | 'idle' | 'offline';
/** ISO timestamp of registration */
@ -69,6 +73,7 @@ export interface AgentRegistration {
capabilities?: AgentCapability[];
version?: string;
metadata?: Record<string, unknown>;
providerRuntimeManifest?: ProviderRuntimeManifest;
sessionKey?: string;
}
@ -77,6 +82,7 @@ export interface AgentHeartbeat {
currentTaskId?: string;
currentTaskTitle?: string;
metadata?: Record<string, unknown>;
providerRuntimeManifest?: ProviderRuntimeManifest;
}
export interface AgentRegistryData {
@ -128,7 +134,13 @@ export interface TaskSyncSnapshot {
// ─── Configuration ───────────────────────────────────────────────
/** How long before an agent is considered offline (no heartbeat) */
const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const AGENT_HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export function registeredAgentIsLive(agent: RegisteredAgent, now = Date.now()): boolean {
if (agent.status === 'offline') return false;
const lastHeartbeat = Date.parse(agent.lastHeartbeat);
return Number.isFinite(lastHeartbeat) && now - lastHeartbeat <= AGENT_HEARTBEAT_TIMEOUT_MS;
}
/** How often to check for stale agents */
const STALE_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute
@ -200,6 +212,9 @@ class AgentRegistryService {
register(registration: AgentRegistration): RegisteredAgent {
const existing = this.agents.get(registration.id);
const now = new Date().toISOString();
const runtimeIdentityChanged = existing
? registrationChangesRuntimeIdentity(registration, existing)
: false;
const agent: RegisteredAgent = {
id: registration.id,
@ -209,6 +224,12 @@ class AgentRegistryService {
capabilities: registration.capabilities ?? existing?.capabilities ?? [],
version: registration.version ?? existing?.version,
metadata: registration.metadata ?? existing?.metadata,
providerRuntimeManifest:
registration.providerRuntimeManifest !== undefined
? immutableManifest(parseProviderRuntimeManifest(registration.providerRuntimeManifest))
: runtimeIdentityChanged
? undefined
: existing?.providerRuntimeManifest,
sessionKey: registration.sessionKey ?? existing?.sessionKey,
status: 'online',
registeredAt: existing?.registeredAt ?? now,
@ -244,6 +265,11 @@ class AgentRegistryService {
if (update?.currentTaskTitle !== undefined)
agent.currentTaskTitle = update.currentTaskTitle || undefined;
if (update?.metadata) agent.metadata = { ...agent.metadata, ...update.metadata };
if (update?.providerRuntimeManifest !== undefined) {
agent.providerRuntimeManifest = immutableManifest(
parseProviderRuntimeManifest(update.providerRuntimeManifest)
);
}
this.agents.set(agentId, agent);
this.persist();
@ -527,8 +553,7 @@ class AgentRegistryService {
for (const agent of this.agents.values()) {
if (agent.status === 'offline') continue;
const lastBeat = new Date(agent.lastHeartbeat).getTime();
if (now - lastBeat > HEARTBEAT_TIMEOUT_MS) {
if (!registeredAgentIsLive(agent, now)) {
agent.status = 'offline';
changed = true;
log.info(
@ -579,7 +604,13 @@ class AgentRegistryService {
const data = JSON.parse(raw) as AgentRegistryData;
if (data.agents) {
for (const [id, agent] of Object.entries(data.agents)) {
this.agents.set(id, agent);
const parsedManifest = agent.providerRuntimeManifest
? safePersistedManifest(agent.providerRuntimeManifest, id)
: undefined;
this.agents.set(id, {
...agent,
providerRuntimeManifest: parsedManifest,
});
}
log.info({ count: this.agents.size }, 'Agent registry loaded from disk');
}
@ -663,6 +694,56 @@ class AgentRegistryService {
}
}
function safePersistedManifest(
manifest: unknown,
agentId: string
): ProviderRuntimeManifest | undefined {
try {
return immutableManifest(parseProviderRuntimeManifest(manifest));
} catch (error) {
log.warn(
{ agentId, error },
'Ignored invalid persisted provider runtime manifest; capability routing will fail closed'
);
return undefined;
}
}
function registrationChangesRuntimeIdentity(
registration: AgentRegistration,
existing: RegisteredAgent
): boolean {
return (
suppliedValueChanged(registration.provider, existing.provider, true) ||
suppliedValueChanged(registration.model, existing.model, true) ||
suppliedValueChanged(registration.version, existing.version, false)
);
}
function suppliedValueChanged(
next: string | undefined,
current: string | undefined,
caseInsensitive: boolean
): boolean {
if (next === undefined) return false;
const normalizedNext = next.trim();
const normalizedCurrent = current?.trim() ?? '';
return caseInsensitive
? normalizedNext.toLowerCase() !== normalizedCurrent.toLowerCase()
: normalizedNext !== normalizedCurrent;
}
function immutableManifest(manifest: ProviderRuntimeManifest): ProviderRuntimeManifest {
return deepFreeze(structuredClone(manifest));
}
function deepFreeze<T>(value: T): T {
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const child of Object.values(value as Record<string, unknown>)) deepFreeze(child);
return value;
}
// Singleton
let instance: AgentRegistryService | null = null;

View file

@ -19,6 +19,9 @@ import {
type RoutingRule,
type RoutingResult,
type RoutingMatchCriteria,
type ProviderRuntimeCapabilityId,
type ProviderRuntimeRouteCandidate,
type ProviderRuntimeSelection,
} from '@veritas-kanban/shared';
import type { Task, AgentType } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
@ -29,6 +32,12 @@ import {
type AgentHealthStatus,
} from './agent-health-service.js';
import { TeamRosterService } from './team-roster-service.js';
import {
getAgentRegistryService,
registeredAgentIsLive,
type RegisteredAgent,
} from './agent-registry-service.js';
import { selectProviderRuntimeManifest } from './provider-runtime-capability-service.js';
const log = createLogger('agent-routing');
@ -36,6 +45,16 @@ type RoutableTask = Pick<Task, 'type' | 'priority' | 'project' | 'subtasks'>;
interface RoutingTraceContext {
taskId?: string;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
}
interface RuntimeManifestRegistryReader {
list(): RegisteredAgent[];
}
interface RuntimeIdentityResolution {
registrations: RegisteredAgent[];
rejectionReason?: string;
}
interface AgentAvailability {
@ -43,17 +62,24 @@ interface AgentAvailability {
health?: AgentHealthStatus;
available: boolean;
reason: string;
runtimeSelection?: ProviderRuntimeSelection;
}
export class AgentRoutingService {
private configService: ConfigService;
private agentHealth: AgentHealthChecker;
private teamRoster: TeamRosterService;
private runtimeRegistry?: RuntimeManifestRegistryReader;
constructor(configService?: ConfigService, agentHealth?: AgentHealthChecker) {
constructor(
configService?: ConfigService,
agentHealth?: AgentHealthChecker,
runtimeRegistry?: RuntimeManifestRegistryReader
) {
this.configService = configService || new ConfigService();
this.agentHealth = agentHealth || new AgentHealthService();
this.teamRoster = new TeamRosterService(this.configService);
this.runtimeRegistry = runtimeRegistry;
}
/**
@ -72,6 +98,10 @@ export class AgentRoutingService {
): Promise<{ result: RoutingResult; trace: CreateGovernanceTraceInput }> {
const config = await this.configService.getConfig();
const routing: AgentRoutingConfig = config.agentRouting || DEFAULT_ROUTING_CONFIG;
const requiredRuntimeCapabilities = uniqueRuntimeCapabilities(
context.requiredRuntimeCapabilities ?? []
);
const runtimeCandidates: ProviderRuntimeRouteCandidate[] = [];
const evaluatedRules: GovernanceTraceRule[] = [];
const steps: GovernanceTraceStep[] = [];
const rosterPreview = this.teamRoster.resolveRoute(
@ -85,7 +115,21 @@ export class AgentRoutingService {
);
if (rosterPreview.matched && rosterPreview.agent) {
const availability = await this.getAgentAvailability(config.agents, rosterPreview.agent);
const profile = config.agentProfiles?.find(
(candidate) => candidate.id === rosterPreview.profileId
);
const availability = await this.getAgentAvailability(
config.agents,
rosterPreview.agent,
requiredRuntimeCapabilities,
profile?.runtime.model
);
recordRuntimeCandidate(
runtimeCandidates,
rosterPreview.agent,
profile?.runtime.model,
availability
);
const ruleId = rosterPreview.ruleId ?? rosterPreview.member?.id ?? 'default';
const traceRule: GovernanceTraceRule = {
id: `team-roster:${ruleId}`,
@ -111,14 +155,14 @@ export class AgentRoutingService {
});
if (availability.available) {
const profile = config.agentProfiles?.find(
(candidate) => candidate.id === rosterPreview.profileId
);
markLastRuntimeCandidateSelected(runtimeCandidates);
const result: RoutingResult = {
agent: rosterPreview.agent,
model: profile?.runtime.model ?? availability.agentConfig?.model,
rule: traceRule.id,
reason: rosterPreview.reason,
reason: withRuntimeReason(rosterPreview.reason, availability.runtimeSelection),
runtimeSelection: availability.runtimeSelection,
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
};
return {
result,
@ -143,11 +187,24 @@ export class AgentRoutingService {
// If routing is disabled, return the global default
if (!routing.enabled) {
const defaultAgent = routing.defaultAgent || config.defaultAgent;
const defaultAvailability = await this.getAgentAvailability(config.agents, defaultAgent);
const defaultAvailability = await this.getAgentAvailability(
config.agents,
defaultAgent,
requiredRuntimeCapabilities,
routing.defaultModel
);
recordRuntimeCandidate(
runtimeCandidates,
defaultAgent,
routing.defaultModel,
defaultAvailability
);
if (!defaultAvailability.available) {
throw new ConflictError('No healthy agent available for routing', {
agent: defaultAgent,
reason: defaultAvailability.reason,
runtimeSelection: defaultAvailability.runtimeSelection,
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
routingEnabled: routing.enabled,
});
}
@ -155,8 +212,14 @@ export class AgentRoutingService {
const result: RoutingResult = {
agent: defaultAgent,
model: routing.defaultModel,
reason: 'Routing disabled, using default agent',
reason: withRuntimeReason(
'Routing disabled, using default agent',
defaultAvailability.runtimeSelection
),
runtimeSelection: defaultAvailability.runtimeSelection,
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
};
markLastRuntimeCandidateSelected(runtimeCandidates);
return {
result,
trace: this.buildRoutingTrace(task, context, result, {
@ -184,7 +247,13 @@ export class AgentRoutingService {
}
if (this.matchesRule(task, rule.match)) {
const availability = await this.getAgentAvailability(config.agents, rule.agent);
const availability = await this.getAgentAvailability(
config.agents,
rule.agent,
requiredRuntimeCapabilities,
rule.model
);
recordRuntimeCandidate(runtimeCandidates, rule.agent, rule.model, availability);
if (!availability.available) {
const message = `Rule "${rule.name}" matched but agent "${rule.agent}" is unavailable: ${availability.reason}.`;
log.warn(message);
@ -200,14 +269,24 @@ export class AgentRoutingService {
if (routing.fallbackOnFailure && rule.fallback) {
const fallbackAvailability = await this.getAgentAvailability(
config.agents,
rule.fallback
rule.fallback,
requiredRuntimeCapabilities
);
recordRuntimeCandidate(
runtimeCandidates,
rule.fallback,
fallbackAvailability.agentConfig?.model,
fallbackAvailability
);
if (fallbackAvailability.available) {
markLastRuntimeCandidateSelected(runtimeCandidates);
const reason = `${message} Using fallback agent "${rule.fallback}".`;
const result: RoutingResult = {
agent: rule.fallback,
rule: rule.id,
reason,
reason: withRuntimeReason(reason, fallbackAvailability.runtimeSelection),
runtimeSelection: fallbackAvailability.runtimeSelection,
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
};
return {
result,
@ -257,8 +336,11 @@ export class AgentRoutingService {
model: rule.model,
fallback: rule.fallback,
rule: rule.id,
reason: `Matched rule: ${rule.name}`,
reason: withRuntimeReason(`Matched rule: ${rule.name}`, availability.runtimeSelection),
runtimeSelection: availability.runtimeSelection,
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
};
markLastRuntimeCandidateSelected(runtimeCandidates);
return {
result,
trace: this.buildRoutingTrace(task, context, result, {
@ -293,7 +375,13 @@ export class AgentRoutingService {
model: routing.defaultModel,
reason: 'No routing rules matched, using default agent',
};
const defaultAvailability = await this.getAgentAvailability(config.agents, result.agent);
const defaultAvailability = await this.getAgentAvailability(
config.agents,
result.agent,
requiredRuntimeCapabilities,
result.model
);
recordRuntimeCandidate(runtimeCandidates, result.agent, result.model, defaultAvailability);
if (!defaultAvailability.available) {
const message = `Default agent "${result.agent}" is unavailable: ${defaultAvailability.reason}.`;
steps.push({
@ -305,9 +393,15 @@ export class AgentRoutingService {
throw new ConflictError('No healthy agent available for routing', {
agent: result.agent,
reason: defaultAvailability.reason,
runtimeSelection: defaultAvailability.runtimeSelection,
runtimeCandidates: optionalRuntimeCandidates(runtimeCandidates),
routingEnabled: routing.enabled,
});
}
result.reason = withRuntimeReason(result.reason, defaultAvailability.runtimeSelection);
result.runtimeSelection = defaultAvailability.runtimeSelection;
markLastRuntimeCandidateSelected(runtimeCandidates);
result.runtimeCandidates = optionalRuntimeCandidates(runtimeCandidates);
return {
result,
@ -418,7 +512,9 @@ export class AgentRoutingService {
private async getAgentAvailability(
agents: AgentConfig[],
agent: AgentType
agent: AgentType,
requiredRuntimeCapabilities: ProviderRuntimeCapabilityId[] = [],
model?: string
): Promise<AgentAvailability> {
const agentConfig = agents.find((candidate) => candidate.type === agent);
if (!agentConfig) {
@ -436,6 +532,19 @@ export class AgentRoutingService {
};
}
const runtimeSelection =
requiredRuntimeCapabilities.length > 0
? this.selectAgentRuntimeManifest(agentConfig, requiredRuntimeCapabilities, model)
: undefined;
if (runtimeSelection && !runtimeSelection.compatible) {
return {
agentConfig,
available: false,
reason: runtimeSelection.reason,
runtimeSelection,
};
}
const health = await this.agentHealth.checkAgent(agentConfig);
if (!health.healthy) {
return {
@ -443,6 +552,7 @@ export class AgentRoutingService {
health,
available: false,
reason: health.reason || 'Agent health check failed',
runtimeSelection,
};
}
@ -450,10 +560,36 @@ export class AgentRoutingService {
agentConfig,
health,
available: true,
reason: 'Agent is healthy',
reason: withRuntimeReason('Agent is healthy', runtimeSelection),
runtimeSelection,
};
}
private selectAgentRuntimeManifest(
agentConfig: AgentConfig,
requiredCapabilities: ProviderRuntimeCapabilityId[],
model?: string
): ProviderRuntimeSelection {
const registeredAgents = (this.runtimeRegistry ?? getAgentRegistryService()).list();
const identity = resolveRegisteredRuntimeIdentity(registeredAgents, agentConfig);
const selection = selectProviderRuntimeManifest({
manifests: identity.registrations
.map((registered) => registered.providerRuntimeManifest)
.filter((manifest) => manifest !== undefined),
provider: agentConfig.provider === 'custom' ? undefined : agentConfig.provider,
model: model ?? agentConfig.model,
requiredCapabilities,
});
return identity.rejectionReason
? {
...selection,
compatible: false,
selectedManifest: undefined,
reason: identity.rejectionReason,
}
: selection;
}
/**
* Check if a task matches a rule's criteria.
* All specified criteria must match (AND logic).
@ -558,6 +694,92 @@ export class AgentRoutingService {
}
}
function resolveRegisteredRuntimeIdentity(
registeredAgents: RegisteredAgent[],
agentConfig: AgentConfig
): RuntimeIdentityResolution {
const exactId = registeredAgents.find((registered) => registered.id === agentConfig.type);
if (exactId) {
return registeredAgentIsLive(exactId)
? { registrations: [exactId] }
: {
registrations: [],
rejectionReason: `Registered agent "${exactId.id}" is offline or outside the five-minute heartbeat window.`,
};
}
const nameMatches = registeredAgents.filter(
(registered) =>
normalizeRuntimeValue(registered.name) === normalizeRuntimeValue(agentConfig.name)
);
if (nameMatches.length === 0) {
return {
registrations: [],
rejectionReason: `No registry identity matches configured agent "${agentConfig.type}".`,
};
}
if (nameMatches.length > 1) {
return {
registrations: [],
rejectionReason: `Multiple registry identities match configured agent name "${agentConfig.name}".`,
};
}
const nameMatch = nameMatches[0] as RegisteredAgent;
return registeredAgentIsLive(nameMatch)
? { registrations: [nameMatch] }
: {
registrations: [],
rejectionReason: `Registered agent "${nameMatch.id}" is offline or outside the five-minute heartbeat window.`,
};
}
function recordRuntimeCandidate(
candidates: ProviderRuntimeRouteCandidate[],
agent: AgentType,
model: string | undefined,
availability: AgentAvailability
): void {
if (!availability.runtimeSelection) return;
candidates.push({
agent,
...(model ? { model } : {}),
available: availability.available,
selected: false,
reason: availability.reason,
selection: availability.runtimeSelection,
});
}
function markLastRuntimeCandidateSelected(candidates: ProviderRuntimeRouteCandidate[]): void {
const candidate = candidates.at(-1);
if (candidate) candidate.selected = true;
}
function optionalRuntimeCandidates(
candidates: ProviderRuntimeRouteCandidate[]
): ProviderRuntimeRouteCandidate[] | undefined {
return candidates.length > 0 ? candidates : undefined;
}
function uniqueRuntimeCapabilities(
capabilities: ProviderRuntimeCapabilityId[]
): ProviderRuntimeCapabilityId[] {
return Array.from(
new Set(capabilities.map((capability) => capability.trim()).filter(Boolean))
).sort((left, right) => left.localeCompare(right));
}
function withRuntimeReason(
reason: string,
runtimeSelection: ProviderRuntimeSelection | undefined
): string {
return runtimeSelection ? `${reason}. ${runtimeSelection.reason}` : reason;
}
function normalizeRuntimeValue(value: string): string {
return value.trim().toLowerCase();
}
// Singleton
let _instance: AgentRoutingService | null = null;

View file

@ -0,0 +1,150 @@
import type {
ProviderRuntimeCapabilityAssessment,
ProviderRuntimeCapabilityId,
ProviderRuntimeManifest,
ProviderRuntimeManifestAssessment,
ProviderRuntimeSelection,
} from '@veritas-kanban/shared';
export interface ProviderRuntimeSelectionRequest {
manifests: ProviderRuntimeManifest[];
provider?: string;
model?: string;
requiredCapabilities?: ProviderRuntimeCapabilityId[];
}
export function selectProviderRuntimeManifest(
request: ProviderRuntimeSelectionRequest
): ProviderRuntimeSelection {
const requiredCapabilities = uniqueSorted(request.requiredCapabilities ?? []);
const candidates = request.manifests
.map((manifest) => assessManifest(manifest, request, requiredCapabilities))
.sort(compareAssessments);
const selectedManifest = candidates.find((candidate) => candidate.compatible);
return {
requiredCapabilities,
compatible: selectedManifest !== undefined,
selectedManifest,
candidates,
reason: selectedManifest
? selectedManifest.advisory
? `Selected manifest ${selectedManifest.manifestDigest} with advisory capability evidence.`
: `Selected manifest ${selectedManifest.manifestDigest} with supported capability evidence.`
: candidates.length === 0
? 'No validated provider runtime manifest is registered.'
: 'No single validated provider runtime manifest satisfies the provider, model, and capability requirements.',
};
}
function assessManifest(
manifest: ProviderRuntimeManifest,
request: ProviderRuntimeSelectionRequest,
requiredCapabilities: ProviderRuntimeCapabilityId[]
): ProviderRuntimeManifestAssessment {
const reasons: string[] = [];
const warnings: string[] = [];
if (manifest.probe.state === 'failed') {
reasons.push('The manifest readiness probe failed.');
} else if (manifest.probe.state === 'degraded') {
warnings.push('The manifest readiness probe is degraded.');
}
if (
request.provider &&
normalize(request.provider) !== normalize(manifest.provider) &&
normalize(request.provider) !== normalize(manifest.adapter)
) {
reasons.push(`Provider ${request.provider} does not match this manifest.`);
}
if (
request.model &&
!manifest.models.some(
(candidate) => normalize(candidate) === normalize(request.model as string)
)
) {
reasons.push(`Model ${request.model} is not reported by this manifest.`);
}
const capabilities = requiredCapabilities.map((capabilityId) =>
assessCapability(manifest, capabilityId)
);
for (const capability of capabilities) {
if (!capability.satisfied) {
reasons.push(`${capability.id}: ${capability.reason}`);
} else if (capability.advisory) {
warnings.push(`${capability.id}: ${capability.reason}`);
}
}
return {
manifestDigest: manifest.digest,
provider: manifest.provider,
adapter: manifest.adapter,
providerVersion: manifest.providerVersion,
models: [...manifest.models],
probeState: manifest.probe.state,
compatible: reasons.length === 0,
advisory: warnings.length > 0,
capabilities,
reasons,
warnings,
};
}
function assessCapability(
manifest: ProviderRuntimeManifest,
capabilityId: ProviderRuntimeCapabilityId
): ProviderRuntimeCapabilityAssessment {
const evidence = manifest.capabilities.find((capability) => capability.id === capabilityId);
if (!evidence) {
return {
id: capabilityId,
state: 'unknown',
satisfied: false,
advisory: false,
reason: 'No capability evidence is present.',
};
}
return {
id: capabilityId,
state: evidence.state,
satisfied: evidence.state === 'supported' || evidence.state === 'advisory',
advisory: evidence.state === 'advisory',
reason: evidence.reason,
};
}
function compareAssessments(
left: ProviderRuntimeManifestAssessment,
right: ProviderRuntimeManifestAssessment
): number {
if (left.compatible !== right.compatible) return left.compatible ? -1 : 1;
if (left.advisory !== right.advisory) return left.advisory ? 1 : -1;
if (left.probeState !== right.probeState) {
return PROBE_STATE_RANK[left.probeState] - PROBE_STATE_RANK[right.probeState];
}
return (
left.provider.localeCompare(right.provider) ||
left.manifestDigest.localeCompare(right.manifestDigest)
);
}
const PROBE_STATE_RANK: Record<ProviderRuntimeManifestAssessment['probeState'], number> = {
ready: 0,
degraded: 1,
failed: 2,
};
function uniqueSorted(values: ProviderRuntimeCapabilityId[]): ProviderRuntimeCapabilityId[] {
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).sort((a, b) =>
a.localeCompare(b)
);
}
function normalize(value: string): string {
return value.trim().toLowerCase();
}

View file

@ -11,6 +11,7 @@ import {
} from '@veritas-kanban/shared';
import { parseProviderRuntimeManifest } from '../schemas/provider-runtime-manifest-schemas.js';
import { calculateProviderRuntimeManifestDigest } from '../utils/provider-runtime-manifest-digest.js';
import { sanitizeProviderRuntimeDiagnostic } from '../utils/provider-runtime-manifest-sanitize.js';
export {
calculateProviderRuntimeManifestDigest,
@ -22,12 +23,6 @@ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
const DEFAULT_CONFORMANCE_PROBE_TIMEOUT_MS = 5_000;
const MAX_DIAGNOSTIC_BYTES = 8 * 1024;
const SECRET_PATTERNS: Array<[RegExp, string]> = [
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
[/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}/gi, '[REDACTED]'],
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
];
export interface ProviderRuntimeIdentityEvidence {
providerVersion?: string;
providerBuild?: string;
@ -239,7 +234,7 @@ export class ProviderRuntimeManifestService {
probe: {
state: probeState,
probedAt,
source: request.identity.source,
source: sanitizeProviderRuntimeDiagnostic(request.identity.source),
diagnostics: sanitizeDiagnostics(diagnostics),
},
};
@ -305,11 +300,7 @@ function sanitizeDiagnostics(values: string[]): string[] {
}
function sanitizeDiagnostic(value: string): string {
let sanitized = value.trim().replace(/\s+/g, ' ');
for (const [pattern, replacement] of SECRET_PATTERNS) {
sanitized = sanitized.replace(pattern, replacement);
}
return sanitized.slice(0, 1000);
return sanitizeProviderRuntimeDiagnostic(value);
}
function immutableClone<T>(value: T): T {

View file

@ -0,0 +1,22 @@
const SECRET_PATTERNS: Array<[RegExp, string]> = [
[/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
[/\b(?:sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}/gi, '[REDACTED]'],
[/\b(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*([^\s"'`,}]+)/gi, '$1=[REDACTED]'],
];
export function sanitizeProviderRuntimeDiagnostic(value: string): string {
let sanitized = normalizeProviderRuntimeDiagnostic(value);
for (const [pattern, replacement] of SECRET_PATTERNS) {
sanitized = sanitized.replace(pattern, replacement);
}
return sanitized.slice(0, 1000);
}
export function containsUnredactedProviderRuntimeSecret(value: string): boolean {
const normalized = normalizeProviderRuntimeDiagnostic(value).slice(0, 1000);
return sanitizeProviderRuntimeDiagnostic(value) !== normalized;
}
function normalizeProviderRuntimeDiagnostic(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}

View file

@ -1,20 +1,24 @@
import type { SandboxProviderCapabilityId } from './sandbox-policy.types.js';
import type {
ProviderRuntimeCapabilityId,
ProviderRuntimeManifest,
ProviderRuntimeSelection,
} from './provider-runtime.types.js';
export type AgentHostPosture =
| 'connected'
| 'stale'
| 'degraded'
| 'disconnected'
| 'risky'
| 'unknown';
'connected' | 'stale' | 'degraded' | 'disconnected' | 'risky' | 'unknown';
export type AgentHostAuthState = 'authenticated' | 'unauthenticated' | 'not-required' | 'unknown';
export type AgentHostRoutingPolicy =
| 'manual'
| 'project-default'
| 'first-capable-healthy'
| 'disabled';
'manual' | 'project-default' | 'first-capable-healthy' | 'disabled';
export interface AgentHostLegacyRuntimePosture {
providers: string[];
models: string[];
tools: string[];
sandboxCapabilities: SandboxProviderCapabilityId[];
}
export interface AgentHostRecord {
id: string;
@ -28,6 +32,8 @@ export interface AgentHostRecord {
supportedModels: string[];
supportedTools: string[];
sandboxCapabilities: SandboxProviderCapabilityId[];
providerRuntimeManifests: ProviderRuntimeManifest[];
legacyRuntimePosture: AgentHostLegacyRuntimePosture;
workspaceLabels: string[];
activeSessions: number;
queueDepth: number;
@ -56,6 +62,7 @@ export type AgentHostCompatibilityCheckId =
| 'model-supported'
| 'agent-supported'
| 'required-tools'
| 'runtime-capabilities'
| 'sandbox-policy'
| 'verification-gates';
@ -72,6 +79,7 @@ export interface AgentHostCompatibilityPreview {
posture: AgentHostPosture;
compatible: boolean;
checks: AgentHostCompatibilityCheck[];
runtimeSelection?: ProviderRuntimeSelection;
reasons: string[];
warnings: string[];
}
@ -82,6 +90,7 @@ export interface AgentHostPreviewRequest {
model?: string;
workspacePath?: string;
requiredTools?: string[];
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
verificationGates?: string[];
sandboxPresetId?: string;
manualHostId?: string;

View file

@ -50,6 +50,8 @@ export interface AgentRegistrationInput {
version?: string;
/** Optional metadata (environment, host, session label, etc.). */
metadata?: Record<string, unknown>;
/** Validated provider runtime evidence used for routing and enforcement. */
providerRuntimeManifest?: import('./provider-runtime.types.js').ProviderRuntimeManifest;
}
/** A registered agent record (stored in the registry). */
@ -82,6 +84,8 @@ export interface AgentHeartbeatInput {
capabilities?: AgentCapability[];
/** Arbitrary metadata update (merged with existing). */
metadata?: Record<string, unknown>;
/** Refreshed provider runtime evidence, if the provider identity changed. */
providerRuntimeManifest?: import('./provider-runtime.types.js').ProviderRuntimeManifest;
}
// ─── Sessions ────────────────────────────────────────────────────

View file

@ -96,6 +96,8 @@ export interface RoutingResult {
fallback?: AgentType;
rule?: string; // ID of matched rule (undefined = default)
reason: string; // Human-readable explanation
runtimeSelection?: import('./provider-runtime.types.js').ProviderRuntimeSelection;
runtimeCandidates?: import('./provider-runtime.types.js').ProviderRuntimeRouteCandidate[];
}
/** Default routing config */

View file

@ -87,6 +87,45 @@ export interface ProviderRuntimeManifest {
digest: string;
}
export interface ProviderRuntimeCapabilityAssessment {
id: ProviderRuntimeCapabilityId;
state: ProviderRuntimeCapabilityState;
satisfied: boolean;
advisory: boolean;
reason: string;
}
export interface ProviderRuntimeManifestAssessment {
manifestDigest: string;
provider: string;
adapter: string;
providerVersion: string;
models: string[];
probeState: ProviderRuntimeProbeState;
compatible: boolean;
advisory: boolean;
capabilities: ProviderRuntimeCapabilityAssessment[];
reasons: string[];
warnings: string[];
}
export interface ProviderRuntimeSelection {
requiredCapabilities: ProviderRuntimeCapabilityId[];
compatible: boolean;
selectedManifest?: ProviderRuntimeManifestAssessment;
candidates: ProviderRuntimeManifestAssessment[];
reason: string;
}
export interface ProviderRuntimeRouteCandidate {
agent: string;
model?: string;
available: boolean;
selected: boolean;
reason: string;
selection: ProviderRuntimeSelection;
}
export function findProviderRuntimeCapability(
manifest: ProviderRuntimeManifest,
capabilityId: ProviderRuntimeCapabilityId

View file

@ -280,8 +280,15 @@ vi.mock('@/hooks/useAgent', () => ({
supportedAgents: ['codex'],
supportedProviders: ['codex-cli'],
supportedModels: ['gpt-5'],
supportedTools: ['code'],
supportedTools: ['tool.calls'],
sandboxCapabilities: ['filesystem.read', 'filesystem.write', 'environment.allowlist'],
providerRuntimeManifests: [],
legacyRuntimePosture: {
providers: ['codex-cli'],
models: ['gpt-5'],
tools: ['code'],
sandboxCapabilities: [],
},
workspaceLabels: ['workspace:veritas-kanban'],
activeSessions: 0,
queueDepth: 0,

View file

@ -1017,6 +1017,10 @@ function AgentHostItem({ host }: { host: AgentHostRecord }) {
</div>
<div className="flex flex-wrap gap-1">
<Badge size="xs" variant="light" color="blue">
{host.providerRuntimeManifests.length} validated manifest
{host.providerRuntimeManifests.length === 1 ? '' : 's'}
</Badge>
{host.supportedAgents.slice(0, 4).map((agent) => (
<Badge key={agent} size="xs" variant="outline" color="gray">
{agent}

View file

@ -3,7 +3,11 @@
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { routingApi } from '@/lib/api/agent';
import type { AgentRoutingConfig, RoutingResult } from '@veritas-kanban/shared';
import type {
AgentRoutingConfig,
ProviderRuntimeCapabilityId,
RoutingResult,
} from '@veritas-kanban/shared';
/** Fetch the current routing config */
export function useRoutingConfig() {
@ -26,10 +30,13 @@ export function useUpdateRoutingConfig() {
}
/** Resolve agent for an existing task */
export function useResolveAgent(taskId: string | undefined) {
export function useResolveAgent(
taskId: string | undefined,
requiredRuntimeCapabilities: ProviderRuntimeCapabilityId[] = []
) {
return useQuery<RoutingResult>({
queryKey: ['routing-resolve', taskId],
queryFn: () => routingApi.resolveForTask(taskId!),
queryKey: ['routing-resolve', taskId, requiredRuntimeCapabilities],
queryFn: () => routingApi.resolveForTask(taskId!, requiredRuntimeCapabilities),
enabled: !!taskId,
staleTime: 30_000,
});
@ -42,6 +49,7 @@ export function useResolveAgentForMetadata(
priority?: string;
project?: string;
subtaskCount?: number;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
} | null
) {
return useQuery<RoutingResult>({

View file

@ -9,6 +9,8 @@ import type {
AgentRoutingConfig,
RoutingResult,
AgentBudgetPolicy,
ProviderRuntimeManifest,
ProviderRuntimeCapabilityId,
} from '@veritas-kanban/shared';
import { API_BASE, apiFetch } from './helpers';
@ -116,6 +118,7 @@ export interface RegisteredAgent {
provider?: string;
capabilities?: Array<{ name: string; description?: string }>;
version?: string;
providerRuntimeManifest?: ProviderRuntimeManifest;
status: 'online' | 'offline' | 'busy' | 'idle';
currentTask?: string;
currentTaskTitle?: string;
@ -169,11 +172,14 @@ export const routingApi = {
},
/** Resolve the best agent for a task */
resolveForTask: async (taskId: string): Promise<RoutingResult> => {
resolveForTask: async (
taskId: string,
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[]
): Promise<RoutingResult> => {
return apiFetch<RoutingResult>(`${API_BASE}/agents/route`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId }),
body: JSON.stringify({ taskId, requiredRuntimeCapabilities }),
});
},
@ -183,6 +189,7 @@ export const routingApi = {
priority?: string;
project?: string;
subtaskCount?: number;
requiredRuntimeCapabilities?: ProviderRuntimeCapabilityId[];
}): Promise<RoutingResult> => {
return apiFetch<RoutingResult>(`${API_BASE}/agents/route`, {
method: 'POST',