mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
parent
9c15d348fa
commit
4cc5891f82
17 changed files with 1204 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
10
CHANGELOG.md
10
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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
239
server/runtime/run-tool-bridge.mjs
Normal file
239
server/runtime/run-tool-bridge.mjs
Normal file
|
|
@ -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 {}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
151
server/src/__tests__/run-tool-bridge-runtime.test.ts
Normal file
151
server/src/__tests__/run-tool-bridge-runtime.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
}> = [];
|
||||
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<string, unknown> } : {}),
|
||||
});
|
||||
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<void>((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<string, unknown>) => void> = [];
|
||||
output.on('line', (line) => {
|
||||
lines.shift()?.(JSON.parse(line) as Record<string, unknown>);
|
||||
});
|
||||
const rpc = (record: Record<string, unknown>) =>
|
||||
new Promise<Record<string, unknown>>((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<void>((resolve) => child.once('close', () => resolve()));
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
api.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
});
|
||||
});
|
||||
137
server/src/__tests__/run-tool-bridge-service.test.ts
Normal file
137
server/src/__tests__/run-tool-bridge-service.test.ts
Normal file
|
|
@ -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<ExecutableAgentProvider, boolean> = {
|
||||
'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/);
|
||||
});
|
||||
});
|
||||
87
server/src/routes/run-tool-bridge.ts
Normal file
87
server/src/routes/run-tool-bridge.ts
Normal file
|
|
@ -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<unknown, unknown, Omit<ToolInvocationRequest, 'taskId' | 'attemptId'>>,
|
||||
res
|
||||
) => {
|
||||
const authority = bridge.authorize(handle(req), 'tool.call');
|
||||
const input = req.validated.body as Omit<ToolInvocationRequest, 'taskId' | 'attemptId'>;
|
||||
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<typeof bridge.authorize>) {
|
||||
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 };
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = 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<string, unknown>)
|
||||
: {};
|
||||
const bridgeServers = bridgeMcp.config.mcpServers as Record<string, unknown>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof Codex>[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') {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
322
server/src/services/run-tool-bridge-service.ts
Normal file
322
server/src/services/run-tool-bridge-service.ts
Normal file
|
|
@ -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<ExecutableAgentProvider, Omit<RunToolBridgeSupport, 'provider'>> = {
|
||||
'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<string, StoredRunToolBridgeAuthority>();
|
||||
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<RunToolBridgeBinding> = {}
|
||||
): 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<string, string>,
|
||||
launch: RunToolBridgeLaunch | undefined
|
||||
): Record<string, string> {
|
||||
return launch
|
||||
? {
|
||||
...source,
|
||||
VK_API_URL: this.apiUrl,
|
||||
[RUN_TOOL_BRIDGE_ENV_KEY]: launch.handle,
|
||||
}
|
||||
: source;
|
||||
}
|
||||
|
||||
codexServer(launch: RunToolBridgeLaunch): Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown>;
|
||||
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, string | boolean | string[]>): 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue