Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions docs/self-host/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,8 @@ tool_servers:
| `record_content` | every type | Whether the arguments and results of calls are kept whole, scrubbed of secrets, in the playbook's ledger, where anyone who may read the playbook reads them. `true` when left out; `false` keeps their sizes alone |
| `request_id` | every type | The response header, or for an MCP server the key of a result's metadata, in which the server returns its own id of a request, recorded with each call |

Every request the server makes to an `mcp` or `openapi` server carries `User-Agent: auto-runtime/<version>`, the name of the runtime's command with the version of the release that runs. That covers the read of a document, every call, and the requests for a token and for an authorization server's metadata, for an entry of this file and for a connection alike. A `User-Agent` in an entry's `headers` is sent on that server's calls instead; the read of its document and the requests for its tokens still carry the runtime's.

### Keep secrets out of the file

Only `headers`, `query_params`, `security`, `env` and `auth` may hold a reference. A reference in `url`, `command`, `args`, `document` or `base_url` stops the server at start: an argument shows in the machine's list of processes, and the URL of a server or of a document is no place for a credential.
Expand Down Expand Up @@ -328,7 +330,7 @@ Which credentials a call sends:

- A requirement is a list of alternatives. Each alternative is a set of schemes that must all be given.
- An operation is offered as a tool when the entry gives one of its alternatives.
- A call sends the credentials of the first alternative the entry gives, in the document's order, and no other.
- A call sends the credentials of the first alternative the entry gives, in the document's order, and no other. Where the operation needs a header that the entry's `headers` do not name, it sends the first alternative the entry gives whose `apiKey` schemes place that header instead.
- An operation whose requirement is empty, or of a document that declares none, sends none.
- An operation for which the entry gives none of the alternatives is not offered as a tool. At start, the server logs a warning that names the schemes it needs.
- The scopes an `oauth2` requirement names are the operation's. The `auth` block asks for its own `scope`, so set it to cover what the operations require.
Expand Down Expand Up @@ -370,17 +372,20 @@ Each operation becomes one tool:

Some operations are not offered as tools. At start, the server logs a warning for each operation it leaves out, naming the entry and the reason. It leaves out an operation that:

- needs a header or a cookie;
- needs a cookie;
- needs a header that the entry's `headers` do not name and its security scheme does not place. The warning says that `headers` can give it, or, for a header the server sets itself such as `Host`, that it does. A header parameter named `Accept`, `Content-Type` or `Authorization` is ignored, as the OpenAPI specification says;
- takes an object in its query;
- takes a body that is not JSON;
- is a `TRACE`, or a `GET` or `HEAD` that takes a body;
- has a path parameter of a `style` other than `simple`;
- has a query parameter of a `style` other than `form`, or with `explode: false`;
- has a query parameter of a `style` other than `form`, such as `spaceDelimited`, `pipeDelimited` or `deepObject`;
- has an input schema of more than 64 KiB.

An operation that needs a header the entry's `headers` name, in any case, is offered, such as one that needs an account id on every call. The header is not an input of the tool: every call sends the entry's value. So is an operation that needs the header of an `apiKey` scheme its call sends, which the call fills with the credential the entry gives under `security`.

A call:

- fills the path and the query, with an array sent as repeated keys;
- fills the path and the query, with an array sent as repeated keys, or as one key of comma-joined items when the document writes the parameter with `explode: false`;
- refuses a path argument that is empty, holds a `/`, or is `.` or `..`;
- sends nothing to an address that would leave the origin or the path of `base_url`;
- sends the body as JSON, with the entry's headers and query parameters.
Expand Down
8 changes: 6 additions & 2 deletions packages/outbound/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @on.auto/outbound

How the server calls systems on the internet. It walks the causes of a failed call, and tells a certificate the server does not trust from any other failure of a call: the reasoning function adapter and the tool-servers package take the classification from here, so a model provider and a tool server whose certificate the server does not trust are named alike; every caller keeps its own client and bounds. And it makes the settings of a client of the AWS SDK, which the Bedrock provider of the reasoning capability and the object store of [`@on.auto/object-storage`](../object-storage/README.md) share, and signs the tokens the server signs in to the ledger's database with when `DATABASE_CREDENTIALS=aws-iam`.
How the server calls systems on the internet. It walks the causes of a failed call, and tells a certificate the server does not trust from any other failure of a call: the reasoning function adapter and the tool-servers package take the classification from here, so a model provider and a tool server whose certificate the server does not trust are named alike; every caller keeps its own client and bounds. It names the runtime in the `User-Agent` of each request a `fetch` it wraps sends, which the server gives its tool servers. And it makes the settings of a client of the AWS SDK, which the Bedrock provider of the reasoning capability and the object store of [`@on.auto/object-storage`](../object-storage/README.md) share, and signs the tokens the server signs in to the ledger's database with when `DATABASE_CREDENTIALS=aws-iam`.

## The causes of a failure

Expand All @@ -10,6 +10,10 @@ How the server calls systems on the internet. It walks the causes of a failed ca

`isUntrustedCertificate(error)` says whether any of the codes `codesOf` names is the code of a certificate the server does not trust.

