diff --git a/AGENTS.md b/AGENTS.md index f8f756a1..32dd00ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,11 @@ Do not run `npm install`, `yarn`, or `bun install`. If lockfile conflicts arise, mediated invocation issues exact-action leases using the server-owned launch manifest digest. Credential-bound sessions are one-shot and raw values may exist only inside the controlled downstream dispatch callback. +- Providers access credential-bound tools only through the system-owned + `veritas-run` MCP bridge and an opaque in-memory run handle. Codex CLI/SDK, + Codex app-server, Claude Code, and ACP stdio inject this shared contract; + Hermes and OpenClaw fail closed until their certified transports can enforce + it. - Classify launch credentials through `run-launch-credential-plan/v1`. Provider boot authentication, task integration definition IDs, and explicit high-risk environment passthrough are separate classes. Task integration diff --git a/CHANGELOG.md b/CHANGELOG.md index 22bb3ef3..8666ef5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added the system-owned `veritas-run` MCP bridge for credential-bound tools. + Opaque, in-memory authority binds the exact task, attempt, catalog, launch + manifest, and two allowed bridge methods. Codex CLI/SDK, Codex app-server, + Claude Code, and ACP stdio inject the same narrow bridge; Hermes and OpenClaw + fail closed before dispatch when a credential-bound catalog is selected + (#970). - Added exact-action credential lease consumption to mediated run-tool calls. The server binds each operation to the active launch manifest, catalog, server, tool, arguments digest, approval, and operation ID; resolves values @@ -38,8 +44,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 definitions, exact source targets, and immutable definition/scope digests. Discovery strips source environment and header values, native provider configuration omits credential-bound servers, covered launch references - report a tool-control-plane boundary, and uncovered, drifted, or invoked - references remain fail closed pending exact-action lease consumption (#968). + report a tool-control-plane boundary, and uncovered or drifted references + remain fail closed (#968). - Added `vk acp serve --stdio` and `vk acp status --json` to expose a task-bound, provider-neutral ACP v1 server view over Veritas-managed conversations. Fresh prompts retain the immutable task envelope, reconnect diff --git a/docs/AGENT-PROVIDERS.md b/docs/AGENT-PROVIDERS.md index 20fb94e4..e5fbbd9f 100644 --- a/docs/AGENT-PROVIDERS.md +++ b/docs/AGENT-PROVIDERS.md @@ -762,8 +762,12 @@ Credential-bound tool definitions compile only when enabled broker definitions, MCP scopes, source targets, and immutable catalog evidence match. They are omitted from native provider MCP configuration and provider environment passthrough. Mediated calls consume exact-action leases using the server-owned -launch-manifest digest and one-shot downstream sessions. System-owned provider -bridge injection remains under #970. +launch-manifest digest and one-shot downstream sessions. The system-owned +`veritas-run` MCP bridge exposes only catalog read and mediated call methods +through an opaque, in-memory run handle. Codex CLI/SDK, Codex app-server, +Claude Code, and ACP stdio inject it. Hermes and OpenClaw reject +credential-bound launches because their certified transports cannot yet +enforce the same bridge contract. Model-provider boot authentication and explicit `env-passthrough` compatibility remain separate, high-risk paths and are never labeled as brokered. See [Credential Broker](CREDENTIAL-BROKER.md). diff --git a/docs/API-REFERENCE.md b/docs/API-REFERENCE.md index 89c0d4e4..14efc9dd 100644 --- a/docs/API-REFERENCE.md +++ b/docs/API-REFERENCE.md @@ -3812,6 +3812,14 @@ MCP action and receive the active launch-manifest digest from the server, not the request body. Values are resolved only inside a one-shot downstream dispatch callback; replays, stale evidence, approval mismatch, unavailable sources, and credential-bearing results fail closed. + +Credential-bound provider runs receive a system-owned `veritas-run` stdio MCP +bridge. Its opaque handle is accepted only by `GET +/api/run-tool-bridge/catalog` and `POST /api/run-tool-bridge/call`. Those +dedicated routes derive task, attempt, catalog, and manifest identity from the +handle; callers cannot supply or override them. The handle grants no general +Veritas API authority and is rejected after terminal lifecycle cleanup, +expiration, or server restart. See [Tool Control Plane v1](architecture/TOOL-CONTROL-PLANE-V1.md). --- diff --git a/docs/CREDENTIAL-BROKER.md b/docs/CREDENTIAL-BROKER.md index 35b413da..96327749 100644 --- a/docs/CREDENTIAL-BROKER.md +++ b/docs/CREDENTIAL-BROKER.md @@ -19,9 +19,11 @@ The v6 foundation includes: The tool control plane compiles value-free credential boundary evidence into a run catalog and consumes leases only inside mediated tool calls. A handle in a -prompt or provider environment is still not a security boundary: -credential-bound native server injection remains omitted, and automatic -system-owned provider bridge injection is tracked by #970. +prompt or provider environment is not itself a credential boundary: +credential-bound native server injection remains omitted. A system-owned +`veritas-run` MCP bridge receives only an opaque, in-memory authority bound to +the exact task, attempt, catalog, launch manifest, and catalog/call methods. +The provider never receives the task credential value. ## Credential classes diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 1e810c26..3fee5873 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -377,6 +377,10 @@ persists an immutable `run-tool-catalog/v1` digest in the launch manifest. server-owned launch manifest, deliver values only inside one-shot downstream sessions, and reject replay, drift, approval mismatch, source failure, or credential-bearing results. +- Credential-bound runs inject the same narrow `veritas-run` MCP bridge into + Codex CLI/SDK, Codex app-server, Claude Code, and ACP stdio. The bridge + carries an opaque run handle, never a task credential. Hermes and OpenClaw + fail closed until their certified transports can enforce this contract. See [Tool Control Plane v1](architecture/TOOL-CONTROL-PLANE-V1.md). diff --git a/docs/architecture/TOOL-CONTROL-PLANE-V1.md b/docs/architecture/TOOL-CONTROL-PLANE-V1.md index db31ea8a..fa79d1ed 100644 --- a/docs/architecture/TOOL-CONTROL-PLANE-V1.md +++ b/docs/architecture/TOOL-CONTROL-PLANE-V1.md @@ -156,11 +156,25 @@ Approval-required definitions reuse the durable approval for the same operation and credential-action fingerprint. Replayed operations, caller manifest overrides, stale run bindings, changed definitions/scopes, mismatched approvals, unavailable sources, and credential-bearing results fail closed. -System-owned provider bridge injection remains under #970. + +The system-owned `veritas-run` stdio MCP bridge exposes only catalog read and +mediated call. Its opaque authority is held in memory and bound to the exact +task, attempt, catalog, launch manifest, expiry, and allowed bridge methods. +The dedicated HTTP surface derives run identity from that authority, so the +provider cannot choose another task, attempt, catalog, or manifest. + +Codex CLI/SDK, Codex app-server, Claude Code, and ACP stdio inject this same +bridge contract. Credential-bound native definitions remain omitted. Hermes +and OpenClaw fail closed at manifest compilation because their certified +transports cannot yet enforce system-owned bridge injection. Completion, +failure, interruption, cancellation, expiry, or restart revokes or rejects the +authority. ## Operator Surfaces - REST: `/api/v1/tool-servers` +- Run bridge: `/api/run-tool-bridge/catalog` and + `/api/run-tool-bridge/call` (opaque run authority only) - CLI: `vk tool-servers` (alias `vk tools`) - MCP: `list_tool_servers`, `discover_tool_server`, `get_run_tool_catalog`, and `call_run_tool` diff --git a/eslint.config.js b/eslint.config.js index 9f083d14..fe24978c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -77,15 +77,18 @@ export default [ }, }, - // Node release/maintenance scripts + // Node release/maintenance scripts and dependency-free runtime assets { - files: ['scripts/**/*.mjs'], + files: ['scripts/**/*.mjs', 'server/runtime/**/*.mjs'], languageOptions: { ecmaVersion: 2022, sourceType: 'module', globals: { + Buffer: 'readonly', console: 'readonly', + fetch: 'readonly', process: 'readonly', + URL: 'readonly', }, }, }, diff --git a/server/runtime/run-tool-bridge.mjs b/server/runtime/run-tool-bridge.mjs new file mode 100644 index 00000000..c2b9f417 --- /dev/null +++ b/server/runtime/run-tool-bridge.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import readline from 'node:readline'; +import { readFileSync } from 'node:fs'; + +const MAX_RECORD_BYTES = 1024 * 1024; +const PROTOCOL_VERSION = '2025-06-18'; +const HANDLE_PATTERN = /^vkbridge_[A-Za-z0-9_-]{32,}$/; +const SERVER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,79}$/; +const TOOL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,239}$/; +const OPERATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/; +const packageVersion = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') +).version; + +const tools = [ + { + name: 'get_run_tool_catalog', + description: 'Read the immutable tool catalog bound to this exact Veritas run', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: 'call_run_tool', + description: + 'Invoke an allowed tool through this run authority with policy, approval, credential, redaction, and causal event enforcement', + inputSchema: { + type: 'object', + properties: { + serverId: { type: 'string' }, + tool: { type: 'string' }, + arguments: { type: 'object', additionalProperties: true }, + operationId: { type: 'string' }, + approvalId: { type: 'string' }, + }, + required: ['serverId', 'tool', 'arguments', 'operationId'], + additionalProperties: false, + }, + }, +]; + +const handle = process.env.VK_RUN_TOOL_BRIDGE_HANDLE; +const apiUrl = normalizeApiUrl(process.env.VK_API_URL || 'http://localhost:3001'); +if (!HANDLE_PATTERN.test(handle || '')) { + throw new Error('Run tool bridge handle is missing or invalid.'); +} + +const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on('line', (line) => { + void accept(line); +}); +input.on('close', () => { + process.exitCode = 0; +}); + +async function accept(line) { + if (!line.trim()) return; + if (Buffer.byteLength(line, 'utf8') > MAX_RECORD_BYTES) { + writeError(null, -32600, 'MCP request exceeded the bounded record limit.'); + return; + } + let record; + try { + record = JSON.parse(line); + } catch { + writeError(null, -32700, 'MCP request was not valid JSON.'); + return; + } + if (!record || typeof record !== 'object' || Array.isArray(record)) { + writeError(null, -32600, 'MCP request must be a JSON object.'); + return; + } + if (!Object.hasOwn(record, 'id')) return; + try { + writeResult(record.id, await dispatch(record.method, record.params)); + } catch (error) { + writeError( + record.id, + error instanceof InvalidParamsError ? -32602 : -32603, + error instanceof Error ? error.message : 'Run tool bridge request failed.' + ); + } +} + +async function dispatch(method, params) { + if (method === 'initialize') { + return { + protocolVersion: PROTOCOL_VERSION, + capabilities: { tools: {} }, + serverInfo: { name: 'veritas-run-tools', version: packageVersion }, + }; + } + if (method === 'ping') return {}; + if (method === 'tools/list') { + assertEmptyObject(params); + return { tools }; + } + if (method === 'tools/call') { + const call = object(params, 'tools/call params'); + const name = string(call.name, 'tools/call name'); + if (!['get_run_tool_catalog', 'call_run_tool'].includes(name)) { + throw new InvalidParamsError(`Unknown run tool bridge action: ${name}`); + } + const argumentsValue = call.arguments ?? {}; + const content = + name === 'get_run_tool_catalog' + ? await request('/api/run-tool-bridge/catalog') + : await request('/api/run-tool-bridge/call', { + method: 'POST', + body: JSON.stringify(parseCall(argumentsValue)), + }); + return { + content: [{ type: 'text', text: JSON.stringify(content, null, 2) }], + }; + } + throw new InvalidParamsError(`Unsupported MCP method: ${String(method)}`); +} + +async function request(requestPath, options = {}) { + const response = await fetch(`${apiUrl}${requestPath}`, { + ...options, + headers: { + 'content-type': 'application/json', + 'x-vk-run-tool-bridge': handle, + ...options.headers, + }, + }); + const body = await response.json().catch(() => undefined); + if (!response.ok) { + throw new Error(apiError(body) || `Run tool bridge request failed (${response.status}).`); + } + if ( + body && + typeof body === 'object' && + !Array.isArray(body) && + body.success === true && + Object.hasOwn(body, 'data') + ) { + return body.data; + } + return body; +} + +function parseCall(value) { + const call = object(value, 'call_run_tool arguments'); + const serverId = string(call.serverId, 'serverId'); + const tool = string(call.tool, 'tool'); + const operationId = string(call.operationId, 'operationId'); + const argumentsValue = object(call.arguments, 'arguments'); + const approvalId = + call.approvalId === undefined ? undefined : string(call.approvalId, 'approvalId'); + if (!SERVER_ID_PATTERN.test(serverId)) throw new InvalidParamsError('serverId is invalid.'); + if (!TOOL_PATTERN.test(tool)) throw new InvalidParamsError('tool is invalid.'); + if (!OPERATION_PATTERN.test(operationId)) throw new InvalidParamsError('operationId is invalid.'); + if (approvalId && !OPERATION_PATTERN.test(approvalId)) { + throw new InvalidParamsError('approvalId is invalid.'); + } + const allowed = new Set(['serverId', 'tool', 'arguments', 'operationId', 'approvalId']); + if (Object.keys(call).some((key) => !allowed.has(key))) { + throw new InvalidParamsError('call_run_tool contains unsupported fields.'); + } + return { + serverId, + tool, + arguments: argumentsValue, + operationId, + ...(approvalId ? { approvalId } : {}), + }; +} + +function assertEmptyObject(value) { + const inputValue = value ?? {}; + if ( + !inputValue || + typeof inputValue !== 'object' || + Array.isArray(inputValue) || + Object.keys(inputValue).length > 0 + ) { + throw new InvalidParamsError('Expected an empty object.'); + } +} + +function object(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new InvalidParamsError(`${label} must be an object.`); + } + return value; +} + +function string(value, label) { + if (typeof value !== 'string' || !value.trim()) { + throw new InvalidParamsError(`${label} must be a non-empty string.`); + } + return value.trim(); +} + +function normalizeApiUrl(value) { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new Error('Run tool bridge API URL must use HTTPS or loopback HTTP.'); + } + url.pathname = url.pathname.replace(/\/+$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +function apiError(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const envelope = + value.error && typeof value.error === 'object' && !Array.isArray(value.error) + ? value.error + : undefined; + const message = + (typeof envelope?.message === 'string' && envelope.message) || + (typeof value.message === 'string' && value.message) || + (typeof value.error === 'string' && value.error); + if (!message) return undefined; + const details = envelope?.details ?? value.details; + return details === undefined ? message : `${message} ${JSON.stringify(details)}`; +} + +function writeResult(id, result) { + write({ jsonrpc: '2.0', id, result }); +} + +function writeError(id, code, message) { + write({ jsonrpc: '2.0', id, error: { code, message } }); +} + +function write(record) { + process.stdout.write(`${JSON.stringify(record)}\n`); +} + +class InvalidParamsError extends Error {} diff --git a/server/src/__tests__/run-launch-manifest-service.test.ts b/server/src/__tests__/run-launch-manifest-service.test.ts index 1f2824a0..d22a8bc8 100644 --- a/server/src/__tests__/run-launch-manifest-service.test.ts +++ b/server/src/__tests__/run-launch-manifest-service.test.ts @@ -449,6 +449,79 @@ describe('RunLaunchManifestService', () => { }); }); + it('fails closed when the selected provider cannot inject the run tool bridge', () => { + const hermesRuntime = providerRuntimeManifestFixture({ + provider: 'hermes-cli', + providerVersion: 'hermes 2026.7.7.2', + }); + const hermesEnvelope: TaskEnvelope = { + ...taskEnvelope, + launchManifest: { + schemaVersion: hermesRuntime.schemaVersion, + digest: hermesRuntime.digest, + provider: hermesRuntime.provider, + adapter: hermesRuntime.adapter, + protocolVersion: hermesRuntime.protocolVersion, + }, + }; + const catalogInput = { + ...brokeredCatalog(), + provider: 'hermes-cli' as const, + providerRuntimeManifestDigest: hermesRuntime.digest, + }; + const runToolCatalog = { + ...catalogInput, + digest: calculateRunToolCatalogDigest(catalogInput), + }; + const brokeredPolicy: SandboxPolicyDryRunResult = { + ...sandboxPolicy, + provider: 'hermes-cli', + preset: { + ...sandboxPolicy.preset, + credentials: { mode: 'brokered', brokerRefs: ['github-token'] }, + }, + effective: { + ...sandboxPolicy.effective, + credentialRefs: ['github-token'], + }, + }; + const manifest = new RunLaunchManifestService().compile( + input({ + taskEnvelope: hermesEnvelope, + providerRuntimeManifest: hermesRuntime, + harnessSupport: { + ...harnessSupport, + agentType: 'hermes', + profileId: 'hermes-cli', + adapterId: 'hermes-cli', + }, + sandboxPolicy: brokeredPolicy, + runToolCatalog, + tools: { + allowed: [], + denied: [], + policyIds: [], + mcpServers: ['github-tools'], + catalogDigest: runToolCatalog.digest, + enforcement: 'enforced', + }, + runtime: { + ...input().runtime, + command: 'hermes', + credentialReferences: ['github-token'], + }, + }) + ); + + expect(manifest.credentials?.brokerState).toBe('supported'); + expect(manifest.enforcement).toMatchObject({ + enforceable: false, + blockers: expect.arrayContaining([ + expect.objectContaining({ code: 'run-tool-bridge-unavailable' }), + ]), + }); + }); + it('fails closed for declared tools, MCP servers, permissions, and health checks without enforcement', () => { const manifest = new RunLaunchManifestService().compile( input({ diff --git a/server/src/__tests__/run-tool-bridge-runtime.test.ts b/server/src/__tests__/run-tool-bridge-runtime.test.ts new file mode 100644 index 00000000..e99c0806 --- /dev/null +++ b/server/src/__tests__/run-tool-bridge-runtime.test.ts @@ -0,0 +1,151 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createServer } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import path from 'node:path'; +import readline from 'node:readline'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const HANDLE = `vkbridge_${'h'.repeat(43)}`; +const runtimePath = fileURLToPath(new URL('../../runtime/run-tool-bridge.mjs', import.meta.url)); +const children: ChildProcessWithoutNullStreams[] = []; + +afterEach(() => { + for (const child of children.splice(0)) { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + } +}); + +describe('run tool bridge runtime', () => { + it('serves the narrow MCP contract and derives run identity from its opaque header', async () => { + const requests: Array<{ + method?: string; + url?: string; + header?: string; + body?: Record; + }> = []; + const api = createServer((request, response) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + requests.push({ + method: request.method, + url: request.url, + header: request.headers['x-vk-run-tool-bridge'] as string | undefined, + ...(body ? { body: JSON.parse(body) as Record } : {}), + }); + response.setHeader('content-type', 'application/json'); + response.end( + JSON.stringify( + request.url?.endsWith('/catalog') + ? { digest: 'catalog-digest' } + : { success: true, data: { operationId: 'operation-1', content: 'found' } } + ) + ); + }); + }); + await new Promise((resolve) => api.listen(0, '127.0.0.1', resolve)); + const port = (api.address() as AddressInfo).port; + const child = spawn(process.execPath, [runtimePath], { + cwd: path.dirname(runtimePath), + env: { + VK_API_URL: `http://127.0.0.1:${port}`, + VK_RUN_TOOL_BRIDGE_HANDLE: HANDLE, + }, + shell: false, + }); + children.push(child); + const output = readline.createInterface({ input: child.stdout, crlfDelay: Infinity }); + const lines: Array<(value: Record) => void> = []; + output.on('line', (line) => { + lines.shift()?.(JSON.parse(line) as Record); + }); + const rpc = (record: Record) => + new Promise>((resolve) => { + lines.push(resolve); + child.stdin.write(`${JSON.stringify(record)}\n`); + }); + + const initialized = await rpc({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: '2025-06-18' }, + }); + expect(initialized).toMatchObject({ + result: { + protocolVersion: '2025-06-18', + serverInfo: { name: 'veritas-run-tools' }, + }, + }); + const listed = await rpc({ + jsonrpc: '2.0', + id: 2, + method: 'tools/list', + params: {}, + }); + expect( + (listed.result as { tools: Array<{ name: string }> }).tools.map((tool) => tool.name) + ).toEqual(['get_run_tool_catalog', 'call_run_tool']); + + await rpc({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'get_run_tool_catalog', arguments: {} }, + }); + const called = await rpc({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'call_run_tool', + arguments: { + serverId: 'github-tools', + tool: 'search', + arguments: { query: 'roadmap' }, + operationId: 'operation-1', + }, + }, + }); + expect(called).toMatchObject({ + result: { + content: [ + { + type: 'text', + text: expect.stringContaining('operation-1'), + }, + ], + }, + }); + expect(requests).toEqual([ + { + method: 'GET', + url: '/api/run-tool-bridge/catalog', + header: HANDLE, + }, + { + method: 'POST', + url: '/api/run-tool-bridge/call', + header: HANDLE, + body: { + serverId: 'github-tools', + tool: 'search', + arguments: { query: 'roadmap' }, + operationId: 'operation-1', + }, + }, + ]); + expect(JSON.stringify(requests)).not.toContain('taskId'); + expect(JSON.stringify(requests)).not.toContain('attemptId'); + + child.stdin.end(); + await new Promise((resolve) => child.once('close', () => resolve())); + await new Promise((resolve, reject) => + api.close((error) => (error ? reject(error) : resolve())) + ); + }); +}); diff --git a/server/src/__tests__/run-tool-bridge-service.test.ts b/server/src/__tests__/run-tool-bridge-service.test.ts new file mode 100644 index 00000000..ff33dfc5 --- /dev/null +++ b/server/src/__tests__/run-tool-bridge-service.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import type { ExecutableAgentProvider, RunToolCatalog } from '@veritas-kanban/shared'; +import { + RUN_TOOL_BRIDGE_ENV_KEY, + RunToolBridgeService, + runToolBridgeSupport, +} from '../services/run-tool-bridge-service.js'; +import { buildSafeCodexEnv } from '../utils/codex-env.js'; + +const DIGEST = `sha256:${'a'.repeat(64)}`; +const HANDLE = `vkbridge_${'h'.repeat(43)}`; + +function service(now = new Date('2026-07-24T12:00:00.000Z')) { + return new RunToolBridgeService({ + now: () => now, + randomHandle: () => HANDLE, + ttlMs: 60_000, + entrypoint: '/opt/veritas/server/runtime/run-tool-bridge.mjs', + apiUrl: 'http://127.0.0.1:3001', + }); +} + +function binding() { + return { + taskId: 'task-970', + attemptId: 'attempt-970', + catalogDigest: DIGEST, + runLaunchManifestDigest: `sha256:${'b'.repeat(64)}`, + }; +} + +describe('RunToolBridgeService', () => { + it('binds opaque authority to the exact run evidence and allowed method', () => { + const bridge = service(); + const launch = bridge.issue(binding()); + + expect(bridge.authorize(launch.handle, 'catalog.read', binding())).toMatchObject({ + ...binding(), + handleId: expect.stringMatching(/^vkbridge_[a-f0-9]{16}$/), + allowedMethods: ['catalog.read', 'tool.call'], + }); + expect(() => bridge.authorize(launch.handle, 'tool.call', { taskId: 'task-other' })).toThrow( + /taskId does not match/ + ); + expect(() => + bridge.authorize(launch.handle, 'tool.call', { attemptId: 'attempt-other' }) + ).toThrow(/attemptId does not match/); + expect(() => + bridge.authorize(launch.handle, 'tool.call', { + catalogDigest: `sha256:${'c'.repeat(64)}`, + }) + ).toThrow(/catalogDigest does not match/); + }); + + it('rejects revoked, expired, and restart-stale handles', () => { + const bridge = service(); + const launch = bridge.issue(binding()); + expect(bridge.revokeRun(binding().taskId, binding().attemptId)).toBe(1); + expect(() => bridge.authorize(launch.handle, 'catalog.read')).toThrow(/stale or revoked/); + + let clock = new Date('2026-07-24T12:00:00.000Z'); + const expiring = new RunToolBridgeService({ + now: () => clock, + randomHandle: () => `vkbridge_${'e'.repeat(43)}`, + ttlMs: 1, + entrypoint: '/opt/veritas/server/runtime/run-tool-bridge.mjs', + apiUrl: 'https://veritas.example', + }); + const expiringLaunch = expiring.issue(binding()); + clock = new Date('2026-07-24T12:00:01.000Z'); + expect(() => expiring.authorize(expiringLaunch.handle, 'catalog.read')).toThrow(/expired/); + + const oldLaunch = service().issue(binding()); + expect(() => service().authorize(oldLaunch.handle, 'catalog.read')).toThrow(/stale or revoked/); + }); + + it('uses one value-free bridge contract across Codex, Claude Code, and ACP adapters', () => { + const bridge = service(); + const launch = bridge.issue(binding()); + const codex = bridge.codexConfig(launch); + const codexCli = bridge.codexCliOverride(launch); + const claude = bridge.claudeServer(launch); + const acp = bridge.acpServer(launch); + const environment = bridge.launchEnvironment( + buildSafeCodexEnv({ + OPENAI_API_KEY: 'provider-auth', + TASK_CREDENTIAL_TOKEN: 'task-credential-sensitive-value', + }), + launch + ); + const serialized = JSON.stringify({ codex, codexCli, claude, acp, environment }); + + expect(serialized).toContain(HANDLE); + expect(serialized).toContain(RUN_TOOL_BRIDGE_ENV_KEY); + expect(serialized).toContain('/opt/veritas/server/runtime/run-tool-bridge.mjs'); + expect(serialized).toContain('get_run_tool_catalog'); + expect(serialized).toContain('call_run_tool'); + expect(serialized).not.toContain('task-credential-sensitive-value'); + expect(codexCli).not.toContain(HANDLE); + }); + + it('publishes an explicit adapter table and fails closed for unverified injection', () => { + const expected: Record = { + 'codex-cli': true, + 'codex-sdk': true, + 'codex-app-server': true, + 'claude-code': true, + 'acp-stdio': true, + 'hermes-cli': false, + openclaw: false, + }; + + expect( + Object.fromEntries( + Object.keys(expected).map((provider) => [ + provider, + runToolBridgeSupport(provider as ExecutableAgentProvider).supported, + ]) + ) + ).toEqual(expected); + }); + + it('detects credential-bound catalogs and rejects insecure remote API URLs', () => { + const bridge = service(); + const catalog = { + entries: [{ status: 'ready', credentialBindings: [{ credentialReference: 'ref' }] }], + } as RunToolCatalog; + expect(bridge.requiresBridge(catalog)).toBe(true); + expect( + () => + new RunToolBridgeService({ + apiUrl: 'http://veritas.example', + entrypoint: '/opt/veritas/server/runtime/run-tool-bridge.mjs', + }) + ).toThrow(/HTTPS or loopback HTTP/); + }); +}); diff --git a/server/src/routes/run-tool-bridge.ts b/server/src/routes/run-tool-bridge.ts new file mode 100644 index 00000000..49af9df0 --- /dev/null +++ b/server/src/routes/run-tool-bridge.ts @@ -0,0 +1,87 @@ +import { Router, type Request, type Router as RouterType } from 'express'; +import type { ToolInvocationRequest } from '@veritas-kanban/shared'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { ConflictError, NotFoundError } from '../middleware/error-handler.js'; +import { validate, type ValidatedRequest } from '../middleware/validate.js'; +import { toolInvocationRequestSchema } from '../schemas/tool-control-plane-schemas.js'; +import { getRunToolBridgeService } from '../services/run-tool-bridge-service.js'; +import { getTaskService } from '../services/task-service.js'; +import { getToolControlPlaneService } from '../services/tool-control-plane-service.js'; + +const router: RouterType = Router(); +const bridge = getRunToolBridgeService(); +const tools = getToolControlPlaneService(); +const bridgeCallSchema = toolInvocationRequestSchema.omit({ + taskId: true, + attemptId: true, +}); + +function handle(req: Request): string | undefined { + const value = req.headers['x-vk-run-tool-bridge']; + return typeof value === 'string' ? value : undefined; +} + +router.get( + '/catalog', + asyncHandler(async (req, res) => { + const authority = bridge.authorize(handle(req), 'catalog.read'); + const { catalog } = await activeContext(authority); + res.json(catalog); + }) +); + +router.post( + '/call', + validate({ body: bridgeCallSchema }), + asyncHandler( + async ( + req: ValidatedRequest>, + res + ) => { + const authority = bridge.authorize(handle(req), 'tool.call'); + const input = req.validated.body as Omit; + const { task } = await activeContext(authority); + res.json( + await tools.invoke( + { + ...input, + taskId: authority.taskId, + attemptId: authority.attemptId, + }, + authority.handleId, + task.git?.worktreePath, + authority.runLaunchManifestDigest + ) + ); + } + ) +); + +async function activeContext(authority: ReturnType) { + const task = await getTaskService().getTask(authority.taskId); + if (!task) { + bridge.revokeRun(authority.taskId, authority.attemptId); + throw new NotFoundError('Run tool bridge task not found.'); + } + if (task.attempt?.id !== authority.attemptId || task.attempt.status !== 'running') { + bridge.revokeRun(authority.taskId, authority.attemptId); + throw new ConflictError('Run tool bridge authority does not match an active attempt.'); + } + const catalog = await tools + .getRunCatalog(authority.taskId, authority.attemptId) + .catch((error) => { + bridge.revokeRun(authority.taskId, authority.attemptId); + throw error; + }); + if ( + catalog.digest !== authority.catalogDigest || + task.attempt.runLaunchManifest?.digest !== authority.runLaunchManifestDigest || + task.attempt.runLaunchManifest.tools.catalogDigest !== authority.catalogDigest + ) { + bridge.revokeRun(authority.taskId, authority.attemptId); + throw new ConflictError('Run tool bridge launch evidence drifted from the active attempt.'); + } + return { task, catalog }; +} + +export { router as runToolBridgeRoutes }; diff --git a/server/src/server.ts b/server/src/server.ts index 46ecf75d..3708906a 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -81,6 +81,7 @@ import { startAfterInitialization } from './utils/startup-gate.js'; import { getCommunicationAdapterService } from './services/communication-adapter-service.js'; import { getRunEventJournalService } from './services/run-event-journal-service.js'; import { getToolControlPlaneService } from './services/tool-control-plane-service.js'; +import { runToolBridgeRoutes } from './routes/run-tool-bridge.js'; const log = createLogger('server'); @@ -417,6 +418,10 @@ app.use('/api', apiRateLimit); // Unauthenticated webhook routes (registered BEFORE authenticate middleware) app.use('/api/webhook', webhookN8nRouter); +// Opaque run-scoped authority. This route is intentionally outside broad API +// authentication and exposes only the immutable catalog and mediated tool call. +app.use('/api/run-tool-bridge', runToolBridgeRoutes); + // Apply authentication to all API routes (except /api/auth which is handled above) app.use('/api', authenticate); diff --git a/server/src/services/clawdbot-agent-service.ts b/server/src/services/clawdbot-agent-service.ts index e59602d7..a25cfd45 100644 --- a/server/src/services/clawdbot-agent-service.ts +++ b/server/src/services/clawdbot-agent-service.ts @@ -213,6 +213,13 @@ import { getToolControlPlaneService, type ToolControlPlaneService, } from './tool-control-plane-service.js'; +import { + getRunToolBridgeService, + RUN_TOOL_BRIDGE_ENV_KEY, + RUN_TOOL_BRIDGE_SERVER_ID, + type RunToolBridgeLaunch, + type RunToolBridgeService, +} from './run-tool-bridge-service.js'; import { getToolPolicyService } from './tool-policy-service.js'; const log = createLogger('clawdbot-agent-service'); @@ -361,6 +368,7 @@ interface PendingAgent { close(): void; }; acpControl?: AcpStdioControl; + runToolBridge?: RunToolBridgeLaunch; /** Durable session key returned by OpenClaw sessions_spawn (openclaw provider only) */ openclawSessionKey?: string; /** Hermes session identity captured from process output (hermes-cli provider only) */ @@ -451,6 +459,7 @@ export class ClawdbotAgentService { private runSupervisor: RunSupervisorService; private conversationLifecycle: ConversationLifecycleService; private toolControlPlane: ToolControlPlaneService; + private runToolBridge: RunToolBridgeService; private logsDir: string; constructor( @@ -465,7 +474,8 @@ export class ClawdbotAgentService { approvalBroker: RunApprovalBrokerService = getRunApprovalBrokerService(), runSupervisor: RunSupervisorService = getRunSupervisorService(), conversationLifecycle = new ConversationLifecycleService(), - toolControlPlane: ToolControlPlaneService = getToolControlPlaneService() + toolControlPlane: ToolControlPlaneService = getToolControlPlaneService(), + runToolBridge: RunToolBridgeService = getRunToolBridgeService() ) { this.configService = new ConfigService(); this.taskService = new TaskService(); @@ -487,6 +497,7 @@ export class ClawdbotAgentService { this.runSupervisor = runSupervisor; this.conversationLifecycle = conversationLifecycle; this.toolControlPlane = toolControlPlane; + this.runToolBridge = runToolBridge; this.logsDir = getLogsDir(); this.ensureLogsDir(); } @@ -1398,7 +1409,6 @@ export class ClawdbotAgentService { conversationRequest.forkTurnId, conversationRequest.intent ); - // Create event emitter for status updates const emitter = new EventEmitter(); @@ -1481,6 +1491,19 @@ export class ClawdbotAgentService { task = claimedTask; } try { + const pending = pendingAgents.get(taskId); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Run launch no longer matches the pending attempt.'); + } + pending.runToolBridge = + runToolCatalog && this.runToolBridge.requiresBridge(runToolCatalog) + ? this.runToolBridge.issue({ + taskId, + attemptId, + catalogDigest: runToolCatalog.digest, + runLaunchManifestDigest: runLaunchManifest.digest, + }) + : undefined; await this.taskService.updateTask(taskId, { status: 'in-progress', attempt, @@ -1488,6 +1511,7 @@ export class ClawdbotAgentService { }); } catch (error) { pendingAgents.delete(taskId); + this.runToolBridge.revokeRun(taskId, attemptId); if (usesManagedWorktree) { await this.worktrees.releaseOwnership(taskId, attemptId).catch((releaseError) => { log.error( @@ -2645,12 +2669,16 @@ export class ClawdbotAgentService { : status === 'interrupted' ? 'run-interrupted' : 'run-failed'; - await this.credentialLeases.revokeRun({ - taskId, - attemptId, - ...(runLaunchManifestDigest ? { runLaunchManifestDigest } : {}), - reason, - }); + try { + await this.credentialLeases.revokeRun({ + taskId, + attemptId, + ...(runLaunchManifestDigest ? { runLaunchManifestDigest } : {}), + reason, + }); + } finally { + this.runToolBridge.revokeRun(taskId, attemptId); + } } private async persistPendingCompletion( @@ -3506,7 +3534,8 @@ export class ClawdbotAgentService { attemptId, startedAt, emitter, - sandboxPolicy + sandboxPolicy, + runLaunchManifest ); }, stop: ({ pending }) => { @@ -3546,7 +3575,8 @@ export class ClawdbotAgentService { startedAt, emitter, abortController, - sandboxPolicy + sandboxPolicy, + runLaunchManifest ).catch(async (error: unknown) => { const current = pendingAgents.get(task.id); if (!current || current.attemptId !== attemptId) return; @@ -4070,6 +4100,9 @@ export class ClawdbotAgentService { throw new ConflictError('ACP run tool catalog does not match launch evidence.'); } const mcpServers = runToolCatalog ? await this.toolControlPlane.acpConfig(runToolCatalog) : []; + if (pending.runToolBridge) { + mcpServers.push(this.runToolBridge.acpServer(pending.runToolBridge)); + } const toolEnvironmentKeys = runToolCatalog ? await this.toolControlPlane.environmentKeys(runToolCatalog) : []; @@ -4322,15 +4355,25 @@ export class ClawdbotAgentService { if (!worktreePath) { throw new Error('Task worktree path is required for Codex app-server'); } + const pending = pendingAgents.get(task.id); + if (!pending || pending.attemptId !== attemptId) { + throw new ConflictError('Codex app-server launch was cancelled before process spawn.', { + taskId: task.id, + attemptId, + }); + } const runToolCatalog = runLaunchManifest.tools.catalogDigest ? await this.toolControlPlane.getRunCatalog(task.id, attemptId) : undefined; if (runToolCatalog && runToolCatalog.digest !== runLaunchManifest.tools.catalogDigest) { throw new ConflictError('Run tool catalog does not match launch evidence.'); } - const mcpServers = runToolCatalog + const mcpServers: Record = runToolCatalog ? await this.toolControlPlane.providerConfig(runToolCatalog) - : undefined; + : {}; + if (pending.runToolBridge) { + mcpServers[RUN_TOOL_BRIDGE_SERVER_ID] = this.runToolBridge.codexServer(pending.runToolBridge); + } const toolEnvironmentKeys = runToolCatalog ? await this.toolControlPlane.environmentKeys(runToolCatalog) : []; @@ -4338,21 +4381,16 @@ export class ClawdbotAgentService { const args = buildCodexAppServerArgs(agentConfig?.args); const child = spawn(command, args, { cwd: worktreePath, - env: buildSafeCodexAppServerEnv(process.env, [ - ...(sandboxPolicy?.effective.envPassthrough ?? []), - ...toolEnvironmentKeys, - ]), + env: this.runToolBridge.launchEnvironment( + buildSafeCodexAppServerEnv(process.env, [ + ...(sandboxPolicy?.effective.envPassthrough ?? []), + ...toolEnvironmentKeys, + ]), + pending.runToolBridge + ), shell: false, detached: process.platform !== 'win32', }); - const pending = pendingAgents.get(task.id); - if (!pending || pending.attemptId !== attemptId) { - child.kill('SIGTERM'); - throw new ConflictError('Codex app-server launch was cancelled before process spawn.', { - taskId: task.id, - attemptId, - }); - } pending.process = child; await this.attachSpawnedProcess(pending, child); @@ -5128,9 +5166,26 @@ export class ClawdbotAgentService { if (runToolCatalog && runToolCatalog.digest !== runLaunchManifest.tools.catalogDigest) { throw new ConflictError('Run tool catalog does not match launch evidence.'); } - const claudeMcp = runToolCatalog + let claudeMcp = runToolCatalog ? await this.toolControlPlane.claudeConfig(runToolCatalog) : undefined; + if (pending.runToolBridge) { + const bridgeMcp = this.runToolBridge.claudeServer(pending.runToolBridge); + const nativeServers = + claudeMcp?.config.mcpServers && + typeof claudeMcp.config.mcpServers === 'object' && + !Array.isArray(claudeMcp.config.mcpServers) + ? (claudeMcp.config.mcpServers as Record) + : {}; + const bridgeServers = bridgeMcp.config.mcpServers as Record; + claudeMcp = { + config: { mcpServers: { ...nativeServers, ...bridgeServers } }, + allowedToolNames: [ + ...(claudeMcp?.allowedToolNames ?? []), + ...bridgeMcp.allowedToolNames, + ].sort(), + }; + } const toolEnvironmentKeys = runToolCatalog ? await this.toolControlPlane.environmentKeys(runToolCatalog) : []; @@ -5761,7 +5816,8 @@ export class ClawdbotAgentService { attemptId: string, startedAt: string, emitter: EventEmitter, - sandboxPolicy: SandboxPolicyDryRunResult | undefined + sandboxPolicy: SandboxPolicyDryRunResult | undefined, + runLaunchManifest: RunLaunchManifest ): Promise { const worktreePath = this.expandPath(task.git?.worktreePath || ''); if (!worktreePath) { @@ -5775,6 +5831,9 @@ export class ClawdbotAgentService { attemptId, }); } + if (pending.runLaunchManifest.digest !== runLaunchManifest.digest) { + throw new ConflictError('Codex CLI bridge launch evidence changed before dispatch.'); + } const command = agentConfig?.command || 'codex'; const args = this.buildCodexArgs( agentConfig, @@ -5782,11 +5841,15 @@ export class ClawdbotAgentService { logPath, attemptId, sandboxPolicy, - pending.conversation + pending.conversation, + pending.runToolBridge ? this.runToolBridge.codexCliOverride(pending.runToolBridge) : undefined ); const child = spawn(command, args, { cwd: worktreePath, - env: buildSafeCodexEnv(process.env, sandboxPolicy?.effective.envPassthrough), + env: this.runToolBridge.launchEnvironment( + buildSafeCodexEnv(process.env, sandboxPolicy?.effective.envPassthrough), + pending.runToolBridge + ), shell: false, detached: process.platform !== 'win32', }); @@ -5982,7 +6045,8 @@ export class ClawdbotAgentService { logPath: string, attemptId: string, sandboxPolicy?: SandboxPolicyDryRunResult, - conversation?: ConversationLifecycleRecord + conversation?: ConversationLifecycleRecord, + runToolBridgeOverride?: string ): string[] { const configured = agentConfig?.args?.length ? [...agentConfig.args] : ['exec']; const args = configured.includes('exec') ? configured : ['exec', ...configured]; @@ -5997,6 +6061,7 @@ export class ClawdbotAgentService { if (!args.includes('--output-last-message')) { args.push('--output-last-message', this.getCodexFinalPath(logPath, attemptId)); } + if (runToolBridgeOverride) args.push('-c', runToolBridgeOverride); if (conversation?.mode === 'resume') { if (!conversation.conversationId) { throw new ConflictError('Codex CLI resume requires an exact conversation ID.'); @@ -6021,20 +6086,14 @@ export class ClawdbotAgentService { startedAt: string, emitter: EventEmitter, abortController: AbortController, - sandboxPolicy: SandboxPolicyDryRunResult | undefined + sandboxPolicy: SandboxPolicyDryRunResult | undefined, + runLaunchManifest: RunLaunchManifest ): Promise { const worktreePath = this.expandPath(task.git?.worktreePath || ''); if (!worktreePath) { throw new Error('Task worktree path is required for Codex SDK'); } - const sdkExecutable = this.resolveCodexSdkExecutable(agentConfig); - const { Codex } = await import('@openai/codex-sdk'); - const codex = new Codex({ - codexPathOverride: sdkExecutable.codexPathOverride, - env: buildSafeCodexEnv(process.env, sandboxPolicy?.effective.envPassthrough), - }); - const pending = pendingAgents.get(task.id); if (!pending || pending.attemptId !== attemptId) { throw new ConflictError('Codex SDK launch was cancelled before thread creation.', { @@ -6042,6 +6101,25 @@ export class ClawdbotAgentService { attemptId, }); } + if (pending.runLaunchManifest.digest !== runLaunchManifest.digest) { + throw new ConflictError('Codex SDK bridge launch evidence changed before dispatch.'); + } + const sdkExecutable = this.resolveCodexSdkExecutable(agentConfig); + const { Codex } = await import('@openai/codex-sdk'); + const codex = new Codex({ + codexPathOverride: sdkExecutable.codexPathOverride, + env: this.runToolBridge.launchEnvironment( + buildSafeCodexEnv(process.env, sandboxPolicy?.effective.envPassthrough), + pending.runToolBridge + ), + ...(pending.runToolBridge + ? { + config: this.runToolBridge.codexConfig(pending.runToolBridge) as NonNullable< + ConstructorParameters[0] + >['config'], + } + : {}), + }); const threadSettings = { workingDirectory: worktreePath, ...this.buildCodexSdkThreadSettings(sandboxPolicy), @@ -7289,6 +7367,10 @@ export class ClawdbotAgentService { ...new Set([ ...runtime.environmentKeys, ...(await this.toolControlPlane.environmentKeys(input.runToolCatalog)), + ...(this.runToolBridge.requiresBridge(input.runToolCatalog) && + this.runToolBridge.support(input.provider).injection === 'codex-config' + ? [RUN_TOOL_BRIDGE_ENV_KEY] + : []), ]), ].sort(); if (input.provider === 'claude-code') { diff --git a/server/src/services/run-launch-manifest-service.ts b/server/src/services/run-launch-manifest-service.ts index 07890117..c68542f1 100644 --- a/server/src/services/run-launch-manifest-service.ts +++ b/server/src/services/run-launch-manifest-service.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer'; import type { AgentBudgetPolicy, + ExecutableAgentProvider, HarnessSupportStatus, ProviderRuntimeManifest, RunLaunchInstructionReference, @@ -34,6 +35,7 @@ import { import { sanitizeProviderRuntimeDiagnostic } from '../utils/provider-runtime-manifest-sanitize.js'; import { calculateRunToolCatalogDigest } from '../utils/tool-control-plane-digest.js'; import { compileProviderLaunchCredentialPlan } from './provider-launch-credential-plan-service.js'; +import { runToolBridgeSupport, runToolCatalogRequiresBridge } from './run-tool-bridge-service.js'; export interface RunLaunchManifestInstructionInput { id: string; @@ -514,6 +516,19 @@ function collectBlockers({ 'Select a credential-free preset until a controlled tool or egress boundary is configured.' ); } + if (runToolCatalogRequiresBridge(input.runToolCatalog)) { + const support = runToolBridgeSupport( + input.providerRuntimeManifest.provider as ExecutableAgentProvider + ); + if (!support.supported) { + add( + 'run-tool-bridge-unavailable', + 'tools.catalogDigest', + `Credential-bound tools require the system-owned Veritas bridge. ${support.reason}`, + 'Select a provider with verified run-scoped MCP injection or use a credential-free tool preset.' + ); + } + } if ( tools.enforcement !== 'enforced' && (tools.allowed.length > 0 || tools.denied.length > 0 || tools.policyIds.length > 0) diff --git a/server/src/services/run-tool-bridge-service.ts b/server/src/services/run-tool-bridge-service.ts new file mode 100644 index 00000000..88b1b6e2 --- /dev/null +++ b/server/src/services/run-tool-bridge-service.ts @@ -0,0 +1,322 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import type { AcpMcpServer, ExecutableAgentProvider, RunToolCatalog } from '@veritas-kanban/shared'; +import { ConflictError, ForbiddenError } from '../middleware/error-handler.js'; + +export const RUN_TOOL_BRIDGE_ENV_KEY = 'VK_RUN_TOOL_BRIDGE_HANDLE'; +export const RUN_TOOL_BRIDGE_SERVER_ID = 'veritas-run'; +export const RUN_TOOL_BRIDGE_METHODS = ['catalog.read', 'tool.call'] as const; + +export type RunToolBridgeMethod = (typeof RUN_TOOL_BRIDGE_METHODS)[number]; + +export interface RunToolBridgeBinding { + taskId: string; + attemptId: string; + catalogDigest: string; + runLaunchManifestDigest: string; +} + +export interface RunToolBridgeLaunch extends RunToolBridgeBinding { + /** Opaque, run-local bearer authority. Never persist or log this value. */ + handle: string; + handleId: string; + expiresAt: string; +} + +export interface RunToolBridgeSupport { + provider: ExecutableAgentProvider; + supported: boolean; + injection: 'codex-config' | 'claude-config' | 'acp-session' | 'unavailable'; + reason: string; +} + +interface StoredRunToolBridgeAuthority extends RunToolBridgeBinding { + handleId: string; + allowedMethods: readonly RunToolBridgeMethod[]; + expiresAt: string; +} + +const SUPPORT: Record> = { + 'codex-cli': { + supported: true, + injection: 'codex-config', + reason: 'Codex CLI accepts system-owned MCP config overrides and inherited named variables.', + }, + 'codex-sdk': { + supported: true, + injection: 'codex-config', + reason: 'Codex SDK accepts the same system-owned Codex config object and launch environment.', + }, + 'codex-app-server': { + supported: true, + injection: 'codex-config', + reason: 'Codex app-server accepts thread-scoped MCP configuration.', + }, + 'claude-code': { + supported: true, + injection: 'claude-config', + reason: 'Claude Code accepts a strict system-owned MCP configuration.', + }, + 'acp-stdio': { + supported: true, + injection: 'acp-session', + reason: 'ACP stdio accepts run-scoped stdio MCP servers during session creation.', + }, + 'hermes-cli': { + supported: false, + injection: 'unavailable', + reason: 'The certified Hermes one-shot interface has no verified system-owned MCP injection.', + }, + openclaw: { + supported: false, + injection: 'unavailable', + reason: 'The certified OpenClaw gateway dispatch cannot bind a local run-scoped MCP process.', + }, +}; + +const DEFAULT_TTL_MS = 24 * 60 * 60 * 1_000; + +export interface RunToolBridgeServiceOptions { + now?: () => Date; + randomHandle?: () => string; + ttlMs?: number; + entrypoint?: string; + apiUrl?: string; +} + +export function runToolBridgeSupport(provider: ExecutableAgentProvider): RunToolBridgeSupport { + return { provider, ...SUPPORT[provider] }; +} + +export function runToolCatalogRequiresBridge(catalog: RunToolCatalog | undefined): boolean { + return Boolean( + catalog?.entries.some( + (entry) => entry.status === 'ready' && (entry.credentialBindings?.length ?? 0) > 0 + ) + ); +} + +export class RunToolBridgeService { + private readonly authorities = new Map(); + private readonly now: () => Date; + private readonly randomHandle: () => string; + private readonly ttlMs: number; + private readonly entrypoint: string; + private readonly apiUrl: string; + + constructor(options: RunToolBridgeServiceOptions = {}) { + this.now = options.now ?? (() => new Date()); + this.randomHandle = + options.randomHandle ?? (() => `vkbridge_${randomBytes(32).toString('base64url')}`); + this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS; + this.entrypoint = path.resolve(options.entrypoint ?? defaultBridgeEntrypoint()); + this.apiUrl = normalizeBridgeApiUrl( + options.apiUrl ?? process.env.VK_API_URL ?? 'http://localhost:3001' + ); + } + + support(provider: ExecutableAgentProvider): RunToolBridgeSupport { + return runToolBridgeSupport(provider); + } + + requiresBridge(catalog: RunToolCatalog | undefined): boolean { + return runToolCatalogRequiresBridge(catalog); + } + + issue(binding: RunToolBridgeBinding): RunToolBridgeLaunch { + if (!binding.taskId || !binding.attemptId) { + throw new ConflictError('Run tool bridge authority requires an exact task and attempt.'); + } + const handle = this.randomHandle(); + if (!/^vkbridge_[A-Za-z0-9_-]{32,}$/.test(handle)) { + throw new ConflictError('Run tool bridge handle source returned an invalid opaque handle.'); + } + const handleHash = hashHandle(handle); + if (this.authorities.has(handleHash)) { + throw new ConflictError('Run tool bridge handle source returned a duplicate handle.'); + } + const handleId = `vkbridge_${handleHash.slice(0, 16)}`; + const expiresAt = new Date(this.now().getTime() + this.ttlMs).toISOString(); + this.authorities.set(handleHash, { + ...binding, + handleId, + allowedMethods: RUN_TOOL_BRIDGE_METHODS, + expiresAt, + }); + return { ...binding, handle, handleId, expiresAt }; + } + + authorize( + handle: string | undefined, + method: RunToolBridgeMethod, + expected: Partial = {} + ): StoredRunToolBridgeAuthority { + if (!handle?.startsWith('vkbridge_')) { + throw new ForbiddenError('Run tool bridge authority is missing or invalid.'); + } + const authority = this.authorities.get(hashHandle(handle)); + if (!authority) { + throw new ForbiddenError('Run tool bridge authority is stale or revoked.'); + } + if (Date.parse(authority.expiresAt) <= this.now().getTime()) { + this.authorities.delete(hashHandle(handle)); + throw new ForbiddenError('Run tool bridge authority expired.'); + } + if (!authority.allowedMethods.includes(method)) { + throw new ForbiddenError('Run tool bridge method is outside the run authority.'); + } + for (const field of [ + 'taskId', + 'attemptId', + 'catalogDigest', + 'runLaunchManifestDigest', + ] as const) { + if (expected[field] && expected[field] !== authority[field]) { + throw new ForbiddenError(`Run tool bridge ${field} does not match the active authority.`); + } + } + return { ...authority }; + } + + revokeRun(taskId: string, attemptId: string): number { + let revoked = 0; + for (const [handleHash, authority] of this.authorities) { + if (authority.taskId === taskId && authority.attemptId === attemptId) { + this.authorities.delete(handleHash); + revoked += 1; + } + } + return revoked; + } + + launchEnvironment( + source: Record, + launch: RunToolBridgeLaunch | undefined + ): Record { + return launch + ? { + ...source, + VK_API_URL: this.apiUrl, + [RUN_TOOL_BRIDGE_ENV_KEY]: launch.handle, + } + : source; + } + + codexServer(launch: RunToolBridgeLaunch): Record { + this.assertLaunch(launch); + return { + command: process.execPath, + args: [this.entrypoint], + enabled: true, + required: true, + env_vars: ['VK_API_URL', RUN_TOOL_BRIDGE_ENV_KEY], + default_tools_approval_mode: 'approve', + enabled_tools: ['get_run_tool_catalog', 'call_run_tool'], + disabled_tools: [], + }; + } + + codexConfig(launch: RunToolBridgeLaunch): Record { + return { + mcp_servers: { + [RUN_TOOL_BRIDGE_SERVER_ID]: this.codexServer(launch), + }, + }; + } + + codexCliOverride(launch: RunToolBridgeLaunch): string { + this.assertLaunch(launch); + return `mcp_servers.${RUN_TOOL_BRIDGE_SERVER_ID}=${tomlInlineTable({ + command: process.execPath, + args: [this.entrypoint], + enabled: true, + required: true, + env_vars: ['VK_API_URL', RUN_TOOL_BRIDGE_ENV_KEY], + enabled_tools: ['get_run_tool_catalog', 'call_run_tool'], + })}`; + } + + claudeServer(launch: RunToolBridgeLaunch): { + config: Record; + allowedToolNames: string[]; + } { + this.assertLaunch(launch); + return { + config: { + mcpServers: { + [RUN_TOOL_BRIDGE_SERVER_ID]: { + command: process.execPath, + args: [this.entrypoint], + env: { + VK_API_URL: this.apiUrl, + [RUN_TOOL_BRIDGE_ENV_KEY]: launch.handle, + }, + }, + }, + }, + allowedToolNames: [ + `mcp__${RUN_TOOL_BRIDGE_SERVER_ID}__get_run_tool_catalog`, + `mcp__${RUN_TOOL_BRIDGE_SERVER_ID}__call_run_tool`, + ], + }; + } + + acpServer(launch: RunToolBridgeLaunch): AcpMcpServer { + this.assertLaunch(launch); + return { + name: 'Veritas run tools', + command: process.execPath, + args: [this.entrypoint], + env: [ + { name: 'VK_API_URL', value: this.apiUrl }, + { name: RUN_TOOL_BRIDGE_ENV_KEY, value: launch.handle }, + ], + }; + } + + private assertLaunch(launch: RunToolBridgeLaunch): void { + this.authorize(launch.handle, 'catalog.read', launch); + } +} + +function defaultBridgeEntrypoint(): string { + return fileURLToPath(new URL('../../runtime/run-tool-bridge.mjs', import.meta.url)); +} + +function hashHandle(handle: string): string { + return createHash('sha256').update(handle).digest('hex'); +} + +function normalizeBridgeApiUrl(value: string): string { + const url = new URL(value); + const loopback = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new ConflictError('Run tool bridge API URL must use HTTPS or loopback HTTP.'); + } + url.pathname = url.pathname.replace(/\/+$/, ''); + url.search = ''; + url.hash = ''; + return url.toString().replace(/\/$/, ''); +} + +function tomlInlineTable(value: Record): string { + return `{${Object.entries(value) + .map(([key, child]) => { + if (Array.isArray(child)) + return `${key}=[${child.map((item) => JSON.stringify(item)).join(',')}]`; + return `${key}=${typeof child === 'string' ? JSON.stringify(child) : String(child)}`; + }) + .join(',')}}`; +} + +let singleton: RunToolBridgeService | undefined; + +export function getRunToolBridgeService(): RunToolBridgeService { + singleton ??= new RunToolBridgeService(); + return singleton; +} + +export function resetRunToolBridgeService(): void { + singleton = undefined; +}