melchizedek-agents 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/AGENT_SETUP.md +3 -1
- package/CHANGELOG.md +232 -0
- package/DOCUMENTATION.md +91 -16
- package/ONBOARDING.md +339 -0
- package/QUICKSTART.md +15 -2
- package/README.md +5 -2
- package/config/agents/examples/council.yaml +1 -1
- package/config/agents/examples/critic.yaml +8 -8
- package/config/agents/examples/image_production.yaml +4 -3
- package/config/agents/syndicate.schema.json +65 -7
- package/config/agents/syndicateSchema.yaml +18 -6
- package/config/agents/templates/intake_extractor.yaml +3 -2
- package/config/agents/templates/systems_operator.yaml +5 -2
- package/dist/lib/a2a/app.d.ts +12 -0
- package/dist/lib/a2a/app.js +22 -11
- package/dist/lib/a2a/executor.js +14 -6
- package/dist/lib/a2a/oauthSetup.d.ts +16 -0
- package/dist/lib/a2a/oauthSetup.js +57 -3
- package/dist/lib/agentDialect.d.ts +18 -8
- package/dist/lib/agentDialect.js +36 -19
- package/dist/lib/compile.d.ts +38 -7
- package/dist/lib/compile.js +57 -37
- package/dist/lib/compileNative.d.ts +11 -3
- package/dist/lib/compileNative.js +18 -1
- package/dist/lib/doctor.d.ts +76 -0
- package/dist/lib/doctor.js +159 -7
- package/dist/lib/loadSyndicate.d.ts +9 -0
- package/dist/lib/models/capabilities.d.ts +9 -0
- package/dist/lib/models/capabilities.js +24 -0
- package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
- package/dist/lib/models/chatCompletionsAdapter.js +19 -5
- package/dist/lib/models/claudeAdapter.js +4 -0
- package/dist/lib/models/contract.d.ts +5 -2
- package/dist/lib/models/gateway.d.ts +2 -1
- package/dist/lib/models/gateway.js +4 -2
- package/dist/lib/models/gatewayAdapter.d.ts +7 -1
- package/dist/lib/models/gatewayAdapter.js +8 -0
- package/dist/lib/models/geminiAdapter.js +8 -2
- package/dist/lib/models/genaiMapping.d.ts +8 -4
- package/dist/lib/models/genaiMapping.js +23 -9
- package/dist/lib/models/gptAdapter.d.ts +4 -2
- package/dist/lib/models/gptAdapter.js +11 -4
- package/dist/lib/models/grokAdapter.d.ts +3 -1
- package/dist/lib/models/grokAdapter.js +11 -3
- package/dist/lib/models/kimiAdapter.d.ts +2 -1
- package/dist/lib/models/kimiAdapter.js +3 -3
- package/dist/lib/models/reasoning.d.ts +30 -2
- package/dist/lib/models/reasoning.js +56 -5
- package/dist/lib/onboarding.d.ts +134 -0
- package/dist/lib/onboarding.js +539 -0
- package/dist/lib/runtime/approvals.d.ts +2 -0
- package/dist/lib/runtime/approvals.js +4 -3
- package/dist/lib/runtime/credentials.d.ts +6 -0
- package/dist/lib/runtime/native/agentLoop.d.ts +5 -2
- package/dist/lib/runtime/native/agentLoop.js +2 -0
- package/dist/lib/runtime/native/checkpoint.d.ts +12 -5
- package/dist/lib/runtime/native/checkpoint.js +13 -19
- package/dist/lib/runtime/native/delegate.d.ts +82 -1
- package/dist/lib/runtime/native/delegate.js +157 -2
- package/dist/lib/runtime/native/interrupts.d.ts +88 -5
- package/dist/lib/runtime/native/interrupts.js +220 -30
- package/dist/lib/runtime/syndicateTurn.d.ts +4 -2
- package/dist/lib/runtime/syndicateTurn.js +295 -62
- package/dist/lib/runtime/turnControl.d.ts +41 -0
- package/dist/lib/session/transcript.d.ts +9 -0
- package/dist/lib/session/transcript.js +20 -1
- package/dist/lib/skills.d.ts +18 -0
- package/dist/lib/skills.js +73 -1
- package/dist/lib/storage/postgres/index.d.ts +6 -1
- package/dist/lib/storage/postgres/index.js +1 -1
- package/dist/lib/storage/postgres/taskQueue.d.ts +7 -3
- package/dist/lib/storage/postgres/taskQueue.js +10 -3
- package/dist/lib/syndicateSchema.d.ts +38 -1
- package/dist/lib/syndicateSchema.js +25 -18
- package/dist/lib/tools/auth.d.ts +13 -1
- package/dist/lib/tools/auth.js +10 -2
- package/dist/lib/tools/credentialHosts.d.ts +88 -0
- package/dist/lib/tools/credentialHosts.js +165 -0
- package/dist/lib/tools/credentialUses.d.ts +92 -0
- package/dist/lib/tools/credentialUses.js +112 -0
- package/dist/lib/tools/mcpToolFactory.js +1 -1
- package/dist/lib/tools/oauthConsent.js +8 -1
- package/dist/lib/tools/oauthHosts.d.ts +2 -0
- package/dist/lib/tools/oauthHosts.js +2 -2
- package/dist/lib/tools/oauthTools.d.ts +10 -1
- package/dist/lib/tools/oauthTools.js +29 -3
- package/dist/lib/tools/openapiTools.d.ts +7 -0
- package/dist/lib/tools/openapiTools.js +49 -3
- package/dist/lib/tools/taskTools.d.ts +32 -2
- package/dist/lib/tools/taskTools.js +93 -33
- package/dist/lib/workflow/turn.d.ts +22 -3
- package/dist/lib/workflow/turn.js +151 -31
- package/dist/lib/workflow.d.ts +10 -7
- package/dist/lib/workflow.js +10 -7
- package/dist/scripts/a2a_server.d.ts +2 -2
- package/dist/scripts/a2a_server.js +11 -2
- package/dist/scripts/assistant_worker.d.ts +2 -0
- package/dist/scripts/assistant_worker.js +13 -4
- package/dist/scripts/doctor.d.ts +4 -2
- package/dist/scripts/doctor.js +6 -4
- package/dist/scripts/setup.d.ts +23 -0
- package/dist/scripts/setup.js +154 -0
- package/dist/scripts/skills_install.d.ts +3 -1
- package/dist/scripts/skills_install.js +17 -2
- package/dist/scripts/yaml_codemod.d.ts +12 -2
- package/dist/scripts/yaml_codemod.js +115 -33
- package/package.json +4 -1
- package/skills/README.md +11 -2
- package/skills/briefs/_shared.md +1 -1
- package/skills/briefs/melchizedek-models.md +4 -2
- package/skills/briefs/skills-readme.md +3 -1
- package/skills/melchizedek-models/SKILL.md +6 -2
- package/skills/melchizedek-onboard/SKILL.md +49 -0
- package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
- package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
- package/skills/melchizedek-onboard-local/SKILL.md +41 -0
- package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/credentialHosts.ts — the operator's credential host allowlist:
|
|
3
|
+
* which hosts the value of each static credential variable may be sent to
|
|
4
|
+
* (ADR 0122).
|
|
5
|
+
*
|
|
6
|
+
* WHY this file exists:
|
|
7
|
+
* A syndicate YAML (possibly registry-stored) names a credential variable
|
|
8
|
+
* and, beside it, the host its value goes to: an OpenAPI `auth` sends
|
|
9
|
+
* `bearer_env` or `api_key.env` to the spec's server (or `base_url`), and
|
|
10
|
+
* an `oauth2` block sends `client_secret_env` to its `token_url`.
|
|
11
|
+
* credentialEnvProblem (lib/tools/credentialEnv.ts) keeps the framework's
|
|
12
|
+
* own secrets out of reach, and the OAuth host allowlist (ADR 0114) binds
|
|
13
|
+
* a provider's tokens, but nothing bound a static secret to its API: a
|
|
14
|
+
* YAML that names TRACKER_TOKEN could aim it at a host of its own. The
|
|
15
|
+
* binding is the operator's, never the YAML's: an environment variable or
|
|
16
|
+
* `createA2AApp({ credentialHosts })`.
|
|
17
|
+
*
|
|
18
|
+
* THE RULE (ADR 0122):
|
|
19
|
+
* - With no allowlist configured, nothing changes: a credential goes to
|
|
20
|
+
* the host the YAML names, as before. The doctor and the server binary
|
|
21
|
+
* name every credential variable a served YAML sends, as unbound.
|
|
22
|
+
* - With an allowlist configured, it is the whole list: every credential
|
|
23
|
+
* variable a YAML sends must be on it, and every host its value goes to
|
|
24
|
+
* must be one of that variable's hosts.
|
|
25
|
+
* - The check runs when a syndicate is loaded to be served, when its tools
|
|
26
|
+
* are compiled, and again before each call sends the value.
|
|
27
|
+
*
|
|
28
|
+
* WHAT IT COVERS: `bearer_env`, `api_key.env` and `client_secret_env` (both
|
|
29
|
+
* grants: the client-credentials token request, the consent step's code
|
|
30
|
+
* exchange and its refresh). Not `client_id_env`: a client id is not a
|
|
31
|
+
* secret. Not MCP_BEARER_TOKENS: it maps each token to its host already,
|
|
32
|
+
* and the operator writes both.
|
|
33
|
+
*
|
|
34
|
+
* THE FORMAT: `MELCHIZEDEK_CREDENTIAL_HOSTS="TRACKER_TOKEN=api.tracker.example.com;WEATHER_KEY=api.weather.example.com,*.weather.example.com"`.
|
|
35
|
+
* Entries are separated by `;` (or a newline), a variable from its hosts by
|
|
36
|
+
* `=`, hosts by `,`. A host is what the OAuth host allowlist takes
|
|
37
|
+
* (lib/tools/oauthHosts.ts): a hostname, `*.example.com`, IPv4 or
|
|
38
|
+
* `[IPv6]`, with no scheme, port or path.
|
|
39
|
+
*
|
|
40
|
+
* Nothing here reads or prints a credential's value: variable names and
|
|
41
|
+
* hosts only. No regular expression here can backtrack: the separators are
|
|
42
|
+
* single characters, and a variable name is one bounded character class.
|
|
43
|
+
*/
|
|
44
|
+
import { hostAllowed, hostPatternProblem } from './oauthHosts.js';
|
|
45
|
+
/** The environment variable the allowlist is read from when no option sets it. */
|
|
46
|
+
export const CREDENTIAL_HOSTS_ENV = 'MELCHIZEDEK_CREDENTIAL_HOSTS';
|
|
47
|
+
/** A credential variable's name, as a YAML may write it (lib/syndicateSchema.ts), bounded. */
|
|
48
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
49
|
+
/**
|
|
50
|
+
* The allowlist, checked and normalized (hosts lowercased, duplicates
|
|
51
|
+
* dropped). Throws, naming the variable and the host, on anything malformed.
|
|
52
|
+
*/
|
|
53
|
+
export function checkCredentialHosts(allowlist, source = 'credentialHosts') {
|
|
54
|
+
if (!allowlist || typeof allowlist !== 'object' || Array.isArray(allowlist))
|
|
55
|
+
throw new Error(`${source} must map each credential variable to its hosts.`);
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const [name, hosts] of Object.entries(allowlist)) {
|
|
58
|
+
if (!ENV_NAME.test(name))
|
|
59
|
+
throw new Error(`${source}: "${name.slice(0, 64)}" is not an environment variable name (A–Z, 0–9, _).`);
|
|
60
|
+
if (!Array.isArray(hosts) || hosts.length === 0)
|
|
61
|
+
throw new Error(`${source}: ${name} needs at least one host.`);
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
for (const raw of hosts) {
|
|
64
|
+
const host = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
65
|
+
const problem = host ? hostPatternProblem(host) : 'is empty';
|
|
66
|
+
if (problem)
|
|
67
|
+
throw new Error(`${source}: ${name}: host "${String(raw).slice(0, 80)}" ${problem}.`);
|
|
68
|
+
seen.add(host);
|
|
69
|
+
}
|
|
70
|
+
out[name] = [...seen];
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** The allowlist from its text form (MELCHIZEDEK_CREDENTIAL_HOSTS). Throws on a malformed entry. */
|
|
75
|
+
export function parseCredentialHosts(text) {
|
|
76
|
+
const parsed = {};
|
|
77
|
+
for (const entry of text.split(/[;\n]/)) {
|
|
78
|
+
const trimmed = entry.trim();
|
|
79
|
+
if (!trimmed)
|
|
80
|
+
continue;
|
|
81
|
+
const eq = trimmed.indexOf('=');
|
|
82
|
+
if (eq <= 0)
|
|
83
|
+
throw new Error(`${CREDENTIAL_HOSTS_ENV}: each entry is VARIABLE=host,host (got "${trimmed.slice(0, 80)}").`);
|
|
84
|
+
const name = trimmed.slice(0, eq).trim();
|
|
85
|
+
if (Object.hasOwn(parsed, name))
|
|
86
|
+
throw new Error(`${CREDENTIAL_HOSTS_ENV}: ${name.slice(0, 64)} is listed twice.`);
|
|
87
|
+
parsed[name] = trimmed.slice(eq + 1).split(',').map((h) => h.trim()).filter((h) => h !== '');
|
|
88
|
+
}
|
|
89
|
+
return checkCredentialHosts(parsed, CREDENTIAL_HOSTS_ENV);
|
|
90
|
+
}
|
|
91
|
+
let configured;
|
|
92
|
+
let envCache;
|
|
93
|
+
/**
|
|
94
|
+
* Sets this process's allowlist (createA2AApp's `credentialHosts` does),
|
|
95
|
+
* which wins over the environment variable. `undefined` returns to the
|
|
96
|
+
* variable.
|
|
97
|
+
*/
|
|
98
|
+
export function setCredentialHosts(allowlist) {
|
|
99
|
+
configured = allowlist === undefined ? undefined : checkCredentialHosts(allowlist);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* The allowlist in force: the one set by `setCredentialHosts`, else the
|
|
103
|
+
* environment variable's, else undefined (not configured). A malformed
|
|
104
|
+
* variable throws, so the first check refuses rather than allowing.
|
|
105
|
+
*/
|
|
106
|
+
export function credentialHosts(env = process.env) {
|
|
107
|
+
if (configured)
|
|
108
|
+
return configured;
|
|
109
|
+
const raw = env[CREDENTIAL_HOSTS_ENV];
|
|
110
|
+
if (raw === undefined || raw.trim() === '')
|
|
111
|
+
return undefined;
|
|
112
|
+
if (envCache?.raw !== raw)
|
|
113
|
+
envCache = { raw, value: parseCredentialHosts(raw) };
|
|
114
|
+
return envCache.value;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Why the value of credential variable `name` may not be sent to `url`, or
|
|
118
|
+
* null. `role` names the URL in the message (`server`, `token_url`).
|
|
119
|
+
* `allowlist` defaults to the one in force; `null` means none is
|
|
120
|
+
* configured, which allows (the behaviour before ADR 0122).
|
|
121
|
+
*/
|
|
122
|
+
export function credentialHostProblem(name, url, role = 'server', allowlist = credentialHosts()) {
|
|
123
|
+
const list = allowlist ?? undefined;
|
|
124
|
+
if (!list)
|
|
125
|
+
return null;
|
|
126
|
+
if (!Object.hasOwn(list, name)) {
|
|
127
|
+
return `${name} is not on the operator's credential host allowlist (${CREDENTIAL_HOSTS_ENV} or createA2AApp's credentialHosts), so it is sent nowhere`;
|
|
128
|
+
}
|
|
129
|
+
let hostname;
|
|
130
|
+
try {
|
|
131
|
+
hostname = new URL(url).hostname;
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return `${role} '${url.slice(0, 200)}' is not a URL`;
|
|
135
|
+
}
|
|
136
|
+
const hosts = list[name];
|
|
137
|
+
if (hostAllowed(hosts, hostname))
|
|
138
|
+
return null;
|
|
139
|
+
return `${name} may be sent only to ${hosts.join(', ')}; ${hostname} (${role}) is not one of them`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The call-time check: why the value of `name` may not be sent to
|
|
143
|
+
* `destination` now, or null. A malformed allowlist refuses.
|
|
144
|
+
*/
|
|
145
|
+
export function credentialCallProblem(name, destination) {
|
|
146
|
+
try {
|
|
147
|
+
return credentialHostProblem(name, destination, 'server', credentialHosts() ?? null);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return `the credential host allowlist (${CREDENTIAL_HOSTS_ENV}) is malformed`;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Marks an OAuth client config (lib/tools/oauthConsent.ts) with the
|
|
155
|
+
* variable its client secret came from, so the consent step's code exchange
|
|
156
|
+
* and the refresh hook can check the token endpoint at call time. Set by
|
|
157
|
+
* oauthClientsFor (lib/tools/oauthTools.ts); a client built by hand carries
|
|
158
|
+
* none and is not checked.
|
|
159
|
+
*/
|
|
160
|
+
export const CLIENT_SECRET_ENV = Symbol.for('melchizedek.clientSecretEnv');
|
|
161
|
+
/** The variable a client config's secret came from, when oauthClientsFor marked it. */
|
|
162
|
+
export function clientSecretEnvOf(client) {
|
|
163
|
+
const name = client[CLIENT_SECRET_ENV];
|
|
164
|
+
return typeof name === 'string' ? name : undefined;
|
|
165
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/credentialUses.ts — every static credential variable a syndicate
|
|
3
|
+
* YAML sends somewhere, and where (ADR 0122).
|
|
4
|
+
*
|
|
5
|
+
* A YAML sends a static credential in three places: an OpenAPI entry's
|
|
6
|
+
* `auth.bearer_env` or `auth.api_key.env` (to the spec's server, or
|
|
7
|
+
* `base_url`), and an `oauth2` block's `client_secret_env` (to its
|
|
8
|
+
* `token_url`; the OpenAPI entry's or the MCP server's `mcp_auth`). The
|
|
9
|
+
* operator's credential host allowlist (lib/tools/credentialHosts.ts) is
|
|
10
|
+
* checked against them when a served syndicate loads
|
|
11
|
+
* (`syndicateCredentialHostProblems`), and the doctor and the server binary
|
|
12
|
+
* name each variable no allowlist binds (`unboundCredentialEnvs`).
|
|
13
|
+
*
|
|
14
|
+
* Names and hosts only: no value is read here.
|
|
15
|
+
*/
|
|
16
|
+
import type { CredentialHostAllowlist } from './credentialHosts.ts';
|
|
17
|
+
/** One credential variable a YAML sends, and to what. */
|
|
18
|
+
export interface CredentialUse {
|
|
19
|
+
/** The agent that holds the tools ("Parent › Child" inside a nested file). */
|
|
20
|
+
agent: string;
|
|
21
|
+
/** `openapi <spec>` or `mcp <url>`. */
|
|
22
|
+
tools: string;
|
|
23
|
+
/** The variable's name. */
|
|
24
|
+
env: string;
|
|
25
|
+
/** Where the YAML names it. */
|
|
26
|
+
role: 'bearer_env' | 'api_key' | 'client_secret_env';
|
|
27
|
+
/**
|
|
28
|
+
* The URLs its value goes to: the token endpoint for a client secret,
|
|
29
|
+
* `base_url` for an OpenAPI entry that sets one. Absent for an OpenAPI
|
|
30
|
+
* entry whose server is in its spec (read only when checked, by
|
|
31
|
+
* `syndicateCredentialHostProblems`).
|
|
32
|
+
*/
|
|
33
|
+
destinations?: string[];
|
|
34
|
+
/** The OpenAPI entry, for its spec's servers. */
|
|
35
|
+
openapi?: OpenApiEntryLike;
|
|
36
|
+
}
|
|
37
|
+
/** The parts of an `oauth2` block read here. */
|
|
38
|
+
interface OAuth2Like {
|
|
39
|
+
token_url: string;
|
|
40
|
+
client_secret_env?: string;
|
|
41
|
+
}
|
|
42
|
+
/** The parts of an `openapi:` entry read here. */
|
|
43
|
+
interface OpenApiEntryLike {
|
|
44
|
+
spec?: string;
|
|
45
|
+
base_url?: string;
|
|
46
|
+
auth?: {
|
|
47
|
+
bearer_env?: string;
|
|
48
|
+
api_key?: {
|
|
49
|
+
env?: string;
|
|
50
|
+
};
|
|
51
|
+
oauth2?: OAuth2Like;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
interface AgentLike {
|
|
55
|
+
name?: string;
|
|
56
|
+
openapi?: OpenApiEntryLike[];
|
|
57
|
+
mcp_server_url?: string;
|
|
58
|
+
mcp_auth?: {
|
|
59
|
+
oauth2?: OAuth2Like;
|
|
60
|
+
};
|
|
61
|
+
yaml_reference?: string;
|
|
62
|
+
}
|
|
63
|
+
/** The parts of a syndicate config read here. */
|
|
64
|
+
export interface SyndicateLike {
|
|
65
|
+
orchestrator?: AgentLike;
|
|
66
|
+
subagents?: AgentLike[];
|
|
67
|
+
}
|
|
68
|
+
/** The credential variables one agent's tools send. */
|
|
69
|
+
export declare function agentCredentialUses(agent: AgentLike, prefix?: string): CredentialUse[];
|
|
70
|
+
/**
|
|
71
|
+
* Every credential variable these syndicates send, orchestrators and
|
|
72
|
+
* subagents, following a nested `yaml_reference:` through `load` when given
|
|
73
|
+
* (once per file; agent names then read "Parent › Child").
|
|
74
|
+
*/
|
|
75
|
+
export declare function syndicateCredentialUses(configs: readonly SyndicateLike[], load?: (ref: string) => SyndicateLike): CredentialUse[];
|
|
76
|
+
/**
|
|
77
|
+
* Why these syndicates may not be served here, one line per refusal
|
|
78
|
+
* (ADR 0122): each credential variable they send, against the operator's
|
|
79
|
+
* credential host allowlist. With none configured, nothing is refused. The
|
|
80
|
+
* A2A server checks every syndicate it loads to serve; the compile checks
|
|
81
|
+
* again, and each call once more.
|
|
82
|
+
*/
|
|
83
|
+
export declare function syndicateCredentialHostProblems(configs: readonly SyndicateLike[], options?: {
|
|
84
|
+
load?: (ref: string) => SyndicateLike;
|
|
85
|
+
allowlist?: CredentialHostAllowlist | null;
|
|
86
|
+
}): string[];
|
|
87
|
+
/**
|
|
88
|
+
* The credential variables these uses send that no allowlist binds, sorted
|
|
89
|
+
* and once each: every one of them when none is configured. Names only.
|
|
90
|
+
*/
|
|
91
|
+
export declare function unboundCredentialEnvs(uses: readonly CredentialUse[], allowlist: CredentialHostAllowlist | null | undefined): string[];
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/credentialUses.ts — every static credential variable a syndicate
|
|
3
|
+
* YAML sends somewhere, and where (ADR 0122).
|
|
4
|
+
*
|
|
5
|
+
* A YAML sends a static credential in three places: an OpenAPI entry's
|
|
6
|
+
* `auth.bearer_env` or `auth.api_key.env` (to the spec's server, or
|
|
7
|
+
* `base_url`), and an `oauth2` block's `client_secret_env` (to its
|
|
8
|
+
* `token_url`; the OpenAPI entry's or the MCP server's `mcp_auth`). The
|
|
9
|
+
* operator's credential host allowlist (lib/tools/credentialHosts.ts) is
|
|
10
|
+
* checked against them when a served syndicate loads
|
|
11
|
+
* (`syndicateCredentialHostProblems`), and the doctor and the server binary
|
|
12
|
+
* name each variable no allowlist binds (`unboundCredentialEnvs`).
|
|
13
|
+
*
|
|
14
|
+
* Names and hosts only: no value is read here.
|
|
15
|
+
*/
|
|
16
|
+
import { resolve } from 'node:path';
|
|
17
|
+
import { credentialHostProblem, credentialHosts } from './credentialHosts.js';
|
|
18
|
+
import { openApiServers } from './openapiTools.js';
|
|
19
|
+
/** The credential variables one agent's tools send. */
|
|
20
|
+
export function agentCredentialUses(agent, prefix = '') {
|
|
21
|
+
const name = `${prefix}${agent.name ?? ''}`;
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const entry of agent.openapi ?? []) {
|
|
24
|
+
const tools = `openapi ${entry?.spec ?? ''}`;
|
|
25
|
+
const auth = entry?.auth;
|
|
26
|
+
if (!auth)
|
|
27
|
+
continue;
|
|
28
|
+
const servers = entry.base_url ? { destinations: [entry.base_url] } : {};
|
|
29
|
+
if (auth.bearer_env)
|
|
30
|
+
out.push({ agent: name, tools, env: auth.bearer_env, role: 'bearer_env', ...servers, openapi: entry });
|
|
31
|
+
if (auth.api_key?.env)
|
|
32
|
+
out.push({ agent: name, tools, env: auth.api_key.env, role: 'api_key', ...servers, openapi: entry });
|
|
33
|
+
if (auth.oauth2?.client_secret_env)
|
|
34
|
+
out.push({ agent: name, tools, env: auth.oauth2.client_secret_env, role: 'client_secret_env', destinations: [auth.oauth2.token_url] });
|
|
35
|
+
}
|
|
36
|
+
const mcp = agent.mcp_auth?.oauth2;
|
|
37
|
+
if (mcp?.client_secret_env)
|
|
38
|
+
out.push({ agent: name, tools: `mcp ${agent.mcp_server_url ?? ''}`, env: mcp.client_secret_env, role: 'client_secret_env', destinations: [mcp.token_url] });
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Every credential variable these syndicates send, orchestrators and
|
|
43
|
+
* subagents, following a nested `yaml_reference:` through `load` when given
|
|
44
|
+
* (once per file; agent names then read "Parent › Child").
|
|
45
|
+
*/
|
|
46
|
+
export function syndicateCredentialUses(configs, load) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const visit = (config, prefix) => {
|
|
50
|
+
const agents = [config.orchestrator, ...(config.subagents ?? [])].filter((a) => !!a);
|
|
51
|
+
for (const agent of agents) {
|
|
52
|
+
if (agent.yaml_reference) {
|
|
53
|
+
if (!load || seen.has(agent.yaml_reference))
|
|
54
|
+
continue;
|
|
55
|
+
seen.add(agent.yaml_reference);
|
|
56
|
+
visit(load(agent.yaml_reference), `${prefix}${agent.name ?? agent.yaml_reference} › `);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
out.push(...agentCredentialUses(agent, prefix));
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
for (const config of configs)
|
|
63
|
+
visit(config, '');
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
/** The URLs a use's value goes to; an OpenAPI spec that cannot be read gives none (its compile fails on its own). */
|
|
67
|
+
function destinationsOf(use) {
|
|
68
|
+
if (use.destinations)
|
|
69
|
+
return use.destinations;
|
|
70
|
+
if (!use.openapi?.spec)
|
|
71
|
+
return [];
|
|
72
|
+
try {
|
|
73
|
+
return openApiServers(use.openapi, resolve(process.cwd()));
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Why these syndicates may not be served here, one line per refusal
|
|
81
|
+
* (ADR 0122): each credential variable they send, against the operator's
|
|
82
|
+
* credential host allowlist. With none configured, nothing is refused. The
|
|
83
|
+
* A2A server checks every syndicate it loads to serve; the compile checks
|
|
84
|
+
* again, and each call once more.
|
|
85
|
+
*/
|
|
86
|
+
export function syndicateCredentialHostProblems(configs, options = {}) {
|
|
87
|
+
const allowlist = options.allowlist === undefined ? credentialHosts() ?? null : options.allowlist;
|
|
88
|
+
if (!allowlist)
|
|
89
|
+
return [];
|
|
90
|
+
const out = new Set();
|
|
91
|
+
for (const use of syndicateCredentialUses(configs, options.load)) {
|
|
92
|
+
const role = use.role === 'client_secret_env' ? 'token_url' : 'server';
|
|
93
|
+
const where = `${use.agent} · ${use.tools} · ${use.role}`;
|
|
94
|
+
if (!Object.hasOwn(allowlist, use.env)) {
|
|
95
|
+
out.add(`${where}: ${credentialHostProblem(use.env, '', role, allowlist)}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const url of destinationsOf(use)) {
|
|
99
|
+
const problem = credentialHostProblem(use.env, url, role, allowlist);
|
|
100
|
+
if (problem)
|
|
101
|
+
out.add(`${where}: ${problem}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...out];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The credential variables these uses send that no allowlist binds, sorted
|
|
108
|
+
* and once each: every one of them when none is configured. Names only.
|
|
109
|
+
*/
|
|
110
|
+
export function unboundCredentialEnvs(uses, allowlist) {
|
|
111
|
+
return [...new Set(uses.map((u) => u.env))].filter((name) => !allowlist || !Object.hasOwn(allowlist, name)).sort();
|
|
112
|
+
}
|
|
@@ -174,7 +174,7 @@ function bearerFetch(oauth2, token) {
|
|
|
174
174
|
// message endpoint the server names), before a token is attached (ADR 0114).
|
|
175
175
|
const destination = input instanceof Request ? input.url : String(input);
|
|
176
176
|
if (oauthCallProblem(oauth2, destination))
|
|
177
|
-
throw new ToolCredentialError('host_refused', oauth2.provider);
|
|
177
|
+
throw new ToolCredentialError('host_refused', oauth2.provider, 'oauth');
|
|
178
178
|
const headers = new Headers(init?.headers);
|
|
179
179
|
headers.set('Authorization', `Bearer ${await token(destination)}`);
|
|
180
180
|
return mcpFetch(input, { ...init, headers });
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { createHash, randomBytes } from 'node:crypto';
|
|
39
39
|
import { scopeHashOf } from '../observability/audit.js';
|
|
40
40
|
import { PROVIDER_NAME } from './auth.js';
|
|
41
|
+
import { clientSecretEnvOf, credentialCallProblem } from './credentialHosts.js';
|
|
41
42
|
/**
|
|
42
43
|
* Flows in this process's memory, at most `max` of them: the oldest is
|
|
43
44
|
* dropped first, and an expired one whenever another is put.
|
|
@@ -192,8 +193,14 @@ export function oauthConsent(options) {
|
|
|
192
193
|
client_id: client.clientId,
|
|
193
194
|
code_verifier: flow.codeVerifier,
|
|
194
195
|
});
|
|
195
|
-
if (client.clientSecret)
|
|
196
|
+
if (client.clientSecret) {
|
|
197
|
+
// The secret goes only to a host the operator binds its variable to (ADR 0122), checked now:
|
|
198
|
+
// the allowlist can change after boot. Refused, nothing is sent.
|
|
199
|
+
const secretEnv = clientSecretEnvOf(client);
|
|
200
|
+
if (secretEnv && credentialCallProblem(secretEnv, client.tokenUrl))
|
|
201
|
+
throw new ConsentError('exchange_failed', flow.provider);
|
|
196
202
|
body.set('client_secret', client.clientSecret);
|
|
203
|
+
}
|
|
197
204
|
let json;
|
|
198
205
|
try {
|
|
199
206
|
const res = await doFetch(client.tokenUrl, {
|
|
@@ -38,6 +38,8 @@
|
|
|
38
38
|
export declare const OAUTH_HOSTS_ENV = "MELCHIZEDEK_OAUTH_HOSTS";
|
|
39
39
|
/** Provider name → the hosts its tokens may be sent to. */
|
|
40
40
|
export type OAuthHostAllowlist = Readonly<Record<string, readonly string[]>>;
|
|
41
|
+
/** Why `pattern` is not a host an allowlist can hold, or null (lowercased, trimmed). Also lib/tools/credentialHosts.ts. */
|
|
42
|
+
export declare function hostPatternProblem(pattern: string): string | null;
|
|
41
43
|
/**
|
|
42
44
|
* The allowlist, checked and normalized (hosts lowercased, duplicates
|
|
43
45
|
* dropped). Throws, naming the provider and the host, on anything malformed.
|
|
@@ -39,8 +39,8 @@ import { PROVIDER_NAME } from './auth.js';
|
|
|
39
39
|
export const OAUTH_HOSTS_ENV = 'MELCHIZEDEK_OAUTH_HOSTS';
|
|
40
40
|
const LABEL = /^[a-z0-9-]{1,63}$/;
|
|
41
41
|
const IPV6 = /^\[[0-9a-f:.]{2,45}\]$/;
|
|
42
|
-
/** Why `pattern` is not a host
|
|
43
|
-
function hostPatternProblem(pattern) {
|
|
42
|
+
/** Why `pattern` is not a host an allowlist can hold, or null (lowercased, trimmed). Also lib/tools/credentialHosts.ts. */
|
|
43
|
+
export function hostPatternProblem(pattern) {
|
|
44
44
|
if (pattern.length > 253)
|
|
45
45
|
return 'is longer than 253 characters';
|
|
46
46
|
if (IPV6.test(pattern))
|
|
@@ -51,6 +51,13 @@
|
|
|
51
51
|
* (`oauthCallProblem`). The client-credentials token endpoint passes the
|
|
52
52
|
* same SSRF guard as the server it serves (ALLOW_PRIVATE_OPENAPI or
|
|
53
53
|
* ALLOW_PRIVATE_MCP), and no redirect from it is followed.
|
|
54
|
+
*
|
|
55
|
+
* WHERE A CLIENT SECRET MAY GO: to the token endpoint, and, when the
|
|
56
|
+
* operator configures a credential host allowlist
|
|
57
|
+
* (lib/tools/credentialHosts.ts, ADR 0122), only to a host it binds the
|
|
58
|
+
* `client_secret_env` variable to: checked at compile, when the consent
|
|
59
|
+
* clients are built, and before each client-credentials token request,
|
|
60
|
+
* code exchange (lib/tools/oauthConsent.ts) and refresh.
|
|
54
61
|
*/
|
|
55
62
|
import type { OAuthProvider } from './auth.ts';
|
|
56
63
|
import type { OAuthHostAllowlist } from './oauthHosts.ts';
|
|
@@ -149,7 +156,9 @@ export declare function clientCredentialsGrant(options: ClientCredentialsOptions
|
|
|
149
156
|
/**
|
|
150
157
|
* What a tool calls for the token of one call, naming where that call goes.
|
|
151
158
|
* Throws ToolCredentialError, whose message names no value: `host_refused`
|
|
152
|
-
* when the destination is not one of the provider's hosts
|
|
159
|
+
* when the destination is not one of the provider's hosts (`allowlist`
|
|
160
|
+
* `'oauth'`), or when a client secret's token endpoint is not bound to its
|
|
161
|
+
* variable (`allowlist` `'credential'`).
|
|
153
162
|
*/
|
|
154
163
|
export type OAuthTokenSource = (ctx: ToolContext | undefined, destination: string) => Promise<string>;
|
|
155
164
|
/**
|
|
@@ -51,10 +51,18 @@
|
|
|
51
51
|
* (`oauthCallProblem`). The client-credentials token endpoint passes the
|
|
52
52
|
* same SSRF guard as the server it serves (ALLOW_PRIVATE_OPENAPI or
|
|
53
53
|
* ALLOW_PRIVATE_MCP), and no redirect from it is followed.
|
|
54
|
+
*
|
|
55
|
+
* WHERE A CLIENT SECRET MAY GO: to the token endpoint, and, when the
|
|
56
|
+
* operator configures a credential host allowlist
|
|
57
|
+
* (lib/tools/credentialHosts.ts, ADR 0122), only to a host it binds the
|
|
58
|
+
* `client_secret_env` variable to: checked at compile, when the consent
|
|
59
|
+
* clients are built, and before each client-credentials token request,
|
|
60
|
+
* code exchange (lib/tools/oauthConsent.ts) and refresh.
|
|
54
61
|
*/
|
|
55
62
|
import { blockedHostReason, checkHost } from '../net/addressGuard.js';
|
|
56
63
|
import { PROVIDER_NAME, ToolCredentialError } from './auth.js';
|
|
57
64
|
import { readCredentialEnv } from './credentialEnv.js';
|
|
65
|
+
import { CLIENT_SECRET_ENV, clientSecretEnvOf, credentialCallProblem, credentialHostProblem } from './credentialHosts.js';
|
|
58
66
|
import { oauthHostProblem, oauthHosts } from './oauthHosts.js';
|
|
59
67
|
/** An agent's declared OAuth grants, OpenAPI entries first, then its MCP server. */
|
|
60
68
|
export function agentOAuthGrants(agent, prefix = '') {
|
|
@@ -264,7 +272,7 @@ export function oauthTokenSource(oauth2, where, server, options = {}) {
|
|
|
264
272
|
/** The call-time check (ADR 0114): before any token is read or fetched. */
|
|
265
273
|
const refuse = (destination) => {
|
|
266
274
|
if (oauthCallProblem(oauth2, destination))
|
|
267
|
-
throw new ToolCredentialError('host_refused', provider);
|
|
275
|
+
throw new ToolCredentialError('host_refused', provider, 'oauth');
|
|
268
276
|
};
|
|
269
277
|
if (oauth2.grant === 'authorization_code') {
|
|
270
278
|
return async (ctx, destination) => {
|
|
@@ -285,6 +293,11 @@ export function oauthTokenSource(oauth2, where, server, options = {}) {
|
|
|
285
293
|
if (blocked)
|
|
286
294
|
throw new Error(`${where}: refusing token endpoint ${new URL(oauth2.token_url).hostname}: ${blocked}`);
|
|
287
295
|
}
|
|
296
|
+
// The client secret goes only to a host the operator binds its variable to (ADR 0122).
|
|
297
|
+
const secretEnv = oauth2.client_secret_env;
|
|
298
|
+
const unbound = credentialHostProblem(secretEnv, oauth2.token_url, 'token_url');
|
|
299
|
+
if (unbound)
|
|
300
|
+
throw new Error(`${where}: ${unbound}`);
|
|
288
301
|
const grant = clientCredentialsGrant({
|
|
289
302
|
provider,
|
|
290
303
|
tokenUrl: oauth2.token_url,
|
|
@@ -296,8 +309,11 @@ export function oauthTokenSource(oauth2, where, server, options = {}) {
|
|
|
296
309
|
});
|
|
297
310
|
return async (ctx, destination) => {
|
|
298
311
|
refuse(destination);
|
|
299
|
-
// The client secret goes to the token endpoint: held to the same rule
|
|
312
|
+
// The client secret goes to the token endpoint: held to the same rule,
|
|
313
|
+
// and to the credential host allowlist (ADR 0122).
|
|
300
314
|
refuse(oauth2.token_url);
|
|
315
|
+
if (credentialCallProblem(secretEnv, oauth2.token_url))
|
|
316
|
+
throw new ToolCredentialError('host_refused', provider, 'credential');
|
|
301
317
|
return grant.token(ctx?.signal);
|
|
302
318
|
};
|
|
303
319
|
}
|
|
@@ -326,6 +342,10 @@ export function oauthClientsFor(configs, options = {}) {
|
|
|
326
342
|
const hostProblems = oauthGrantHostProblems(o, [], allowlist);
|
|
327
343
|
if (hostProblems.length)
|
|
328
344
|
throw new Error(`${where}: ${hostProblems.join('; ')}`);
|
|
345
|
+
// The client secret goes to the token endpoint: a host the operator binds its variable to (ADR 0122).
|
|
346
|
+
const secretProblem = o.client_secret_env ? credentialHostProblem(o.client_secret_env, o.token_url, 'token_url') : null;
|
|
347
|
+
if (secretProblem)
|
|
348
|
+
throw new Error(`${where}: ${secretProblem}`);
|
|
329
349
|
const sig = JSON.stringify([o.authorization_url, o.token_url, o.client_id ?? null, o.client_id_env ?? null, o.client_secret_env ?? null, [...(o.scopes ?? [])].sort(), o.authorization_params ?? {}]);
|
|
330
350
|
const before = signature.get(o.provider);
|
|
331
351
|
if (before !== undefined) {
|
|
@@ -341,6 +361,8 @@ export function oauthClientsFor(configs, options = {}) {
|
|
|
341
361
|
...(o.client_secret_env ? { clientSecret: readCredentialEnv(o.client_secret_env, where, env) } : {}),
|
|
342
362
|
scopes: [...(o.scopes ?? [])],
|
|
343
363
|
...(o.authorization_params ? { authorizationParams: { ...o.authorization_params } } : {}),
|
|
364
|
+
// The variable the secret came from, so the exchange and the refresh check its host at call time.
|
|
365
|
+
...(o.client_secret_env ? { [CLIENT_SECRET_ENV]: o.client_secret_env } : {}),
|
|
344
366
|
};
|
|
345
367
|
}
|
|
346
368
|
return clients;
|
|
@@ -403,7 +425,11 @@ export function oauthRefreshProviders(clients, options = {}) {
|
|
|
403
425
|
refresh: async (refreshToken, context) => {
|
|
404
426
|
// A refresh token goes only to a token endpoint the allowlist binds to its provider (ADR 0114).
|
|
405
427
|
if (oauthCallProblem({ provider: name, grant: 'authorization_code' }, client.tokenUrl))
|
|
406
|
-
throw new ToolCredentialError('host_refused', name);
|
|
428
|
+
throw new ToolCredentialError('host_refused', name, 'oauth');
|
|
429
|
+
// The client secret too: only to a host the operator binds its variable to (ADR 0122).
|
|
430
|
+
const secretEnv = clientSecretEnvOf(client);
|
|
431
|
+
if (client.clientSecret && secretEnv && credentialCallProblem(secretEnv, client.tokenUrl))
|
|
432
|
+
throw new ToolCredentialError('host_refused', name, 'credential');
|
|
407
433
|
const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: client.clientId });
|
|
408
434
|
if (client.clientSecret)
|
|
409
435
|
body.set('client_secret', client.clientSecret);
|
|
@@ -45,6 +45,13 @@ export declare function boundResult(result: unknown): unknown;
|
|
|
45
45
|
* an operation the spec does not have, or a server the guard refuses.
|
|
46
46
|
*/
|
|
47
47
|
export declare function buildOpenApiOwnTools(entry: OpenApiConfig, baseDir?: string): Promise<Tool[]>;
|
|
48
|
+
/**
|
|
49
|
+
* The server URLs one `openapi:` entry calls: its `base_url`, else its
|
|
50
|
+
* spec's server. Reads and parses the spec (bounded); throws as the compile
|
|
51
|
+
* would on a spec that cannot be read. For the load-time credential host
|
|
52
|
+
* check (lib/tools/credentialUses.ts, ADR 0122).
|
|
53
|
+
*/
|
|
54
|
+
export declare function openApiServers(entry: OpenApiConfig, baseDir?: string): string[];
|
|
48
55
|
/**
|
|
49
56
|
* The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
|
|
50
57
|
* Tools, each marked so require_approval can name it by its operationId.
|
|
@@ -36,6 +36,12 @@
|
|
|
36
36
|
* instead, fetched per call: the run's user's own (authorization_code,
|
|
37
37
|
* through `ctx.accessToken` and the consent pause) or the server's own
|
|
38
38
|
* (client_credentials), lib/tools/oauthTools.ts, ADR 0112.
|
|
39
|
+
* - STATIC CREDENTIALS ONLY TO THEIR HOSTS. When the operator configures a
|
|
40
|
+
* credential host allowlist (MELCHIZEDEK_CREDENTIAL_HOSTS, or
|
|
41
|
+
* createA2AApp's credentialHosts; lib/tools/credentialHosts.ts,
|
|
42
|
+
* ADR 0122), a `bearer_env` or `api_key` variable is sent only to a host
|
|
43
|
+
* it binds that variable to: checked at compile (every server), and
|
|
44
|
+
* again before each call, which is refused with nothing sent.
|
|
39
45
|
* - THE SSRF GUARD. Every server URL the tools will call must be http(s)
|
|
40
46
|
* and pass lib/net/addressGuard.ts: its literal rules at compile time
|
|
41
47
|
* (offline), and the full check with DNS before each call (a name can
|
|
@@ -67,6 +73,7 @@ import { callOperation, hostProblem } from './openapi/call.js';
|
|
|
67
73
|
import { MAX_SPEC_BYTES, operationNamed, parseOpenApiSpec } from './openapi/parse.js';
|
|
68
74
|
import { ToolCredentialError } from './auth.js';
|
|
69
75
|
import { readCredentialEnv } from './credentialEnv.js';
|
|
76
|
+
import { credentialCallProblem, credentialHostProblem } from './credentialHosts.js';
|
|
70
77
|
import { oauthTokenSource, tokenTransportProblem } from './oauthTools.js';
|
|
71
78
|
export { credentialEnvProblem } from './credentialEnv.js';
|
|
72
79
|
export { namesTool, toSnake } from './openapi/parse.js';
|
|
@@ -89,11 +96,15 @@ function credentialFor(auth, spec) {
|
|
|
89
96
|
return undefined;
|
|
90
97
|
const read = (env) => readCredentialEnv(env, `openapi ${spec}`);
|
|
91
98
|
if (auth.bearer_env)
|
|
92
|
-
return { kind: 'bearer', token: read(auth.bearer_env) };
|
|
99
|
+
return { credential: { kind: 'bearer', token: read(auth.bearer_env) }, env: auth.bearer_env };
|
|
93
100
|
if (auth.api_key)
|
|
94
|
-
return { kind: 'api_key', in: auth.api_key.in, name: auth.api_key.name, value: read(auth.api_key.env) };
|
|
101
|
+
return { credential: { kind: 'api_key', in: auth.api_key.in, name: auth.api_key.name, value: read(auth.api_key.env) }, env: auth.api_key.env };
|
|
95
102
|
return undefined;
|
|
96
103
|
}
|
|
104
|
+
/** The variable a static `auth` sends (bearer_env or api_key.env), or undefined. */
|
|
105
|
+
function staticCredentialEnv(auth) {
|
|
106
|
+
return auth?.bearer_env ?? auth?.api_key?.env;
|
|
107
|
+
}
|
|
97
108
|
/**
|
|
98
109
|
* Cut an API result to MAX_RESULT_CHARS, saying so. Kept for consumers of
|
|
99
110
|
* this module; the tools cut with capResult (lib/tools/tool.ts).
|
|
@@ -138,11 +149,25 @@ function readSpec(specPath, source) {
|
|
|
138
149
|
* provider and what to do, never a value (and, for a user who has not
|
|
139
150
|
* granted it, the run's consent step has already asked).
|
|
140
151
|
*/
|
|
141
|
-
function operationTool(op,
|
|
152
|
+
function operationTool(op, held, oauth) {
|
|
153
|
+
const credential = held?.credential;
|
|
142
154
|
const tool = {
|
|
143
155
|
name: op.name,
|
|
144
156
|
declaration: () => op.declaration,
|
|
145
157
|
async execute(args, ctx) {
|
|
158
|
+
// The operator's credential host allowlist, again at each call (ADR 0122): the
|
|
159
|
+
// compile checked it, and it can change. Refused, nothing is sent; the
|
|
160
|
+
// message names the host, never the value.
|
|
161
|
+
if (held && credentialCallProblem(held.env, op.baseUrl)) {
|
|
162
|
+
let host = op.baseUrl;
|
|
163
|
+
try {
|
|
164
|
+
host = new URL(op.baseUrl).hostname;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// A server that is not a URL is named as written.
|
|
168
|
+
}
|
|
169
|
+
return { error: `${op.name} was not called: the operator's credential host allowlist does not let this API's credential be sent to ${host}` };
|
|
170
|
+
}
|
|
146
171
|
let sent = credential;
|
|
147
172
|
if (oauth) {
|
|
148
173
|
try {
|
|
@@ -186,12 +211,17 @@ export async function buildOpenApiOwnTools(entry, baseDir = process.cwd()) {
|
|
|
186
211
|
picked = all.filter((o) => o.method === 'get');
|
|
187
212
|
}
|
|
188
213
|
const chosen = entry.base_url ? picked.map((o) => ({ ...o, baseUrl: entry.base_url })) : picked;
|
|
214
|
+
const credentialEnv = staticCredentialEnv(entry.auth);
|
|
189
215
|
for (const baseUrl of new Set(chosen.map((o) => o.baseUrl))) {
|
|
190
216
|
if (!baseUrl)
|
|
191
217
|
throw new Error(`openapi ${entry.spec}: the spec names no server; set base_url`);
|
|
192
218
|
const problem = await hostProblem(baseUrl, false);
|
|
193
219
|
if (problem)
|
|
194
220
|
throw new Error(`openapi ${entry.spec}: ${problem}`);
|
|
221
|
+
// A static credential goes only to a host the operator binds its variable to (ADR 0122).
|
|
222
|
+
const bound = credentialEnv ? credentialHostProblem(credentialEnv, baseUrl) : null;
|
|
223
|
+
if (bound)
|
|
224
|
+
throw new Error(`openapi ${entry.spec}: ${bound}`);
|
|
195
225
|
}
|
|
196
226
|
let oauth;
|
|
197
227
|
if (entry.auth?.oauth2 && chosen.length) {
|
|
@@ -205,6 +235,22 @@ export async function buildOpenApiOwnTools(entry, baseDir = process.cwd()) {
|
|
|
205
235
|
}
|
|
206
236
|
return chosen.map((o) => operationTool(o, credential, oauth));
|
|
207
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* The server URLs one `openapi:` entry calls: its `base_url`, else its
|
|
240
|
+
* spec's server. Reads and parses the spec (bounded); throws as the compile
|
|
241
|
+
* would on a spec that cannot be read. For the load-time credential host
|
|
242
|
+
* check (lib/tools/credentialUses.ts, ADR 0122).
|
|
243
|
+
*/
|
|
244
|
+
export function openApiServers(entry, baseDir = process.cwd()) {
|
|
245
|
+
if (entry.base_url)
|
|
246
|
+
return [entry.base_url];
|
|
247
|
+
const specPath = resolve(baseDir, entry.spec);
|
|
248
|
+
const ext = extname(specPath).toLowerCase();
|
|
249
|
+
if (!['.yaml', '.yml', '.json'].includes(ext))
|
|
250
|
+
throw new Error(`openapi ${entry.spec}: a spec is .yaml, .yml or .json`);
|
|
251
|
+
const all = parseOpenApiSpec(readSpec(specPath, entry.spec), ext === '.json' ? 'json' : 'yaml', { source: entry.spec });
|
|
252
|
+
return [...new Set(all.map((o) => o.baseUrl).filter((u) => u !== ''))];
|
|
253
|
+
}
|
|
208
254
|
/**
|
|
209
255
|
* The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
|
|
210
256
|
* Tools, each marked so require_approval can name it by its operationId.
|