agent-relay-server 0.89.1 → 0.90.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/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/src/config-store.ts +14 -0
- package/src/db/provider-quotas.ts +32 -1
- package/src/mcp/tools-spawn.ts +3 -0
- package/src/project-mcp.ts +52 -0
- package/src/quota-advisory.ts +18 -2
- package/src/routes/agents-spawn.ts +2 -0
- package/src/routes/provider-quotas.ts +30 -3
- package/src/services/spawn-agent.ts +18 -0
package/docs/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Relay API",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.90.0",
|
|
6
6
|
"description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
|
|
7
7
|
"license": {
|
|
8
8
|
"name": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0",
|
|
4
4
|
"description": "Lightweight HTTP message relay for inter-agent communication across machines",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"CONTRIBUTING.md"
|
|
34
34
|
],
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"agent-relay-sdk": "0.2.
|
|
36
|
+
"agent-relay-sdk": "0.2.69",
|
|
37
37
|
"ajv": "^8.20.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
package/src/config-store.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./
|
|
|
5
5
|
import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
|
|
6
6
|
import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
|
|
7
7
|
import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
|
|
8
|
+
import { PROJECT_MCP_POLICY_NAMES } from "./project-mcp";
|
|
8
9
|
import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
9
10
|
import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
|
|
10
11
|
import type {
|
|
@@ -225,6 +226,17 @@ function cleanAgentProfileProjectInstructions(value: unknown): AgentProfile["pro
|
|
|
225
226
|
return { policy: cleanEnum(policy, "projectInstructions.policy", PROJECT_INSTRUCTION_POLICY_NAMES) as NonNullable<AgentProfile["projectInstructions"]>["policy"] };
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
// #667 — project `.mcp.json` projection profile default. Mirrors projectInstructions:
|
|
230
|
+
// boolean | { policy } → normalized { policy? } | undefined (OFF).
|
|
231
|
+
function cleanAgentProfileProjectMcp(value: unknown): AgentProfile["projectMcp"] {
|
|
232
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
233
|
+
if (value === true) return {};
|
|
234
|
+
if (!isRecord(value)) throw new ValidationError("projectMcp must be a boolean or { policy } object");
|
|
235
|
+
const policy = value.policy;
|
|
236
|
+
if (policy === undefined || policy === null) return {};
|
|
237
|
+
return { policy: cleanEnum(policy, "projectMcp.policy", PROJECT_MCP_POLICY_NAMES) as NonNullable<AgentProfile["projectMcp"]>["policy"] };
|
|
238
|
+
}
|
|
239
|
+
|
|
228
240
|
function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
|
|
229
241
|
const isolated = input.base !== "host";
|
|
230
242
|
// "vanilla" (#552) additionally suppresses the REPO instruction cascade
|
|
@@ -245,6 +257,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
|
|
|
245
257
|
globalInstructions: input.instructions?.globalInstructions ?? (isolated ? "ignore" : "allow"),
|
|
246
258
|
},
|
|
247
259
|
...(input.projectInstructions ? { projectInstructions: input.projectInstructions } : {}),
|
|
260
|
+
...(input.projectMcp ? { projectMcp: input.projectMcp } : {}),
|
|
248
261
|
relay: {
|
|
249
262
|
context: input.relay?.context ?? !isolated,
|
|
250
263
|
skills: input.relay?.skills ?? !isolated,
|
|
@@ -319,6 +332,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
319
332
|
: cleanEnum(instructions.globalInstructions, "instructions.globalInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
|
|
320
333
|
},
|
|
321
334
|
projectInstructions: cleanAgentProfileProjectInstructions(value.projectInstructions),
|
|
335
|
+
projectMcp: cleanAgentProfileProjectMcp(value.projectMcp),
|
|
322
336
|
relay: {
|
|
323
337
|
context: relay.context === undefined ? defaults.relay.context : cleanBoolean(relay.context, "relay.context"),
|
|
324
338
|
skills: relay.skills === undefined ? defaults.relay.skills : cleanBoolean(relay.skills, "relay.skills"),
|
|
@@ -5,6 +5,9 @@ import { parseStringArray } from "./shared.ts";
|
|
|
5
5
|
import type { AgentCard, ProviderQuotaError, ProviderQuotaMatrix, ProviderQuotaRecord, ProviderQuotaSnapshot, ProviderQuotaUpdateInput, QuotaState } from "../types";
|
|
6
6
|
|
|
7
7
|
const PROVIDER_QUOTA_STALE_AFTER_MS = 10 * 60_000;
|
|
8
|
+
const QUOTA_UTILIZATION_EPSILON = 1e-9;
|
|
9
|
+
const QUOTA_RESET_BOUNDARY_ADVANCE_MS = 5_000;
|
|
10
|
+
const QUOTA_RESET_UTILIZATION_MAX = 0.01;
|
|
8
11
|
|
|
9
12
|
export interface ProviderQuotaAdvisoryInput {
|
|
10
13
|
provider: string;
|
|
@@ -193,6 +196,30 @@ function quotaWindowKey(window: QuotaState["windows"][number]): string {
|
|
|
193
196
|
return `${window.name}:${window.unit}`;
|
|
194
197
|
}
|
|
195
198
|
|
|
199
|
+
function quotaResetBoundaryAdvanced(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
|
|
200
|
+
return quotaResetBoundaryMoved(existing, incoming)
|
|
201
|
+
&& incoming.resetsAt! - existing.resetsAt! > QUOTA_RESET_BOUNDARY_ADVANCE_MS;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function quotaResetBoundaryMoved(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
|
|
205
|
+
return typeof existing.resetsAt === "number"
|
|
206
|
+
&& Number.isFinite(existing.resetsAt)
|
|
207
|
+
&& typeof incoming.resetsAt === "number"
|
|
208
|
+
&& Number.isFinite(incoming.resetsAt)
|
|
209
|
+
&& incoming.resetsAt > existing.resetsAt;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function quotaWindowLooksReset(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
|
|
213
|
+
return incoming.utilization <= QUOTA_RESET_UTILIZATION_MAX
|
|
214
|
+
&& quotaResetBoundaryMoved(existing, incoming);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function quotaWindowUtilizationRegressed(existing: QuotaState["windows"][number], incoming: QuotaState["windows"][number]): boolean {
|
|
218
|
+
return incoming.utilization + QUOTA_UTILIZATION_EPSILON < existing.utilization
|
|
219
|
+
&& !quotaResetBoundaryAdvanced(existing, incoming)
|
|
220
|
+
&& !quotaWindowLooksReset(existing, incoming);
|
|
221
|
+
}
|
|
222
|
+
|
|
196
223
|
function publicQuotaState(quota: StoredQuotaState): QuotaState {
|
|
197
224
|
return {
|
|
198
225
|
windows: quota.windows.map((window) => ({ ...window })),
|
|
@@ -283,6 +310,10 @@ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: Quo
|
|
|
283
310
|
mergedTimes.set(key, existingUpdatedAt);
|
|
284
311
|
return window;
|
|
285
312
|
}
|
|
313
|
+
if (quotaWindowUtilizationRegressed(window, incomingWindow)) {
|
|
314
|
+
mergedTimes.set(key, existingUpdatedAt);
|
|
315
|
+
return window;
|
|
316
|
+
}
|
|
286
317
|
acceptedIncomingWindow = true;
|
|
287
318
|
updatedAt = Math.max(updatedAt, incoming.updatedAt);
|
|
288
319
|
source = incoming.source;
|
|
@@ -427,7 +458,7 @@ export function upsertProviderQuota(input: ProviderQuotaUpdateInput, now = Date.
|
|
|
427
458
|
provider,
|
|
428
459
|
accountKey,
|
|
429
460
|
previousQuota: existingQuota ? publicQuotaState(existingQuota) : undefined,
|
|
430
|
-
quota:
|
|
461
|
+
quota: publicQuotaState(mergedQuota.quota),
|
|
431
462
|
sourceAgentIds,
|
|
432
463
|
previousAdvisoryState: existing?.quota_advisory_state,
|
|
433
464
|
now,
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../servic
|
|
|
4
4
|
import { buildSpawnTargets } from "../spawn-targets";
|
|
5
5
|
import { McpAuthError, McpNotFoundError } from "../mcp-errors";
|
|
6
6
|
import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
|
|
7
|
+
import { parseProjectMcp, PROJECT_MCP_POLICY_NAMES } from "../project-mcp";
|
|
7
8
|
import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
|
|
8
9
|
import { listCommands } from "../commands-db";
|
|
9
10
|
import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
|
|
@@ -34,6 +35,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
|
|
|
34
35
|
expectReply: { type: "boolean", description: "Legacy compatibility flag. Spawned workers now report up automatically: each turn-final response is routed to the spawner by lineage, without a reply obligation or idle nag." },
|
|
35
36
|
systemPromptAppend: { type: "string" },
|
|
36
37
|
projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
|
|
38
|
+
projectMcp: { type: "object", description: "#667 — project `.mcp.json` MCP projection (default OFF). When set, the worker gets the MCP servers committed in the spawn cwd's `.mcp.json` (e.g. an app's playtest/manuscript servers), injected by relay through the controlled `--mcp-config` path that survives `--strict-mcp-config` — sourced from the project, not ambient host MCP, and without triggering Claude's project-trust modal. `policy: \"all\"` (the default) injects every declared server. Omit to keep current host-only behavior.", properties: { policy: { type: "string", enum: PROJECT_MCP_POLICY_NAMES } }, additionalProperties: false },
|
|
37
39
|
profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
|
|
38
40
|
tags: { type: "array", items: { type: "string" } },
|
|
39
41
|
capabilities: { type: "array", items: { type: "string" } },
|
|
@@ -108,6 +110,7 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
|
|
|
108
110
|
expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
|
|
109
111
|
systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
|
|
110
112
|
projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
|
|
113
|
+
projectMcp: parseProjectMcp(args.projectMcp, (m) => new ValidationError(m)),
|
|
111
114
|
tags: optionalStringArray(args.tags, "tags"),
|
|
112
115
|
capabilities: optionalStringArray(args.capabilities, "capabilities"),
|
|
113
116
|
providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Project `.mcp.json` MCP projection (#667) — the relay-owned, OPT-IN path that hands a
|
|
2
|
+
// spawned worker its spawn-cwd's COMMITTED `.mcp.json` servers, sourced from the PROJECT
|
|
3
|
+
// (in-repo, trusted) rather than ambient host MCP. Sibling seam to project-instructions
|
|
4
|
+
// (#409): same opt-in + projection shape, default OFF.
|
|
5
|
+
//
|
|
6
|
+
// PROVENANCE, NOT TYPE: "vanilla/zero-host-bleed" means relay OWNS provisioning and sources
|
|
7
|
+
// it from the project, never the host. A repo's `.mcp.json` is part of the code the agent
|
|
8
|
+
// works in; it should travel with the repo like it does for a human who clones it.
|
|
9
|
+
//
|
|
10
|
+
// DIVISION OF LABOR: relay only carries the OPT-IN DECISION (this module + the spawn
|
|
11
|
+
// param/profile field). The actual file read + `--mcp-config` injection happens RUNNER-side
|
|
12
|
+
// (launch-assembly.ts → resolveProjectMcpServers), which is the filesystem authority for the
|
|
13
|
+
// spawn cwd (relay may run on a different host than the orchestrator). It composes with #662
|
|
14
|
+
// `--strict-mcp-config` — the controlled inject path strict PRESERVES — and deliberately does
|
|
15
|
+
// NOT enable Claude's ambient project-`.mcp.json` discovery, whose trust modal wedges headless
|
|
16
|
+
// agents (the very reason #662 added strict to all managed launches).
|
|
17
|
+
|
|
18
|
+
export const PROJECT_MCP_POLICY_NAMES = ["all"] as const;
|
|
19
|
+
export type ProjectMcpPolicyName = (typeof PROJECT_MCP_POLICY_NAMES)[number];
|
|
20
|
+
|
|
21
|
+
/** Per-spawn projection request. Presence (a non-undefined value) means "inject project
|
|
22
|
+
* MCP"; absence means OFF. `policy` selects the server set (`all` = every declared server;
|
|
23
|
+
* the sole value today). */
|
|
24
|
+
export interface ProjectMcpPolicy {
|
|
25
|
+
policy?: ProjectMcpPolicyName;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a spawn-transport's `projectMcp` wire value into the normalized policy (or
|
|
30
|
+
* undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift. Mirrors
|
|
31
|
+
* parseProjectInstructions exactly:
|
|
32
|
+
* - undefined / null / false → undefined (OFF)
|
|
33
|
+
* - true → {} (ON, default policy)
|
|
34
|
+
* - { policy } → validated policy
|
|
35
|
+
* Throws `onInvalid(message)` for a malformed shape or unknown policy name.
|
|
36
|
+
*/
|
|
37
|
+
export function parseProjectMcp(
|
|
38
|
+
value: unknown,
|
|
39
|
+
onInvalid: (message: string) => Error,
|
|
40
|
+
): ProjectMcpPolicy | undefined {
|
|
41
|
+
if (value === undefined || value === null || value === false) return undefined;
|
|
42
|
+
if (value === true) return {};
|
|
43
|
+
if (typeof value !== "object") {
|
|
44
|
+
throw onInvalid("projectMcp must be a boolean or { policy } object");
|
|
45
|
+
}
|
|
46
|
+
const policy = (value as Record<string, unknown>).policy;
|
|
47
|
+
if (policy === undefined || policy === null) return {};
|
|
48
|
+
if (typeof policy !== "string" || !PROJECT_MCP_POLICY_NAMES.includes(policy as ProjectMcpPolicyName)) {
|
|
49
|
+
throw onInvalid(`projectMcp.policy must be one of: ${PROJECT_MCP_POLICY_NAMES.join(", ")}`);
|
|
50
|
+
}
|
|
51
|
+
return { policy: policy as ProjectMcpPolicyName };
|
|
52
|
+
}
|
package/src/quota-advisory.ts
CHANGED
|
@@ -32,6 +32,7 @@ type AgentRecipientRow = {
|
|
|
32
32
|
|
|
33
33
|
const RESET_ROUNDING_PERCENT = 99;
|
|
34
34
|
const QUOTA_THRESHOLD_EPSILON = 1e-9;
|
|
35
|
+
const QUOTA_RESET_BOUNDARY_ADVANCE_MS = 5_000;
|
|
35
36
|
|
|
36
37
|
export function installQuotaAdvisoryHandler(): void {
|
|
37
38
|
setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
|
|
@@ -46,7 +47,7 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
46
47
|
const previousWindow = input.previousQuota?.windows.find((candidate) => candidate.name === window.name);
|
|
47
48
|
const remaining = quotaRemaining(window.utilization);
|
|
48
49
|
const remainingPercent = percent(remaining);
|
|
49
|
-
|
|
50
|
+
let previous = nextState[window.name];
|
|
50
51
|
const reset = hasLastQuotaAdvisory(previous) && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
|
|
51
52
|
if (reset) {
|
|
52
53
|
notifyQuotaAdvisory({
|
|
@@ -65,8 +66,12 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
|
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
if (previous && hasLastQuotaAdvisory(previous) && quotaAdvisoryResetBoundaryAdvanced(previous, window)) {
|
|
70
|
+
delete nextState[window.name];
|
|
71
|
+
previous = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
|
|
69
|
-
if (previous) delete nextState[window.name];
|
|
70
75
|
continue;
|
|
71
76
|
}
|
|
72
77
|
|
|
@@ -101,6 +106,17 @@ function hasLastQuotaAdvisory(previous: QuotaAdvisoryWindowState | undefined): b
|
|
|
101
106
|
return previous?.lastQuotaAdvisoryStage !== undefined || previous?.lastQuotaAdvisoryBand !== undefined;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
function quotaAdvisoryResetBoundaryAdvanced(
|
|
110
|
+
previous: QuotaAdvisoryWindowState,
|
|
111
|
+
window: QuotaState["windows"][number],
|
|
112
|
+
): boolean {
|
|
113
|
+
return typeof previous.lastResetsAt === "number"
|
|
114
|
+
&& Number.isFinite(previous.lastResetsAt)
|
|
115
|
+
&& typeof window.resetsAt === "number"
|
|
116
|
+
&& Number.isFinite(window.resetsAt)
|
|
117
|
+
&& window.resetsAt - previous.lastResetsAt > QUOTA_RESET_BOUNDARY_ADVANCE_MS;
|
|
118
|
+
}
|
|
119
|
+
|
|
104
120
|
function lastQuotaAdvisoryStage(
|
|
105
121
|
previous: QuotaAdvisoryWindowState | undefined,
|
|
106
122
|
criticalRemaining: number,
|
|
@@ -7,6 +7,7 @@ import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optional
|
|
|
7
7
|
import { registerAgent } from "../services/register-agent";
|
|
8
8
|
import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
|
|
9
9
|
import { parseProjectInstructions } from "../project-instructions";
|
|
10
|
+
import { parseProjectMcp } from "../project-mcp";
|
|
10
11
|
import { authContextFromRequest } from "../services/auth-context";
|
|
11
12
|
import { listHostDirectories } from "../agent-spawn";
|
|
12
13
|
import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
|
|
@@ -121,6 +122,7 @@ export const postAgentSpawn: Handler = async (req) => {
|
|
|
121
122
|
prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
|
|
122
123
|
systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
|
|
123
124
|
projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
|
|
125
|
+
projectMcp: parseProjectMcp(parsed.body.projectMcp, (m) => new ValidationError(m)),
|
|
124
126
|
tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
|
|
125
127
|
capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
|
|
126
128
|
providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { isRecord } from "agent-relay-sdk";
|
|
2
|
-
import { acquireProviderQuotaLease, getOrchestrator, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
|
|
2
|
+
import { acquireProviderQuotaLease, getOrchestrator, getProviderQuotaLease, listProviderQuotas, releaseProviderQuotaLease, upsertProviderQuota } from "../db";
|
|
3
3
|
import { emitRelayEvent } from "../events";
|
|
4
4
|
import { quotaStateFromUnknown, providerQuotaErrorFromUnknown } from "../quota";
|
|
5
|
+
import { authContextFromRequest } from "../services/auth-context";
|
|
5
6
|
import { cleanString } from "../validation";
|
|
6
7
|
import { authorizeRoute, error, json, parseBody, parseQueryInt, type Handler } from "./_shared";
|
|
7
8
|
import type { ProviderQuotaUpdateInput } from "../types";
|
|
@@ -45,6 +46,8 @@ export const postProviderQuotaRoute: Handler = async (req) => {
|
|
|
45
46
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
46
47
|
const input = cleanProviderQuotaUpdate(parsed.body);
|
|
47
48
|
if (typeof input === "string") return error(input);
|
|
49
|
+
const providerWriteGate = authorizeProviderQuotaWrite(req, input);
|
|
50
|
+
if (providerWriteGate) return providerWriteGate;
|
|
48
51
|
const record = upsertProviderQuota(input);
|
|
49
52
|
emitRelayEvent({ type: "provider-quota.updated", source: "server", subject: `${record.provider}:${record.accountKey}`, data: {} });
|
|
50
53
|
return json(record, 201);
|
|
@@ -82,12 +85,36 @@ function cleanPublisherLeaseBody(body: unknown): {
|
|
|
82
85
|
|
|
83
86
|
const runnerLeaseHolderId = (sourceAgentId: string) => `runner:${sourceAgentId}`;
|
|
84
87
|
|
|
88
|
+
function authorizeProviderQuotaSourceAgent(req: Request, sourceAgentId: string): Response | null {
|
|
89
|
+
const ctx = authContextFromRequest(req);
|
|
90
|
+
if (ctx.component?.role !== "provider") return null;
|
|
91
|
+
const denied = authorizeRoute(req, { scope: "quota:write" });
|
|
92
|
+
if (denied) return denied;
|
|
93
|
+
return ctx.callerAgentId === sourceAgentId ? null : error("forbidden", 403);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function authorizeProviderQuotaWrite(req: Request, input: ProviderQuotaUpdateInput): Response | null {
|
|
97
|
+
const ctx = authContextFromRequest(req);
|
|
98
|
+
if (ctx.component?.role !== "provider") return null;
|
|
99
|
+
if (!input.sourceAgentId) return error("sourceAgentId required for provider quota writer", 403);
|
|
100
|
+
const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
|
|
101
|
+
if (denied) return denied;
|
|
102
|
+
const lease = getProviderQuotaLease(input.provider, input.accountKey);
|
|
103
|
+
const activeLease = lease && lease.expiresAt > Date.now() ? lease : null;
|
|
104
|
+
if (activeLease?.orchestratorId === runnerLeaseHolderId(input.sourceAgentId)) return null;
|
|
105
|
+
return json({
|
|
106
|
+
skipped: true,
|
|
107
|
+
reason: "provider quota lease not held by source agent",
|
|
108
|
+
...(activeLease ? { holder: { orchestratorId: activeLease.orchestratorId, expiresAt: activeLease.expiresAt } } : {}),
|
|
109
|
+
}, 202);
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
export const postProviderQuotaPublisherLeaseAcquireRoute: Handler = async (req) => {
|
|
86
113
|
const parsed = await parseBody<unknown>(req);
|
|
87
114
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
88
115
|
const input = cleanPublisherLeaseBody(parsed.body);
|
|
89
116
|
if (typeof input === "string") return error(input);
|
|
90
|
-
const denied =
|
|
117
|
+
const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
|
|
91
118
|
if (denied) return denied;
|
|
92
119
|
return json(acquireProviderQuotaLease({
|
|
93
120
|
provider: input.provider,
|
|
@@ -104,7 +131,7 @@ export const postProviderQuotaPublisherLeaseReleaseRoute: Handler = async (req)
|
|
|
104
131
|
const input = cleanPublisherLeaseBody(parsed.body);
|
|
105
132
|
if (typeof input === "string") return error(input);
|
|
106
133
|
if (!input.leaseToken) return error("leaseToken required");
|
|
107
|
-
const denied =
|
|
134
|
+
const denied = authorizeProviderQuotaSourceAgent(req, input.sourceAgentId);
|
|
108
135
|
if (denied) return denied;
|
|
109
136
|
return json({
|
|
110
137
|
released: releaseProviderQuotaLease({
|
|
@@ -33,6 +33,7 @@ import { runnerRuntimeTokenEnv } from "../runtime-tokens";
|
|
|
33
33
|
import { selectSpawnOrchestrator } from "../spawn-targets";
|
|
34
34
|
import { DEFAULT_SPAWN_PROFILE_NAME, getAgentProfile } from "../config-store";
|
|
35
35
|
import { composeSpawnInstructionsWithEvents, type ProjectInstructionPolicy } from "../project-instructions";
|
|
36
|
+
import type { ProjectMcpPolicy } from "../project-mcp";
|
|
36
37
|
import { autoSeedProjectForCwd } from "./project";
|
|
37
38
|
import { hasComponentScope, isComponentAuthorizedFor } from "../security";
|
|
38
39
|
import { isPathWithinBase } from "../utils";
|
|
@@ -74,6 +75,15 @@ export interface SpawnAgentInput {
|
|
|
74
75
|
* `vanilla` (manual+promoted; excludes `ingested` host-CLAUDE.md content).
|
|
75
76
|
*/
|
|
76
77
|
projectInstructions?: ProjectInstructionPolicy;
|
|
78
|
+
/**
|
|
79
|
+
* #667 — project `.mcp.json` MCP projection. When set, the runner reads the spawn cwd's
|
|
80
|
+
* COMMITTED `.mcp.json` and injects those servers via the controlled `--mcp-config`
|
|
81
|
+
* (composing with #662 `--strict-mcp-config`), so a worker spawned into a repo gets that
|
|
82
|
+
* repo's MCP servers — sourced from the project, never ambient host MCP. Additive-from-
|
|
83
|
+
* empty + opt-in: absent (undefined) = OFF → the launch is byte-identical to today. Sibling
|
|
84
|
+
* to projectInstructions; carried to the runner on the in-flight `agentProfile.projectMcp`.
|
|
85
|
+
*/
|
|
86
|
+
projectMcp?: ProjectMcpPolicy;
|
|
77
87
|
/**
|
|
78
88
|
* Legacy report-back contract flag. Spawned children now report up by lineage: their
|
|
79
89
|
* turn-final response is delivered to the spawner automatically, without a reply obligation.
|
|
@@ -170,6 +180,14 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
170
180
|
const agentProfile = withResolvedProvisioning(getAgentProfile(profileName)?.value, input.provider);
|
|
171
181
|
if (!agentProfile) throw new McpNotFoundError(input.profile ? "agent profile not found" : `default agent profile ${DEFAULT_SPAWN_PROFILE_NAME} not found`);
|
|
172
182
|
|
|
183
|
+
// #667 — project `.mcp.json` projection: a per-spawn `projectMcp` overrides the profile
|
|
184
|
+
// default. The runner does the filesystem read + injection from `agentProfile.projectMcp`
|
|
185
|
+
// (it owns the spawn cwd), so thread the resolved decision onto the in-flight profile copy
|
|
186
|
+
// (withResolvedProvisioning returned a fresh object; mutating it never touches the cached
|
|
187
|
+
// config). Like projectInstructions, an explicit `false` parses to undefined and so cannot
|
|
188
|
+
// turn a profile default OFF — opt-in is the trust gate, not opt-out.
|
|
189
|
+
if (input.projectMcp !== undefined) agentProfile.projectMcp = input.projectMcp;
|
|
190
|
+
|
|
173
191
|
// --- THE GATE (uniform across transports) ---------------------------------
|
|
174
192
|
// Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
|
|
175
193
|
// this; the HTTP route did not — asserting here makes it transport-invariant.
|