## The User-Agent of a request

`withUserAgent(fetch, userAgent)` (`src/user-agent/user-agent-fetch.ts`) sends each request through the `fetch` it wraps with `User-Agent` set to `userAgent` where the request sets none, in whatever case the request writes the name, so a `User-Agent` the caller writes is the one sent. It reads the request's headers as a `Headers`, a list of pairs of strings or an object of strings, and hands headers of any other shape to the `fetch` beneath as they are, which sends its own (`node` for Node's, measured with Node 26.10.0). The server wraps `globalThis.fetch` once with `auto-runtime/<version>`, the name of the runtime's command and the version of the release it announces to an MCP server in `clientInfo`, since a product token holds no `@` or `/` in its name, and gives that fetch to the tool servers alone (`packages/server/src/composition/served-tool-access.ts`); every other client of the server sends what its own client sends, the model providers, the object store, the exporters of telemetry, the requests to the identity issuer and those of prediction functions among them.

## AWS clients

`proxySettingsOf(environment)`, in the main entry, reads `ProxySettings`, `{ enabled, environment }`: the switch, `NODE_USE_ENV_PROXY=1`, and the proxy's variables that are set, `HTTPS_PROXY`, `HTTP_PROXY` and `NO_PROXY` in either case. The server reads them once and hands them to every client it makes, the model providers' and the object store's.
Expand All @@ -20,4 +24,4 @@ How the server calls systems on the internet. It walks the causes of a failed ca

## Source

`src/causes` holds the walk of a failure's causes, `src/certificates` the classification, `src/proxy` the settings of the environment proxy, and `src/aws` the settings of AWS clients.
`src/causes` holds the walk of a failure's causes, `src/certificates` the classification, `src/user-agent` the `User-Agent` of a request, `src/proxy` the settings of the environment proxy, and `src/aws` the settings of AWS clients.
1 change: 1 addition & 0 deletions packages/outbound/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { causesOf, codesOf } from './causes/error-causes.ts';
export { isUntrustedCertificate } from './certificates/untrusted-certificates.ts';
export { proxySettingsOf, proxySettingsVariables, type ProxySettings } from './proxy/proxy-settings.ts';
export { withUserAgent } from './user-agent/user-agent-fetch.ts';
84 changes: 84 additions & 0 deletions packages/outbound/src/user-agent/user-agent-fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { once } from 'node:events';
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';

import { Schema } from 'effect';
import { afterEach, describe, expect, it } from 'vitest';

import { withUserAgent } from '../index.ts';

interface Heard {
readonly method: string;
readonly userAgent: string | undefined;
readonly tenant: string | undefined;
}

const portOf = Schema.decodeUnknownSync(Schema.Struct({ port: Schema.Number }));

const runtime = 'auto-runtime/1.2.3';

const closing: (() => Promise<void>)[] = [];

afterEach(async () => {
await Promise.all(closing.splice(0).map((close) => close()));
});

async function listening() {
const heard: Heard[] = [];
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
const tenant = request.headers['x-tenant'];
heard.push({
method: String(request.method),
userAgent: request.headers['user-agent'],
tenant: typeof tenant === 'string' ? tenant : undefined,
});
response.writeHead(204).end();
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
closing.push(async () => {
server.closeAllConnections();
server.close();
await once(server, 'close');
});
return { url: new URL(`http://127.0.0.1:${portOf(server.address()).port}/`), heard: () => [...heard] };
}

describe('a request sent through the fetch of the runtime', () => {
it('carries the User-Agent of the runtime where it carries none, whatever form its headers take', async () => {
const api = await listening();
const fetch = withUserAgent(globalThis.fetch, runtime);

await fetch(api.url);
await fetch(api.url.href, { method: 'POST', headers: { 'X-Tenant': 'acme-eu' }, body: 'note' });
await fetch(api.url, { headers: new Headers({ 'X-Tenant': 'acme-us' }) });
await fetch(api.url, { headers: [['X-Tenant', 'acme-ap']] });

expect(api.heard()).toEqual([
{ method: 'GET', userAgent: runtime, tenant: undefined },
{ method: 'POST', userAgent: runtime, tenant: 'acme-eu' },
{ method: 'GET', userAgent: runtime, tenant: 'acme-us' },
{ method: 'GET', userAgent: runtime, tenant: 'acme-ap' },
]);
});

it('keeps a User-Agent it carries already, written in any case and in any form', async () => {
const api = await listening();
const fetch = withUserAgent(globalThis.fetch, runtime);

await fetch(api.url, { headers: { 'User-Agent': 'operator/2.0' } });
await fetch(api.url, { headers: new Headers({ 'user-agent': 'operator/2.1' }) });
await fetch(api.url, { headers: [['USER-AGENT', 'operator/2.2']] });

expect(api.heard().map(({ userAgent }) => userAgent)).toEqual(['operator/2.0', 'operator/2.1', 'operator/2.2']);
});

it('leaves headers it cannot read as written to the fetch beneath it, which sends its own User-Agent', async () => {
const api = await listening();
const fetch = withUserAgent(globalThis.fetch, runtime);

await fetch(api.url, { headers: { 'X-Tenant': 7 } });
await expect(fetch(api.url, { headers: [['X-Tenant', 'acme-eu', 'more']] })).rejects.toThrow(TypeError);

expect(api.heard()).toEqual([{ method: 'GET', userAgent: 'node', tenant: '7' }]);
});
});
39 changes: 39 additions & 0 deletions packages/outbound/src/user-agent/user-agent-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Predicate } from 'effect';

