melchizedek-agents 1.1.0 → 1.3.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 +19 -0
- package/AGENT_SETUP.md +3 -1
- package/CHANGELOG.md +373 -0
- package/DOCUMENTATION.md +193 -21
- package/ONBOARDING.md +378 -0
- package/QUICKSTART.md +15 -2
- package/README.md +26 -3
- package/config/agents/examples/connectors.yaml +82 -0
- 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 +389 -33
- 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 +25 -11
- package/dist/lib/a2a/executor.js +14 -6
- package/dist/lib/a2a/oauthSetup.d.ts +16 -0
- package/dist/lib/a2a/oauthSetup.js +77 -8
- package/dist/lib/agentDialect.d.ts +18 -8
- package/dist/lib/agentDialect.js +36 -19
- package/dist/lib/chatgpt/adapter.d.ts +50 -0
- package/dist/lib/chatgpt/adapter.js +107 -0
- package/dist/lib/chatgpt/cli.d.ts +23 -0
- package/dist/lib/chatgpt/cli.js +77 -0
- package/dist/lib/chatgpt/oauth.d.ts +103 -0
- package/dist/lib/chatgpt/oauth.js +448 -0
- package/dist/lib/chatgpt/state.d.ts +98 -0
- package/dist/lib/chatgpt/state.js +145 -0
- package/dist/lib/chatgpt/store.d.ts +24 -0
- package/dist/lib/chatgpt/store.js +94 -0
- package/dist/lib/compile.d.ts +38 -7
- package/dist/lib/compile.js +97 -62
- package/dist/lib/compileNative.d.ts +11 -3
- package/dist/lib/compileNative.js +18 -1
- package/dist/lib/dispatch.js +6 -2
- package/dist/lib/doctor.d.ts +107 -0
- package/dist/lib/doctor.js +217 -7
- package/dist/lib/loadSyndicate.d.ts +15 -0
- package/dist/lib/mcp/server.d.ts +195 -0
- package/dist/lib/mcp/server.js +564 -0
- package/dist/lib/memory/supabaseMemoryService.js +2 -2
- package/dist/lib/models/adapterResolver.js +6 -1
- 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 +3 -2
- package/dist/lib/models/gateway.js +8 -4
- 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 +135 -0
- package/dist/lib/onboarding.js +578 -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 +324 -77
- 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 +184 -9
- package/dist/lib/syndicateSchema.js +143 -29
- 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 +98 -0
- package/dist/lib/tools/credentialUses.js +120 -0
- package/dist/lib/tools/generateImageTool.js +2 -1
- package/dist/lib/tools/inspectImageTool.js +1 -1
- package/dist/lib/tools/mcpToolFactory.d.ts +29 -1
- package/dist/lib/tools/mcpToolFactory.js +72 -26
- package/dist/lib/tools/mcpTransports.d.ts +12 -0
- package/dist/lib/tools/mcpTransports.js +11 -0
- package/dist/lib/tools/oauthConsent.d.ts +18 -3
- package/dist/lib/tools/oauthConsent.js +57 -15
- package/dist/lib/tools/oauthDiscovery.d.ts +202 -0
- package/dist/lib/tools/oauthDiscovery.js +616 -0
- package/dist/lib/tools/oauthHosts.d.ts +2 -0
- package/dist/lib/tools/oauthHosts.js +2 -2
- package/dist/lib/tools/oauthTools.d.ts +54 -13
- package/dist/lib/tools/oauthTools.js +106 -23
- 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/tools/xApiSearchTool.js +2 -2
- 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 +19 -2
- package/dist/scripts/assistant_worker.d.ts +2 -0
- package/dist/scripts/assistant_worker.js +24 -5
- package/dist/scripts/doctor.d.ts +4 -2
- package/dist/scripts/doctor.js +6 -4
- package/dist/scripts/mcp_server.d.ts +12 -0
- package/dist/scripts/mcp_server.js +345 -0
- package/dist/scripts/setup.d.ts +25 -0
- package/dist/scripts/setup.js +181 -0
- package/dist/scripts/skills_install.d.ts +3 -1
- package/dist/scripts/skills_install.js +17 -2
- package/dist/scripts/syndicate_chat.js +12 -3
- package/dist/scripts/yaml_codemod.d.ts +12 -2
- package/dist/scripts/yaml_codemod.js +115 -33
- package/package.json +8 -2
- 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 +71 -0
- package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
- package/skills/melchizedek-onboard-keys/SKILL.md +47 -0
- package/skills/melchizedek-onboard-local/SKILL.md +41 -0
- package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
|
@@ -26,6 +26,7 @@ import { DEFAULT_ROUTE_KEY, NODE_KINDS, ROUTE_STEP_SUFFIX, START_NAME, elementNa
|
|
|
26
26
|
import { isSecretShapedEnvName } from './tools/skills/env.js';
|
|
27
27
|
import { OAUTH_SCOPE, PROVIDER_NAME } from './tools/auth.js';
|
|
28
28
|
import { V2_AGENT_KEYS, conflictMessage, conflictsOf, toEngineAgent } from './agentDialect.js';
|
|
29
|
+
import { MCP_TRANSPORTS } from './tools/mcpTransports.js';
|
|
29
30
|
// ── Leaf rules ───────────────────────────────────────────────────────────────
|
|
30
31
|
/**
|
|
31
32
|
* ADK's agent-name rule (validateAgentName in ADK's base_agent), kept: checked here
|
|
@@ -45,7 +46,7 @@ const thinkingConfig = z
|
|
|
45
46
|
includeThoughts: z.boolean().optional().describe('Stream the thinking trace in the response.'),
|
|
46
47
|
})
|
|
47
48
|
.describe('Older spelling of `reasoning` for Gemini and Claude (thinkingLevel or thinkingBudget). Cannot be combined with `reasoning`.');
|
|
48
|
-
export const REASONING_LEVELS = ['none', 'low', 'medium', 'high'];
|
|
49
|
+
export const REASONING_LEVELS = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
49
50
|
/**
|
|
50
51
|
* The provider-neutral reasoning key (ADR 0047): a level, or a token budget.
|
|
51
52
|
* The compiler maps it per provider (lib/compile.ts reasoningConfig).
|
|
@@ -62,7 +63,7 @@ const reasoningSchema = z
|
|
|
62
63
|
return `must be one of ${REASONING_LEVELS.join(' | ')}, or { budget_tokens: <integer ≥ 0> } (got ${describeValue(iss.input)}${hint ? ` — did you mean "${hint}"?` : ''})`;
|
|
63
64
|
},
|
|
64
65
|
})
|
|
65
|
-
.describe('How hard this agent reasons, on any provider: none | low | medium | high, or { budget_tokens: <int> }. Replaces generateContentConfig.thinkingConfig and reasoningEffort (ADR 0047).');
|
|
66
|
+
.describe('How hard this agent reasons, on any provider: none | low | medium | high | xhigh | max, or { budget_tokens: <int> }. xhigh and max reach the models that take them; elsewhere they go as the model\'s highest setting (ADR 0117). Replaces generateContentConfig.thinkingConfig and reasoningEffort (ADR 0047).');
|
|
66
67
|
/**
|
|
67
68
|
* Loose on purpose: provider-specific fields (e.g. `toolConfig`) pass through
|
|
68
69
|
* to the SDK. Only the documented fields are typed, so `temperature: hot`
|
|
@@ -89,7 +90,7 @@ const generateContentConfig = z
|
|
|
89
90
|
.optional()
|
|
90
91
|
.describe('Older spelling of `reasoning` for the chat-completions and Responses providers. Cannot be combined with `reasoning`.'),
|
|
91
92
|
})
|
|
92
|
-
.describe('Deprecated v1 spelling (ADR 0115): write `sampling` (temperature, top_p, max_output_tokens, stop), `output.mime` (responseMimeType)
|
|
93
|
+
.describe('Deprecated v1 spelling (ADR 0115): write `sampling` (temperature, top_p, max_output_tokens, stop), `output.mime` (responseMimeType), `reasoning` (thinkingConfig, reasoningEffort) and `tool_choice` (toolConfig.functionCallingConfig) instead; npx melchizedek-codemod rewrites a file. Still read: the Gemini GenerateContentConfig shape the engine takes. Do not set tools here.')
|
|
93
94
|
.meta({ deprecated: true });
|
|
94
95
|
// ── YAML v2 agent keys (ADR 0115) ────────────────────────────────────────────
|
|
95
96
|
/** `sampling` and `output` must say something: an empty block is a leftover. */
|
|
@@ -116,6 +117,20 @@ const outputSchemaV2 = z
|
|
|
116
117
|
})
|
|
117
118
|
.refine(nonEmpty, { message: 'must set schema or mime', when: quiet })
|
|
118
119
|
.describe('What this agent\'s final reply is (ADR 0115). Replaces outputSchema and generateContentConfig.responseMimeType; cannot be combined with them.');
|
|
120
|
+
export const TOOL_CHOICE_MODES = ['auto', 'none', 'required'];
|
|
121
|
+
const toolChoiceSchema = z
|
|
122
|
+
.union([
|
|
123
|
+
z.enum(TOOL_CHOICE_MODES),
|
|
124
|
+
z.strictObject({
|
|
125
|
+
name: z.string().min(1).describe('The one tool the model must call: a name from this agent\'s tools, subagents or MCP tools.'),
|
|
126
|
+
}),
|
|
127
|
+
], {
|
|
128
|
+
error: (iss) => {
|
|
129
|
+
const hint = typeof iss.input === 'string' ? suggest(iss.input, TOOL_CHOICE_MODES) : undefined;
|
|
130
|
+
return `must be one of ${TOOL_CHOICE_MODES.join(' | ')}, or { name: <tool> } (got ${describeValue(iss.input)}${hint ? ` — did you mean "${hint}"?` : ''})`;
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
.describe('Which tools the model may call, on any provider (ADR 0117): auto (the default), none, required (some tool), or { name: <tool> } (that tool). A provider that rejects forcing sends a weaker choice and marks the span llm.tool_choice.weakened. Replaces generateContentConfig.toolConfig.functionCallingConfig; cannot be combined with it.');
|
|
119
134
|
const modelOverrideEntry = z
|
|
120
135
|
.strictObject({
|
|
121
136
|
instruction: z.string().min(1).optional().describe('Replaces the agent\'s instruction on this provider.'),
|
|
@@ -203,8 +218,12 @@ const oauth2Auth = z
|
|
|
203
218
|
grant: z
|
|
204
219
|
.enum(['authorization_code', 'client_credentials'])
|
|
205
220
|
.describe('authorization_code: each user\'s own token, granted through the consent pause (ADR 0085). client_credentials: the server\'s own token, from the token endpoint.'),
|
|
206
|
-
|
|
207
|
-
|
|
221
|
+
client_registration: z
|
|
222
|
+
.literal('dynamic')
|
|
223
|
+
.optional()
|
|
224
|
+
.describe('"dynamic": an MCP server\'s authorization_code grant with no pre-issued client. The authorization server is discovered from the MCP server (RFC 9728, RFC 8414) and this deployment registers itself as a public PKCE client (RFC 7591), once per server and redirect URI, sealed in the credential store (ADR 0124). Every discovered host must be on the operator\'s OAuth host allowlist. MCP servers only; no client id, client secret or endpoint URL beside it.'),
|
|
225
|
+
authorization_url: z.string().url().optional().describe('The provider\'s authorization endpoint (https). authorization_code only; discovered with client_registration: dynamic.'),
|
|
226
|
+
token_url: z.string().url().optional().describe('The provider\'s token endpoint (https). Required, except with client_registration: dynamic, which discovers it.'),
|
|
208
227
|
client_id: z.string().min(1).max(512).optional().describe('The OAuth client id, written out (it is not a secret). Or client_id_env.'),
|
|
209
228
|
client_id_env: envName.optional().describe('Environment variable holding the client id. Or client_id.'),
|
|
210
229
|
client_secret_env: envName
|
|
@@ -214,13 +233,25 @@ const oauth2Auth = z
|
|
|
214
233
|
authorization_params: z
|
|
215
234
|
.record(z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/), z.string().max(512))
|
|
216
235
|
.optional()
|
|
217
|
-
.describe('Extra authorization parameters (e.g. access_type: offline). authorization_code only; never state, redirect_uri, scope, client_id or the PKCE pair.'),
|
|
236
|
+
.describe('Extra authorization parameters (e.g. access_type: offline). authorization_code only; never state, redirect_uri, scope, client_id, resource or the PKCE pair.'),
|
|
218
237
|
})
|
|
219
238
|
.superRefine((o, ctx) => {
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
239
|
+
if (o.client_registration === 'dynamic') {
|
|
240
|
+
if (o.grant !== 'authorization_code')
|
|
241
|
+
ctx.addIssue({ code: 'custom', path: ['client_registration'], message: 'client_registration: dynamic is for an authorization_code grant' });
|
|
242
|
+
for (const key of ['client_id', 'client_id_env', 'client_secret_env', 'authorization_url', 'token_url']) {
|
|
243
|
+
if (o[key] !== undefined)
|
|
244
|
+
ctx.addIssue({ code: 'custom', path: [key], message: `${key} cannot be combined with client_registration: dynamic, which discovers the endpoints and registers the client (ADR 0124)` });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
if (!!o.client_id === !!o.client_id_env)
|
|
249
|
+
ctx.addIssue({ code: 'custom', path: ['client_id'], message: 'exactly one of client_id or client_id_env' });
|
|
250
|
+
if (o.token_url === undefined)
|
|
251
|
+
ctx.addIssue({ code: 'custom', path: ['token_url'], message: 'token_url is required (on an MCP server, client_registration: dynamic discovers it)' });
|
|
252
|
+
if (o.grant === 'authorization_code' && !o.authorization_url) {
|
|
253
|
+
ctx.addIssue({ code: 'custom', path: ['authorization_url'], message: 'an authorization_code grant needs authorization_url' });
|
|
254
|
+
}
|
|
224
255
|
}
|
|
225
256
|
if (o.grant === 'client_credentials') {
|
|
226
257
|
if (!o.client_secret_env)
|
|
@@ -230,13 +261,37 @@ const oauth2Auth = z
|
|
|
230
261
|
ctx.addIssue({ code: 'custom', path: [key], message: `${key} is for authorization_code only` });
|
|
231
262
|
}
|
|
232
263
|
}
|
|
233
|
-
const reserved = ['state', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'client_id', 'response_type', 'scope'];
|
|
264
|
+
const reserved = ['state', 'redirect_uri', 'code_challenge', 'code_challenge_method', 'client_id', 'response_type', 'scope', 'resource'];
|
|
234
265
|
for (const key of Object.keys(o.authorization_params ?? {})) {
|
|
235
266
|
if (reserved.includes(key))
|
|
236
267
|
ctx.addIssue({ code: 'custom', path: ['authorization_params', key], message: `authorization_params may not set "${key}"` });
|
|
237
268
|
}
|
|
238
269
|
})
|
|
239
270
|
.describe('An OAuth 2 access token sent as a bearer token on every call (lib/tools/oauthTools.ts). Secrets come from environment variable names, never values.');
|
|
271
|
+
// ── MCP (ADR 0124) ───────────────────────────────────────────────────────────
|
|
272
|
+
const mcpTransport = z
|
|
273
|
+
.enum(MCP_TRANSPORTS)
|
|
274
|
+
.describe('How the MCP server is reached: auto (the default) tries Streamable HTTP and falls back to the legacy SSE transport on the spec\'s signal; streamable_http or sse use that one only (ADR 0124).');
|
|
275
|
+
/** An `mcp_servers` entry's name: lowercase, bounded, one character class (no backtracking). */
|
|
276
|
+
export const MCP_SERVER_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
277
|
+
const mcpServerEntry = z
|
|
278
|
+
.strictObject({
|
|
279
|
+
name: z
|
|
280
|
+
.string()
|
|
281
|
+
.regex(MCP_SERVER_NAME, 'lowercase letters, digits, _ and -, starting with a letter (at most 64)')
|
|
282
|
+
.describe('A short name for the server, unique on this agent: logs and errors name it.'),
|
|
283
|
+
url: z.string().url().describe('The MCP server\'s URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard.'),
|
|
284
|
+
tools: z
|
|
285
|
+
.array(z.string().min(1))
|
|
286
|
+
.min(1)
|
|
287
|
+
.describe('The server\'s tools this agent may use, by name; any other tool it lists is not exposed. A name may appear on one server only, and never among the agent\'s own tools. require_approval may name these.'),
|
|
288
|
+
auth: z
|
|
289
|
+
.strictObject({ oauth2: oauth2Auth })
|
|
290
|
+
.optional()
|
|
291
|
+
.describe('The OAuth grant this server takes (ADR 0112, ADR 0124). Without it, MCP_BEARER_TOKENS applies.'),
|
|
292
|
+
transport: mcpTransport.optional(),
|
|
293
|
+
})
|
|
294
|
+
.describe('One MCP server among several (ADR 0124).');
|
|
240
295
|
// ── OpenAPI ──────────────────────────────────────────────────────────────────
|
|
241
296
|
const openapiEntry = z
|
|
242
297
|
.strictObject({
|
|
@@ -302,6 +357,7 @@ const agentFields = {
|
|
|
302
357
|
reasoning: reasoningSchema.optional(),
|
|
303
358
|
sampling: samplingSchema.optional(),
|
|
304
359
|
output: outputSchemaV2.optional(),
|
|
360
|
+
tool_choice: toolChoiceSchema.optional(),
|
|
305
361
|
model_overrides: modelOverridesSchema.optional(),
|
|
306
362
|
outputSchema: z
|
|
307
363
|
.record(z.string(), z.unknown())
|
|
@@ -311,7 +367,7 @@ const agentFields = {
|
|
|
311
367
|
mcp_server_url: z
|
|
312
368
|
.string()
|
|
313
369
|
.optional()
|
|
314
|
-
.describe('MCP server (SSE) whose tools are discovered at runtime and merged with `tools`.'),
|
|
370
|
+
.describe('MCP server (Streamable HTTP, or legacy SSE) whose tools are discovered at runtime and merged with `tools`. For more than one server, use mcp_servers.'),
|
|
315
371
|
mcp_tools: z
|
|
316
372
|
.array(z.string().min(1))
|
|
317
373
|
.min(1)
|
|
@@ -321,6 +377,13 @@ const agentFields = {
|
|
|
321
377
|
.strictObject({ oauth2: oauth2Auth })
|
|
322
378
|
.optional()
|
|
323
379
|
.describe('The OAuth grant the MCP server takes (ADR 0112). With authorization_code, each user\'s own token on their own connection, and mcp_tools is required. Needs mcp_server_url; replaces MCP_BEARER_TOKENS for this server.'),
|
|
380
|
+
mcp_transport: mcpTransport.optional().describe('How mcp_server_url is reached: auto (the default), streamable_http or sse (ADR 0124). Needs mcp_server_url.'),
|
|
381
|
+
mcp_servers: z
|
|
382
|
+
.array(mcpServerEntry)
|
|
383
|
+
.min(1)
|
|
384
|
+
.max(16)
|
|
385
|
+
.optional()
|
|
386
|
+
.describe('Several MCP servers, each with the tools this agent may use from it, its grant and its transport (ADR 0124). Cannot be combined with mcp_server_url.'),
|
|
324
387
|
skills: skillsSchema.optional(),
|
|
325
388
|
openapi: z.array(openapiEntry).min(1).optional(),
|
|
326
389
|
code_execution: z
|
|
@@ -374,7 +437,7 @@ export const subagentSchema = z
|
|
|
374
437
|
.string()
|
|
375
438
|
.min(1)
|
|
376
439
|
.optional()
|
|
377
|
-
.describe('A whole nested syndicate (filename under the agents dir) used as this subagent. An approval request or ask_user question raised inside it pauses the turn with the agent path (ADR 0110, ADR 0111): a delegate syndicate\'s gates anywhere, a dispatch syndicate\'s on its
|
|
440
|
+
.describe('A whole nested syndicate (filename under the agents dir) used as this subagent. An approval request or ask_user question raised inside it pauses the turn with the agent path (ADR 0110, ADR 0111): a delegate syndicate\'s gates anywhere, a dispatch syndicate\'s on its routes, which it classifies and runs as at the top, its route\'s answer being its own (ADR 0120), and a workflow\'s ask_user nodes and gates, wherever each runs: delegated to as a subagent, as a dispatch route or as a workflow node (ADR 0119).'),
|
|
378
441
|
a2a_agent_url: z
|
|
379
442
|
.string()
|
|
380
443
|
.min(1)
|
|
@@ -663,6 +726,61 @@ export const REASONING_OLDER_SPELLING = ['thinkingConfig', 'reasoningEffort'];
|
|
|
663
726
|
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
664
727
|
/** Same normalisation lib/dispatch.ts uses to match a route to a subagent. */
|
|
665
728
|
const routeNorm = (s) => s.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
729
|
+
/** Every tool name an agent's `mcp_servers` entries list. */
|
|
730
|
+
function mcpServerTools(agent) {
|
|
731
|
+
return (Array.isArray(agent.mcp_servers) ? agent.mcp_servers : []).flatMap((e) => (isObj(e) && Array.isArray(e.tools) ? e.tools.filter((t) => typeof t === 'string') : []));
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* An agent's `mcp_servers` against itself and its other keys (ADR 0124):
|
|
735
|
+
* not beside `mcp_server_url`; names unique; a tool name on one server only
|
|
736
|
+
* and never among the agent's own `tools` or OpenAPI `operations`, so no
|
|
737
|
+
* server can shadow another's tool or the agent's own; and dynamic client
|
|
738
|
+
* registration only here or under `mcp_auth`, never on an OpenAPI entry.
|
|
739
|
+
*/
|
|
740
|
+
function mcpServersProblems(agent, path) {
|
|
741
|
+
const out = [];
|
|
742
|
+
(Array.isArray(agent.openapi) ? agent.openapi : []).forEach((e, i) => {
|
|
743
|
+
if (isObj(e) && isObj(e.auth) && isObj(e.auth.oauth2) && e.auth.oauth2.client_registration !== undefined) {
|
|
744
|
+
out.push({ path: [...path, 'openapi', i, 'auth', 'oauth2', 'client_registration'], message: 'client_registration: dynamic discovers its endpoints from an MCP server; an OpenAPI entry names its own (ADR 0124)' });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
if (!Array.isArray(agent.mcp_servers))
|
|
748
|
+
return out;
|
|
749
|
+
if (agent.mcp_server_url !== undefined) {
|
|
750
|
+
out.push({ path: [...path, 'mcp_servers'], message: 'cannot be combined with mcp_server_url: list that server as one more mcp_servers entry (ADR 0124)' });
|
|
751
|
+
}
|
|
752
|
+
const own = new Set([
|
|
753
|
+
...(Array.isArray(agent.tools) ? agent.tools.filter((t) => typeof t === 'string') : []),
|
|
754
|
+
...(Array.isArray(agent.openapi) ? agent.openapi : []).flatMap((e) => (isObj(e) && Array.isArray(e.operations) ? e.operations.filter((t) => typeof t === 'string') : [])),
|
|
755
|
+
]);
|
|
756
|
+
const names = new Map();
|
|
757
|
+
const owner = new Map();
|
|
758
|
+
agent.mcp_servers.forEach((entry, i) => {
|
|
759
|
+
if (!isObj(entry))
|
|
760
|
+
return;
|
|
761
|
+
const name = typeof entry.name === 'string' ? entry.name : `#${i}`;
|
|
762
|
+
if (typeof entry.name === 'string') {
|
|
763
|
+
if (names.has(entry.name))
|
|
764
|
+
out.push({ path: [...path, 'mcp_servers', i, 'name'], message: `'${entry.name}' names mcp_servers entry ${names.get(entry.name)} too; each server's name is unique on the agent` });
|
|
765
|
+
else
|
|
766
|
+
names.set(entry.name, i);
|
|
767
|
+
}
|
|
768
|
+
(Array.isArray(entry.tools) ? entry.tools : []).forEach((tool, j) => {
|
|
769
|
+
if (typeof tool !== 'string')
|
|
770
|
+
return;
|
|
771
|
+
if (own.has(tool)) {
|
|
772
|
+
out.push({ path: [...path, 'mcp_servers', i, 'tools', j], message: `'${tool}' is also one of this agent's own tools; an MCP server's tool may not share a name with it (ADR 0124)` });
|
|
773
|
+
}
|
|
774
|
+
else if (owner.has(tool) && owner.get(tool) !== name) {
|
|
775
|
+
out.push({ path: [...path, 'mcp_servers', i, 'tools', j], message: `'${tool}' is also listed on MCP server '${owner.get(tool)}'; a tool name may come from one server only (ADR 0124)` });
|
|
776
|
+
}
|
|
777
|
+
else {
|
|
778
|
+
owner.set(tool, name);
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
});
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
666
784
|
/**
|
|
667
785
|
* Rules that span fields, which a JSON Schema an editor reads cannot express
|
|
668
786
|
* cleanly. Defensive about shape: runs on raw input alongside zod, so a file
|
|
@@ -698,25 +816,19 @@ function crossFieldProblems(raw) {
|
|
|
698
816
|
// An OpenAPI operation can be gated once it is named under `operations`,
|
|
699
817
|
// and an MCP tool once it is named under `mcp_tools`.
|
|
700
818
|
const operations = (Array.isArray(agent.openapi) ? agent.openapi : []).flatMap((e) => (isObj(e) && Array.isArray(e.operations) ? e.operations : []));
|
|
701
|
-
const mcpTools = Array.isArray(agent.mcp_tools) ? agent.mcp_tools : [];
|
|
819
|
+
const mcpTools = [...(Array.isArray(agent.mcp_tools) ? agent.mcp_tools : []), ...mcpServerTools(agent)];
|
|
702
820
|
(Array.isArray(agent.require_approval) ? agent.require_approval : []).forEach((name, j) => {
|
|
703
821
|
if (typeof name === 'string' && !tools.includes(name) && !operations.includes(name) && !mcpTools.includes(name)) {
|
|
704
|
-
out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools, its openapi operations or its mcp_tools` });
|
|
822
|
+
out.push({ path: [...path, 'require_approval', j], message: `'${name}' is not in this agent's tools, its openapi operations or its mcp_tools (or an mcp_servers entry's tools)` });
|
|
705
823
|
}
|
|
706
824
|
});
|
|
707
825
|
};
|
|
708
826
|
// A skill script run pauses for approval the same way, so it is allowed in
|
|
709
|
-
// the same places; `skills.tools`
|
|
710
|
-
// already carry outright.
|
|
711
|
-
const skillProblems = (agent, path
|
|
827
|
+
// the same places, a delegated subagent included (ADR 0118); `skills.tools`
|
|
828
|
+
// may only unlock tools the agent does not already carry outright.
|
|
829
|
+
const skillProblems = (agent, path) => {
|
|
712
830
|
if (!isObj(agent.skills))
|
|
713
831
|
return;
|
|
714
|
-
if (agent.skills.scripts === 'local' && !allowed) {
|
|
715
|
-
out.push({
|
|
716
|
-
path: [...path, 'skills', 'scripts'],
|
|
717
|
-
message: 'skill scripts on a delegated subagent are not supported yet; run them on the orchestrator or a plan-dispatch route (ADR 0110)',
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
832
|
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
|
721
833
|
(Array.isArray(agent.skills.tools) ? agent.skills.tools : []).forEach((name, j) => {
|
|
722
834
|
if (typeof name === 'string' && tools.includes(name)) {
|
|
@@ -763,6 +875,10 @@ function crossFieldProblems(raw) {
|
|
|
763
875
|
if (isObj(agent.mcp_auth) && isObj(agent.mcp_auth.oauth2) && agent.mcp_auth.oauth2.grant === 'authorization_code' && agent.mcp_tools === undefined) {
|
|
764
876
|
out.push({ path: [...path, 'mcp_auth'], message: 'an authorization_code mcp_auth needs mcp_tools: no user\'s grant exists at startup to list the server\'s tools' });
|
|
765
877
|
}
|
|
878
|
+
if (agent.mcp_transport !== undefined && typeof agent.mcp_server_url !== 'string') {
|
|
879
|
+
out.push({ path: [...path, 'mcp_transport'], message: 'mcp_transport is how mcp_server_url is reached; it needs mcp_server_url (an mcp_servers entry names its own transport)' });
|
|
880
|
+
}
|
|
881
|
+
out.push(...mcpServersProblems(agent, path));
|
|
766
882
|
};
|
|
767
883
|
if (isObj(raw.orchestrator))
|
|
768
884
|
executionProblems(raw.orchestrator, ['orchestrator'], true);
|
|
@@ -823,19 +939,17 @@ function crossFieldProblems(raw) {
|
|
|
823
939
|
v2Problems(sub, ['subagents', i]);
|
|
824
940
|
if (isObj(raw.orchestrator)) {
|
|
825
941
|
gateProblems(raw.orchestrator, ['orchestrator'], true);
|
|
826
|
-
skillProblems(raw.orchestrator, ['orchestrator']
|
|
942
|
+
skillProblems(raw.orchestrator, ['orchestrator']);
|
|
827
943
|
questionProblems(raw.orchestrator, ['orchestrator'], true);
|
|
828
944
|
}
|
|
829
|
-
const dispatching = isObj(raw.dispatch);
|
|
830
945
|
// A workflow's subagents are its nodes, not delegated tools: a gated call pauses its node, and
|
|
831
946
|
// the walk resumes it (ADR 0098); a skill script run pauses on the same approval (ADR 0106). A map item cannot (workflowProblems).
|
|
832
|
-
const pausing = dispatching || isObj(raw.workflow);
|
|
833
947
|
subs.forEach((sub, i) => {
|
|
834
948
|
if (!isObj(sub))
|
|
835
949
|
return;
|
|
836
|
-
// A delegated subagent's gate and
|
|
950
|
+
// A delegated subagent's gate, question and skill script run pause the turn through the open call (ADR 0110, ADR 0118).
|
|
837
951
|
gateProblems(sub, ['subagents', i], true);
|
|
838
|
-
skillProblems(sub, ['subagents', i]
|
|
952
|
+
skillProblems(sub, ['subagents', i]);
|
|
839
953
|
questionProblems(sub, ['subagents', i], true);
|
|
840
954
|
const hasRef = typeof sub.yaml_reference === 'string';
|
|
841
955
|
const hasRemote = typeof sub.a2a_agent_url === 'string';
|
package/dist/lib/tools/auth.d.ts
CHANGED
|
@@ -93,14 +93,26 @@ export interface CredentialStore {
|
|
|
93
93
|
/** What the ToolContext member gives a tool: a valid access token for a provider, for this run's user only. */
|
|
94
94
|
export type ToolAccessToken = (provider: string) => Promise<string>;
|
|
95
95
|
export type ToolCredentialErrorCode = 'not_connected' | 'expired' | 'refresh_failed' | 'unreadable' | 'no_user' | 'invalid' | 'unavailable' | 'grant_failed' | 'host_refused';
|
|
96
|
+
/**
|
|
97
|
+
* Which operator allowlist refused a `host_refused` call: the OAuth host
|
|
98
|
+
* allowlist, which binds a provider's tokens to its hosts
|
|
99
|
+
* (`MELCHIZEDEK_OAUTH_HOSTS`, ADR 0114), or the credential host allowlist,
|
|
100
|
+
* which binds a static credential variable such as a client secret to its
|
|
101
|
+
* hosts (`MELCHIZEDEK_CREDENTIAL_HOSTS`, ADR 0122).
|
|
102
|
+
*/
|
|
103
|
+
export type HostAllowlist = 'oauth' | 'credential';
|
|
96
104
|
/**
|
|
97
105
|
* A credential that cannot be used. The message names the provider and what
|
|
98
106
|
* to do, never a token, a user or a key, so a tool may return it to the model.
|
|
107
|
+
* A `host_refused` error names the allowlist that refused (`allowlist`,
|
|
108
|
+
* `'oauth'` when not given).
|
|
99
109
|
*/
|
|
100
110
|
export declare class ToolCredentialError extends Error {
|
|
101
111
|
readonly code: ToolCredentialErrorCode;
|
|
102
112
|
readonly provider?: string;
|
|
103
|
-
|
|
113
|
+
/** For `host_refused`: the allowlist that refused the host. */
|
|
114
|
+
readonly allowlist?: HostAllowlist;
|
|
115
|
+
constructor(code: ToolCredentialErrorCode, provider?: string, allowlist?: HostAllowlist);
|
|
104
116
|
}
|
|
105
117
|
/** A provider name: lowercase letters, digits, and . _ - inside; at most 64 characters. */
|
|
106
118
|
export declare const PROVIDER_NAME: RegExp;
|
package/dist/lib/tools/auth.js
CHANGED
|
@@ -32,11 +32,15 @@
|
|
|
32
32
|
/**
|
|
33
33
|
* A credential that cannot be used. The message names the provider and what
|
|
34
34
|
* to do, never a token, a user or a key, so a tool may return it to the model.
|
|
35
|
+
* A `host_refused` error names the allowlist that refused (`allowlist`,
|
|
36
|
+
* `'oauth'` when not given).
|
|
35
37
|
*/
|
|
36
38
|
export class ToolCredentialError extends Error {
|
|
37
39
|
code;
|
|
38
40
|
provider;
|
|
39
|
-
|
|
41
|
+
/** For `host_refused`: the allowlist that refused the host. */
|
|
42
|
+
allowlist;
|
|
43
|
+
constructor(code, provider, allowlist) {
|
|
40
44
|
const p = provider ? `"${provider}"` : 'this provider';
|
|
41
45
|
super(code === 'not_connected'
|
|
42
46
|
? `No ${p} account is connected for this user; ask them to connect it.`
|
|
@@ -53,11 +57,15 @@ export class ToolCredentialError extends Error {
|
|
|
53
57
|
: code === 'grant_failed'
|
|
54
58
|
? `The server's own ${p} authorization could not be obtained from the provider's token endpoint.`
|
|
55
59
|
: code === 'host_refused'
|
|
56
|
-
?
|
|
60
|
+
? allowlist === 'credential'
|
|
61
|
+
? `The ${p} client secret may not be sent to this host: the operator's credential host allowlist (MELCHIZEDEK_CREDENTIAL_HOSTS) does not bind it there.`
|
|
62
|
+
: `The ${p} authorization may not be sent to this host: the operator's OAuth host allowlist (MELCHIZEDEK_OAUTH_HOSTS) does not include it.`
|
|
57
63
|
: `Invalid credential request for ${p}.`);
|
|
58
64
|
this.name = 'ToolCredentialError';
|
|
59
65
|
this.code = code;
|
|
60
66
|
this.provider = provider;
|
|
67
|
+
if (code === 'host_refused')
|
|
68
|
+
this.allowlist = allowlist ?? 'oauth';
|
|
61
69
|
}
|
|
62
70
|
}
|
|
63
71
|
/** A provider name: lowercase letters, digits, and . _ - inside; at most 64 characters. */
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
/** The environment variable the allowlist is read from when no option sets it. */
|
|
45
|
+
export declare const CREDENTIAL_HOSTS_ENV = "MELCHIZEDEK_CREDENTIAL_HOSTS";
|
|
46
|
+
/** Credential variable name → the hosts its value may be sent to. */
|
|
47
|
+
export type CredentialHostAllowlist = Readonly<Record<string, readonly string[]>>;
|
|
48
|
+
/**
|
|
49
|
+
* The allowlist, checked and normalized (hosts lowercased, duplicates
|
|
50
|
+
* dropped). Throws, naming the variable and the host, on anything malformed.
|
|
51
|
+
*/
|
|
52
|
+
export declare function checkCredentialHosts(allowlist: Record<string, readonly string[]>, source?: string): Record<string, string[]>;
|
|
53
|
+
/** The allowlist from its text form (MELCHIZEDEK_CREDENTIAL_HOSTS). Throws on a malformed entry. */
|
|
54
|
+
export declare function parseCredentialHosts(text: string): Record<string, string[]>;
|
|
55
|
+
/**
|
|
56
|
+
* Sets this process's allowlist (createA2AApp's `credentialHosts` does),
|
|
57
|
+
* which wins over the environment variable. `undefined` returns to the
|
|
58
|
+
* variable.
|
|
59
|
+
*/
|
|
60
|
+
export declare function setCredentialHosts(allowlist: Record<string, readonly string[]> | undefined): void;
|
|
61
|
+
/**
|
|
62
|
+
* The allowlist in force: the one set by `setCredentialHosts`, else the
|
|
63
|
+
* environment variable's, else undefined (not configured). A malformed
|
|
64
|
+
* variable throws, so the first check refuses rather than allowing.
|
|
65
|
+
*/
|
|
66
|
+
export declare function credentialHosts(env?: NodeJS.ProcessEnv): CredentialHostAllowlist | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Why the value of credential variable `name` may not be sent to `url`, or
|
|
69
|
+
* null. `role` names the URL in the message (`server`, `token_url`).
|
|
70
|
+
* `allowlist` defaults to the one in force; `null` means none is
|
|
71
|
+
* configured, which allows (the behaviour before ADR 0122).
|
|
72
|
+
*/
|
|
73
|
+
export declare function credentialHostProblem(name: string, url: string, role?: string, allowlist?: CredentialHostAllowlist | null | undefined): string | null;
|
|
74
|
+
/**
|
|
75
|
+
* The call-time check: why the value of `name` may not be sent to
|
|
76
|
+
* `destination` now, or null. A malformed allowlist refuses.
|
|
77
|
+
*/
|
|
78
|
+
export declare function credentialCallProblem(name: string, destination: string): string | null;
|
|
79
|
+
/**
|
|
80
|
+
* Marks an OAuth client config (lib/tools/oauthConsent.ts) with the
|
|
81
|
+
* variable its client secret came from, so the consent step's code exchange
|
|
82
|
+
* and the refresh hook can check the token endpoint at call time. Set by
|
|
83
|
+
* oauthClientsFor (lib/tools/oauthTools.ts); a client built by hand carries
|
|
84
|
+
* none and is not checked.
|
|
85
|
+
*/
|
|
86
|
+
export declare const CLIENT_SECRET_ENV: unique symbol;
|
|
87
|
+
/** The variable a client config's secret came from, when oauthClientsFor marked it. */
|
|
88
|
+
export declare function clientSecretEnvOf(client: object): string | undefined;
|
|
@@ -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
|
+
}
|