agent-relay-server 0.20.0 → 0.21.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/package.json +2 -2
- package/public/index.html +471 -46
- package/runner/src/adapter.ts +1 -1
- package/scripts/install-bin-shim.cjs +16 -3
- package/src/agent-ref.ts +217 -0
- package/src/automations.ts +4 -1
- package/src/cli.ts +8 -2
- package/src/config-store.ts +35 -1
- package/src/context-router.ts +7 -7
- package/src/db.ts +111 -29
- package/src/managed-policy.ts +4 -1
- package/src/mcp.ts +208 -67
- package/src/routes.ts +15 -3
- package/src/runtime-tokens.ts +26 -1
- package/src/security.ts +3 -1
package/src/managed-policy.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAgentProfile } from "./config-store";
|
|
1
|
+
import { getAgentProfile, spawnGrantForProfile } from "./config-store";
|
|
2
2
|
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
3
3
|
import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
|
|
4
4
|
import type { SpawnPolicy, WorkspaceMode } from "./types";
|
|
@@ -29,6 +29,7 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
29
29
|
const providerArgs = managedPolicyProviderArgs(policy);
|
|
30
30
|
const prompt = managedPolicyLaunchPrompt(policy);
|
|
31
31
|
const agentProfile = policy.profile ? getAgentProfile(policy.profile)?.value : undefined;
|
|
32
|
+
const grant = spawnGrantForProfile(policy.profile);
|
|
32
33
|
return buildSpawnCommand({
|
|
33
34
|
provider: policy.provider,
|
|
34
35
|
cwd: policy.cwd,
|
|
@@ -55,6 +56,8 @@ export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string,
|
|
|
55
56
|
policyName: policy.name,
|
|
56
57
|
spawnRequestId: requestId,
|
|
57
58
|
createdBy: ctx.createdBy,
|
|
59
|
+
canSpawn: grant.canSpawn,
|
|
60
|
+
maxSpawnedAgents: grant.maxSpawnedAgents,
|
|
58
61
|
}),
|
|
59
62
|
requestedBy: ctx.createdBy,
|
|
60
63
|
requestedAt: ctx.requestedAt ?? Date.now(),
|
package/src/mcp.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { bytesToStream, readBodyBytes } from "./http-body";
|
|
|
9
9
|
import { MAX_BODY_BYTES, VERSION } from "./config";
|
|
10
10
|
import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
|
|
11
11
|
import {
|
|
12
|
+
countLiveSpawnedAgents,
|
|
12
13
|
createArtifact,
|
|
13
14
|
createActivityEvent,
|
|
14
15
|
getAgent,
|
|
@@ -19,12 +20,16 @@ import {
|
|
|
19
20
|
getThread,
|
|
20
21
|
linkArtifact,
|
|
21
22
|
listAgents,
|
|
23
|
+
searchAgents,
|
|
24
|
+
type AgentSearchFilter,
|
|
25
|
+
type AgentSearchSort,
|
|
22
26
|
listArtifactsForEntity,
|
|
23
27
|
listOrchestrators,
|
|
24
28
|
sendMessageWithResult,
|
|
25
29
|
upsertArtifactBlob,
|
|
26
30
|
ValidationError,
|
|
27
31
|
} from "./db";
|
|
32
|
+
import { planSend, type DeliveryReceipt } from "./agent-ref";
|
|
28
33
|
import { emitRelayEvent } from "./events";
|
|
29
34
|
import { emitMessageQueued, emitNewMessage } from "./sse";
|
|
30
35
|
import {
|
|
@@ -38,7 +43,7 @@ import {
|
|
|
38
43
|
import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider } from "./types";
|
|
39
44
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
40
45
|
import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
|
|
41
|
-
import {
|
|
46
|
+
import { runnerRuntimeTokenEnv } from "./runtime-tokens";
|
|
42
47
|
|
|
43
48
|
type JsonRpcId = string | number | null;
|
|
44
49
|
|
|
@@ -75,13 +80,13 @@ const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep
|
|
|
75
80
|
const TOOLS: ToolDefinition[] = [
|
|
76
81
|
{
|
|
77
82
|
name: "relay_send_message",
|
|
78
|
-
description: "Send an Agent Relay message
|
|
83
|
+
description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply. Unknown or ambiguous targets are rejected up front, never silently dropped.",
|
|
79
84
|
requiredScopes: ["messages:write", "message:send"],
|
|
80
85
|
inputSchema: {
|
|
81
86
|
type: "object",
|
|
82
87
|
properties: {
|
|
83
|
-
from: { type: "string", description: "
|
|
84
|
-
to: { type: "string", description: "Target agent id, label, tag:, cap:, policy:, or broadcast." },
|
|
88
|
+
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
89
|
+
to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, policy:, or broadcast." },
|
|
85
90
|
body: { type: "string" },
|
|
86
91
|
subject: { type: "string" },
|
|
87
92
|
channel: { type: "string" },
|
|
@@ -91,18 +96,18 @@ const TOOLS: ToolDefinition[] = [
|
|
|
91
96
|
payload: { type: "object" },
|
|
92
97
|
meta: { type: "object" },
|
|
93
98
|
},
|
|
94
|
-
required: ["
|
|
99
|
+
required: ["to", "body"],
|
|
95
100
|
additionalProperties: false,
|
|
96
101
|
},
|
|
97
102
|
},
|
|
98
103
|
{
|
|
99
104
|
name: "relay_reply",
|
|
100
|
-
description: "Reply to an Agent Relay message by id, preserving reply/thread/channel context.",
|
|
105
|
+
description: "Reply to an Agent Relay message by id, preserving reply/thread/channel context. Returns a delivery receipt; expectReply:false means the original sender is no longer reachable.",
|
|
101
106
|
requiredScopes: ["messages:write", "message:send"],
|
|
102
107
|
inputSchema: {
|
|
103
108
|
type: "object",
|
|
104
109
|
properties: {
|
|
105
|
-
from: { type: "string", description: "
|
|
110
|
+
from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
|
|
106
111
|
messageId: { type: "integer", minimum: 1 },
|
|
107
112
|
body: { type: "string" },
|
|
108
113
|
subject: { type: "string" },
|
|
@@ -111,7 +116,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
111
116
|
payload: { type: "object" },
|
|
112
117
|
meta: { type: "object" },
|
|
113
118
|
},
|
|
114
|
-
required: ["
|
|
119
|
+
required: ["messageId", "body"],
|
|
115
120
|
additionalProperties: false,
|
|
116
121
|
},
|
|
117
122
|
},
|
|
@@ -184,7 +189,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
184
189
|
},
|
|
185
190
|
{
|
|
186
191
|
name: "relay_agent_status",
|
|
187
|
-
description: "Inspect Relay agent, orchestrator, and managed spawn-policy status.",
|
|
192
|
+
description: "Inspect Relay agent, orchestrator, and managed spawn-policy status. The default agent list shows only RUNNING agents (the ones you can actually message/reply/pair with); pass includeOffline:true for the full roster.",
|
|
188
193
|
requiredScopes: ["agents:read", "agent:read"],
|
|
189
194
|
inputSchema: {
|
|
190
195
|
type: "object",
|
|
@@ -192,14 +197,47 @@ const TOOLS: ToolDefinition[] = [
|
|
|
192
197
|
agentId: { type: "string" },
|
|
193
198
|
policyName: { type: "string" },
|
|
194
199
|
orchestratorId: { type: "string" },
|
|
200
|
+
includeOffline: { type: "boolean", description: "Include offline/stale agents in the default list (default false)." },
|
|
195
201
|
},
|
|
196
202
|
additionalProperties: false,
|
|
197
203
|
},
|
|
198
204
|
},
|
|
205
|
+
{
|
|
206
|
+
name: "relay_find_agents",
|
|
207
|
+
description: "Search the fleet for agents by label, capability, provider, tag, machine, status, or kind, sorted by last-active/created/label. Defaults to running agents only. Use this to find a reachable, capable peer before messaging or pairing — instead of dumping the whole roster.",
|
|
208
|
+
requiredScopes: ["agents:read", "agent:read"],
|
|
209
|
+
inputSchema: {
|
|
210
|
+
type: "object",
|
|
211
|
+
properties: {
|
|
212
|
+
label: { type: "string", description: "Substring match on label or name." },
|
|
213
|
+
capability: { type: ["string", "array"], items: { type: "string" }, description: "One or more capabilities (OR within)." },
|
|
214
|
+
provider: { type: ["string", "array"], items: { type: "string" }, description: "Model provider, e.g. claude or codex." },
|
|
215
|
+
tag: { type: ["string", "array"], items: { type: "string" } },
|
|
216
|
+
machine: { type: ["string", "array"], items: { type: "string" } },
|
|
217
|
+
kind: { type: ["string", "array"], items: { type: "string" } },
|
|
218
|
+
status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
|
|
219
|
+
spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
|
|
220
|
+
sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
|
|
221
|
+
order: { type: "string", enum: ["asc", "desc"] },
|
|
222
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
223
|
+
},
|
|
224
|
+
additionalProperties: false,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "relay_whoami",
|
|
229
|
+
description: "Return your own Relay identity and agent card (id, label, status, tags, capabilities). Your identity comes from your auth token — you do NOT need this to send, reply, or pair; use it only when you need to reason about yourself.",
|
|
230
|
+
requiredScopes: ["mcp:use"],
|
|
231
|
+
inputSchema: {
|
|
232
|
+
type: "object",
|
|
233
|
+
properties: {},
|
|
234
|
+
additionalProperties: false,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
199
237
|
{
|
|
200
238
|
name: "relay_spawn_agent",
|
|
201
|
-
description: "
|
|
202
|
-
requiredScopes: ["
|
|
239
|
+
description: "Spawn a long-living provider agent through Relay's orchestrator. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
|
|
240
|
+
requiredScopes: ["command:spawn"],
|
|
203
241
|
inputSchema: {
|
|
204
242
|
type: "object",
|
|
205
243
|
properties: {
|
|
@@ -212,6 +250,7 @@ const TOOLS: ToolDefinition[] = [
|
|
|
212
250
|
approvalMode: { type: "string", enum: APPROVAL_MODES },
|
|
213
251
|
prompt: { type: "string" },
|
|
214
252
|
systemPromptAppend: { type: "string" },
|
|
253
|
+
profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
|
|
215
254
|
tags: { type: "array", items: { type: "string" } },
|
|
216
255
|
capabilities: { type: "array", items: { type: "string" } },
|
|
217
256
|
providerArgs: { type: "array", items: { type: "string" } },
|
|
@@ -224,8 +263,8 @@ const TOOLS: ToolDefinition[] = [
|
|
|
224
263
|
},
|
|
225
264
|
{
|
|
226
265
|
name: "relay_shutdown_agent",
|
|
227
|
-
description: "
|
|
228
|
-
requiredScopes: ["
|
|
266
|
+
description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children. Admin tokens keep full reach.",
|
|
267
|
+
requiredScopes: ["command:shutdown"],
|
|
229
268
|
inputSchema: {
|
|
230
269
|
type: "object",
|
|
231
270
|
properties: {
|
|
@@ -327,6 +366,8 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
327
366
|
else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
|
|
328
367
|
else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
|
|
329
368
|
else if (name === "relay_agent_status") result = relayAgentStatus(args);
|
|
369
|
+
else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
|
|
370
|
+
else if (name === "relay_whoami") result = relayWhoami(auth);
|
|
330
371
|
else if (name === "relay_spawn_agent") result = relaySpawnAgent(auth, args);
|
|
331
372
|
else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
|
|
332
373
|
else throw new ValidationError(`unknown tool: ${name}`);
|
|
@@ -340,12 +381,67 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
|
|
|
340
381
|
}
|
|
341
382
|
}
|
|
342
383
|
|
|
343
|
-
|
|
384
|
+
// A managed/runtime MCP token is minted with `constraints.agents = [its own agent id]`
|
|
385
|
+
// (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
|
|
386
|
+
// security layer already treats it as the only `from` this token may use. Derive it so
|
|
387
|
+
// agents never need to know — or type — their own id.
|
|
388
|
+
function senderIdentity(auth: McpAuthContext): string | undefined {
|
|
389
|
+
if (auth.kind !== "component") return undefined;
|
|
390
|
+
const agents = auth.component?.constraints?.agents;
|
|
391
|
+
return agents?.length === 1 ? agents[0] : undefined;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Caller's own agent id for spawn/shutdown gating (#221). `senderIdentity` covers
|
|
395
|
+
// identity-bearing tokens (interactive/mcp, constraints.agents). Managed agents spawned by
|
|
396
|
+
// the orchestrator authenticate with a runner token that carries no `agents` constraint but
|
|
397
|
+
// DOES carry its spawnRequestId/policy — resolve those back to the registered agent card.
|
|
398
|
+
// Returns undefined for server/admin tokens (unrestricted by design).
|
|
399
|
+
function callerAgentId(auth: McpAuthContext): string | undefined {
|
|
400
|
+
const direct = senderIdentity(auth);
|
|
401
|
+
if (direct) return direct;
|
|
402
|
+
if (auth.kind !== "component") return undefined;
|
|
403
|
+
const c = auth.component?.constraints;
|
|
404
|
+
const spawnRequestId = c?.spawnRequestIds?.length === 1 ? c.spawnRequestIds[0] : undefined;
|
|
405
|
+
const policyName = c?.policies?.length === 1 ? c.policies[0] : undefined;
|
|
406
|
+
if (!spawnRequestId && !policyName) return undefined;
|
|
407
|
+
const match = listAgents().find((a) =>
|
|
408
|
+
(spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
|
|
409
|
+
(policyName !== undefined && a.meta?.policyName === policyName));
|
|
410
|
+
return match?.id;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function resolveSender(auth: McpAuthContext, rawFrom: unknown): string {
|
|
414
|
+
// Token identity wins and cannot be spoofed; any provided `from` is ignored when known.
|
|
415
|
+
const identity = senderIdentity(auth);
|
|
416
|
+
if (identity) return identity;
|
|
417
|
+
// Server/integration/multi-agent tokens carry no single identity — keep requiring `from`.
|
|
418
|
+
return stringField(rawFrom, "from", { required: true, max: 200 });
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
|
|
422
|
+
const agentId = senderIdentity(auth);
|
|
423
|
+
const agent = agentId ? getAgent(agentId) : null;
|
|
424
|
+
return {
|
|
425
|
+
agentId: agentId ?? null,
|
|
426
|
+
actor: auth.actor,
|
|
427
|
+
kind: auth.kind,
|
|
428
|
+
scopes: auth.scopes,
|
|
429
|
+
agent: agent ?? null,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
344
434
|
const attachments = optionalAttachments(args.attachments);
|
|
345
435
|
const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
|
|
436
|
+
const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
|
|
437
|
+
// Resolve the target to a canonical agent id (so poll-time matching works) and refuse
|
|
438
|
+
// up front when it's unknown or ambiguous — never store a message no one will receive.
|
|
439
|
+
const plan = planSend(requestedTo, listAgents());
|
|
440
|
+
if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
|
|
441
|
+
if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
|
|
346
442
|
const input: SendMessageInput = {
|
|
347
|
-
from:
|
|
348
|
-
to:
|
|
443
|
+
from: resolveSender(auth, args.from),
|
|
444
|
+
to: plan.to,
|
|
349
445
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
350
446
|
subject: optionalString(args.subject, "subject", 200),
|
|
351
447
|
channel: optionalString(args.channel, "channel", 120),
|
|
@@ -359,10 +455,10 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
|
|
|
359
455
|
assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
|
|
360
456
|
const result = sendMessageWithResult(input);
|
|
361
457
|
emitMessage(result.message, result.created);
|
|
362
|
-
return result.message;
|
|
458
|
+
return { ...result.message, delivery: plan.receipt };
|
|
363
459
|
}
|
|
364
460
|
|
|
365
|
-
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message {
|
|
461
|
+
function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
|
|
366
462
|
const messageId = positiveId(args.messageId, "messageId");
|
|
367
463
|
const parent = getMessage(messageId);
|
|
368
464
|
if (!parent) throw new McpNotFoundError(`message ${messageId} not found`);
|
|
@@ -375,7 +471,7 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
|
|
|
375
471
|
...replyContext(parent),
|
|
376
472
|
}, attachments);
|
|
377
473
|
const input: SendMessageInput = {
|
|
378
|
-
from:
|
|
474
|
+
from: resolveSender(auth, args.from),
|
|
379
475
|
to: parent.from,
|
|
380
476
|
body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
|
|
381
477
|
subject: optionalString(args.subject, "subject", 200),
|
|
@@ -389,7 +485,13 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
|
|
|
389
485
|
assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
|
|
390
486
|
const result = sendMessageWithResult(input);
|
|
391
487
|
emitMessage(result.message, result.created);
|
|
392
|
-
|
|
488
|
+
// Reply routing is fixed to the parent's sender — never reject, but report whether
|
|
489
|
+
// that original sender is still reachable so the agent doesn't wait forever.
|
|
490
|
+
const plan = planSend(input.to, listAgents());
|
|
491
|
+
const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
|
|
492
|
+
? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
|
|
493
|
+
: plan.receipt;
|
|
494
|
+
return { ...result.message, delivery };
|
|
393
495
|
}
|
|
394
496
|
|
|
395
497
|
function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
|
|
@@ -490,13 +592,49 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
|
|
|
490
592
|
if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
|
|
491
593
|
return { orchestrator };
|
|
492
594
|
}
|
|
595
|
+
// Default to running agents only — offline/stale rows are pure noise (you can only
|
|
596
|
+
// message/reply/pair with a live agent) and bloat the payload. includeOffline opts back in.
|
|
597
|
+
const includeOffline = optionalBoolean(args.includeOffline, "includeOffline") === true;
|
|
493
598
|
return {
|
|
494
|
-
agents:
|
|
599
|
+
agents: searchAgents({ status: includeOffline ? "all" : "running" }),
|
|
495
600
|
spawnPolicies: listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)),
|
|
496
601
|
orchestrators: listOrchestrators(),
|
|
497
602
|
};
|
|
498
603
|
}
|
|
499
604
|
|
|
605
|
+
function optionalStringOrArray(value: unknown, field: string): string | string[] | undefined {
|
|
606
|
+
if (value === undefined || value === null) return undefined;
|
|
607
|
+
if (typeof value === "string") return value;
|
|
608
|
+
if (Array.isArray(value)) {
|
|
609
|
+
return value.map((item, i) => stringField(item, `${field}[${i}]`, { required: true, max: 200 }));
|
|
610
|
+
}
|
|
611
|
+
throw new ValidationError(`${field} must be a string or string array`);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
615
|
+
// `spawnedBy: "me"` resolves to the caller's own id — list the children you spawned (#221).
|
|
616
|
+
const spawnedByArg = optionalString(args.spawnedBy, "spawnedBy", 240);
|
|
617
|
+
const spawnedBy = spawnedByArg === "me" ? callerAgentId(auth) ?? "\0unresolved" : spawnedByArg;
|
|
618
|
+
const filter: AgentSearchFilter = {
|
|
619
|
+
label: optionalString(args.label, "label", 200),
|
|
620
|
+
capability: optionalStringOrArray(args.capability, "capability"),
|
|
621
|
+
provider: optionalStringOrArray(args.provider, "provider"),
|
|
622
|
+
tag: optionalStringOrArray(args.tag, "tag"),
|
|
623
|
+
machine: optionalStringOrArray(args.machine, "machine"),
|
|
624
|
+
kind: optionalStringOrArray(args.kind, "kind"),
|
|
625
|
+
spawnedBy,
|
|
626
|
+
// Default to running — you can only act on a live agent (consistent with #218).
|
|
627
|
+
status: optionalStringOrArray(args.status, "status") ?? "running",
|
|
628
|
+
};
|
|
629
|
+
const sort: AgentSearchSort = {
|
|
630
|
+
sortBy: optionalEnum(args.sortBy, "sortBy", ["lastActive", "created", "label"] as const),
|
|
631
|
+
order: optionalEnum(args.order, "order", ["asc", "desc"] as const),
|
|
632
|
+
limit: optionalPositiveInt(args.limit, "limit"),
|
|
633
|
+
};
|
|
634
|
+
const agents = searchAgents(filter, sort);
|
|
635
|
+
return { agents, count: agents.length };
|
|
636
|
+
}
|
|
637
|
+
|
|
500
638
|
function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
501
639
|
const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
|
|
502
640
|
const cwd = optionalString(args.cwd, "cwd", 500);
|
|
@@ -510,28 +648,43 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
510
648
|
const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
|
|
511
649
|
const label = optionalString(args.label, "label", 120);
|
|
512
650
|
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
651
|
+
const profile = optionalString(args.profile, "profile", 120);
|
|
652
|
+
|
|
653
|
+
// #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
|
|
654
|
+
// granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
|
|
655
|
+
// Server/admin tokens have no caller identity → unrestricted by design.
|
|
656
|
+
const callerId = callerAgentId(auth);
|
|
657
|
+
if (callerId) {
|
|
658
|
+
const me = getAgent(callerId);
|
|
659
|
+
if (me?.spawnedBy) {
|
|
660
|
+
throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
|
|
661
|
+
}
|
|
662
|
+
const quota = auth.component?.constraints?.maxSpawnedAgents ?? 0;
|
|
663
|
+
const live = countLiveSpawnedAgents(callerId);
|
|
664
|
+
if (live >= quota) {
|
|
665
|
+
throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
513
669
|
assertComponentResourceAllowed(auth, {
|
|
514
670
|
scope: "agent:write",
|
|
515
671
|
resource: { orchestratorId: orchestrator.id, cwd: resolvedCwd, policyName, spawnRequestId },
|
|
516
672
|
});
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
spawnRequestId,
|
|
533
|
-
createdBy: auth.actor,
|
|
534
|
-
});
|
|
673
|
+
|
|
674
|
+
// Child runner token: a normal long-living agent that is NOT itself spawn-capable
|
|
675
|
+
// (canSpawn:false → no grandchildren), stamped with authoritative lineage so it registers
|
|
676
|
+
// with spawnedBy = caller (the child can't forge it; it's read from the signed token).
|
|
677
|
+
const env = runnerRuntimeTokenEnv({
|
|
678
|
+
orchestratorId: orchestrator.id,
|
|
679
|
+
cwd: resolvedCwd,
|
|
680
|
+
provider,
|
|
681
|
+
label,
|
|
682
|
+
policyName,
|
|
683
|
+
spawnRequestId,
|
|
684
|
+
createdBy: callerId ?? auth.actor,
|
|
685
|
+
canSpawn: false,
|
|
686
|
+
...(callerId ? { spawnedBy: callerId } : {}),
|
|
687
|
+
});
|
|
535
688
|
const command = createCommand({
|
|
536
689
|
type: "agent.spawn",
|
|
537
690
|
source: "system",
|
|
@@ -542,6 +695,7 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
542
695
|
modelParams: selection,
|
|
543
696
|
cwd: resolvedCwd,
|
|
544
697
|
label,
|
|
698
|
+
profile: profile || undefined,
|
|
545
699
|
tags: optionalStringArray(args.tags, "tags") ?? [],
|
|
546
700
|
capabilities: optionalStringArray(args.capabilities, "capabilities") ?? [],
|
|
547
701
|
approvalMode,
|
|
@@ -562,34 +716,6 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
|
|
|
562
716
|
return { ok: true, orchestratorId: orchestrator.id, provider, command };
|
|
563
717
|
}
|
|
564
718
|
|
|
565
|
-
function childRunnerEnvForDelegatingComponent(
|
|
566
|
-
auth: McpAuthContext,
|
|
567
|
-
input: {
|
|
568
|
-
orchestratorId: string;
|
|
569
|
-
cwd: string;
|
|
570
|
-
provider: SpawnProvider;
|
|
571
|
-
label?: string;
|
|
572
|
-
policyName?: string;
|
|
573
|
-
spawnRequestId: string;
|
|
574
|
-
},
|
|
575
|
-
): Record<string, string> {
|
|
576
|
-
const component = auth.component;
|
|
577
|
-
if (!component) throw new McpAuthError("component token required for child-agent delegation");
|
|
578
|
-
if (component.constraints?.canDelegate !== true) {
|
|
579
|
-
throw new McpAuthError("component token cannot delegate child agents");
|
|
580
|
-
}
|
|
581
|
-
return childRunnerRuntimeTokenEnv({
|
|
582
|
-
parentAgentId: component.sub,
|
|
583
|
-
orchestratorId: input.orchestratorId,
|
|
584
|
-
cwd: input.cwd,
|
|
585
|
-
provider: input.provider,
|
|
586
|
-
label: input.label,
|
|
587
|
-
policyName: input.policyName,
|
|
588
|
-
spawnRequestId: input.spawnRequestId,
|
|
589
|
-
createdBy: component.sub,
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
|
|
593
719
|
function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
|
|
594
720
|
const agentId = optionalString(args.agentId, "agentId", 240);
|
|
595
721
|
const policyName = optionalString(args.policyName, "policyName", 120);
|
|
@@ -598,6 +724,21 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
|
|
|
598
724
|
if (!agentId && !policyName && !spawnRequestId && !tmuxSession) {
|
|
599
725
|
throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
|
|
600
726
|
}
|
|
727
|
+
|
|
728
|
+
// #221: an agent caller may only shut down its OWN live spawned children, addressed by
|
|
729
|
+
// agentId. Broad targeting (policy/spawnRequestId/tmux) and cross-agent kills stay admin-only
|
|
730
|
+
// — otherwise spawn permission silently becomes kill-anyone permission. Server/admin tokens
|
|
731
|
+
// (no caller identity) keep full reach.
|
|
732
|
+
const callerId = callerAgentId(auth);
|
|
733
|
+
if (callerId) {
|
|
734
|
+
if (!agentId || policyName || spawnRequestId || tmuxSession) {
|
|
735
|
+
throw new McpAuthError("agents may only shut down their own spawned children, addressed by agentId");
|
|
736
|
+
}
|
|
737
|
+
const target = getAgent(agentId);
|
|
738
|
+
if (!target || target.spawnedBy !== callerId) {
|
|
739
|
+
throw new McpAuthError(`agent ${agentId} is not one of your spawned children`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
601
742
|
const orchestrator = selectControlOrchestrator({
|
|
602
743
|
orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
|
|
603
744
|
agentId,
|
package/src/routes.ts
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
getAgentTimeline,
|
|
6
6
|
listAgents,
|
|
7
7
|
listPendingReplyObligations,
|
|
8
|
-
|
|
8
|
+
searchAgents,
|
|
9
9
|
setStatus,
|
|
10
10
|
setLabel,
|
|
11
11
|
setTags,
|
|
@@ -124,6 +124,7 @@ import {
|
|
|
124
124
|
setAgentProfile,
|
|
125
125
|
setConfig,
|
|
126
126
|
setStewardConfig,
|
|
127
|
+
spawnGrantForProfile,
|
|
127
128
|
upsertManagedAgentState,
|
|
128
129
|
updateManagedAgentState,
|
|
129
130
|
} from "./config-store";
|
|
@@ -1240,6 +1241,12 @@ const postAgent: Handler = async (req) => {
|
|
|
1240
1241
|
if (!parsed.ok) return error(parsed.error, parsed.status);
|
|
1241
1242
|
try {
|
|
1242
1243
|
const input = normalizeAgentInput(parsed.body);
|
|
1244
|
+
// Lineage is authoritative from the registering token's signed constraints — never the
|
|
1245
|
+
// client-sent body (a child can't forge its own parent). Set by relay at spawn for
|
|
1246
|
+
// agent-initiated spawns (`spawnedBy`) and the delegating-component path (`parentAgents`).
|
|
1247
|
+
const constraints = getComponentAuth(req)?.constraints;
|
|
1248
|
+
const lineage = constraints?.spawnedBy ?? constraints?.parentAgents?.[0];
|
|
1249
|
+
if (lineage) input.spawnedBy = lineage;
|
|
1243
1250
|
const existing = getAgent(input.id);
|
|
1244
1251
|
const agent = upsertAgent(input);
|
|
1245
1252
|
const policyName = metaString(agent.meta, "policyName");
|
|
@@ -1305,8 +1312,8 @@ const findAgents: Handler = (req) => {
|
|
|
1305
1312
|
const url = new URL(req.url);
|
|
1306
1313
|
const capability = url.searchParams.get("capability");
|
|
1307
1314
|
if (!capability) return error("capability query param required");
|
|
1308
|
-
const
|
|
1309
|
-
return json(
|
|
1315
|
+
const includeAll = url.searchParams.get("all") === "true";
|
|
1316
|
+
return json(searchAgents({ capability, status: includeAll ? "all" : "running" }));
|
|
1310
1317
|
};
|
|
1311
1318
|
|
|
1312
1319
|
const postRouteAdvice: Handler = async (req) => {
|
|
@@ -2538,6 +2545,8 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2538
2545
|
const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
|
|
2539
2546
|
const label = cleanString(parsed.body.label, "label", { max: 120 });
|
|
2540
2547
|
const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
|
|
2548
|
+
const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
|
|
2549
|
+
const grant = spawnGrantForProfile(profile);
|
|
2541
2550
|
|
|
2542
2551
|
const orchestrators = listOrchestrators().filter(
|
|
2543
2552
|
(o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
|
|
@@ -2565,6 +2574,7 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2565
2574
|
workspaceMode,
|
|
2566
2575
|
label,
|
|
2567
2576
|
approvalMode,
|
|
2577
|
+
profile: profile || undefined,
|
|
2568
2578
|
spawnRequestId: requestId,
|
|
2569
2579
|
requestedBy: "dashboard",
|
|
2570
2580
|
requestedAt: Date.now(),
|
|
@@ -2575,6 +2585,8 @@ const postAgentSpawn: Handler = async (req) => {
|
|
|
2575
2585
|
label,
|
|
2576
2586
|
spawnRequestId: requestId,
|
|
2577
2587
|
createdBy: "dashboard",
|
|
2588
|
+
canSpawn: grant.canSpawn,
|
|
2589
|
+
maxSpawnedAgents: grant.maxSpawnedAgents,
|
|
2578
2590
|
}),
|
|
2579
2591
|
}),
|
|
2580
2592
|
});
|
package/src/runtime-tokens.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import { createToken, revokeToken } from "./token-db";
|
|
1
|
+
import { createToken, getTokenProfile, revokeToken } from "./token-db";
|
|
2
2
|
import { verifyComponentTokenAllowExpired } from "./security";
|
|
3
3
|
import type { TokenRecord } from "./types";
|
|
4
4
|
|
|
5
|
+
// Scopes that turn an agent's runtime token into a spawn-capable one (#221). Appended to the
|
|
6
|
+
// provider-agent base scope only when the resolved profile permits (maxSpawnedAgents > 0).
|
|
7
|
+
// Children never receive these (no grandchildren), so a child's token can never gate the
|
|
8
|
+
// spawn/shutdown MCP tools open. Kept distinct from `agent:write` (self-management) on
|
|
9
|
+
// purpose — that scope must stay for heartbeat/status without implying spawn rights.
|
|
10
|
+
const SPAWN_SCOPES = ["command:spawn", "command:shutdown"] as const;
|
|
11
|
+
|
|
12
|
+
function runnerScopeWithSpawn(canSpawn: boolean): string[] | undefined {
|
|
13
|
+
if (!canSpawn) return undefined; // undefined → createToken falls back to the profile's scope
|
|
14
|
+
const base = getTokenProfile("provider-agent")?.scope ?? [];
|
|
15
|
+
return [...base, ...SPAWN_SCOPES];
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
interface RuntimeTokenResult {
|
|
6
19
|
token: string;
|
|
7
20
|
record: TokenRecord;
|
|
@@ -76,6 +89,12 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
76
89
|
policyName?: string;
|
|
77
90
|
spawnRequestId?: string;
|
|
78
91
|
createdBy?: string;
|
|
92
|
+
/** Grant the spawn/shutdown scopes (resolved from the agent's profile maxSpawnedAgents>0). */
|
|
93
|
+
canSpawn?: boolean;
|
|
94
|
+
/** Live-children quota baked into the token for the runtime spawn check. */
|
|
95
|
+
maxSpawnedAgents?: number;
|
|
96
|
+
/** Parent agent id — stamped so the child registers with an authoritative `spawnedBy`. */
|
|
97
|
+
spawnedBy?: string;
|
|
79
98
|
}): RuntimeTokenResult {
|
|
80
99
|
const subject = input.policyName
|
|
81
100
|
? `runner:policy:${input.policyName}`
|
|
@@ -86,11 +105,14 @@ export function issueRunnerRuntimeToken(input: {
|
|
|
86
105
|
profileId: "provider-agent",
|
|
87
106
|
sub: subject,
|
|
88
107
|
role: "provider",
|
|
108
|
+
scope: runnerScopeWithSpawn(input.canSpawn ?? false),
|
|
89
109
|
constraints: {
|
|
90
110
|
orchestrators: [input.orchestratorId],
|
|
91
111
|
cwdPrefixes: [input.cwd],
|
|
92
112
|
...(input.policyName ? { policies: [input.policyName] } : {}),
|
|
93
113
|
...(input.spawnRequestId ? { spawnRequestIds: [input.spawnRequestId] } : {}),
|
|
114
|
+
...(input.canSpawn && input.maxSpawnedAgents ? { maxSpawnedAgents: input.maxSpawnedAgents } : {}),
|
|
115
|
+
...(input.spawnedBy ? { spawnedBy: input.spawnedBy } : {}),
|
|
94
116
|
},
|
|
95
117
|
createdBy: input.createdBy ?? "runtime",
|
|
96
118
|
});
|
|
@@ -204,6 +226,9 @@ export function runnerRuntimeTokenEnv(input: {
|
|
|
204
226
|
policyName?: string;
|
|
205
227
|
spawnRequestId?: string;
|
|
206
228
|
createdBy?: string;
|
|
229
|
+
canSpawn?: boolean;
|
|
230
|
+
maxSpawnedAgents?: number;
|
|
231
|
+
spawnedBy?: string;
|
|
207
232
|
}): Record<string, string> {
|
|
208
233
|
const issued = issueRunnerRuntimeToken(input);
|
|
209
234
|
return {
|
package/src/security.ts
CHANGED
|
@@ -438,8 +438,10 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
|
|
|
438
438
|
for (const [key, item] of Object.entries(record)) {
|
|
439
439
|
if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
|
|
440
440
|
if (typeof item !== "boolean") return false;
|
|
441
|
-
} else if (key === "cwd") {
|
|
441
|
+
} else if (key === "cwd" || key === "spawnedBy") {
|
|
442
442
|
if (typeof item !== "string") return false;
|
|
443
|
+
} else if (key === "maxSpawnedAgents") {
|
|
444
|
+
if (typeof item !== "number") return false;
|
|
443
445
|
} else if (!Array.isArray(item) || !item.every((entry) => typeof entry === "string")) {
|
|
444
446
|
return false;
|
|
445
447
|
}
|