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.
@@ -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 { childRunnerRuntimeTokenEnv, runnerRuntimeTokenEnv } from "./runtime-tokens";
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 with structured fields instead of shell quoting.",
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: "Sender Relay agent id." },
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: ["from", "to", "body"],
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: "Sender Relay agent id." },
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: ["from", "messageId", "body"],
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: "Request a provider agent spawn through Relay's orchestrator command bus.",
202
- requiredScopes: ["agents:write", "agent:write"],
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: "Request an agent shutdown through Relay's orchestrator command bus.",
228
- requiredScopes: ["agents:write", "agent:write"],
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
- function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message {
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: stringField(args.from, "from", { required: true, max: 200 }),
348
- to: stringField(args.to, "to", { required: true, max: 200 }),
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: stringField(args.from, "from", { required: true, max: 200 }),
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
- return result.message;
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: listAgents(),
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
- const env = auth.component?.role === "provider"
518
- ? childRunnerEnvForDelegatingComponent(auth, {
519
- orchestratorId: orchestrator.id,
520
- cwd: resolvedCwd,
521
- provider,
522
- label,
523
- policyName,
524
- spawnRequestId,
525
- })
526
- : runnerRuntimeTokenEnv({
527
- orchestratorId: orchestrator.id,
528
- cwd: resolvedCwd,
529
- provider,
530
- label,
531
- policyName,
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
- findAgentsByCapability,
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 onlineOnly = url.searchParams.get("all") !== "true";
1309
- return json(findAgentsByCapability(capability, onlineOnly));
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
  });
@@ -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
  }