From 28c8905e3bf9a5d5d78b1f66df9859ce6cd71e1e Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 19:57:42 -0700 Subject: [PATCH 1/3] feat(runtime): expose supervised node context to live validators --- api-surface.json | 4 +- docs/api/primitive-catalog.md | 2 +- docs/api/runtime.md | 17 +++ docs/canonical-api.md | 4 +- package.json | 2 +- src/runtime/environment-provider.ts | 12 +- src/runtime/supervise/scope.ts | 4 + src/runtime/supervise/types.ts | 2 + src/runtime/types.ts | 6 + .../fixtures/agent-improvement-proposal.json | 10 +- .../agent-profile-improvement-proposal.json | 6 +- .../provider-executor-composition.test.ts | 37 +++++ .../kernel/provider-validator-context.test.ts | 126 ++++++++++++++++++ 13 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 tests/kernel/provider-validator-context.test.ts diff --git a/api-surface.json b/api-surface.json index 01741c80..9ba82049 100644 --- a/api-surface.json +++ b/api-surface.json @@ -925,7 +925,7 @@ "ExecutorExecutionBinding": "type cbd52e7e2eb8", "ExecutorFactory": "type 0d6f475ad3d4", "ExecutorMaterialization": "type ceafe44da26b", - "ExecutorNodeContext": "type 7f86daa88edc", + "ExecutorNodeContext": "type 17a7348871c5", "ExecutorProgress": "type c91983468166", "ExecutorProgressEvent": "type 19b8d5b7224e", "ExecutorRegistry": "type 2abca0065370", @@ -1306,7 +1306,7 @@ "UntrackedCopyStats": "type 3d60a38b88e2", "UsageEvent": "type 110014e66fac", "VERIFY_TAIL_CHARS": "value 95999f4bd438", - "ValidationCtx": "type 45da43f45d39", + "ValidationCtx": "type 4905637394bb", "Validator": "type c67346b2cb69", "VerifierEnvironmentOptions": "type 8381e1b6dc2e", "Verify": "type 4e1beeeba7ee", diff --git a/docs/api/primitive-catalog.md b/docs/api/primitive-catalog.md index b0a563f5..262d0257 100644 --- a/docs/api/primitive-catalog.md +++ b/docs/api/primitive-catalog.md @@ -7,7 +7,7 @@ # Primitive catalog — the never-stale anti-reinvention inventory -> **GENERATED** from `@tangle-network/agent-runtime@0.229.0` and `@tangle-network/agent-eval@0.182.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. +> **GENERATED** from `@tangle-network/agent-runtime@0.230.0` and `@tangle-network/agent-eval@0.182.0` by `scripts/gen-primitive-catalog.mjs`. Do NOT hand-edit — run `pnpm run docs:api`. This is the mechanical companion to the JUDGMENT in `canonical-api.md` (§2 decision table + §1.5 AgentProfile law): that doc says WHICH primitive to reach for and what NOT to build; this catalog proves WHAT exists. Per-symbol signatures + `file:line` live in the per-module pages under `docs/api/`. ## 1. agent-runtime — own public surface diff --git a/docs/api/runtime.md b/docs/api/runtime.md index cbfc58b9..9baa4a86 100644 --- a/docs/api/runtime.md +++ b/docs/api/runtime.md @@ -4186,6 +4186,8 @@ OPT-IN executable score for this worker, with the SAME contract the sandbox seam has: `validate` runs while the environment is still alive, so `ValidationCtx.box` can read files and run commands in the environment it is scoring. Every other supervised hook fires after teardown and can only read the artifact. +`ValidationCtx.node` identifies the supervised node, including its recursion depth, so a +shared validator can apply a root-only contract without applying it to nested managers. The verdict becomes the settled artifact's verdict. Absent, nothing changes and the leaf falls back to its own settle verdict. @@ -17701,6 +17703,8 @@ OPT-IN executable score for this worker, with the SAME contract the sandbox seam has: `validate` runs while the environment is still alive, so `ValidationCtx.box` can read files and run commands in the environment it is scoring. Every other supervised hook fires after teardown and can only read the artifact. +`ValidationCtx.node` identifies the supervised node, including its recursion depth, so a +shared validator can apply a root-only contract without applying it to nested managers. The verdict becomes the settled artifact's verdict. Absent, nothing changes and the leaf falls back to its own settle verdict. @@ -21427,6 +21431,12 @@ Kernel-owned context for the concrete supervised node a factory is constructing. > `readonly` **nodeId**: `string` +##### depth? + +> `readonly` `optional` **depth?**: `number` + +Recursion depth supplied by Runtime scopes (root = 0). Standalone callers may omit it. + ##### attemptId > `readonly` **attemptId**: `string` @@ -24218,6 +24228,13 @@ Live sandbox for this iteration. Validators that need execution-grounded evidence can inspect files or run commands here instead of forcing callers to bypass the loop kernel with raw Sandbox SDK orchestration. +##### node? + +> `readonly` `optional` **node?**: [`ExecutorNodeContext`](#executornodecontext) + +Detached, immutable node identity supplied by supervised provider execution. +Runtime scopes include depth (root = 0); standalone execution may omit this context. + ##### signal > **signal**: `AbortSignal` diff --git a/docs/canonical-api.md b/docs/canonical-api.md index 3fc009a2..e9234d3c 100644 --- a/docs/canonical-api.md +++ b/docs/canonical-api.md @@ -4,7 +4,7 @@ Generated signatures and the complete export list live in docs/api/. Run pnpm docs:freshness after editing this file. --> -> **Version 0.229.0.** +> **Version 0.230.0.** > [`docs/api/primitive-catalog.md`](./api/primitive-catalog.md) lists every export and import path. > `agent-eval` must satisfy `>=0.182.0 <0.183.0`. > `sandbox` must satisfy `>=0.36.4 <0.40.0`. @@ -175,7 +175,7 @@ A general "loop" primitive is the single most common modelling error in this rep | Pick the **execution transport a driven loop runs on** (`sandbox` box / cli-bridge / router) from a product flag | `resolveSandboxClient({ backend })`: `/kernel` | a per-product `if (backend === 'router') …` branch re-wiring `createExecutor` + `inlineSandboxClient` | | Adapt an exact `AgentProfile` to agent-eval's `ChatClient` without moving credentials or execution policy into Eval | `profileChatClient({ profile, executor, context })`: `/kernel` | a provider fetch configured separately from the profile, or request fields that override the profile's model policy | | Pick / register a leaf backend, or bring your own agent | `createExecutor({ backend })` / `createExecutorRegistry()` / implement `Executor`: `/kernel` | a per-vendor adapter or closed `inline\|sandbox\|cli` switch (won't report through the `UsageEvent` channel) | -| Run workers on a Tangle box — or any provider's boxes — through the `AgentEnvironmentProvider` contract (`createTangleProvider` in production), carrying a caller-owned session credential (`backend.model.authMode` + `authFiles`), a per-turn `timeoutMs`, or platform `context` on every turn | `createExecutor({ backend: 'provider', provider })`: `/kernel` — Runtime depends on no provider package, so any object satisfying the contract composes the same way. Create fields ride the neutral create input through `ProviderSeam.defaults`; per-turn configuration rides `ProviderSeam.promptOptions` — the same field and the same name the sandbox path spells `ExecCtx.promptOptions` — merged UNDER every streamed turn on the plain and the steerable provider path alike, with the mapped turn's own field winning and the runtime's abort signal applied last. `ProviderSeam.validator` scores the worker while the environment is STILL ALIVE, so the check can read files or run commands in the environment it grades. `create` resolving is the provider's readiness promise; this seam adds no wait of its own. Worked offline wiring: [`examples/provider-executor`](../examples/provider-executor/) | wrapping the `SandboxClient` to reach a create field (the wrapper is invisible to every record the run produces), writing a session credential into the `AgentProfile`, a turn-level model override (refused: the materialization record names the profile's model), or a second readiness poll around a provider that already promises a ready environment | +| Run workers on a Tangle box — or any provider's boxes — through the `AgentEnvironmentProvider` contract (`createTangleProvider` in production), carrying a caller-owned session credential (`backend.model.authMode` + `authFiles`), a per-turn `timeoutMs`, or platform `context` on every turn | `createExecutor({ backend: 'provider', provider })`: `/kernel` — Runtime depends on no provider package, so any object satisfying the contract composes the same way. Create fields ride the neutral create input through `ProviderSeam.defaults`; per-turn configuration rides `ProviderSeam.promptOptions` — the same field and the same name the sandbox path spells `ExecCtx.promptOptions` — merged UNDER every streamed turn on the plain and the steerable provider path alike, with the mapped turn's own field winning and the runtime's abort signal applied last. `ProviderSeam.validator` scores the worker while the environment is STILL ALIVE, so the check can read files or run commands in the environment it grades. The immutable `ValidationCtx.node` includes the Runtime node and recursion depth; check `ctx.node?.depth === 0` for a root-only contract shared with nested directors. `create` resolving is the provider's readiness promise; this seam adds no wait of its own. Worked offline wiring: [`examples/provider-executor`](../examples/provider-executor/) | wrapping the `SandboxClient` to reach a create field (the wrapper is invisible to every record the run produces), writing a session credential into the `AgentProfile`, a turn-level model override (refused: the materialization record names the profile's model), or a second readiness poll around a provider that already promises a ready environment | | Carry one portable model identity across direct and CLI Bridge execution | Set `model.provider` and use either a bare `model.default` or one qualified by that exact provider. Runtime removes only the matching provider prefix at a direct provider boundary. CLI Bridge receives the complete `runner/provider/model` route. | stripping the first path segment without checking the provider, or rewriting the model in a product client | | Run a worker as a **conversation on a bare `/v1/chat/completions` endpoint** (no sandbox), with session continuity for `continuity: 'resume'` graphs | `chatTransportExecutor(options)` + `chatWorkerSeam({ url, sessions?, deliverable? })` + `createChatSessionStore()`: `/kernel` | a leaf-seam fake of a chat worker, a multishot transcript loop outside the kernel (no ledger, no conserved pool), or a resume that re-primes a fresh session | | Optimize text or named components with upstream GEPA | `officialGepa({ recipe, ... })`, passed as `improve(...).method` from root `.` | a local GEPA approximation, prompt mutation loop, or silent fallback when Python is unavailable | diff --git a/package.json b/package.json index 9ed4e73a..9360a58e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.229.0", + "version": "0.230.0", "description": "Shared task-lifecycle skeleton for agents: a recursive loop kernel for chat turns, one-shot tasks, and multi-attempt loops, with trace capture and eval-gated self-improvement. Domain behavior lives in adapters; scoring and ship-gates in @tangle-network/agent-eval.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/runtime/environment-provider.ts b/src/runtime/environment-provider.ts index 302ce49f..4fa0acf9 100644 --- a/src/runtime/environment-provider.ts +++ b/src/runtime/environment-provider.ts @@ -115,6 +115,7 @@ import type { ExecutorExecutionBinding, ExecutorFactory, ExecutorMaterialization, + ExecutorNodeContext, ExecutorResult, Runtime, Spend, @@ -557,6 +558,8 @@ export interface ProviderExecutorOptions { * has: `validate` runs while the environment is still alive, so `ValidationCtx.box` can read * files and run commands in the environment it is scoring. Every other supervised hook fires * after teardown and can only read the artifact. + * `ValidationCtx.node` identifies the supervised node, including its recursion depth, so a + * shared validator can apply a root-only contract without applying it to nested managers. * * The verdict becomes the settled artifact's verdict. Absent, nothing changes and the leaf falls * back to its own settle verdict. @@ -640,6 +643,8 @@ function createProviderExecutor( placement?: { id: string; digest: string }, ): Executor { const controller = linkAbort(ctx.signal) + const node = + ctx.node === undefined ? undefined : detachedSnapshot(ctx.node, 'provider executor node') let environment: AgentEnvironment | undefined let artifact: ExecutorResult | undefined @@ -664,8 +669,8 @@ function createProviderExecutor( 'provider placement: profileForCreate cannot change the selected profile', ) } - const executionId = retention?.executionId ?? ctx.node?.nodeId ?? `provider-run-${randomUUID()}` - const attemptId = ctx.node?.attemptId ?? newExecutionAttemptId(executionId) + const executionId = retention?.executionId ?? node?.nodeId ?? `provider-run-${randomUUID()}` + const attemptId = node?.attemptId ?? newExecutionAttemptId(executionId) const trace = createPushTraceSource({ runId: executionId }) const providerModel = concreteProfileModel(createProfile) // The provider owns the model call inside its environment and the create input carries no @@ -725,6 +730,7 @@ function createProviderExecutor( signal, controller, options, + ...(node === undefined ? {} : { node }), retention, executionId, trace, @@ -904,6 +910,7 @@ interface StreamProviderExecutorArgs { signal: AbortSignal controller: AbortController options: ProviderExecutorOptions + node?: ExecutorNodeContext retention?: RetainedExecutorContext executionId: string trace: ReturnType @@ -1081,6 +1088,7 @@ async function* streamProviderExecutor( // hook fires after teardown and can only read the artifact. const verdict = await args.options.validator?.validate(result, { iteration: 0, + ...(args.node === undefined ? {} : { node: args.node }), box: environmentAsSandboxInstance(environment, { requireTerminalEvent: args.options.requireTerminalEvent ?? true, }), diff --git a/src/runtime/supervise/scope.ts b/src/runtime/supervise/scope.ts index 756e12f2..83d24c2f 100644 --- a/src/runtime/supervise/scope.ts +++ b/src/runtime/supervise/scope.ts @@ -877,6 +877,7 @@ export function createScope(args: ScopeArgs): Scope { rootId: args.root, parentId: args.parentId, nodeId: id, + depth: args.depth + 1, attemptId, ...(identity ? { identity } : {}), }, @@ -1870,6 +1871,7 @@ export function createScope(args: ScopeArgs): Scope { journal: args.journal, root: args.ownerMaterialization.journalRoot ?? args.root, nodeId: args.ownerMaterialization.nodeId ?? args.parentId, + depth: args.depth, runtime: args.ownerMaterialization.runtime, attemptId: args.ownerMaterialization.attemptId, ...(authoredProfile === undefined ? {} : { authoredProfile }), @@ -2057,6 +2059,7 @@ interface OwnerMaterializationState { readonly journal: SpawnJournal readonly root: NodeId readonly nodeId: NodeId + readonly depth: number readonly runtime: NodeSnapshot['runtime'] /** Rotated by `beginScopeOwnerAttempt` on every driver attempt after the first. */ attemptId: string @@ -2197,6 +2200,7 @@ export function scopeOwnerExecutorNodeContext(scope: Scope): ExecutorNo rootId: state.root, parentId: state.nodeId, nodeId: state.nodeId, + depth: state.depth, attemptId: state.attemptId, }) } diff --git a/src/runtime/supervise/types.ts b/src/runtime/supervise/types.ts index 297e7fc7..c93f024f 100644 --- a/src/runtime/supervise/types.ts +++ b/src/runtime/supervise/types.ts @@ -701,6 +701,8 @@ export interface ExecutorNodeContext { readonly rootId: NodeId readonly parentId: NodeId readonly nodeId: NodeId + /** Recursion depth supplied by Runtime scopes (root = 0). Standalone callers may omit it. */ + readonly depth?: number /** Kernel-minted identity for this concrete execution attempt. */ readonly attemptId: string readonly identity?: NodeExecutionIdentity diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 7b872887..28d724a8 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -24,6 +24,7 @@ import type { import type { AgentRunOutcome } from '@tangle-network/sandbox/runtime' import type { RuntimeHooks } from '../runtime-hooks' import type { RuntimeRunHandle } from '../runtime-run' +import type { ExecutorNodeContext } from './supervise/types' // DefaultVerdict is a substrate primitive — it lives in @tangle-network/agent-eval. // agent-runtime re-exports it here so existing consumers keep working without @@ -42,6 +43,11 @@ export interface ValidationCtx { * to bypass the loop kernel with raw Sandbox SDK orchestration. */ box?: SandboxInstance + /** + * Detached, immutable node identity supplied by supervised provider execution. + * Runtime scopes include depth (root = 0); standalone execution may omit this context. + */ + readonly node?: ExecutorNodeContext /** Cooperative cancellation channel. */ signal: AbortSignal /** diff --git a/src/testing/fixtures/agent-improvement-proposal.json b/src/testing/fixtures/agent-improvement-proposal.json index 04676d99..83b53941 100644 --- a/src/testing/fixtures/agent-improvement-proposal.json +++ b/src/testing/fixtures/agent-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt"], - "digest": "sha256:d919d82d0c32b394f2aa10eedeba5a6c96c03efc21e4c0035d6482938124fadf", + "digest": "sha256:8a6be0dffd2acd2002525433806319410ab64dfbd34b1bee9379b70624367cea", "evaluation": { "decision": { "contributingChecks": [ @@ -4882,7 +4882,7 @@ ], "metadata": { "fixture": "agent-improvement-proposal", - "runtimeVersion": "0.229.0" + "runtimeVersion": "0.230.0" }, "objectives": [ { @@ -4993,8 +4993,8 @@ "baselineContentHash": "sha256:5c21ee53e513fc604cb09754e21c392b24a424da0ef37dbf8f1ee4a8a0b08f09", "candidateContentHash": "sha256:60fcbb1c728194bd51d7d19cb732d1c3f1881dce7e0a6266b41c8b98cfd65693", "kind": "agent-eval-loop", - "recordDigest": "sha256:5decf029a05932d379bcdfcfe1732ff756ec74f722afd834b86c1bd9351a9b3e", - "runId": "agent-runtime-0.229.0-proposal-fixture", + "recordDigest": "sha256:cabdbcdaff1e9ce4cfab7e2b17dc38bb6e342a5f27c5981ef99a4c956c388f5f", + "runId": "agent-runtime-0.230.0-proposal-fixture", "schema": "agent-candidate-experiment" } }, @@ -5021,5 +5021,5 @@ ], "kind": "agent-improvement-proposal", "proposedAt": "2026-07-10T01:00:00.000Z", - "runId": "agent-runtime-0.229.0-proposal-fixture" + "runId": "agent-runtime-0.230.0-proposal-fixture" } diff --git a/src/testing/fixtures/agent-profile-improvement-proposal.json b/src/testing/fixtures/agent-profile-improvement-proposal.json index bb507851..ebb76657 100644 --- a/src/testing/fixtures/agent-profile-improvement-proposal.json +++ b/src/testing/fixtures/agent-profile-improvement-proposal.json @@ -1,6 +1,6 @@ { "changedSurfaces": ["prompt", "skills"], - "digest": "sha256:3847ba4ead8ca34693e05a04196e93819183c6e6e17eadf927de17870d45288a", + "digest": "sha256:84097d923139e2b6787636e3c834206b2de90fd7d9ec3228728435c6da757e48", "evaluation": { "decision": { "contributingChecks": [ @@ -1715,7 +1715,7 @@ ], "metadata": { "fixture": "agent-profile-improvement-proposal", - "runtimeVersion": "0.229.0" + "runtimeVersion": "0.230.0" }, "objectives": [ { @@ -1826,7 +1826,7 @@ "baselineContentHash": "sha256:21c495a37c418c10bde64fbaa188beddeed31f1f051ea60a6a6582a9ee0db704", "candidateContentHash": "sha256:103f77bc8481601eef1ad5fe6ba84a40dffabc3a44f421f8c8559121edab84e9", "kind": "agent-eval-loop", - "recordDigest": "sha256:eeda522856de67fbb34f9a9221bba1429b2f769b6dedaefc7c103bf1008802a9", + "recordDigest": "sha256:0595d437053c831277ba2a92ac88716ba863913dfe1f07a6d4a31a85398a93a0", "runId": "profile-improvement-1", "schema": "agent-profile-improvement-experiment" } diff --git a/tests/kernel/provider-executor-composition.test.ts b/tests/kernel/provider-executor-composition.test.ts index 0e498f66..52d27374 100644 --- a/tests/kernel/provider-executor-composition.test.ts +++ b/tests/kernel/provider-executor-composition.test.ts @@ -211,6 +211,7 @@ describe("createExecutor({ backend: 'provider' })", () => { provider, validator: { async validate(out, validationCtx) { + expect(validationCtx.node).toBeUndefined() // The environment is still alive here: the check runs a command inside the box it // is scoring, which no post-teardown hook can do. const proof = await validationCtx.box?.exec('cat answer.txt') @@ -227,6 +228,42 @@ describe("createExecutor({ backend: 'provider' })", () => { expect(lifecycle).toEqual(['exec:cat answer.txt', 'destroy']) }) + it('detaches and freezes the validator node before execution begins', async () => { + const { provider } = recordingProvider() + const node = { + rootId: 'root', + parentId: 'root', + nodeId: 'root:s0', + depth: 1, + attemptId: 'root:s0:attempt:1', + identity: { correlation: { project: 'original' } }, + } + const executor = createExecutor({ + backend: 'provider', + provider, + validator: { + async validate(_out, validationCtx) { + expect(validationCtx.node).not.toBe(node) + expect(validationCtx.node?.depth).toBe(1) + expect(validationCtx.node?.identity?.correlation?.project).toBe('original') + expect(Object.isFrozen(validationCtx.node)).toBe(true) + expect(Object.isFrozen(validationCtx.node?.identity?.correlation)).toBe(true) + expect(() => Object.assign(validationCtx.node ?? {}, { depth: 0 })).toThrow() + return { valid: true, score: 1 } + }, + }, + })(spec, { ...ctx(), node }) + node.depth = 9 + node.identity.correlation.project = 'changed' + + for await (const _event of executor.execute('work', new AbortController().signal)) { + // Drain before reading the checked artifact. + } + + expect((await executor.resultArtifact()).verdict).toEqual({ valid: true, score: 1 }) + expect(node.depth).toBe(9) + }) + it('carries the declared prompt options through the steerable session', async () => { const turns: AgentTurnInput[] = [] const provider: AgentEnvironmentProvider = { diff --git a/tests/kernel/provider-validator-context.test.ts b/tests/kernel/provider-validator-context.test.ts new file mode 100644 index 00000000..2da869c2 --- /dev/null +++ b/tests/kernel/provider-validator-context.test.ts @@ -0,0 +1,126 @@ +import type { + AgentEnvironmentProvider, + CreateAgentEnvironmentInput, +} from '@tangle-network/agent-interface/environment-provider' +import { expect, it } from 'vitest' +import { supervise } from '../../src/runtime/supervise/supervise' +import type { ExecutorNodeContext } from '../../src/runtime/supervise/types' +import { runtimeToolDeclarations, testAgentProfile } from './test-agent-profile' + +async function callTool( + input: CreateAgentEnvironmentInput, + name: string, + args: Record, +): Promise> { + const server = input.runtimeAttachments?.mcp['agent-runtime-coordination'] + if (server?.transport !== 'http') throw new Error('missing coordination attachment') + const headers = Object.fromEntries( + Object.entries(server.headers ?? {}).map(([key, value]) => { + if (value.kind !== 'secret-ref' || value.format !== 'bearer') { + throw new Error('expected private bearer reference') + } + return [key, `Bearer ${input.env?.[value.key]}`] + }), + ) + const response = await fetch(server.url, { + method: 'POST', + headers: { ...headers, 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: name, + method: 'tools/call', + params: { name, arguments: args }, + }), + }) + const reply = (await response.json()) as { + result?: { structuredContent?: Record; isError?: boolean } + } + if (!response.ok || reply.result?.isError || !reply.result?.structuredContent) { + throw new Error(`coordination ${name} failed: ${JSON.stringify(reply)}`) + } + return reply.result.structuredContent +} + +it('scopes a live product check to the root across a provider director and its leaf', async () => { + const tools = runtimeToolDeclarations('spawn_worker', 'await_event') + const root = testAgentProfile('root', { harness: 'codex', tools }) + const director = testAgentProfile('director', { harness: 'codex', tools }) + const leaf = testAgentProfile('leaf', { harness: 'codex' }) + const checked: Array<{ name: string; node: ExecutorNodeContext }> = [] + const commands: string[] = [] + const destroyed = new Set() + const provider: AgentEnvironmentProvider = { + name: 'recursive-validation-fixture', + capabilities: () => ({ create: { runtimeAttachments: { mcp: true } } }), + async create(input) { + const name = input.profile?.name ?? 'missing' + return { + id: `env-${name}`, + provider: 'recursive-validation-fixture', + status: async () => 'running', + destroy: async () => { + destroyed.add(name) + }, + exec: async (command) => { + expect(destroyed.has(name)).toBe(false) + commands.push(`${name}:${command}`) + return { exitCode: name === 'root' ? 0 : 1, stdout: '', stderr: '' } + }, + async *stream() { + if (name !== 'leaf') { + const spawned = await callTool(input, 'spawn_worker', { + profile: name === 'root' ? director : leaf, + task: 'Complete this assignment.', + budget: { + maxIterations: name === 'root' ? 8 : 2, + maxTokens: name === 'root' ? 1000 : 100, + }, + }) + expect(typeof spawned.workerId, JSON.stringify(spawned)).toBe('string') + await callTool(input, 'await_event', { timeoutMs: 5000 }) + } + yield { type: 'text', data: { text: name } } + yield { type: 'done', data: { outcome: { type: 'completed' } } } + }, + } + }, + } + + const result = await supervise(root, 'Build the product.', { + runId: 'validator-context', + backend: { + backend: 'provider', + provider, + validator: { + async validate(out, ctx) { + if (ctx.node?.depth === undefined) throw new Error('missing supervised depth') + checked.push({ name: out.content, node: ctx.node }) + if (ctx.node.depth !== 0) return { valid: out.content.length > 0, score: 1 } + const check = await ctx.box?.exec('verify-product') + return { valid: check?.exitCode === 0, score: 1 } + }, + }, + }, + budget: { maxIterations: 32, maxTokens: 4000 }, + perWorker: { maxIterations: 8, maxTokens: 1000 }, + driverRetry: { enabled: false }, + coordination: { + authentication: true, + publicUrl: ({ port }) => `http://127.0.0.1:${port}/mcp`, + }, + }) + + expect( + checked.map(({ name, node }) => ({ name, depth: node.depth })), + JSON.stringify(result), + ).toEqual([ + { name: 'leaf', depth: 2 }, + { name: 'director', depth: 1 }, + { name: 'root', depth: 0 }, + ]) + expect(new Set(checked.map(({ node }) => node.nodeId)).size).toBe(3) + expect(new Set(checked.map(({ node }) => node.attemptId)).size).toBe(3) + expect(checked.every(({ node }) => Object.isFrozen(node))).toBe(true) + expect(commands).toEqual(['root:verify-product']) + expect([...destroyed]).toEqual(['leaf', 'director', 'root']) +}) From 5a2ae50a1f236c9c495f43a2278a7f1e82171cd0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 20:02:50 -0700 Subject: [PATCH 2/3] 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 46b001e1..d3ed2113 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 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/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 2a849e08..3cffc060 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 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 fb572e9f..c7dbab4b 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 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 7301ceba..c5ea1177 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 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() From 4b4920a611a0180f6afc0e85f2b174436657e88c Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 15 Sep 2026 21:03:50 -0700 Subject: [PATCH 3/3] 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 d94bf2e2..88f4d783 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 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([