From 5a2ae50a1f236c9c495f43a2278a7f1e82171cd0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 20:02:50 -0700 Subject: [PATCH 1/2] fix(coordination): preflight public endpoints before provider admission --- docs/agent-managed-compute/reliability.md | 8 + scripts/check-model-execution-boundary.mjs | 1 + .../check-model-execution-boundary.test.mjs | 8 + src/mcp/tool-server.ts | 2 +- src/runtime/supervise/coordination-mcp.ts | 16 +- .../supervise/coordination-preflight.test.ts | 150 ++++++++++++++++++ .../supervise/coordination-preflight.ts | 117 ++++++++++++++ tests/helpers/coordination-proxy.ts | 42 +++++ tests/kernel/coordination-mcp.test.ts | 142 +++++++++++++++-- .../nested-retained-owner-journal.test.ts | 10 +- ...ise-coordination-channel-preflight.test.ts | 39 +++++ .../supervise-retained-owner-recovery.test.ts | 10 +- tests/runtime/cli-executor-shutdown.test.ts | 3 +- 13 files changed, 532 insertions(+), 16 deletions(-) create mode 100644 src/runtime/supervise/coordination-preflight.test.ts create mode 100644 src/runtime/supervise/coordination-preflight.ts create mode 100644 tests/helpers/coordination-proxy.ts diff --git a/docs/agent-managed-compute/reliability.md b/docs/agent-managed-compute/reliability.md index 46b001e18..d3ed2113f 100644 --- a/docs/agent-managed-compute/reliability.md +++ b/docs/agent-managed-compute/reliability.md @@ -229,6 +229,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 only before the original credential expires. diff --git a/scripts/check-model-execution-boundary.mjs b/scripts/check-model-execution-boundary.mjs index 21f2e895f..55f71a580 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 b2eae4426..8793d8cc8 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/src/mcp/tool-server.ts b/src/mcp/tool-server.ts index 19cdd94d3..f4b7830d9 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 2a849e085..3cffc060a 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' @@ -556,8 +557,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 000000000..64ecd222e --- /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 000000000..1260abdac --- /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 000000000..d819b25d3 --- /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 fb572e9f9..c7dbab4bb 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 = { @@ -516,6 +531,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) => { @@ -524,6 +588,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( @@ -543,12 +608,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}`, @@ -839,21 +905,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' }), @@ -970,9 +1038,58 @@ 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('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 }, publicUrl }, async (mcp) => { original = mcp.headers @@ -990,7 +1107,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 05c047e7e..24d2711ad 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 9706e0ece..065b46b78 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 7301ceba1..c5ea11772 100644 --- a/tests/kernel/supervise-retained-owner-recovery.test.ts +++ b/tests/kernel/supervise-retained-owner-recovery.test.ts @@ -9,11 +9,14 @@ 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 })), ) @@ -140,6 +143,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) @@ -159,7 +164,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' }), @@ -259,7 +264,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 478f4ed99..6c08769bb 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() From 4b4920a611a0180f6afc0e85f2b174436657e88c Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 21:03:50 -0700 Subject: [PATCH 2/2] fix(release): align and validate the selected dependency cohort --- release/cohort.json | 4 ++-- scripts/release-cohort.test.mjs | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/release/cohort.json b/release/cohort.json index d94bf2e28..88f4d783e 100644 --- a/release/cohort.json +++ b/release/cohort.json @@ -16,8 +16,8 @@ "agentKnowledge": { "name": "@tangle-network/agent-knowledge", "repository": "tangle-network/agent-knowledge", - "version": "17.0.1", - "ref": "6cccacf1f6d254c2b26223bd3ff0934f24b0db99" + "version": "17.0.2", + "ref": "ee172b1ed30d507652f1132eda382f46aadc0f95" } } } diff --git a/scripts/release-cohort.test.mjs b/scripts/release-cohort.test.mjs index 53c1cf297..dc560ae29 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([