diff --git a/docs/agent-managed-compute/reliability.md b/docs/agent-managed-compute/reliability.md index a05246ac..a4ff09a9 100644 --- a/docs/agent-managed-compute/reliability.md +++ b/docs/agent-managed-compute/reliability.md @@ -231,6 +231,14 @@ Omit `coordination.port` to allocate a separate port for each concurrent manager Runtime does not provision a proxy or tunnel. Remote public endpoints require HTTPS. +Before provider admission, Runtime checks each configured public endpoint with authenticated `initialize` and `tools/list` requests. +The returned grants must match that manager's exact tool names. +The check takes at most 10 seconds, or `coordination.requestTimeoutMs` when shorter, and stops on manager cancellation. +Responses are limited to 1 MiB independently of the incoming request limit, and redirects are refused. +Failure closes the listener and reports a credential-free cause before inference starts. +This verifies the operator's public route; it does not establish reachability from the provider's network. +Omitting `coordination.publicUrl` preserves local-only startup without this network check. + For same-host coordinator restart, configure `authentication.signingKeys` with an active key ID and a secret key map. Keep the public URL, run ID, actor ID, tool grants, and verification key stable until the retained credential expires. Stable keys support resumed coordination within the original scope deadline and any explicit credential expiry. diff --git a/scripts/check-model-execution-boundary.mjs b/scripts/check-model-execution-boundary.mjs index 21f2e895..55f71a58 100644 --- a/scripts/check-model-execution-boundary.mjs +++ b/scripts/check-model-execution-boundary.mjs @@ -17,6 +17,7 @@ const dynamicNonModelFetchOwners = new Set([ 'src/platform/auth.ts', 'src/platform/integrations.ts', 'src/runtime/mcp-environment.ts', + 'src/runtime/supervise/coordination-preflight.ts', 'bench/src/research-shot.ts', 'bench/src/search-tool.ts', 'bench/src/benchmarks/aec-bench.ts', diff --git a/scripts/check-model-execution-boundary.test.mjs b/scripts/check-model-execution-boundary.test.mjs index b2eae442..8793d8cc 100644 --- a/scripts/check-model-execution-boundary.test.mjs +++ b/scripts/check-model-execution-boundary.test.mjs @@ -48,6 +48,14 @@ describe('model execution boundary source check', () => { expect(violations[0]?.location).toBe('3:7') }) + it('permits dynamic MCP preflight routes without admitting named model endpoints', () => { + const path = 'src/runtime/supervise/coordination-preflight.ts' + expect(checkJavaScript(path, 'await fetch(input.url)')).toEqual([]) + expect( + checkJavaScript(path, "await fetch('https://router.tangle.tools/v1/chat/completions')"), + ).toHaveLength(1) + }) + it('rejects qualified and aliased global fetch calls', () => { expect( checkJavaScript( diff --git a/scripts/release-cohort.test.mjs b/scripts/release-cohort.test.mjs index 53c1cf29..dc560ae2 100644 --- a/scripts/release-cohort.test.mjs +++ b/scripts/release-cohort.test.mjs @@ -1,7 +1,9 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { rangeAdmits } from './lib/packed-package-test.mjs' import { assertReleaseCohortArtifacts, readReleaseCohort, @@ -17,6 +19,19 @@ afterEach(() => { }) describe('release cohort', () => { + it.each(Object.values(readReleaseCohort().packages))( + 'selects $name@$version within its workspace catalog range', + ({ name, version }) => { + const { catalog } = parse( + readFileSync(new URL('../pnpm-workspace.yaml', import.meta.url), 'utf8'), + ) + expect( + rangeAdmits(catalog[name], version), + `${name}@${version} must be admitted by catalog range ${catalog[name]}`, + ).toBe(true) + }, + ) + it('reads one exact source identity for every first-party dependency', () => { const cohort = readReleaseCohort() expect(Object.keys(cohort.packages)).toEqual([ diff --git a/src/mcp/tool-server.ts b/src/mcp/tool-server.ts index 19cdd94d..f4b7830d 100644 --- a/src/mcp/tool-server.ts +++ b/src/mcp/tool-server.ts @@ -18,7 +18,7 @@ import type { JsonRpcMessage, JsonRpcResponse, McpToolDescriptor, McpTransport } export type { JsonRpcMessage, JsonRpcResponse, McpToolDescriptor, McpTransport } from './protocol' -const PROTOCOL_VERSION = '2024-11-05' +export const PROTOCOL_VERSION = '2024-11-05' /** @experimental */ export interface StdioToolServerOptions { diff --git a/src/runtime/supervise/coordination-mcp.ts b/src/runtime/supervise/coordination-mcp.ts index c4a28f4c..fdda8e34 100644 --- a/src/runtime/supervise/coordination-mcp.ts +++ b/src/runtime/supervise/coordination-mcp.ts @@ -51,6 +51,7 @@ import { coordinationHttpHandler, coordinationHttpLimits, } from './coordination-http' +import { preflightPublicCoordination } from './coordination-preflight' import { singleFlightTools } from './single-flight-tools' export type { CoordinationHttpAudit, CoordinationHttpOptions } from './coordination-http' @@ -567,8 +568,21 @@ export async function serveCoordinationMcp( audiences.add(publicAddress.host) if (host === '0.0.0.0' || host === '::') audiences.add(`127.0.0.1:${port}`) paths.add(publicAddress.pathname) + if (configured !== undefined) { + await preflightPublicCoordination({ + url, + headers, + signal: opts.scope.signal, + requestTimeoutMs, + toolNames: selectedNames, + }) + } } catch (error) { - await new Promise((resolve) => server.close(() => resolve())) + closed = true + await new Promise((resolve) => { + server.close(() => resolve()) + server.closeAllConnections() + }) throw error } diff --git a/src/runtime/supervise/coordination-preflight.test.ts b/src/runtime/supervise/coordination-preflight.test.ts new file mode 100644 index 00000000..64ecd222 --- /dev/null +++ b/src/runtime/supervise/coordination-preflight.test.ts @@ -0,0 +1,150 @@ +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { preflightPublicCoordination } from './coordination-preflight' + +const closes: Array<() => Promise> = [] +afterEach(async () => { + await Promise.all(closes.splice(0).map((close) => close())) +}) + +async function endpoint(handler: (request: IncomingMessage, response: ServerResponse) => void) { + const server = createServer(handler) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + closes.push( + () => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }), + ) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test endpoint did not bind') + return `http://127.0.0.1:${address.port}` +} + +function inspect(url: string, signal = new AbortController().signal, requestTimeoutMs = 1000) { + return preflightPublicCoordination({ + url, + headers: { Authorization: 'Bearer private-test-credential' }, + signal, + requestTimeoutMs, + toolNames: ['stop'], + }) +} + +describe('public coordination preflight', () => { + it('performs authenticated initialization and checks the exact granted tool names', async () => { + const methods: string[] = [] + const url = await endpoint((request, response) => { + expect(request.headers.authorization).toBe('Bearer private-test-credential') + let body = '' + request.on('data', (chunk) => { + body += String(chunk) + }) + request.on('end', () => { + const rpc = JSON.parse(body) + methods.push(rpc.method) + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpc.id, + result: + rpc.method === 'initialize' + ? { + protocolVersion: '2024-11-05', + serverInfo: { name: 'coordination' }, + capabilities: { tools: {} }, + } + : { tools: [{ name: 'stop' }] }, + }), + ) + }) + }) + await inspect(url) + expect(methods).toEqual(['initialize', 'tools/list']) + }) + + it.each([401, 403, 404, 503])( + 'refuses HTTP %s without retaining echoed secrets', + async (status) => { + const url = await endpoint((_request, response) => { + response.writeHead(status).end('private-test-credential private-response') + }) + await expect(inspect(url)).rejects.toThrow( + `coordination public endpoint preflight failed: HTTP ${status}`, + ) + try { + await inspect(url) + } catch (error) { + expect(String(error)).not.toContain('private-') + expect(String(error)).not.toContain(url) + expect(error).not.toHaveProperty('cause') + } + }, + ) + + it('refuses redirects instead of forwarding actor credentials to another endpoint', async () => { + let redirected = 0 + const target = await endpoint((_request, response) => { + redirected++ + response.end('{}') + }) + const url = await endpoint((_request, response) => + response.writeHead(307, { location: target }).end(), + ) + await expect(inspect(url)).rejects.toThrow('HTTP 307') + expect(redirected).toBe(0) + }) + + it.each(['not-json', '{}', '{"jsonrpc":"2.0","id":"other","result":{}}'])( + 'refuses malformed MCP response %s', + async (body) => { + const url = await endpoint((_request, response) => response.end(body)) + await expect(inspect(url)).rejects.toThrow('invalid MCP response') + }, + ) + + it('bounds a hanging response body and distinguishes timeout from cancellation', async () => { + const url = await endpoint((_request, response) => { + response.writeHead(200) + response.write('{') + }) + await expect(inspect(url, undefined, 40)).rejects.toThrow('timed out') + const controller = new AbortController() + const pending = inspect(url, controller.signal) + controller.abort('private-test-credential in a caller reason') + await expect(pending).rejects.toThrow('preflight failed: cancelled') + }) + + it('bounds response bytes before decoding or retaining an upstream body', async () => { + const url = await endpoint((_request, response) => response.end('x'.repeat(1024 * 1024 + 1))) + await expect(inspect(url)).rejects.toThrow('response too large') + }) + + it('rejects incomplete or extra tool grants', async () => { + const url = await endpoint((request, response) => { + let body = '' + request.on('data', (chunk) => { + body += String(chunk) + }) + request.on('end', () => { + const rpc = JSON.parse(body) + response.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpc.id, + result: + rpc.method === 'initialize' + ? { + protocolVersion: '2024-11-05', + serverInfo: { name: 'coordination' }, + capabilities: { tools: {} }, + } + : { tools: [{ name: 'spawn_worker' }] }, + }), + ) + }) + }) + await expect(inspect(url)).rejects.toThrow('coordination tool grants differ') + }) +}) diff --git a/src/runtime/supervise/coordination-preflight.ts b/src/runtime/supervise/coordination-preflight.ts new file mode 100644 index 00000000..1260abda --- /dev/null +++ b/src/runtime/supervise/coordination-preflight.ts @@ -0,0 +1,117 @@ +import { ConfigError } from '../../errors' +import { PROTOCOL_VERSION } from '../../mcp/tool-server' +import { linkAbort, runAbortable } from './abortable' + +const MAX_RESPONSE_BYTES = 1024 * 1024 + +class CoordinationPreflightError extends ConfigError { + constructor(reason: string) { + super(`coordination public endpoint preflight failed: ${reason}`) + } +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Check the operator's route before it becomes a provider attachment. This does not prove cloud egress. */ +export async function preflightPublicCoordination(input: { + url: string + headers: Readonly> + signal: AbortSignal + requestTimeoutMs: number + toolNames: readonly string[] +}): Promise { + const deadline = new AbortController() + const linked = linkAbort(input.signal, deadline.signal) + const timer = setTimeout(() => deadline.abort(), Math.min(input.requestTimeoutMs, 10_000)) + timer.unref() + const rpc = async (method: string, params?: unknown): Promise> => { + const response = await fetch(input.url, { + method: 'POST', + headers: { ...input.headers, 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: method, method, ...(params ? { params } : {}) }), + redirect: 'manual', + signal: linked.signal, + }) + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined) + throw new CoordinationPreflightError(`HTTP ${response.status}`) + } + const reader = response.body?.getReader() + if (!reader) throw new CoordinationPreflightError('invalid MCP response') + let size = 0 + const chunks: Uint8Array[] = [] + try { + for (;;) { + const part = await reader.read() + if (part.done) break + size += part.value.byteLength + if (size > MAX_RESPONSE_BYTES) { + void reader.cancel().catch(() => undefined) + throw new CoordinationPreflightError('response too large') + } + chunks.push(part.value) + } + } finally { + reader.releaseLock() + } + let body: unknown + try { + body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) + } catch { + throw new CoordinationPreflightError('invalid MCP response') + } + if ( + !record(body) || + body.jsonrpc !== '2.0' || + body.id !== method || + body.error || + !record(body.result) + ) { + throw new CoordinationPreflightError('invalid MCP response') + } + return body.result + } + try { + await runAbortable( + async () => { + const initialized = await rpc('initialize', { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'agent-runtime-coordination-preflight', version: '1' }, + }) + if ( + initialized.protocolVersion !== PROTOCOL_VERSION || + !record(initialized.serverInfo) || + initialized.serverInfo.name !== 'coordination' || + !record(initialized.capabilities) || + !record(initialized.capabilities.tools) + ) + throw new CoordinationPreflightError('invalid MCP initialization') + const listing = await rpc('tools/list') + if ( + !Array.isArray(listing.tools) || + listing.tools.some((tool) => !record(tool) || typeof tool.name !== 'string') + ) { + throw new CoordinationPreflightError('invalid MCP tool list') + } + const names = listing.tools.map((tool) => tool.name).sort() + if (JSON.stringify(names) !== JSON.stringify([...input.toolNames].sort())) { + throw new CoordinationPreflightError('coordination tool grants differ') + } + }, + linked.signal, + 'coordination public endpoint preflight cancelled', + ) + } catch (error) { + // Neither a response body nor an upstream exception is safe to retain: both can echo credentials. + if (input.signal.aborted) throw new CoordinationPreflightError('cancelled') + if (deadline.signal.aborted) throw new CoordinationPreflightError('timed out') + if (error instanceof CoordinationPreflightError) throw error + throw new CoordinationPreflightError('transport unavailable') + } finally { + clearTimeout(timer) + linked.release() + } +} diff --git a/tests/helpers/coordination-proxy.ts b/tests/helpers/coordination-proxy.ts new file mode 100644 index 00000000..d819b25d --- /dev/null +++ b/tests/helpers/coordination-proxy.ts @@ -0,0 +1,42 @@ +import { createServer, request } from 'node:http' + +/** A stable public address whose upstream changes when a retained coordinator restarts. */ +export async function coordinationProxy() { + let upstream: { host: string; port: number } | undefined + const server = createServer((incoming, outgoing) => { + if (!upstream) { + outgoing.writeHead(503).end() + return + } + const target = request( + { + hostname: upstream.host, + port: upstream.port, + path: incoming.url, + method: incoming.method, + headers: { ...incoming.headers, host: `${upstream.host}:${upstream.port}` }, + }, + (response) => { + outgoing.writeHead(response.statusCode ?? 502, response.headers) + response.pipe(outgoing) + }, + ) + target.on('error', () => outgoing.writeHead(502).end()) + outgoing.on('close', () => target.destroy()) + incoming.pipe(target) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('proxy did not bind') + return { + url: `http://127.0.0.1:${address.port}`, + forwardTo(port: number, host = '127.0.0.1') { + upstream = { host, port } + }, + close: () => + new Promise((resolve) => { + server.closeAllConnections() + server.close(() => resolve()) + }), + } +} diff --git a/tests/kernel/coordination-mcp.test.ts b/tests/kernel/coordination-mcp.test.ts index f3bfcd1b..6645e46a 100644 --- a/tests/kernel/coordination-mcp.test.ts +++ b/tests/kernel/coordination-mcp.test.ts @@ -1,6 +1,10 @@ +import { mkdtemp, rm } from 'node:fs/promises' import { createServer, request } from 'node:http' -import { networkInterfaces } from 'node:os' -import { describe, expect, it, vi } from 'vitest' +import { connect, type Socket } from 'node:net' +import { networkInterfaces, tmpdir } from 'node:os' +import { join } from 'node:path' +import { createKnowledgeTools, createRunScopedStores } from '@tangle-network/agent-knowledge' +import { afterEach, describe, expect, it, vi } from 'vitest' import { InMemoryResultBlobStore, InMemorySpawnJournal } from '../../src/durable/spawn-journal' import { DEFAULT_AWAIT_EVENT_TIMEOUT_MS } from '../../src/mcp/tools/coordination' import { coordinationHttpHandler } from '../../src/runtime/supervise/coordination-http' @@ -21,9 +25,20 @@ import type { Scope, UsageEvent, } from '../../src/runtime/supervise/types' +import { coordinationProxy } from '../helpers/coordination-proxy' import { supervisorAgent } from '../helpers/runtime-with-test-brain' import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' +const proxies: Awaited>[] = [] +afterEach(async () => { + await Promise.all(proxies.splice(0).map((proxy) => proxy.close())) +}) +async function publicProxy() { + const proxy = await coordinationProxy() + proxies.push(proxy) + return proxy +} + // A real (simple) delivering leaf — NOT a mock of the MCP path; the HTTP→MCP→Scope.spawn is real. function deliveringLeaf(name: string, out: unknown): Agent { const ex: Executor = { @@ -526,6 +541,55 @@ function postHttp( } describe('authenticated and bounded coordination HTTP', () => { + it('preflights the coordination and Knowledge tools independently of the incoming request limit', async () => { + const root = await mkdtemp(join(tmpdir(), 'coordination-knowledge-')) + try { + const nodeTools = createKnowledgeTools({ + stores: createRunScopedStores({ root }), + runId: 'preflight', + retrieverVersion: 'test', + }).map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchemaJson!, + handler: tool.handler, + })) + const toolNames = [ + 'spawn_worker', + 'observe_agent', + 'steer_agent', + 'await_event', + 'list_questions', + 'answer_question', + 'ask_parent', + 'stop', + 'read_journal', + 'list_analysts', + 'run_analyst', + ...nodeTools.map((tool) => tool.name), + ] + await withBoundHttp( + { + maxRequestBytes: 1024, + publicUrl: ({ port }) => `http://127.0.0.1:${port}/mcp`, + analysts: { kinds: [], run: async () => [] }, + nodeTools, + toolNames, + }, + async (mcp) => { + const listing = await jsonRpc(mcp.url, 'tools/list', {}, mcp.headers) + expect(Buffer.byteLength(JSON.stringify(listing))).toBeGreaterThan(1024) + expect(Buffer.byteLength(JSON.stringify(listing))).toBeLessThan(1024 * 1024) + expect(listing.result).toMatchObject({ + tools: toolNames.map((name) => expect.objectContaining({ name })), + }) + }, + ) + } finally { + await rm(root, { recursive: true, force: true }) + } + }) + it.each(['0.0.0.0', '::'])( 'accepts only the actual bound address behind a proxy with wildcard %s', async (host) => { @@ -534,6 +598,7 @@ describe('authenticated and bounded coordination HTTP', () => { .find((entry) => entry?.family === 'IPv4' && !entry.internal)?.address if (!address) throw new Error('This HTTP regression needs an assigned non-loopback IPv4 address') + const proxy = await publicProxy() const post = (port: number, headers: Record, path = '/mcp') => new Promise((resolve, reject) => { const req = request( @@ -553,12 +618,13 @@ describe('authenticated and bounded coordination HTTP', () => { publicUrl: async ({ port }) => { // The bound socket exists before public routing and credentials are ready. expect(await post(port, { Host: `${address}:${port}` })).toBe(403) - return 'https://coordination.example/mcp' + proxy.forwardTo(port, address) + return `${proxy.url}/mcp` }, }, async (mcp) => { expect(await post(mcp.port, { ...mcp.headers, Host: `${address}:${mcp.port}` })).toBe(200) - expect(await post(mcp.port, { ...mcp.headers, Host: 'coordination.example' })).toBe(200) + expect(await post(mcp.port, { ...mcp.headers, Host: new URL(proxy.url).host })).toBe(200) for (const authority of [ `${address}:${mcp.port + 1}`, `192.0.2.1:${mcp.port}`, @@ -850,21 +916,23 @@ describe('authenticated and bounded coordination HTTP', () => { it.each([false, true])( 'binds the caller-owned endpoint and credential audience with async resolution=%s', async (asynchronous) => { + const proxy = await publicProxy() await withBoundHttp( { - publicUrl: ({ actorId }) => { - const url = `https://coordination.example/${actorId}` + publicUrl: ({ actorId, port }) => { + proxy.forwardTo(port) + const url = `${proxy.url}/${actorId}` return asynchronous ? Promise.resolve(url) : url }, }, async (mcp) => { - expect(mcp.url).toBe('https://coordination.example/actor-a') + expect(mcp.url).toBe(`${proxy.url}/actor-a`) expect(mcp.url).not.toContain(mcp.headers.Authorization!) const response = await fetch(`http://127.0.0.1:${mcp.port}/actor-a`, { method: 'POST', headers: { ...mcp.headers, - Host: 'coordination.example', + Host: new URL(proxy.url).host, 'content-type': 'application/json', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), @@ -981,6 +1049,51 @@ describe('authenticated and bounded coordination HTTP', () => { }) describe('coordination credential continuity', () => { + it('closes partial startup connections when public preflight fails', async () => { + let socket: Socket | undefined + try { + await withBoundHttp({}, async (parent) => { + await expect( + withBoundHttp( + { + publicUrl: async ({ port }) => { + socket = connect(port, '127.0.0.1') + await new Promise((resolve, reject) => { + socket!.once('connect', resolve) + socket!.once('error', reject) + }) + socket.write('POST /mcp HTTP/1.1\r\n') + return parent.url + }, + identity: { runId: 'run-a', actorId: 'actor-b' }, + }, + async () => { + throw new Error('must not admit a failed endpoint') + }, + ), + ).rejects.toThrow('coordination public endpoint preflight failed: HTTP 401') + }) + } finally { + socket?.destroy() + } + }, 2_000) + + it('refuses a public route to another actor before exposing a manager handle', async () => { + await withBoundHttp({}, async (parent) => { + await expect( + withBoundHttp( + { + publicUrl: parent.url, + identity: { runId: 'run-a', actorId: 'actor-b' }, + }, + async () => { + throw new Error('must not expose the wrong actor') + }, + ), + ).rejects.toThrow('coordination public endpoint preflight failed: HTTP 401') + }) + }) + it('does not revive a revoked signed credential through signature padding', async () => { await withBoundHttp( { @@ -1006,7 +1119,11 @@ describe('coordination credential continuity', () => { it('resumes scope-bound signed credentials only when the receiver permits that lifetime', async () => { const signingKeys = { activeKeyId: 'run', keys: { run: 'k'.repeat(48) } } - const publicUrl = 'https://coordination.example/scope-bound' + const proxy = await publicProxy() + const publicUrl = ({ port }: { port: number }) => { + proxy.forwardTo(port) + return `${proxy.url}/scope-bound` + } let original: Readonly> = {} await withBoundHttp({ authentication: { signingKeys }, publicUrl }, async (mcp) => { expect(mcp.credentialExpiresAt).toBeUndefined() @@ -1032,7 +1149,11 @@ describe('coordination credential continuity', () => { it('accepts an original credential only for its exact restarted authority and retained verification key', async () => { const signingKeys = { activeKeyId: 'original', keys: { original: 'a'.repeat(48) } } - const publicUrl = 'https://coordination.example/manager' + const proxy = await publicProxy() + const publicUrl = ({ port }: { port: number }) => { + proxy.forwardTo(port) + return `${proxy.url}/manager` + } let original: Readonly> = {} await withBoundHttp( { authentication: { signingKeys, ttlMs: 900_000 }, publicUrl }, @@ -1053,7 +1174,12 @@ describe('coordination credential continuity', () => { for (const mismatch of [ { identity: { runId: 'other-run', actorId: 'actor-a' } }, { identity: { runId: 'run-a', actorId: 'other-actor' } }, - { publicUrl: 'https://coordination.example/other' }, + { + publicUrl: ({ port }: { port: number }) => { + proxy.forwardTo(port) + return `${proxy.url}/other` + }, + }, { toolNames: ['probe', 'stop'] }, { authentication: { signingKeys: { activeKeyId: 'next', keys: { next: 'b'.repeat(48) } } } }, ]) { diff --git a/tests/kernel/nested-retained-owner-journal.test.ts b/tests/kernel/nested-retained-owner-journal.test.ts index 05c047e7..24d2711a 100644 --- a/tests/kernel/nested-retained-owner-journal.test.ts +++ b/tests/kernel/nested-retained-owner-journal.test.ts @@ -8,13 +8,16 @@ import type { import { afterEach, describe, expect, it } from 'vitest' import { createFileRunContext } from '../../src/runtime/supervise/run-context' import type { SpawnEvent, SpawnJournal } from '../../src/runtime/supervise/types' +import { coordinationProxy } from '../helpers/coordination-proxy' import { durableRetainedProvider } from '../helpers/durable-retained-provider' import { supervise } from '../helpers/runtime-with-test-brain' import { scriptedBrain } from './scripted-brain' import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const directories: string[] = [] +const proxies: Awaited>[] = [] afterEach(async () => { + await Promise.all(proxies.splice(0).map((proxy) => proxy.close())) await Promise.all( directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), ) @@ -103,11 +106,16 @@ describe('nested retained owner journal isolation', () => { } }, } + const proxy = await coordinationProxy() + proxies.push(proxy) const coordination = { authentication: { signingKeys: { activeKeyId: 'test', keys: { test: 'nested-test-secret-'.repeat(3) } }, }, - publicUrl: () => 'https://coordination.example/nested', + publicUrl: ({ port }: { port: number }) => { + proxy.forwardTo(port) + return `${proxy.url}/nested` + }, } const finalizer = () => ({ finalizedBy: 'manager' }) const manager = testAgentProfile('manager', { diff --git a/tests/kernel/supervise-coordination-channel-preflight.test.ts b/tests/kernel/supervise-coordination-channel-preflight.test.ts index 9706e0ec..065b46b7 100644 --- a/tests/kernel/supervise-coordination-channel-preflight.test.ts +++ b/tests/kernel/supervise-coordination-channel-preflight.test.ts @@ -8,6 +8,7 @@ * refusal points at, so the same child is admitted once one is present. */ +import { createServer } from 'node:http' import type { AgentProfile } from '@tangle-network/agent-interface' import type { AgentEnvironmentCapabilities, @@ -229,3 +230,41 @@ it('never admits a provider manager when asynchronous endpoint provisioning fail expect(events.some((event) => event.kind === 'settled' && event.status === 'down')).toBe(true) await expect(fetch(localUrl)).rejects.toThrow() }) + +it.each([401, 403, 404, 503, 200, 'timeout'] as const)( + 'refuses a public coordinator failure %s before provider creation', + async (status) => { + const server = createServer((_request, response) => { + if (status !== 'timeout') response.writeHead(status).end('private-upstream-response') + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('endpoint did not bind') + const { provider: base, creates } = neverCreatingProvider() + const provider: AgentEnvironmentProvider = { + ...base, + capabilities: () => ({ create: { runtimeAttachments: { mcp: true } } }), + } + try { + const { events } = await spawnLeadFromRoot( + { + backend: { backend: 'provider', provider }, + coordination: { + authentication: true, + publicUrl: `http://127.0.0.1:${address.port}/manager`, + requestTimeoutMs: 100, + }, + driverRetry: { enabled: false }, + }, + true, + ) + expect(creates()).toBe(0) + expect(events.some((event) => event.kind === 'settled' && event.status === 'down')).toBe(true) + expect(JSON.stringify(events)).toContain('coordination public endpoint preflight failed') + expect(JSON.stringify(events)).not.toContain('private-upstream-response') + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + } + }, +) diff --git a/tests/kernel/supervise-retained-owner-recovery.test.ts b/tests/kernel/supervise-retained-owner-recovery.test.ts index eec1e9ac..6c8b1a3a 100644 --- a/tests/kernel/supervise-retained-owner-recovery.test.ts +++ b/tests/kernel/supervise-retained-owner-recovery.test.ts @@ -4,16 +4,20 @@ import { join } from 'node:path' import type { AgentEnvironment, AgentEnvironmentProvider, + AgentTurnInput, } from '@tangle-network/agent-interface/environment-provider' import { afterEach, describe, expect, it } from 'vitest' import { createFileRunContext } from '../../src/runtime/supervise/run-context' import { supervise } from '../../src/runtime/supervise/supervise' import type { SpawnEvent, SpawnJournal } from '../../src/runtime/supervise/types' +import { coordinationProxy } from '../helpers/coordination-proxy' import { durableRetainedProvider } from '../helpers/durable-retained-provider' import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' const directories: string[] = [] +const proxies: Awaited>[] = [] afterEach(async () => { + await Promise.all(proxies.splice(0).map((proxy) => proxy.close())) await Promise.all( directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), ) @@ -23,6 +27,8 @@ describe('retained external supervisor recovery', () => { it('reconstructs a reprompt interrupted after environment admission without a third dispatch', async () => { const directory = await mkdtemp(join(tmpdir(), 'retained-owner-reprompt-crash-')) directories.push(directory) + const proxy = await coordinationProxy() + proxies.push(proxy) const stateFile = join(directory, 'provider.json') const runDirectory = join(directory, 'run') const context = createFileRunContext(runDirectory) @@ -113,7 +119,8 @@ describe('retained external supervisor recovery', () => { }, publicUrl: (address: { port: number }) => { port = address.port - return 'https://coordination.example/manager' + proxy.forwardTo(port) + return `${proxy.url}/manager` }, }, journal: { @@ -184,6 +191,8 @@ describe('retained external supervisor recovery', () => { async (cleanup) => { const directory = await mkdtemp(join(tmpdir(), 'retained-owner-reprompt-')) directories.push(directory) + const proxy = await coordinationProxy() + proxies.push(proxy) const stateFile = join(directory, 'provider.json') const runDirectory = join(directory, 'run') const context = createFileRunContext(runDirectory) @@ -285,7 +294,8 @@ describe('retained external supervisor recovery', () => { }, publicUrl: (address) => { coordinationPort = address.port - return `https://coordination.example:${address.port}/manager` + proxy.forwardTo(coordinationPort) + return `${proxy.url}/manager` }, }, }, @@ -445,6 +455,8 @@ async function setup( ) { const directory = await mkdtemp(join(tmpdir(), 'retained-owner-')) directories.push(directory) + const proxy = await coordinationProxy() + proxies.push(proxy) const stateFile = join(directory, 'provider.json') const runDirectory = join(directory, 'run') const context = createFileRunContext(runDirectory) @@ -464,7 +476,7 @@ async function setup( method: 'POST', headers: { Authorization: `Bearer ${originalToken}`, - Host: 'coordination.example', + Host: new URL(proxy.url).host, 'content-type': 'application/json', }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }), @@ -564,7 +576,8 @@ async function setup( }, publicUrl: (address) => { port = address.port - return 'https://coordination.example/manager' + proxy.forwardTo(port) + return `${proxy.url}/manager` }, }, }) diff --git a/tests/runtime/cli-executor-shutdown.test.ts b/tests/runtime/cli-executor-shutdown.test.ts index 478f4ed9..6c08769b 100644 --- a/tests/runtime/cli-executor-shutdown.test.ts +++ b/tests/runtime/cli-executor-shutdown.test.ts @@ -104,7 +104,8 @@ describe('CLI shutdown acknowledgement', () => { it('escalates an ignored SIGTERM and waits for actual process exit', async () => { const child = await startChild('') try { - expect(await child.executor.teardown(40)).toEqual({ destroyed: true }) + // The child must be scheduled to record SIGTERM before escalation; full-suite load can exceed 40 ms. + expect(await child.executor.teardown(1_000)).toEqual({ destroyed: true }) expect(await readFile(child.terminated, 'utf8')).toBe('SIGTERM') const pid = Number(await readFile(child.ready, 'utf8')) expect(() => process.kill(pid, 0)).toThrow()