type OutboundFetch = (url: unknown, init?: unknown) => Promise<Response>;

type HeadersInit = NonNullable<ConstructorParameters<typeof Headers>[0]>;

function isHeaderPair(written: unknown): written is [string, string] {
return Array.isArray(written) && written.length === 2 && written.every((part) => typeof part === 'string');
}

function isHeaderRecord(written: unknown): written is Readonly<Record<string, string>> {
return Predicate.isObject(written) && Object.values(written).every((value) => typeof value === 'string');
}

function isHeadersInit(written: unknown): written is HeadersInit {
return (
written instanceof Headers ||
(Array.isArray(written) && written.every((pair) => isHeaderPair(pair))) ||
isHeaderRecord(written)
);
}

function headersOf(init: unknown): Headers | undefined {
const written: unknown = Predicate.hasProperty(init, 'headers') ? init.headers : undefined;
if (written === undefined) {
return new Headers();
}
return isHeadersInit(written) ? new Headers(written) : undefined;
}

export function withUserAgent(fetch: typeof globalThis.fetch, userAgent: string): OutboundFetch {
return (url, init) => {
const headers = headersOf(init);
if (headers !== undefined && !headers.has('user-agent')) {
headers.set('user-agent', userAgent);
}
return fetch(String(url), Object.assign({}, init, headers === undefined ? {} : { headers }));
};
}
5 changes: 4 additions & 1 deletion packages/server/src/composition/served-tool-access.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AppRuntime } from '@on.auto/api';
import type { DispatcherServices } from '@on.auto/operations';
import { withUserAgent } from '@on.auto/outbound';
import {
describedToolServers,
makeToolAccess,
Expand Down Expand Up @@ -33,12 +34,14 @@ export async function toolAccessOf(
{ toolTiming, connections, telemetry }: ToolAccessWiring & ServedConnections,
): Promise<ToolAccess> {
const traceMeta = telemetry?.traceMeta;
const outboundFetch = withUserAgent(globalThis.fetch, `auto-runtime/${release.version}`);
const reportLeftOut: Describing['reportLeftOut'] = (server, leftOut) => {
void runtime.run(logLeftOutOperation(server, leftOut));
};
const servers = await Effect.runPromise(describedToolServers(toolServers, { reportLeftOut }));
const servers = await Effect.runPromise(describedToolServers(toolServers, { fetch: outboundFetch, reportLeftOut }));
return makeToolAccess(servers, {
clientInfo: release,
fetch: outboundFetch,
connections,
reportLeftOut,
...(toolTiming === undefined ? {} : { timing: toolTiming }),
Expand Down
44 changes: 44 additions & 0 deletions packages/server/src/tool-servers/runtime-user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { serveFakeApi } from '@on.auto/tool-servers/testing';
import { afterEach, describe, expect, it } from 'vitest';

import { release } from '../composition/release.ts';
import { alpha, servingReasoning } from '../testing/servers/reasoning-server.ts';

const apiKey = 'billing-api-key-5c2e8d1f';

const runtime = `auto-runtime/${release.version}`;

const closing: (() => Promise<void>)[] = [];

afterEach(async () => {
await Promise.all(closing.splice(0).map((close) => close()));
});

describe('the requests the server makes to its tool servers', () => {
it('name the command of the runtime and its release as their User-Agent, but on the calls of an entry that writes its own', async () => {
const billing = await serveFakeApi({ key: apiKey });
closing.push(billing.close);
const entry = { document: billing.documentUrl, security: { apiKeyAuth: '${BILLING_KEY}' }, org: 'acme' };
const own = { ...entry, headers: { 'User-Agent': 'operator-agent/2.0' } };
const server = await servingReasoning([], {
BILLING_KEY: apiKey,
TOOL_SERVERS: JSON.stringify({ billing: entry, ledger: own }),
});
closing.push(server.stop);
await server.call('POST', '/v1/orgs/acme/playbooks', { body: { playbook: 'alpha', name: 'Alpha' } });
const search = { body: { arguments: { query: 'acme' } } };

const called = [
await server.call('POST', `${alpha}/tool-servers/billing/tools/search/execute`, search),
await server.call('POST', `${alpha}/tool-servers/ledger/tools/search/execute`, search),
];

expect(called.map(({ status }) => status)).toEqual([200, 200]);
expect(billing.heard().map(({ path, userAgent }) => [path, userAgent])).toEqual([
['/openapi.json', runtime],
['/openapi.json', runtime],
['/v1/search?query=acme', runtime],
['/v1/search?query=acme', 'operator-agent/2.0'],
]);
});
});
Loading