melchizedek-agents 1.0.3 → 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 +29 -2
- package/AGENT_SETUP.md +3 -1
- package/CHANGELOG.md +524 -0
- package/DOCUMENTATION.md +178 -43
- package/ONBOARDING.md +339 -0
- package/QUICKSTART.md +15 -2
- package/README.md +6 -2
- package/config/agents/README.md +2 -2
- package/config/agents/examples/ares.yaml +6 -10
- package/config/agents/examples/assistant.yaml +6 -6
- package/config/agents/examples/augustin.yaml +9 -15
- package/config/agents/examples/cartographers.yaml +6 -6
- package/config/agents/examples/council.yaml +7 -7
- package/config/agents/examples/critic.yaml +24 -24
- package/config/agents/examples/harness.yaml +4 -4
- package/config/agents/examples/image_production.yaml +37 -35
- package/config/agents/examples/librarian.yaml +4 -4
- package/config/agents/examples/patient_advocate.yaml +6 -10
- package/config/agents/examples/pipeline.yaml +20 -19
- package/config/agents/examples/research.yaml +36 -43
- package/config/agents/examples/scribe.yaml +31 -32
- package/config/agents/examples/scriptorium.yaml +6 -6
- package/config/agents/examples/structured_critic.yaml +82 -0
- package/config/agents/examples/style_council.yaml +9 -11
- package/config/agents/examples/syndicate.yaml +3 -5
- package/config/agents/examples/tutor.yaml +2 -2
- package/config/agents/examples/weather.yaml +2 -2
- package/config/agents/syndicate.schema.json +859 -13
- package/config/agents/syndicateSchema.yaml +122 -73
- package/config/agents/templates/README.md +1 -1
- package/config/agents/templates/account_memory.yaml +4 -4
- package/config/agents/templates/case_desk.yaml +6 -6
- package/config/agents/templates/conversational.yaml +2 -2
- package/config/agents/templates/draft_review.yaml +6 -6
- package/config/agents/templates/intake_extractor.yaml +64 -62
- package/config/agents/templates/research_brief.yaml +6 -6
- package/config/agents/templates/research_desk.yaml +6 -6
- package/config/agents/templates/review_panel.yaml +8 -8
- package/config/agents/templates/support_triage.yaml +30 -29
- package/config/agents/templates/systems_operator.yaml +24 -5
- package/db/migrations/0014_durable_runs.sql +31 -0
- package/dist/lib/a2a/app.d.ts +27 -0
- package/dist/lib/a2a/app.js +64 -5
- package/dist/lib/a2a/executor.d.ts +7 -1
- package/dist/lib/a2a/executor.js +43 -5
- package/dist/lib/a2a/oauthSetup.d.ts +106 -0
- package/dist/lib/a2a/oauthSetup.js +259 -0
- package/dist/lib/agentDialect.d.ts +76 -0
- package/dist/lib/agentDialect.js +151 -0
- package/dist/lib/compile.d.ts +42 -6
- package/dist/lib/compile.js +97 -45
- package/dist/lib/compileNative.d.ts +11 -3
- package/dist/lib/compileNative.js +20 -1
- package/dist/lib/dispatch.d.ts +10 -5
- package/dist/lib/dispatch.js +10 -5
- package/dist/lib/doctor.d.ts +110 -1
- package/dist/lib/doctor.js +205 -9
- package/dist/lib/loadSyndicate.d.ts +54 -2
- package/dist/lib/loadSyndicate.js +22 -2
- package/dist/lib/memory/namespace.d.ts +6 -5
- package/dist/lib/memory/namespace.js +6 -5
- package/dist/lib/memory/providers.js +3 -4
- package/dist/lib/models/capabilities.d.ts +25 -1
- package/dist/lib/models/capabilities.js +79 -1
- 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/observability/tracer.d.ts +8 -1
- package/dist/lib/observability/tracer.js +33 -5
- package/dist/lib/onboarding.d.ts +134 -0
- package/dist/lib/onboarding.js +539 -0
- package/dist/lib/runtime/approvals.d.ts +8 -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 +23 -3
- package/dist/lib/runtime/native/agentLoop.js +107 -11
- package/dist/lib/runtime/native/checkpoint.d.ts +171 -0
- package/dist/lib/runtime/native/checkpoint.js +359 -0
- package/dist/lib/runtime/native/delegate.d.ts +222 -18
- package/dist/lib/runtime/native/delegate.js +390 -39
- package/dist/lib/runtime/native/interrupts.d.ts +184 -2
- package/dist/lib/runtime/native/interrupts.js +391 -1
- package/dist/lib/runtime/native/request.d.ts +13 -3
- package/dist/lib/runtime/native/request.js +28 -4
- package/dist/lib/runtime/native/telemetry.js +10 -15
- package/dist/lib/runtime/questions.d.ts +7 -4
- package/dist/lib/runtime/sessions.d.ts +1 -1
- package/dist/lib/runtime/syndicateTurn.d.ts +11 -3
- package/dist/lib/runtime/syndicateTurn.js +353 -68
- package/dist/lib/runtime/turnControl.d.ts +56 -0
- package/dist/lib/runtime/turnControl.js +10 -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 +12 -2
- package/dist/lib/storage/postgres/index.js +8 -3
- package/dist/lib/storage/postgres/taskQueue.d.ts +20 -4
- package/dist/lib/storage/postgres/taskQueue.js +29 -5
- package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
- package/dist/lib/storage/postgres/taskStore.js +74 -8
- package/dist/lib/syndicateSchema.d.ts +316 -1
- package/dist/lib/syndicateSchema.js +203 -29
- package/dist/lib/tools/auth.d.ts +20 -6
- package/dist/lib/tools/auth.js +21 -5
- package/dist/lib/tools/credentialEnv.d.ts +28 -0
- package/dist/lib/tools/credentialEnv.js +52 -0
- package/dist/lib/tools/credentialHosts.d.ts +88 -0
- package/dist/lib/tools/credentialHosts.js +165 -0
- package/dist/lib/tools/credentialStore.js +9 -2
- package/dist/lib/tools/credentialUses.d.ts +92 -0
- package/dist/lib/tools/credentialUses.js +112 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
- package/dist/lib/tools/mcpToolFactory.js +179 -21
- package/dist/lib/tools/oauthConsent.js +8 -1
- package/dist/lib/tools/oauthHosts.d.ts +73 -0
- package/dist/lib/tools/oauthHosts.js +154 -0
- package/dist/lib/tools/oauthTools.d.ts +242 -0
- package/dist/lib/tools/oauthTools.js +447 -0
- package/dist/lib/tools/openapiTools.d.ts +11 -6
- package/dist/lib/tools/openapiTools.js +88 -38
- package/dist/lib/tools/taskTools.d.ts +61 -6
- package/dist/lib/tools/taskTools.js +145 -21
- package/dist/lib/workflow/turn.d.ts +22 -3
- package/dist/lib/workflow/turn.js +151 -31
- package/dist/lib/workflow.d.ts +14 -7
- package/dist/lib/workflow.js +14 -7
- package/dist/lib/workflowConfig.d.ts +6 -0
- package/dist/scripts/a2a_server.d.ts +36 -0
- package/dist/scripts/a2a_server.js +131 -1
- package/dist/scripts/assistant_worker.d.ts +12 -0
- package/dist/scripts/assistant_worker.js +81 -22
- package/dist/scripts/doctor.d.ts +7 -2
- package/dist/scripts/doctor.js +9 -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 +62 -0
- package/dist/scripts/yaml_codemod.js +652 -0
- package/package.json +8 -2
- package/skills/README.md +11 -2
- package/skills/briefs/_shared.md +1 -1
- package/skills/briefs/melchizedek-author.md +7 -5
- package/skills/briefs/melchizedek-models.md +7 -4
- package/skills/briefs/melchizedek.md +1 -0
- package/skills/briefs/skills-readme.md +3 -1
- package/skills/melchizedek/SKILL.md +1 -0
- package/skills/melchizedek-author/SKILL.md +7 -4
- package/skills/melchizedek-author/assets/minimal.yaml +13 -13
- package/skills/melchizedek-models/SKILL.md +11 -4
- 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,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/tools/oauthTools.ts — `auth: { oauth2 }` from a syndicate YAML: an
|
|
3
|
+
* OpenAPI entry or an MCP server whose calls carry an OAuth access token
|
|
4
|
+
* (WS6-3c, ADR 0112).
|
|
5
|
+
*
|
|
6
|
+
* WHY this file exists:
|
|
7
|
+
* The credential store (ADR 0072) holds each user's tokens and the consent
|
|
8
|
+
* step (ADR 0085) puts them there, but nothing said which tools send which
|
|
9
|
+
* provider's token. The YAML says it, next to the tool it protects:
|
|
10
|
+
*
|
|
11
|
+
* openapi:
|
|
12
|
+
* - spec: specs/tracker.yaml
|
|
13
|
+
* auth:
|
|
14
|
+
* oauth2:
|
|
15
|
+
* provider: tracker
|
|
16
|
+
* grant: authorization_code
|
|
17
|
+
* authorization_url: https://tracker.example.com/oauth/authorize
|
|
18
|
+
* token_url: https://tracker.example.com/oauth/token
|
|
19
|
+
* client_id_env: TRACKER_CLIENT_ID
|
|
20
|
+
* client_secret_env: TRACKER_CLIENT_SECRET
|
|
21
|
+
* scopes: [issues:read]
|
|
22
|
+
*
|
|
23
|
+
* and `mcp_auth: { oauth2: { ... } }` beside an agent's `mcp_server_url`.
|
|
24
|
+
*
|
|
25
|
+
* THE TWO GRANTS:
|
|
26
|
+
* - authorization_code: the token is the run's own user's, read through
|
|
27
|
+
* `ctx.accessToken(provider)` at each call. A user who has not granted it
|
|
28
|
+
* is asked (the consent pause, when the run has a consent step whose
|
|
29
|
+
* clients include the provider: `oauthClientsFor` builds them from the
|
|
30
|
+
* same YAML, and `oauthRefreshProviders` the store's refresh hooks).
|
|
31
|
+
* The tool never chooses whose token.
|
|
32
|
+
* - client_credentials: the server's own token for the provider, from its
|
|
33
|
+
* token endpoint with the client id and secret, held in this process's
|
|
34
|
+
* memory until shortly before it expires, never in the credential store
|
|
35
|
+
* (no user owns it).
|
|
36
|
+
*
|
|
37
|
+
* SECRETS: the YAML names environment variables, never values; the variable
|
|
38
|
+
* names pass credentialEnvProblem (lib/tools/credentialEnv.ts), so a YAML
|
|
39
|
+
* cannot send a framework secret to a token endpoint. No token, client
|
|
40
|
+
* secret or provider error text reaches a result, an error, a log line or
|
|
41
|
+
* a span: a failure is reported by kind (ToolCredentialError).
|
|
42
|
+
*
|
|
43
|
+
* WHERE A TOKEN MAY GO: a token is sent only over https, or http to a
|
|
44
|
+
* loopback host (local development), to the server the YAML names, and
|
|
45
|
+
* only to a host the operator's allowlist binds to the provider
|
|
46
|
+
* (lib/tools/oauthHosts.ts, ADR 0114): with none configured, an
|
|
47
|
+
* authorization_code grant is refused. The allowlist is checked when a
|
|
48
|
+
* served syndicate loads (`syndicateOAuthHostProblems`), when the tools
|
|
49
|
+
* compile (the server, the token endpoint and the authorization
|
|
50
|
+
* endpoint), and again before each call sends a token
|
|
51
|
+
* (`oauthCallProblem`). The client-credentials token endpoint passes the
|
|
52
|
+
* same SSRF guard as the server it serves (ALLOW_PRIVATE_OPENAPI or
|
|
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.
|
|
61
|
+
*/
|
|
62
|
+
import { blockedHostReason, checkHost } from '../net/addressGuard.js';
|
|
63
|
+
import { PROVIDER_NAME, ToolCredentialError } from './auth.js';
|
|
64
|
+
import { readCredentialEnv } from './credentialEnv.js';
|
|
65
|
+
import { CLIENT_SECRET_ENV, clientSecretEnvOf, credentialCallProblem, credentialHostProblem } from './credentialHosts.js';
|
|
66
|
+
import { oauthHostProblem, oauthHosts } from './oauthHosts.js';
|
|
67
|
+
/** An agent's declared OAuth grants, OpenAPI entries first, then its MCP server. */
|
|
68
|
+
export function agentOAuthGrants(agent, prefix = '') {
|
|
69
|
+
const name = `${prefix}${agent.name ?? ''}`;
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const entry of agent.openapi ?? []) {
|
|
72
|
+
if (entry?.auth?.oauth2)
|
|
73
|
+
out.push({ agent: name, tools: `openapi ${entry.spec ?? ''}`, oauth2: entry.auth.oauth2 });
|
|
74
|
+
}
|
|
75
|
+
if (agent.mcp_auth?.oauth2)
|
|
76
|
+
out.push({ agent: name, tools: `mcp ${agent.mcp_server_url ?? ''}`, oauth2: agent.mcp_auth.oauth2 });
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/** The environment variables a grant reads, by role. */
|
|
80
|
+
export function oauthEnvNames(oauth2) {
|
|
81
|
+
return [oauth2.client_id_env, oauth2.client_secret_env].filter((e) => typeof e === 'string' && e !== '');
|
|
82
|
+
}
|
|
83
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
84
|
+
/** Why a token may not be sent to `raw`, or null: https, or http to a loopback host. */
|
|
85
|
+
export function tokenTransportProblem(raw) {
|
|
86
|
+
let url;
|
|
87
|
+
try {
|
|
88
|
+
url = new URL(raw);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return `'${raw.slice(0, 200)}' is not a URL`;
|
|
92
|
+
}
|
|
93
|
+
if (url.username || url.password)
|
|
94
|
+
return `${url.hostname} must not carry credentials in its URL`;
|
|
95
|
+
if (url.protocol === 'https:')
|
|
96
|
+
return null;
|
|
97
|
+
if (url.protocol === 'http:' && LOOPBACK.has(url.hostname))
|
|
98
|
+
return null;
|
|
99
|
+
return `an OAuth token is sent only over https (http only to a loopback host), not to ${url.protocol}//${url.hostname}`;
|
|
100
|
+
}
|
|
101
|
+
// ── The operator's host allowlist (ADR 0114) ─────────────────────────────────
|
|
102
|
+
/**
|
|
103
|
+
* Why this grant may not be declared for these servers, one line per
|
|
104
|
+
* refusal: each server the tools call, the token endpoint and the
|
|
105
|
+
* authorization endpoint must be hosts the operator's allowlist binds to the
|
|
106
|
+
* provider (lib/tools/oauthHosts.ts). With no allowlist, an
|
|
107
|
+
* authorization_code grant is refused and a client_credentials one allowed.
|
|
108
|
+
* `allowlist` defaults to the one in force; `null` means none is configured.
|
|
109
|
+
*/
|
|
110
|
+
export function oauthGrantHostProblems(oauth2, servers, allowlist) {
|
|
111
|
+
const list = allowlist === undefined ? oauthHosts() ?? null : allowlist;
|
|
112
|
+
const urls = servers.map((s) => ['server', s]);
|
|
113
|
+
urls.push(['token_url', oauth2.token_url]);
|
|
114
|
+
if (oauth2.authorization_url)
|
|
115
|
+
urls.push(['authorization_url', oauth2.authorization_url]);
|
|
116
|
+
const out = new Set();
|
|
117
|
+
for (const [role, url] of urls) {
|
|
118
|
+
const problem = oauthHostProblem(oauth2, url, role, list);
|
|
119
|
+
if (problem)
|
|
120
|
+
out.add(problem);
|
|
121
|
+
}
|
|
122
|
+
return [...out];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Why a call may not send this grant's token (or secret) to `destination`
|
|
126
|
+
* now, or null: the transport rule and the allowlist in force, checked again
|
|
127
|
+
* at each call. The compile checked the same; this holds if a request is
|
|
128
|
+
* aimed elsewhere, or the allowlist changed. A malformed allowlist refuses.
|
|
129
|
+
*/
|
|
130
|
+
export function oauthCallProblem(oauth2, destination) {
|
|
131
|
+
const transport = tokenTransportProblem(destination);
|
|
132
|
+
if (transport)
|
|
133
|
+
return transport;
|
|
134
|
+
try {
|
|
135
|
+
return oauthHostProblem(oauth2, destination, 'server', oauthHosts() ?? null);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return 'the OAuth host allowlist is malformed';
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
|
|
142
|
+
/** A token response, read whole up to a bound, as a JSON object; undefined otherwise. */
|
|
143
|
+
async function readTokenJson(res) {
|
|
144
|
+
const reader = res.body?.getReader();
|
|
145
|
+
if (!reader)
|
|
146
|
+
return undefined;
|
|
147
|
+
const chunks = [];
|
|
148
|
+
let size = 0;
|
|
149
|
+
for (;;) {
|
|
150
|
+
const { done, value } = await reader.read();
|
|
151
|
+
if (done)
|
|
152
|
+
break;
|
|
153
|
+
size += value.byteLength;
|
|
154
|
+
if (size > MAX_TOKEN_RESPONSE_BYTES) {
|
|
155
|
+
await reader.cancel().catch(() => { });
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
chunks.push(value);
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
162
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** Why the server may not call this token endpoint, or null (the full guard, with DNS). */
|
|
169
|
+
async function tokenEndpointProblem(raw, allowPrivate) {
|
|
170
|
+
const transport = tokenTransportProblem(raw);
|
|
171
|
+
if (transport)
|
|
172
|
+
return transport;
|
|
173
|
+
if (allowPrivate)
|
|
174
|
+
return null;
|
|
175
|
+
const reason = await checkHost(new URL(raw).hostname);
|
|
176
|
+
return reason ? `refusing token endpoint ${new URL(raw).hostname}: ${reason}` : null;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* One token-endpoint request (RFC 6749 4.4, 6): the endpoint checked by the
|
|
180
|
+
* guard first, no redirect followed, a time limit, a bounded response. Any
|
|
181
|
+
* failure is ToolCredentialError('grant_failed'), never the provider's text,
|
|
182
|
+
* which may echo what it refused.
|
|
183
|
+
*/
|
|
184
|
+
async function tokenRequest(tokenUrl, body, provider, options) {
|
|
185
|
+
if (await tokenEndpointProblem(tokenUrl, options.allowPrivate))
|
|
186
|
+
throw new ToolCredentialError('grant_failed', provider);
|
|
187
|
+
let json;
|
|
188
|
+
try {
|
|
189
|
+
const res = await options.doFetch(tokenUrl, {
|
|
190
|
+
method: 'POST',
|
|
191
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
192
|
+
body,
|
|
193
|
+
// A redirect would carry the client secret (or the refresh token) to wherever it points.
|
|
194
|
+
redirect: 'error',
|
|
195
|
+
signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs)]) : AbortSignal.timeout(options.timeoutMs),
|
|
196
|
+
});
|
|
197
|
+
json = await readTokenJson(res);
|
|
198
|
+
if (!res.ok)
|
|
199
|
+
json = undefined;
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
json = undefined;
|
|
203
|
+
}
|
|
204
|
+
const accessToken = json?.access_token;
|
|
205
|
+
const tokenType = json?.token_type;
|
|
206
|
+
if (typeof accessToken !== 'string' || !accessToken)
|
|
207
|
+
throw new ToolCredentialError('grant_failed', provider);
|
|
208
|
+
if (tokenType !== undefined && (typeof tokenType !== 'string' || tokenType.toLowerCase() !== 'bearer'))
|
|
209
|
+
throw new ToolCredentialError('grant_failed', provider);
|
|
210
|
+
const refreshToken = typeof json?.refresh_token === 'string' && json.refresh_token ? json.refresh_token : undefined;
|
|
211
|
+
const expiresIn = typeof json?.expires_in === 'number' ? json.expires_in : typeof json?.expires_in === 'string' ? Number(json.expires_in) : undefined;
|
|
212
|
+
const scopes = typeof json?.scope === 'string' ? json.scope.split(' ').filter((x) => x !== '') : undefined;
|
|
213
|
+
return {
|
|
214
|
+
accessToken,
|
|
215
|
+
...(refreshToken ? { refreshToken } : {}),
|
|
216
|
+
...(expiresIn !== undefined && Number.isFinite(expiresIn) && expiresIn > 0 ? { expiresAt: new Date(options.now() + expiresIn * 1000) } : {}),
|
|
217
|
+
...(scopes ? { scopes } : {}),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The server's own token for a provider (RFC 6749 4.4), fetched when first
|
|
222
|
+
* needed and again shortly before it expires, one request at a time. A
|
|
223
|
+
* failure throws ToolCredentialError('grant_failed'), never the provider's
|
|
224
|
+
* text, which may echo what it refused.
|
|
225
|
+
*/
|
|
226
|
+
export function clientCredentialsGrant(options) {
|
|
227
|
+
const now = options.now ?? Date.now;
|
|
228
|
+
const skew = options.refreshSkewMs ?? 60_000;
|
|
229
|
+
const doFetch = options.fetch ?? fetch;
|
|
230
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
231
|
+
let held;
|
|
232
|
+
let pending;
|
|
233
|
+
const request = async (signal) => {
|
|
234
|
+
const body = new URLSearchParams({ grant_type: 'client_credentials', client_id: options.clientId, client_secret: options.clientSecret });
|
|
235
|
+
if (options.scopes?.length)
|
|
236
|
+
body.set('scope', options.scopes.join(' '));
|
|
237
|
+
const issued = await tokenRequest(options.tokenUrl, body, options.provider, { allowPrivate: options.allowPrivate === true, doFetch, timeoutMs, now, ...(signal ? { signal } : {}) });
|
|
238
|
+
held = { accessToken: issued.accessToken, ...(issued.expiresAt ? { expiresAt: issued.expiresAt.getTime() } : {}) };
|
|
239
|
+
return issued.accessToken;
|
|
240
|
+
};
|
|
241
|
+
return {
|
|
242
|
+
async token(signal) {
|
|
243
|
+
if (held && (held.expiresAt === undefined || held.expiresAt - skew > now()))
|
|
244
|
+
return held.accessToken;
|
|
245
|
+
if (!pending)
|
|
246
|
+
pending = request(signal).finally(() => (pending = undefined));
|
|
247
|
+
return pending;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The token source a declared `oauth2` block gives its tools. Reads the
|
|
253
|
+
* client-credentials variables now, so a variable that is not set (or that
|
|
254
|
+
* the allowlist refuses) fails the compile, naming the variable only, and
|
|
255
|
+
* so does a host the operator's OAuth host allowlist does not bind to the
|
|
256
|
+
* provider (ADR 0114). `where` prefixes those messages; `server` is the URL
|
|
257
|
+
* (or URLs) the token is sent to.
|
|
258
|
+
*/
|
|
259
|
+
export function oauthTokenSource(oauth2, where, server, options = {}) {
|
|
260
|
+
const servers = typeof server === 'string' ? [server] : [...server];
|
|
261
|
+
for (const s of servers) {
|
|
262
|
+
const problem = tokenTransportProblem(s);
|
|
263
|
+
if (problem)
|
|
264
|
+
throw new Error(`${where}: ${problem}`);
|
|
265
|
+
}
|
|
266
|
+
if (!PROVIDER_NAME.test(oauth2.provider))
|
|
267
|
+
throw new Error(`${where}: provider must match ${PROVIDER_NAME}`);
|
|
268
|
+
const hostProblems = oauthGrantHostProblems(oauth2, servers);
|
|
269
|
+
if (hostProblems.length)
|
|
270
|
+
throw new Error(`${where}: ${hostProblems.join('; ')}`);
|
|
271
|
+
const provider = oauth2.provider;
|
|
272
|
+
/** The call-time check (ADR 0114): before any token is read or fetched. */
|
|
273
|
+
const refuse = (destination) => {
|
|
274
|
+
if (oauthCallProblem(oauth2, destination))
|
|
275
|
+
throw new ToolCredentialError('host_refused', provider, 'oauth');
|
|
276
|
+
};
|
|
277
|
+
if (oauth2.grant === 'authorization_code') {
|
|
278
|
+
return async (ctx, destination) => {
|
|
279
|
+
refuse(destination);
|
|
280
|
+
if (!ctx?.accessToken)
|
|
281
|
+
throw new ToolCredentialError('unavailable', provider);
|
|
282
|
+
return ctx.accessToken(provider);
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
const env = options.env ?? process.env;
|
|
286
|
+
if (!oauth2.client_secret_env)
|
|
287
|
+
throw new Error(`${where}: a client_credentials grant needs client_secret_env`);
|
|
288
|
+
const transport = tokenTransportProblem(oauth2.token_url);
|
|
289
|
+
if (transport)
|
|
290
|
+
throw new Error(`${where}: token_url: ${transport}`);
|
|
291
|
+
if (options.allowPrivate !== true) {
|
|
292
|
+
const blocked = blockedHostReason(new URL(oauth2.token_url).hostname);
|
|
293
|
+
if (blocked)
|
|
294
|
+
throw new Error(`${where}: refusing token endpoint ${new URL(oauth2.token_url).hostname}: ${blocked}`);
|
|
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}`);
|
|
301
|
+
const grant = clientCredentialsGrant({
|
|
302
|
+
provider,
|
|
303
|
+
tokenUrl: oauth2.token_url,
|
|
304
|
+
clientId: oauth2.client_id ?? readCredentialEnv(oauth2.client_id_env ?? '', where, env),
|
|
305
|
+
clientSecret: readCredentialEnv(oauth2.client_secret_env, where, env),
|
|
306
|
+
...(oauth2.scopes?.length ? { scopes: [...oauth2.scopes] } : {}),
|
|
307
|
+
...(options.allowPrivate ? { allowPrivate: true } : {}),
|
|
308
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
309
|
+
});
|
|
310
|
+
return async (ctx, destination) => {
|
|
311
|
+
refuse(destination);
|
|
312
|
+
// The client secret goes to the token endpoint: held to the same rule,
|
|
313
|
+
// and to the credential host allowlist (ADR 0122).
|
|
314
|
+
refuse(oauth2.token_url);
|
|
315
|
+
if (credentialCallProblem(secretEnv, oauth2.token_url))
|
|
316
|
+
throw new ToolCredentialError('host_refused', provider, 'credential');
|
|
317
|
+
return grant.token(ctx?.signal);
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* The OAuth clients the consent step needs for these syndicates' declared
|
|
322
|
+
* authorization-code grants, by provider: pass them as
|
|
323
|
+
* `oauthConsent({ providers })` (lib/tools/oauthConsent.ts). Client ids and
|
|
324
|
+
* secrets are read from the variables the YAML names. Two declarations of
|
|
325
|
+
* one provider must agree on its endpoints, client and scopes, or this
|
|
326
|
+
* throws: a provider's grant is one grant, whichever tool asks for it.
|
|
327
|
+
* `load` reads a nested `yaml_reference:` file, when given.
|
|
328
|
+
*/
|
|
329
|
+
export function oauthClientsFor(configs, options = {}) {
|
|
330
|
+
const env = options.env ?? process.env;
|
|
331
|
+
const allowlist = options.allowlist === undefined ? oauthHosts(env) ?? null : options.allowlist;
|
|
332
|
+
const clients = {};
|
|
333
|
+
const signature = new Map();
|
|
334
|
+
for (const use of syndicateOAuthGrants(configs, options.load)) {
|
|
335
|
+
const o = use.oauth2;
|
|
336
|
+
if (o.grant !== 'authorization_code')
|
|
337
|
+
continue;
|
|
338
|
+
const where = `${use.agent} · ${use.tools} · oauth2`;
|
|
339
|
+
if (!o.authorization_url)
|
|
340
|
+
throw new Error(`${where}: an authorization_code grant needs authorization_url`);
|
|
341
|
+
// The consent step sends the client secret, the code and later the refresh token to these endpoints (ADR 0114).
|
|
342
|
+
const hostProblems = oauthGrantHostProblems(o, [], allowlist);
|
|
343
|
+
if (hostProblems.length)
|
|
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}`);
|
|
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 ?? {}]);
|
|
350
|
+
const before = signature.get(o.provider);
|
|
351
|
+
if (before !== undefined) {
|
|
352
|
+
if (before !== sig)
|
|
353
|
+
throw new Error(`${where}: provider "${o.provider}" is declared twice with different endpoints, client or scopes`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
signature.set(o.provider, sig);
|
|
357
|
+
clients[o.provider] = {
|
|
358
|
+
authorizationUrl: o.authorization_url,
|
|
359
|
+
tokenUrl: o.token_url,
|
|
360
|
+
clientId: o.client_id ?? readCredentialEnv(o.client_id_env ?? '', where, env),
|
|
361
|
+
...(o.client_secret_env ? { clientSecret: readCredentialEnv(o.client_secret_env, where, env) } : {}),
|
|
362
|
+
scopes: [...(o.scopes ?? [])],
|
|
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 } : {}),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
return clients;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Every OAuth grant these syndicates declare, orchestrators and subagents,
|
|
372
|
+
* following a nested `yaml_reference:` through `load` when given (once per
|
|
373
|
+
* file; agent names then read "Parent › Child").
|
|
374
|
+
*/
|
|
375
|
+
export function syndicateOAuthGrants(configs, load) {
|
|
376
|
+
const out = [];
|
|
377
|
+
const seen = new Set();
|
|
378
|
+
const visit = (config, prefix) => {
|
|
379
|
+
const agents = [config.orchestrator, ...(config.subagents ?? [])].filter((a) => !!a);
|
|
380
|
+
for (const agent of agents) {
|
|
381
|
+
if (agent.yaml_reference) {
|
|
382
|
+
if (!load || seen.has(agent.yaml_reference))
|
|
383
|
+
continue;
|
|
384
|
+
seen.add(agent.yaml_reference);
|
|
385
|
+
visit(load(agent.yaml_reference), `${prefix}${agent.name ?? agent.yaml_reference} › `);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
out.push(...agentOAuthGrants(agent, prefix));
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
for (const config of configs)
|
|
392
|
+
visit(config, '');
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Why these syndicates may not be served here, one line per refusal
|
|
397
|
+
* (ADR 0114): each declared grant's MCP server, token endpoint and
|
|
398
|
+
* authorization endpoint against the operator's OAuth host allowlist (an
|
|
399
|
+
* OpenAPI entry's servers are in its spec, so its compile checks those).
|
|
400
|
+
* The A2A server checks every syndicate it loads to serve; the compile
|
|
401
|
+
* checks again, and each call once more.
|
|
402
|
+
*/
|
|
403
|
+
export function syndicateOAuthHostProblems(configs, options = {}) {
|
|
404
|
+
const allowlist = options.allowlist === undefined ? oauthHosts() ?? null : options.allowlist;
|
|
405
|
+
const out = [];
|
|
406
|
+
for (const use of syndicateOAuthGrants(configs, options.load)) {
|
|
407
|
+
const servers = use.tools.startsWith('mcp ') && use.tools.length > 4 ? [use.tools.slice(4)] : [];
|
|
408
|
+
for (const problem of oauthGrantHostProblems(use.oauth2, servers, allowlist))
|
|
409
|
+
out.push(`${use.agent} · ${use.tools} · oauth2: ${problem}`);
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* The credential store's refresh hooks for the same providers
|
|
415
|
+
* (`credentialStore({ providers })`, lib/tools/credentialStore.ts): an
|
|
416
|
+
* expired user token is renewed at the provider's token endpoint with its
|
|
417
|
+
* refresh token (RFC 6749 6) instead of asking the person again. Built from
|
|
418
|
+
* `oauthClientsFor`'s clients; `allowPrivate` lets a private or loopback
|
|
419
|
+
* token endpoint through the guard (development).
|
|
420
|
+
*/
|
|
421
|
+
export function oauthRefreshProviders(clients, options = {}) {
|
|
422
|
+
const providers = {};
|
|
423
|
+
for (const [name, client] of Object.entries(clients)) {
|
|
424
|
+
providers[name] = {
|
|
425
|
+
refresh: async (refreshToken, context) => {
|
|
426
|
+
// A refresh token goes only to a token endpoint the allowlist binds to its provider (ADR 0114).
|
|
427
|
+
if (oauthCallProblem({ provider: name, grant: 'authorization_code' }, client.tokenUrl))
|
|
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');
|
|
433
|
+
const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: client.clientId });
|
|
434
|
+
if (client.clientSecret)
|
|
435
|
+
body.set('client_secret', client.clientSecret);
|
|
436
|
+
return tokenRequest(client.tokenUrl, body, name, {
|
|
437
|
+
allowPrivate: options.allowPrivate === true,
|
|
438
|
+
doFetch: options.fetch ?? fetch,
|
|
439
|
+
timeoutMs: options.timeoutMs ?? 10_000,
|
|
440
|
+
now: options.now ?? Date.now,
|
|
441
|
+
...(context.signal ? { signal: context.signal } : {}),
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return providers;
|
|
447
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Tool } from './tool.ts';
|
|
2
|
+
import type { OAuth2AuthConfig } from './oauthTools.ts';
|
|
3
|
+
export { credentialEnvProblem } from './credentialEnv.ts';
|
|
2
4
|
export { namesTool, toSnake } from './openapi/parse.ts';
|
|
3
5
|
export interface OpenApiAuthConfig {
|
|
4
6
|
/** Environment variable holding a bearer token (`Authorization: Bearer …`). */
|
|
@@ -9,6 +11,8 @@ export interface OpenApiAuthConfig {
|
|
|
9
11
|
in: 'header' | 'query';
|
|
10
12
|
name: string;
|
|
11
13
|
};
|
|
14
|
+
/** An OAuth access token per call: the run's user's (authorization_code) or the server's (client_credentials). ADR 0112. */
|
|
15
|
+
oauth2?: OAuth2AuthConfig;
|
|
12
16
|
}
|
|
13
17
|
/** One entry of an agent's `openapi:` list. */
|
|
14
18
|
export interface OpenApiConfig {
|
|
@@ -29,12 +33,6 @@ export declare const MAX_RESULT_CHARS = 20000;
|
|
|
29
33
|
* operationId (lib/compile.ts). Its value is the operationId.
|
|
30
34
|
*/
|
|
31
35
|
export declare const OPENAPI_TOOL: unique symbol;
|
|
32
|
-
/**
|
|
33
|
-
* Why an `auth` may not read this variable, or null. With
|
|
34
|
-
* OPENAPI_CREDENTIAL_ENVS set, only the names it lists are allowed; without
|
|
35
|
-
* it, anything but the framework's own variables is.
|
|
36
|
-
*/
|
|
37
|
-
export declare function credentialEnvProblem(name: string, env?: NodeJS.ProcessEnv): string | null;
|
|
38
36
|
/**
|
|
39
37
|
* Cut an API result to MAX_RESULT_CHARS, saying so. Kept for consumers of
|
|
40
38
|
* this module; the tools cut with capResult (lib/tools/tool.ts).
|
|
@@ -47,6 +45,13 @@ export declare function boundResult(result: unknown): unknown;
|
|
|
47
45
|
* an operation the spec does not have, or a server the guard refuses.
|
|
48
46
|
*/
|
|
49
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[];
|
|
50
55
|
/**
|
|
51
56
|
* The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
|
|
52
57
|
* Tools, each marked so require_approval can name it by its operationId.
|
|
@@ -32,7 +32,16 @@
|
|
|
32
32
|
* OPENAPI_CREDENTIAL_ENVS, when the operator sets it, is the exact list
|
|
33
33
|
* of variables an `auth` may name. A credential is held by the tool,
|
|
34
34
|
* never written to session state, and its value never appears in an
|
|
35
|
-
* error the model reads.
|
|
35
|
+
* error the model reads. An `auth: { oauth2 }` sends an OAuth token
|
|
36
|
+
* instead, fetched per call: the run's user's own (authorization_code,
|
|
37
|
+
* through `ctx.accessToken` and the consent pause) or the server's own
|
|
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.
|
|
36
45
|
* - THE SSRF GUARD. Every server URL the tools will call must be http(s)
|
|
37
46
|
* and pass lib/net/addressGuard.ts: its literal rules at compile time
|
|
38
47
|
* (offline), and the full check with DNS before each call (a name can
|
|
@@ -62,6 +71,11 @@ import { extname, resolve } from 'node:path';
|
|
|
62
71
|
import { MAX_RESULT_CHARS as TOOL_RESULT_CHARS, capResult } from './tool.js';
|
|
63
72
|
import { callOperation, hostProblem } from './openapi/call.js';
|
|
64
73
|
import { MAX_SPEC_BYTES, operationNamed, parseOpenApiSpec } from './openapi/parse.js';
|
|
74
|
+
import { ToolCredentialError } from './auth.js';
|
|
75
|
+
import { readCredentialEnv } from './credentialEnv.js';
|
|
76
|
+
import { credentialCallProblem, credentialHostProblem } from './credentialHosts.js';
|
|
77
|
+
import { oauthTokenSource, tokenTransportProblem } from './oauthTools.js';
|
|
78
|
+
export { credentialEnvProblem } from './credentialEnv.js';
|
|
65
79
|
export { namesTool, toSnake } from './openapi/parse.js';
|
|
66
80
|
/** Characters a single API result may carry into the conversation: every tool's shared limit (lib/tools/tool.ts). */
|
|
67
81
|
export const MAX_RESULT_CHARS = TOOL_RESULT_CHARS;
|
|
@@ -70,51 +84,27 @@ export const MAX_RESULT_CHARS = TOOL_RESULT_CHARS;
|
|
|
70
84
|
* operationId (lib/compile.ts). Its value is the operationId.
|
|
71
85
|
*/
|
|
72
86
|
export const OPENAPI_TOOL = Symbol.for('melchizedek.openapiTool');
|
|
73
|
-
/** Prefixes and names of variables the framework itself reads: never an API credential. */
|
|
74
|
-
const FRAMEWORK_ENV_PREFIXES = [
|
|
75
|
-
'A2A_', 'SUPABASE_', 'DATABASE_', 'MCP_', 'MODEL_', 'MEMORY_', 'OTEL_', 'TELEMETRY_', 'MELCHIZEDEK_',
|
|
76
|
-
'GOOGLE_', 'GEMINI_', 'ANTHROPIC_', 'OPENAI_', 'AZURE_', 'AWS_', 'XAI_', 'MOONSHOT_', 'OLLAMA_', 'WIKI_',
|
|
77
|
-
'ALLOW_', 'OPENAPI_',
|
|
78
|
-
];
|
|
79
|
-
const FRAMEWORK_ENV_NAMES = new Set(['PUBLIC_URL', 'HOST', 'PORT', 'PATH', 'HOME', 'NODE_OPTIONS', 'WEB_EXTRACT_CHAR_LIMIT']);
|
|
80
|
-
/**
|
|
81
|
-
* Why an `auth` may not read this variable, or null. With
|
|
82
|
-
* OPENAPI_CREDENTIAL_ENVS set, only the names it lists are allowed; without
|
|
83
|
-
* it, anything but the framework's own variables is.
|
|
84
|
-
*/
|
|
85
|
-
export function credentialEnvProblem(name, env = process.env) {
|
|
86
|
-
const allow = env.OPENAPI_CREDENTIAL_ENVS?.split(',').map((s) => s.trim()).filter(Boolean);
|
|
87
|
-
if (allow?.length) {
|
|
88
|
-
return allow.includes(name) ? null : `${name} is not in OPENAPI_CREDENTIAL_ENVS (${allow.join(', ')})`;
|
|
89
|
-
}
|
|
90
|
-
if (FRAMEWORK_ENV_NAMES.has(name) || FRAMEWORK_ENV_PREFIXES.some((p) => name.startsWith(p))) {
|
|
91
|
-
return `${name} is one of the framework's own settings and may not be sent to an API; give the API its own variable (or list it in OPENAPI_CREDENTIAL_ENVS)`;
|
|
92
|
-
}
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
87
|
/**
|
|
96
88
|
* The credential an `auth` names, read from the environment once, at
|
|
97
89
|
* compile. Throws (failing the compile) on a variable the allowlist refuses
|
|
98
90
|
* or one that is not set; the message names the variable, never its value.
|
|
91
|
+
* An `oauth2` block is not read here: its token is fetched per call
|
|
92
|
+
* (oauthTokenSource, lib/tools/oauthTools.ts).
|
|
99
93
|
*/
|
|
100
94
|
function credentialFor(auth, spec) {
|
|
101
95
|
if (!auth)
|
|
102
96
|
return undefined;
|
|
103
|
-
const read = (env) => {
|
|
104
|
-
const refused = credentialEnvProblem(env);
|
|
105
|
-
if (refused)
|
|
106
|
-
throw new Error(`openapi ${spec}: ${refused}`);
|
|
107
|
-
const value = process.env[env]?.trim();
|
|
108
|
-
if (!value)
|
|
109
|
-
throw new Error(`openapi ${spec}: ${env} is not set (auth reads it from the environment)`);
|
|
110
|
-
return value;
|
|
111
|
-
};
|
|
97
|
+
const read = (env) => readCredentialEnv(env, `openapi ${spec}`);
|
|
112
98
|
if (auth.bearer_env)
|
|
113
|
-
return { kind: 'bearer', token: read(auth.bearer_env) };
|
|
99
|
+
return { credential: { kind: 'bearer', token: read(auth.bearer_env) }, env: auth.bearer_env };
|
|
114
100
|
if (auth.api_key)
|
|
115
|
-
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 };
|
|
116
102
|
return undefined;
|
|
117
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
|
+
}
|
|
118
108
|
/**
|
|
119
109
|
* Cut an API result to MAX_RESULT_CHARS, saying so. Kept for consumers of
|
|
120
110
|
* this module; the tools cut with capResult (lib/tools/tool.ts).
|
|
@@ -152,13 +142,42 @@ function readSpec(specPath, source) {
|
|
|
152
142
|
closeSync(fd);
|
|
153
143
|
}
|
|
154
144
|
}
|
|
155
|
-
/**
|
|
156
|
-
|
|
145
|
+
/**
|
|
146
|
+
* An own Tool that calls one operation (lib/tools/openapi/call.ts), its
|
|
147
|
+
* result cut to MAX_RESULT_CHARS. With `oauth`, each call fetches its token
|
|
148
|
+
* first; a token that cannot be had is answered as `{ error }` naming the
|
|
149
|
+
* provider and what to do, never a value (and, for a user who has not
|
|
150
|
+
* granted it, the run's consent step has already asked).
|
|
151
|
+
*/
|
|
152
|
+
function operationTool(op, held, oauth) {
|
|
153
|
+
const credential = held?.credential;
|
|
157
154
|
const tool = {
|
|
158
155
|
name: op.name,
|
|
159
156
|
declaration: () => op.declaration,
|
|
160
157
|
async execute(args, ctx) {
|
|
161
|
-
|
|
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
|
+
}
|
|
171
|
+
let sent = credential;
|
|
172
|
+
if (oauth) {
|
|
173
|
+
try {
|
|
174
|
+
sent = { kind: 'bearer', token: await oauth(ctx, op.baseUrl) };
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
return { error: error instanceof ToolCredentialError ? error.message : 'The authorization for this API could not be obtained.' };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return capResult(await callOperation(op, args ?? {}, { credential: sent, signal: ctx?.signal }));
|
|
162
181
|
},
|
|
163
182
|
};
|
|
164
183
|
Object.defineProperty(tool, OPENAPI_TOOL, { value: op.operationId });
|
|
@@ -192,14 +211,45 @@ export async function buildOpenApiOwnTools(entry, baseDir = process.cwd()) {
|
|
|
192
211
|
picked = all.filter((o) => o.method === 'get');
|
|
193
212
|
}
|
|
194
213
|
const chosen = entry.base_url ? picked.map((o) => ({ ...o, baseUrl: entry.base_url })) : picked;
|
|
214
|
+
const credentialEnv = staticCredentialEnv(entry.auth);
|
|
195
215
|
for (const baseUrl of new Set(chosen.map((o) => o.baseUrl))) {
|
|
196
216
|
if (!baseUrl)
|
|
197
217
|
throw new Error(`openapi ${entry.spec}: the spec names no server; set base_url`);
|
|
198
218
|
const problem = await hostProblem(baseUrl, false);
|
|
199
219
|
if (problem)
|
|
200
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}`);
|
|
201
225
|
}
|
|
202
|
-
|
|
226
|
+
let oauth;
|
|
227
|
+
if (entry.auth?.oauth2 && chosen.length) {
|
|
228
|
+
// Every server the token goes to takes it over https (or loopback http).
|
|
229
|
+
for (const server of new Set(chosen.map((o) => o.baseUrl))) {
|
|
230
|
+
const problem = tokenTransportProblem(server);
|
|
231
|
+
if (problem)
|
|
232
|
+
throw new Error(`openapi ${entry.spec}: ${problem}`);
|
|
233
|
+
}
|
|
234
|
+
oauth = oauthTokenSource(entry.auth.oauth2, `openapi ${entry.spec}`, [...new Set(chosen.map((o) => o.baseUrl))], { allowPrivate: process.env.ALLOW_PRIVATE_OPENAPI === 'true' });
|
|
235
|
+
}
|
|
236
|
+
return chosen.map((o) => operationTool(o, credential, oauth));
|
|
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 !== ''))];
|
|
203
253
|
}
|
|
204
254
|
/**
|
|
205
255
|
* The tools one `openapi:` entry gives an agent: buildOpenApiOwnTools' own
|